diff options
Diffstat (limited to 'java')
376 files changed, 15611 insertions, 16115 deletions
diff --git a/java/res/drawable/checkbox.xml b/java/res/drawable/checkbox.xml new file mode 100644 index 00000000..189d01ff --- /dev/null +++ b/java/res/drawable/checkbox.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="20dp" + android:height="20dp" + android:viewportWidth="20" + android:viewportHeight="20"> + <path + android:pathData="M10,0C4.48,0 0,4.48 0,10C0,15.52 4.48,20 10,20C15.52,20 20,15.52 20,10C20,4.48 15.52,0 10,0ZM10,18C5.59,18 2,14.41 2,10C2,5.59 5.59,2 10,2C14.41,2 18,5.59 18,10C18,14.41 14.41,18 10,18ZM5.4,9.6L8,12.2L14.6,5.6L16,7L8,15L4,11L5.4,9.6Z" + android:fillColor="#ffffff" + android:fillType="evenOdd"/> +</vector> diff --git a/java/res/drawable/chooser_direct_share_label_placeholder.xml b/java/res/drawable/chooser_direct_share_label_placeholder.xml deleted file mode 100644 index b21444bf..00000000 --- a/java/res/drawable/chooser_direct_share_label_placeholder.xml +++ /dev/null @@ -1,37 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ Copyright (C) 2019 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ http://www.apache.org/licenses/LICENSE-2.0 - ~ - ~ Unless required by applicable law or agreed to in writing, software - ~ distributed under the License is distributed on an "AS IS" BASIS, - ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - ~ See the License for the specific language governing permissions and - ~ limitations under the License - --> -<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> - - <!-- This drawable is intended to be used as the background of a two line TextView. We only - want the height to be ~1 line. Do this cheaply by applying padding to the bottom. --> - <item android:bottom="18dp"> - <shape android:shape="rectangle" > - - <!-- Size used for scaling should the container be different dimensions --> - <size android:width="@dimen/chooser_direct_share_label_placeholder_max_width" - android:height="18dp"/> - - <!-- Absurd corner radius to ensure pill shape --> - <corners android:bottomLeftRadius="100dp" - android:bottomRightRadius="100dp" - android:topLeftRadius="100dp" - android:topRightRadius="100dp" /> - - <solid android:color="@color/chooser_gradient_background "/> - </shape> - </item> -</layer-list>
\ No newline at end of file diff --git a/java/res/drawable/ic_file_video.xml b/java/res/drawable/ic_file_video.xml index ec6e290b..3156c55a 100644 --- a/java/res/drawable/ic_file_video.xml +++ b/java/res/drawable/ic_file_video.xml @@ -23,5 +23,6 @@ <path android:fillColor="@android:color/white" - android:pathData="m4,20c-0.55,0 -1.02,-0.19 -1.42,-0.57c-0.39,-0.4 -0.58,-0.88 -0.58,-1.43l0,-12c0,-0.55 0.19,-1.02 0.58,-1.4c0.39,-0.4 0.87,-0.6 1.42,-0.6l12,0c0.55,0 1.02,0.2 1.4,0.6c0.4,0.38 0.6,0.85 0.6,1.4l0,4.5l4,-4l0,11l-4,-4l0,4.5c0,0.55 -0.2,1.03 -0.6,1.43c-0.38,0.38 -0.85,0.57 -1.4,0.57l-12,0zm0,-2l12,0l0,-12l-12,0l0,12zm0,0l0,-12l0,12z"/> + android:pathData="M2,12C2,6.48 6.48,2 12,2C17.52,2 22,6.48 22,12C22,17.52 17.52,22 12,22C6.48,22 2,17.52 2,12ZM16,12L10,7.5V16.5L16,12Z" + android:fillType="evenOdd"/> </vector> diff --git a/java/res/drawable/ic_play_circle_filled_24px.xml b/java/res/drawable/ic_play_circle_filled_24px.xml new file mode 100644 index 00000000..f67127ca --- /dev/null +++ b/java/res/drawable/ic_play_circle_filled_24px.xml @@ -0,0 +1,3 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="20dp" android:viewportHeight="20" android:viewportWidth="20" android:width="20dp"> + <path android:fillColor="#ffffff" android:fillType="evenOdd" android:pathData="M0,10C0,4.48 4.48,0 10,0C15.52,0 20,4.48 20,10C20,15.52 15.52,20 10,20C4.48,20 0,15.52 0,10ZM14,10L8,5.5V14.5L14,10Z"/> +</vector> diff --git a/java/res/drawable/resolver_profile_tab_bg.xml b/java/res/drawable/resolver_profile_tab_bg.xml index 8bb23a53..97f3b7e2 100644 --- a/java/res/drawable/resolver_profile_tab_bg.xml +++ b/java/res/drawable/resolver_profile_tab_bg.xml @@ -25,7 +25,7 @@ </item> <item> - <selector android:enterFadeDuration="100"> + <selector> <item android:state_selected="false"> <shape android:shape="rectangle"> <corners android:radius="12dp" /> diff --git a/java/res/layout-h480dp/image_preview_loading_item.xml b/java/res/layout-h480dp/image_preview_loading_item.xml new file mode 100644 index 00000000..85020e9a --- /dev/null +++ b/java/res/layout-h480dp/image_preview_loading_item.xml @@ -0,0 +1,32 @@ +<!-- + ~ 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. + --> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="@dimen/chooser_preview_image_width" + android:layout_height="@dimen/chooser_preview_image_height_tall"> + + <ProgressBar + android:id="@+id/loading_indicator" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:indeterminate="true" + android:indeterminateTint="?androidprv:attr/materialColorPrimary" + android:indeterminateTintMode="src_in" /> + +</FrameLayout> diff --git a/java/res/layout-h480dp/image_preview_other_item.xml b/java/res/layout-h480dp/image_preview_other_item.xml new file mode 100644 index 00000000..470f105a --- /dev/null +++ b/java/res/layout-h480dp/image_preview_other_item.xml @@ -0,0 +1,31 @@ +<!-- + ~ Copyright (C) 2023 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="@dimen/chooser_preview_image_width" + android:layout_height="@dimen/chooser_preview_image_height_tall"> + + <TextView + android:id="@+id/label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:drawableTop="@drawable/ic_file_copy" + android:drawablePadding="8dp" + android:textAppearance="@style/TextAppearance.ChooserDefault" /> + +</FrameLayout>
\ No newline at end of file diff --git a/java/res/layout/chooser_action_row.xml b/java/res/layout/chooser_action_row.xml index 55d6adf7..7bce113e 100644 --- a/java/res/layout/chooser_action_row.xml +++ b/java/res/layout/chooser_action_row.xml @@ -20,10 +20,9 @@ <com.android.intentresolver.widget.ScrollableActionRow android:id="@androidprv:id/chooser_action_row" - android:layout_width="match_parent" + android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="center_horizontal" - android:gravity="center"/> + android:layout_gravity="center_horizontal" /> <View android:layout_width="match_parent" diff --git a/java/res/layout/chooser_action_view.xml b/java/res/layout/chooser_action_view.xml index ba9134cc..d045a7e3 100644 --- a/java/res/layout/chooser_action_view.xml +++ b/java/res/layout/chooser_action_view.xml @@ -18,7 +18,7 @@ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" style="?android:attr/borderlessButtonStyle" android:background="@drawable/chooser_action_button_bg" - android:paddingBottom="8dp" + android:paddingVertical="15dp" android:paddingHorizontal="@dimen/chooser_edge_margin_normal_half" android:clickable="true" android:drawablePadding="6dp" @@ -27,6 +27,5 @@ android:ellipsize="end" android:gravity="center" android:maxLines="1" - android:maxWidth="@dimen/chooser_action_max_width" android:textColor="?androidprv:attr/materialColorOnSurface" android:textSize="12sp" /> diff --git a/java/res/layout/chooser_grid_item.xml b/java/res/layout/chooser_grid_item.xml new file mode 100644 index 00000000..18abc7bc --- /dev/null +++ b/java/res/layout/chooser_grid_item.xml @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 2006, The Android Open Source Project +** +** Licensed under the Apache License, Version 2.0 (the "License"); +** you may not use this file except in compliance with the License. +** You may obtain a copy of the License at +** +** http://www.apache.org/licenses/LICENSE-2.0 +** +** Unless required by applicable law or agreed to in writing, software +** distributed under the License is distributed on an "AS IS" BASIS, +** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +** See the License for the specific language governing permissions and +** limitations under the License. +*/ +--> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:id="@androidprv:id/item" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minHeight="100dp" + android:gravity="top|center_horizontal" + android:paddingVertical="@dimen/grid_padding" + android:paddingHorizontal="4dp" + android:focusable="true" + android:background="?android:attr/selectableItemBackgroundBorderless"> + + <ImageView android:id="@android:id/icon" + android:layout_width="@dimen/chooser_icon_size" + android:layout_height="@dimen/chooser_icon_size" + android:layout_marginHorizontal="8dp" + android:scaleType="fitCenter" /> + + <!-- Size manually tuned to match specs --> + <Space android:layout_width="1dp" + android:layout_height="7dp"/> + + <!-- NOTE: for id/text1 and id/text2 below set the width to match parent as a workaround for + b/269395540 i.e. prevent views bounds change during a transition animation. It does not + affect pinned views as we change their layout parameters programmatically (but that's even + more narrow possibility and it's not clear if the root cause or the bug would affect it). + --> + <!-- App name or Direct Share target name, DS set to 2 lines --> + <com.android.intentresolver.widget.BadgeTextView + android:id="@android:id/text1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textColor="?androidprv:attr/materialColorOnSurface" + android:textSize="12sp" + android:maxLines="1" + android:ellipsize="end" /> + + <!-- Activity name if set, gone for Direct Share targets --> + <TextView android:id="@android:id/text2" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textSize="12sp" + android:textColor="?androidprv:attr/materialColorOnSurfaceVariant" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:lines="1" + android:gravity="top|center_horizontal" + android:ellipsize="end"/> + +</LinearLayout> + diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml index 3c836b4c..4e8cf7ba 100644 --- a/java/res/layout/chooser_grid_preview_file.xml +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -26,8 +26,6 @@ android:orientation="vertical" android:background="?androidprv:attr/materialColorSurfaceContainer"> - <include layout="@layout/chooser_headline_row"/> - <RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content" diff --git a/java/res/layout/chooser_grid_preview_files_text.xml b/java/res/layout/chooser_grid_preview_files_text.xml index c64d7ddd..65c62f82 100644 --- a/java/res/layout/chooser_grid_preview_files_text.xml +++ b/java/res/layout/chooser_grid_preview_files_text.xml @@ -25,8 +25,6 @@ android:orientation="vertical" android:background="?androidprv:attr/materialColorSurfaceContainer"> - <include layout="@layout/chooser_headline_row" /> - <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" @@ -55,6 +53,7 @@ android:maxLines="@integer/text_preview_lines" android:ellipsize="end" android:linksClickable="false" + android:textColor="?androidprv:attr/materialColorOnSurfaceVariant" android:textAppearance="@style/TextAppearance.ChooserDefault"/> </LinearLayout> diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml index 4a832324..4745e04c 100644 --- a/java/res/layout/chooser_grid_preview_image.xml +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -26,7 +26,13 @@ android:importantForAccessibility="no" android:background="?androidprv:attr/materialColorSurfaceContainer"> - <include layout="@layout/chooser_headline_row"/> + <ViewStub + android:id="@+id/chooser_headline_row_stub" + android:layout="@layout/chooser_headline_row" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="@dimen/chooser_edge_margin_normal" + android:layout_marginBottom="@dimen/chooser_view_spacing" /> <com.android.intentresolver.widget.ScrollableImagePreviewView android:id="@+id/scrollable_image_preview" diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml index df906cce..ee54c0ae 100644 --- a/java/res/layout/chooser_grid_preview_text.xml +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -27,8 +27,6 @@ android:orientation="vertical" android:background="?androidprv:attr/materialColorSurfaceContainer"> - <include layout="@layout/chooser_headline_row" /> - <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" @@ -116,4 +114,3 @@ <include layout="@layout/chooser_action_row" /> </LinearLayout> - diff --git a/java/res/layout/chooser_grid.xml b/java/res/layout/chooser_grid_scrollable_preview.xml index 8320b284..c1bcf912 100644 --- a/java/res/layout/chooser_grid.xml +++ b/java/res/layout/chooser_grid_scrollable_preview.xml @@ -25,6 +25,7 @@ android:layout_gravity="center" app:maxCollapsedHeight="0dp" app:maxCollapsedHeightSmall="56dp" + app:useScrollablePreviewNestedFlingLogic="true" android:maxWidth="@dimen/chooser_width" android:id="@androidprv:id/contentPanel"> @@ -60,38 +61,67 @@ </RelativeLayout> <FrameLayout - android:id="@androidprv:id/content_preview_container" + android:id="@+id/chooser_headline_row_container" android:layout_width="match_parent" android:layout_height="wrap_content" - android:visibility="gone" /> + app:layout_alwaysShow="true" + android:background="?androidprv:attr/materialColorSurfaceContainer"> + + <ViewStub + android:id="@+id/chooser_headline_row_stub" + android:inflatedId="@+id/chooser_headline_row" + android:layout="@layout/chooser_headline_row" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="@dimen/chooser_edge_margin_normal" + android:layout_marginBottom="@dimen/chooser_view_spacing" /> + </FrameLayout> - <TabHost - android:id="@androidprv:id/profile_tabhost" + <com.android.intentresolver.widget.ChooserNestedScrollView android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_alignParentTop="true" - android:layout_centerHorizontal="true" - android:background="?androidprv:attr/materialColorSurfaceContainer"> + android:layout_height="wrap_content"> + <LinearLayout - android:orientation="vertical" android:layout_width="match_parent" - android:layout_height="wrap_content"> - <TabWidget - android:id="@android:id/tabs" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <FrameLayout + android:id="@androidprv:id/content_preview_container" android:layout_width="match_parent" android:layout_height="wrap_content" - android:visibility="gone"> - </TabWidget> - <FrameLayout - android:id="@android:id/tabcontent" + android:visibility="gone" /> + + <TabHost + android:id="@androidprv:id/profile_tabhost" android:layout_width="match_parent" - android:layout_height="wrap_content"> - <com.android.intentresolver.ResolverViewPager - android:id="@androidprv:id/profile_pager" + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:layout_centerHorizontal="true" + android:background="?androidprv:attr/materialColorSurfaceContainer"> + <LinearLayout + android:orientation="vertical" android:layout_width="match_parent" - android:layout_height="wrap_content"/> - </FrameLayout> - </LinearLayout> - </TabHost> + android:layout_height="wrap_content"> + <TabWidget + android:id="@android:id/tabs" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone"> + </TabWidget> + <FrameLayout + android:id="@android:id/tabcontent" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <com.android.intentresolver.ResolverViewPager + android:id="@androidprv:id/profile_pager" + android:layout_width="match_parent" + android:layout_height="wrap_content"/> + </FrameLayout> + </LinearLayout> + </TabHost> + </LinearLayout> + + </com.android.intentresolver.widget.ChooserNestedScrollView> </com.android.intentresolver.widget.ResolverDrawerLayout> diff --git a/java/res/layout/chooser_headline_row.xml b/java/res/layout/chooser_headline_row.xml index 62781847..bfce7473 100644 --- a/java/res/layout/chooser_headline_row.xml +++ b/java/res/layout/chooser_headline_row.xml @@ -38,6 +38,21 @@ android:textSize="18sp" /> + <TextView + android:id="@+id/metadata" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:visibility="gone" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@id/barrier" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constrainedWidth="true" + app:layout_constraintTop_toBottomOf="@id/headline" + style="@style/TextAppearance.ChooserDefault" + android:fontFamily="@androidprv:string/config_bodyFontFamily" + android:textSize="12sp" + /> + <androidx.constraintlayout.widget.Barrier android:id="@+id/barrier" android:layout_width="wrap_content" @@ -50,13 +65,17 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:maxWidth="@dimen/modify_share_text_toggle_max_width" + android:background="@drawable/chooser_action_button_bg" app:layout_constraintEnd_toEndOf="parent" android:maxLines="2" android:ellipsize="end" android:visibility="gone" - android:paddingTop="3dp" - style="@style/TextAppearance.ChooserDefault" + android:paddingVertical="3dp" + android:paddingHorizontal="@dimen/chooser_edge_margin_normal_half" + style="?android:attr/borderlessButtonStyle" android:drawableEnd="@drawable/chevron_right" + android:textColor="?androidprv:attr/materialColorOnSurface" + android:textSize="12sp" /> <!-- This is only relevant for image+text preview, but needs to be in this layout so it can diff --git a/java/res/layout/chooser_list_per_profile.xml b/java/res/layout/chooser_list_per_profile_wrap.xml index ef82090c..fc0431d7 100644 --- a/java/res/layout/chooser_list_per_profile.xml +++ b/java/res/layout/chooser_list_per_profile_wrap.xml @@ -18,16 +18,23 @@ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_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. --> + <androidx.recyclerview.widget.RecyclerView android:layout_width="match_parent" - android:layout_height="match_parent" + android:layout_height="wrap_content" app:layoutManager="com.android.intentresolver.ChooserGridLayoutManager" android:id="@androidprv:id/resolver_list" android:clipToPadding="false" android:background="?androidprv:attr/materialColorSurfaceContainer" android:scrollbars="none" - android:elevation="1dp" android:nestedScrollingEnabled="true" /> <include layout="@layout/resolver_empty_states" /> diff --git a/java/res/layout/image_preview_image_item.xml b/java/res/layout/image_preview_image_item.xml index 3f534831..442e9345 100644 --- a/java/res/layout/image_preview_image_item.xml +++ b/java/res/layout/image_preview_image_item.xml @@ -18,8 +18,8 @@ <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="46dp" - android:layout_height="46dp"> + android:layout_width="@dimen/chooser_preview_image_height_tall" + android:layout_height="@dimen/chooser_preview_image_height_tall"> <com.android.intentresolver.widget.RoundedRectImageView android:id="@+id/image" diff --git a/java/res/layout/image_preview_loading_item.xml b/java/res/layout/image_preview_loading_item.xml new file mode 100644 index 00000000..a8a8f264 --- /dev/null +++ b/java/res/layout/image_preview_loading_item.xml @@ -0,0 +1,33 @@ +<!-- + ~ Copyright (C) 2023 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="wrap_content" + android:layout_height="@dimen/chooser_preview_image_height_tall" + android:padding="8dp"> + + <ProgressBar + android:id="@+id/loading_indicator" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:indeterminate="true" + android:indeterminateTint="?androidprv:attr/materialColorPrimary" + android:indeterminateTintMode="src_in" /> + +</FrameLayout> diff --git a/java/res/layout/image_preview_other_item.xml b/java/res/layout/image_preview_other_item.xml index 07f87e3a..db458656 100644 --- a/java/res/layout/image_preview_other_item.xml +++ b/java/res/layout/image_preview_other_item.xml @@ -16,16 +16,18 @@ <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="@dimen/chooser_preview_image_width" - android:layout_height="@dimen/chooser_preview_image_height_tall"> + android:layout_width="wrap_content" + android:layout_height="@dimen/chooser_preview_image_height_tall" + android:paddingHorizontal="@dimen/chooser_edge_margin_normal_half"> <TextView android:id="@+id/label" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:drawableTop="@drawable/ic_file_copy" - android:drawablePadding="8dp" + android:drawableStart="@drawable/ic_file_copy" + android:drawablePadding="4dp" + android:gravity="bottom" android:textAppearance="@style/TextAppearance.ChooserDefault" /> </FrameLayout> diff --git a/java/res/layout/resolver_empty_states.xml b/java/res/layout/resolver_empty_states.xml index d77630ee..0cf6e955 100644 --- a/java/res/layout/resolver_empty_states.xml +++ b/java/res/layout/resolver_empty_states.xml @@ -79,13 +79,13 @@ android:layout_centerHorizontal="true" android:layout_below="@androidprv:id/resolver_empty_state_subtitle" android:indeterminateTint="?android:attr/colorAccent"/> + <TextView + android:id="@android:id/empty" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/noApplications" + android:textColor="?androidprv:attr/materialColorOnSurfaceVariant" + android:padding="@dimen/chooser_edge_margin_normal" + android:gravity="center"/> </RelativeLayout> - <TextView android:id="@android:id/empty" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="?android:attr/colorBackground" - android:text="@string/noApplications" - android:padding="@dimen/chooser_edge_margin_normal" - android:layout_marginBottom="56dp" - android:gravity="center"/> </RelativeLayout> diff --git a/java/res/values-af/strings.xml b/java/res/values-af/strings.xml index 23dca362..52063f5b 100644 --- a/java/res/values-af/strings.xml +++ b/java/res/values-af/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deel tans video met skakel}other{Deel tans # video’s met skakel}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deel tans lêer met teks}other{Deel tans # lêers met teks}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deel tans lêer met skakel}other{Deel tans # lêers met skakel}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deel tans album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Net prent}other{Net prente}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Net video}other{Net video’s}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Net lêer}other{Net lêers}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Opneemtoestemming is nie aan hierdie program verleen nie, maar dit kan oudio deur hierdie USB-toestel opneem."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Persoonlik"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Werk"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privaat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Persoonlike aansig"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Werkaansig"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privaat aansig"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Deur jou IT-admin geblokkeer"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Hierdie inhoud kan nie met werkprogramme gedeel word nie"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Hierdie inhoud kan nie met werkprogramme oopgemaak word nie"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Hierdie inhoud kan nie met persoonlike apps gedeel word nie"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Hierdie inhoud kan nie met persoonlike programme oopgemaak word nie"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Hierdie inhoud kan nie met privaat apps gedeel word nie"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Hierdie inhoud kan nie met privaat apps oopgemaak word nie"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Werkapps word onderbreek"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Hervat"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Geen werkprogramme nie"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Geen persoonlike programme nie"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Geen private apps nie"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Maak <xliff:g id="APP">%s</xliff:g> in jou persoonlike profiel oop?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Maak <xliff:g id="APP">%s</xliff:g> in jou werkprofiel oop?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gebruik persoonlike blaaier"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Sluit teks in"</string> <string name="exclude_link" msgid="1332778255031992228">"Sluit skakel uit"</string> <string name="include_link" msgid="827855767220339802">"Sluit skakel in"</string> + <string name="pinned" msgid="7623664001331394139">"Vasgespeld"</string> </resources> diff --git a/java/res/values-am/strings.xml b/java/res/values-am/strings.xml index d381cfd1..d1d581a2 100644 --- a/java/res/values-am/strings.xml +++ b/java/res/values-am/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ቪድዮ ከአገናኝ ጋር በማጋራት ላይ}one{# ቪድዮ ከአገናኝ ጋር በማጋራት ላይ}other{# ቪድዮዎችን ከአገናኝ ጋር በማጋራት ላይ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ፋይልን ከጽሑፍ ጋር በማጋራት ላይ}one{# ፋይልን ከጽሑፍ ጋር በማጋራት ላይ}other{# ፋይሎችን ከጽሑፍ ጋር በማጋራት ላይ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ፋይልን ከአገናኝ ጋር በማጋራት ላይ}one{# ፋይልን ከአገናኝ ጋር በማጋራት ላይ}other{# ፋይሎችን ከአገናኝ ጋር በማጋራት ላይ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"የተጋራ አልበም"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ምስል ብቻ}one{ምስል ብቻ}other{ምስሎች ብቻ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ቪድዮ ብቻ}one{ቪድዮ ብቻ}other{ቪድዮዎች ብቻ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ፋይል ብቻ}one{ፋይል ብቻ}other{ፋይሎች ብቻ}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ይህ መተግበሪያ የመቅረጽ ፈቃድ አልተሰጠውም፣ ነገር ግን በዚህ ዩኤስቢ መሣሪያ በኩል ኦዲዮን መቅረጽ ይችላል።"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"የግል"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ሥራ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"የግል"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"የግል ዕይታ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"የስራ ዕይታ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"የግል ዕይታ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"በእርስዎ የአይቲ አስተዳዳሪ ታግዷል"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ይህ ይዘት በሥራ መተግበሪያዎች መጋራት አይችልም"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ይህ ይዘት በሥራ መተግበሪያዎች መከፈት አይችልም"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ይህ ይዘት በግል መተግበሪያዎች መጋራት አይችልም"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ይህ ይዘት በግል መተግበሪያዎች መከፈት አይችልም"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ይህ ይዘት በግል መተግበሪያዎች ሊጋራ አይችልም"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ይህ ይዘት በግል መተግበሪያዎች ሊከፈት አይችልም"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"የሥራ መተግበሪያዎች ባሉበት ቆመዋል"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ከቆመበት ቀጥል"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ምንም የሥራ መተግበሪያዎች የሉም"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ምንም የግል መተግበሪያዎች የሉም"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ምንም የግል መተግበሪያዎች የሉም"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> በግል መገለጫዎ ውስጥ ይከፈት?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> በስራ መገለጫዎ ውስጥ ይከፈት?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"የግል አሳሽ ተጠቀም"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"ጽሁፍ ጨምር"</string> <string name="exclude_link" msgid="1332778255031992228">"አገናኝን አታካትት"</string> <string name="include_link" msgid="827855767220339802">"አገናኝ አካትት"</string> + <string name="pinned" msgid="7623664001331394139">"ፒን ተደርጓል"</string> </resources> diff --git a/java/res/values-ar/strings.xml b/java/res/values-ar/strings.xml index 049f1b49..76e74b83 100644 --- a/java/res/values-ar/strings.xml +++ b/java/res/values-ar/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{مشاركة فيديو واحد ورابط}zero{مشاركة # فيديو ورابط}two{مشاركة فيديوهَين ورابط}few{مشاركة # فيديوهات ورابط}many{مشاركة # فيديو ورابط}other{مشاركة # فيديو ورابط}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{مشاركة ملف واحد ونص}zero{مشاركة # ملف ونص}two{مشاركة # ملفَّين ونص}few{مشاركة # ملفات ونص}many{مشاركة # ملفًا ونص}other{مشاركة # ملف ونص}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{مشاركة ملف واحد ورابط}zero{مشاركة # ملف ورابط}two{مشاركة ملفَّين ورابط}few{مشاركة # ملفات ورابط}many{مشاركة # ملفًا ورابط}other{مشاركة # ملف ورابط}}"</string> + <string name="sharing_album" msgid="191743129899503345">"مشاركة الألبوم"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{الصورة فقط}zero{الصور فقط}two{الصورتان فقط}few{الصور فقط}many{الصور فقط}other{الصور فقط}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{الفيديو فقط}zero{الفيديوهات فقط}two{الفيديوهان فقط}few{الفيديوهات فقط}many{الفيديوهات فقط}other{الفيديوهات فقط}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{الملف فقط}zero{الملفات فقط}two{الملفان فقط}few{الملفات فقط}many{الملفات فقط}other{الملفات فقط}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"لم يتم منح هذا التطبيق إذن تسجيل، ولكن يمكنه تسجيل الصوت من خلال جهاز USB هذا."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"شخصي"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"للعمل"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"المساحة الخاصّة"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"عرض المحتوى الشخصي"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"عرض محتوى العمل"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"عرض المساحة الخاصّة"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"حظر مشرف تكنولوجيا المعلومات مشاركة المحتوى"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"لا يمكن مشاركة هذا المحتوى مع تطبيقات العمل."</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"لا يمكن فتح هذا المحتوى باستخدام تطبيقات العمل."</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"لا يمكن مشاركة هذا المحتوى مع التطبيقات الشخصية."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"لا يمكن فتح هذا المحتوى باستخدام التطبيقات الشخصية."</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"لا يمكن مشاركة هذا المحتوى مع التطبيقات الخاصة"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"لا يمكن فتح هذا المحتوى باستخدام التطبيقات الخاصة"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"تطبيقات العمل متوقفة مؤقتًا."</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"إلغاء الإيقاف المؤقت"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ما مِن تطبيقات عمل."</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ما مِن تطبيقات شخصية."</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ما مِن تطبيقات خاصة"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"هل تريد فتح <xliff:g id="APP">%s</xliff:g> في ملفك الشخصي؟"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"هل تريد فتح <xliff:g id="APP">%s</xliff:g> في ملفك الشخصي للعمل؟"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"استخدام المتصفّح الشخصي"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"تضمين النص"</string> <string name="exclude_link" msgid="1332778255031992228">"استثناء الرابط"</string> <string name="include_link" msgid="827855767220339802">"تضمين الرابط"</string> + <string name="pinned" msgid="7623664001331394139">"مثبَّت"</string> </resources> diff --git a/java/res/values-as/strings.xml b/java/res/values-as/strings.xml index 93d8d10f..c732113a 100644 --- a/java/res/values-as/strings.xml +++ b/java/res/values-as/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{লিংকৰ সৈতে ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}one{লিংকৰ সৈতে # টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}other{লিংকৰ সৈতে # টা ভিডিঅ’ শ্বেয়াৰ কৰি থকা হৈছে}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{পাঠৰ সৈতে ফাইল শ্বেয়াৰ কৰি থকা হৈছে}one{পাঠৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}other{পাঠৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{লিংকৰ সৈতে ফাইল শ্বেয়াৰ কৰি থকা হৈছে}one{লিংকৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}other{লিংকৰ সৈতে # টা ফাইল শ্বেয়াৰ কৰি থকা হৈছে}}"</string> + <string name="sharing_album" msgid="191743129899503345">"এলবাম শ্বেয়াৰ কৰি থকা হৈছে"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{কেৱল প্ৰতিচ্ছবি}one{কেৱল প্ৰতিচ্ছবিসমূহ}other{কেৱল প্ৰতিচ্ছবিসমূহ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{কেৱল ভিডিঅ’}one{কেৱল ভিডিঅ’সমূহ}other{কেৱল ভিডিঅ’সমূহ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{কেৱল ফাইল}one{কেৱল ফাইলসমূহ}other{কেৱল ফাইলসমূহ}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"এই এপ্টোক ৰেকর্ড কৰাৰ অনুমতি দিয়া হোৱা নাই কিন্তু ই এই ইউএছবি ডিভাইচটোৰ জৰিয়তে অডিঅ\' ৰেকর্ড কৰিব পাৰে।"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ব্যক্তিগত"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"কৰ্মস্থান"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ব্যক্তিগত"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ব্যক্তিগত ভিউ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"কৰ্মস্থানৰ ভিউ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ব্যক্তিগত ভিউ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"আপোনাৰ আইটি প্ৰশাসকে অৱৰোধ কৰিছে"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"এই সমল কৰ্মস্থানৰ এপৰ সৈতে শ্বেয়াৰ কৰিব নোৱাৰি"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"এই সমল কৰ্মস্থানৰ এপৰ জৰিয়তে খুলিব নোৱাৰি"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"এই সমল ব্যক্তিগত এপৰ সৈতে শ্বেয়াৰ কৰিব নোৱাৰি"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"এই সমল ব্যক্তিগত এপৰ জৰিয়তে খুলিব নোৱাৰি"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"এই সমল ব্যক্তিগত এপৰ সৈতে শ্বেয়াৰ কৰিব নোৱাৰি"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"এই সমল ব্যক্তিগত এপৰ জৰিয়তে খুলিব নোৱাৰি"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"কাম সম্পর্কীয় এপ্ পজ কৰা আছে"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"আনপজ কৰক"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"কোনো কৰ্মস্থানৰ এপ্ নাই"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"কোনো ব্যক্তিগত এপ্ নাই"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"কোনো ব্যক্তিগত এপ্নাই"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"আপোনাৰ ব্যক্তিগত প্ৰ’ফাইলত <xliff:g id="APP">%s</xliff:g> খুলিবনে?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"আপোনাৰ কর্মস্থানৰ প্ৰ\'ফাইলত <xliff:g id="APP">%s</xliff:g> খুলিবনে?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ব্যক্তিগত ব্ৰাউজাৰ ব্যৱহাৰ কৰক"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"পাঠ অন্তৰ্ভুক্ত কৰক"</string> <string name="exclude_link" msgid="1332778255031992228">"লিংক বহিৰ্ভূত কৰক"</string> <string name="include_link" msgid="827855767220339802">"লিংক অন্তৰ্ভুক্ত কৰক"</string> + <string name="pinned" msgid="7623664001331394139">"পিন কৰা আছে"</string> </resources> diff --git a/java/res/values-az/strings.xml b/java/res/values-az/strings.xml index 8e58d593..d5d6b75f 100644 --- a/java/res/values-az/strings.xml +++ b/java/res/values-az/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Link olan video paylaşılır}other{Link olan # video paylaşılır}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Mətn olan fayl paylaşılır}other{Mətn olan # fayl paylaşılır}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Link olan fayl paylaşılır}other{Link olan # fayl paylaşılır}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albom paylaşılır"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Yalnız şəkil}other{Yalnız şəkillər}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Yalnız video}other{Yalnız videolar}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Yalnız fayl}other{Yalnız fayllar}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Tətbiqə qeydə almaq icazəsi verilməsə də, bu USB vasitəsilə səsi qeydə ala bilər."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Şəxsi"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"İş"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Məxfi"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Şəxsi məzmuna baxış"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"İş məzmununa baxış"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Şəxsi baxış"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT admininiz tərəfindən bloklanıb"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bu kontenti iş tətbiqləri ilə paylaşmaq mümkün deyil"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu kontenti iş tətbiqləri ilə açmaq mümkün deyil"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bu kontenti şəxsi tətbiqlər ilə paylaşmaq mümkün deyil"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bu kontenti şəxsi tətbiqlər ilə açmaq mümkün deyil"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Bu kontenti şəxsi tətbiqlərlə paylaşmaq mümkün deyil"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Bu kontenti şəxsi tətbiqlərlə açmaq mümkün deyil"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"İş tətbiqləri durdurulub"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Pauzanı bitirin"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"İş tətbiqi yoxdur"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Şəxsi tətbiq yoxdur"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Şəxsi tətbiq yoxdur"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Şəxsi profilinizdə <xliff:g id="APP">%s</xliff:g> tətbiqi açılsın?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"İş profilinizdə <xliff:g id="APP">%s</xliff:g> tətbiqi açılsın?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Şəxsi brauzerdən istifadə edin"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Mətn daxil edin"</string> <string name="exclude_link" msgid="1332778255031992228">"Keçidi istisna edin"</string> <string name="include_link" msgid="827855767220339802">"Keçid daxil edin"</string> + <string name="pinned" msgid="7623664001331394139">"Bərkidilib"</string> </resources> diff --git a/java/res/values-b+sr+Latn/strings.xml b/java/res/values-b+sr+Latn/strings.xml index b772afec..99339496 100644 --- a/java/res/values-b+sr+Latn/strings.xml +++ b/java/res/values-b+sr+Latn/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deli se video sa linkom}one{Deli se # video sa linkom}few{Dele se # video snimka sa linkom}other{Deli se # videa sa linkom}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deli se fajl sa tekstom}one{Deli se # fajl sa tekstom}few{Dele se # fajla sa tekstom}other{Deli se # fajlova sa tekstom}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deli se fajl sa linkom}one{Deli se # fajl sa linkom}few{Dele se # fajla sa linkom}other{Deli se # fajlova sa linkom}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deljeni album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}few{Samo slike}other{Samo slike}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo video}one{Samo videi}few{Samo videi}other{Samo videi}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo fajl}one{Samo fajlovi}few{Samo fajlovi}other{Samo fajlovi}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ova aplikacija nema dozvolu za snimanje, ali bi mogla da snima zvuk pomoću ovog USB uređaja."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Lično"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Poslovno"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privatno"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Lični prikaz"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Prikaz za posao"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatni prikaz"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokira IT administrator"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ovaj sadržaj ne može da se deli pomoću poslovnih aplikacija"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ovaj sadržaj ne može da se otvara pomoću poslovnih aplikacija"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ovaj sadržaj ne može da se deli pomoću ličnih aplikacija"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ovaj sadržaj ne može da se otvara pomoću ličnih aplikacija"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Ovaj sadržaj ne može da se deli pomoću privatnih aplikacija"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Ovaj sadržaj ne može da se otvori pomoću privatnih aplikacija"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Poslovne aplikacije su pauzirane"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Ponovo aktiviraj"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nema poslovnih aplikacija"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nema ličnih aplikacija"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Bez privatnih aplikacija"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Želite da na ličnom profilu otvorite: <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Želite da na poslovnom profilu otvorite: <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Koristi lični pregledač"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Uvrsti tekst"</string> <string name="exclude_link" msgid="1332778255031992228">"Izuzmi link"</string> <string name="include_link" msgid="827855767220339802">"Uvrsti link"</string> + <string name="pinned" msgid="7623664001331394139">"Zakačeno"</string> </resources> diff --git a/java/res/values-be/strings.xml b/java/res/values-be/strings.xml index 7f0a3939..6f9190bc 100644 --- a/java/res/values-be/strings.xml +++ b/java/res/values-be/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Абагульванне відэа са спасылкай}one{Абагульванне # відэа са спасылкай}few{Абагульванне # відэа са спасылкай}many{Абагульванне # відэа са спасылкай}other{Абагульванне # відэа са спасылкай}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Абагульванне файла з тэкстам}one{Абагульванне # файла з тэкстам}few{Абагульванне # файлаў з тэкстам}many{Абагульванне # файлаў з тэкстам}other{Абагульванне # файла з тэкстам}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Абагульванне файла са спасылкай}one{Абагульванне # файла са спасылкай}few{Абагульванне # файлаў са спасылкай}many{Абагульванне # файлаў са спасылкай}other{Абагульванне # файла са спасылкай}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Ідзе абагульванне альбома"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Толькі відарыс}one{Толькі відарысы}few{Толькі відарысы}many{Толькі відарысы}other{Толькі відарысы}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Толькі відэа}one{Толькі відэа}few{Толькі відэа}many{Толькі відэа}other{Толькі відэа}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Толькі файл}one{Толькі файлы}few{Толькі файлы}many{Толькі файлы}other{Толькі файлы}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"У гэтай праграмы няма дазволу на запіс, аднак яна зможа запісваць аўдыя праз гэту USB-прыладу."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Асабісты"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Працоўны"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Прыватная прастора"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Прагляд асабістага змесціва"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Прагляд працоўнага змесціва"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Прыватная прастора"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Заблакіравана вашым ІТ-адміністратарам"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Не ўдалося абагуліць гэта змесціва з працоўнымі праграмамі"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Не ўдалося адкрыць гэта змесціва з дапамогай працоўных праграм"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Не ўдалося абагуліць гэта змесціва з асабістымі праграмамі"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Не ўдалося адкрыць гэта змесціва з дапамогай асабістых праграм"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Не ўдалося абагуліць гэта змесціва з прыватнымі праграмамі"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Не ўдалося адкрыць гэта змесціва з дапамогай прыватных праграм"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Працоўныя праграмы прыпынены"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Уключыць"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Няма працоўных праграм"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Няма асабістых праграм"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Даступных прыватных праграм няма"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Адкрыць праграму \"<xliff:g id="APP">%s</xliff:g>\" з выкарыстаннем асабістага профілю?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Адкрыць праграму \"<xliff:g id="APP">%s</xliff:g>\" з выкарыстаннем працоўнага профілю?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Скарыстаць асабісты браўзер"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Дадаць тэкст"</string> <string name="exclude_link" msgid="1332778255031992228">"Выдаліць спасылку"</string> <string name="include_link" msgid="827855767220339802">"Дадаць спасылку"</string> + <string name="pinned" msgid="7623664001331394139">"Замацавана"</string> </resources> diff --git a/java/res/values-bg/strings.xml b/java/res/values-bg/strings.xml index 8ee68f9f..f16e5486 100644 --- a/java/res/values-bg/strings.xml +++ b/java/res/values-bg/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Споделяне на видеоклипа чрез връзка}other{Споделяне на # видеоклипа чрез връзка}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Споделяне на файла чрез SMS съобщение}other{Споделяне на # файла чрез SMS съобщение}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Споделяне на файла чрез връзка}other{Споделяне на # файла чрез връзка}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Споделяне на албума"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Само изображение}other{Само изображения}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Само видеоклип}other{Само видеоклипове}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Само файл}other{Само файлове}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Приложението няма разрешение за записване, но може да записва звук чрез това USB устройство."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Лични"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Служебни"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Частно"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Личен изглед"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Служебен изглед"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Частен изглед"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Блокирано от системния ви администратор"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Това съдържание не може да се споделя със служебни приложения"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Това съдържание не може да се отваря със служебни приложения"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Това съдържание не може да се споделя с лични приложения"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Това съдържание не може да се отваря с лични приложения"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Това съдържание не може да се споделя с частни приложения"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Това съдържание не може да се отваря с частни приложения"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Служебните приложения са поставени на пауза"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Отмяна на паузата"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Няма подходящи служебни приложения"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Няма подходящи лични приложения"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Няма частни приложения"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Искате ли да отворите <xliff:g id="APP">%s</xliff:g> в личния си потребителски профил?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Искате ли да отворите <xliff:g id="APP">%s</xliff:g> в служебния си потребителски профил?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Използване на личния браузър"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Включване на текста"</string> <string name="exclude_link" msgid="1332778255031992228">"Изключване на връзката"</string> <string name="include_link" msgid="827855767220339802">"Включване на връзката"</string> + <string name="pinned" msgid="7623664001331394139">"Фиксирано"</string> </resources> diff --git a/java/res/values-bn/strings.xml b/java/res/values-bn/strings.xml index d940b770..41778ade 100644 --- a/java/res/values-bn/strings.xml +++ b/java/res/values-bn/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{লিঙ্ক সহ ভিডিও শেয়ার করা হচ্ছে}one{লিঙ্ক সহ #টি ভিডিও শেয়ার করা হচ্ছে}other{লিঙ্ক সহ #টি ভিডিও শেয়ার করা হচ্ছে}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{টেক্সট সহ ফাইল শেয়ার করা হচ্ছে}one{টেক্সট সহ #টি ফাইল শেয়ার করা হচ্ছে}other{টেক্সট সহ #টি ফাইল শেয়ার করা হচ্ছে}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{লিঙ্ক সহ ফাইল শেয়ার করা হচ্ছে}one{লিঙ্ক সহ #টি ফাইল শেয়ার করা হচ্ছে}other{লিঙ্ক সহ #টি ফাইল শেয়ার করা হচ্ছে}}"</string> + <string name="sharing_album" msgid="191743129899503345">"অ্যালবাম শেয়ার করা হচ্ছে"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{শুধু ছবি}one{শুধু ছবি}other{শুধু ছবি}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{শুধু ভিডিও}one{শুধু ভিডিও}other{শুধু ভিডিও}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{শুধু ফাইল}one{শুধু ফাইল}other{শুধু ফাইল}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"এই অ্যাপকে রেকর্ড করার অনুমতি দেওয়া হয়নি কিন্তু USB ডিভাইসের মাধ্যমে সেটি অডিও রেকর্ড করতে পারে।"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ব্যক্তিগত"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"অফিস"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"প্রাইভেট"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ব্যক্তিগত ভিউ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"অফিসের ভিউ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ব্যক্তিগত ভিউ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"আপনার আইটি অ্যাডমিন ব্লক করেছেন"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"অফিসের অ্যাপে এই কন্টেন্ট শেয়ার করা যাবে না"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"অফিসের অ্যাপে এই খোলা যাবে না"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ব্যক্তিগত অ্যাপে এই কন্টেন্ট শেয়ার করা যাবে না"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ব্যক্তিগত অ্যাপে এই কন্টেন্ট খোলা যাবে না"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"এই কন্টেন্ট ব্যক্তিগত অ্যাপে শেয়ার করা যাবে না"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"এই কন্টেন্ট ব্যক্তিগত অ্যাপে খোলা যাবে না"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"অফিসের অ্যাপ পজ করা আছে"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"আনপজ করুন"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"এর জন্য কোনও অফিস অ্যাপ নেই"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ব্যক্তিগত অ্যাপে দেখা যাবে না"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"কোনও ব্যক্তিগত অ্যাপ নেই"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"আপনার ব্যক্তিগত প্রোফাইল থেকে <xliff:g id="APP">%s</xliff:g> খুলবেন?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"আপনার অফিস প্রোফাইল থেকে <xliff:g id="APP">%s</xliff:g> খুলবেন?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ব্যক্তিগত ব্রাউজার ব্যবহার করুন"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"টেক্সট যোগ করুন"</string> <string name="exclude_link" msgid="1332778255031992228">"লিঙ্ক বাদ দিন"</string> <string name="include_link" msgid="827855767220339802">"লিঙ্ক যোগ করুন"</string> + <string name="pinned" msgid="7623664001331394139">"পিন করা হয়েছে"</string> </resources> diff --git a/java/res/values-bs/strings.xml b/java/res/values-bs/strings.xml index 35425c84..d8cfc346 100644 --- a/java/res/values-bs/strings.xml +++ b/java/res/values-bs/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{i još # fajl}one{i još # fajl}few{i još # fajla}other{i još # fajlova}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Dijeljenje teksta"</string> <string name="sharing_link" msgid="2307694372813942916">"Dijeljenje linka"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Dijeli se slika}one{Dijeli se # slika}few{Dijele se # slike}other{Dijeli se # slika}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Dijeljenje slike}one{Dijeljenje # slike}few{Dijeljenje # slike}other{Dijeljenje # slika}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Dijeljenje videozapisa}one{Dijeljenje # videozapisa}few{Dijeljenje # videozapisa}other{Dijeljenje # videozapisa}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{Dijeljenje # fajla}one{Dijeljenje # fajla}few{Dijeljenje # fajla}other{Dijeljenje # fajlova}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Dijeljenje slike putem poruke}one{Dijeljenje # slike putem poruke}few{Dijeljenje # slike putem poruke}other{Dijeljenje # slika putem poruke}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Dijeljenje videozapisa putem linka}one{Dijeljenje # videozapisa putem linka}few{Dijeljenje # videozapisa putem linka}other{Dijeljenje # videozapisa putem linka}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Dijeljenje fajla putem poruke}one{Dijeljenje # fajla putem poruke}few{Dijeljenje # fajla putem poruke}other{Dijeljenje # fajlova putem poruke}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Dijeljenje fajla putem linka}one{Dijeljenje # fajla putem linka}few{Dijeljenje # fajla putem linka}other{Dijeljenje # fajlova putem linka}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Dijeljenje albuma"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}few{Samo slike}other{Samo slike}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo videozapis}one{Samo videozapisi}few{Samo videozapisi}other{Samo videozapisi}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo fajl}one{Samo fajlovi}few{Samo fajlovi}other{Samo fajlovi}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ovoj aplikaciji nije dato odobrenje za snimanje, ali može snimati zvuk putem ovog USB uređaja."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Lično"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Posao"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privatno"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Prikaz ličnog sadržaja"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Prikaz poslovnog sadržaja"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatan prikaz"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokirao je vaš IT administrator"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ovaj sadržaj nije moguće dijeliti pomoću poslovnih aplikacija"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ovaj sadržaj nije moguće otvoriti pomoću poslovnih aplikacija"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ovaj sadržaj nije moguće dijeliti pomoću ličnih aplikacija"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ovaj sadržaj nije moguće otvoriti pomoću ličnih aplikacija"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Sadržaj se ne može dijeliti pomoću privatnih aplikacija"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Sadržaj se ne može otvoriti pomoću privatnih aplikacija"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Poslovne aplikacije su pauzirane"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Ponovo pokreni"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nema poslovnih aplikacija"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nema ličnih aplikacija"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nema privatnih aplikacija"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na ličnom profilu?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na radnom profilu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Koristi lični preglednik"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Uključi tekst"</string> <string name="exclude_link" msgid="1332778255031992228">"Izuzmi link"</string> <string name="include_link" msgid="827855767220339802">"Uključi link"</string> + <string name="pinned" msgid="7623664001331394139">"Zakačeno"</string> </resources> diff --git a/java/res/values-ca/strings.xml b/java/res/values-ca/strings.xml index b1460f48..d3af1d2b 100644 --- a/java/res/values-ca/strings.xml +++ b/java/res/values-ca/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{S\'està compartint el vídeo amb un enllaç}many{S\'estan compartint # de vídeos amb un enllaç}other{S\'estan compartint # vídeos amb un enllaç}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{S\'està compartint el fitxer amb text}many{S\'estan compartint # de fitxers amb text}other{S\'estan compartint # fitxers amb text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{S\'està compartint el fitxer amb un enllaç}many{S\'estan compartint # de fitxers amb un enllaç}other{S\'estan compartint # fitxers amb un enllaç}}"</string> + <string name="sharing_album" msgid="191743129899503345">"S\'està compartint l\'àlbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Només imatge}many{Només imatges}other{Només imatges}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Només vídeo}many{Només vídeos}other{Només vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Només fitxer}many{Només fitxers}other{Només fitxers}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aquesta aplicació no té permís de gravació, però pot capturar àudio a través d\'aquest dispositiu USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Feina"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualització personal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualització de treball"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualització privada"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloquejat per l\'administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"No es pot compartir aquest contingut amb aplicacions de treball"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"No es pot obrir aquest contingut amb aplicacions de treball"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"No es pot compartir aquest contingut amb aplicacions personals"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"No es pot obrir aquest contingut amb aplicacions personals"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Aquest contingut no es pot compartir amb aplicacions privades"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Aquest contingut no es pot obrir amb aplicacions privades"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Les aplicacions de treball estan en pausa"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactiva"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Cap aplicació de treball"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Cap aplicació personal"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Cap aplicació privada"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vols obrir <xliff:g id="APP">%s</xliff:g> al teu perfil personal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vols obrir <xliff:g id="APP">%s</xliff:g> al teu perfil de treball?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utilitza el navegador personal"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Inclou text"</string> <string name="exclude_link" msgid="1332778255031992228">"Exclou l\'enllaç"</string> <string name="include_link" msgid="827855767220339802">"Inclou l\'enllaç"</string> + <string name="pinned" msgid="7623664001331394139">"Fixat"</string> </resources> diff --git a/java/res/values-cs/strings.xml b/java/res/values-cs/strings.xml index 06336793..8c3d6f88 100644 --- a/java/res/values-cs/strings.xml +++ b/java/res/values-cs/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sdílení videa s odkazem}few{Sdílení # videí s odkazem}many{Sdílení # videa s odkazem}other{Sdílení # videí s odkazem}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sdílení souboru s textem}few{Sdílení # souborů s textem}many{Sdílení # souboru s textem}other{Sdílení # souborů s textem}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sdílení souboru s odkazem}few{Sdílení # souborů s odkazem}many{Sdílení # souboru s odkazem}other{Sdílení # souborů s odkazem}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sdílení alba"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Pouze obrázek}few{Pouze obrázky}many{Pouze obrázky}other{Pouze obrázky}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Pouze video}few{Pouze videa}many{Pouze videa}other{Pouze videa}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Pouze soubor}few{Pouze soubory}many{Pouze soubory}other{Pouze soubory}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Tato aplikace nemá oprávnění k nahrávání, ale může zaznamenávat zvuk prostřednictvím tohoto zařízení USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Osobní"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Pracovní"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Soukromé"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Osobní zobrazení"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pracovní zobrazení"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Soukromé zobrazení"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokováno administrátorem IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tento obsah nelze sdílet pomocí pracovních aplikací"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tento obsah nelze otevřít pomocí pracovních aplikací"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tento obsah nelze sdílet pomocí osobních aplikací"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tento obsah nelze otevřít pomocí osobních aplikací"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tento obsah nelze sdílet se soukromými aplikacemi"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tento obsah nelze otevřít pomocí soukromých aplikací"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Pracovní aplikace jsou pozastaveny"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Zrušit pozastavení"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Žádné pracovní aplikace"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Žádné osobní aplikace"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Žádné soukromé aplikace"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Otevřít aplikaci <xliff:g id="APP">%s</xliff:g> v osobním profilu?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Otevřít aplikaci <xliff:g id="APP">%s</xliff:g> v pracovním profilu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Použít osobní prohlížeč"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Zahrnout text"</string> <string name="exclude_link" msgid="1332778255031992228">"Vyloučit odkaz"</string> <string name="include_link" msgid="827855767220339802">"Zahrnout odkaz"</string> + <string name="pinned" msgid="7623664001331394139">"Připnuto"</string> </resources> diff --git a/java/res/values-da/strings.xml b/java/res/values-da/strings.xml index 57f81416..cc726342 100644 --- a/java/res/values-da/strings.xml +++ b/java/res/values-da/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deler video med et link}one{Deler # video med et link}other{Deler # videoer med et link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deler fil med tekst}one{Deler # fil med tekst}other{Deler # filer med tekst}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deler fil med et link}one{Deler # fil med et link}other{Deler # filer med et link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deling af albummet"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Kun billedet}one{Kun billedet}other{Kun billeder}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Kun video}one{Kun video}other{Kun videoer}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Kun filen}one{Kun filen}other{Kun filer}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Denne app har ikke fået tilladelse til at optage, men optager muligvis lyd via denne USB-enhed."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personlig"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Arbejde"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visningen Personligt"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visningen Arbejde"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visningen Privat"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokeret af din it-administrator"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Dette indhold kan ikke deles med arbejdsapps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Dette indhold kan ikke åbnes med arbejdsapps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Dette indhold kan ikke deles med personlige apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Dette indhold kan ikke åbnes med personlige apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Dette indhold kan ikke deles med private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Dette indhold kan ikke åbnes med private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Dine arbejdsapps er sat på pause"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Genoptag"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Der er ingen arbejdsapps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Der er ingen personlige apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ingen private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vil du åbne <xliff:g id="APP">%s</xliff:g> på din personlige profil?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vil du åbne <xliff:g id="APP">%s</xliff:g> på din arbejdsprofil?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Brug personlig browser"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Inkluder tekst"</string> <string name="exclude_link" msgid="1332778255031992228">"Ekskluder link"</string> <string name="include_link" msgid="827855767220339802">"Inkluder link"</string> + <string name="pinned" msgid="7623664001331394139">"Fastgjort"</string> </resources> diff --git a/java/res/values-de/strings.xml b/java/res/values-de/strings.xml index f5de3756..32c8265f 100644 --- a/java/res/values-de/strings.xml +++ b/java/res/values-de/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Video wird per Link geteilt}other{# Videos werden per Link geteilt}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Datei wird per SMS geteilt}other{# Dateien werden per SMS geteilt}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Datei wird per Link geteilt}other{# Dateien werden per Link geteilt}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Album teilen"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Nur Bild}other{Nur Bilder}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Nur Video}other{Nur Videos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Nur Datei}other{Nur Dateien}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Diese App hat noch keine Berechtigung zum Aufnehmen erhalten, könnte aber Audioaufnahmen über dieses USB-Gerät machen."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Privat"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Geschäftlich"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Vertraulich"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Private Ansicht"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Geschäftliche Ansicht"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private Ansicht"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Von deinem IT-Administrator blockiert"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Diese Art von Inhalt kann nicht über geschäftliche Apps geteilt werden"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Diese Art von Inhalt kann nicht mit geschäftlichen Apps geöffnet werden"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Diese Art von Inhalt kann nicht über private Apps geteilt werden"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Diese Art von Inhalt kann nicht mit privaten Apps geöffnet werden"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Diese Art von Inhalt kann nicht über interne Apps geteilt werden"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Diese Art von Inhalt kann nicht mit internen Apps geöffnet werden"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Geschäftliche Apps sind pausiert"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Nicht mehr pausieren"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Keine geschäftlichen Apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Keine privaten Apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Keine internen Apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> in deinem privaten Profil öffnen?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> in deinem Arbeitsprofil öffnen?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Privaten Browser verwenden"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Text einschließen"</string> <string name="exclude_link" msgid="1332778255031992228">"Link ausschließen"</string> <string name="include_link" msgid="827855767220339802">"Link einschließen"</string> + <string name="pinned" msgid="7623664001331394139">"Angepinnt"</string> </resources> diff --git a/java/res/values-el/strings.xml b/java/res/values-el/strings.xml index b0c4abf5..5e02ab9e 100644 --- a/java/res/values-el/strings.xml +++ b/java/res/values-el/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Κοινοποίηση βίντεο με σύνδεσμο}other{Κοινοποίηση # βίντεο με σύνδεσμο}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Κοινοποίηση αρχείου με κείμενο}other{Κοινοποίηση # αρχείων με κείμενο}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Κοινοποίηση αρχείου με σύνδεσμο}other{Κοινοποίηση # αρχείων με σύνδεσμο}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Κοινοποίηση λευκώματος"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Μόνο εικόνα}other{Μόνο εικόνες}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Μόνο βίντεο}other{Μόνο βίντεο}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Μόνο αρχείο}other{Μόνο αρχεία}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Δεν έχει εκχωρηθεί άδεια εγγραφής σε αυτή την εφαρμογή, αλλά μέσω αυτής της συσκευής USB θα μπορεί να εγγράφει ήχο."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Προσωπικό"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Εργασία"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Ιδιωτική"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Προσωπική προβολή"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Προβολή εργασίας"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Ιδιωτική προβολή"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Αποκλείστηκε από τον διαχειριστή IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Δεν είναι δυνατή η κοινοποίηση αυτού του περιεχομένου με εφαρμογές εργασιών"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Δεν είναι δυνατό το άνοιγμα αυτού του περιεχομένου με εφαρμογές εργασιών"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Δεν είναι δυνατή η κοινοποίηση αυτού του περιεχομένου με προσωπικές εφαρμογές"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Δεν είναι δυνατό το άνοιγμα αυτού του περιεχομένου με προσωπικές εφαρμογές"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Δεν είναι δυνατή η κοινοποίηση αυτού του περιεχομένου σε ιδιωτικές εφαρμογές"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Δεν είναι δυνατό το άνοιγμα αυτού του περιεχομένου με ιδιωτικές εφαρμογές"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Οι εφαρμογές εργασιών τέθηκαν σε παύση"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Αναίρεση παύσης"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Δεν υπάρχουν εφαρμογές εργασιών"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Δεν υπάρχουν προσωπικές εφαρμογές"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Καμία ιδιωτική εφαρμογή"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Θέλετε να ανοίξετε την εφαρμογή <xliff:g id="APP">%s</xliff:g> στο προσωπικό σας προφίλ;"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Θέλετε να ανοίξετε την εφαρμογή <xliff:g id="APP">%s</xliff:g> στο προφίλ σας εργασίας;"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Χρήση προσωπικού προγράμματος περιήγησης"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Συμπερίληψη κειμένου"</string> <string name="exclude_link" msgid="1332778255031992228">"Εξαίρεση συνδέσμου"</string> <string name="include_link" msgid="827855767220339802">"Συμπερίληψη συνδέσμου"</string> + <string name="pinned" msgid="7623664001331394139">"Καρφιτσωμένο"</string> </resources> diff --git a/java/res/values-en-rAU/strings.xml b/java/res/values-en-rAU/strings.xml index c42a5531..28067f40 100644 --- a/java/res/values-en-rAU/strings.xml +++ b/java/res/values-en-rAU/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can\'t be shared with private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can\'t be opened with private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Include text"</string> <string name="exclude_link" msgid="1332778255031992228">"Exclude link"</string> <string name="include_link" msgid="827855767220339802">"Include link"</string> + <string name="pinned" msgid="7623664001331394139">"Pinned"</string> </resources> diff --git a/java/res/values-en-rCA/strings.xml b/java/res/values-en-rCA/strings.xml index c42a5531..de4865f0 100644 --- a/java/res/values-en-rCA/strings.xml +++ b/java/res/values-en-rCA/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can’t be shared with private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can’t be opened with private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Include text"</string> <string name="exclude_link" msgid="1332778255031992228">"Exclude link"</string> <string name="include_link" msgid="827855767220339802">"Include link"</string> + <string name="pinned" msgid="7623664001331394139">"Pinned"</string> </resources> diff --git a/java/res/values-en-rGB/strings.xml b/java/res/values-en-rGB/strings.xml index c42a5531..28067f40 100644 --- a/java/res/values-en-rGB/strings.xml +++ b/java/res/values-en-rGB/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can\'t be shared with private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can\'t be opened with private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Include text"</string> <string name="exclude_link" msgid="1332778255031992228">"Exclude link"</string> <string name="include_link" msgid="827855767220339802">"Include link"</string> + <string name="pinned" msgid="7623664001331394139">"Pinned"</string> </resources> diff --git a/java/res/values-en-rIN/strings.xml b/java/res/values-en-rIN/strings.xml index c42a5531..28067f40 100644 --- a/java/res/values-en-rIN/strings.xml +++ b/java/res/values-en-rIN/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can\'t be shared with private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can\'t be opened with private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Include text"</string> <string name="exclude_link" msgid="1332778255031992228">"Exclude link"</string> <string name="include_link" msgid="827855767220339802">"Include link"</string> + <string name="pinned" msgid="7623664001331394139">"Pinned"</string> </resources> diff --git a/java/res/values-en-rXC/strings.xml b/java/res/values-en-rXC/strings.xml index 95a8922d..f02e2c0d 100644 --- a/java/res/values-en-rXC/strings.xml +++ b/java/res/values-en-rXC/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Sharing video with link}other{Sharing # videos with link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Sharing file with text}other{Sharing # files with text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Sharing file with link}other{Sharing # files with link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Sharing album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image only}other{Images only}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video only}other{Videos only}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File only}other{Files only}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"This app has not been granted record permission but could capture audio through this USB device."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Work"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Work view"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Private view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocked by your IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"This content can’t be shared with work apps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"This content can’t be opened with work apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"This content can’t be shared with personal apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"This content can’t be opened with personal apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"This content can’t be shared with private apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"This content can’t be opened with private apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Work apps are paused"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"No work apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"No personal apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No private apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Open <xliff:g id="APP">%s</xliff:g> in your personal profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Open <xliff:g id="APP">%s</xliff:g> in your work profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Use personal browser"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Include text"</string> <string name="exclude_link" msgid="1332778255031992228">"Exclude link"</string> <string name="include_link" msgid="827855767220339802">"Include link"</string> + <string name="pinned" msgid="7623664001331394139">"Pinned"</string> </resources> diff --git a/java/res/values-es-rUS/strings.xml b/java/res/values-es-rUS/strings.xml index 7bb36dc1..a881ede8 100644 --- a/java/res/values-es-rUS/strings.xml +++ b/java/res/values-es-rUS/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartir video con vínculo}many{Compartir # de videos con vínculo}other{Compartir # videos con vínculo}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartir archivo con texto}many{Compartir # de archivos con texto}other{Compartir # archivos con texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartir archivo con vínculo}many{Compartir # de archivos con vínculo}other{Compartir # archivos con vínculo}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Se comparte este álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Solo imagen}many{Solo imágenes}other{Solo imágenes}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Solo video}many{Solo videos}other{Solo videos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Solo archivo}many{Solo archivos}other{Solo archivos}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aunque no se le otorgó permiso de grabación a esta app, puede capturar audio con este dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabajo"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privada"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vista personal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vista de trabajo"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqueado por tu administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"No se pueden usar apps de trabajo para compartir este contenido"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"No se puede abrir este contenido con apps de trabajo"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"No se pueden usar apps personales para compartir este contenido"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"No se puede abrir este contenido con apps personales"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"No se puede compartir este contenido con apps privadas"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"No se puede abrir este contenido con apps privadas"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Se pausaron las apps de trabajo"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reanudar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"El contenido no es compatible con apps de trabajo"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"El contenido no es compatible con apps personales"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No hay apps privadas"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"¿Quieres abrir <xliff:g id="APP">%s</xliff:g> en tu perfil personal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"¿Quieres abrir <xliff:g id="APP">%s</xliff:g> en tu perfil de trabajo?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar un navegador personal"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Incluir texto"</string> <string name="exclude_link" msgid="1332778255031992228">"Excluir vínculo"</string> <string name="include_link" msgid="827855767220339802">"Incluir vínculo"</string> + <string name="pinned" msgid="7623664001331394139">"Fijado"</string> </resources> diff --git a/java/res/values-es/strings.xml b/java/res/values-es/strings.xml index 9e28d051..62063d99 100644 --- a/java/res/values-es/strings.xml +++ b/java/res/values-es/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartiendo vídeo con enlace}many{Compartiendo # vídeos con enlace}other{Compartiendo # vídeos con enlace}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartiendo archivo con mensaje de texto}many{Compartiendo # archivos con mensaje de texto}other{Compartiendo # archivos con mensaje de texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartiendo archivo con enlace}many{Compartiendo # archivos con enlace}other{Compartiendo # archivos con enlace}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Compartiendo álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Solo imagen}many{Solo imágenes}other{Solo imágenes}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Solo vídeo}many{Solo vídeos}other{Solo vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Solo archivo}many{Solo archivos}other{Solo archivos}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Esta aplicación no tiene permiso para grabar, pero podría capturar audio con este dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabajo"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privada"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Ver contenido personal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ver contenido de trabajo"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqueado por tu administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Este contenido no se puede compartir con aplicaciones de trabajo"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Este contenido no se puede abrir con aplicaciones de trabajo"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Este contenido no se puede compartir con aplicaciones personales"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Este contenido no se puede abrir con aplicaciones personales"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Este contenido no se puede compartir con aplicaciones privadas"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Este contenido no se puede abrir con aplicaciones privadas"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Las aplicaciones de trabajo están en pausa"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactivar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ninguna aplicación de trabajo"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ninguna aplicación personal"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"No hay aplicaciones privadas"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"¿Abrir <xliff:g id="APP">%s</xliff:g> en tu perfil personal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"¿Abrir <xliff:g id="APP">%s</xliff:g> en tu perfil de trabajo?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar navegador personal"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Incluir texto"</string> <string name="exclude_link" msgid="1332778255031992228">"Excluir enlace"</string> <string name="include_link" msgid="827855767220339802">"Incluir enlace"</string> + <string name="pinned" msgid="7623664001331394139">"Fijado"</string> </resources> diff --git a/java/res/values-et/strings.xml b/java/res/values-et/strings.xml index fc1da949..27d14e00 100644 --- a/java/res/values-et/strings.xml +++ b/java/res/values-et/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Linki sisaldava video jagamine}other{# linki sisaldava video jagamine}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Teksti sisaldava faili jagamine}other{# teksti sisaldava faili jagamine}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Linki sisaldava faili jagamine}other{# linki sisaldava faili jagamine}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albumi jagamine"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Ainult pilt}other{Ainult pildid}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Ainult video}other{Ainult videod}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Ainult fail}other{Ainult failid}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Sellele rakendusele pole antud salvestamise luba, kuid see saab heli jäädvustada selle USB-seadme kaudu."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Isiklik"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Töö"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privaatne"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Isiklik vaade"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Töövaade"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privaatne vaade"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokeeris teie IT-administraator"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Seda sisu ei saa töörakendustega jagada"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Seda sisu ei saa töörakendustega avada"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Seda sisu ei saa isiklike rakendustega jagada."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Seda sisu ei saa isiklike rakendustega avada"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Seda sisu ei saa privaatsete rakendustega jagada"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Seda sisu ei saa privaatsete rakendustega avada"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Töörakendused on peatatud"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Jätka"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Töörakendusi pole"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Isiklikke rakendusi pole"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Privaatseid rakendusi pole"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Kas avada <xliff:g id="APP">%s</xliff:g> teie isiklikul profiilil?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Kas avada <xliff:g id="APP">%s</xliff:g> teie tööprofiilil?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Kasuta isiklikku brauserit"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Kaasa tekst"</string> <string name="exclude_link" msgid="1332778255031992228">"Välista link"</string> <string name="include_link" msgid="827855767220339802">"Kaasa link"</string> + <string name="pinned" msgid="7623664001331394139">"Kinnitatud"</string> </resources> diff --git a/java/res/values-eu/strings.xml b/java/res/values-eu/strings.xml index e8a85b96..a77d5db2 100644 --- a/java/res/values-eu/strings.xml +++ b/java/res/values-eu/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{eta beste # fitxategi}other{eta beste # fitxategi}}"</string> <string name="sharing_text" msgid="8137537443603304062">"Partekatuko den testua"</string> <string name="sharing_link" msgid="2307694372813942916">"Esteka partekatzen"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Irudia partekatuko da}other{# irudi partekatuko dira}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{Irudia partekatzen}other{# irudi partekatzen}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{Bideoa partekatzen}other{# bideo partekatzen}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# fitxategi partekatuko da}other{# fitxategi partekatuko dira}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{Irudi testudun bat partekatuko da}other{# irudi testudun partekatuko dira}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Bideo estekadun bat partekatuko da}other{# bideo estekadun partekatuko dira}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Fitxategi testudun bat partekatuko da}other{# fitxategi testudun partekatuko dira}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Fitxategi estekadun bat partekatuko da}other{# fitxategi estekadun partekatuko dira}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albuma partekatzea"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Irudia soilik}other{Irudiak bakarrik}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Bideoa soilik}other{Bideoak soilik}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fitxategia soilik}other{Fitxategiak soilik}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aplikazioak ez du grabatzeko baimenik, baina baliteke audioa grabatzea USB bidezko gailu horren bidez."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Pertsonala"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Lanekoa"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Pribatua"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Ikuspegi pertsonala"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Laneko ikuspegia"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Ikuspegi pribatua"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IKT saileko administratzaileak blokeatu egin du"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Eduki hau ezin da laneko aplikazioekin partekatu"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Eduki hau ezin da laneko aplikazioekin ireki"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Eduki hau ezin da aplikazio pertsonalekin partekatu"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Eduki hau ezin da aplikazio pertsonalekin ireki"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Eduki hau ezin da aplikazio pribatuekin partekatu"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Eduki hau ezin da aplikazio pribatuekin ireki"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Pausatuta daude laneko aplikazioak"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Berraktibatu"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ez dago laneko aplikaziorik"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ez dago aplikazio pertsonalik"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Aplikazio pribaturik ez"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Profil pertsonalean ireki nahi duzu <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Laneko profilean ireki nahi duzu <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Erabili arakatzaile pertsonala"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Sartu testua"</string> <string name="exclude_link" msgid="1332778255031992228">"Utzi kanpoan esteka"</string> <string name="include_link" msgid="827855767220339802">"Sartu esteka"</string> + <string name="pinned" msgid="7623664001331394139">"Ainguratuta"</string> </resources> diff --git a/java/res/values-fa/strings.xml b/java/res/values-fa/strings.xml index 010e0bc0..55204e51 100644 --- a/java/res/values-fa/strings.xml +++ b/java/res/values-fa/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{درحال همرسانی ویدیو با پیوند}one{درحال همرسانی # ویدیو با پیوند}other{درحال همرسانی # ویدیو با پیوند}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{درحال همرسانی فایل با نوشتار}one{درحال همرسانی # فایل با نوشتار}other{درحال همرسانی # فایل با نوشتار}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{درحال همرسانی فایل با پیوند}one{درحال همرسانی # فایل با پیوند}other{درحال همرسانی # فایل با پیوند}}"</string> + <string name="sharing_album" msgid="191743129899503345">"همرسانی آلبوم"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{فقط تصویر}one{فقط تصویر}other{فقط تصویر}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{فقط ویدیو}one{فقط ویدیو}other{فقط ویدیو}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{فقط فایل}one{فقط فایل}other{فقط فایل}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"مجوز ضبط به این برنامه داده نشده است اما میتواند صدا را ازطریق این دستگاه USB ضبط کند."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"شخصی"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"کاری"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"خصوصی"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"نمای شخصی"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"نمای کاری"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"نمای خصوصی"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"سرپرست فناوری اطلاعات آن را مسدود کرده است"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"نمیتوان این محتوا را با برنامههای کاری همرسانی کرد"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"نمیتوان این محتوا را با برنامههای کاری باز کرد"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"نمیتوان این محتوا را با برنامههای شخصی همرسانی کرد"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"نمیتوان این محتوا را با برنامههای شخصی باز کرد"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"نمیتوان این محتوا را با برنامههای خصوصی همرسانی کرد"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"نمیتوان این محتوا را با برنامههای خصوصی باز کرد"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"برنامههای کاری موقتاً متوقف شدهاند"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"لغو مکث"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"برنامه کاریای وجود ندارد"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"برنامه شخصیای وجود ندارد"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"بدون برنامه خصوصی"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> در نمایه شخصی باز شود؟"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> در نمایه کاری باز شود؟"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"استفاده از مرورگر شخصی"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"لحاظ کردن نوشتار"</string> <string name="exclude_link" msgid="1332778255031992228">"مستثنی کردن پیوند"</string> <string name="include_link" msgid="827855767220339802">"لحاظ کردن پیوند"</string> + <string name="pinned" msgid="7623664001331394139">"سنجاقشده"</string> </resources> diff --git a/java/res/values-fi/strings.xml b/java/res/values-fi/strings.xml index 792746b2..1456ddf5 100644 --- a/java/res/values-fi/strings.xml +++ b/java/res/values-fi/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Videota ja linkkiä jaetaan}other{# videota ja linkkiä jaetaan}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Tiedostoa ja tekstiä jaetaan}other{# tiedostoa ja tekstiä jaetaan}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Tiedostoa ja linkkiä jaetaan}other{# tiedostoa ja linkkiä jaetaan}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albumia jaetaan"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Vain kuva}other{Vain kuvat}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vain video}other{Vain videot}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Vain tiedostot}other{Vain tiedostot}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Sovellus ei ole saanut tallennuslupaa mutta voi tallentaa ääntä tämän USB-laitteen avulla."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Henkilökohtainen"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Työ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Yksityinen"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Henkilökohtainen näkymä"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Työnäkymä"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Yksityinen näkymä"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT-järjestelmänvalvojasi estämä"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tätä sisältöä ei voi jakaa työsovelluksilla"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tätä sisältöä ei voi avata työsovelluksilla"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tätä sisältöä ei voi jakaa henkilökohtaisilla sovelluksilla"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tätä sisältöä ei voi avata henkilökohtaisilla sovelluksilla"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tätä sisältöä ei voi jakaa yksityisillä sovelluksilla"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tätä sisältöä ei voi avata yksityisillä sovelluksilla"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Työsovellukset on keskeytetty"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Jatka käyttöä"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ei työsovelluksia"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ei henkilökohtaisia sovelluksia"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ei yksityisiä sovelluksia"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Avataanko <xliff:g id="APP">%s</xliff:g> henkilökohtaisessa profiilissa?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Avataanko <xliff:g id="APP">%s</xliff:g> työprofiilissa?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Käytä henkilökohtaista selainta"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Liitä teksti mukaan"</string> <string name="exclude_link" msgid="1332778255031992228">"Jätä linkki pois"</string> <string name="include_link" msgid="827855767220339802">"Liitä linkki mukaan"</string> + <string name="pinned" msgid="7623664001331394139">"Kiinnitetty"</string> </resources> diff --git a/java/res/values-fr-rCA/strings.xml b/java/res/values-fr-rCA/strings.xml index c0ee0ea6..a8916bb0 100644 --- a/java/res/values-fr-rCA/strings.xml +++ b/java/res/values-fr-rCA/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Partage d\'une vidéo avec un lien}one{Partage de # vidéo avec un lien}many{Partage de # de vidéos avec un lien}other{Partage de # vidéos avec un lien}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Partage d\'un fichier avec du texte}one{Partage de # fichier avec du texte}many{Partage de # de fichiers avec du texte}other{Partage de # fichiers avec du texte}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Partage d\'un fichier avec un lien}one{Partage de # fichier avec un lien}many{Partage de # de fichiers avec un lien}other{Partage de # fichiers avec un lien}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Album partagé"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image uniquement}one{Image uniquement}many{Images uniquement}other{Images uniquement}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vidéo uniquement}one{Vidéo uniquement}many{Vidéos uniquement}other{Vidéos uniquement}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fichier uniquement}one{Fichier uniquement}many{Fichiers uniquement}other{Fichiers uniquement}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Cette application n\'a pas été autorisée à effectuer des enregistrements, mais elle pourrait capturer du contenu audio par l\'intermédiaire de cet appareil USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personnel"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Professionnel"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privé"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Affichage personnel"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Affichage professionnel"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Affichage privé"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqué par votre administrateur informatique"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Impossible de partager ce contenu avec des applications professionnelles"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Impossible d\'ouvrir ce contenu avec des applications professionnelles"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Impossible de partager ce contenu avec des applications personnelles"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Impossible d\'ouvrir ce contenu avec des applications personnelles"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Impossible de partager ce contenu avec des applis privées"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Impossible d\'ouvrir ce contenu avec des applis privées"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Les applications professionnelles sont interrompues"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Réactiver"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Aucune application professionnelle"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Aucune application personnelle"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Aucune appli privée"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil personnel?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil professionnel?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utiliser le navigateur du profil personnel"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Inclure le texte"</string> <string name="exclude_link" msgid="1332778255031992228">"Exclure le lien"</string> <string name="include_link" msgid="827855767220339802">"Inclure le lien"</string> + <string name="pinned" msgid="7623664001331394139">"Épinglée"</string> </resources> diff --git a/java/res/values-fr/strings.xml b/java/res/values-fr/strings.xml index 68faf9bd..8d19248f 100644 --- a/java/res/values-fr/strings.xml +++ b/java/res/values-fr/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Partager 1 vidéo avec un lien}one{Partager # vidéo avec un lien}many{Partager # vidéos avec un lien}other{Partager # vidéos avec un lien}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Partager 1 fichier avec du texte}one{Partager # fichier avec du texte}many{Partager # fichiers avec du texte}other{Partager # fichiers avec du texte}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Partager 1 fichier avec un lien}one{Partager # fichier avec un lien}many{Partager # fichiers avec un lien}other{Partager # fichiers avec un lien}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Partage de l\'album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Image uniquement}one{Image uniquement}many{Images uniquement}other{Images uniquement}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vidéo uniquement}one{Vidéo uniquement}many{Vidéos uniquement}other{Vidéos uniquement}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fichier uniquement}one{Fichier uniquement}many{Fichiers uniquement}other{Fichiers uniquement}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Cette application n\'a pas reçu l\'autorisation d\'enregistrer des contenus audio, mais peut le faire via ce périphérique USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personnel"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Professionnel"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privé"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vue personnelle"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vue professionnelle"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Affichage en mode privé"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqué par votre administrateur informatique"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Impossible de partager ce contenu avec des applis professionnelles"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Impossible d\'ouvrir ce contenu avec des applis professionnelles"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Impossible de partager ce contenu avec des applis personnelles"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Impossible d\'ouvrir ce contenu avec des applis personnelles"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Impossible de partager ce contenu avec des applications privées"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Impossible d\'ouvrir ce contenu avec des applications privées"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Les applis professionnelles sont en pause"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Réactiver"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Aucune appli professionnelle"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Aucune appli personnelle"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Aucune application privée"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil personnel ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Ouvrir <xliff:g id="APP">%s</xliff:g> dans votre profil professionnel ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utiliser le navigateur personnel"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Inclure le texte"</string> <string name="exclude_link" msgid="1332778255031992228">"Exclure le lien"</string> <string name="include_link" msgid="827855767220339802">"Inclure le lien"</string> + <string name="pinned" msgid="7623664001331394139">"Épinglée"</string> </resources> diff --git a/java/res/values-gl/strings.xml b/java/res/values-gl/strings.xml index c782666c..81ab1d16 100644 --- a/java/res/values-gl/strings.xml +++ b/java/res/values-gl/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartindo vídeo con ligazón}other{Compartindo # vídeos con ligazón}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartindo ficheiro con texto}other{Compartindo # ficheiros con texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartindo ficheiro con ligazón}other{Compartindo # ficheiros con ligazón}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Compartindo álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Só a imaxe}other{Só as imaxes}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Só o vídeo}other{Só os vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Só o ficheiro}other{Só os ficheiros}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Esta aplicación non está autorizada a realizar gravacións, pero podería capturar audio a través deste dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Persoal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Traballo"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privada"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vista persoal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vista de traballo"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"O teu administrador de TI bloqueou a instalación"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Este contido non pode compartirse con aplicacións do traballo"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Este contido non pode abrirse con aplicacións do traballo"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Este contido non pode compartirse con aplicacións persoais"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Este contido non pode abrirse con aplicacións persoais"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Este contido non se pode compartir con aplicacións privadas"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Este contido non se pode abrir con aplicacións privadas"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"As aplicacións do traballo están en pausa"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactivar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Non hai ningunha aplicación do traballo compatible"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Non hai ningunha aplicación persoal compatible"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Non hai ningunha aplicación privada"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Queres abrir <xliff:g id="APP">%s</xliff:g> no teu perfil persoal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Queres abrir <xliff:g id="APP">%s</xliff:g> no teu perfil de traballo?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Utilizar navegador persoal"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Incluír texto"</string> <string name="exclude_link" msgid="1332778255031992228">"Excluír ligazón"</string> <string name="include_link" msgid="827855767220339802">"Incluír ligazón"</string> + <string name="pinned" msgid="7623664001331394139">"Elemento fixado"</string> </resources> diff --git a/java/res/values-gu/strings.xml b/java/res/values-gu/strings.xml index 70a99e84..5f74da1c 100644 --- a/java/res/values-gu/strings.xml +++ b/java/res/values-gu/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{લિંક સાથે વીડિયો શેર કરી રહ્યાં છીએ}one{લિંક સાથે # વીડિયો શેર કરી રહ્યાં છીએ}other{લિંક સાથે # વીડિયો શેર કરી રહ્યાં છીએ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ટેક્સ્ટ સાથે ફાઇલ શેર કરી રહ્યાં છીએ}one{ટેક્સ્ટ સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}other{ટેક્સ્ટ સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{લિંક સાથે ફાઇલ શેર કરી રહ્યાં છીએ}one{લિંક સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}other{લિંક સાથે # ફાઇલ શેર કરી રહ્યાં છીએ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"આલ્બમ શેર કરી રહ્યાં છીએ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{માત્ર છબી}one{માત્ર છબી}other{માત્ર છબીઓ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ફક્ત વીડિયો}one{ફક્ત વીડિયો}other{ફક્ત વીડિયો}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ફક્ત ફાઇલ}one{ફક્ત ફાઇલ}other{ફક્ત ફાઇલો}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"આ ઍપને રેકૉર્ડ કરવાની પરવાનગી આપવામાં આવી નથી પરંતુ તે આ USB ડિવાઇસ મારફતે ઑડિયો કૅપ્ચર કરી શકે છે."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"વ્યક્તિગત"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ઑફિસ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ખાનગી"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"વ્યક્તિગત વ્યૂ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ઑફિસ વ્યૂ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ખાનગી વ્યૂ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"તમારા IT વ્યવસ્થાપકે બ્લૉક કર્યું છે"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"આ કન્ટેન્ટ ઑફિસ માટેની ઍપ સાથે શેર કરી શકાતું નથી"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"આ કન્ટેન્ટ ઑફિસ માટેની ઍપ વડે ખોલી શકાતું નથી"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"આ કન્ટેન્ટ વ્યક્તિગત ઍપ સાથે શેર કરી શકાતું નથી"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"આ કન્ટેન્ટ વ્યક્તિગત ઍપ વડે ખોલી શકાતું નથી"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"આ કન્ટેન્ટ ખાનગી ઍપ સાથે શેર કરી શકાતું નથી"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"આ કન્ટેન્ટ ખાનગી ઍપ વડે ખોલી શકાતું નથી"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ઑફિસ માટેની ઍપ થોભાવવામાં આવી છે"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ફરી ચાલુ કરો"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"કોઈ ઑફિસ માટેની ઍપ સપોર્ટ કરતી નથી"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"કોઈ વ્યક્તિગત ઍપ સપોર્ટ કરતી નથી"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"કોઈ ખાનગી ઍપ નથી"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"તમારી વ્યક્તિગત પ્રોફાઇલમાં <xliff:g id="APP">%s</xliff:g> ખોલીએ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"તમારી ઑફિસની પ્રોફાઇલમાં <xliff:g id="APP">%s</xliff:g> ખોલીએ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"વ્યક્તિગત બ્રાઉઝરનો ઉપયોગ કરો"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"ટેક્સ્ટ શામેલ કરો"</string> <string name="exclude_link" msgid="1332778255031992228">"લિંકને બાકાત કરો"</string> <string name="include_link" msgid="827855767220339802">"લિંક શામેલ કરો"</string> + <string name="pinned" msgid="7623664001331394139">"પિન કરેલી"</string> </resources> diff --git a/java/res/values-h480dp/dimens.xml b/java/res/values-h480dp/dimens.xml index b5c86c77..74fab4ea 100644 --- a/java/res/values-h480dp/dimens.xml +++ b/java/res/values-h480dp/dimens.xml @@ -22,7 +22,7 @@ <dimen name="resolver_button_bar_spacing">8dp</dimen> <dimen name="chooser_preview_width">-1px</dimen> - <dimen name="chooser_preview_image_height_tall">192dp</dimen> + <dimen name="chooser_preview_image_height_tall">284dp</dimen> <dimen name="grid_padding">10dp</dimen> <dimen name="width_text_image_preview_size">56dp</dimen> </resources> diff --git a/java/res/values-h480dp/integers.xml b/java/res/values-h480dp/integers.xml index c1693057..1195d6b7 100644 --- a/java/res/values-h480dp/integers.xml +++ b/java/res/values-h480dp/integers.xml @@ -15,5 +15,5 @@ --> <resources xmlns:android="http://schemas.android.com/apk/res/android"> - <integer name="text_preview_lines">3</integer> + <integer name="text_preview_lines">8</integer> </resources> diff --git a/java/res/values-hi/strings.xml b/java/res/values-hi/strings.xml index 899aa1cf..291594fc 100644 --- a/java/res/values-hi/strings.xml +++ b/java/res/values-hi/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{लिंक के साथ वीडियो शेयर किया जा रहा है}one{लिंक के साथ # वीडियो शेयर किया जा रहा है}other{लिंक के साथ # वीडियो शेयर किए जा रहे हैं}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{टेक्स्ट के साथ फ़ाइल शेयर की जा रही है}one{टेक्स्ट के साथ # फ़ाइल शेयर की जा रही है}other{टेक्स्ट के साथ # फ़ाइलें शेयर की जा रही हैं}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{लिंक के साथ फ़ाइल शेयर की जा रही है}one{लिंक के साथ # फ़ाइल शेयर की जा रही है}other{लिंक के साथ # फ़ाइलें शेयर की जा रही हैं}}"</string> + <string name="sharing_album" msgid="191743129899503345">"एल्बम शेयर किया जा रहा है"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{सिर्फ़ इमेज}one{सिर्फ़ इमेज}other{सिर्फ़ इमेज}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{सिर्फ़ वीडियो}one{सिर्फ़ वीडियो}other{सिर्फ़ वीडियो}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{सिर्फ़ फ़ाइल}one{सिर्फ़ फ़ाइल}other{सिर्फ़ फ़ाइलें}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"इस ऐप्लिकेशन को रिकॉर्ड करने की अनुमति नहीं दी गई है. हालांकि, ऐप्लिकेशन इस यूएसबी डिवाइस से ऐसा कर सकता है."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"निजी प्रोफ़ाइल"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"वर्क प्रोफ़ाइल"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"प्राइवेट"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"निजी व्यू"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"वर्क व्यू"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"निजी व्यू"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"आपके आईटी एडमिन ने इस कॉन्टेंट को शेयर करने की सुविधा ब्लॉक कर रखी है"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"इस कॉन्टेंट को ऑफ़िस के काम से जुड़े ऐप्लिकेशन का इस्तेमाल करके, शेयर नहीं किया जा सकता"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"इस कॉन्टेंट को ऑफ़िस के काम से जुड़े ऐप्लिकेशन पर खोला नहीं जा सकता"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"इस कॉन्टेंट को निजी ऐप्लिकेशन के ज़रिए शेयर नहीं किया जा सकता"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"इस कॉन्टेंट को निजी ऐप्लिकेशन पर खोला नहीं जा सकता"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"इस कॉन्टेंट को निजी ऐप्लिकेशन पर शेयर नहीं किया जा सकता"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"इस कॉन्टेंट को निजी ऐप्लिकेशन की मदद से नहीं खोला जा सकता"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"वर्क ऐप्लिकेशन बंद किए गए हैं"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"चालू करें"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"यह कॉन्टेंट, ऑफ़िस के काम से जुड़े आपके किसी भी ऐप्लिकेशन पर खोला नहीं जा सकता"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"यह कॉन्टेंट आपके किसी भी निजी ऐप्लिकेशन पर खोला नहीं जा सकता"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"कोई निजी ऐप्लिकेशन उपलब्ध नहीं है"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"क्या <xliff:g id="APP">%s</xliff:g> को निजी प्रोफ़ाइल में खोलना है?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"क्या <xliff:g id="APP">%s</xliff:g> को वर्क प्रोफ़ाइल में खोलना है?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"निजी ब्राउज़र का इस्तेमाल करें"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"टेक्स्ट जोड़ें"</string> <string name="exclude_link" msgid="1332778255031992228">"लिंक हटाएं"</string> <string name="include_link" msgid="827855767220339802">"लिंक जोड़ें"</string> + <string name="pinned" msgid="7623664001331394139">"पिन किया गया"</string> </resources> diff --git a/java/res/values-hr/strings.xml b/java/res/values-hr/strings.xml index b9d8f867..0d969c40 100644 --- a/java/res/values-hr/strings.xml +++ b/java/res/values-hr/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Dijeli se videozapis s vezom}one{Dijeli se # videozapis s vezom}few{Dijele se # videozapisa s vezom}other{Dijeli se # videozapisa s vezom}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Dijeli se datoteka s tekstom}one{Dijeli se # datoteka s tekstom}few{Dijele se # datoteke s tekstom}other{Dijeli se # datoteka s tekstom}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Dijeli se datoteka s vezom}one{Dijeli se # datoteka s vezom}few{Dijele se # datoteke s vezom}other{Dijeli se # datoteka s vezom}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Dijeljenje albuma"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}few{Samo slike}other{Samo slike}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo videozapis}one{Samo videozapisi}few{Samo videozapisi}other{Samo videozapisi}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo datoteka}one{Samo datoteke}few{Samo datoteke}other{Samo datoteke}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ta aplikacija nema dopuštenje za snimanje, no mogla bi primati zvuk putem ovog USB uređaja."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Osobno"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Posao"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privatno"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Osobni prikaz"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Poslovni prikaz"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatni prikaz"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokirao vaš IT administrator"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Taj se sadržaj ne može dijeliti pomoću poslovnih aplikacija"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Taj se sadržaj ne može otvoriti pomoću poslovnih aplikacija"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Taj se sadržaj ne može dijeliti pomoću osobnih aplikacija"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Taj se sadržaj ne može otvoriti pomoću osobnih aplikacija"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Taj se sadržaj ne može dijeliti pomoću privatnih aplikacija"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Taj se sadržaj ne može otvoriti pomoću privatnih aplikacija"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Poslovne aplikacije su pauzirane"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Ponovno pokreni"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Poslovne aplikacije nisu dostupne"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Osobne aplikacije nisu dostupne"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nema privatnih aplikacija"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Želite li otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na osobnom profilu?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Želite li otvoriti aplikaciju <xliff:g id="APP">%s</xliff:g> na poslovnom profilu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Koristi osobni preglednik"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Uključi tekst"</string> <string name="exclude_link" msgid="1332778255031992228">"Isključi vezu"</string> <string name="include_link" msgid="827855767220339802">"Uključi vezu"</string> + <string name="pinned" msgid="7623664001331394139">"Prikvačeno"</string> </resources> diff --git a/java/res/values-hu/strings.xml b/java/res/values-hu/strings.xml index 4ad60f92..1ecadb77 100644 --- a/java/res/values-hu/strings.xml +++ b/java/res/values-hu/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Videó megosztása linkkel}other{# videó megosztása linkkel}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Fájl megosztása szöveggel}other{# fájl megosztása szöveggel}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Fájl megosztása linkkel}other{# fájl megosztása linkkel}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Album megosztása"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Csak kép}other{Csak képek}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Csak videó}other{Csak videók}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Csak fájl}other{Csak fájlok}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ez az alkalmazás nem rendelkezik rögzítési engedéllyel, de ezzel az USB-eszközzel képes a hangfelvételre."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Személyes"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Munka"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privát"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Személyes nézet"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Munkanézet"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privát nézet"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Rendszergazda által letiltva"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ez a tartalom nem osztható meg munkahelyi alkalmazásokkal"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ez a tartalom nem nyitható meg munkahelyi alkalmazásokkal"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ez a tartalom nem osztható meg személyes alkalmazásokkal"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ez a tartalom nem nyitható meg személyes alkalmazásokkal"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Ez a tartalom nem osztható meg privát alkalmazásokkal"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Ez a tartalom nem nyitható meg privát alkalmazásokkal"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"A munkahelyi alkalmazások szüneteltetve vannak"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Szüneteltetés feloldása"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nincs munkahelyi alkalmazás"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nincs személyes alkalmazás"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nincsenek privát alkalmazások"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Megnyitja a(z) <xliff:g id="APP">%s</xliff:g> alkalmazást a személyes profil használatával?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Megnyitja a(z) <xliff:g id="APP">%s</xliff:g> alkalmazást a munkaprofil használatával?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Személyes böngésző használata"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Szöveggel együtt"</string> <string name="exclude_link" msgid="1332778255031992228">"Link eltávolítása"</string> <string name="include_link" msgid="827855767220339802">"Linkkel együtt"</string> + <string name="pinned" msgid="7623664001331394139">"Kitűzve"</string> </resources> diff --git a/java/res/values-hy/strings.xml b/java/res/values-hy/strings.xml index 3db5ed02..e4ba3ed4 100644 --- a/java/res/values-hy/strings.xml +++ b/java/res/values-hy/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Տեսանյութի ուղարկում հղման միջոցով}one{# տեսանյութի ուղարկում հղման միջոցով}other{# տեսանյութի ուղարկում հղման միջոցով}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Ֆայլի ուղարկում տեքստային հաղորդագրության միջոցով}one{# ֆայլի ուղարկում տեքստային հաղորդագրության միջոցով}other{# ֆայլի ուղարկում տեքստային հաղորդագրության միջոցով}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Ֆայլի ուղարկում հղման միջոցով}one{# ֆայլի ուղարկում հղման միջոցով}other{# ֆայլի ուղարկում հղման միջոցով}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Ալբոմը դարձվում է ընդհանուր"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Միայն պատկերը}one{Միայն պատկերը}other{Միայն պատկերները}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Միայն տեսանյութը}one{Միայն տեսանյութը}other{Միայն տեսանյութերը}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Միայն ֆայլը}one{Միայն ֆայլը}other{Միայն ֆայլերը}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Հավելվածը ձայնագրելու թույլտվություն չունի, սակայն կկարողանա գրանցել ձայնն այս USB սարքի միջոցով։"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Անձնական"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Աշխատանքային"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Մասնավոր"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Անձնական"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Աշխատանքային"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Անձնական դիտակերպ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Արգելափակվել է ձեր ՏՏ ադմինիստրատորի կողմից"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Այս բովանդակությունը հնարավոր չէ ուղարկել աշխատանքային հավելվածներով"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Այս բովանդակությունը հնարավոր չէ բացել աշխատանքային հավելվածներով"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Այս բովանդակությունը հնարավոր չէ ուղարկել անձնական հավելվածներով"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Այս բովանդակությունը հնարավոր չէ բացել անձնական հավելվածներով"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Այս բովանդակությամբ հնարավոր չէ կիսվել մասնավոր հավելվածներով"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Այս բովանդակությունը հնարավոր չէ բացել մասնավոր հավելվածներով"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Աշխատանքային հավելվածները դադարեցված են"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Նորից միացնել"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Աշխատանքային հավելվածներ չկան"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Անձնական հավելվածներ չկան"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Անձնական հավելվածները չեն աջակցում որոշակի բովանդակություն"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Բացե՞լ <xliff:g id="APP">%s</xliff:g> հավելվածը ձեր անձնական պրոֆիլում"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Բացե՞լ <xliff:g id="APP">%s</xliff:g> հավելվածը ձեր աշխատանքային պրոֆիլում"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Օգտագործել անձնական դիտարկիչը"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Ներառել տեքստը"</string> <string name="exclude_link" msgid="1332778255031992228">"Բացառել հղումը"</string> <string name="include_link" msgid="827855767220339802">"Ներառել հղումը"</string> + <string name="pinned" msgid="7623664001331394139">"Ամրացված է"</string> </resources> diff --git a/java/res/values-in/strings.xml b/java/res/values-in/strings.xml index 4fbcb5a7..13c20f36 100644 --- a/java/res/values-in/strings.xml +++ b/java/res/values-in/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Membagikan video dengan link}other{Membagikan # video dengan link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Membagikan file dengan teks}other{Membagikan # file dengan teks}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Membagikan file dengan link}other{Membagikan # file dengan link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Berbagi album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Khusus gambar}other{Khusus gambar}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Khusus video}other{Khusus video}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Khusus file}other{Khusus file}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Aplikasi ini tidak diberi izin merekam, tetapi dapat merekam audio melalui perangkat USB ini."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Pribadi"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Kerja"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privasi"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Tampilan pribadi"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Tampilan kerja"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Tampilan pribadi"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Diblokir oleh admin IT Anda"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Konten ini tidak dapat dibagikan dengan aplikasi kerja"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Konten ini tidak dapat dibuka dengan aplikasi kerja"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Konten ini tidak dapat dibagikan ke aplikasi pribadi"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Konten ini tidak dapat dibuka dengan aplikasi pribadi"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Konten ini tidak dapat dibagikan dengan aplikasi yang ada di profil privasi"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Konten ini tidak dapat dibuka dengan aplikasi yang ada di profil privasi"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplikasi kerja dijeda"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Batalkan jeda"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Tidak ada aplikasi kerja"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Tidak ada aplikasi pribadi"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Tidak ada aplikasi pribadi"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Buka <xliff:g id="APP">%s</xliff:g> di profil pribadi?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Buka <xliff:g id="APP">%s</xliff:g> di profil kerja?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gunakan browser pribadi"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Sertakan teks"</string> <string name="exclude_link" msgid="1332778255031992228">"Kecualikan link"</string> <string name="include_link" msgid="827855767220339802">"Sertakan link"</string> + <string name="pinned" msgid="7623664001331394139">"Disematkan"</string> </resources> diff --git a/java/res/values-is/strings.xml b/java/res/values-is/strings.xml index f5664058..1fc0f98c 100644 --- a/java/res/values-is/strings.xml +++ b/java/res/values-is/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deilir myndskeiði með tengli}one{Deilir # myndskeiði með tengli}other{Deilir # myndskeiðum með tengli}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deilir skrá með texta}one{Deilir # skrá með texta}other{Deilir # skrám með texta}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deilir skrá með tengli}one{Deilir # skrá með tengli}other{Deilir # skrám með tengli}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deilir albúmi"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Eingöngu mynd}one{Eingöngu myndir}other{Eingöngu myndir}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Eingöngu myndskeið}one{Eingöngu myndskeið}other{Eingöngu myndskeið}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Eingöngu skrá}one{Eingöngu skrár}other{Eingöngu skrár}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Þetta forrit hefur ekki fengið heimild fyrir upptöku en gæti tekið upp hljóð í gegnum þetta USB-tæki."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Persónulegt"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Vinna"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Lokað"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Persónulegt yfirlit"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vinnuyfirlit"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Lokuð stilling"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Útilokað af kerfisstjóra"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Ekki er hægt að deila þessu efni með vinnuforritum"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Ekki er hægt að opna þetta efni með vinnuforritum"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Ekki er hægt að deila þessu efni með forritum til einkanota"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Ekki er hægt að opna þetta efni með forritum til einkanota"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Ekki er hægt að deila þessu efni með einkaforritum"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Ekki er hægt að opna þetta efni með einkaforritum"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Hlé gert á vinnuforritum"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Ljúka hléi"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Engin vinnuforrit"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Engin forrit til einkanota"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Engin einkaforrit"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Opna <xliff:g id="APP">%s</xliff:g> í þínu eigin sniði?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Opna <xliff:g id="APP">%s</xliff:g> í vinnusniðinu þínu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Nota einkavafra"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Hafa texta með"</string> <string name="exclude_link" msgid="1332778255031992228">"Útiloka tengil"</string> <string name="include_link" msgid="827855767220339802">"Hafa tengil með"</string> + <string name="pinned" msgid="7623664001331394139">"Fest"</string> </resources> diff --git a/java/res/values-it/strings.xml b/java/res/values-it/strings.xml index 7f861f52..96baefae 100644 --- a/java/res/values-it/strings.xml +++ b/java/res/values-it/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Condivisione video con link in corso…}many{Condivisione # video con link in corso…}other{Condivisione # video con link in corso…}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Condivisione file con messaggio in corso…}many{Condivisione # file con messaggio in corso…}other{Condivisione # file con messaggio in corso…}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Condivisione file con link in corso…}many{Condivisione # file con link in corso…}other{Condivisione # file con link in corso…}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Condivisione album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Soltanto l\'immagine}many{Soltanto le immagini}other{Soltanto le immagini}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Soltanto il video}many{Soltanto i video}other{Soltanto i video}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Soltanto il file}many{Soltanto i file}other{Soltanto i file}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"A questa app non è stata concessa l\'autorizzazione di registrazione, ma l\'app potrebbe acquisire l\'audio tramite questo dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personale"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Lavoro"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privato"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualizzazione personale"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualizzazione di lavoro"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualizzazione privata"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloccati dall\'amministratore IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Questi contenuti non possono essere condivisi con app di lavoro"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Questi contenuti non possono essere aperti con app di lavoro"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Questi contenuti non possono essere condivisi con app personali"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Questi contenuti non possono essere aperti con app personali"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Questi contenuti non possono essere condivisi con app private"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Questi contenuti non possono essere aperti con app private"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Le app di lavoro sono in pausa"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Riattiva"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nessuna app di lavoro"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nessuna app personale"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nessuna app privata"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Aprire <xliff:g id="APP">%s</xliff:g> nel tuo profilo personale?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Aprire <xliff:g id="APP">%s</xliff:g> nel tuo profilo di lavoro?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usa il browser personale"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Includi testo"</string> <string name="exclude_link" msgid="1332778255031992228">"Escludi link"</string> <string name="include_link" msgid="827855767220339802">"Includi link"</string> + <string name="pinned" msgid="7623664001331394139">"Elemento fissato"</string> </resources> diff --git a/java/res/values-iw/strings.xml b/java/res/values-iw/strings.xml index 28b72fda..8cfa7bd6 100644 --- a/java/res/values-iw/strings.xml +++ b/java/res/values-iw/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{שיתוף סרטון עם קישור}one{שיתוף # סרטונים עם קישור}two{שיתוף # סרטונים עם קישור}other{שיתוף # סרטונים עם קישור}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{שיתוף קובץ עם טקסט}one{שיתוף # קבצים עם טקסט}two{שיתוף # קבצים עם טקסט}other{שיתוף # קבצים עם טקסט}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{שיתוף תמונה עם קישור}one{שיתוף # תמונות עם קישור}two{שיתוף # תמונות עם קישור}other{שיתוף # תמונות עם קישור}}"</string> + <string name="sharing_album" msgid="191743129899503345">"שיתוף האלבום"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{תמונה בלבד}one{תמונות בלבד}two{תמונות בלבד}other{תמונות בלבד}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{סרטון בלבד}one{סרטונים בלבד}two{סרטונים בלבד}other{סרטונים בלבד}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{קובץ בלבד}one{קבצים בלבד}two{קבצים בלבד}other{קבצים בלבד}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"לאפליקציה זו לא ניתנה הרשאת הקלטה, אבל אפשר להקליט אודיו באמצעות התקן ה-USB הזה."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"אישי"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"עבודה"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"מרחב פרטי"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"תצוגה אישית"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"תצוגת עבודה"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"תצוגה פרטית"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"נחסם על ידי מנהל ה-IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"אי אפשר לשתף את התוכן הזה עם אפליקציות לעבודה"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"אי אפשר לפתוח את התוכן הזה באמצעות אפליקציות לעבודה"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"אי אפשר לשתף את התוכן הזה עם אפליקציות לשימוש אישי"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"אי אפשר לפתוח את התוכן הזה באמצעות אפליקציות לשימוש אישי"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"אי אפשר לשתף את התוכן הזה עם אפליקציות פרטיות"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"אי אפשר לפתוח את התוכן הזה באפליקציות פרטיות"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"האפליקציות לעבודה מושהות"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ביטול ההשהיה"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"אין אפליקציות לעבודה"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"אין אפליקציות לשימוש אישי"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"התוכן הזה לא זמין לאפליקציות פרטיות"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"לפתוח את <xliff:g id="APP">%s</xliff:g> בפרופיל האישי?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"לפתוח את <xliff:g id="APP">%s</xliff:g> בפרופיל העבודה?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"בדפדפן האישי"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"הכללת הטקסט"</string> <string name="exclude_link" msgid="1332778255031992228">"החרגת הקישור"</string> <string name="include_link" msgid="827855767220339802">"הכללת הקישור"</string> + <string name="pinned" msgid="7623664001331394139">"מוצמד"</string> </resources> diff --git a/java/res/values-ja/strings.xml b/java/res/values-ja/strings.xml index e99b73d2..d85b6864 100644 --- a/java/res/values-ja/strings.xml +++ b/java/res/values-ja/strings.xml @@ -57,7 +57,7 @@ <string name="more_files" msgid="1043875756612339842">"{count,plural, =1{その他 # ファイル}other{その他 # ファイル}}"</string> <string name="sharing_text" msgid="8137537443603304062">"テキストの共有"</string> <string name="sharing_link" msgid="2307694372813942916">"リンクを共有中"</string> - <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{1 枚の画像を共有します}other{# 枚の画像を共有します}}"</string> + <string name="sharing_images" msgid="5251443722186962006">"{count,plural, =1{画像を共有しています}other{# 枚の画像を共有しています}}"</string> <string name="sharing_videos" msgid="3583423190182877434">"{count,plural, =1{動画を共有中}other{# 個の動画を共有中}}"</string> <string name="sharing_files" msgid="1275646542246028823">"{count,plural, =1{# 個のファイルを共有中}other{# 個のファイルを共有中}}"</string> <string name="sharing_images_with_text" msgid="9005717434461730242">"{count,plural, =1{テキスト付き画像を共有しています}other{テキスト付き画像を # 件共有しています}}"</string> @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{リンク付き動画を共有中}other{リンク付き動画を # 件共有中}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{テキスト付きファイルを共有中}other{テキスト付きファイルを # 件共有中}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{リンク付きファイルを共有中}other{リンク付きファイルを # 件共有中}}"</string> + <string name="sharing_album" msgid="191743129899503345">"アルバムの共有"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{画像のみ}other{画像のみ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{動画のみ}other{動画のみ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ファイルのみ}other{ファイルのみ}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"このアプリに録音権限は付与されていませんが、この USB デバイスから音声を収集できるようになります。"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"個人用"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"仕事用"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"プライベート"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"個人用ビュー"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"仕事用ビュー"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"プライベート ビュー"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT 管理者によりブロックされました"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"このコンテンツを仕事用アプリと共有することはできません"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"このコンテンツを仕事用アプリで開くことはできません"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"このコンテンツを個人用アプリと共有することはできません"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"このコンテンツを個人用アプリで開くことはできません"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"このコンテンツを限定公開アプリと共有することはできません"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"このコンテンツを限定公開アプリで開くことはできません"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"仕事用アプリ一時停止中"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"一時停止を解除"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"仕事用アプリはありません"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"個人用アプリはありません"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"限定公開アプリは対応していません"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"個人用プロファイルで <xliff:g id="APP">%s</xliff:g> を開きますか?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"仕事用プロファイルで <xliff:g id="APP">%s</xliff:g> を開きますか?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"個人用ブラウザを使用"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"テキストを含める"</string> <string name="exclude_link" msgid="1332778255031992228">"リンクを除外"</string> <string name="include_link" msgid="827855767220339802">"リンクを含める"</string> + <string name="pinned" msgid="7623664001331394139">"固定されています"</string> </resources> diff --git a/java/res/values-ka/strings.xml b/java/res/values-ka/strings.xml index 5a47bbac..91f3b2ac 100644 --- a/java/res/values-ka/strings.xml +++ b/java/res/values-ka/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ვიდეო ზიარდება ბმულით}other{# ვიდეო ზიარდება ბმულით}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ფაილი ზიარდება ტექსტით}other{# ფაილი ზიარდება ტექსტით}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ფაილი ზიარდება ბმულით}other{# ფაილი ზიარდება ბმულით}}"</string> + <string name="sharing_album" msgid="191743129899503345">"გაზიარებული ალბომი"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{მხოლოდ სურათი}other{მხოლოდ სურათები}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{მხოლოდ ვიდეო}other{მხოლოდ ვიდეოები}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{მხოლოდ ფაილი}other{მხოლოდ ფაილები}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ამ აპს არ აქვს მინიჭებული ჩაწერის ნებართვა, მაგრამ შეუძლია ჩაიწეროს აუდიო ამ USB მოწყობილობის მეშვეობით."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"პირადი"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"სამსახური"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"კერძო"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"პირადი ხედი"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"სამსახურის ხედი"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"პირადი სივრცე"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"დაბლოკილია თქვენი IT-ადმინისტრატორის მიერ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ამ კონტენტის სამსახურის აპებისთვის გაზიარება შეუძლებელია"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ამ კონტენტის სამსახურის აპებით გახსნა შეუძლებელია"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ამ კონტენტის პირადი აპებისთვის გაზიარება შეუძლებელია"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ამ კონტენტის პირადი აპებით გახსნა შეუძლებელია"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ამ კონტენტის პირადი აპებისთვის გაზიარება შეუძლებელია"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ამ კონტენტის პირადი აპებით გახსნა შეუძლებელია"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"სამსახურის აპები დაპაუზებულია"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"პაუზის გაუქმება"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"სამსახურის აპები არ არის"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"პირადი აპები არ არის"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"არსებული პირადი აპებით მხარდაჭერილი არ არის"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"გსურთ <xliff:g id="APP">%s</xliff:g>-ის გახსნა თქვენს პირად პროფილში?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"გსურთ <xliff:g id="APP">%s</xliff:g>-ის გახსნა თქვენს სამსახურის პროფილში?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"პირადი ბრაუზერის გამოყენება"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"ტექსტის ჩასმა"</string> <string name="exclude_link" msgid="1332778255031992228">"ბმულის ამოღება"</string> <string name="include_link" msgid="827855767220339802">"ბმულის დართვა"</string> + <string name="pinned" msgid="7623664001331394139">"ჩამაგრებული"</string> </resources> diff --git a/java/res/values-kk/strings.xml b/java/res/values-kk/strings.xml index add2fd0a..ce4055f6 100644 --- a/java/res/values-kk/strings.xml +++ b/java/res/values-kk/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Сілтемесі бар бейне жіберу}other{Сілтемесі бар # бейне жіберу}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Мәтіні бар файл жіберу}other{Мәтіні бар # файл жіберу}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Сілтемесі бар файл жіберу}other{Сілтемесі бар # файл жіберу}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Альбомды бөлісу"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Тек сурет}other{Тек суреттер}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Тек бейне}other{Тек бейнелер}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Тек файл}other{Тек файлдар}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Қолданбаға жазу рұқсаты берілмеді, бірақ ол осы USB құрылғысы арқылы дыбыс жаза алады."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Жеке"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Жұмыс"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Құпия"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Жеке көру"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Жұмыс деректерін көру"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Құпия көрініс"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Әкімшіңіз бөгеген"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Бұл контентті жұмыс қолданбаларымен бөлісу мүмкін емес."</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Бұл контентті жұмыс қолданбаларымен ашу мүмкін емес."</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Бұл контентті жеке қолданбалармен бөлісу мүмкін емес."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Бұл контентті жеке қолданбалармен ашу мүмкін емес."</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Бұл контентті жеке қолданбалармен бөлісу мүмкін емес."</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Бұл контентті жеке қолданбалармен ашу мүмкін емес."</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Жұмыс қолданбалары кідіртілген."</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Қайта қосу"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Жұмыс қолданбалары жоқ."</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Жеке қолданбалар жоқ."</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Жеке қолданбалар жоқ."</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> қолданбасын жеке профиліңізде ашу керек пе?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> қолданбасын жұмыс профиліңізде ашу керек пе?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Жеке браузерді пайдалану"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Мәтін қосу"</string> <string name="exclude_link" msgid="1332778255031992228">"Сілтемені шығару"</string> <string name="include_link" msgid="827855767220339802">"Сілтеме қосу"</string> + <string name="pinned" msgid="7623664001331394139">"Бекітілген"</string> </resources> diff --git a/java/res/values-km/strings.xml b/java/res/values-km/strings.xml index 39a2048b..b6ff7b70 100644 --- a/java/res/values-km/strings.xml +++ b/java/res/values-km/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ចែករំលែកវីដេអូជាមួយតំណ}other{ចែករំលែក # វីដេអូជាមួយតំណ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ចែករំលែកឯកសារជាមួយអក្សរ}other{ចែករំលែក # ឯកសារជាមួយអក្សរ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ចែករំលែកឯកសារជាមួយតំណ}other{ចែករំលែកឯកសារ # ជាមួយតំណ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"កំពុងចែករំលែកអាល់ប៊ុម"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{រូបភាពតែប៉ុណ្ណោះ}other{រូបភាពតែប៉ុណ្ណោះ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{វីដេអូតែប៉ុណ្ណោះ}other{វីដេអូតែប៉ុណ្ណោះ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ឯកសារតែប៉ុណ្ណោះ}other{ឯកសារតែប៉ុណ្ណោះ}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"កម្មវិធីនេះមិនទាន់បានទទួលសិទ្ធិថតសំឡេងនៅឡើយទេ ប៉ុន្តែអាចថតសំឡេងតាមរយៈឧបករណ៍ USB នេះបាន។"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ផ្ទាល់ខ្លួន"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ការងារ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ឯកជន"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ទិដ្ឋភាពផ្ទាល់ខ្លួន"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ទិដ្ឋភាពការងារ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ទិដ្ឋភាពឯកជន"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"បានទប់ស្កាត់ដោយអ្នកគ្រប់គ្រងផ្នែកព័ត៌មានវិទ្យារបស់អ្នក"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ខ្លឹមសារនេះមិនអាចចែករំលែកតាមរយៈកម្មវិធីការងារបានទេ"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ខ្លឹមសារនេះមិនអាចបើកតាមរយៈកម្មវិធីការងារបានទេ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"មិនអាចចែករំលែកខ្លឹមសារនេះជាមួយកម្មវិធីផ្ទាល់ខ្លួនបានទេ"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ខ្លឹមសារនេះមិនអាចបើកតាមរយៈកម្មវិធីផ្ទាល់ខ្លួនបានទេ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"មិនអាចចែករំលែកខ្លឹមសារនេះជាមួយកម្មវិធីឯកជនបានទេ"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"មិនអាចបើកខ្លឹមសារនេះដោយប្រើកម្មវិធីឯកជនបានទេ"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"កម្មវិធីការងារត្រូវបានផ្អាក"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ឈប់ផ្អាក"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"គ្មានកម្មវិធីការងារទេ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"គ្មានកម្មវិធីផ្ទាល់ខ្លួនទេ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"គ្មានកម្មវិធីឯកជនទេ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"បើក <xliff:g id="APP">%s</xliff:g> នៅក្នុងកម្រងព័ត៌មានផ្ទាល់ខ្លួនរបស់អ្នកឬ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"បើក <xliff:g id="APP">%s</xliff:g> នៅក្នុងកម្រងព័ត៌មានការងាររបស់អ្នកឬ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ប្រើកម្មវិធីរុករកតាមអ៊ីនធឺណិតផ្ទាល់ខ្លួន"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"រួមបញ្ចូលអក្សរ"</string> <string name="exclude_link" msgid="1332778255031992228">"មិនរួមបញ្ចូលតំណ"</string> <string name="include_link" msgid="827855767220339802">"រួមបញ្ចូលតំណ"</string> + <string name="pinned" msgid="7623664001331394139">"បានខ្ទាស់"</string> </resources> diff --git a/java/res/values-kn/strings.xml b/java/res/values-kn/strings.xml index cb27a7af..33abd7f4 100644 --- a/java/res/values-kn/strings.xml +++ b/java/res/values-kn/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ಲಿಂಕ್ನೊಂದಿಗೆ ವೀಡಿಯೊವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಲಿಂಕ್ನೊಂದಿಗೆ # ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಲಿಂಕ್ನೊಂದಿಗೆ # ವೀಡಿಯೊಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ಪಠ್ಯದೊಂದಿಗೆ ಫೈಲ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಪಠ್ಯದೊಂದಿಗೆ # ಫೈಲ್ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಪಠ್ಯದೊಂದಿಗೆ # ಫೈಲ್ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ಲಿಂಕ್ನೊಂದಿಗೆ ಫೈಲ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}one{ಲಿಂಕ್ನೊಂದಿಗೆ # ಫೈಲ್ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}other{ಲಿಂಕ್ನೊಂದಿಗೆ # ಫೈಲ್ಗಳನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ಆಲ್ಬಮ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುತ್ತಿದೆ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ಚಿತ್ರ ಮಾತ್ರ}one{ಚಿತ್ರಗಳು ಮಾತ್ರ}other{ಚಿತ್ರಗಳು ಮಾತ್ರ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ವೀಡಿಯೊ ಮಾತ್ರ}one{ವೀಡಿಯೊಗಳು ಮಾತ್ರ}other{ವೀಡಿಯೊಗಳು ಮಾತ್ರ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ಫೈಲ್ ಮಾತ್ರ}one{ಫೈಲ್ಗಳು ಮಾತ್ರ}other{ಫೈಲ್ಗಳು ಮಾತ್ರ}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ಈ ಆ್ಯಪ್ಗೆ ರೆಕಾರ್ಡ್ ಅನುಮತಿಯನ್ನು ನೀಡಲಾಗಿಲ್ಲ, ಆದರೆ ಈ USB ಸಾಧನದ ಮೂಲಕ ಆಡಿಯೊವನ್ನು ಸೆರೆಹಿಡಿಯಬಲ್ಲದು."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ವೈಯಕ್ತಿಕ"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ಕೆಲಸ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ಖಾಸಗಿ"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ವೈಯಕ್ತಿಕ ವೀಕ್ಷಣೆ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ಕೆಲಸದ ವೀಕ್ಷಣೆ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ಖಾಸಗಿ ವೀಕ್ಷಣೆ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ನಿಮ್ಮ IT ನಿರ್ವಾಹಕರಿಂದ ನಿರ್ಬಂಧಿಸಲಾಗಿದೆ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ಗಳ ಈ ವಿಷಯವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುವುದಿಲ್ಲ"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ಗಳ ಈ ವಿಷಯವನ್ನು ತೆರೆಯಲಾಗುವುದಿಲ್ಲ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ವೈಯಕ್ತಿಕ ಆ್ಯಪ್ಗಳ ಮೂಲಕ ಈ ವಿಷಯವನ್ನು ಹಂಚಿಕೊಳ್ಳಲಾಗುವುದಿಲ್ಲ"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ವೈಯಕ್ತಿಕ ಆ್ಯಪ್ಗಳ ಮೂಲಕ ಈ ವಿಷಯವನ್ನು ತೆರೆಯಲಾಗುವುದಿಲ್ಲ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ಈ ಕಂಟೆಂಟ್ ಅನ್ನು ಖಾಸಗಿ ಆ್ಯಪ್ಗಳ ಜೊತೆಗೆ ಹಂಚಿಕೊಳ್ಳಲು ಸಾಧ್ಯವಿಲ್ಲ"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ಈ ಕಂಟೆಂಟ್ ಅನ್ನು ಖಾಸಗಿ ಆ್ಯಪ್ಗಳ ಮೂಲಕ ತೆರೆಯಲು ಸಾಧ್ಯವಿಲ್ಲ"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ಗಳನ್ನು ವಿರಾಮಗೊಳಿಸಲಾಗಿದೆ"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ವಿರಾಮವನ್ನು ರದ್ದುಗೊಳಿಸಿ"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ಯಾವುದೇ ಕೆಲಸಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಆ್ಯಪ್ಗಳಿಲ್ಲ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ಯಾವುದೇ ವೈಯಕ್ತಿಕ ಆ್ಯಪ್ಗಳಿಲ್ಲ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ಯಾವುದೇ ಖಾಸಗಿ ಆ್ಯಪ್ಗಳಿಲ್ಲ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"ನಿಮ್ಮ ವೈಯಕ್ತಿಕ ಪ್ರೊಫೈಲ್ನಲ್ಲಿ <xliff:g id="APP">%s</xliff:g> ಅನ್ನು ತೆರೆಯಬೇಕೆ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"ನಿಮ್ಮ ಉದ್ಯೋಗದ ಪ್ರೊಫೈಲ್ನಲ್ಲಿ <xliff:g id="APP">%s</xliff:g> ಅನ್ನು ತೆರೆಯಬೇಕೆ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ವೈಯಕ್ತಿಕ ಬ್ರೌಸರ್ ಬಳಸಿ"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"ಪಠ್ಯವನ್ನು ಸೇರಿಸಿ"</string> <string name="exclude_link" msgid="1332778255031992228">"ಲಿಂಕ್ ಹೊರತುಪಡಿಸಿ"</string> <string name="include_link" msgid="827855767220339802">"ಲಿಂಕ್ ಸೇರಿಸಿ"</string> + <string name="pinned" msgid="7623664001331394139">"ಪಿನ್ ಮಾಡಲಾಗಿದೆ"</string> </resources> diff --git a/java/res/values-ko/strings.xml b/java/res/values-ko/strings.xml index de8de12a..9110dc9c 100644 --- a/java/res/values-ko/strings.xml +++ b/java/res/values-ko/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{링크로 동영상 공유 중}other{링크로 동영상 #개 공유 중}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{텍스트로 파일 공유 중}other{텍스트로 파일 #개 공유 중}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{링크로 파일 공유 중}other{링크로 파일 #개 공유 중}}"</string> + <string name="sharing_album" msgid="191743129899503345">"앨범 공유"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{이미지만}other{이미지만}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{동영상만}other{동영상만}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{파일만}other{파일만}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"이 앱에는 녹음 권한이 부여되지 않았지만, 이 USB 기기를 통해 오디오를 녹음할 수 있습니다."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"개인"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"직장"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"비공개"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"개인 뷰"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"직장 뷰"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"비공개 뷰"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT 관리자에 의해 차단됨"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"이 콘텐츠는 직장 앱을 통해 공유할 수 없습니다."</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"이 콘텐츠는 직장 앱으로 열 수 없습니다."</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"이 콘텐츠는 개인 앱을 통해 공유할 수 없습니다."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"이 콘텐츠는 개인 앱으로 열 수 없습니다."</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"이 콘텐츠는 비공개 앱에 공유할 수 없습니다."</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"이 콘텐츠는 비공개 앱으로 열 수 없습니다."</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"직장 앱이 일시중지됨"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"일시중지 해제"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"직장 앱 없음"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"개인 앱 없음"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"비공개 앱을 사용할 수 없습니다."</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"개인 프로필에서 <xliff:g id="APP">%s</xliff:g> 앱을 여시겠습니까?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"직장 프로필에서 <xliff:g id="APP">%s</xliff:g> 앱을 여시겠습니까?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"개인 브라우저 사용"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"텍스트 포함"</string> <string name="exclude_link" msgid="1332778255031992228">"링크 제외"</string> <string name="include_link" msgid="827855767220339802">"링크 포함"</string> + <string name="pinned" msgid="7623664001331394139">"고정됨"</string> </resources> diff --git a/java/res/values-ky/strings.xml b/java/res/values-ky/strings.xml index 96ffb0ce..b6ac7bb1 100644 --- a/java/res/values-ky/strings.xml +++ b/java/res/values-ky/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Видеону шилтеме менен жөнөтүү}other{# видеону шилтеме менен жөнөтүү}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Файлды текст менен жөнөтүү}other{# файлды текст менен жөнөтүү}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Файлды шилтеме менен жөнөтүү}other{# файлды шилтеме менен жөнөтүү}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Альбом бөлүшүлүүдө"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Сүрөт гана}other{Сүрөттөр гана}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Видео гана}other{Видеолор гана}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Файл гана}other{Файлдар гана}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Бул колдонмого жаздырууга уруксат берилген эмес, бирок ушул USB түзмөгү аркылуу үндөрдү жаза алат."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Жеке"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Жумуш"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Купуя"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Жеке көрүнүш"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Жумуш көрүнүшү"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Купуя көрүнүш"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT администраторуңуз бөгөттөп койгон"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Бул нерсени жумуш колдонмолору менен бөлүшө албайсыз"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Бул нерсени жумуш колдонмолору менен ача албайсыз"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Бул нерсени жеке колдонмолор менен бөлүшө албайсыз"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Бул нерсени жеке колдонмолор менен ача албайсыз"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Бул мазмун жеке колдонмолор менен бөлүшүлбөйт"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Бул мазмун жеке колдонмолор менен ачылбайт"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Жумуш колдонмолору тындырылды"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Иштетүү"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Жумуш колдонмолору жок"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Жеке колдонмолор жок"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Жеке колдонмолор жок"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> колдонмосу жеке профилде ачылсынбы?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> колдонмосу жумуш профилинде ачылсынбы?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Жеке серепчини колдонуу"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Текст кошуу"</string> <string name="exclude_link" msgid="1332778255031992228">"Шилтемени чыгарып салуу"</string> <string name="include_link" msgid="827855767220339802">"Шилтеме кошуу"</string> + <string name="pinned" msgid="7623664001331394139">"Кадалган"</string> </resources> diff --git a/java/res/values-lo/strings.xml b/java/res/values-lo/strings.xml index 91c39162..1cc677ed 100644 --- a/java/res/values-lo/strings.xml +++ b/java/res/values-lo/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ກຳລັງແບ່ງປັນວິດີໂອພ້ອມລິ້ງ}other{ກຳລັງແບ່ງປັນ # ວິດີໂອພ້ອມລິ້ງ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ກຳລັງແບ່ງປັນໄຟລ໌ພ້ອມຂໍ້ຄວາມ}other{ກຳລັງແບ່ງປັນ # ໄຟລ໌ພ້ອມຂໍ້ຄວາມ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ກຳລັງແບ່ງປັນໄຟລ໌ພ້ອມລິ້ງ}other{ກຳລັງແບ່ງປັນ # ໄຟລ໌ພ້ອມລິ້ງ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ກຳລັງແບ່ງປັນອະລະບ້ຳ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ຮູບເທົ່ານັ້ນ}other{ຮູບເທົ່ານັ້ນ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ວິດີໂອເທົ່ານັ້ນ}other{ວິດີໂອເທົ່ານັ້ນ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ໄຟລ໌ເທົ່ານັ້ນ}other{ໄຟລ໌ເທົ່ານັ້ນ}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ແອັບນີ້ບໍ່ໄດ້ຮັບສິດອະນຸຍາດໃນການບັນທຶກ ແຕ່ສາມາດບັນທຶກສຽງໄດ້ຜ່ານອຸປະກອນ USB ນີ້."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ສ່ວນຕົວ"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ວຽກ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ສ່ວນຕົວ"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ມຸມມອງສ່ວນຕົວ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ມຸມມອງວຽກ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ມຸມມອງສ່ວນຕົວ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ຖືກບລັອກໄວ້ໂດຍຜູ້ເບິ່ງແຍງໄອທີຂອງທ່ານ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກແບ່ງປັນກັບແອັບບ່ອນເຮັດວຽກໄດ້"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກເປີດໄດ້ດ້ວຍແອັບບ່ອນເຮັດວຽກ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກແບ່ງປັນກັບແອັບສ່ວນຕົວໄດ້"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ເນື້ອຫານີ້ບໍ່ສາມາດຖືກເປີດໄດ້ດ້ວຍແອັບສ່ວນຕົວ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ບໍ່ສາມາດແບ່ງປັນເນື້ອຫານີ້ໂດຍໃຊ້ແອັບສ່ວນຕົວໄດ້"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ບໍ່ສາມາດເປີດເນື້ອຫານີ້ໂດຍໃຊ້ແອັບສ່ວນຕົວໄດ້"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ຢຸດແອັບບ່ອນເຮັດວຽກໄວ້ຊົ່ວຄາວແລ້ວ"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ຍົກເລີກການຢຸດຊົ່ວຄາວ"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ບໍ່ມີແອັບບ່ອນເຮັດວຽກ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ບໍ່ມີແອັບສ່ວນຕົວ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ບໍ່ມີແອັບສ່ວນຕົວ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"ເປີດ <xliff:g id="APP">%s</xliff:g> ໃນໂປຣໄຟລ໌ສ່ວນຕົວຂອງທ່ານບໍ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"ເປີດ <xliff:g id="APP">%s</xliff:g> ໃນໂປຣໄຟລ໌ບ່ອນເຮັດວຽກຂອງທ່ານບໍ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ໃຊ້ໂປຣແກຣມທ່ອງເວັບສ່ວນຕົວ"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"ຮວມຂໍ້ຄວາມ"</string> <string name="exclude_link" msgid="1332778255031992228">"ບໍ່ຮວມລິ້ງ"</string> <string name="include_link" msgid="827855767220339802">"ຮວມລິ້ງ"</string> + <string name="pinned" msgid="7623664001331394139">"ປັກໝຸດແລ້ວ"</string> </resources> diff --git a/java/res/values-lt/strings.xml b/java/res/values-lt/strings.xml index 1d7fc237..17971c47 100644 --- a/java/res/values-lt/strings.xml +++ b/java/res/values-lt/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Bendrinamas vaizdo įrašas su nuoroda}one{Bendrinamas # vaizdo įrašas su nuoroda}few{Bendrinami # vaizdo įrašai su nuoroda}many{Bendrinamas # vaizdo įrašo su nuoroda}other{Bendrinama # vaizdo įrašų su nuoroda}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Bendrinamas failas su tekstu}one{Bendrinamas # failas su tekstu}few{Bendrinami # failai su tekstu}many{Bendrinama # failo su tekstu}other{Bendrinama # failų su tekstu}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Bendrinamas failas su nuoroda}one{Bendrinamas # failas su nuoroda}few{Bendrinami # failai su nuoroda}many{Bendrinama # failo su nuoroda}other{Bendrinama # failų su nuoroda}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Bendrinamas albumas"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Tik vaizdas}one{Tik vaizdai}few{Tik vaizdai}many{Tik vaizdai}other{Tik vaizdai}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Tik vaizdo įrašas}one{Tik vaizdo įrašai}few{Tik vaizdo įrašai}many{Tik vaizdo įrašai}other{Tik vaizdo įrašai}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Tik failas}one{Tik failai}few{Tik failai}many{Tik failai}other{Tik failai}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Šiai programai nebuvo suteiktas leidimas įrašyti, bet ji gali užfiksuoti garsą per šį USB įrenginį."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Asmeninis"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Darbo"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privatus"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Asmeninė peržiūra"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Darbo peržiūra"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privatus rodinys"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Užblokavo jūsų IT administratorius"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Šio turinio negalima bendrinti su darbo programomis"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Šio turinio negalima atidaryti naudojant darbo programas"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Šio turinio negalima bendrinti su asmeninėmis programomis"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Šio turinio negalima atidaryti naudojant asmenines programas"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Šio turinio negalima bendrinti su privačiomis programomis"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Šio turinio negalima atidaryti naudojant privačias programas"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Darbo programos pristabdytos"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Atšaukti pristabdymą"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nėra darbo programų"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nėra asmeninių programų"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nėra privačių programų"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Atidaryti „<xliff:g id="APP">%s</xliff:g>“ asmeniniame profilyje?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Atidaryti „<xliff:g id="APP">%s</xliff:g>“ darbo profilyje?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Naudoti asmeninę naršyklę"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Įtraukti tekstą"</string> <string name="exclude_link" msgid="1332778255031992228">"Išskirti nuorodą"</string> <string name="include_link" msgid="827855767220339802">"Įtraukti nuorodą"</string> + <string name="pinned" msgid="7623664001331394139">"Prisegta"</string> </resources> diff --git a/java/res/values-lv/strings.xml b/java/res/values-lv/strings.xml index d1506be1..76070171 100644 --- a/java/res/values-lv/strings.xml +++ b/java/res/values-lv/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Tiek kopīgots videoklips ar saiti}zero{Tiek kopīgoti # videoklipi ar saitēm}one{Tiek kopīgots # videoklips ar saitēm}other{Tiek kopīgoti # videoklipi ar saitēm}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Tiek kopīgots fails ar tekstu}zero{Tiek kopīgoti # faili ar tekstu}one{Tiek kopīgots # fails ar tekstu}other{Tiek kopīgoti # faili ar tekstu}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Tiek kopīgots fails ar saiti}zero{Tiek kopīgoti # faili ar saitēm}one{Tiek kopīgots # fails ar saitēm}other{Tiek kopīgoti # faili ar saitēm}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Notiek albuma kopīgošana"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Tikai attēls}zero{Tikai attēli}one{Tikai attēli}other{Tikai attēli}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Tikai videoklips}zero{Tikai videoklipi}one{Tikai videoklipi}other{Tikai videoklipi}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Tikai fails}zero{Tikai faili}one{Tikai faili}other{Tikai faili}}"</string> @@ -74,19 +75,24 @@ <string name="file_preview_a11y_description" msgid="7397224827802410602">"Faila priekšskatījuma sīktēls"</string> <string name="chooser_no_direct_share_targets" msgid="4233416657754261844">"Nav ieteikta neviena persona, ar ko kopīgot"</string> <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Šai lietotnei nav piešķirta ierakstīšanas atļauja, taču tā varētu tvert audio, izmantojot šo USB ierīci."</string> - <string name="resolver_personal_tab" msgid="1381052735324320565">"Personīgais profils"</string> + <string name="resolver_personal_tab" msgid="1381052735324320565">"Privātais profils"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Darba profils"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privāts"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personisks skats"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Darba skats"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privātais skats"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloķējis jūsu IT administrators"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Šo saturu nevar kopīgot ar darba lietotnēm"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Šo saturu nevar atvērt darba lietotnēs"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Šo saturu nevar kopīgot ar personīgajām lietotnēm"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Šo saturu nevar atvērt personīgajās lietotnēs"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Šo saturu nevar kopīgot ar privātajām lietotnēm"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Šo saturu nevar atvērt privātajās lietotnēs"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Darba lietotnes ir apturētas."</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Aktivizēt"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nav darba lietotņu"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nav personīgu lietotņu"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nav privātu lietotņu"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vai atvērt lietotni <xliff:g id="APP">%s</xliff:g> jūsu personīgajā profilā?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vai atvērt lietotni <xliff:g id="APP">%s</xliff:g> jūsu darba profilā?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Izmantot personīgo pārlūku"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Iekļaut tekstu"</string> <string name="exclude_link" msgid="1332778255031992228">"Izslēgt saiti"</string> <string name="include_link" msgid="827855767220339802">"Iekļaut saiti"</string> + <string name="pinned" msgid="7623664001331394139">"Piespraustās"</string> </resources> diff --git a/java/res/values-mk/strings.xml b/java/res/values-mk/strings.xml index 939ecae9..ceca73ce 100644 --- a/java/res/values-mk/strings.xml +++ b/java/res/values-mk/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Се споделува видео со линк}one{Се споделуваат # видео со линк}other{Се споделуваat # видеa со линк}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Се споделува датотека со SMS}one{Се споделуваат # датотека со SMS}other{Се споделуваат # датотеки со SMS}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Се споделува датотека со линк}one{Се споделуваат # датотека со линк}other{Се споделуваат # датотеки со линк}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Споделување албум"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Само слика}one{Само слики}other{Само слики}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Само видео}one{Само видеа}other{Само видеа}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Само датотека}one{Само датотеки}other{Само датотеки}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"На апликацијава не ѝ е доделена дозвола за снимање, но може да снима аудио преку овој USB-уред."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Лични"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"За работа"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Приватно"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Личен приказ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Работен приказ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Приватен приказ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Блокирано од IT-администраторот"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Овие содржини не може да се споделуваат со работни апликации"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Овие содржини не може да се отвораат со работни апликации"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Овие содржини не може да се споделуваат со лични апликации"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Овие содржини не може да се отвораат со лични апликации"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Овие содржини не може да се споделуваат со приватни апликации"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Овие содржини не може да се отвораат со лични апликации"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Работните апликации се паузирани"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Прекини ја паузата"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Нема работни апликации"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Нема лични апликации"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Нема приватни апликации"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Да се отвори <xliff:g id="APP">%s</xliff:g> во личниот профил?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Да се отвори <xliff:g id="APP">%s</xliff:g> во работниот профил?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Користи личен прелистувач"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Опфати текст"</string> <string name="exclude_link" msgid="1332778255031992228">"Исклучи линк"</string> <string name="include_link" msgid="827855767220339802">"Вклучи линк"</string> + <string name="pinned" msgid="7623664001331394139">"Закачено"</string> </resources> diff --git a/java/res/values-ml/strings.xml b/java/res/values-ml/strings.xml index e42ac807..5697294b 100644 --- a/java/res/values-ml/strings.xml +++ b/java/res/values-ml/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ലിങ്കിനൊപ്പം വീഡിയോ പങ്കിടുന്നു}other{ലിങ്കിനൊപ്പം # വീഡിയോകൾ പങ്കിടുന്നു}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ടെക്സ്റ്റിനൊപ്പം ഫയൽ പങ്കിടുന്നു}other{ടെക്സ്റ്റിനൊപ്പം # ഫയലുകൾ പങ്കിടുന്നു}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ലിങ്കിനൊപ്പം ഫയൽ പങ്കിടുന്നു}other{ലിങ്കിനൊപ്പം # ഫയലുകൾ പങ്കിടുന്നു}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ആൽബം പങ്കിടൽ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ചിത്രം മാത്രം}other{ചിത്രങ്ങൾ മാത്രം}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{വീഡിയോ മാത്രം}other{വീഡിയോകൾ മാത്രം}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ഫയൽ മാത്രം}other{ഫയലുകൾ മാത്രം}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ഈ ആപ്പിന് റെക്കോർഡ് അനുമതി നൽകിയിട്ടില്ല, എന്നാൽ ഈ USB ഉപകരണത്തിലൂടെ ഓഡിയോ ക്യാപ്ചർ ചെയ്യാനാവും."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"വ്യക്തിപരം"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ഔദ്യോഗികം"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"സ്വകാര്യം"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"വ്യക്തിപര കാഴ്ച"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ഔദ്യോഗിക കാഴ്ച"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"സ്വകാര്യ കാഴ്ച"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"നിങ്ങളുടെ ഐടി അഡ്മിൻ ബ്ലോക്ക് ചെയ്തു"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ഔദ്യോഗിക ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം പങ്കിടാനാകില്ല"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ഔദ്യോഗിക ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം തുറക്കാനാകില്ല"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"വ്യക്തിപര ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം പങ്കിടാനാകില്ല"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"വ്യക്തിപര ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം തുറക്കാനാകില്ല"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"സ്വകാര്യ ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം പങ്കിടാനാകില്ല"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"സ്വകാര്യ ആപ്പുകൾ ഉപയോഗിച്ച് ഈ ഉള്ളടക്കം തുറക്കാനാകില്ല"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ഔദ്യോഗിക ആപ്പുകൾ തൽക്കാലം നിർത്തിയിരിക്കുന്നു"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"താൽക്കാലികമായി നിർത്തിയത് മാറ്റുക"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ഔദ്യോഗിക ആപ്പുകൾ ഇല്ല"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"വ്യക്തിപര ആപ്പുകൾ ഇല്ല"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"സ്വകാര്യ ആപ്പുകൾ ഇല്ല"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g>, നിങ്ങളുടെ വ്യക്തിപരമായ പ്രൊഫൈലിൽ തുറക്കണോ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g>, നിങ്ങളുടെ ഔദ്യോഗിക പ്രൊഫൈലിൽ തുറക്കണോ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"വ്യക്തിപരമായ ബ്രൗസർ ഉപയോഗിക്കുക"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"ടെക്സ്റ്റ് ഉൾപ്പെടുത്തുക"</string> <string name="exclude_link" msgid="1332778255031992228">"ലിങ്ക് ഒഴിവാക്കുക"</string> <string name="include_link" msgid="827855767220339802">"ലിങ്ക് ഉൾപ്പെടുത്തുക"</string> + <string name="pinned" msgid="7623664001331394139">"പിൻ ചെയ്തത്"</string> </resources> diff --git a/java/res/values-mn/strings.xml b/java/res/values-mn/strings.xml index 9d0ea1b8..4ef02012 100644 --- a/java/res/values-mn/strings.xml +++ b/java/res/values-mn/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Холбоостой видео хуваалцаж байна}other{Холбоостой # видео хуваалцаж байна}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Тексттэй файл хуваалцаж байна}other{Тексттэй # файл хуваалцаж байна}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Холбоостой файл хуваалцаж байна}other{Холбоостой # файл хуваалцаж байна}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Цомог хуваалцаж байна"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Зөвхөн зураг}other{Зөвхөн зургууд}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Зөвхөн видео}other{Зөвхөн видеонууд}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Зөвхөн файл}other{Зөвхөн файлууд}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Энэ апликейшнд бичих зөвшөөрөл олгогдоогүй ч энэ USB төхөөрөмжөөр дамжуулан аудио бичиж чадсан."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Хувийн"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Ажил"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Хувийн"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Хувийн харагдах байдал"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ажлын харагдах байдал"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Хувийн харагдах байдал"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Таны IT админ блоклосон"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Энэ контентыг ажлын аппуудаар хуваалцах боломжгүй"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Энэ контентыг ажлын аппуудаар нээх боломжгүй"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Энэ контентыг хувийн аппуудаар хуваалцах боломжгүй"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Энэ контентыг хувийн аппуудаар нээх боломжгүй"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Энэ контентыг хувийн аппуудаар хуваалцах боломжгүй"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Энэ контентыг хувийн аппуудаар нээх боломжгүй"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Ажлын аппуудыг түр зогсоосон"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Үргэлжлүүлэх"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ямар ч ажлын апп байхгүй байна"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ямар ч хувийн апп байхгүй байна"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ямар ч хувийн апп байхгүй"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Хувийн профайл дээрээ <xliff:g id="APP">%s</xliff:g>-г нээх үү?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Ажлын профайл дээрээ <xliff:g id="APP">%s</xliff:g>-г нээх үү?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Хувийн хөтөч ашиглах"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Текстийг оруулах"</string> <string name="exclude_link" msgid="1332778255031992228">"Холбоосыг хасах"</string> <string name="include_link" msgid="827855767220339802">"Холбоосыг оруулах"</string> + <string name="pinned" msgid="7623664001331394139">"Бэхэлсэн"</string> </resources> diff --git a/java/res/values-mr/strings.xml b/java/res/values-mr/strings.xml index 81aac409..9eaec274 100644 --- a/java/res/values-mr/strings.xml +++ b/java/res/values-mr/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{लिंकसह व्हिडिओ शेअर करत आहे}other{लिंकसह # व्हिडिओ शेअर करत आहे}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{मजकुरासह फाइल शेअर करत आहे}other{मजकुरासह # फाइल शेअर करत आहे}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{लिंकसह फाइल शेअर करत आहे}other{लिंकसह # फाइल शेअर करत आहे}}"</string> + <string name="sharing_album" msgid="191743129899503345">"अल्बम शेअर करत आहे"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{फक्त इमेज}other{फक्त इमेज}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{फक्त व्हिडिओ}other{फक्त व्हिडिओ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{फक्त फाइल}other{फक्त फाइल}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"या अॅपला रेकॉर्ड करण्याची परवानगी दिली गेली नाही पण हे USB डिव्हाइस वापरून ऑडिओ कॅप्चर केला जाऊ शकतो."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"वैयक्तिक"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"कार्य"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"खाजगी"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"वैयक्तिक दृश्य"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"कार्य दृश्य"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"खाजगी व्ह्यू"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"तुमच्या IT ॲडमिनने ब्लॉक केले"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"हा आशय कार्य ॲप्ससह शेअर केला जाऊ शकत नाही"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"हा आशय कार्य ॲप्स वापरून उघडला जाऊ शकत नाही"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"हा आशय वैयक्तिक ॲप्ससह शेअर केला जाऊ शकत नाही"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"हा आशय वैयक्तिक ॲप्स वापरून उघडला जाऊ शकत नाही"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"हा आशय खाजगी ॲप्ससोबत शेअर केला जाऊ शकत नाही"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"हा आशय खाजगी ॲप्स वापरून उघडला जाऊ शकत नाही"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"कामाशी संबंधित अॅप्स थांबवली आहेत"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"पुन्हा सुरू करा"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"कोणतीही कार्य ॲप्स सपोर्ट करत नाहीत"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"कोणतीही वैयक्तिक ॲप्स सपोर्ट करत नाहीत"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"कोणतीही खाजगी अॅप्स नाहीत"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"तुमच्या वैयक्तिक प्रोफाइलमध्ये <xliff:g id="APP">%s</xliff:g> उघडायचे आहे का?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"तुमच्या कार्य प्रोफाइलमध्ये <xliff:g id="APP">%s</xliff:g> उघडायचे आहे का?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"वैयक्तिक ब्राउझर वापरा"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"मजकूर समाविष्ट करा"</string> <string name="exclude_link" msgid="1332778255031992228">"लिंक वगळा"</string> <string name="include_link" msgid="827855767220339802">"लिंक समाविष्ट करा"</string> + <string name="pinned" msgid="7623664001331394139">"पिन केलेली"</string> </resources> diff --git a/java/res/values-ms/strings.xml b/java/res/values-ms/strings.xml index dfdde1b8..de0f20d2 100644 --- a/java/res/values-ms/strings.xml +++ b/java/res/values-ms/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Berkongsi video dengan pautan}other{Berkongsi # video dengan pautan}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Berkongsi fail dengan teks}other{Berkongsi # fail dengan teks}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Berkongsi fail dengan pautan}other{Berkongsi # fail dengan pautan}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Berkongsi album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Imej sahaja}other{Imej sahaja}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video sahaja}other{Video sahaja}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Fail sahaja}other{Fail sahaja}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Apl ini belum diberikan kebenaran merakam tetapi dapat merakam audio melalui peranti USB ini."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Peribadi"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Kerja"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Persendirian"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Paparan peribadi"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Paparan kerja"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Paparan peribadi"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Disekat oleh pentadbir IT anda"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Kandungan ini tidak boleh dikongsi dengan apl kerja"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Kandungan ini tidak boleh dibuka dengan apl kerja"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Kandungan ini tidak boleh dikongsi dengan apl peribadi"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Kandungan ini tidak boleh dibuka dengan apl peribadi"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Kandungan ini tidak boleh dikongsi dengan apl peribadi"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Kandungan ini tidak boleh dibuka dengan apl peribadi"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Apl kerja dijeda"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Nyahjeda"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Tiada apl kerja"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Tiada apl peribadi"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Tiada apl peribadi"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Buka <xliff:g id="APP">%s</xliff:g> dalam profil peribadi anda?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Buka <xliff:g id="APP">%s</xliff:g> dalam profil kerja anda?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gunakan penyemak imbas peribadi"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Sertakan teks"</string> <string name="exclude_link" msgid="1332778255031992228">"Kecualikan pautan"</string> <string name="include_link" msgid="827855767220339802">"Sertakan pautan"</string> + <string name="pinned" msgid="7623664001331394139">"Disemat"</string> </resources> diff --git a/java/res/values-my/strings.xml b/java/res/values-my/strings.xml index af258250..10fb4b9b 100644 --- a/java/res/values-my/strings.xml +++ b/java/res/values-my/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{လင့်ခ်ပါသောဗီဒီယိုကို မျှဝေနေသည်}other{လင့်ခ်ပါသောဗီဒီယို # ခုကို မျှဝေနေသည်}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{စာသားပါသောဖိုင်ကို မျှဝေနေသည်}other{စာသားပါသောဖိုင် # ခုကို မျှဝေနေသည်}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{လင့်ခ်ပါသောဖိုင်ကို မျှဝေနေသည်}other{လင့်ခ်ပါသောဖိုင် # ခုကို မျှဝေနေသည်}}"</string> + <string name="sharing_album" msgid="191743129899503345">"အယ်လ်ဘမ် မျှဝေနေသည်"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ပုံသာလျှင်}other{ပုံများသာလျှင်}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ဗီဒီယိုသာလျှင်}other{ဗီဒီယိုများသာလျှင်}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ဖိုင်သာလျှင်}other{ဖိုင်များသာလျှင်}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ဤအက်ပ်ကို အသံဖမ်းခွင့် ပေးမထားသော်လည်း ၎င်းသည် ဤ USB စက်ပစ္စည်းမှတစ်ဆင့် အသံများကို ဖမ်းယူနိုင်ပါသည်။"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ကိုယ်ပိုင်"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"အလုပ်"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"သီးသန့်"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ပုဂ္ဂိုလ်ရေးဆိုင်ရာ မြင်ကွင်း"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"အလုပ် မြင်ကွင်း"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"သီးသန့်ပြသခြင်း"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"သင်၏ IT စီမံခန့်ခွဲသူက ပိတ်ထားသည်"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ဤအကြောင်းအရာကို အလုပ်သုံးအက်ပ်များဖြင့် မမျှဝေနိုင်ပါ"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ဤအကြောင်းအရာကို အလုပ်သုံးအက်ပ်များဖြင့် မဖွင့်နိုင်ပါ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ဤအကြောင်းအရာကို ကိုယ်ပိုင်အက်ပ်များဖြင့် မမျှဝေနိုင်ပါ"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ဤအကြောင်းအရာကို ကိုယ်ပိုင်အက်ပ်များဖြင့် မဖွင့်နိုင်ပါ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ဤအကြောင်းအရာကို သီးသန့်အက်ပ်များဖြင့် မမျှဝေနိုင်ပါ"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ဤအကြောင်းအရာကို သီးသန့်အက်ပ်များဖြင့် မဖွင့်နိုင်ပါ"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"အလုပ်သုံးအက်ပ်များကို ခေတ္တရပ်ထားသည်"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ပြန်ဖွင့်ရန်"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"အလုပ်သုံးအက်ပ်များ မရှိပါ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ကိုယ်ပိုင်အက်ပ်များ မရှိပါ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"သီးသန့်အက်ပ် မရှိပါ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> ကို သင့်ကိုယ်ပိုင်ပရိုဖိုင်တွင် ဖွင့်မလား။"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> ကို သင့်အလုပ်ပရိုဖိုင်တွင် ဖွင့်မလား။"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ကိုယ်ပိုင်ဘရောင်ဇာ သုံးရန်"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"စာသားထည့်သွင်းရန်"</string> <string name="exclude_link" msgid="1332778255031992228">"လင့်ခ် ဖယ်ထုတ်ရန်"</string> <string name="include_link" msgid="827855767220339802">"လင့်ခ်ထည့်သွင်းရန်"</string> + <string name="pinned" msgid="7623664001331394139">"ပင်ထိုးထားသည်"</string> </resources> diff --git a/java/res/values-nb/strings.xml b/java/res/values-nb/strings.xml index d6979daa..3aeaf799 100644 --- a/java/res/values-nb/strings.xml +++ b/java/res/values-nb/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deler videoen med link}other{Deler # videoer med link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deler filen med tekst}other{Deler # filer med tekst}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deler filen med link}other{Deler # filer med link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deler album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Bare bildet}other{Bare bildene}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Bare videoen}other{Bare videoene}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Bare filen}other{Bare filene}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Denne appen har ikke fått tillatelse til å spille inn, men kan ta opp lyd med denne USB-enheten."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personlig"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Jobb"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personlig visning"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Jobbvisning"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privat visning"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokkert av IT-administratoren din"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Dette innholdet kan ikke deles med jobbapper"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Dette innholdet kan ikke åpnes med jobbapper"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Dette innholdet kan ikke deles med personlige apper"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Dette innholdet kan ikke åpnes med personlige apper"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Dette innholdet kan ikke deles med private apper"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Dette innholdet kan ikke åpnes med private apper"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Jobbapper er satt på pause"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Slå av pausen"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ingen jobbapper"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Ingen personlige apper"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ingen private apper"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vil du åpne <xliff:g id="APP">%s</xliff:g> i den personlige profilen din?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vil du åpne <xliff:g id="APP">%s</xliff:g> i jobbprofilen din?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Bruk den personlige nettleseren"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Inkluder teksten"</string> <string name="exclude_link" msgid="1332778255031992228">"Ekskluder linken"</string> <string name="include_link" msgid="827855767220339802">"Inkluder linken"</string> + <string name="pinned" msgid="7623664001331394139">"Festet"</string> </resources> diff --git a/java/res/values-ne/strings.xml b/java/res/values-ne/strings.xml index b5533d2e..1c5ee2f7 100644 --- a/java/res/values-ne/strings.xml +++ b/java/res/values-ne/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{लिंक भएको भिडियो सेयर गरिँदै छ}other{लिंक भएका # वटा भिडियो सेयर गरिँदै छन्}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{टेक्स्ट भएको फाइल सेयर गरिँदै छ}other{टेक्स्ट भएका # वटा फाइल सेयर गरिँदै छन्}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{लिंक भएको फाइल सेयर गरिँदै छ}other{लिंक भएका # वटा फाइल सेयर गरिँदै छन्}}"</string> + <string name="sharing_album" msgid="191743129899503345">"एल्बम सेयर गरिँदै छ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{फोटो मात्र}other{फोटोहरू मात्र}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{भिडियो मात्र}other{भिडियोहरू मात्र}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{फाइल मात्र}other{फाइलहरू मात्र}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"यो एपलाई रेकर्ड गर्ने अनुमति प्रदान गरिएको छैन तर यसले यो USB यन्त्रमार्फत अडियो क्याप्चर गर्न सक्छ।"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"व्यक्तिगत"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"काम"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"निजी"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"व्यक्तिगत दृश्य"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"कार्य दृश्य"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"निजी भ्यू"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"तपाईंका IT एड्मिनले ब्लक गर्नुभएको छ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"यो सामग्री कामसम्बन्धी एपहरूमार्फत सेयर गर्न मिल्दैन"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"यो सामग्री कामसम्बन्धी एपहरूमार्फत खोल्न मिल्दैन"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"यो सामग्री व्यक्तिगत एपहरूमार्फत सेयर गर्न मिल्दैन"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"यो सामग्री व्यक्तिगत एपहरूमार्फत खोल्न मिल्दैन"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"यो सामग्री निजी एपहरूमार्फत सेयर गर्न मिल्दैन"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"यो सामग्री निजी एपहरूमार्फत खोल्न मिल्दैन"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"कामसम्बन्धी एपहरू पज गरिएका छन्"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"अनपज गर्नुहोस्"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"यो सामग्री खोल्न मिल्ने कुनै पनि कामसम्बन्धी एप छैन"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"यो सामग्री खोल्न मिल्ने कुनै पनि व्यक्तिगत एप छैन"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"कुनै पनि निजी एप छैन"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> तपाईंको व्यक्तिगत प्रोफाइलमा खोल्ने हो?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> तपाईंको कार्य प्रोफाइलमा खोल्ने हो?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"व्यक्तिगत ब्राउजर प्रयोग गर्नुहोस्"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"टेक्स्ट समावेश गर्नुहोस्"</string> <string name="exclude_link" msgid="1332778255031992228">"लिंक हटाउनुहोस्"</string> <string name="include_link" msgid="827855767220339802">"लिंक समावेश गर्नुहोस्"</string> + <string name="pinned" msgid="7623664001331394139">"पिन गरिएको"</string> </resources> diff --git a/java/res/values-night/styles.xml b/java/res/values-night/styles.xml new file mode 100644 index 00000000..95071bac --- /dev/null +++ b/java/res/values-night/styles.xml @@ -0,0 +1,22 @@ +<!-- + ~ Copyright (C) 2022 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<resources xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"> + + <style name="Theme.DeviceDefault.Resolver" parent="Theme.DeviceDefault.ResolverCommon"> + <item name="android:windowLightNavigationBar">false</item> + </style> +</resources> diff --git a/java/res/values-nl/strings.xml b/java/res/values-nl/strings.xml index e5977285..c77ee336 100644 --- a/java/res/values-nl/strings.xml +++ b/java/res/values-nl/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Video delen via link}other{# video\'s delen via link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Bestand delen via tekstbericht}other{# bestanden delen via tekstbericht}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Bestand delen via link}other{# bestanden delen via link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Album wordt gedeeld"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Alleen afbeelding}other{Alleen afbeeldingen}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Alleen video}other{Alleen video\'s}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Alleen bestand}other{Alleen bestanden}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Deze app heeft geen opnamerechten gekregen, maar zou audio kunnen vastleggen via dit USB-apparaat."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Persoonlijk"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Werk"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privé"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Persoonlijke weergave"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Werkweergave"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privéweergave"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Geblokkeerd door je IT-beheerder"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Deze content kan niet worden gedeeld met werk-apps"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Deze content kan niet worden geopend met werk-apps"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Deze content kan niet worden gedeeld met persoonlijke apps"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Deze content kan niet worden geopend met persoonlijke apps"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Deze content kan niet worden gedeeld met privé-apps"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Deze content kan niet worden geopend met privé-apps"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Werk-apps zijn gepauzeerd"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Hervatten"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Geen werk-apps"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Geen persoonlijke apps"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Geen privé-apps"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> openen in je persoonlijke profiel?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> openen in je werkprofiel?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Persoonlijke browser gebruiken"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Tekst opnemen"</string> <string name="exclude_link" msgid="1332778255031992228">"Link uitsluiten"</string> <string name="include_link" msgid="827855767220339802">"Link opnemen"</string> + <string name="pinned" msgid="7623664001331394139">"Vastgezet"</string> </resources> diff --git a/java/res/values-or/strings.xml b/java/res/values-or/strings.xml index a4eb4de4..228af287 100644 --- a/java/res/values-or/strings.xml +++ b/java/res/values-or/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ଲିଙ୍କ ସହ ଭିଡିଓ ସେୟାର କରାଯାଉଛି}other{ଲିଙ୍କ ସହ #ଟି ଭିଡିଓ ସେୟାର କରାଯାଉଛି}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ଟେକ୍ସଟ ସହ ଫାଇଲ ସେୟାର କରାଯାଉଛି}other{ଟେକ୍ସଟ ସହ #ଟି ଫାଇଲ ସେୟାର କରାଯାଉଛି}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ଲିଙ୍କ ସହ ଫାଇଲ ସେୟାର କରାଯାଉଛି}other{ଲିଙ୍କ ସହ #ଟି ଫାଇଲ ସେୟାର କରାଯାଉଛି}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ଆଲବମ ସେୟାର କରାଯାଉଛି"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{କେବଳ ଇମେଜ}other{କେବଳ ଇମେଜଗୁଡ଼ିକ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{କେବଳ ଭିଡିଓ}other{କେବଳ ଭିଡିଓଗୁଡ଼ିକ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{କେବଳ ଫାଇଲ}other{କେବଳ ଫାଇଲଗୁଡ଼ିକ}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ଏହି ଆପ୍କୁ ରେକର୍ଡ କରିବାକୁ ଅନୁମତି ଦିଆଯାଇ ନାହିଁ କିନ୍ତୁ ଏହି USB ଡିଭାଇସ୍ ଜରିଆରେ ଅଡିଓ କ୍ୟାପ୍ଚର୍ କରିପାରିବ।"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ବ୍ୟକ୍ତିଗତ"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ୱାର୍କ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ପ୍ରାଇଭେଟ"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ବ୍ୟକ୍ତିଗତ ଭ୍ୟୁ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ୱାର୍କ ଭ୍ୟୁ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ପ୍ରାଇଭେଟ ଭ୍ୟୁ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ଆପଣଙ୍କ IT ଆଡମିନଙ୍କ ଦ୍ୱାରା ବ୍ଲକ୍ କରାଯାଇଛି"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ଏହି ବିଷୟବସ୍ତୁ ୱାର୍କ ଆପଗୁଡ଼ିକରେ ସେୟାର୍ କରାଯାଇପାରିବ ନାହିଁ"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ଏହି ବିଷୟବସ୍ତୁ ୱାର୍କ ଆପଗୁଡ଼ିକରେ ଖୋଲାଯାଇପାରିବ ନାହିଁ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ଏହି ବିଷୟବସ୍ତୁ ବ୍ୟକ୍ତିଗତ ଆପଗୁଡ଼ିକରେ ସେୟାର୍ କରାଯାଇପାରିବ ନାହିଁ"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ଏହି ବିଷୟବସ୍ତୁ ବ୍ୟକ୍ତିଗତ ଆପଗୁଡ଼ିକରେ ଖୋଲାଯାଇପାରିବ ନାହିଁ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ଏହି ବିଷୟବସ୍ତୁକୁ ପ୍ରାଇଭେଟ ଆପ୍ସରେ ସେୟାର କରାଯାଇପାରିବ ନାହିଁ"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ଏହି ବିଷୟବସ୍ତୁକୁ ପ୍ରାଇଭେଟ ଆପ୍ସରେ ଖୋଲାଯାଇପାରିବ ନାହିଁ"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ୱାର୍କ ଆପ୍ସକୁ ବିରତ କରାଯାଇଛି"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ପୁଣି ଚାଲୁ କରନ୍ତୁ"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"କୌଣସି ୱାର୍କ ଆପ୍ ନାହିଁ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"କୌଣସି ବ୍ୟକ୍ତିଗତ ଆପ୍ ନାହିଁ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"କୌଣସି ପ୍ରାଇଭେଟ ଆପ୍ସ ନାହିଁ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g>କୁ ଆପଣଙ୍କ ବ୍ୟକ୍ତିଗତ ପ୍ରୋଫାଇଲରେ ଖୋଲିବେ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g>କୁ ଆପଣଙ୍କ ୱାର୍କ ପ୍ରୋଫାଇଲରେ ଖୋଲିବେ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ବ୍ୟକ୍ତିଗତ ବ୍ରାଉଜର୍ ବ୍ୟବହାର କରନ୍ତୁ"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"ଟେକ୍ସଟକୁ ଅନ୍ତର୍ଭୁକ୍ତ କରନ୍ତୁ"</string> <string name="exclude_link" msgid="1332778255031992228">"ଲିଙ୍କକୁ ବାଦ ଦିଅନ୍ତୁ"</string> <string name="include_link" msgid="827855767220339802">"ଲିଙ୍କକୁ ଅନ୍ତର୍ଭୁକ୍ତ କରନ୍ତୁ"</string> + <string name="pinned" msgid="7623664001331394139">"ପିନ କରାଯାଇଛି"</string> </resources> diff --git a/java/res/values-pa/strings.xml b/java/res/values-pa/strings.xml index d5477741..48b92189 100644 --- a/java/res/values-pa/strings.xml +++ b/java/res/values-pa/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{ਲਿੰਕ ਨਾਲ ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿੰਕ ਨਾਲ # ਵੀਡੀਓ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿੰਕ ਨਾਲ # ਵੀਡੀਓ ਸਾਂਝੇ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿਖਤ ਸੁਨੇਹੇ ਨਾਲ # ਫ਼ਾਈਲਾਂ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{ਲਿੰਕ ਨਾਲ ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}one{ਲਿੰਕ ਨਾਲ # ਫ਼ਾਈਲ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}other{ਲਿੰਕ ਨਾਲ # ਫ਼ਾਈਲਾਂ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ਐਲਬਮ ਨੂੰ ਸਾਂਝਾ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ਸਿਰਫ਼ ਚਿੱਤਰ}one{ਸਿਰਫ਼ ਚਿੱਤਰ}other{ਸਿਰਫ਼ ਚਿੱਤਰ}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ਸਿਰਫ਼ ਵੀਡੀਓ}one{ਸਿਰਫ਼ ਵੀਡੀਓ}other{ਸਿਰਫ਼ ਵੀਡੀਓ}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ਸਿਰਫ਼ ਫ਼ਾਈਲ}one{ਸਿਰਫ਼ ਫ਼ਾਈਲ}other{ਸਿਰਫ਼ ਫ਼ਾਈਲਾਂ}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ਇਸ ਐਪ ਨੂੰ ਰਿਕਾਰਡ ਕਰਨ ਦੀ ਇਜਾਜ਼ਤ ਨਹੀਂ ਦਿੱਤੀ ਗਈ ਪਰ ਇਹ USB ਡੀਵਾਈਸ ਰਾਹੀਂ ਆਡੀਓ ਕੈਪਚਰ ਕਰ ਸਕਦੀ ਹੈ।"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ਨਿੱਜੀ"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"ਕੰਮ ਸੰਬੰਧੀ"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ਪ੍ਰਾਈਵੇਟ"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ਵਿਅਕਤੀਗਤ ਦ੍ਰਿਸ਼"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ਕਾਰਜ ਦ੍ਰਿਸ਼"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ਨਿੱਜੀ ਦ੍ਰਿਸ਼"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ਤੁਹਾਡੇ ਆਈ.ਟੀ. ਪ੍ਰਸ਼ਾਸਕ ਵੱਲੋਂ ਬਲਾਕ ਕੀਤਾ ਗਿਆ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਕੰਮ ਸੰਬੰਧੀ ਐਪਾਂ ਨਾਲ ਸਾਂਝਾ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਕੰਮ ਸੰਬੰਧੀ ਐਪਾਂ ਨਾਲ ਨਹੀਂ ਖੋਲ੍ਹਿਆ ਜਾ ਸਕਦਾ"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਸਾਂਝਾ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਨਹੀਂ ਖੋਲ੍ਹਿਆ ਜਾ ਸਕਦਾ"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਸਾਂਝਾ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ਇਸ ਸਮੱਗਰੀ ਨੂੰ ਨਿੱਜੀ ਐਪਾਂ ਨਾਲ ਨਹੀਂ ਖੋਲ੍ਹਿਆ ਜਾ ਸਕਦਾ"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ਕੰਮ ਸੰਬੰਧੀ ਐਪਾਂ ਨੂੰ ਰੋਕਿਆ ਗਿਆ ਹੈ"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ਰੋਕ ਹਟਾਓ"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ਕੋਈ ਕੰਮ ਸੰਬੰਧੀ ਐਪ ਨਹੀਂ"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ਕੋਈ ਨਿੱਜੀ ਐਪ ਨਹੀਂ"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ਕੋਈ ਨਿੱਜੀ ਐਪ ਨਹੀਂ"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"ਕੀ ਆਪਣੇ ਨਿੱਜੀ ਪ੍ਰੋਫਾਈਲ ਵਿੱਚ <xliff:g id="APP">%s</xliff:g> ਨੂੰ ਖੋਲ੍ਹਣਾ ਹੈ?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"ਕੀ ਆਪਣੇ ਕਾਰਜ ਪ੍ਰੋਫਾਈਲ ਵਿੱਚ <xliff:g id="APP">%s</xliff:g> ਨੂੰ ਖੋਲ੍ਹਣਾ ਹੈ?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ਨਿੱਜੀ ਬ੍ਰਾਊਜ਼ਰ ਵਰਤੋ"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"ਲਿਖਤ ਨੂੰ ਸ਼ਾਮਲ ਕਰੋ"</string> <string name="exclude_link" msgid="1332778255031992228">"ਲਿੰਕ ਨੂੰ ਸ਼ਾਮਲ ਨਾ ਕਰੋ"</string> <string name="include_link" msgid="827855767220339802">"ਲਿੰਕ ਸ਼ਾਮਲ ਕਰੋ"</string> + <string name="pinned" msgid="7623664001331394139">"ਪਿੰਨ ਕੀਤਾ ਗਿਆ"</string> </resources> diff --git a/java/res/values-pl/strings.xml b/java/res/values-pl/strings.xml index 0d218a0f..92877b42 100644 --- a/java/res/values-pl/strings.xml +++ b/java/res/values-pl/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Udostępnianie filmu przez link}few{Udostępnianie # filmów przez link}many{Udostępnianie # filmów przez link}other{Udostępnianie # filmu przez link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Udostępnianie pliku przez SMS}few{Udostępnianie # plików przez SMS}many{Udostępnianie # plików przez SMS}other{Udostępnianie # pliku przez SMS}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Udostępnianie pliku przez link}few{Udostępnianie # plików przez link}many{Udostępnianie # plików przez link}other{Udostępnianie # pliku przez link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Udostępnij album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Tylko obraz}few{Tylko obrazy}many{Tylko obrazy}other{Tylko obrazy}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Tylko film}few{Tylko filmy}many{Tylko filmy}other{Tylko filmy}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Tylko plik}few{Tylko pliki}many{Tylko pliki}other{Tylko pliki}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ta aplikacja nie ma uprawnień do nagrywania, ale może rejestrować dźwięk za pomocą tego urządzenia USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Osobiste"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Służbowe"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Prywatne"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Widok osobisty"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Widok służbowy"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Widok prywatny"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Działanie zablokowane przez administratora IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tych treści nie można udostępniać w aplikacjach służbowych"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tych treści nie można otworzyć w aplikacjach służbowych"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tych treści nie można udostępniać w aplikacjach osobistych"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tych treści nie można otworzyć w aplikacjach osobistych"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tych treści nie można udostępniać w aplikacjach prywatnych"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tych treści nie można otworzyć w aplikacjach prywatnych"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplikacje służbowe są wstrzymane"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Cofnij wstrzymanie"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Brak aplikacji służbowych"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Brak aplikacji osobistych"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Brak prywatnych aplikacji"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Otworzyć aplikację <xliff:g id="APP">%s</xliff:g> w profilu osobistym?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Otworzyć aplikację <xliff:g id="APP">%s</xliff:g> w profilu służbowym?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Użyj przeglądarki osobistej"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Dołącz tekst"</string> <string name="exclude_link" msgid="1332778255031992228">"Wyklucz link"</string> <string name="include_link" msgid="827855767220339802">"Dołącz link"</string> + <string name="pinned" msgid="7623664001331394139">"Przypięte"</string> </resources> diff --git a/java/res/values-port/dimens.xml b/java/res/values-port/dimens.xml new file mode 100644 index 00000000..100a7e17 --- /dev/null +++ b/java/res/values-port/dimens.xml @@ -0,0 +1,18 @@ +<!-- + ~ Copyright (C) 2022 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<resources xmlns:android="http://schemas.android.com/apk/res/android"> + <dimen name="chooser_width">-1px</dimen> +</resources> diff --git a/java/res/values-pt-rBR/strings.xml b/java/res/values-pt-rBR/strings.xml index c7c8ecf8..b70ffae6 100644 --- a/java/res/values-pt-rBR/strings.xml +++ b/java/res/values-pt-rBR/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartilhando vídeo com link}one{Compartilhando # vídeo com link}many{Compartilhando # de vídeos com link}other{Compartilhando # vídeos com link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartilhando arquivo com texto}one{Compartilhando # arquivo com texto}many{Compartilhando # de arquivos com texto}other{Compartilhando # arquivos com texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartilhando arquivo com link}one{Compartilhando # arquivo com link}many{Compartilhando # de arquivos com link}other{Compartilhando # arquivos com link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Compartilhando álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Somente imagem}one{Somente imagem}many{Somente imagens}other{Somente imagens}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Somente vídeo}one{Somente vídeo}many{Somente vídeos}other{Somente vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Somente arquivo}one{Somente arquivo}many{Somente arquivos}other{Somente arquivos}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Este app não tem permissão de gravação, mas pode capturar áudio pelo dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Pessoal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabalho"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privado"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualização pessoal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualização de trabalho"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualização particular"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Compartilhamento bloqueado pelo administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Não é possível compartilhar esse conteúdo com apps de trabalho"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir esse conteúdo com apps de trabalho"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Não é possível compartilhar esse conteúdo com apps pessoais"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Não é possível abrir esse conteúdo com apps pessoais"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Não é possível compartilhar esse conteúdo com apps particulares"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Não é possível abrir esse conteúdo com apps particulares"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Os apps de trabalho foram pausados"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reativar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nenhum app de trabalho"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nenhum app pessoal"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Sem apps particulares"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil pessoal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil de trabalho?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar o navegador pessoal"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Incluir texto"</string> <string name="exclude_link" msgid="1332778255031992228">"Excluir link"</string> <string name="include_link" msgid="827855767220339802">"Incluir link"</string> + <string name="pinned" msgid="7623664001331394139">"Fixada"</string> </resources> diff --git a/java/res/values-pt-rPT/strings.xml b/java/res/values-pt-rPT/strings.xml index 0ed5cf88..46ad2a78 100644 --- a/java/res/values-pt-rPT/strings.xml +++ b/java/res/values-pt-rPT/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{A partilhar vídeo com link}many{A partilhar # vídeos com link}other{A partilhar # vídeos com link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{A partilhar ficheiro com texto}many{A partilhar # ficheiros com texto}other{A partilhar # ficheiros com texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{A partilhar ficheiro com link}many{A partilhar # ficheiros com link}other{A partilhar # ficheiros com link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Partilhar álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Apenas imagem}many{Apenas imagens}other{Apenas imagens}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Apenas vídeo}many{Apenas vídeos}other{Apenas vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Apenas ficheiro}many{Apenas ficheiros}other{Apenas ficheiros}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Esta app não recebeu autorização de gravação, mas pode capturar áudio através deste dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Pessoal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabalho"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privada"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Vista pessoal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Vista de trabalho"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Vista privada"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bloqueado pelo administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Não é possível partilhar este conteúdo com apps de trabalho"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir este conteúdo com apps de trabalho"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Não é possível partilhar este conteúdo com apps pessoais"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Não é possível abrir este conteúdo com apps pessoais"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Não é possível partilhar este conteúdo com apps privadas"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Não é possível abrir este conteúdo com apps privadas"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"As apps de trabalho estão pausadas"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Retomar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Sem apps de trabalho"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Sem apps pessoais"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nenhuma app privada"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Abrir a app <xliff:g id="APP">%s</xliff:g> no seu perfil pessoal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Abrir a app <xliff:g id="APP">%s</xliff:g> no seu perfil de trabalho?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar navegador pessoal"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Incluir texto"</string> <string name="exclude_link" msgid="1332778255031992228">"Excluir link"</string> <string name="include_link" msgid="827855767220339802">"Incluir link"</string> + <string name="pinned" msgid="7623664001331394139">"Afixada"</string> </resources> diff --git a/java/res/values-pt/strings.xml b/java/res/values-pt/strings.xml index c7c8ecf8..b70ffae6 100644 --- a/java/res/values-pt/strings.xml +++ b/java/res/values-pt/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Compartilhando vídeo com link}one{Compartilhando # vídeo com link}many{Compartilhando # de vídeos com link}other{Compartilhando # vídeos com link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Compartilhando arquivo com texto}one{Compartilhando # arquivo com texto}many{Compartilhando # de arquivos com texto}other{Compartilhando # arquivos com texto}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Compartilhando arquivo com link}one{Compartilhando # arquivo com link}many{Compartilhando # de arquivos com link}other{Compartilhando # arquivos com link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Compartilhando álbum"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Somente imagem}one{Somente imagem}many{Somente imagens}other{Somente imagens}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Somente vídeo}one{Somente vídeo}many{Somente vídeos}other{Somente vídeos}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Somente arquivo}one{Somente arquivo}many{Somente arquivos}other{Somente arquivos}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Este app não tem permissão de gravação, mas pode capturar áudio pelo dispositivo USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Pessoal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabalho"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privado"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Visualização pessoal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Visualização de trabalho"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Visualização particular"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Compartilhamento bloqueado pelo administrador de TI"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Não é possível compartilhar esse conteúdo com apps de trabalho"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Não é possível abrir esse conteúdo com apps de trabalho"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Não é possível compartilhar esse conteúdo com apps pessoais"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Não é possível abrir esse conteúdo com apps pessoais"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Não é possível compartilhar esse conteúdo com apps particulares"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Não é possível abrir esse conteúdo com apps particulares"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Os apps de trabalho foram pausados"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reativar"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nenhum app de trabalho"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nenhum app pessoal"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Sem apps particulares"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil pessoal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Abrir o app <xliff:g id="APP">%s</xliff:g> no seu perfil de trabalho?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Usar o navegador pessoal"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Incluir texto"</string> <string name="exclude_link" msgid="1332778255031992228">"Excluir link"</string> <string name="include_link" msgid="827855767220339802">"Incluir link"</string> + <string name="pinned" msgid="7623664001331394139">"Fixada"</string> </resources> diff --git a/java/res/values-ro/strings.xml b/java/res/values-ro/strings.xml index ed949a6a..8029c8b2 100644 --- a/java/res/values-ro/strings.xml +++ b/java/res/values-ro/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Se trimite videoclipul cu linkul}few{Se trimit # videoclipuri cu linkul}other{Se trimit # de videoclipuri cu linkul}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Se trimite fișierul cu text}few{Se trimit # fișiere cu text}other{Se trimit # de fișiere cu text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Se trimite fișierul cu linkul}few{Se trimit # fișiere cu linkul}other{Se trimit # de fișiere cu linkul}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Se permite accesul la album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Numai imaginea}few{Numai imaginile}other{Numai imaginile}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Numai videoclipul}few{Numai videoclipurile}other{Numai videoclipurile}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Numai fișierul}few{Numai fișierele}other{Numai fișierele}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Permisiunea de înregistrare nu a fost acordată aplicației, dar aceasta poate să înregistreze conținut audio prin intermediul acestui dispozitiv USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Serviciu"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Afișarea conținutului personal"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Afișarea conținutului de lucru"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Secțiunea Privat"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blocat de administratorul IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Acest conținut nu poate fi trimis cu aplicații pentru lucru"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Acest conținut nu poate fi deschis cu aplicații pentru lucru"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Acest conținut nu poate fi trimis către aplicații personale"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Acest conținut nu poate fi deschis cu aplicații personale"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Acest conținut nu poate fi trimis cu aplicații private"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Acest conținut nu poate fi deschis cu aplicații private"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplicațiile pentru lucru sunt întrerupte"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Reactivează"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nicio aplicație pentru lucru"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nicio aplicație personală"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nu există aplicații private"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Deschizi <xliff:g id="APP">%s</xliff:g> în profilul personal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Deschizi <xliff:g id="APP">%s</xliff:g> în profilul de serviciu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Folosește browserul personal"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Include textul"</string> <string name="exclude_link" msgid="1332778255031992228">"Exclude linkul"</string> <string name="include_link" msgid="827855767220339802">"Include linkul"</string> + <string name="pinned" msgid="7623664001331394139">"Fixat"</string> </resources> diff --git a/java/res/values-ru/strings.xml b/java/res/values-ru/strings.xml index 86cd0136..a868eb9a 100644 --- a/java/res/values-ru/strings.xml +++ b/java/res/values-ru/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Отправка видео со ссылкой}one{Отправка # видео со ссылкой}few{Отправка # видео со ссылкой}many{Отправка # видео со ссылкой}other{Отправка # видео со ссылкой}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Отправка файла с текстом}one{Отправка # файла с текстом}few{Отправка # файлов с текстом}many{Отправка # файлов с текстом}other{Отправка # файла с текстом}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Отправка файла со ссылкой}one{Отправка # файла со ссылкой}few{Отправка # файлов со ссылкой}many{Отправка # файлов со ссылкой}other{Отправка # файла со ссылкой}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Вы делитесь альбомом"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Только изображение}one{Только изображения}few{Только изображения}many{Только изображения}other{Только изображения}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Только видео}one{Только видео}few{Только видео}many{Только видео}other{Только видео}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Только файл}one{Только файлы}few{Только файлы}many{Только файлы}other{Только файлы}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Приложению не разрешено записывать звук, однако оно может делать это с помощью этого USB-устройства."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Личное"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Рабочее"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Частное пространство"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Просмотр личных данных"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Просмотр рабочих данных"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Частное пространство"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Заблокировано вашим администратором"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Этим контентом нельзя делиться с рабочими приложениями."</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Этот контент нельзя открыть в рабочем приложении."</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Этим контентом нельзя делиться с личными приложениями."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Этот контент нельзя открыть в личном приложении."</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Этим контентом нельзя делиться через частные приложения."</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Этот контент нельзя открыть в частном приложении."</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Рабочие приложения приостановлены"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Включить"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Не поддерживается рабочими приложениями."</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Не поддерживается личными приложениями."</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Частные приложения не поддерживаются."</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Открыть приложение \"<xliff:g id="APP">%s</xliff:g>\" в личном профиле?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Открыть приложение \"<xliff:g id="APP">%s</xliff:g>\" в рабочем профиле?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Использовать личный браузер"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Вернуть текст"</string> <string name="exclude_link" msgid="1332778255031992228">"Исключить ссылку"</string> <string name="include_link" msgid="827855767220339802">"Вернуть ссылку"</string> + <string name="pinned" msgid="7623664001331394139">"Закреплено"</string> </resources> diff --git a/java/res/values-si/strings.xml b/java/res/values-si/strings.xml index bb8a1911..0ddf7886 100644 --- a/java/res/values-si/strings.xml +++ b/java/res/values-si/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{සබැඳිය සමග වීඩියෝව බෙදා ගැනීම}one{සබැඳිය සමග වීඩියෝ #ක් බෙදා ගැනීම}other{සබැඳිය සමග වීඩියෝ #ක් බෙදා ගැනීම}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{පෙළ සමග ගොනුව බෙදා ගැනීම}one{පෙළ සමග ගොනු #ක් බෙදා ගැනීම}other{පෙළ සමග ගොනු #ක් බෙදා ගැනීම}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{සබැඳිය සමග ගොනුව බෙදා ගැනීම}one{සබැඳිය සමග ගොනු #ක් බෙදා ගැනීම}other{සබැඳිය සමග ගොනු #ක් බෙදා ගැනීම}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ඇල්බමය බෙදා ගැනීම"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{රූපය පමණි}one{රූප පමණි}other{රූප පමණි}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{වීඩියෝව පමණි}one{වීඩියෝ පමණි}other{වීඩියෝ පමණි}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ගොනුව පමණි}one{ගොනු පමණි}other{ගොනු පමණි}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"මෙම යෙදුමට පටිගත කිරීම් අවසරයක් ලබා දී නොමැති නමුත් මෙම USB උපාංගය හරහා ශ්රව්ය ග්රහණය කර ගත හැකිය."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"පුද්ගලික"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"කාර්යාල"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"පෞද්ගලික"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"පෞද්ගලික දසුන"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"කාර්යාල දසුන"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"පෞද්ගලික දසුන"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ඔබගේ IT පරිපාලක විසින් අවහිර කර ඇත"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"මෙම අන්තර්ගතය කාර්යාල යෙදුම් සමඟ බෙදා ගත නොහැකිය"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"මෙම අන්තර්ගතය කාර්යාල යෙදුම් සමඟ විවෘත කළ නොහැකිය"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් සමඟ බෙදා ගත නොහැකිය"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් සමඟ විවෘත කළ නොහැකිය"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් මගින් බෙදා ගත නොහැක"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"මෙම අන්තර්ගතය පුද්ගලික යෙදුම් මගින් විවෘත කළ නොහැක"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"කාර්යාල යෙදුම් විරාම කර ඇත"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"විරාම නොකරන්න"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"කාර්යාල යෙදුම් නැත"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"පුද්ගලික යෙදුම් නැත"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"පුද්ගලික යෙදුම් නැත"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> ඔබගේ පුද්ගලික පැතිකඩ තුළ විවෘත කරන්නද?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> ඔබගේ කාර්යාල පැතිකඩ තුළ විවෘත කරන්නද?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"පුද්ගලික බ්රව්සරය භාවිත කරන්න"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"පාඨය ඇතළත් කරන්න"</string> <string name="exclude_link" msgid="1332778255031992228">"සබැඳිය බැහැර කරන්න"</string> <string name="include_link" msgid="827855767220339802">"සබැඳිය ඇතුළත් කරන්න"</string> + <string name="pinned" msgid="7623664001331394139">"අමුණා ඇත"</string> </resources> diff --git a/java/res/values-sk/strings.xml b/java/res/values-sk/strings.xml index ed0f74df..b76a5dc2 100644 --- a/java/res/values-sk/strings.xml +++ b/java/res/values-sk/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Zdieľa sa video s odkazom}few{Zdieľajú sa # videá s odkazom}many{Sharing # videos with link}other{Zdieľa sa # videí s odkazom}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Zdieľa sa súbor s textom}few{Zdieľajú sa # súbory s textom}many{Sharing # files with text}other{Zdieľa sa # súborov s textom}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Zdieľa sa súbor s odkazom}few{Zdieľajú sa # súbory s odkazom}many{Sharing # files with link}other{Zdieľa sa # súborov s odkazom}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Zdieľanie albumu"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Iba obrázok}few{Iba obrázky}many{Iba obrázky}other{Iba obrázky}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Iba video}few{Iba videá}many{Iba videá}other{Iba videá}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Iba súbor}few{Iba súbory}many{Iba súbory}other{Iba súbory}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Tejto aplikácii nebolo udelené povolenie na nahrávanie, ale môže nasnímať zvuk cez toto zariadenie USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Osobné"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Pracovné"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Súkromné"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Osobné zobrazenie"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pracovné zobrazenie"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Súkromné zobrazenie"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokované vaším správcom IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Tento obsah sa nedá zdieľať pomocou pracovných aplikácií"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Tento obsah sa nedá otvoriť pomocou pracovných aplikácií"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Tento obsah sa nedá zdieľať pomocou osobných aplikácií"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Tento obsah sa nedá otvoriť pomocou osobných aplikácií"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Tento obsah sa nedá zdieľať pomocou súkromných aplikácií"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Tento obsah sa nedá otvoriť pomocou súkromných aplikácií"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Pracovné aplikácie sú pozastavené"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Zrušiť pozastavenie"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Žiadne pracovné aplikácie"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Žiadne osobné aplikácie"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Žiadne súkromné aplikácie"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Chcete otvoriť <xliff:g id="APP">%s</xliff:g> v osobnom profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Chcete otvoriť <xliff:g id="APP">%s</xliff:g> v pracovnom profile?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Použiť osobný prehliadač"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Zahrnúť text"</string> <string name="exclude_link" msgid="1332778255031992228">"Vylúčiť odkaz"</string> <string name="include_link" msgid="827855767220339802">"Zahrnúť odkaz"</string> + <string name="pinned" msgid="7623664001331394139">"Pripnuté"</string> </resources> diff --git a/java/res/values-sl/strings.xml b/java/res/values-sl/strings.xml index dc2442e7..c68eb0a2 100644 --- a/java/res/values-sl/strings.xml +++ b/java/res/values-sl/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Deljenje videoposnetka s povezavo}one{Deljenje # videoposnetka s povezavo}two{Deljenje # videoposnetkov s povezavo}few{Deljenje # videoposnetkov s povezavo}other{Deljenje # videoposnetkov s povezavo}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Deljenje datoteke z besedilom}one{Deljenje # datoteke z besedilom}two{Deljenje # datotek z besedilom}few{Deljenje # datotek z besedilom}other{Deljenje # datotek z besedilom}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Deljenje datoteke s povezavo}one{Deljenje # datoteke s povezavo}two{Deljenje # datotek s povezavo}few{Deljenje # datotek s povezavo}other{Deljenje # datotek s povezavo}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Deljenje albuma"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Samo slika}one{Samo slike}two{Samo slike}few{Samo slike}other{Samo slike}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Samo videoposnetek}one{Samo videoposnetki}two{Samo videoposnetki}few{Samo videoposnetki}other{Samo videoposnetki}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Samo datoteka}one{Samo datoteke}two{Samo datoteke}few{Samo datoteke}other{Samo datoteke}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ta aplikacija sicer nima dovoljenja za snemanje, vendar bi lahko zajemala zvok prek te naprave USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Osebno"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Delo"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Zasebno"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Pogled osebnega profila"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pogled delovnega profila"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Zasebni pogled"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blokiral skrbnik za IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Te vsebine ni mogoče deliti z delovnimi aplikacijami."</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Te vsebine ni mogoče odpreti z delovnimi aplikacijami."</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Te vsebine ni mogoče deliti z osebnimi aplikacijami."</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Te vsebine ni mogoče odpreti z osebnimi aplikacijami."</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Te vsebine ni mogoče deliti z zasebnimi aplikacijami"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Te vsebine ni mogoče odpreti z zasebnimi aplikacijami"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Delovne aplikacije so začasno zaustavljene"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Znova aktiviraj"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nobena delovna aplikacija ni na voljo"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nobena osebna aplikacija"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Ni zasebnih aplikacij"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Želite aplikacijo <xliff:g id="APP">%s</xliff:g> odpreti v osebnem profilu?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Želite aplikacijo <xliff:g id="APP">%s</xliff:g> odpreti v delovnem profilu?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Uporabi osebni brskalnik"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Vključi besedilo"</string> <string name="exclude_link" msgid="1332778255031992228">"Izloči povezavo"</string> <string name="include_link" msgid="827855767220339802">"Vključi povezavo"</string> + <string name="pinned" msgid="7623664001331394139">"Pripeto"</string> </resources> diff --git a/java/res/values-sq/strings.xml b/java/res/values-sq/strings.xml index 596efea4..68a28543 100644 --- a/java/res/values-sq/strings.xml +++ b/java/res/values-sq/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Po ndahet një video me lidhje}other{Po ndahen # video me lidhje}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Po ndahet një skedar me tekst}other{Po ndahen # skedarë me tekst}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Po ndahet një skedar me lidhje}other{Po ndahen # skedarë me lidhje}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albumi po ndahet"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Vetëm imazhi}other{Vetëm imazhet}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Vetëm videoja}other{Vetëm videot}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Vetëm skedari}other{Vetëm skedarët}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Këtij aplikacioni nuk i është dhënë leje për regjistrim, por mund të regjistrojë audio përmes kësaj pajisjeje USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Puna"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Private"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Pamja personale"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Pamja e punës"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Pamja private"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bllokuar nga administratori yt i teknologjisë së informacionit"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Kjo përmbajtje nuk mund të ndahet me aplikacione pune"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Kjo përmbajtje nuk mund të hapet me aplikacione pune"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Kjo përmbajtje nuk mund të ndahet me aplikacione personale"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Kjo përmbajtje nuk mund të hapet me aplikacione personale"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Kjo përmbajtje nuk mund të ndahet me aplikacione private"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Kjo përmbajtje nuk mund të hapet me aplikacione private"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Aplikacionet e punës janë vendosur në pauzë"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Hiq nga pauza"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Nuk ka aplikacione pune"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Nuk ka aplikacione personale"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Nuk ka aplikacione private"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Të hapet <xliff:g id="APP">%s</xliff:g> në profilin tënd personal?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Të hapet <xliff:g id="APP">%s</xliff:g> në profilin tënd të punës?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Përdor shfletuesin personal"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Përfshi tekstin"</string> <string name="exclude_link" msgid="1332778255031992228">"Përjashto lidhjen"</string> <string name="include_link" msgid="827855767220339802">"Përfshi lidhjen"</string> + <string name="pinned" msgid="7623664001331394139">"U gozhdua"</string> </resources> diff --git a/java/res/values-sr/strings.xml b/java/res/values-sr/strings.xml index 71d32f23..a74fa350 100644 --- a/java/res/values-sr/strings.xml +++ b/java/res/values-sr/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Дели се видео са линком}one{Дели се # видео са линком}few{Деле се # видео снимка са линком}other{Дели се # видеа са линком}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Дели се фајл са текстом}one{Дели се # фајл са текстом}few{Деле се # фајла са текстом}other{Дели се # фајлова са текстом}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Дели се фајл са линком}one{Дели се # фајл са линком}few{Деле се # фајла са линком}other{Дели се # фајлова са линком}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Дељени албум"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Само слика}one{Само слике}few{Само слике}other{Само слике}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Само видео}one{Само видеи}few{Само видеи}other{Само видеи}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Само фајл}one{Само фајлови}few{Само фајлови}other{Само фајлови}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ова апликација нема дозволу за снимање, али би могла да снима звук помоћу овог USB уређаја."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Лично"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Пословно"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Приватно"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Лични приказ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Приказ за посао"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Приватни приказ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Блокира ИТ администратор"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Овај садржај не може да се дели помоћу пословних апликација"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Овај садржај не може да се отвара помоћу пословних апликација"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Овај садржај не може да се дели помоћу личних апликација"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Овај садржај не може да се отвара помоћу личних апликација"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Овај садржај не може да се дели помоћу приватних апликација"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Овај садржај не може да се отвори помоћу приватних апликација"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Пословне апликације су паузиране"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Поново активирај"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Нема пословних апликација"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Нема личних апликација"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Без приватних апликација"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Желите да на личном профилу отворите: <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Желите да на пословном профилу отворите: <xliff:g id="APP">%s</xliff:g>?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Користи лични прегледач"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Уврсти текст"</string> <string name="exclude_link" msgid="1332778255031992228">"Изузми линк"</string> <string name="include_link" msgid="827855767220339802">"Уврсти линк"</string> + <string name="pinned" msgid="7623664001331394139">"Закачено"</string> </resources> diff --git a/java/res/values-sv/strings.xml b/java/res/values-sv/strings.xml index 21a286d4..3f57f368 100644 --- a/java/res/values-sv/strings.xml +++ b/java/res/values-sv/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Delar video med länk}other{Delar # videor med länk}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Delar fil med text}other{Delar # filer med text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Delar fil med länk}other{Delar # filer med länk}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Delar album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Endast bild}other{Endast bilder}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Endast video}other{Endast videor}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Endast fil}other{Endast filer}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Appen har inte fått inspelningsbehörighet men kan spela in ljud via denna USB-enhet."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Privat"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Jobb"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Privat"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personlig vy"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Jobbvy"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Privat vy"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Blockeras av IT-administratören"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Det här innehållet kan inte delas med jobbappar"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Det här innehållet kan inte öppnas med jobbappar"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Det här innehållet kan inte delas med privata appar"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Det här innehållet kan inte öppnas med privata appar"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Det här innehållet kan inte delas med privata appar"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Det här innehållet kan inte öppnas med privata appar"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Jobbappar har pausats"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Återuppta"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Inga jobbappar"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Inga privata appar"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Inga privata appar"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vill du öppna <xliff:g id="APP">%s</xliff:g> i din privata profil?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vill du öppna <xliff:g id="APP">%s</xliff:g> i din jobbprofil?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Använd privat webbläsare"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Inkludera text"</string> <string name="exclude_link" msgid="1332778255031992228">"Uteslut länk"</string> <string name="include_link" msgid="827855767220339802">"Inkludera länk"</string> + <string name="pinned" msgid="7623664001331394139">"Fäst"</string> </resources> diff --git a/java/res/values-sw/strings.xml b/java/res/values-sw/strings.xml index d182ccd4..b29737c8 100644 --- a/java/res/values-sw/strings.xml +++ b/java/res/values-sw/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Inashiriki video na kiungo}other{Inashiriki video # na kiungo}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Inashiriki faili na maandishi}other{Inashiriki faili # na maandishi}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Inashiriki faili na kiungo}other{Inashiriki faili # na kiungo}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Kutumia albamu pamoja"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Picha pekee}other{Picha pekee}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video pekee}other{Video pekee}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Faili pekee}other{Faili pekee}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Programu hii haijapewa ruhusa ya kurekodi lakini inaweza kurekodi sauti kupitia kifaa hiki cha USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Binafsi"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Kazini"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Faragha"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Mwonekano wa binafsi"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Mwonekano wa kazini"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Mwonekano wa faragha"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Imezuiwa na msimamizi wako wa Tehama"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Huwezi kushiriki maudhui haya na programu za kazini"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Huwezi kufungua maudhui haya ukitumia programu za kazini"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Huwezi kushiriki maudhui haya na programu za binafsi"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Huwezi kufungua maudhui haya ukitumia programu za binafsi"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Programu za faragha haziruhusiwi kufikia maudhui haya"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Huwezi kufungua maudhui haya ukitumia programu za faragha"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Programu za kazini zimesitishwa"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Acha kusitisha"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Hakuna programu za kazini"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Hakuna programu za binafsi"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Hakuna programu za faragha"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Je, unataka kufungua <xliff:g id="APP">%s</xliff:g> katika wasifu wako binafsi?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Je, unataka kufungua <xliff:g id="APP">%s</xliff:g> katika wasifu wako wa kazi?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Tumia kivinjari cha binafsi"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Jumuisha maandishi"</string> <string name="exclude_link" msgid="1332778255031992228">"Usijumuishe kiungo"</string> <string name="include_link" msgid="827855767220339802">"Jumuisha kiungo"</string> + <string name="pinned" msgid="7623664001331394139">"Imebandikwa"</string> </resources> diff --git a/java/res/values-ta/strings.xml b/java/res/values-ta/strings.xml index 43525f97..3d0333fd 100644 --- a/java/res/values-ta/strings.xml +++ b/java/res/values-ta/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{இணைப்புடன் வீடியோவைப் பகிர்கிறது}other{இணைப்புடன் # வீடியோக்களைப் பகிர்கிறது}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{வார்த்தைகளைக் கொண்ட ஃபைலைப் பகிர்கிறது}other{வார்த்தைகளைக் கொண்ட # ஃபைல்களைப் பகிர்கிறது}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{இணைப்பைக் கொண்ட ஃபைலைப் பகிர்கிறது}other{இணைப்பைக் கொண்ட # ஃபைல்களைப் பகிர்கிறது}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ஆல்பத்தைப் பகிர்தல்"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{படம் மட்டும்}other{படங்கள் மட்டும்}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{வீடியோ மட்டும்}other{வீடியோக்கள் மட்டும்}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ஃபைல் மட்டும்}other{ஃபைல்கள் மட்டும்}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"இந்த ஆப்ஸிற்கு ரெக்கார்டு செய்வதற்கான அனுமதி வழங்கப்படவில்லை, எனினும் இந்த USB சாதனம் மூலம் ஆடியோவைப் பதிவுசெய்ய முடியும்."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"தனிப்பட்ட சுயவிவரம்"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"பணிச் சுயவிவரம்"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ரகசியம்"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"தனிப்பட்ட காட்சி"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"பணிக் காட்சி"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ரகசியக் காட்சி"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"இதை உங்கள் IT நிர்வாகி தடைசெய்துள்ளார்"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"பணி ஆப்ஸுடன் இந்த உள்ளடக்கத்தைப் பகிர முடியாது"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"பணி ஆப்ஸ் மூலம் இந்த உள்ளடக்கத்தைத் திறக்க முடியாது"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"தனிப்பட்ட ஆப்ஸுடன் இந்த உள்ளடக்கத்தைப் பகிர முடியாது"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"தனிப்பட்ட ஆப்ஸ் மூலம் இந்த உள்ளடக்கத்தைத் திறக்க முடியாது"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"தனிப்பட்ட ஆப்ஸுடன் இந்த உள்ளடக்கத்தைப் பகிர முடியாது"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"தனிப்பட்ட ஆப்ஸ் மூலம் இந்த உள்ளடக்கத்தைத் திறக்க முடியாது"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"பணி ஆப்ஸ் இடைநிறுத்தப்பட்டுள்ளன"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"மீண்டும் இயக்கு"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"பணி ஆப்ஸ் எதுவுமில்லை"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"தனிப்பட்ட ஆப்ஸ் எதுவுமில்லை"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"தனிப்பட்ட ஆப்ஸ் எதுவுமில்லை"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"உங்கள் தனிப்பட்ட கணக்கில் <xliff:g id="APP">%s</xliff:g> ஆப்ஸைத் திறக்கவா?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"உங்கள் பணிக் கணக்கில் <xliff:g id="APP">%s</xliff:g> ஆப்ஸைத் திறக்கவா?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"தனிப்பட்ட உலாவியைப் பயன்படுத்து"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"வார்த்தைகளைச் சேர்"</string> <string name="exclude_link" msgid="1332778255031992228">"இணைப்பைத் தவிர்"</string> <string name="include_link" msgid="827855767220339802">"இணைப்பைச் சேர்"</string> + <string name="pinned" msgid="7623664001331394139">"பின் செய்யப்பட்டுள்ளது"</string> </resources> diff --git a/java/res/values-te/strings.xml b/java/res/values-te/strings.xml index 9a6a6ec2..0ddb058b 100644 --- a/java/res/values-te/strings.xml +++ b/java/res/values-te/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{లింక్ చేయడం ద్వారా వీడియోను షేర్ చేయడం}other{లింక్ చేయడం ద్వారా # వీడియోలను షేర్ చేయడం}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా ఫైల్ను షేర్ చేయడం}other{టెక్స్ట్ మెసేజ్ పంపడం ద్వారా # ఫైల్స్ను షేర్ చేయడం}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{లింక్ చేయడం ద్వారా ఫైల్ను షేర్ చేయడం}other{లింక్ చేయడం ద్వారా # ఫైల్స్ను షేర్ చేయడం}}"</string> + <string name="sharing_album" msgid="191743129899503345">"ఆల్బమ్ షేర్ చేయబడుతోంది"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{ఇమేజ్ మాత్రమే}other{ఇమేజ్లు మాత్రమే}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{వీడియో మాత్రమే}other{వీడియోలు మాత్రమే}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ఫైల్ మాత్రమే}other{ఫైళ్లు మాత్రమే}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"ఈ యాప్కు రికార్డ్ చేసే అనుమతి మంజూరు కాలేదు, అయినా ఈ USB పరికరం ద్వారా ఆడియోను క్యాప్చర్ చేయగలదు."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"వ్యక్తిగతం"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"వర్క్ ప్లేస్"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ప్రైవేట్"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"వ్యక్తిగత వీక్షణ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"పని వీక్షణ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"ప్రైవేట్ వీక్షణ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"మీ IT అడ్మిన్ ద్వారా బ్లాక్ చేయబడింది"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"ఈ కంటెంట్ వర్క్ యాప్తో షేర్ చేయడం సాధ్యం కాదు"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"ఈ కంటెంట్ వర్క్ యాప్తో తెరవడం సాధ్యం కాదు"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"ఈ కంటెంట్ను వ్యక్తిగత యాప్స్ లోకి షేర్ చేయడం సాధ్యం కాదు"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"ఈ కంటెంట్ వ్యక్తిగత యాప్తో తెరవడం సాధ్యం కాదు"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"ఈ కంటెంట్ను ప్రైవేట్ యాప్లతో షేర్ చేయడం సాధ్యం కాదు"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"ఈ కంటెంట్ను ప్రైవేట్ యాప్లతో తెరవడం సాధ్యం కాదు"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"వర్క్ యాప్లు పాజ్ చేయబడ్డాయి"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"అన్పాజ్ చేయండి"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"వర్క్ యాప్లు లేవు"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"వ్యక్తిగత యాప్లు లేవు"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ప్రైవేట్ యాప్లు ఏవీ లేవు"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g>ను మీ వ్యక్తిగత ప్రొఫైల్లో తెరవాలా?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g>ను మీ వర్క్ ప్రొఫైల్లో తెరవాలా?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"వ్యక్తిగత బ్రౌజర్ను ఉపయోగించండి"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"టెక్స్ట్ను చేర్చండి"</string> <string name="exclude_link" msgid="1332778255031992228">"లింక్ను మినహాయించండి"</string> <string name="include_link" msgid="827855767220339802">"లింక్ను చేర్చండి"</string> + <string name="pinned" msgid="7623664001331394139">"పిన్ చేయబడింది"</string> </resources> diff --git a/java/res/values-th/strings.xml b/java/res/values-th/strings.xml index 70b7c8f5..1c3e5f86 100644 --- a/java/res/values-th/strings.xml +++ b/java/res/values-th/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{กำลังแชร์วิดีโอพร้อมลิงก์}other{กำลังแชร์วิดีโอ # รายการพร้อมลิงก์}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{กำลังแชร์ไฟล์พร้อมข้อความ}other{กำลังแชร์ไฟล์ # รายการพร้อมข้อความ}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{กำลังแชร์ไฟล์พร้อมลิงก์}other{กำลังแชร์ไฟล์ # รายการพร้อมลิงก์}}"</string> + <string name="sharing_album" msgid="191743129899503345">"กำลังแชร์อัลบั้ม"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{รูปภาพเท่านั้น}other{รูปภาพเท่านั้น}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{วิดีโอเท่านั้น}other{วิดีโอเท่านั้น}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{ไฟล์เท่านั้น}other{ไฟล์เท่านั้น}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"แอปนี้ไม่ได้รับอนุญาตให้บันทึกเสียงแต่อาจเก็บเสียงผ่านอุปกรณ์ USB นี้ได้"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ส่วนตัว"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"งาน"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"ส่วนตัว"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"มุมมองส่วนตัว"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"ดูงาน"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"มุมมองส่วนตัว"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"ผู้ดูแลระบบไอทีบล็อกไว้"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"แชร์เนื้อหานี้โดยใช้แอปงานไม่ได้"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"เปิดเนื้อหานี้โดยใช้แอปงานไม่ได้"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"แชร์เนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"เปิดเนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"แชร์เนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"เปิดเนื้อหานี้โดยใช้แอปส่วนตัวไม่ได้"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"แอปงานหยุดชั่วคราว"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"ยกเลิกการหยุดชั่วคราว"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"ไม่มีแอปงาน"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"ไม่มีแอปส่วนตัว"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"ไม่มีแอปส่วนตัว"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"เปิด <xliff:g id="APP">%s</xliff:g> ในโปรไฟล์ส่วนตัวไหม"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"เปิด <xliff:g id="APP">%s</xliff:g> ในโปรไฟล์งานไหม"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ใช้เบราว์เซอร์ส่วนตัว"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"รวมข้อความ"</string> <string name="exclude_link" msgid="1332778255031992228">"ไม่รวมลิงก์"</string> <string name="include_link" msgid="827855767220339802">"รวมลิงก์"</string> + <string name="pinned" msgid="7623664001331394139">"ปักหมุดไว้"</string> </resources> diff --git a/java/res/values-tl/strings.xml b/java/res/values-tl/strings.xml index 8e8cc62b..f845ae41 100644 --- a/java/res/values-tl/strings.xml +++ b/java/res/values-tl/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Nagbabahagi ng video na may link}one{Nagbabahagi ng # video na may link}other{Nagbabahagi ng # na video na may link}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Nagbabahagi ng file na may text}one{Nagbabahagi ng # file na may text}other{Nagbabahagi ng # na file na may text}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Nagbabahagi ng file na may link}one{Nagbabahagi ng # file na may link}other{Nagbabahagi ng # na file na may link}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Ibinabahagi ang album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Larawan lang}one{Mga larawan lang}other{Mga larawan lang}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Video lang}one{Mga video lang}other{Mga video lang}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{File lang}one{Mga file lang}other{Mga file lang}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Hindi nabigyan ng pahintulot ang app na ito para mag-record pero nakakapag-capture ito ng audio sa pamamagitan ng USB device na ito."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Personal"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Trabaho"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Pribado"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Personal na view"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"View ng trabaho"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Pribadong view"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Na-block ng iyong IT admin"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Hindi puwedeng ibahagi sa mga app para sa trabaho ang content na ito"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Hindi puwedeng buksan sa mga app para sa trabaho ang content na ito"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Hindi puwedeng ibahagi sa mga personal na app ang content na ito"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Hindi puwedeng buksan sa mga personal na app ang content na ito"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Hindi maibabahagi ang content na ito sa mga pribadong app"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Hindi mabubuksan ang content na ito gamit ang mga pribadong app"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Naka-pause ang mga app para sa trabaho"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"I-unpause"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Walang app para sa trabaho"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Walang personal na app"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Walang pribadong app"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Buksan ang <xliff:g id="APP">%s</xliff:g> sa iyong personal na profile?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Buksan ang <xliff:g id="APP">%s</xliff:g> sa iyong profile sa trabaho?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Gamitin ang personal na browser"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Isama ang text"</string> <string name="exclude_link" msgid="1332778255031992228">"Huwag isama ang link"</string> <string name="include_link" msgid="827855767220339802">"Isama ang link"</string> + <string name="pinned" msgid="7623664001331394139">"Naka-pin"</string> </resources> diff --git a/java/res/values-tr/strings.xml b/java/res/values-tr/strings.xml index 83c51cb3..e46c3e43 100644 --- a/java/res/values-tr/strings.xml +++ b/java/res/values-tr/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Bağlantı ekli video paylaşılıyor}other{Bağlantı ekli # video paylaşılıyor}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Metin ekli dosya paylaşılıyor}other{Metin ekli # dosya paylaşılıyor}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Bağlantı ekli dosya paylaşılıyor}other{Bağlantı ekli # dosya paylaşılıyor}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albüm paylaşılıyor"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Yalnızca resim}other{Yalnızca resimler}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Yalnızca video}other{Yalnızca videolar}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Yalnızca dosya}other{Yalnızca dosyalar}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Bu uygulamaya ses kaydetme izni verilmedi ancak bu USB cihazı üzerinden sesleri yakalayabilir."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Kişisel"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"İş"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Gizli"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Kişisel görünüm"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"İş görünümü"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Gizli görünüm"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"BT yöneticiniz tarafından engellendi"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bu içerik, iş uygulamalarıyla paylaşılamaz"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu içerik, iş uygulamalarıyla açılamaz"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bu içerik, kişisel uygulamalarla paylaşılamaz"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bu içerik, kişisel uygulamalarla açılamaz"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Bu içerik, özel uygulamalarla paylaşılamaz"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Bu içerik, özel uygulamalarla açılamaz"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"İş uygulamaları duraklatıldı"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Devam ettir"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"İş uygulaması yok"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Kişisel uygulama yok"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Özel uygulama yok"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> uygulaması kişisel profilinizde açılsın mı?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> uygulaması iş profilinizde açılsın mı?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Kişisel tarayıcıyı kullan"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Metni dahil et"</string> <string name="exclude_link" msgid="1332778255031992228">"Bağlantıyı hariç tut"</string> <string name="include_link" msgid="827855767220339802">"Bağlantıyı dahil et"</string> + <string name="pinned" msgid="7623664001331394139">"Sabitlendi"</string> </resources> diff --git a/java/res/values-uk/strings.xml b/java/res/values-uk/strings.xml index 46573598..f9635aea 100644 --- a/java/res/values-uk/strings.xml +++ b/java/res/values-uk/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Надсилання відео з посиланням}one{Надсилання # відео з посиланням}few{Надсилання # відео з посиланням}many{Надсилання # відео з посиланням}other{Надсилання # відео з посиланням}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Надсилання файлу з текстом}one{Надсилання # файлу з текстом}few{Надсилання # файлів із текстом}many{Надсилання # файлів із текстом}other{Надсилання # файлу з текстом}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Надсилання файлу з посиланням}one{Надсилання # файлу з посиланням}few{Надсилання # файлів із посиланням}many{Надсилання # файлів із посиланням}other{Надсилання # файлу з посиланням}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Надання спільного доступу до альбома"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Лише зображення}one{Лише зображення}few{Лише зображення}many{Лише зображення}other{Лише зображення}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Лише відео}one{Лише відео}few{Лише відео}many{Лише відео}other{Лише відео}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Лише файл}one{Лише файли}few{Лише файли}many{Лише файли}other{Лише файли}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Цей додаток не має дозволу на запис, але він може фіксувати звук через цей USB-пристрій."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Особисте"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Робоче"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Приватний простір"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Особистий перегляд"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Робочий перегляд"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Приватний перегляд"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Заблоковано адміністратором"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Цим контентом не можна ділитися в робочих додатках"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Цей контент не можна відкривати в робочих додатках"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Цим контентом не можна ділитися в особистих додатках"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Цей контент не можна відкривати в особистих додатках"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Цим контентом не можна ділитися в приватних додатках"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Цей контент не можна відкривати в приватних додатках"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Робочі додатки призупинено"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Увімкнути знову"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Немає робочих додатків"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Немає особистих додатків"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Немає приватних додатків"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Відкрити додаток <xliff:g id="APP">%s</xliff:g> в особистому профілі?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Відкрити додаток <xliff:g id="APP">%s</xliff:g> у робочому профілі?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Використати особистий веб-переглядач"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Додати текст"</string> <string name="exclude_link" msgid="1332778255031992228">"Вилучити посилання"</string> <string name="include_link" msgid="827855767220339802">"Додати посилання"</string> + <string name="pinned" msgid="7623664001331394139">"Закріплено"</string> </resources> diff --git a/java/res/values-ur/strings.xml b/java/res/values-ur/strings.xml index daec2511..858ca9c7 100644 --- a/java/res/values-ur/strings.xml +++ b/java/res/values-ur/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{لنک کے ساتھ ویڈیو کا اشتراک کیا جا رہا ہے}other{لنک کے ساتھ # ویڈیوز کا اشتراک کیا جا رہا ہے}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{ٹیکسٹ کے ساتھ فائل کا اشتراک کیا جا رہا ہے}other{ٹیکسٹ کے ساتھ # فائلز کا اشتراک کیا جا رہا ہے}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{لنک کے ساتھ فائل کا اشتراک کیا جا رہا ہے}other{لنک کے ساتھ # فائلز کا اشتراک کیا جا رہا ہے}}"</string> + <string name="sharing_album" msgid="191743129899503345">"البم کا اشتراک کیا جا رہا ہے"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{صرف تصویر}other{صرف تصاویر}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{صرف ویڈیو}other{صرف ویڈیوز}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{صرف فائل}other{صرف فائلز}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"اس ایپ کو ریکارڈ کرنے کی اجازت عطا نہیں کی گئی ہے مگر اس USB آلہ کے ذریعے آڈیو کیپچر کر سکتی ہے۔"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"ذاتی"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"دفتر"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"نجی"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"ذاتی ملاحظہ"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"دفتری ملاحظہ"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"نجی ملاحظہ"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"آپ کے IT منتظم کے ذریعے مسدود کردہ"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"اس مواد کا اشتراک ورک ایپس کے ساتھ نہیں کیا جا سکتا"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"اس مواد کو ورک ایپس کے ساتھ نہیں کھولا جا سکتا"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"اس مواد کا اشتراک ذاتی ایپس کے ساتھ نہیں کیا جا سکتا"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"اس مواد کو ذاتی ایپس کے ساتھ نہیں کھولا جا سکتا"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"اس مواد کا اشتراک نجی ایپس کے ساتھ نہیں کیا جا سکتا"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"اس مواد کو ذاتی ایپس کے ساتھ نہیں کھولا جا سکتا"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"ورک ایپس موقوف ہیں"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"غیر موقوف کریں"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"کوئی ورک ایپ نہیں"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"کوئی ذاتی ایپ نہیں"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"کوئی نجی ایپ نہیں ہے"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"اپنی ذاتی پروفائل میں <xliff:g id="APP">%s</xliff:g> کھولیں؟"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"اپنی دفتری پروفائل میں <xliff:g id="APP">%s</xliff:g> کھولیں؟"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"ذاتی براؤزر استعمال کریں"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"ٹیکسٹ شامل کریں"</string> <string name="exclude_link" msgid="1332778255031992228">"لنک خارج کریں"</string> <string name="include_link" msgid="827855767220339802">"لنک شامل کریں"</string> + <string name="pinned" msgid="7623664001331394139">"پن کردہ"</string> </resources> diff --git a/java/res/values-uz/strings.xml b/java/res/values-uz/strings.xml index 83fd9333..047c2ee3 100644 --- a/java/res/values-uz/strings.xml +++ b/java/res/values-uz/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Havolali videoni yuborish}other{# ta havolali videoni yuborish}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Matnli faylni yuborish}other{# ta matnli faylni yuborish}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Havolali faylni yuborish}other{# ta havolali faylni yuborish}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Albom ulashilmoqda"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Faqat rasm}other{Faqat rasmlar}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Faqat video}other{Faqat videolar}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Faqat fayl}other{Faqat fayllar}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Bu ilovaga yozib olish ruxsati berilmagan, lekin shu USB orqali ovozlarni yozib olishi mumkin."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Shaxsiy"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Ish"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Maxfiy"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Shaxsiy rejim"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ishchi rejim"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Maxfiy rejim"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Administratoringiz tomonidan bloklangan"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bu kontent ishga oid ilovalar bilan ulashilmaydi"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bu kontent ishga oid ilovalar bilan ochilmaydi"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bu kontent shaxsiy ilovalar bilan ulashilmaydi"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bu kontent shaxsiy ilovalar bilan ochilmaydi"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Bu kontent shaxsiy ilovalar orqali ulashilmaydi"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Bu kontent shaxsiy ilovalar orqali ochilmaydi"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Ishga oid ilovalar pauza qilingan"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Davom ettirish"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Ishga oid ilovalar topilmadi"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Shaxsiy ilovalar topilmadi"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Shaxsiy ilovalar ishlamaydi"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"<xliff:g id="APP">%s</xliff:g> shaxsiy profilda ochilsinmi?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"<xliff:g id="APP">%s</xliff:g> shaxsiy profilda ochilsinmi?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Shaxsiy brauzerdan foydalanish"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Matnni kiritish"</string> <string name="exclude_link" msgid="1332778255031992228">"Havolani chiqarib tashlash"</string> <string name="include_link" msgid="827855767220339802">"Havolani kiritish"</string> + <string name="pinned" msgid="7623664001331394139">"Mahkamlangan"</string> </resources> diff --git a/java/res/values-vi/strings.xml b/java/res/values-vi/strings.xml index 9eb8af81..89260efe 100644 --- a/java/res/values-vi/strings.xml +++ b/java/res/values-vi/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Đang chia sẻ video có đường liên kết}other{Đang chia sẻ # video có đường liên kết}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Đang chia sẻ tệp có văn bản}other{Đang chia sẻ # tệp có văn bản}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Đang chia sẻ tệp có đường liên kết}other{Đang chia sẻ # tệp có đường liên kết}}"</string> + <string name="sharing_album" msgid="191743129899503345">"Chia sẻ album"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Chỉ chia sẻ hình ảnh}other{Chỉ chia sẻ các hình ảnh}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{Chỉ chia sẻ video}other{Chỉ chia sẻ các video}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Chỉ chia sẻ tệp}other{Chỉ chia sẻ các tệp}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Ứng dụng này chưa được cấp quyền ghi âm nhưng vẫn có thể ghi âm thông qua thiết bị USB này."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Cá nhân"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Công việc"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Riêng tư"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Chế độ xem cá nhân"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Chế độ xem công việc"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Chế độ xem riêng tư"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Bị quản trị viên CNTT chặn"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Bạn không thể chia sẻ nội dung này bằng ứng dụng công việc"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Bạn không thể mở nội dung này bằng ứng dụng công việc"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Bạn không thể chia sẻ nội dung này bằng ứng dụng cá nhân"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Bạn không thể mở nội dung này bằng ứng dụng cá nhân"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Không chia sẻ được nội dung này bằng ứng dụng riêng tư"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Không mở được nội dung này bằng ứng dụng riêng tư"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Các ứng dụng công việc đã bị tạm dừng"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Tiếp tục"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Không có ứng dụng công việc"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Không có ứng dụng cá nhân"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Không có ứng dụng riêng tư nào"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Mở <xliff:g id="APP">%s</xliff:g> trong hồ sơ cá nhân của bạn?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Mở <xliff:g id="APP">%s</xliff:g> trong hồ sơ công việc của bạn?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Dùng trình duyệt cá nhân"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Thêm văn bản"</string> <string name="exclude_link" msgid="1332778255031992228">"Không kèm đường liên kết"</string> <string name="include_link" msgid="827855767220339802">"Thêm đường liên kết"</string> + <string name="pinned" msgid="7623664001331394139">"Đã ghim"</string> </resources> diff --git a/java/res/values-zh-rCN/strings.xml b/java/res/values-zh-rCN/strings.xml index f008a9a3..9049644a 100644 --- a/java/res/values-zh-rCN/strings.xml +++ b/java/res/values-zh-rCN/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{正在分享带有链接的视频}other{正在分享带有链接的 # 个视频}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{正在分享带有文本的文件}other{正在分享带有文本的 # 个文件}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{正在分享带有链接的文件}other{正在分享带有链接的 # 个文件}}"</string> + <string name="sharing_album" msgid="191743129899503345">"分享影集"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{仅限图片}other{仅限图片}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{仅限视频}other{仅限视频}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{仅限文件}other{仅限文件}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"此应用未获得录音权限,但能通过此 USB 设备录制音频。"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"个人"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"工作"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"私密"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"个人视图"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"工作视图"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"私密视图"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"已被 IT 管理员禁止"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"无法使用工作应用分享该内容"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"无法使用工作应用打开该内容"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"无法使用个人应用分享该内容"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"无法使用个人应用打开该内容"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"无法使用私人应用分享该内容"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"无法使用私人应用打开该内容"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"工作应用已暂停"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"解除暂停"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"没有支持该内容的工作应用"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"没有支持该内容的个人应用"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"无可用的专用应用"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"要使用个人资料打开 <xliff:g id="APP">%s</xliff:g> 吗?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"要使用工作资料打开 <xliff:g id="APP">%s</xliff:g> 吗?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"使用个人浏览器"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"包括文本"</string> <string name="exclude_link" msgid="1332778255031992228">"排除链接"</string> <string name="include_link" msgid="827855767220339802">"包括链接"</string> + <string name="pinned" msgid="7623664001331394139">"已固定"</string> </resources> diff --git a/java/res/values-zh-rHK/strings.xml b/java/res/values-zh-rHK/strings.xml index c48dc430..bab7dc9a 100644 --- a/java/res/values-zh-rHK/strings.xml +++ b/java/res/values-zh-rHK/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{正在分享影片 (含有連結)}other{正在分享 # 部影片 (含有連結)}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{正在分享檔案 (含有文字)}other{正在分享 # 個檔案 (含有文字)}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{正在分享檔案 (含有連結)}other{正在分享 # 個檔案 (含有連結)}}"</string> + <string name="sharing_album" msgid="191743129899503345">"共享相簿"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{僅含圖片}other{僅含圖片}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{僅含影片}other{僅含影片}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{僅含檔案}other{僅含檔案}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"此應用程式尚未獲授予錄音權限,但可透過此 USB 裝置記錄音訊。"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"個人"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"工作"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"私人"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"個人檢視模式"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"工作檢視模式"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"私人檢視模式"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"已被你的 IT 管理員封鎖"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"無法使用工作應用程式分享此內容"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"無法使用工作應用程式開啟此內容"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"無法與個人應用程式分享此內容"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"無法使用個人應用程式開啟此內容"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"無法使用私人應用程式分享此內容"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"無法使用私人應用程式開啟此內容"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"已暫停工作應用程式"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"取消暫停"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"沒有適用的工作應用程式"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"沒有適用的個人應用程式"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"沒有私人應用程式"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"要在個人設定檔中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"要在工作設定檔中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"使用個人瀏覽器"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"加入文字"</string> <string name="exclude_link" msgid="1332778255031992228">"不包括連結"</string> <string name="include_link" msgid="827855767220339802">"加入連結"</string> + <string name="pinned" msgid="7623664001331394139">"固定咗"</string> </resources> diff --git a/java/res/values-zh-rTW/strings.xml b/java/res/values-zh-rTW/strings.xml index 96ff900b..88172c78 100644 --- a/java/res/values-zh-rTW/strings.xml +++ b/java/res/values-zh-rTW/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{分享含有連結的影片}other{分享 # 部含有連結的影片}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{分享含有文字的檔案}other{分享含有文字的 # 個檔案}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{分享含有連結的檔案}other{分享含有連結的 # 個檔案}}"</string> + <string name="sharing_album" msgid="191743129899503345">"共享相簿"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{只有圖片}other{只有圖片}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{只有影片}other{只有影片}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{只有檔案}other{只有檔案}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"這個應用程式未取得錄製內容的權限,但可以透過這部 USB 裝置錄製音訊。"</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"個人"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"工作"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"私人"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"個人檢視模式"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"工作檢視模式"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"私人檢視模式"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"IT 管理員已封鎖這項操作"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"無法透過工作應用程式分享這項內容"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"無法使用工作應用程式開啟這項內容"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"無法與個人應用程式分享這項內容"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"無法使用個人應用程式開啟這項內容"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"無法透過私人應用程式分享這項內容"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"無法使用私人應用程式開啟這項內容"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"工作應用程式目前為暫停狀態"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"取消暫停"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"沒有適用的工作應用程式"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"沒有適用的個人應用程式"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"私人應用程式不支援這項功能"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"要在個人資料夾中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"要在工作資料夾中開啟「<xliff:g id="APP">%s</xliff:g>」嗎?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"使用個人瀏覽器"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"加回文字"</string> <string name="exclude_link" msgid="1332778255031992228">"排除連結"</string> <string name="include_link" msgid="827855767220339802">"加回連結"</string> + <string name="pinned" msgid="7623664001331394139">"已固定"</string> </resources> diff --git a/java/res/values-zu/strings.xml b/java/res/values-zu/strings.xml index a6feae36..c845de43 100644 --- a/java/res/values-zu/strings.xml +++ b/java/res/values-zu/strings.xml @@ -66,6 +66,7 @@ <string name="sharing_videos_with_link" msgid="6383290441403042321">"{count,plural, =1{Yabelana ngevidiyo ngelinki}one{Yabelana ngamavidiyo angu-# ngelinki}other{Yabelana ngamavidiyo angu-# ngelinki}}"</string> <string name="sharing_files_with_text" msgid="7331187260405018080">"{count,plural, =1{Yabelana ngefayela ngombhalo}one{Yabelana ngamafayela angu-# ngombhalo}other{Yabelana ngamafayela angu-# ngombhalo}}"</string> <string name="sharing_files_with_link" msgid="6052797122358827239">"{count,plural, =1{Yabelana ngefayela ngelinki}one{Yabelana ngamafayela angu-# ngelinki}other{Yabelana ngamafayela angu-# ngelinki}}"</string> + <string name="sharing_album" msgid="191743129899503345">"I-albhamu eyabiwe"</string> <string name="sharing_images_only" msgid="7762589767189955438">"{count,plural, =1{Isithombe kuphela}one{Izithombe kuphela}other{Izithombe kuphela}}"</string> <string name="sharing_videos_only" msgid="5549729252364968606">"{count,plural, =1{ividiyo kuphela}one{Amavidiyo kuphela}other{Amavidiyo kuphela}}"</string> <string name="sharing_files_only" msgid="6603666533766964768">"{count,plural, =1{Ifayela kuphela}one{Amafayela kuphela}other{Amafayela kuphela}}"</string> @@ -76,17 +77,22 @@ <string name="usb_device_resolve_prompt_warn" msgid="4254493957548169620">"Lolu hlelo lokusebenza alunikeziwe imvume yokurekhoda kodwa lungathwebula umsindo ngale divayisi ye-USB."</string> <string name="resolver_personal_tab" msgid="1381052735324320565">"Okomuntu siqu"</string> <string name="resolver_work_tab" msgid="3588325717455216412">"Umsebenzi"</string> + <string name="resolver_private_tab" msgid="3707548826254095157">"Okuyimfihlo"</string> <string name="resolver_personal_tab_accessibility" msgid="4467784352232582574">"Ukubuka komuntu siqu"</string> <string name="resolver_work_tab_accessibility" msgid="7581878836587799920">"Ukubuka komsebenzi"</string> + <string name="resolver_private_tab_accessibility" msgid="2513122834337197252">"Ukubuka okuyimfihlo"</string> <string name="resolver_cross_profile_blocked" msgid="3515194063758605377">"Kuvinjelwe umlawuli wakho we-IT"</string> <string name="resolver_cant_share_with_work_apps_explanation" msgid="2984105853145456723">"Lokhu okuqukethwe akukwazi ukwabiwa nama-app womsebenzi"</string> <string name="resolver_cant_access_work_apps_explanation" msgid="1463093773348988122">"Lokhu okuqukethwe akukwazi ukukopishwa ngama-app womsebenzi"</string> <string name="resolver_cant_share_with_personal_apps_explanation" msgid="6406971348929464569">"Lokhu okuqukethwe akukwazi ukwabiwa nama-app womuntu siqu"</string> <string name="resolver_cant_access_personal_apps_explanation" msgid="6209543716289792706">"Lokhu okuqukethwe akukwazi ukukopishwa ngama-app womuntu siqu"</string> + <string name="resolver_cant_share_with_private_apps_explanation" msgid="1781980997411434697">"Lokhu okuqukethwe akukwazi ukwabiwa ngama-app agodliwe"</string> + <string name="resolver_cant_access_private_apps_explanation" msgid="5978609934961648342">"Lokhu okuqukethwe akukwazi ukuvulwa ngama-app agodliwe"</string> <string name="resolver_turn_on_work_apps" msgid="7115260573975624516">"Ama-app omsebenzi amisiwe"</string> <string name="resolver_switch_on_work" msgid="8678893259344318807">"Qhubekisa"</string> <string name="resolver_no_work_apps_available" msgid="6139818641313189903">"Awekho ama-app womsebenzi"</string> <string name="resolver_no_personal_apps_available" msgid="8479033344701050767">"Awekho ama-app womuntu siqu"</string> + <string name="resolver_no_private_apps_available" msgid="4164473548027417456">"Awekho ama-app ayimfihlo"</string> <string name="miniresolver_open_in_personal" msgid="8397377137465016575">"Vula i-<xliff:g id="APP">%s</xliff:g> kwiphrofayela yakho siqu?"</string> <string name="miniresolver_open_in_work" msgid="4271638122142624693">"Vula i-<xliff:g id="APP">%s</xliff:g> kwiphrofayela yakho yomsebenzi?"</string> <string name="miniresolver_use_personal_browser" msgid="1428911732509069292">"Sebenzisa isiphequluli somuntu siqu"</string> @@ -95,4 +101,5 @@ <string name="include_text" msgid="642280283268536140">"Faka umbhalo"</string> <string name="exclude_link" msgid="1332778255031992228">"Ungafaki ilinki"</string> <string name="include_link" msgid="827855767220339802">"Faka ilinki"</string> + <string name="pinned" msgid="7623664001331394139">"Kuphiniwe"</string> </resources> diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml index 67acb3ae..c9f2c300 100644 --- a/java/res/values/attrs.xml +++ b/java/res/values/attrs.xml @@ -32,6 +32,11 @@ will push all ignoreOffset siblings below it when the drawer is moved i.e. setting the top limit the ignoreOffset elements. --> <attr name="ignoreOffsetTopLimit" format="reference" /> + <!-- Specifies whether ResolverDrawerLayout should use an alternative nested fling logic + adjusted for the scrollable preview feature. + Controlled by the flag com.android.intentresolver.Flags#FLAG_SCROLLABLE_PREVIEW. + --> + <attr name="useScrollablePreviewNestedFlingLogic" format="boolean" /> </declare-styleable> <declare-styleable name="ResolverDrawerLayout_LayoutParams"> diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index 6590d70e..bd868c9f 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -19,8 +19,7 @@ <!-- chooser/resolver (sharesheet) spacing --> <dimen name="chooser_action_corner_radius">28dp</dimen> <dimen name="chooser_action_horizontal_margin">2dp</dimen> - <dimen name="chooser_action_max_width">200dp</dimen> - <dimen name="chooser_width">412dp</dimen> + <dimen name="chooser_width">450dp</dimen> <dimen name="chooser_corner_radius">28dp</dimen> <dimen name="chooser_corner_radius_small">14dp</dimen> <dimen name="chooser_row_text_option_translate">25dp</dimen> @@ -33,7 +32,6 @@ <dimen name="chooser_preview_image_max_dimen">200dp</dimen> <dimen name="chooser_header_scroll_elevation">4dp</dimen> <dimen name="chooser_max_collapsed_height">288dp</dimen> - <dimen name="chooser_direct_share_label_placeholder_max_width">72dp</dimen> <dimen name="chooser_icon_size">56dp</dimen> <dimen name="chooser_badge_size">22dp</dimen> <dimen name="resolver_icon_size">32dp</dimen> @@ -60,7 +58,7 @@ <!-- Note that the values in this section are for landscape phones. For screen configs taller than 480dp, the values are set in values-h480dp/dimens.xml --> <dimen name="chooser_preview_width">412dp</dimen> - <dimen name="chooser_preview_image_height_tall">46dp</dimen> + <dimen name="chooser_preview_image_height_tall">124dp</dimen> <dimen name="grid_padding">8dp</dimen> <dimen name="width_text_image_preview_size">46dp</dimen> <!-- END SECTION --> diff --git a/java/res/values/integers.xml b/java/res/values/integers.xml index 6d57e43e..8d203bca 100644 --- a/java/res/values/integers.xml +++ b/java/res/values/integers.xml @@ -17,5 +17,5 @@ <resources> <!-- Note that this is the value for landscape phones, the value for all screens taller than 480dp is set in values-h480dp/integers.xml --> - <integer name="text_preview_lines">1</integer> + <integer name="text_preview_lines">3</integer> </resources> diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 4b5367c0..c026ee59 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -210,6 +210,11 @@ } </string> + + <!-- Title atop a sharing UI indicating that an album (typically of photos/videos) is being + shared [CHAR_LIMIT=50] --> + <string name="sharing_album">Sharing album</string> + <!-- Message indicating that the attached text has been removed from this share and only the images are being shared. [CHAR LIMIT=none] --> <string name="sharing_images_only">{count, plural, @@ -250,16 +255,20 @@ <!-- Prompt for the USB device resolver dialog with warning text for USB device dialogs. [CHAR LIMIT=200] --> <string name="usb_device_resolve_prompt_warn">This app has not been granted record permission but could capture audio through this USB device.</string> - <!-- ResolverActivity - profile tabs --> + <!-- ChooserActivity + ResolverActivity - profile tabs --> <!-- Label of a tab on a screen. A user can tap this tap to switch to the 'Personal' view (that shows their personal content) if they have a work profile on their device. [CHAR LIMIT=NONE] --> <string name="resolver_personal_tab">Personal</string> <!-- Label of a tab on a screen. A user can tap this tab to switch to the 'Work' view (that shows their work content) if they have a work profile on their device. [CHAR LIMIT=NONE] --> <string name="resolver_work_tab">Work</string> + <!-- Label of a tab on a screen. A user can tap this tab to switch to the 'Private' view (that shows their Private Space content) if they have private space configured on their device. [CHAR LIMIT=NONE] --> + <string name="resolver_private_tab">Private</string> <!-- Accessibility label for the personal tab button. [CHAR LIMIT=NONE] --> <string name="resolver_personal_tab_accessibility">Personal view</string> <!-- Accessibility label for the work tab button. [CHAR LIMIT=NONE] --> <string name="resolver_work_tab_accessibility">Work view</string> + <!-- Accessibility label for the private tab button. [CHAR LIMIT=NONE] --> + <string name="resolver_private_tab_accessibility">Private view</string> <!-- Title of a screen. This text lets the user know that their IT admin doesn't allow them to share this content across profiles. [CHAR LIMIT=NONE] --> <string name="resolver_cross_profile_blocked">Blocked by your IT admin</string> @@ -275,6 +284,12 @@ <!-- Error message. This message lets the user know that their IT admin doesn't allow them to open this specific content with an app in their personal profile. [CHAR LIMIT=NONE] --> <string name="resolver_cant_access_personal_apps_explanation">This content can\u2019t be opened with personal apps</string> + <!-- Error message. This text is explaining that the user's IT admin doesn't allow this specific content to be shared with apps in the private profile. [CHAR LIMIT=NONE] --> + <string name="resolver_cant_share_with_private_apps_explanation">This content can\u2019t be shared with private apps</string> + + <!-- Error message. This message lets the user know that their IT admin doesn't allow them to open this specific content with an app in their private profile. [CHAR LIMIT=NONE] --> + <string name="resolver_cant_access_private_apps_explanation">This content can\u2019t be opened with private apps</string> + <!-- Error message. This text lets the user know that they need to turn on work apps in order to share or open content. There's also a button a user can tap to turn on the apps. [CHAR LIMIT=NONE] --> <string name="resolver_turn_on_work_apps">Work apps are paused</string> <!-- Button text. This button unpauses a user's work apps and data. [CHAR LIMIT=NONE] --> @@ -286,6 +301,9 @@ <!-- Error message. This text lets the user know that their current personal apps don't support the specific content. [CHAR LIMIT=NONE] --> <string name="resolver_no_personal_apps_available">No personal apps</string> + <!-- Error message. This text lets the user know that their current private apps don't support the specific content. [CHAR LIMIT=NONE] --> + <string name="resolver_no_private_apps_available">No private apps</string> + <!-- Dialog title. User must choose between opening content in a cross-profile app or same-profile browser. [CHAR LIMIT=NONE] --> <string name="miniresolver_open_in_personal">Open <xliff:g id="app" example="YouTube">%s</xliff:g> in your personal profile?</string> <!-- Dialog title. User must choose between opening content in a cross-profile app or same-profile browser. [CHAR LIMIT=NONE] --> @@ -303,4 +321,18 @@ <string name="exclude_link">Exclude link</string> <!-- Title for a button. Adds back a (previously excluded) web link into the shared content. --> <string name="include_link">Include link</string> + + <!-- Accessibility content description for a sharesheet target that has been pinned to the + front of the list by the user. [CHAR LIMIT=NONE] --> + <string name="pinned">Pinned</string> + + <!-- Accessibility content description for an image that the user may select for sharing. + [CHAR LIMIT=NONE] --> + <string name="selectable_image">Selectable image</string> + <!-- Accessibility content description for a video that the user may select for sharing. + [CHAR LIMIT=NONE] --> + <string name="selectable_video">Selectable video</string> + <!-- Accessibility content description for an item that the user may select for sharing. + [CHAR LIMIT=NONE] --> + <string name="selectable_item">Selectable item</string> </resources> diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml index 9a31a141..143009d0 100644 --- a/java/res/values/styles.xml +++ b/java/res/values/styles.xml @@ -23,7 +23,7 @@ <item name="android:taskOpenExitAnimation">@anim/resolver_close_anim</item> </style> <style name="Theme.DeviceDefault.ResolverCommon" - parent="@android:style/Theme.DeviceDefault.DayNight"> + parent="@android:style/Theme.DeviceDefault.DayNight"> <item name="android:windowAnimationStyle">@style/ResolverAnimation</item> <item name="android:windowIsTranslucent">true</item> <item name="android:windowNoTitle">true</item> @@ -45,6 +45,7 @@ <style name="Theme.DeviceDefault.Chooser" parent="Theme.DeviceDefault.Resolver"> <item name="*android:iconfactoryIconSize">@dimen/chooser_icon_size</item> <item name="*android:iconfactoryBadgeSize">@dimen/chooser_badge_size</item> + <item name="android:windowLayoutInDisplayCutoutMode">always</item> </style> <style name="TextAppearance.ChooserDefault" diff --git a/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt b/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt deleted file mode 100644 index 5067c0ee..00000000 --- a/java/src-debug/com/android/intentresolver/flags/DebugFeatureFlagRepository.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import android.util.SparseBooleanArray -import androidx.annotation.GuardedBy -import com.android.systemui.flags.BooleanFlag -import com.android.systemui.flags.FlagManager -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag -import javax.annotation.concurrent.ThreadSafe - -@ThreadSafe -internal class DebugFeatureFlagRepository( - private val flagManager: FlagManager, - private val deviceConfig: DeviceConfigProxy, -) : FeatureFlagRepository { - @GuardedBy("self") - private val cache = hashMapOf<String, Boolean>() - - override fun isEnabled(flag: UnreleasedFlag): Boolean = isFlagEnabled(flag) - - override fun isEnabled(flag: ReleasedFlag): Boolean = isFlagEnabled(flag) - - private fun isFlagEnabled(flag: BooleanFlag): Boolean { - synchronized(cache) { - cache[flag.name]?.let { return it } - } - val flagValue = readFlagValue(flag) - return synchronized(cache) { - // the first read saved in the cache wins - cache.getOrPut(flag.name) { flagValue } - } - } - - private fun readFlagValue(flag: BooleanFlag): Boolean { - val localOverride = runCatching { - flagManager.isEnabled(flag.name) - }.getOrDefault(null) - val remoteOverride = deviceConfig.isEnabled(flag) - - // Only check for teamfood if the default is false - // and there is no server override. - if (remoteOverride == null - && !flag.default - && localOverride == null - && !flag.isTeamfoodFlag - && flag.teamfood - ) { - return flagManager.isTeamfoodEnabled - } - return localOverride ?: remoteOverride ?: flag.default - } - - companion object { - /** keep in sync with [com.android.systemui.flags.Flags] */ - private const val TEAMFOOD_FLAG_NAME = "teamfood" - - private val BooleanFlag.isTeamfoodFlag: Boolean - get() = name == TEAMFOOD_FLAG_NAME - - private val FlagManager.isTeamfoodEnabled: Boolean - get() = runCatching { - isEnabled(TEAMFOOD_FLAG_NAME) ?: false - }.getOrDefault(false) - } -} diff --git a/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt b/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt deleted file mode 100644 index f9fa2c6a..00000000 --- a/java/src-release/com/android/intentresolver/flags/ReleaseFeatureFlagRepository.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag -import javax.annotation.concurrent.ThreadSafe - -@ThreadSafe -internal class ReleaseFeatureFlagRepository( - private val deviceConfig: DeviceConfigProxy, -) : FeatureFlagRepository { - override fun isEnabled(flag: UnreleasedFlag): Boolean = flag.default - - override fun isEnabled(flag: ReleasedFlag): Boolean = - deviceConfig.isEnabled(flag) ?: flag.default -} diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java deleted file mode 100644 index 4b06db3b..00000000 --- a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java +++ /dev/null @@ -1,582 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.intentresolver; - -import android.annotation.IntDef; -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.annotation.UserIdInt; -import android.app.AppGlobals; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.pm.IPackageManager; -import android.os.Trace; -import android.os.UserHandle; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; - -import com.android.internal.annotations.VisibleForTesting; - -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.function.Supplier; - -/** - * Skeletal {@link PagerAdapter} implementation of a work or personal profile page for - * intent resolution (including share sheet). - */ -public abstract class AbstractMultiProfilePagerAdapter extends PagerAdapter { - - private static final String TAG = "AbstractMultiProfilePagerAdapter"; - static final int PROFILE_PERSONAL = 0; - static final int PROFILE_WORK = 1; - - @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) - @interface Profile {} - - private final Context mContext; - private int mCurrentPage; - private OnProfileSelectedListener mOnProfileSelectedListener; - - private Set<Integer> mLoadedPages; - private final EmptyStateProvider mEmptyStateProvider; - private final UserHandle mWorkProfileUserHandle; - private final UserHandle mCloneProfileUserHandle; - private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet. - - AbstractMultiProfilePagerAdapter( - Context context, - int currentPage, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { - mContext = Objects.requireNonNull(context); - mCurrentPage = currentPage; - mLoadedPages = new HashSet<>(); - mWorkProfileUserHandle = workProfileUserHandle; - mCloneProfileUserHandle = cloneProfileUserHandle; - mEmptyStateProvider = emptyStateProvider; - mWorkProfileQuietModeChecker = workProfileQuietModeChecker; - } - - void setOnProfileSelectedListener(OnProfileSelectedListener listener) { - mOnProfileSelectedListener = listener; - } - - Context getContext() { - return mContext; - } - - /** - * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets - * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed - * page and rebuilds the list. - */ - void setupViewPager(ViewPager viewPager) { - viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { - @Override - public void onPageSelected(int position) { - mCurrentPage = position; - if (!mLoadedPages.contains(position)) { - rebuildActiveTab(true); - mLoadedPages.add(position); - } - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfileSelected(position); - } - } - - @Override - public void onPageScrollStateChanged(int state) { - if (mOnProfileSelectedListener != null) { - mOnProfileSelectedListener.onProfilePageStateChanged(state); - } - } - }); - viewPager.setAdapter(this); - viewPager.setCurrentItem(mCurrentPage); - mLoadedPages.add(mCurrentPage); - } - - void clearInactiveProfileCache() { - if (mLoadedPages.size() == 1) { - return; - } - mLoadedPages.remove(1 - mCurrentPage); - } - - @Override - public ViewGroup instantiateItem(ViewGroup container, int position) { - final ProfileDescriptor profileDescriptor = getItem(position); - container.addView(profileDescriptor.rootView); - return profileDescriptor.rootView; - } - - @Override - public void destroyItem(ViewGroup container, int position, Object view) { - container.removeView((View) view); - } - - @Override - public int getCount() { - return getItemCount(); - } - - protected int getCurrentPage() { - return mCurrentPage; - } - - @VisibleForTesting - public UserHandle getCurrentUserHandle() { - return getActiveListAdapter().getUserHandle(); - } - - @Override - public boolean isViewFromObject(View view, Object object) { - return view == object; - } - - @Override - public CharSequence getPageTitle(int position) { - return null; - } - - public UserHandle getCloneUserHandle() { - return mCloneProfileUserHandle; - } - - /** - * Returns the {@link ProfileDescriptor} relevant to the given <code>pageIndex</code>. - * <ul> - * <li>For a device with only one user, <code>pageIndex</code> value of - * <code>0</code> would return the personal profile {@link ProfileDescriptor}.</li> - * <li>For a device with a work profile, <code>pageIndex</code> value of <code>0</code> would - * return the personal profile {@link ProfileDescriptor}, and <code>pageIndex</code> value of - * <code>1</code> would return the work profile {@link ProfileDescriptor}.</li> - * </ul> - */ - abstract ProfileDescriptor getItem(int pageIndex); - - protected ViewGroup getEmptyStateView(int pageIndex) { - return getItem(pageIndex).getEmptyStateView(); - } - - /** - * Returns the number of {@link ProfileDescriptor} objects. - * <p>For a normal consumer device with only one user returns <code>1</code>. - * <p>For a device with a work profile returns <code>2</code>. - */ - abstract int getItemCount(); - - /** - * Performs view-related initialization procedures for the adapter specified - * by <code>pageIndex</code>. - */ - abstract void setupListAdapter(int pageIndex); - - /** - * Returns the adapter of the list view for the relevant page specified by - * <code>pageIndex</code>. - * <p>This method is meant to be implemented with an implementation-specific return type - * depending on the adapter type. - */ - @VisibleForTesting - public abstract Object getAdapterForIndex(int pageIndex); - - /** - * Returns the {@link ResolverListAdapter} instance of the profile that represents - * <code>userHandle</code>. If there is no such adapter for the specified - * <code>userHandle</code>, returns {@code null}. - * <p>For example, if there is a work profile on the device with user id 10, calling this method - * with <code>UserHandle.of(10)</code> returns the work profile {@link ResolverListAdapter}. - */ - @Nullable - abstract ResolverListAdapter getListAdapterForUserHandle(UserHandle userHandle); - - /** - * Returns the {@link ResolverListAdapter} instance of the profile that is currently visible - * to the user. - * <p>For example, if the user is viewing the work tab in the share sheet, this method returns - * the work profile {@link ResolverListAdapter}. - * @see #getInactiveListAdapter() - */ - @VisibleForTesting - public abstract ResolverListAdapter getActiveListAdapter(); - - /** - * If this is a device with a work profile, returns the {@link ResolverListAdapter} instance - * of the profile that is <b><i>not</i></b> currently visible to the user. Otherwise returns - * {@code null}. - * <p>For example, if the user is viewing the work tab in the share sheet, this method returns - * the personal profile {@link ResolverListAdapter}. - * @see #getActiveListAdapter() - */ - @VisibleForTesting - public abstract @Nullable ResolverListAdapter getInactiveListAdapter(); - - public abstract ResolverListAdapter getPersonalListAdapter(); - - public abstract @Nullable ResolverListAdapter getWorkListAdapter(); - - abstract Object getCurrentRootAdapter(); - - abstract ViewGroup getActiveAdapterView(); - - abstract @Nullable ViewGroup getInactiveAdapterView(); - - /** - * Rebuilds the tab that is currently visible to the user. - * <p>Returns {@code true} if rebuild has completed. - */ - boolean rebuildActiveTab(boolean doPostProcessing) { - Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); - boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); - Trace.endSection(); - return result; - } - - /** - * Rebuilds the tab that is not currently visible to the user, if such one exists. - * <p>Returns {@code true} if rebuild has completed. - */ - boolean rebuildInactiveTab(boolean doPostProcessing) { - Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); - if (getItemCount() == 1) { - Trace.endSection(); - return false; - } - boolean result = rebuildTab(getInactiveListAdapter(), doPostProcessing); - Trace.endSection(); - return result; - } - - private int userHandleToPageIndex(UserHandle userHandle) { - if (userHandle.equals(getPersonalListAdapter().getUserHandle())) { - return PROFILE_PERSONAL; - } else { - return PROFILE_WORK; - } - } - - private boolean rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing) { - if (shouldSkipRebuild(activeListAdapter)) { - activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); - return false; - } - return activeListAdapter.rebuildList(doPostProcessing); - } - - private boolean shouldSkipRebuild(ResolverListAdapter activeListAdapter) { - EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); - return emptyState != null && emptyState.shouldSkipDataRebuild(); - } - - /** - * The empty state screens are shown according to their priority: - * <ol> - * <li>(highest priority) cross-profile disabled by policy (handled in - * {@link #rebuildTab(ResolverListAdapter, boolean)})</li> - * <li>no apps available</li> - * <li>(least priority) work is off</li> - * </ol> - * - * The intention is to prevent the user from having to turn - * the work profile on if there will not be any apps resolved - * anyway. - */ - void showEmptyResolverListEmptyState(ResolverListAdapter listAdapter) { - final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); - - if (emptyState == null) { - return; - } - - emptyState.onEmptyStateShown(); - - View.OnClickListener clickListener = null; - - if (emptyState.getButtonClickListener() != null) { - clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(listAdapter.getUserHandle())); - AbstractMultiProfilePagerAdapter.this.showSpinner(descriptor.getEmptyStateView()); - }); - } - - showEmptyState(listAdapter, emptyState, clickListener); - } - - /** - * Class to get user id of the current process - */ - public static class MyUserIdProvider { - /** - * @return user id of the current process - */ - public int getMyUserId() { - return UserHandle.myUserId(); - } - } - - /** - * Utility class to check if there are cross profile intents, it is in a separate class so - * it could be mocked in tests - */ - public static class CrossProfileIntentsChecker { - - private final ContentResolver mContentResolver; - - public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { - mContentResolver = contentResolver; - } - - /** - * Returns {@code true} if at least one of the provided {@code intents} can be forwarded - * from {@code source} (user id) to {@code target} (user id). - */ - public boolean hasCrossProfileIntents(List<Intent> intents, @UserIdInt int source, - @UserIdInt int target) { - IPackageManager packageManager = AppGlobals.getPackageManager(); - - return intents.stream().anyMatch(intent -> - null != IntentForwarderActivity.canForward(intent, source, target, - packageManager, mContentResolver)); - } - } - - protected void showEmptyState(ResolverListAdapter activeListAdapter, EmptyState emptyState, - View.OnClickListener buttonOnClick) { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE); - ViewGroup emptyStateView = descriptor.getEmptyStateView(); - resetViewVisibilitiesForEmptyState(emptyStateView); - emptyStateView.setVisibility(View.VISIBLE); - - View container = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_container); - setupContainerPadding(container); - - TextView titleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title); - String title = emptyState.getTitle(); - if (title != null) { - titleView.setVisibility(View.VISIBLE); - titleView.setText(title); - } else { - titleView.setVisibility(View.GONE); - } - - TextView subtitleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle); - String subtitle = emptyState.getSubtitle(); - if (subtitle != null) { - subtitleView.setVisibility(View.VISIBLE); - subtitleView.setText(subtitle); - } else { - subtitleView.setVisibility(View.GONE); - } - - View defaultEmptyText = emptyStateView.findViewById(com.android.internal.R.id.empty); - defaultEmptyText.setVisibility(emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); - - Button button = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button); - button.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); - button.setOnClickListener(buttonOnClick); - - activeListAdapter.markTabLoaded(); - } - - /** - * Sets up the padding of the view containing the empty state screens. - * <p>This method is meant to be overridden so that subclasses can customize the padding. - */ - protected void setupContainerPadding(View container) {} - - private void showSpinner(View emptyStateView) { - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); - } - - private void resetViewVisibilitiesForEmptyState(View emptyStateView) { - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.VISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); - emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.GONE); - emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.GONE); - } - - protected void showListView(ResolverListAdapter activeListAdapter) { - ProfileDescriptor descriptor = getItem( - userHandleToPageIndex(activeListAdapter.getUserHandle())); - descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.VISIBLE); - View emptyStateView = descriptor.rootView.findViewById(com.android.internal.R.id.resolver_empty_state); - emptyStateView.setVisibility(View.GONE); - } - - boolean shouldShowEmptyStateScreen(ResolverListAdapter listAdapter) { - int count = listAdapter.getUnfilteredCount(); - return (count == 0 && listAdapter.getPlaceholderCount() == 0) - || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) - && mWorkProfileQuietModeChecker.get()); - } - - protected static class ProfileDescriptor { - final ViewGroup rootView; - private final ViewGroup mEmptyStateView; - ProfileDescriptor(ViewGroup rootView) { - this.rootView = rootView; - mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); - } - - protected ViewGroup getEmptyStateView() { - return mEmptyStateView; - } - } - - public interface OnProfileSelectedListener { - /** - * Callback for when the user changes the active tab from personal to work or vice versa. - * <p>This callback is only called when the intent resolver or share sheet shows - * the work and personal profiles. - * @param profileIndex {@link #PROFILE_PERSONAL} if the personal profile was selected or - * {@link #PROFILE_WORK} if the work profile was selected. - */ - void onProfileSelected(int profileIndex); - - - /** - * Callback for when the scroll state changes. Useful for discovering when the user begins - * dragging, when the pager is automatically settling to the current page, or when it is - * fully stopped/idle. - * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} - * or {@link ViewPager#SCROLL_STATE_SETTLING} - * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged - */ - void onProfilePageStateChanged(int state); - } - - /** - * Returns an empty state to show for the current profile page (tab) if necessary. - * This could be used e.g. to show a blocker on a tab if device management policy doesn't - * allow to use it or there are no apps available. - */ - public interface EmptyStateProvider { - /** - * When a non-null empty state is returned the corresponding profile page will show - * this empty state - * @param resolverListAdapter the current adapter - */ - @Nullable - default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - return null; - } - } - - /** - * Empty state provider that combines multiple providers. Providers earlier in the list have - * priority, that is if there is a provider that returns non-null empty state then all further - * providers will be ignored. - */ - public static class CompositeEmptyStateProvider implements EmptyStateProvider { - - private final EmptyStateProvider[] mProviders; - - public CompositeEmptyStateProvider(EmptyStateProvider... providers) { - mProviders = providers; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - for (EmptyStateProvider provider : mProviders) { - EmptyState emptyState = provider.getEmptyState(resolverListAdapter); - if (emptyState != null) { - return emptyState; - } - } - return null; - } - } - - /** - * Describes how the blocked empty state should look like for a profile tab - */ - public interface EmptyState { - /** - * Title that will be shown on the empty state - */ - @Nullable - default String getTitle() { return null; } - - /** - * Subtitle that will be shown underneath the title on the empty state - */ - @Nullable - default String getSubtitle() { return null; } - - /** - * If non-null then a button will be shown and this listener will be called - * when the button is clicked - */ - @Nullable - default ClickListener getButtonClickListener() { return null; } - - /** - * If true then default text ('No apps can perform this action') and style for the empty - * state will be applied, title and subtitle will be ignored. - */ - default boolean useDefaultEmptyView() { return false; } - - /** - * Returns true if for this empty state we should skip rebuilding of the apps list - * for this tab. - */ - default boolean shouldSkipDataRebuild() { return false; } - - /** - * Called when empty state is shown, could be used e.g. to track analytics events - */ - default void onEmptyStateShown() {} - - interface ClickListener { - void onClick(TabControl currentTab); - } - - interface TabControl { - void showSpinner(); - } - } - - - /** - * Listener for when the user switches on the work profile from the work tab. - */ - interface OnSwitchOnWorkSelectedListener { - /** - * Callback for when the user switches on the work profile from the work tab. - */ - void onSwitchOnWorkSelected(); - } -} diff --git a/java/src/com/android/intentresolver/AnnotatedUserHandles.java b/java/src/com/android/intentresolver/AnnotatedUserHandles.java deleted file mode 100644 index 168f36d6..00000000 --- a/java/src/com/android/intentresolver/AnnotatedUserHandles.java +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.annotation.Nullable; -import android.app.Activity; -import android.app.ActivityManager; -import android.os.UserHandle; -import android.os.UserManager; - -import androidx.annotation.VisibleForTesting; - -/** - * Helper class to precompute the (immutable) designations of various user handles in the system - * that may contribute to the current Sharesheet session. - */ -public final class AnnotatedUserHandles { - /** The user id of the app that started the share activity. */ - public final int userIdOfCallingApp; - - /** - * The {@link UserHandle} that launched Sharesheet. - * TODO: I believe this would always be the handle corresponding to {@code userIdOfCallingApp} - * except possibly if the caller used {@link Activity#startActivityAsUser()} to launch - * Sharesheet as a different user than they themselves were running as. Verify and document. - */ - public final UserHandle userHandleSharesheetLaunchedAs; - - /** - * The {@link UserHandle} that owns the "personal tab" in a tabbed share UI (or the *only* 'tab' - * in a non-tabbed UI). - * - * This is never a work or clone user, but may either be the root user (0) or a "secondary" - * multi-user profile (i.e., one that's not root, work, nor clone). This is a "secondary" - * profile only when that user is the active "foreground" user. - * - * In the current implementation, we can assert that this is the root user (0) any time we - * display a tabbed UI (i.e., any time `workProfileUserHandle` is non-null), or any time that we - * have a clone profile. This note is only provided for informational purposes; clients should - * avoid making any reliances on that assumption. - */ - public final UserHandle personalProfileUserHandle; - - /** - * The {@link UserHandle} that owns the "work tab" in a tabbed share UI. This is (an arbitrary) - * one of the "managed" profiles associated with {@link personalProfileUserHandle}. - */ - @Nullable - public final UserHandle workProfileUserHandle; - - /** - * The {@link UserHandle} of the clone profile belonging to {@link personalProfileUserHandle}. - */ - @Nullable - public final UserHandle cloneProfileUserHandle; - - /** - * The "tab owner" user handle (i.e., either {@link personalProfileUserHandle} or - * {@link workProfileUserHandle}) that either matches or owns the profile of the - * {@link userHandleSharesheetLaunchedAs}. - * - * In the current implementation, we can assert that this is the same as - * `userHandleSharesheetLaunchedAs` except when the latter is the clone profile; then this is - * the "personal" profile owning that clone profile (which we currently know must belong to - * user 0, but clients should avoid making any reliances on that assumption). - */ - public final UserHandle tabOwnerUserHandleForLaunch; - - /** Compute all handle designations for a new Sharesheet session in the specified activity. */ - public static AnnotatedUserHandles forShareActivity(Activity shareActivity) { - // TODO: consider integrating logic for `ResolverActivity.EXTRA_CALLING_USER`? - UserHandle userHandleSharesheetLaunchedAs = UserHandle.of(UserHandle.myUserId()); - - // ActivityManager.getCurrentUser() refers to the current Foreground user. When clone/work - // profile is active, we always make the personal tab from the foreground user. - // Outside profiles, current foreground user is potentially the same as the sharesheet - // process's user (UserHandle.myUserId()), so we continue to create personal tab with the - // current foreground user. - UserHandle personalProfileUserHandle = UserHandle.of(ActivityManager.getCurrentUser()); - - UserManager userManager = shareActivity.getSystemService(UserManager.class); - - return newBuilder() - .setUserIdOfCallingApp(shareActivity.getLaunchedFromUid()) - .setUserHandleSharesheetLaunchedAs(userHandleSharesheetLaunchedAs) - .setPersonalProfileUserHandle(personalProfileUserHandle) - .setWorkProfileUserHandle( - getWorkProfileForUser(userManager, personalProfileUserHandle)) - .setCloneProfileUserHandle( - getCloneProfileForUser(userManager, personalProfileUserHandle)) - .build(); - } - - @VisibleForTesting static Builder newBuilder() { - return new Builder(); - } - - /** - * Returns the {@link UserHandle} to use when querying resolutions for intents in a - * {@link ResolverListController} configured for the provided {@code userHandle}. - */ - public UserHandle getQueryIntentsUser(UserHandle userHandle) { - // In case launching app is in clonedProfile, and we are building the personal tab, intent - // resolution will be attempted as clonedUser instead of user 0. This is because intent - // resolution from user 0 and clonedUser is not guaranteed to return same results. - // We do not care about the case when personal adapter is started with non-root user - // (secondary user case), as clone profile is guaranteed to be non-active in that case. - UserHandle queryIntentsUser = userHandle; - if (isLaunchedAsCloneProfile() && userHandle.equals(personalProfileUserHandle)) { - queryIntentsUser = cloneProfileUserHandle; - } - return queryIntentsUser; - } - - private Boolean isLaunchedAsCloneProfile() { - return userHandleSharesheetLaunchedAs.equals(cloneProfileUserHandle); - } - - private AnnotatedUserHandles( - int userIdOfCallingApp, - UserHandle userHandleSharesheetLaunchedAs, - UserHandle personalProfileUserHandle, - @Nullable UserHandle workProfileUserHandle, - @Nullable UserHandle cloneProfileUserHandle) { - if ((userIdOfCallingApp < 0) || UserHandle.isIsolated(userIdOfCallingApp)) { - throw new SecurityException("Can't start a resolver from uid " + userIdOfCallingApp); - } - - this.userIdOfCallingApp = userIdOfCallingApp; - this.userHandleSharesheetLaunchedAs = userHandleSharesheetLaunchedAs; - this.personalProfileUserHandle = personalProfileUserHandle; - this.workProfileUserHandle = workProfileUserHandle; - this.cloneProfileUserHandle = cloneProfileUserHandle; - this.tabOwnerUserHandleForLaunch = - (userHandleSharesheetLaunchedAs == workProfileUserHandle) - ? workProfileUserHandle : personalProfileUserHandle; - } - - @Nullable - private static UserHandle getWorkProfileForUser( - UserManager userManager, UserHandle profileOwnerUserHandle) { - return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()) - .stream() - .filter(info -> info.isManagedProfile()) - .findFirst() - .map(info -> info.getUserHandle()) - .orElse(null); - } - - @Nullable - private static UserHandle getCloneProfileForUser( - UserManager userManager, UserHandle profileOwnerUserHandle) { - return userManager.getProfiles(profileOwnerUserHandle.getIdentifier()) - .stream() - .filter(info -> info.isCloneProfile()) - .findFirst() - .map(info -> info.getUserHandle()) - .orElse(null); - } - - @VisibleForTesting - static class Builder { - private int mUserIdOfCallingApp; - private UserHandle mUserHandleSharesheetLaunchedAs; - private UserHandle mPersonalProfileUserHandle; - private UserHandle mWorkProfileUserHandle; - private UserHandle mCloneProfileUserHandle; - - public Builder setUserIdOfCallingApp(int id) { - mUserIdOfCallingApp = id; - return this; - } - - public Builder setUserHandleSharesheetLaunchedAs(UserHandle user) { - mUserHandleSharesheetLaunchedAs = user; - return this; - } - - public Builder setPersonalProfileUserHandle(UserHandle user) { - mPersonalProfileUserHandle = user; - return this; - } - - public Builder setWorkProfileUserHandle(UserHandle user) { - mWorkProfileUserHandle = user; - return this; - } - - public Builder setCloneProfileUserHandle(UserHandle user) { - mCloneProfileUserHandle = user; - return this; - } - - public AnnotatedUserHandles build() { - return new AnnotatedUserHandles( - mUserIdOfCallingApp, - mUserHandleSharesheetLaunchedAs, - mPersonalProfileUserHandle, - mWorkProfileUserHandle, - mCloneProfileUserHandle); - } - } -} diff --git a/java/src/com/android/intentresolver/ChooserActionFactory.java b/java/src/com/android/intentresolver/ChooserActionFactory.java index 06c7e8d7..79998fbc 100644 --- a/java/src/com/android/intentresolver/ChooserActionFactory.java +++ b/java/src/com/android/intentresolver/ChooserActionFactory.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityOptions; import android.app.PendingIntent; @@ -34,9 +33,14 @@ import android.text.TextUtils; import android.util.Log; import android.view.View; +import androidx.annotation.Nullable; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; +import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.ui.ShareResultSender; +import com.android.intentresolver.ui.model.ShareAction; import com.android.intentresolver.widget.ActionRow; import com.android.internal.annotations.VisibleForTesting; @@ -44,6 +48,7 @@ import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.Callable; import java.util.function.Consumer; @@ -51,8 +56,11 @@ import java.util.function.Consumer; * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application * requirements of Sharesheet / {@link ChooserActivity}. */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { - /** Delegate interface to launch activities when the actions are selected. */ + /** + * Delegate interface to launch activities when the actions are selected. + */ public interface ActionActivityStarter { /** * Request an activity launch for the provided target. Implementations may choose to exit @@ -90,20 +98,17 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio private final Context mContext; - @Nullable - private final Runnable mCopyButtonRunnable; - private final Runnable mEditButtonRunnable; + @Nullable private Runnable mCopyButtonRunnable; + @Nullable private Runnable mEditButtonRunnable; private final ImmutableList<ChooserAction> mCustomActions; - private final @Nullable ChooserAction mModifyShareAction; private final Consumer<Boolean> mExcludeSharedTextAction; + @Nullable private final ShareResultSender mShareResultSender; private final Consumer</* @Nullable */ Integer> mFinishCallback; - private final ChooserActivityLogger mLogger; + private final EventLog mLog; /** * @param context - * @param chooserRequest data about the invocation of the current Sharesheet session. - * @param integratedDeviceComponents info about other components that are available on this - * device to implement the supported action types. + * @param imageEditor an explicit Activity to launch for editing images * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" * setting is updated. The argument is whether the shared text is to be excluded. * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image @@ -114,54 +119,74 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio */ public ChooserActionFactory( Context context, - ChooserRequestParameters chooserRequest, - ChooserIntegratedDeviceComponents integratedDeviceComponents, - ChooserActivityLogger logger, + Intent targetIntent, + String referrerPackageName, + List<ChooserAction> chooserActions, + Optional<ComponentName> imageEditor, + EventLog log, Consumer<Boolean> onUpdateSharedTextIsExcluded, Callable</* @Nullable */ View> firstVisibleImageQuery, ActionActivityStarter activityStarter, - Consumer</* @Nullable */ Integer> finishCallback) { + @Nullable ShareResultSender shareResultSender, + Consumer</* @Nullable */ Integer> finishCallback, + ClipboardManager clipboardManager) { this( context, makeCopyButtonRunnable( - context, - chooserRequest.getTargetIntent(), - chooserRequest.getReferrerPackageName(), + clipboardManager, + targetIntent, + referrerPackageName, finishCallback, - logger), + log), makeEditButtonRunnable( getEditSharingTarget( context, - chooserRequest.getTargetIntent(), - integratedDeviceComponents), + targetIntent, + imageEditor), firstVisibleImageQuery, activityStarter, - logger), - chooserRequest.getChooserActions(), - chooserRequest.getModifyShareAction(), + log), + chooserActions, onUpdateSharedTextIsExcluded, - logger, + log, + shareResultSender, finishCallback); + } @VisibleForTesting ChooserActionFactory( Context context, @Nullable Runnable copyButtonRunnable, - Runnable editButtonRunnable, + @Nullable Runnable editButtonRunnable, List<ChooserAction> customActions, - @Nullable ChooserAction modifyShareAction, Consumer<Boolean> onUpdateSharedTextIsExcluded, - ChooserActivityLogger logger, + EventLog log, + @Nullable ShareResultSender shareResultSender, Consumer</* @Nullable */ Integer> finishCallback) { mContext = context; mCopyButtonRunnable = copyButtonRunnable; mEditButtonRunnable = editButtonRunnable; mCustomActions = ImmutableList.copyOf(customActions); - mModifyShareAction = modifyShareAction; mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; - mLogger = logger; + mLog = log; + mShareResultSender = shareResultSender; mFinishCallback = finishCallback; + + if (mShareResultSender != null) { + if (mEditButtonRunnable != null) { + mEditButtonRunnable = () -> { + mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT); + editButtonRunnable.run(); + }; + } + if (mCopyButtonRunnable != null) { + mCopyButtonRunnable = () -> { + mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY); + copyButtonRunnable.run(); + }; + } + } } @Override @@ -185,11 +210,9 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio ActionRow.Action actionRow = createCustomAction( mContext, mCustomActions.get(i), - mFinishCallback, - () -> { - mLogger.logCustomActionSelected(position); - } - ); + () -> logCustomAction(position), + mShareResultSender, + mFinishCallback); if (actionRow != null) { actions.add(actionRow); } @@ -198,21 +221,6 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } /** - * Provides a share modification action, if any. - */ - @Override - @Nullable - public ActionRow.Action getModifyShareAction() { - return createCustomAction( - mContext, - mModifyShareAction, - mFinishCallback, - () -> { - mLogger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE); - }); - } - - /** * <p> * Creates an exclude-text action that can be called when the user changes shared text * status in the Media + Text preview. @@ -228,27 +236,25 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio @Nullable private static Runnable makeCopyButtonRunnable( - Context context, + ClipboardManager clipboardManager, Intent targetIntent, String referrerPackageName, Consumer<Integer> finishCallback, - ChooserActivityLogger logger) { + EventLog log) { final ClipData clipData; try { clipData = extractTextToCopy(targetIntent); } catch (Throwable t) { Log.e(TAG, "Failed to extract data to copy", t); - return null; + return null; } if (clipData == null) { return null; } return () -> { - ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( - Context.CLIPBOARD_SERVICE); clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); - logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); + log.logActionSelected(EventLog.SELECTION_TYPE_COPY); finishCallback.accept(Activity.RESULT_OK); }; } @@ -277,18 +283,18 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio return clipData; } + @Nullable private static TargetInfo getEditSharingTarget( Context context, Intent originalIntent, - ChooserIntegratedDeviceComponents integratedComponents) { - final ComponentName editorComponent = integratedComponents.getEditSharingComponent(); + Optional<ComponentName> imageEditor) { final Intent resolveIntent = new Intent(originalIntent); // Retain only URI permission grant flags if present. Other flags may prevent the scene // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); - resolveIntent.setComponent(editorComponent); + imageEditor.ifPresent(resolveIntent::setComponent); resolveIntent.setAction(Intent.ACTION_EDIT); resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET); String originalAction = originalIntent.getAction(); @@ -307,7 +313,7 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio final ResolveInfo ri = context.getPackageManager().resolveActivity( resolveIntent, PackageManager.GET_META_DATA); if (ri == null || ri.activityInfo == null) { - Log.e(TAG, "Device-specified editor (" + editorComponent + ") not available"); + Log.e(TAG, "Device-specified editor (" + imageEditor + ") not available"); return null; } @@ -316,21 +322,22 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio ri, context.getString(R.string.screenshot_edit), "", - resolveIntent, - null); + resolveIntent); dri.getDisplayIconHolder().setDisplayIcon( context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); return dri; } + @Nullable private static Runnable makeEditButtonRunnable( - TargetInfo editSharingTarget, + @Nullable TargetInfo editSharingTarget, Callable</* @Nullable */ View> firstVisibleImageQuery, ActionActivityStarter activityStarter, - ChooserActivityLogger logger) { + EventLog log) { + if (editSharingTarget == null) return null; return () -> { // Log share completion via edit. - logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_EDIT); + log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); View firstImageView = null; try { @@ -347,12 +354,13 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio } @Nullable - private static ActionRow.Action createCustomAction( + static ActionRow.Action createCustomAction( Context context, - ChooserAction action, - Consumer<Integer> finishCallback, - Runnable loggingRunnable) { - if (action == null || action.getAction() == null) { + @Nullable ChooserAction action, + Runnable loggingRunnable, + ShareResultSender shareResultSender, + Consumer</* @Nullable */ Integer> finishCallback) { + if (action == null) { return null; } Drawable icon = action.getIcon().loadDrawable(context); @@ -372,18 +380,25 @@ public final class ChooserActionFactory implements ChooserContentPreviewUi.Actio null, null, ActivityOptions.makeCustomAnimation( - context, - R.anim.slide_in_right, - R.anim.slide_out_left) - .toBundle()); + context, + R.anim.slide_in_right, + R.anim.slide_out_left) + .toBundle()); } catch (PendingIntent.CanceledException e) { Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); } if (loggingRunnable != null) { loggingRunnable.run(); } + if (shareResultSender != null) { + shareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); + } finishCallback.accept(Activity.RESULT_OK); } ); } + + void logCustomAction(int position) { + mLog.logCustomActionSelected(position); + } } diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 63ac6435..9643b9f0 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,26 +16,30 @@ package com.android.intentresolver; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; +import static android.app.VoiceInteractor.PickOptionRequest.Option; +import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; +import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; +import static androidx.lifecycle.LifecycleKt.getCoroutineScope; + +import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; +import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_PERSONAL; +import static com.android.intentresolver.profiles.MultiProfilePagerAdapter.PROFILE_WORK; +import static com.android.intentresolver.ui.model.ActivityModel.ACTIVITY_MODEL_KEY; import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; -import android.annotation.IntDef; -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.app.Activity; +import static java.util.Objects.requireNonNull; + import android.app.ActivityManager; import android.app.ActivityOptions; +import android.app.ActivityThread; +import android.app.VoiceInteractor; +import android.app.admin.DevicePolicyEventLogger; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; import android.app.prediction.AppTargetId; +import android.content.ClipboardManager; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; @@ -52,34 +56,42 @@ import android.database.Cursor; import android.graphics.Insets; import android.net.Uri; import android.os.Bundle; -import android.os.Environment; +import android.os.StrictMode; import android.os.SystemClock; +import android.os.Trace; import android.os.UserHandle; -import android.os.UserManager; -import android.os.storage.StorageManager; import android.service.chooser.ChooserTarget; +import android.stats.devicepolicy.DevicePolicyEnums; +import android.text.TextUtils; import android.util.Log; import android.util.Slog; -import android.util.SparseArray; +import android.view.Gravity; +import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; +import android.view.Window; import android.view.WindowInsets; -import android.view.animation.AlphaAnimation; -import android.view.animation.Animation; -import android.view.animation.LinearInterpolator; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TabHost; +import android.widget.TabWidget; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.viewmodel.CreationExtras; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; +import com.android.intentresolver.ChooserRefinementManager.RefinementType; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; @@ -87,44 +99,85 @@ 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.flags.FeatureFlagRepository; -import com.android.intentresolver.flags.FeatureFlagRepositoryFactory; +import com.android.intentresolver.data.model.ChooserRequest; +import com.android.intentresolver.data.repository.DevicePolicyResources; +import com.android.intentresolver.domain.interactor.UserInteractor; +import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.icons.DefaultTargetDataLoader; +import com.android.intentresolver.icons.Caching; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.inject.Background; +import com.android.intentresolver.logging.EventLog; import com.android.intentresolver.measurements.Tracer; import com.android.intentresolver.model.AbstractResolverComparator; import com.android.intentresolver.model.AppPredictionServiceResolverComparator; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; +import com.android.intentresolver.platform.AppPredictionAvailable; +import com.android.intentresolver.platform.ImageEditor; +import com.android.intentresolver.platform.NearbyShare; +import com.android.intentresolver.profiles.ChooserMultiProfilePagerAdapter; +import com.android.intentresolver.profiles.MultiProfilePagerAdapter.ProfileType; +import com.android.intentresolver.profiles.OnProfileSelectedListener; +import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.profiles.TabConfig; +import com.android.intentresolver.shared.model.Profile; import com.android.intentresolver.shortcuts.AppPredictorFactory; import com.android.intentresolver.shortcuts.ShortcutLoader; +import com.android.intentresolver.ui.ActionTitle; +import com.android.intentresolver.ui.ProfilePagerResources; +import com.android.intentresolver.ui.ShareResultSender; +import com.android.intentresolver.ui.ShareResultSenderFactory; +import com.android.intentresolver.ui.model.ActivityModel; +import com.android.intentresolver.ui.viewmodel.ChooserViewModel; +import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView; +import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; +import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.internal.util.LatencyTracker; + +import com.google.common.collect.ImmutableList; + +import dagger.hilt.android.AndroidEntryPoint; + +import kotlin.Pair; + +import kotlinx.coroutines.CoroutineDispatcher; -import java.io.File; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.text.Collator; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; +import java.util.function.Supplier; + +import javax.inject.Inject; +import javax.inject.Provider; /** * The Chooser Activity handles intent resolution specifically for sharing intents - * for example, as generated by {@see android.content.Intent#createChooser(Intent, CharSequence)}. * */ -public class ChooserActivity extends ResolverActivity implements - ResolverListAdapter.ResolverListCommunicator { +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +@AndroidEntryPoint(FragmentActivity.class) +public class ChooserActivity extends Hilt_ChooserActivity implements + ResolverListAdapter.ResolverListCommunicator, PackagesChangedListener, StartsSelectedItem { private static final String TAG = "ChooserActivity"; /** @@ -138,7 +191,6 @@ public class ChooserActivity extends ResolverActivity implements /** * Transition name for the first image preview. * To be used for shared element transition into this activity. - * @hide */ public static final String FIRST_IMAGE_PREVIEW_TRANSITION_NAME = "screenshot_preview_image"; @@ -147,6 +199,39 @@ public class ChooserActivity extends ResolverActivity implements public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; private static final String SHORTCUT_TARGET = "shortcut_target"; + ////////////////////////////////////////////////////////////////////////////////////////////// + // Inherited properties. + ////////////////////////////////////////////////////////////////////////////////////////////// + private static final String TAB_TAG_PERSONAL = "personal"; + private static final String TAB_TAG_WORK = "work"; + + private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; + public static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; + + private int mLayoutId; + private UserHandle mHeaderCreatorUser; + private boolean mRegistered; + private PackageMonitor mPersonalPackageMonitor; + private PackageMonitor mWorkPackageMonitor; + + protected ResolverDrawerLayout mResolverDrawerLayout; + private TabHost mTabHost; + private ResolverViewPager mViewPager; + protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; + protected final LatencyTracker mLatencyTracker = getLatencyTracker(); + + /** See {@link #setRetainInOnStop}. */ + private boolean mRetainInOnStop; + protected Insets mSystemWindowInsets = null; + private ResolverActivity.PickTargetOptionRequest mPickOptionRequest; + + @Nullable + private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + + ////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////// + + // TODO: these data structures are for one-time use in shuttling data from where they're // populated in `ShortcutToChooserTargetConverter` to where they're consumed in // `ShortcutSelectionLogic` which packs the appropriate elements into the final `TargetInfo`. @@ -155,44 +240,47 @@ public class ChooserActivity extends ResolverActivity implements private final Map<ChooserTarget, AppTarget> mDirectShareAppTargetCache = new HashMap<>(); private final Map<ChooserTarget, ShortcutInfo> mDirectShareShortcutInfoCache = new HashMap<>(); - public static final int TARGET_TYPE_DEFAULT = 0; - public static final int TARGET_TYPE_CHOOSER_TARGET = 1; - public static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; - public static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; + static final int TARGET_TYPE_DEFAULT = 0; + static final int TARGET_TYPE_CHOOSER_TARGET = 1; + static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; + static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; private static final int SCROLL_STATUS_IDLE = 0; private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; - @IntDef(flag = false, prefix = { "TARGET_TYPE_" }, value = { - TARGET_TYPE_DEFAULT, - TARGET_TYPE_CHOOSER_TARGET, - TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, - TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE - }) - @Retention(RetentionPolicy.SOURCE) - public @interface ShareTargetType {} - - private ChooserIntegratedDeviceComponents mIntegratedDeviceComponents; - - /* TODO: this is `nullable` because we have to defer the assignment til onCreate(). We make the - * only assignment there, and expect it to be ready by the time we ever use it -- - * someday if we move all the usage to a component with a narrower lifecycle (something that - * matches our Activity's create/destroy lifecycle, not its Java object lifecycle) then we - * should be able to make this assignment as "final." - */ - @Nullable - private ChooserRequestParameters mChooserRequest; + @Inject public UserInteractor mUserInteractor; + @Inject @Background public CoroutineDispatcher mBackgroundDispatcher; + @Inject public ChooserHelper mChooserHelper; + @Inject public FeatureFlags mFeatureFlags; + @Inject public android.service.chooser.FeatureFlags mChooserServiceFeatureFlags; + @Inject public EventLog mEventLog; + @Inject @AppPredictionAvailable public boolean mAppPredictionAvailable; + @Inject @ImageEditor public Optional<ComponentName> mImageEditor; + @Inject @NearbyShare public Optional<ComponentName> mNearbyShare; + protected TargetDataLoader mTargetDataLoader; + @Inject public Provider<TargetDataLoader> mTargetDataLoaderProvider; + @Inject + @Caching + public Provider<TargetDataLoader> mCachingTargetDataLoaderProvider; + @Inject public DevicePolicyResources mDevicePolicyResources; + @Inject public ProfilePagerResources mProfilePagerResources; + @Inject public PackageManager mPackageManager; + @Inject public ClipboardManager mClipboardManager; + @Inject public IntentForwarding mIntentForwarding; + @Inject public ShareResultSenderFactory mShareResultSenderFactory; + + private ActivityModel mActivityModel; + private ChooserRequest mRequest; + private ProfileHelper mProfiles; + private ProfileAvailability mProfileAvailability; + @Nullable private ShareResultSender mShareResultSender; private ChooserRefinementManager mRefinementManager; - private FeatureFlagRepository mFeatureFlagRepository; private ChooserContentPreviewUi mChooserContentPreviewUi; private boolean mShouldDisplayLandscape; - // statsd logger wrapper - protected ChooserActivityLogger mChooserActivityLogger; - private long mChooserShownTime; protected boolean mIsSuccessfullySelected; @@ -214,164 +302,1059 @@ public class ChooserActivity extends ResolverActivity implements private int mScrollStatus = SCROLL_STATUS_IDLE; - @VisibleForTesting - protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = new EnterTransitionAnimationDelegate(this, () -> mResolverDrawerLayout); - private View mContentView = null; - - private final SparseArray<ProfileRecord> mProfileRecords = new SparseArray<>(); + private final Map<Integer, ProfileRecord> mProfileRecords = new HashMap<>(); private boolean mExcludeSharedText = false; + /** + * When we intend to finish the activity with a shared element transition, we can't immediately + * finish() when the transition is invoked, as the receiving end may not be able to start the + * animation and the UI breaks if this takes too long. Instead we defer finishing until onStop + * in order to wait for the transition to begin. + */ + private boolean mFinishWhenStopped = false; - public ChooserActivity() {} + private final AtomicLong mIntentReceivedTime = new AtomicLong(-1); + + protected ActivityModel createActivityModel() { + return ActivityModel.createFrom(this); + } + + private ChooserViewModel mViewModel; + + @NonNull + @Override + public CreationExtras getDefaultViewModelCreationExtras() { + return addDefaultArgs( + super.getDefaultViewModelCreationExtras(), + new Pair<>(ACTIVITY_MODEL_KEY, createActivityModel())); + } @Override protected void onCreate(Bundle savedInstanceState) { - Tracer.INSTANCE.markLaunched(); - final long intentReceivedTime = System.currentTimeMillis(); - mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + super.onCreate(savedInstanceState); + Log.i(TAG, "onCreate"); - getChooserActivityLogger().logSharesheetTriggered(); + mTargetDataLoader = mChooserServiceFeatureFlags.chooserPayloadToggling() + ? mCachingTargetDataLoaderProvider.get() + : mTargetDataLoaderProvider.get(); - mFeatureFlagRepository = createFeatureFlagRepository(); - mIntegratedDeviceComponents = getIntegratedDeviceComponents(); + setTheme(R.style.Theme_DeviceDefault_Chooser); - try { - mChooserRequest = new ChooserRequestParameters( - getIntent(), - getReferrerPackageName(), - getReferrer(), - mFeatureFlagRepository); - } catch (IllegalArgumentException e) { - Log.e(TAG, "Caller provided invalid Chooser request parameters", e); - finish(); - super_onCreate(null); - return; + // Initializer is invoked when this function returns, via Lifecycle. + mChooserHelper.setInitializer(this::initialize); + if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { + mChooserHelper.setOnChooserRequestChanged(this::onChooserRequestChanged); + mChooserHelper.setOnPendingSelection(this::onPendingSelection); } + } - mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); + @Override + protected final void onStart() { + super.onStart(); + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + } - mRefinementManager.getRefinementCompletion().observe(this, completion -> { - if (completion.consume()) { - TargetInfo targetInfo = completion.getTargetInfo(); - // targetInfo is non-null if the refinement process was successful. - if (targetInfo != null) { - maybeRemoveSharedText(targetInfo); - - // We already block suspended targets from going to refinement, and we probably - // can't recover a Chooser session if that's the reason the refined target fails - // to launch now. Fire-and-forget the refined launch; ignore the return value - // and just make sure the Sharesheet session gets cleaned up regardless. - ChooserActivity.super.onTargetSelected(targetInfo, false); - } + @Override + protected final void onResume() { + super.onResume(); + Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); + mFinishWhenStopped = false; + mRefinementManager.onActivityResume(); + } + + @Override + protected final void onStop() { + super.onStop(); + + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mRetainInOnStop) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { finish(); } - }); + } - BasePreviewViewModel previewViewModel = - new ViewModelProvider(this, createPreviewViewModelFactory()) - .get(BasePreviewViewModel.class); - mChooserContentPreviewUi = new ChooserContentPreviewUi( - getLifecycle(), - previewViewModel.createOrReuseProvider(mChooserRequest), - mChooserRequest.getTargetIntent(), - previewViewModel.createOrReuseImageLoader(), - createChooserActionFactory(), - mEnterTransitionAnimationDelegate, - new HeadlineGeneratorImpl(this)); + if (mRefinementManager != null) { + mRefinementManager.onActivityStop(isChangingConfigurations()); + } + + if (mFinishWhenStopped) { + mFinishWhenStopped = false; + finish(); + } + } + + @Override + protected final void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (mViewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, mViewPager.getCurrentItem()); + } + } + + @Override + protected final void onRestart() { + super.onRestart(); + if (mFeatureFlags.fixPrivateSpaceLockedOnRestart()) { + if (mChooserMultiProfilePagerAdapter.hasPageForProfile(Profile.Type.PRIVATE.ordinal()) + && !mProfileAvailability.isAvailable(mProfiles.getPrivateProfile())) { + Log.d(TAG, "Exiting due to unavailable profile"); + finish(); + return; + } + } + + if (!mRegistered) { + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false); + if (mProfiles.getWorkProfilePresent()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false); + } + mRegistered = true; + } + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mChooserMultiProfilePagerAdapter != null) { + mChooserMultiProfilePagerAdapter.destroy(); + } + + if (isFinishing()) { + mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); + } + + mBackgroundThreadPoolExecutor.shutdownNow(); + + destroyProfileRecords(); + } + + /** DO NOT CALL. Only for use from ChooserHelper as a callback. */ + private void initialize() { + + mViewModel = new ViewModelProvider(this).get(ChooserViewModel.class); + mRequest = mViewModel.getRequest().getValue(); + mActivityModel = mViewModel.getActivityModel(); + + mProfiles = new ProfileHelper( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher, + mFeatureFlags); - setAdditionalTargets(mChooserRequest.getAdditionalTargets()); + mProfileAvailability = new ProfileAvailability( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher); - setSafeForwardingMode(true); + mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); + + mIntentReceivedTime.set(System.currentTimeMillis()); + mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); mPinnedSharedPrefs = getPinnedSharedPrefs(this); + updateShareResultSender(); - mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + mMaxTargetsPerRow = + getResources().getInteger(R.integer.config_chooser_max_targets_per_row); mShouldDisplayLandscape = shouldDisplayLandscape(getResources().getConfiguration().orientation); - setRetainInOnStop(mChooserRequest.shouldRetainInOnStop()); + setRetainInOnStop(mRequest.shouldRetainInOnStop()); createProfileRecords( new AppPredictorFactory( - getApplicationContext(), - mChooserRequest.getSharedText(), - mChooserRequest.getTargetIntentFilter()), - mChooserRequest.getTargetIntentFilter()); - - super.onCreate( - savedInstanceState, - mChooserRequest.getTargetIntent(), - mChooserRequest.getTitle(), - mChooserRequest.getDefaultTitleResource(), - mChooserRequest.getInitialIntents(), - /* rList: List<ResolveInfo> = */ null, - /* supportsAlwaysUseOption = */ false, - new DefaultTargetDataLoader(this, getLifecycle(), false)); + this, + Objects.toString(mRequest.getSharedText(), null), + mRequest.getShareTargetFilter(), + mAppPredictionAvailable + ), + mRequest.getShareTargetFilter() + ); - mChooserShownTime = System.currentTimeMillis(); - final long systemCost = mChooserShownTime - intentReceivedTime; - getChooserActivityLogger().logChooserActivityShown( - isWorkProfile(), mChooserRequest.getTargetType(), systemCost); + mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + /* context = */ this, + mProfilePagerResources, + mRequest, + mProfiles, + mProfileAvailability, + mRequest.getInitialIntents(), + mMaxTargetsPerRow); + + maybeDisableRecentsScreenshot(mProfiles, mProfileAvailability); + + if (!configureContentView(mTargetDataLoader)) { + mPersonalPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false + ); + if (mProfiles.getWorkProfilePresent()) { + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false + ); + } + mRegistered = true; + final ResolverDrawerLayout rdl = findViewById( + com.android.internal.R.id.contentPanel); + if (rdl != null) { + rdl.setOnDismissedListener(new ResolverDrawerLayout.OnDismissedListener() { + @Override + public void onDismissed() { + finish(); + } + }); + + boolean hasTouchScreen = mPackageManager + .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); + + if (isVoiceInteraction() || !hasTouchScreen) { + rdl.setCollapsed(false); + } + + rdl.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + rdl.setOnApplyWindowInsetsListener(this::onApplyWindowInsets); + + mResolverDrawerLayout = rdl; + } + + Intent intent = mRequest.getTargetIntent(); + final Set<String> categories = intent.getCategories(); + MetricsLogger.action(this, + mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED + : MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED, + intent.getAction() + ":" + intent.getType() + ":" + + (categories != null ? Arrays.toString(categories.toArray()) + : "")); + } + + getEventLog().logSharesheetTriggered(); + mRefinementManager = new ViewModelProvider(this).get(ChooserRefinementManager.class); + mRefinementManager.getRefinementCompletion().observe(this, completion -> { + if (completion.consume()) { + if (completion.getRefinedIntent() == null) { + finish(); + return; + } + + // Prepare to regenerate our "system actions" based on the refined intent. + // TODO: optimize if needed. `TARGET_INFO` cases don't require a new action + // factory at all. And if we break up `ChooserActionFactory`, we could avoid + // resolving a new editor intent unless we're handling an `EDIT_ACTION`. + ChooserActionFactory refinedActionFactory = + createChooserActionFactory(completion.getRefinedIntent()); + switch (completion.getType()) { + case TARGET_INFO: { + TargetInfo refinedTarget = completion + .getOriginalTargetInfo() + .tryToCloneWithAppliedRefinement( + completion.getRefinedIntent()); + if (refinedTarget == null) { + Log.e(TAG, "Failed to apply refinement to any matching source intent"); + } else { + maybeRemoveSharedText(refinedTarget); + + // We already block suspended targets from going to refinement, and we + // probably can't recover a Chooser session if that's the reason the + // refined target fails to launch now. Fire-and-forget the refined + // launch, and make sure Sharesheet gets cleaned up regardless of the + // outcome of that launch.launch; ignore + + safelyStartActivity(refinedTarget); + } + } + break; + + case COPY_ACTION: { + if (refinedActionFactory.getCopyButtonRunnable() != null) { + refinedActionFactory.getCopyButtonRunnable().run(); + } + } + break; + + case EDIT_ACTION: { + if (refinedActionFactory.getEditButtonRunnable() != null) { + refinedActionFactory.getEditButtonRunnable().run(); + } + } + break; + } + + finish(); + } + }); + BasePreviewViewModel previewViewModel = + new ViewModelProvider(this, createPreviewViewModelFactory()) + .get(BasePreviewViewModel.class); + previewViewModel.init( + mRequest.getTargetIntent(), + mRequest.getAdditionalContentUri(), + mChooserServiceFeatureFlags.chooserPayloadToggling()); + ChooserContentPreviewUi.ActionFactory actionFactory = + decorateActionFactoryWithRefinement( + createChooserActionFactory(mRequest.getTargetIntent())); + mChooserContentPreviewUi = new ChooserContentPreviewUi( + getCoroutineScope(getLifecycle()), + previewViewModel.getPreviewDataProvider(), + mRequest.getTargetIntent(), + previewViewModel.getImageLoader(), + actionFactory, + createModifyShareActionFactory(), + mEnterTransitionAnimationDelegate, + new HeadlineGeneratorImpl(this), + mRequest.getContentTypeHint(), + mRequest.getMetadataText(), + mChooserServiceFeatureFlags.chooserPayloadToggling()); + updateStickyContentPreview(); + if (shouldShowStickyContentPreview()) { + getEventLog().logActionShareWithPreview( + mChooserContentPreviewUi.getPreferredContentPreview()); + } + mChooserShownTime = System.currentTimeMillis(); + final long systemCost = mChooserShownTime - mIntentReceivedTime.get(); + getEventLog().logChooserActivityShown( + isWorkProfile(), mRequest.getTargetType(), systemCost); if (mResolverDrawerLayout != null) { mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); mResolverDrawerLayout.setOnCollapsedChangedListener( isCollapsed -> { mChooserMultiProfilePagerAdapter.setIsCollapsed(isCollapsed); - getChooserActivityLogger().logSharesheetExpansionChanged(isCollapsed); + getEventLog().logSharesheetExpansionChanged(isCollapsed); }); } - if (DEBUG) { Log.d(TAG, "System Time Cost is " + systemCost); } - - getChooserActivityLogger().logShareStarted( - getReferrerPackageName(), - mChooserRequest.getTargetType(), - mChooserRequest.getCallerChooserTargets().size(), - (mChooserRequest.getInitialIntents() == null) - ? 0 : mChooserRequest.getInitialIntents().length, + getEventLog().logShareStarted( + mRequest.getReferrerPackage(), + mRequest.getTargetType(), + mRequest.getCallerChooserTargets().size(), + mRequest.getInitialIntents().size(), isWorkProfile(), mChooserContentPreviewUi.getPreferredContentPreview(), - mChooserRequest.getTargetAction(), - mChooserRequest.getChooserActions().size(), - mChooserRequest.getModifyShareAction() != null + mRequest.getTargetAction(), + mRequest.getChooserActions().size(), + mRequest.getModifyShareAction() != null ); - mEnterTransitionAnimationDelegate.postponeTransition(); + Tracer.INSTANCE.markLaunched(); } - @VisibleForTesting - protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return ChooserIntegratedDeviceComponents.get(this, new SecureSettings()); + private void maybeDisableRecentsScreenshot( + ProfileHelper profileHelper, ProfileAvailability profileAvailability) { + for (Profile profile : profileHelper.getProfiles()) { + if (profile.getType() == Profile.Type.PRIVATE) { + if (profileAvailability.isAvailable(profile)) { + // Show blank screen in Recent preview if private profile is available + // to not leak its presence. + setRecentsScreenshotEnabled(false); + } + return; + } + } + } + + private void onChooserRequestChanged(ChooserRequest chooserRequest) { + // intentional reference comparison + if (mRequest == chooserRequest) { + return; + } + boolean recreateAdapters = shouldUpdateAdapters(mRequest, chooserRequest); + mRequest = chooserRequest; + updateShareResultSender(); + mChooserContentPreviewUi.updateModifyShareAction(); + if (recreateAdapters) { + recreatePagerAdapter(); + } else { + setTabsViewEnabled(true); + } + } + + private void onPendingSelection() { + setTabsViewEnabled(false); + } + + private void onAppTargetsLoaded(ResolverListAdapter listAdapter) { + if (mChooserMultiProfilePagerAdapter == null) { + return; + } + if (!isProfilePagerAdapterAttached() + && listAdapter == mChooserMultiProfilePagerAdapter.getActiveListAdapter()) { + mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager); + setTabsViewEnabled(true); + } + } + + private void updateShareResultSender() { + IntentSender chosenComponentSender = mRequest.getChosenComponentSender(); + if (chosenComponentSender != null) { + mShareResultSender = mShareResultSenderFactory.create( + mViewModel.getActivityModel().getLaunchedFromUid(), chosenComponentSender); + } else { + mShareResultSender = null; + } + } + + private boolean shouldUpdateAdapters( + ChooserRequest oldChooserRequest, ChooserRequest newChooserRequest) { + Intent oldTargetIntent = oldChooserRequest.getTargetIntent(); + Intent newTargetIntent = newChooserRequest.getTargetIntent(); + List<Intent> oldAltIntents = oldChooserRequest.getAdditionalTargets(); + List<Intent> newAltIntents = newChooserRequest.getAdditionalTargets(); + + // TODO: a workaround for the unnecessary target reloading caused by multiple flow updates - + // an artifact of the current implementation; revisit. + return !oldTargetIntent.equals(newTargetIntent) || !oldAltIntents.equals(newAltIntents); + } + + private void recreatePagerAdapter() { + if (!mChooserServiceFeatureFlags.chooserPayloadToggling()) { + return; + } + destroyProfileRecords(); + createProfileRecords( + new AppPredictorFactory( + this, + Objects.toString(mRequest.getSharedText(), null), + mRequest.getShareTargetFilter(), + mAppPredictionAvailable + ), + mRequest.getShareTargetFilter() + ); + + int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); + if (mChooserMultiProfilePagerAdapter != null) { + mChooserMultiProfilePagerAdapter.destroy(); + } + // Update the pager adapter but do not attach it to the view till the targets are reloaded, + // see onChooserAppTargetsLoaded method. + mChooserMultiProfilePagerAdapter = createMultiProfilePagerAdapter( + /* context = */ this, + mProfilePagerResources, + mRequest, + mProfiles, + mProfileAvailability, + mRequest.getInitialIntents(), + mMaxTargetsPerRow); + mChooserMultiProfilePagerAdapter.setCurrentPage(currentPage); + for (int i = 0, count = mChooserMultiProfilePagerAdapter.getItemCount(); i < count; i++) { + mChooserMultiProfilePagerAdapter.getPageAdapterForIndex(i) + .getListAdapter().setAnimateItems(false); + } + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + mPersonalPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false); + if (mProfiles.getWorkProfilePresent()) { + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mWorkPackageMonitor = createPackageMonitor( + mChooserMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false); + } + postRebuildList( + mChooserMultiProfilePagerAdapter.rebuildTabs( + mProfiles.getWorkProfilePresent() || mProfiles.getPrivateProfilePresent())); + setTabsViewEnabled(false); + } + + private void setTabsViewEnabled(boolean isEnabled) { + TabWidget tabs = mTabHost.getTabWidget(); + if (tabs != null) { + tabs.setEnabled(isEnabled); + } + View tabContent = mTabHost.findViewById(com.android.internal.R.id.profile_pager); + if (tabContent != null) { + tabContent.setEnabled(isEnabled); + } } @Override - protected int appliedThemeResId() { - return R.style.Theme_DeviceDefault_Chooser; + protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { + if (mViewPager != null) { + mViewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + } + mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + // Inherited methods + ////////////////////////////////////////////////////////////////////////////////////////////// + + private boolean isAutolaunching() { + return !mRegistered && isFinishing(); + } + + private boolean maybeAutolaunchIfSingleTarget() { + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + if (count != 1) { + return false; + } + + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) { + return false; + } + + // Only one target, so we're a candidate to auto-launch! + final TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(0, false); + if (shouldAutoLaunchSingleChoice(target)) { + safelyStartActivity(target); + finish(); + return true; + } + return false; } - protected FeatureFlagRepository createFeatureFlagRepository() { - return new FeatureFlagRepositoryFactory().create(getApplicationContext()); + private boolean isTwoPagePersonalAndWorkConfiguration() { + return (mChooserMultiProfilePagerAdapter.getCount() == 2) + && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) + && mChooserMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); + } + + /** + * When we have a personal and a work profile, we auto launch in the following scenario: + * - There is 1 resolved target on each profile + * - That target is the same app on both profiles + * - The target app has permission to communicate cross profiles + * - The target app has declared it supports cross-profile communication via manifest metadata + */ + private boolean maybeAutolaunchIfCrossProfileSupported() { + if (!isTwoPagePersonalAndWorkConfiguration()) { + return false; + } + + ResolverListAdapter activeListAdapter = + (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mChooserMultiProfilePagerAdapter.getPersonalListAdapter() + : mChooserMultiProfilePagerAdapter.getWorkListAdapter(); + + ResolverListAdapter inactiveListAdapter = + (mChooserMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mChooserMultiProfilePagerAdapter.getWorkListAdapter() + : mChooserMultiProfilePagerAdapter.getPersonalListAdapter(); + + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { + return false; + } + + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } + + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); + TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), + inactiveProfileTarget.getResolvedComponentName())) { + return false; + } + + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { + return false; + } + + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { + return false; + } + + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) + .setBoolean(activeListAdapter.getUserHandle() + .equals(mProfiles.getPersonalHandle())) + .setStrings(getMetricsCategory()) + .write(); + safelyStartActivity(activeProfileTarget); + finish(); + return true; + } + + /** + * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} + */ + private boolean maybeAutolaunchActivity() { + int numberOfProfiles = mChooserMultiProfilePagerAdapter.getItemCount(); + // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the + // correct intent-picker UIs (e.g., mini-resolver) if it was launched without + // ACTION_SEND. + if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { + return true; + } else if (maybeAutolaunchIfCrossProfileSupported()) { + return true; + } + return false; + } + + @Override // ResolverListCommunicator + public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, + boolean rebuildCompleted) { + if (isAutolaunching()) { + return; + } + if (mChooserMultiProfilePagerAdapter + .shouldShowEmptyStateScreen((ChooserListAdapter) listAdapter)) { + mChooserMultiProfilePagerAdapter + .showEmptyResolverListEmptyState((ChooserListAdapter) listAdapter); + } else { + mChooserMultiProfilePagerAdapter.showListView((ChooserListAdapter) listAdapter); + } + // showEmptyResolverListEmptyState can mark the tab as loaded, + // which is a precondition for auto launching + if (rebuildCompleted && maybeAutolaunchActivity()) { + return; + } + if (doPostProcessing) { + maybeCreateHeader(listAdapter); + onListRebuilt(listAdapter, rebuildCompleted); + } + } + + private CharSequence getOrLoadDisplayLabel(TargetInfo info) { + if (info.isDisplayResolveInfo()) { + mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); + } + CharSequence displayLabel = info.getDisplayLabel(); + return displayLabel == null ? "" : displayLabel; + } + + protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { + final ActionTitle title = ActionTitle.forAction(intent.getAction()); + + // While there may already be a filtered item, we can only use it in the title if the list + // is already sorted and all information relevant to it is already in the list. + final boolean named = + mChooserMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0; + if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) { + return getString(defaultTitleRes); + } else { + return named + ? getString( + title.namedTitleRes, + getOrLoadDisplayLabel( + mChooserMultiProfilePagerAdapter + .getActiveListAdapter().getFilteredItem())) + : getString(title.titleRes); + } + } + + /** + * Configure the area above the app selection list (title, content preview, etc). + */ + private void maybeCreateHeader(ResolverListAdapter listAdapter) { + if (mHeaderCreatorUser != null + && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { + return; + } + if (!mProfiles.getWorkProfilePresent() + && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setVisibility(View.GONE); + } + } + + CharSequence title = mRequest.getTitle() != null + ? mRequest.getTitle() + : getTitleForAction(mRequest.getTargetIntent(), + mRequest.getDefaultTitleResource()); + + if (!TextUtils.isEmpty(title)) { + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setText(title); + } + setTitle(title); + } + + final ImageView iconView = findViewById(com.android.internal.R.id.icon); + if (iconView != null) { + listAdapter.loadFilteredItemIconTaskAsync(iconView); + } + mHeaderCreatorUser = listAdapter.getUserHandle(); + } + + /** Start the activity specified by the {@link TargetInfo}.*/ + public final void safelyStartActivity(TargetInfo cti) { + // In case cloned apps are present, we would want to start those apps in cloned user + // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle + // identifies the correct user space in such cases. + UserHandle activityUserHandle = cti.getResolveInfo().userHandle; + safelyStartActivityAsUser(cti, activityUserHandle, null); + } + + protected final void safelyStartActivityAsUser( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // We're dispatching intents that might be coming from legacy apps, so + // don't kill ourselves. + StrictMode.disableDeathOnFileUriExposure(); + try { + safelyStartActivityInternal(cti, user, options); + } finally { + StrictMode.enableDeathOnFileUriExposure(); + } + } + + @VisibleForTesting + protected void safelyStartActivityInternal( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // If the target is suspended, the activity will not be successfully launched. + // Do not unregister from package manager updates in this case + if (!cti.isSuspended() && mRegistered) { + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + // If needed, show that intent is forwarded + // from managed profile to owner or other way around. + String profileSwitchMessage = mIntentForwarding.forwardMessageFor( + mRequest.getTargetIntent()); + if (profileSwitchMessage != null) { + Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); + } + try { + if (cti.startAsCaller(this, options, user.getIdentifier())) { + maybeSendShareResult(cti); + maybeLogCrossProfileTargetLaunch(cti, user); + } + } catch (RuntimeException e) { + Slog.wtf(TAG, + "Unable to launch as uid " + mActivityModel.getLaunchedFromUid() + + " package " + mActivityModel.getLaunchedFromPackage() + + ", while running in " + ActivityThread.currentProcessName(), e); + } + } + + private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { + if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) { + return; + } + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) + .setBoolean(currentUserHandle.equals(mProfiles.getPersonalHandle())) + .setStrings(getMetricsCategory(), + cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") + .write(); + } + + private LatencyTracker getLatencyTracker() { + return LatencyTracker.getInstance(this); + } + + /** + * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets + * called and we are launched in a new task. + */ + protected final void setRetainInOnStop(boolean retainInOnStop) { + mRetainInOnStop = retainInOnStop; + } + + // @NonFinalForTesting + @VisibleForTesting + protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { + return new CrossProfileIntentsChecker(getContentResolver()); + } + + protected final EmptyStateProvider createEmptyStateProvider( + ProfileHelper profileHelper, + ProfileAvailability profileAvailability) { + EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); + + EmptyStateProvider workProfileOffEmptyStateProvider = + new WorkProfilePausedEmptyStateProvider( + this, + profileHelper, + profileAvailability, + /* onSwitchOnWorkSelectedListener = */ + () -> { + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + }, + getMetricsCategory()); + + EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + mProfiles, + mProfileAvailability, + getMetricsCategory(), + mProfilePagerResources + ); + + // Return composite provider, the order matters (the higher, the more priority) + return new CompositeEmptyStateProvider( + blockerEmptyStateProvider, + workProfileOffEmptyStateProvider, + noAppsEmptyStateProvider + ); } + /** + * Returns the {@link List} of {@link UserHandle} to pass on to the + * {@link ResolverRankerServiceResolverComparator} as per the provided {@code userHandle}. + */ + private List<UserHandle> getResolverRankerServiceUserHandleList(UserHandle userHandle) { + return getResolverRankerServiceUserHandleListInternal(userHandle); + } + + private List<UserHandle> getResolverRankerServiceUserHandleListInternal(UserHandle userHandle) { + List<UserHandle> userList = new ArrayList<>(); + userList.add(userHandle); + // Add clonedProfileUserHandle to the list only if we are: + // a. Building the Personal Tab. + // b. CloneProfile exists on the device. + if (userHandle.equals(mProfiles.getPersonalHandle()) + && mProfiles.getCloneUserPresent()) { + userList.add(mProfiles.getCloneHandle()); + } + return userList; + } + + /** + * Start activity as a fixed user handle. + * @param cti TargetInfo to be launched. + * @param user User to launch this activity as. + */ + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) + public final void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { + safelyStartActivityAsUser(cti, user, null); + } + + @Override // ResolverListCommunicator + public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (!mChooserMultiProfilePagerAdapter.onHandlePackagesChanged( + (ChooserListAdapter) listAdapter, + mProfileAvailability.getWaitingToEnableProfile())) { + // We no longer have any items... just finish the activity. + finish(); + } + } + + final Option optionForChooserTarget(TargetInfo target, int index) { + return new Option(getOrLoadDisplayLabel(target), index); + } + + @Override // ResolverListCommunicator + public final void sendVoiceChoicesIfNeeded() { + if (!isVoiceInteraction()) { + // Clearly not needed. + return; + } + + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getCount(); + final Option[] options = new Option[count]; + for (int i = 0; i < options.length; i++) { + TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); + if (target == null) { + // If this occurs, a new set of targets is being loaded. Let that complete, + // and have the next call to send voice choices proceed instead. + return; + } + options[i] = optionForChooserTarget(target, i); + } + + mPickOptionRequest = new ResolverActivity.PickTargetOptionRequest( + new VoiceInteractor.Prompt(getTitle()), options, null); + getVoiceInteractor().submitRequest(mPickOptionRequest); + } + + /** + * Sets up the content view. + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + private boolean configureContentView(TargetDataLoader targetDataLoader) { + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null) { + throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " + + "cannot be null."); + } + Trace.beginSection("configureContentView"); + // We partially rebuild the inactive adapter to determine if we should auto launch + // isTabLoaded will be true here if the empty state screen is shown instead of the list. + boolean rebuildCompleted = mChooserMultiProfilePagerAdapter.rebuildTabs( + mProfiles.getWorkProfilePresent()); + + mLayoutId = R.layout.chooser_grid_scrollable_preview; + + setContentView(mLayoutId); + mTabHost = findViewById(com.android.internal.R.id.profile_tabhost); + mViewPager = requireViewById(com.android.internal.R.id.profile_pager); + mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager); + boolean result = postRebuildList(rebuildCompleted); + Trace.endSection(); + return result; + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList. + * @param rebuildCompleted + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + protected boolean postRebuildList(boolean rebuildCompleted) { + return postRebuildListInternal(rebuildCompleted); + } + + /** + * Add a label to signify that the user can pick a different app. + * @param adapter The adapter used to provide data to item views. + */ + public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { + final boolean useHeader = adapter.hasFilteredItem(); + if (useHeader) { + FrameLayout stub = findViewById(com.android.internal.R.id.stub); + stub.setVisibility(View.VISIBLE); + TextView textView = (TextView) LayoutInflater.from(this).inflate( + R.layout.resolver_different_item_header, null, false); + if (mProfiles.getWorkProfilePresent()) { + textView.setGravity(Gravity.CENTER); + } + stub.addView(textView); + } + } + private void setupViewVisibilities() { + ChooserListAdapter activeListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + if (!mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { + addUseDifferentAppLabelIfNecessary(activeListAdapter); + } + } + /** + * Finishing procedures to be performed after the list has been rebuilt. + * @param rebuildCompleted + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + final boolean postRebuildListInternal(boolean rebuildCompleted) { + int count = mChooserMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + + // We only rebuild asynchronously when we have multiple elements to sort. In the case where + // we're already done, we can check if we should auto-launch immediately. + if (rebuildCompleted && maybeAutolaunchActivity()) { + return true; + } + + setupViewVisibilities(); + + if (mProfiles.getWorkProfilePresent() + || (mProfiles.getPrivateProfilePresent() + && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getPrivateProfile())))) { + setupProfileTabs(); + } + + return false; + } + + private void setupProfileTabs() { + mChooserMultiProfilePagerAdapter.setupProfileTabs( + getLayoutInflater(), + mTabHost, + mViewPager, + R.layout.resolver_profile_tab_button, + com.android.internal.R.id.profile_pager, + () -> onProfileTabSelected(mViewPager.getCurrentItem()), + new OnProfileSelectedListener() { + @Override + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) {} + + @Override + public void onProfilePageStateChanged(int state) { + onHorizontalSwipeStateChanged(state); + } + }); + mOnSwitchOnWorkSelectedListener = () -> { + View workTab = mTabHost.getTabWidget().getChildAt( + mChooserMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; + } + + ////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////// + private void createProfileRecords( AppPredictorFactory factory, IntentFilter targetIntentFilter) { - UserHandle mainUserHandle = getPersonalProfileUserHandle(); + UserHandle mainUserHandle = mProfiles.getPersonalHandle(); ProfileRecord record = createProfileRecord(mainUserHandle, targetIntentFilter, factory); if (record.shortcutLoader == null) { Tracer.INSTANCE.endLaunchToShortcutTrace(); } - UserHandle workUserHandle = getWorkProfileUserHandle(); + UserHandle workUserHandle = mProfiles.getWorkHandle(); if (workUserHandle != null) { createProfileRecord(workUserHandle, targetIntentFilter, factory); } + + UserHandle privateUserHandle = mProfiles.getPrivateHandle(); + if (privateUserHandle != null && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getPrivateProfile()))) { + createProfileRecord(privateUserHandle, targetIntentFilter, factory); + } } private ProfileRecord createProfileRecord( @@ -380,7 +1363,7 @@ public class ChooserActivity extends ResolverActivity implements ShortcutLoader shortcutLoader = ActivityManager.isLowRamDeviceStatic() ? null : createShortcutLoader( - getApplicationContext(), + this, appPredictor, userHandle, targetIntentFilter, @@ -392,7 +1375,7 @@ public class ChooserActivity extends ResolverActivity implements @Nullable private ProfileRecord getProfileRecord(UserHandle userHandle) { - return mProfileRecords.get(userHandle.getIdentifier(), null); + return mProfileRecords.get(userHandle.getIdentifier()); } @VisibleForTesting @@ -404,7 +1387,7 @@ public class ChooserActivity extends ResolverActivity implements Consumer<ShortcutLoader.Result> callback) { return new ShortcutLoader( context, - getLifecycle(), + getCoroutineScope(getLifecycle()), appPredictor, userHandle, targetIntentFilter, @@ -412,147 +1395,73 @@ public class ChooserActivity extends ResolverActivity implements } static SharedPreferences getPinnedSharedPrefs(Context context) { - // The code below is because in the android:ui process, no one can hear you scream. - // The package info in the context isn't initialized in the way it is for normal apps, - // so the standard, name-based context.getSharedPreferences doesn't work. Instead, we - // build the path manually below using the same policy that appears in ContextImpl. - // This fails silently under the hood if there's a problem, so if we find ourselves in - // the case where we don't have access to credential encrypted storage we just won't - // have our pinned target info. - final File prefsFile = new File(new File( - Environment.getDataUserCePackageDirectory(StorageManager.UUID_PRIVATE_INTERNAL, - context.getUserId(), context.getPackageName()), - "shared_prefs"), - PINNED_SHARED_PREFS_NAME + ".xml"); - return context.getSharedPreferences(prefsFile, MODE_PRIVATE); + return context.getSharedPreferences(PINNED_SHARED_PREFS_NAME, MODE_PRIVATE); } - @Override - protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - if (shouldShowTabs()) { - mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( - initialIntents, rList, filterLastUsed, targetDataLoader); - } else { - mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile( - initialIntents, rList, filterLastUsed, targetDataLoader); - } - return mChooserMultiProfilePagerAdapter; - } + private ChooserMultiProfilePagerAdapter createMultiProfilePagerAdapter( + Context context, + ProfilePagerResources profilePagerResources, + ChooserRequest request, + ProfileHelper profileHelper, + ProfileAvailability profileAvailability, + List<Intent> initialIntents, + int maxTargetsPerRow) { + Log.d(TAG, "createMultiProfilePagerAdapter"); + + Profile launchedAs = profileHelper.getLaunchedAsProfile(); + + Intent[] initialIntentArray = initialIntents.toArray(new Intent[0]); + List<Intent> payloadIntents = request.getPayloadIntents(); + + List<TabConfig<ChooserGridAdapter>> tabs = new ArrayList<>(); + for (Profile profile : profileHelper.getProfiles()) { + if (profile.getType() == Profile.Type.PRIVATE + && !profileAvailability.isAvailable(profile)) { + continue; + } + ChooserGridAdapter adapter = createChooserGridAdapter( + context, + payloadIntents, + profile.equals(launchedAs) ? initialIntentArray : null, + profile.getPrimary().getHandle() + ); + tabs.add(new TabConfig<>( + /* profile = */ profile.getType().ordinal(), + profilePagerResources.profileTabLabel(profile.getType()), + profilePagerResources.profileTabAccessibilityLabel(profile.getType()), + /* tabTag = */ profile.getType().name(), + adapter)); + } + + EmptyStateProvider emptyStateProvider = + createEmptyStateProvider(profileHelper, profileAvailability); + + Supplier<Boolean> workProfileQuietModeChecker = + () -> !(profileHelper.getWorkProfilePresent() + && profileAvailability.isAvailable( + requireNonNull(profileHelper.getWorkProfile()))); - @Override - protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean isSendAction = mChooserRequest.isSendActionTarget(); - - final EmptyState noWorkToPersonalEmptyState = - new DevicePolicyBlockerEmptyState( - /* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ - isSendAction ? RESOLVER_CANT_SHARE_WITH_PERSONAL : RESOLVER_CANT_ACCESS_PERSONAL, - /* defaultSubtitleResource= */ - isSendAction ? R.string.resolver_cant_share_with_personal_apps_explanation - : R.string.resolver_cant_access_personal_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, - /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); - - final EmptyState noPersonalToWorkEmptyState = - new DevicePolicyBlockerEmptyState( - /* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ - isSendAction ? RESOLVER_CANT_SHARE_WITH_WORK : RESOLVER_CANT_ACCESS_WORK, - /* defaultSubtitleResource= */ - isSendAction ? R.string.resolver_cant_share_with_work_apps_explanation - : R.string.resolver_cant_access_work_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, - /* devicePolicyEventCategory= */ ResolverActivity.METRICS_CATEGORY_CHOOSER); - - return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), - noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), getTabOwnerUserHandleForLaunch()); - } - - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - ChooserGridAdapter adapter = createChooserGridAdapter( - /* context */ this, - /* payloadIntents */ mIntents, - initialIntents, - rList, - filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle(), - targetDataLoader); return new ChooserMultiProfilePagerAdapter( /* context */ this, - adapter, - createEmptyStateProvider(/* workProfileUserHandle= */ null), - /* workProfileQuietModeChecker= */ () -> false, - /* workProfileUserHandle= */ null, - getCloneProfileUserHandle(), - mMaxTargetsPerRow); + ImmutableList.copyOf(tabs), + emptyStateProvider, + workProfileQuietModeChecker, + launchedAs.getType().ordinal(), + profileHelper.getWorkHandle(), + profileHelper.getCloneHandle(), + maxTargetsPerRow); } - private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - int selectedProfile = findSelectedProfile(); - ChooserGridAdapter personalAdapter = createChooserGridAdapter( - /* context */ this, - /* payloadIntents */ mIntents, - selectedProfile == PROFILE_PERSONAL ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle(), - targetDataLoader); - ChooserGridAdapter workAdapter = createChooserGridAdapter( - /* context */ this, - /* payloadIntents */ mIntents, - selectedProfile == PROFILE_WORK ? initialIntents : null, - rList, - filterLastUsed, - /* userHandle */ getWorkProfileUserHandle(), - targetDataLoader); - return new ChooserMultiProfilePagerAdapter( - /* context */ this, - personalAdapter, - workAdapter, - createEmptyStateProvider(/* workProfileUserHandle= */ getWorkProfileUserHandle()), - () -> mWorkProfileAvailability.isQuietModeEnabled(), - selectedProfile, - getWorkProfileUserHandle(), - getCloneProfileUserHandle(), - mMaxTargetsPerRow); + protected EmptyStateProvider createBlockerEmptyStateProvider() { + return new NoCrossProfileEmptyStateProvider( + mProfiles, + mDevicePolicyResources, + createCrossProfileIntentsChecker(), + mRequest.isSendActionTarget()); } private int findSelectedProfile() { - int selectedProfile = getSelectedProfileExtra(); - if (selectedProfile == -1) { - selectedProfile = getProfileForUser(getTabOwnerUserHandleForLaunch()); - } - return selectedProfile; - } - - @Override - protected boolean postRebuildList(boolean rebuildCompleted) { - updateStickyContentPreview(); - if (shouldShowStickyContentPreview() - || mChooserMultiProfilePagerAdapter - .getCurrentRootAdapter().getSystemRowCount() != 0) { - getChooserActivityLogger().logActionShareWithPreview( - mChooserContentPreviewUi.getPreferredContentPreview()); - } - return postRebuildListInternal(rebuildCompleted); + return mProfiles.getLaunchedAsProfileType().ordinal(); } /** @@ -560,12 +1469,11 @@ public class ChooserActivity extends ResolverActivity implements * @return true if it is work profile, false if it is parent profile (or no work profile is * set up) */ - protected boolean isWorkProfile() { - return getSystemService(UserManager.class) - .getUserInfo(UserHandle.myUserId()).isManagedProfile(); + private boolean isWorkProfile() { + return mProfiles.getLaunchedAsProfileType() == Profile.Type.WORK; } - @Override + //@Override protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { return new PackageMonitor() { @Override @@ -578,6 +1486,7 @@ public class ChooserActivity extends ResolverActivity implements /** * Update UI to reflect changes in data. */ + @Override public void handlePackagesChanged() { handlePackagesChanged(/* listAdapter */ null); } @@ -591,40 +1500,23 @@ public class ChooserActivity extends ResolverActivity implements // Refresh pinned items mPinnedSharedPrefs = getPinnedSharedPrefs(this); if (listAdapter == null) { - handlePackageChangePerProfile(mChooserMultiProfilePagerAdapter.getActiveListAdapter()); - if (mChooserMultiProfilePagerAdapter.getCount() > 1) { - handlePackageChangePerProfile( - mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); - } + mChooserMultiProfilePagerAdapter.refreshPackagesInAllTabs(); } else { - handlePackageChangePerProfile(listAdapter); + listAdapter.handlePackagesChanged(); } - updateProfileViewButton(); - } - - private void handlePackageChangePerProfile(ResolverListAdapter adapter) { - ProfileRecord record = getProfileRecord(adapter.getUserHandle()); - if (record != null && record.shortcutLoader != null) { - record.shortcutLoader.reset(); - } - adapter.handlePackagesChanged(); - } - - @Override - protected void onResume() { - super.onResume(); - Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); - maybeCancelFinishAnimation(); - - mRefinementManager.onActivityResume(); } @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager.isLayoutRtl()) { - mMultiProfilePagerAdapter.setupViewPager(viewPager); + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + + if (mSystemWindowInsets != null) { + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + } + if (mViewPager.isLayoutRtl()) { + mChooserMultiProfilePagerAdapter.setupViewPager(mViewPager); } mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); @@ -654,7 +1546,7 @@ public class ChooserActivity extends ResolverActivity implements } private void updateTabPadding() { - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { View tabs = findViewById(com.android.internal.R.id.tabs); float iconSize = getResources().getDimension(R.dimen.chooser_icon_size); // The entire width consists of icons or padding. Divide the item padding in half to get @@ -685,7 +1577,8 @@ public class ChooserActivity extends ResolverActivity implements ViewGroup layout = mChooserContentPreviewUi.displayContentPreview( getResources(), getLayoutInflater(), - parent); + parent, + requireViewById(R.id.chooser_headline_row_container)); if (layout != null) { adjustPreviewWidth(getResources().getConfiguration().orientation, layout); @@ -711,46 +1604,17 @@ public class ChooserActivity extends ResolverActivity implements return resolver.query(uri, null, null, null, null); } - @Override - protected void onStop() { - super.onStop(); - mRefinementManager.onActivityStop(isChangingConfigurations()); - - if (maybeCancelFinishAnimation()) { - finish(); - } - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - if (isFinishing()) { - mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); - } - - mBackgroundThreadPoolExecutor.shutdownNow(); - - destroyProfileRecords(); - } - private void destroyProfileRecords() { - for (int i = 0; i < mProfileRecords.size(); ++i) { - mProfileRecords.valueAt(i).destroy(); - } + mProfileRecords.values().forEach(ProfileRecord::destroy); mProfileRecords.clear(); } @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { - if (mChooserRequest == null) { - return defIntent; - } - Intent result = defIntent; - if (mChooserRequest.getReplacementExtras() != null) { + if (mRequest.getReplacementExtras() != null) { final Bundle replExtras = - mChooserRequest.getReplacementExtras().getBundle(aInfo.packageName); + mRequest.getReplacementExtras().getBundle(aInfo.packageName); if (replExtras != null) { result = new Intent(defIntent); result.putExtras(replExtras); @@ -769,33 +1633,22 @@ public class ChooserActivity extends ResolverActivity implements return result; } - @Override - public void onActivityStarted(TargetInfo cti) { - if (mChooserRequest.getChosenComponentSender() != null) { + private void maybeSendShareResult(TargetInfo cti) { + if (mShareResultSender != null) { final ComponentName target = cti.getResolvedComponentName(); if (target != null) { - final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target); - try { - mChooserRequest.getChosenComponentSender().sendIntent( - this, Activity.RESULT_OK, fillIn, null, null); - } catch (IntentSender.SendIntentException e) { - Slog.e(TAG, "Unable to launch supplied IntentSender to report " - + "the chosen component: " + e); - } + mShareResultSender.onComponentSelected(target, cti.isChooserTargetInfo()); } } } private void addCallerChooserTargets() { - if (!mChooserRequest.getCallerChooserTargets().isEmpty()) { + if (!mRequest.getCallerChooserTargets().isEmpty()) { // Send the caller's chooser targets only to the default profile. - UserHandle defaultUser = (findSelectedProfile() == PROFILE_WORK) - ? getAnnotatedUserHandles().workProfileUserHandle - : getAnnotatedUserHandles().personalProfileUserHandle; - if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle() == defaultUser) { + if (mChooserMultiProfilePagerAdapter.getActiveProfile() == findSelectedProfile()) { mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( /* origTarget */ null, - new ArrayList<>(mChooserRequest.getCallerChooserTargets()), + new ArrayList<>(mRequest.getCallerChooserTargets()), TARGET_TYPE_DEFAULT, /* directShareShortcutInfoCache */ Collections.emptyMap(), /* directShareAppTargetCache */ Collections.emptyMap()); @@ -803,26 +1656,19 @@ public class ChooserActivity extends ResolverActivity implements } } - @Override - public int getLayoutResource() { - return R.layout.chooser_grid; - } - @Override // ResolverListCommunicator public boolean shouldGetActivityMetadata() { return true; } - @Override public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { - // Note that this is only safe because the Intent handled by the ChooserActivity is - // guaranteed to contain no extras unknown to the local ClassLoader. That is why this - // method can not be replaced in the ResolverActivity whole hog. - if (!super.shouldAutoLaunchSingleChoice(target)) { + if (target.isSuspended()) { return false; } - return getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); + // TODO: migrate to ChooserRequest + return mViewModel.getActivityModel().getIntent() + .getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); } private void showTargetDetails(TargetInfo targetInfo) { @@ -837,8 +1683,9 @@ public class ChooserActivity extends ResolverActivity implements // TODO: implement these type-conditioned behaviors polymorphically, and consider moving // the logic into `ChooserTargetActionsDialogFragment.show()`. boolean isShortcutPinned = targetInfo.isSelectableTargetInfo() && targetInfo.isPinned(); - IntentFilter intentFilter = targetInfo.isSelectableTargetInfo() - ? mChooserRequest.getTargetIntentFilter() : null; + IntentFilter intentFilter; + intentFilter = targetInfo.isSelectableTargetInfo() + ? mRequest.getShareTargetFilter() : null; String shortcutTitle = targetInfo.isSelectableTargetInfo() ? targetInfo.getDisplayLabel().toString() : null; String shortcutIdKey = targetInfo.getDirectShareShortcutId(); @@ -848,31 +1695,32 @@ public class ChooserActivity extends ResolverActivity implements targetList, // Adding userHandle from ResolveInfo allows the app icon in Dialog Box to be // resolved correctly within the same tab. - getResolveInfoUserHandle( - targetInfo.getResolveInfo(), - mChooserMultiProfilePagerAdapter.getCurrentUserHandle()), + targetInfo.getResolveInfo().userHandle, shortcutIdKey, shortcutTitle, isShortcutPinned, intentFilter); } - @Override - protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { + protected boolean onTargetSelected(TargetInfo target) { if (mRefinementManager.maybeHandleSelection( target, - mChooserRequest.getRefinementIntentSender(), + mRequest.getRefinementIntentSender(), getApplication(), getMainThreadHandler())) { return false; } updateModelAndChooserCounts(target); maybeRemoveSharedText(target); - return super.onTargetSelected(target, alwaysCheck); + safelyStartActivity(target); + + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + return !target.isSuspended(); } @Override - public void startSelected(int which, boolean always, boolean filtered) { + public void startSelected(int which, /* unused */ boolean always, boolean filtered) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); TargetInfo targetInfo = currentListAdapter @@ -883,7 +1731,7 @@ public class ChooserActivity extends ResolverActivity implements final long selectionCost = System.currentTimeMillis() - mChooserShownTime; - if (targetInfo.isMultiDisplayResolveInfo()) { + if ((targetInfo != null) && targetInfo.isMultiDisplayResolveInfo()) { MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; if (!mti.hasSelected()) { // Add userHandle based badge to the stackedAppDialogBox. @@ -891,24 +1739,47 @@ public class ChooserActivity extends ResolverActivity implements getSupportFragmentManager(), mti, which, - getResolveInfoUserHandle( - targetInfo.getResolveInfo(), - mChooserMultiProfilePagerAdapter.getCurrentUserHandle())); + targetInfo.getResolveInfo().userHandle); return; } } + if (isFinishing()) { + return; + } - super.startSelected(which, always, filtered); + TargetInfo target = mChooserMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(which, filtered); + if (target != null) { + if (onTargetSelected(target)) { + MetricsLogger.action( + this, MetricsEvent.ACTION_APP_DISAMBIG_TAP); + MetricsLogger.action(this, + mChooserMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED + : MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); + finish(); + } + } - if (currentListAdapter.getCount() > 0) { + // TODO: both of the conditions around this switch logic *should* be redundant, and + // can be removed if certain invariants can be guaranteed. In particular, it seems + // like targetInfo (from `ChooserListAdapter.targetInfoForPosition()`) is *probably* + // expected to be null only at out-of-bounds indexes where `getPositionTargetType()` + // returns TARGET_BAD; then the switch falls through to a default no-op, and we don't + // need to null-check targetInfo. We only need the null check if it's possible that + // the ChooserListAdapter contains null elements "in the middle" of its list data, + // such that they're classified as belonging to one of the real target types. That + // should probably never happen. But why would this method ever be invoked with a + // null target at all? Even an out-of-bounds index should never be "selected"... + if ((currentListAdapter.getCount() > 0) && (targetInfo != null)) { switch (currentListAdapter.getPositionTargetType(which)) { case ChooserListAdapter.TARGET_SERVICE: - getChooserActivityLogger().logShareTargetSelected( - ChooserActivityLogger.SELECTION_TYPE_SERVICE, + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_SERVICE, targetInfo.getResolveInfo().activityInfo.processName, which, /* directTargetAlsoRanked= */ getRankedPosition(targetInfo), - mChooserRequest.getCallerChooserTargets().size(), + mRequest.getCallerChooserTargets().size(), targetInfo.getHashedTargetIdForMetrics(this), targetInfo.isPinned(), mIsSuccessfullySelected, @@ -917,8 +1788,8 @@ public class ChooserActivity extends ResolverActivity implements return; case ChooserListAdapter.TARGET_CALLER: case ChooserListAdapter.TARGET_STANDARD: - getChooserActivityLogger().logShareTargetSelected( - ChooserActivityLogger.SELECTION_TYPE_APP, + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_APP, targetInfo.getResolveInfo().activityInfo.processName, (which - currentListAdapter.getSurfacedTargetInfo().size()), /* directTargetAlsoRanked= */ -1, @@ -934,8 +1805,8 @@ public class ChooserActivity extends ResolverActivity implements // they are from the alphabetical pool. // TODO: why do we log a different selection type if the -1 value already // designates the same condition? - getChooserActivityLogger().logShareTargetSelected( - ChooserActivityLogger.SELECTION_TYPE_STANDARD, + getEventLog().logShareTargetSelected( + EventLog.SELECTION_TYPE_STANDARD, targetInfo.getResolveInfo().activityInfo.processName, /* value= */ -1, /* directTargetAlsoRanked= */ -1, @@ -945,7 +1816,6 @@ public class ChooserActivity extends ResolverActivity implements mIsSuccessfullySelected, selectionCost ); - return; } } } @@ -967,19 +1837,8 @@ public class ChooserActivity extends ResolverActivity implements return -1; } - @Override - protected boolean shouldAddFooterView() { - // To accommodate for window insets - return true; - } - - @Override protected void applyFooterView(int height) { - int count = mChooserMultiProfilePagerAdapter.getItemCount(); - - for (int i = 0; i < count; i++) { - mChooserMultiProfilePagerAdapter.getAdapterForIndex(i).setFooterHeight(height); - } + mChooserMultiProfilePagerAdapter.setFooterHeightInEveryAdapter(height); } private void logDirectShareTargetReceived(UserHandle forUser) { @@ -987,7 +1846,7 @@ public class ChooserActivity extends ResolverActivity implements if (profileRecord == null) { return; } - getChooserActivityLogger().logDirectShareTargetReceived( + getEventLog().logDirectShareTargetReceived( MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER, (int) (SystemClock.elapsedRealtime() - profileRecord.loadingStartTime)); } @@ -999,7 +1858,7 @@ public class ChooserActivity extends ResolverActivity implements if (info != null) { sendClickToAppPredictor(info); final ResolveInfo ri = info.getResolveInfo(); - Intent targetIntent = getTargetIntent(); + Intent targetIntent = mRequest.getTargetIntent(); if (ri != null && ri.activityInfo != null && targetIntent != null) { ChooserListAdapter currentListAdapter = mChooserMultiProfilePagerAdapter.getActiveListAdapter(); @@ -1022,12 +1881,12 @@ public class ChooserActivity extends ResolverActivity implements mIsSuccessfullySelected = true; } - private void maybeRemoveSharedText(@androidx.annotation.NonNull TargetInfo targetInfo) { + private void maybeRemoveSharedText(@NonNull TargetInfo targetInfo) { Intent targetIntent = targetInfo.getTargetIntent(); if (targetIntent == null) { return; } - Intent originalTargetIntent = new Intent(mChooserRequest.getTargetIntent()); + Intent originalTargetIntent = new Intent(mRequest.getTargetIntent()); // Our TargetInfo implementations add associated component to the intent, let's do the same // for the sake of the comparison below. if (targetIntent.getComponent() != null) { @@ -1097,107 +1956,36 @@ public class ChooserActivity extends ResolverActivity implements ProfileRecord record = getProfileRecord(userHandle); // We cannot use APS service when clone profile is present as APS service cannot sort // cross profile targets as of now. - return (record == null || getCloneProfileUserHandle() != null) ? null : record.appPredictor; + return ((record == null) || (mProfiles.getCloneUserPresent())) + ? null : record.appPredictor; } - /** - * Sort intents alphabetically based on display label. - */ - static class AzInfoComparator implements Comparator<DisplayResolveInfo> { - Comparator<DisplayResolveInfo> mComparator; - AzInfoComparator(Context context) { - Collator collator = Collator - .getInstance(context.getResources().getConfiguration().locale); - // Adding two stage comparator, first stage compares using displayLabel, next stage - // compares using resolveInfo.userHandle - mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator) - .thenComparingInt(displayResolveInfo -> - getResolveInfoUserHandle( - displayResolveInfo.getResolveInfo(), - // TODO: User resolveInfo.userHandle, once its available. - UserHandle.SYSTEM).getIdentifier()); - } - - @Override - public int compare( - DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) { - return mComparator.compare(lhsp, rhsp); - } - } - - protected ChooserActivityLogger getChooserActivityLogger() { - if (mChooserActivityLogger == null) { - mChooserActivityLogger = new ChooserActivityLogger(); - } - return mChooserActivityLogger; - } - - public class ChooserListController extends ResolverListController { - public ChooserListController( - Context context, - PackageManager pm, - Intent targetIntent, - String referrerPackageName, - int launchedFromUid, - AbstractResolverComparator resolverComparator, - UserHandle queryIntentsAsUser) { - super( - context, - pm, - targetIntent, - referrerPackageName, - launchedFromUid, - resolverComparator, - queryIntentsAsUser); - } - - @Override - boolean isComponentFiltered(ComponentName name) { - return mChooserRequest.getFilteredComponentNames().contains(name); - } - - @Override - public boolean isComponentPinned(ComponentName name) { - return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false); - } + protected EventLog getEventLog() { + return mEventLog; } - @VisibleForTesting - public ChooserGridAdapter createChooserGridAdapter( + private ChooserGridAdapter createChooserGridAdapter( Context context, List<Intent> payloadIntents, Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { + UserHandle userHandle) { ChooserListAdapter chooserListAdapter = createChooserListAdapter( context, payloadIntents, initialIntents, - rList, - filterLastUsed, + /* TODO: not used, remove. rList= */ null, + /* TODO: not used, remove. filterLastUsed= */ false, createListController(userHandle), userHandle, - getTargetIntent(), - mChooserRequest, - mMaxTargetsPerRow, - targetDataLoader); + mRequest.getTargetIntent(), + mRequest.getReferrerFillInIntent(), + mMaxTargetsPerRow + ); return new ChooserGridAdapter( context, new ChooserGridAdapter.ChooserActivityDelegate() { @Override - public boolean shouldShowTabs() { - return ChooserActivity.this.shouldShowTabs(); - } - - @Override - public View buildContentPreview(ViewGroup parent) { - return createContentPreviewView(parent); - } - - @Override public void onTargetSelected(int itemIndex) { startSelected(itemIndex, false, true); } @@ -1215,17 +2003,11 @@ public class ChooserActivity extends ResolverActivity implements showTargetDetails(longPressedTargetInfo); } } - - @Override - public void updateProfileViewButton(View newButtonFromProfileRow) { - mProfileView = newButtonFromProfileRow; - mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); - ChooserActivity.this.updateProfileViewButton(); - } }, chooserListAdapter, shouldShowContentPreview(), - mMaxTargetsPerRow); + mMaxTargetsPerRow, + mFeatureFlags); } @VisibleForTesting @@ -1238,12 +2020,9 @@ public class ChooserActivity extends ResolverActivity implements ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, - ChooserRequestParameters chooserRequest, - int maxTargetsPerRow, - TargetDataLoader targetDataLoader) { - UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getPersonalProfileUserHandle()) - ? getCloneProfileUserHandle() : userHandle; + Intent referrerFillInIntent, + int maxTargetsPerRow) { + UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle); return new ChooserListAdapter( context, payloadIntents, @@ -1253,54 +2032,72 @@ public class ChooserActivity extends ResolverActivity implements createListController(userHandle), userHandle, targetIntent, + referrerFillInIntent, this, - context.getPackageManager(), - getChooserActivityLogger(), - chooserRequest, + mPackageManager, + getEventLog(), maxTargetsPerRow, initialIntentsUserSpace, - targetDataLoader); + mTargetDataLoader, + () -> { + ProfileRecord record = getProfileRecord(userHandle); + if (record != null && record.shortcutLoader != null) { + record.shortcutLoader.reset(); + } + }, + mFeatureFlags); } - @Override - protected void onWorkProfileStatusUpdated() { - UserHandle workUser = getWorkProfileUserHandle(); + private void onWorkProfileStatusUpdated() { + UserHandle workUser = mProfiles.getWorkHandle(); ProfileRecord record = workUser == null ? null : getProfileRecord(workUser); if (record != null && record.shortcutLoader != null) { record.shortcutLoader.reset(); } - super.onWorkProfileStatusUpdated(); + if (mChooserMultiProfilePagerAdapter.getCurrentUserHandle().equals( + mProfiles.getWorkHandle())) { + mChooserMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mChooserMultiProfilePagerAdapter.clearInactiveProfileCache(); + } } - @Override @VisibleForTesting protected ChooserListController createListController(UserHandle userHandle) { AppPredictor appPredictor = getAppPredictor(userHandle); AbstractResolverComparator resolverComparator; if (appPredictor != null) { - resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), - getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger(), - getIntegratedDeviceComponents().getNearbySharingComponent()); + resolverComparator = new AppPredictionServiceResolverComparator( + this, + mRequest.getTargetIntent(), + mRequest.getLaunchedFromPackage(), + appPredictor, + userHandle, + getEventLog(), + mNearbyShare.orElse(null) + ); } else { resolverComparator = new ResolverRankerServiceResolverComparator( this, - getTargetIntent(), - getReferrerPackageName(), + mRequest.getTargetIntent(), + mRequest.getReferrerPackage(), null, - getChooserActivityLogger(), + getEventLog(), getResolverRankerServiceUserHandleList(userHandle), - getIntegratedDeviceComponents().getNearbySharingComponent()); + mNearbyShare.orElse(null)); } return new ChooserListController( this, - mPm, - getTargetIntent(), - getReferrerPackageName(), - getAnnotatedUserHandles().userIdOfCallingApp, + mPackageManager, + mRequest.getTargetIntent(), + mRequest.getReferrerPackage(), + mViewModel.getActivityModel().getLaunchedFromUid(), resolverComparator, - getQueryIntentsUser(userHandle)); + mProfiles.getQueryIntentsHandle(userHandle), + mRequest.getFilteredComponentNames(), + mPinnedSharedPrefs); } @VisibleForTesting @@ -1308,18 +2105,80 @@ public class ChooserActivity extends ResolverActivity implements return PreviewViewModel.Companion.getFactory(); } - private ChooserActionFactory createChooserActionFactory() { + private ChooserContentPreviewUi.ActionFactory decorateActionFactoryWithRefinement( + ChooserContentPreviewUi.ActionFactory originalFactory) { + if (!mFeatureFlags.refineSystemActions()) { + return originalFactory; + } + + return new ChooserContentPreviewUi.ActionFactory() { + @Override + @Nullable + public Runnable getEditButtonRunnable() { + return () -> { + if (!mRefinementManager.maybeHandleSelection( + RefinementType.EDIT_ACTION, + List.of(mRequest.getTargetIntent()), + null, + mRequest.getRefinementIntentSender(), + getApplication(), + getMainThreadHandler())) { + originalFactory.getEditButtonRunnable().run(); + } + }; + } + + @Override + @Nullable + public Runnable getCopyButtonRunnable() { + return () -> { + if (!mRefinementManager.maybeHandleSelection( + RefinementType.COPY_ACTION, + List.of(mRequest.getTargetIntent()), + null, + mRequest.getRefinementIntentSender(), + getApplication(), + getMainThreadHandler())) { + originalFactory.getCopyButtonRunnable().run(); + } + }; + } + + @Override + public List<ActionRow.Action> createCustomActions() { + return originalFactory.createCustomActions(); + } + + @Override + @Nullable + public ActionRow.Action getModifyShareAction() { + return originalFactory.getModifyShareAction(); + } + + @Override + public Consumer<Boolean> getExcludeSharedTextAction() { + return originalFactory.getExcludeSharedTextAction(); + } + }; + } + + private ChooserActionFactory createChooserActionFactory(Intent targetIntent) { return new ChooserActionFactory( this, - mChooserRequest, - mIntegratedDeviceComponents, - getChooserActivityLogger(), + targetIntent, + mRequest.getLaunchedFromPackage(), + mRequest.getChooserActions(), + mImageEditor, + getEventLog(), (isExcluded) -> mExcludeSharedText = isExcluded, this::getFirstVisibleImgPreviewView, new ChooserActionFactory.ActionActivityStarter() { @Override public void safelyStartActivityAsPersonalProfileUser(TargetInfo targetInfo) { - safelyStartActivityAsUser(targetInfo, getPersonalProfileUserHandle()); + safelyStartActivityAsUser( + targetInfo, + mProfiles.getPersonalHandle() + ); finish(); } @@ -1329,16 +2188,33 @@ public class ChooserActivity extends ResolverActivity implements ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( ChooserActivity.this, sharedElement, sharedElementName); safelyStartActivityAsUser( - targetInfo, getPersonalProfileUserHandle(), options.toBundle()); - startFinishAnimation(); + targetInfo, + mProfiles.getPersonalHandle(), + options.toBundle()); + // Can't finish right away because the shared element transition may not + // be ready to start. + mFinishWhenStopped = true; } }, - (status) -> { - if (status != null) { - setResult(status); - } - finish(); - }); + mShareResultSender, + this::finishWithStatus, + mClipboardManager); + } + + private Supplier<ActionRow.Action> createModifyShareActionFactory() { + return () -> ChooserActionFactory.createCustomAction( + ChooserActivity.this, + mRequest.getModifyShareAction(), + () -> getEventLog().logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE), + mShareResultSender, + this::finishWithStatus); + } + + private void finishWithStatus(@Nullable Integer status) { + if (status != null) { + setResult(status); + } + finish(); } /* @@ -1348,7 +2224,7 @@ public class ChooserActivity extends ResolverActivity implements */ private void handleLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { - if (mChooserMultiProfilePagerAdapter == null) { + if (mChooserMultiProfilePagerAdapter == null || !isProfilePagerAdapterAttached()) { return; } RecyclerView recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); @@ -1383,8 +2259,7 @@ public class ChooserActivity extends ResolverActivity implements updateTabPadding(); } - UserHandle currentUserHandle = mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); - int currentProfile = getProfileForUser(currentUserHandle); + int currentProfile = mChooserMultiProfilePagerAdapter.getActiveProfile(); int initialProfile = findSelectedProfile(); if (currentProfile != initialProfile) { return; @@ -1410,9 +2285,7 @@ public class ChooserActivity extends ResolverActivity implements int top, int bottom, RecyclerView recyclerView, ChooserGridAdapter gridAdapter) { int offset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; - int rowsToShow = gridAdapter.getSystemRowCount() - + gridAdapter.getProfileRowCount() - + gridAdapter.getServiceTargetRowCount() + int rowsToShow = gridAdapter.getServiceTargetRowCount() + gridAdapter.getCallerAndRankedTargetRowCount(); // then this is most likely not a SEND_* action, so check @@ -1434,7 +2307,7 @@ public class ChooserActivity extends ResolverActivity implements offset += stickyContentPreview.getHeight(); } - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { offset += findViewById(com.android.internal.R.id.tabs).getHeight(); } @@ -1457,7 +2330,8 @@ public class ChooserActivity extends ResolverActivity implements rowsToShow--; } } else { - ViewGroup currentEmptyStateView = getActiveEmptyStateView(); + ViewGroup currentEmptyStateView = + mChooserMultiProfilePagerAdapter.getActiveEmptyStateView(); if (currentEmptyStateView.getVisibility() == View.VISIBLE) { offset += currentEmptyStateView.getHeight(); } @@ -1466,82 +2340,73 @@ public class ChooserActivity extends ResolverActivity implements return Math.min(offset, bottom - top); } + private boolean isProfilePagerAdapterAttached() { + return mChooserMultiProfilePagerAdapter == mViewPager.getAdapter(); + } + /** * If we have a tabbed view and are showing 1 row in the current profile and an empty - * state screen in the other profile, to prevent cropping of the empty state screen we show + * state screen in another profile, to prevent cropping of the empty state screen we show * a second row in the current profile. */ private boolean shouldShowExtraRow(int rowsToShow) { - return shouldShowTabs() - && rowsToShow == 1 - && mChooserMultiProfilePagerAdapter.shouldShowEmptyStateScreen( - mChooserMultiProfilePagerAdapter.getInactiveListAdapter()); - } - - /** - * Returns {@link #PROFILE_WORK}, if the given user handle matches work user handle. - * Returns {@link #PROFILE_PERSONAL}, otherwise. - **/ - private int getProfileForUser(UserHandle currentUserHandle) { - if (currentUserHandle.equals(getWorkProfileUserHandle())) { - return PROFILE_WORK; - } - // We return personal profile, as it is the default when there is no work profile, personal - // profile represents rootUser, clonedUser & secondaryUser, covering all use cases. - return PROFILE_PERSONAL; - } - - private ViewGroup getActiveEmptyStateView() { - int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); - return mChooserMultiProfilePagerAdapter.getEmptyStateView(currentPage); + return rowsToShow == 1 + && mChooserMultiProfilePagerAdapter + .shouldShowEmptyStateScreenInAnyInactiveAdapter(); } - @Override // ResolverListCommunicator - public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - mChooserMultiProfilePagerAdapter.getActiveListAdapter().notifyDataSetChanged(); - super.onHandlePackagesChanged(listAdapter); - } - - @Override - public void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { + protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { setupScrollListener(); maybeSetupGlobalLayoutListener(); ChooserListAdapter chooserListAdapter = (ChooserListAdapter) listAdapter; - if (chooserListAdapter.getUserHandle() - .equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) { + UserHandle listProfileUserHandle = chooserListAdapter.getUserHandle(); + if (listProfileUserHandle.equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) { mChooserMultiProfilePagerAdapter.getActiveAdapterView() .setAdapter(mChooserMultiProfilePagerAdapter.getCurrentRootAdapter()); mChooserMultiProfilePagerAdapter .setupListAdapter(mChooserMultiProfilePagerAdapter.getCurrentPage()); } + //TODO: move this block inside ChooserListAdapter (should be called when + // ResolverListAdapter#mPostListReadyRunnable is executed. if (chooserListAdapter.getDisplayResolveInfoCount() == 0) { + if (rebuildComplete && mChooserServiceFeatureFlags.chooserPayloadToggling()) { + onAppTargetsLoaded(listAdapter); + } chooserListAdapter.notifyDataSetChanged(); } else { - chooserListAdapter.updateAlphabeticalList(); + if (mChooserServiceFeatureFlags.chooserPayloadToggling()) { + chooserListAdapter.updateAlphabeticalList( + () -> onAppTargetsLoaded(listAdapter)); + } else { + chooserListAdapter.updateAlphabeticalList(); + } } if (rebuildComplete) { - long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listAdapter.getUserHandle()); + long duration = Tracer.INSTANCE.endAppTargetLoadingSection(listProfileUserHandle); if (duration >= 0) { Log.d(TAG, "app target loading time " + duration + " ms"); } addCallerChooserTargets(); - getChooserActivityLogger().logSharesheetAppLoadComplete(); - maybeQueryAdditionalPostProcessingTargets(chooserListAdapter); + getEventLog().logSharesheetAppLoadComplete(); + maybeQueryAdditionalPostProcessingTargets( + listProfileUserHandle, + chooserListAdapter.getDisplayResolveInfos()); mLatencyTracker.onActionEnd(ACTION_LOAD_SHARE_SHEET); } } - private void maybeQueryAdditionalPostProcessingTargets(ChooserListAdapter chooserListAdapter) { - UserHandle userHandle = chooserListAdapter.getUserHandle(); + private void maybeQueryAdditionalPostProcessingTargets( + UserHandle userHandle, + DisplayResolveInfo[] displayResolveInfos) { ProfileRecord record = getProfileRecord(userHandle); if (record == null || record.shortcutLoader == null) { return; } record.loadingStartTime = SystemClock.elapsedRealtime(); - record.shortcutLoader.updateAppTargets(chooserListAdapter.getDisplayResolveInfos()); + record.shortcutLoader.updateAppTargets(displayResolveInfos); } @MainThread @@ -1567,7 +2432,7 @@ public class ChooserActivity extends ResolverActivity implements adapter.completeServiceTargetLoading(); } - if (mMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == adapter) { long duration = Tracer.INSTANCE.endLaunchToShortcutTrace(); if (duration >= 0) { Log.d(TAG, "stat to first shortcut time: " + duration + " ms"); @@ -1575,20 +2440,22 @@ public class ChooserActivity extends ResolverActivity implements } logDirectShareTargetReceived(userHandle); sendVoiceChoicesIfNeeded(); - getChooserActivityLogger().logSharesheetDirectLoadComplete(); + getEventLog().logSharesheetDirectLoadComplete(); } private void setupScrollListener() { if (mResolverDrawerLayout == null) { return; } - int elevatedViewResId = shouldShowTabs() ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; + int elevatedViewResId = mProfiles.getWorkProfilePresent() + ? com.android.internal.R.id.tabs : com.android.internal.R.id.chooser_header; final View elevatedView = mResolverDrawerLayout.findViewById(elevatedViewResId); final float defaultElevation = elevatedView.getElevation(); final float chooserHeaderScrollElevation = getResources().getDimensionPixelSize(R.dimen.chooser_header_scroll_elevation); mChooserMultiProfilePagerAdapter.getActiveAdapterView().addOnScrollListener( new RecyclerView.OnScrollListener() { + @Override public void onScrollStateChanged(RecyclerView view, int scrollState) { if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) { @@ -1603,6 +2470,7 @@ public class ChooserActivity extends ResolverActivity implements } } + @Override public void onScrolled(RecyclerView view, int dx, int dy) { if (view.getChildCount() > 0) { View child = view.getLayoutManager().findViewByPosition(0); @@ -1618,7 +2486,7 @@ public class ChooserActivity extends ResolverActivity implements } private void maybeSetupGlobalLayoutListener() { - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { return; } final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); @@ -1649,11 +2517,13 @@ public class ChooserActivity extends ResolverActivity implements } private boolean shouldShowStickyContentPreviewNoOrientationCheck() { - return shouldShowTabs() - && (mMultiProfilePagerAdapter.getListAdapterForUserHandle( - UserHandle.of(UserHandle.myUserId())).getCount() > 0 - || shouldShowContentPreviewWhenEmpty()) - && shouldShowContentPreview(); + if (!shouldShowContentPreview()) { + return false; + } + ResolverListAdapter adapter = mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( + UserHandle.of(UserHandle.myUserId())); + boolean isEmpty = adapter == null || adapter.getCount() == 0; + return !isEmpty || shouldShowContentPreviewWhenEmpty(); } /** @@ -1671,7 +2541,7 @@ public class ChooserActivity extends ResolverActivity implements * @return true if we want to show the content preview area */ protected boolean shouldShowContentPreview() { - return (mChooserRequest != null) && mChooserRequest.isSendActionTarget(); + return mRequest.isSendActionTarget(); } private void updateStickyContentPreview() { @@ -1715,53 +2585,22 @@ public class ChooserActivity extends ResolverActivity implements contentPreviewContainer.setVisibility(View.GONE); } - private void startFinishAnimation() { - View rootView = findRootView(); - if (rootView != null) { - rootView.startAnimation(new FinishAnimation(this, rootView)); - } - } - - private boolean maybeCancelFinishAnimation() { - View rootView = findRootView(); - Animation animation = (rootView == null) ? null : rootView.getAnimation(); - if (animation instanceof FinishAnimation) { - boolean hasEnded = animation.hasEnded(); - animation.cancel(); - rootView.clearAnimation(); - return !hasEnded; - } - return false; - } - - private View findRootView() { - if (mContentView == null) { - mContentView = findViewById(android.R.id.content); - } - return mContentView; - } - - /** - * Intentionally override the {@link ResolverActivity} implementation as we only need that - * implementation for the intent resolver case. - */ - @Override - public void onButtonClick(View v) {} - - /** - * Intentionally override the {@link ResolverActivity} implementation as we only need that - * implementation for the intent resolver case. - */ - @Override - protected void resetButtonBar() {} - - @Override protected String getMetricsCategory() { return METRICS_CATEGORY_CHOOSER; } - @Override - protected void onProfileTabSelected() { + protected void onProfileTabSelected(int currentPage) { + setupViewVisibilities(); + maybeLogProfileChange(); + if (mProfiles.getWorkProfilePresent()) { + // The device policy logger is only concerned with sessions that include a work profile. + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(currentPage) + .setStrings(getMetricsCategory()) + .write(); + } + // This fixes an edge case where after performing a variety of gestures, vertical scrolling // ends up disabled. That's because at some point the old tab's vertical scrolling is // disabled and the new tab's is enabled. For context, see b/159997845 @@ -1771,25 +2610,28 @@ public class ChooserActivity extends ResolverActivity implements } } - @Override protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - if (shouldShowTabs()) { + mSystemWindowInsets = insets.getInsets(WindowInsets.Type.systemBars()); + if (mFeatureFlags.fixEmptyStatePaddingBug() || mProfiles.getWorkProfilePresent()) { mChooserMultiProfilePagerAdapter - .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); - mChooserMultiProfilePagerAdapter.setupContainerPadding( - getActiveEmptyStateView().findViewById(com.android.internal.R.id.resolver_empty_state_container)); + .setEmptyStateBottomOffset(mSystemWindowInsets.bottom); } - WindowInsets result = super.onApplyWindowInsets(v, insets); + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + + // Need extra padding so the list can fully scroll up + // To accommodate for window insets + applyFooterView(mSystemWindowInsets.bottom); + if (mResolverDrawerLayout != null) { mResolverDrawerLayout.requestLayout(); } - return result; + return WindowInsets.CONSUMED; } private void setHorizontalScrollingEnabled(boolean enabled) { - ResolverViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - viewPager.setSwipingEnabled(enabled); + mViewPager.setSwipingEnabled(enabled); } private void setVerticalScrollEnabled(boolean enabled) { @@ -1799,7 +2641,6 @@ public class ChooserActivity extends ResolverActivity implements layoutManager.setVerticalScrollEnabled(enabled); } - @Override void onHorizontalSwipeStateChanged(int state) { if (state == ViewPager.SCROLL_STATE_DRAGGING) { if (mScrollStatus == SCROLL_STATUS_IDLE) { @@ -1814,74 +2655,8 @@ public class ChooserActivity extends ResolverActivity implements } } - /** - * Used in combination with the scene transition when launching the image editor - */ - private static class FinishAnimation extends AlphaAnimation implements - Animation.AnimationListener { - @Nullable - private Activity mActivity; - @Nullable - private View mRootView; - private final float mFromAlpha; - - FinishAnimation(@NonNull Activity activity, @NonNull View rootView) { - super(rootView.getAlpha(), 0.0f); - mActivity = activity; - mRootView = rootView; - mFromAlpha = rootView.getAlpha(); - setInterpolator(new LinearInterpolator()); - long duration = activity.getWindow().getTransitionBackgroundFadeDuration(); - setDuration(duration); - // The scene transition animation looks better when it's not overlapped with this - // fade-out animation thus the delay. - // It is most likely that the image editor will cause this activity to stop and this - // animation will be cancelled in the background without running (i.e. we'll animate - // only when this activity remains partially visible after the image editor launch). - setStartOffset(duration); - super.setAnimationListener(this); - } - - @Override - public void setAnimationListener(AnimationListener listener) { - throw new UnsupportedOperationException(); - } - - @Override - public void cancel() { - if (mRootView != null) { - mRootView.setAlpha(mFromAlpha); - } - cleanup(); - super.cancel(); - } - - @Override - public void onAnimationStart(Animation animation) { - } - - @Override - public void onAnimationEnd(Animation animation) { - Activity activity = mActivity; - cleanup(); - if (activity != null) { - activity.finish(); - } - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - - private void cleanup() { - mActivity = null; - mRootView = null; - } - } - - @Override protected void maybeLogProfileChange() { - getChooserActivityLogger().logSharesheetProfileChanged(); + getEventLog().logSharesheetProfileChanged(); } private static class ProfileRecord { diff --git a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java index 5f373525..aaa7554c 100644 --- a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java +++ b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java @@ -70,7 +70,7 @@ public class ChooserGridLayoutManager extends GridLayoutManager { return super.getRowCountForAccessibility(recycler, state) - 1; } - void setVerticalScrollEnabled(boolean verticalScrollEnabled) { + public void setVerticalScrollEnabled(boolean verticalScrollEnabled) { mVerticalScrollEnabled = verticalScrollEnabled; } diff --git a/java/src/com/android/intentresolver/ChooserHelper.kt b/java/src/com/android/intentresolver/ChooserHelper.kt new file mode 100644 index 00000000..6317ee1d --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserHelper.kt @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.app.Activity +import android.os.UserHandle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.ui.viewmodel.ChooserViewModel +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.log +import dagger.hilt.android.scopes.ActivityScoped +import java.util.function.Consumer +import javax.inject.Inject +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +private const val TAG: String = "ChooserHelper" + +/** + * __Purpose__ + * + * Cleanup aid. Provides a pathway to cleaner code. + * + * __Incoming References__ + * + * ChooserHelper must not expose any properties or functions directly back to ChooserActivity. If a + * value or operation is required by ChooserActivity, then it must be added to ChooserInitializer + * (or a new interface as appropriate) with ChooserActivity supplying a callback to receive it at + * the appropriate point. This enforces unidirectional control flow. + * + * __Outgoing References__ + * + * _ChooserActivity_ + * + * This class must only reference it's host as Activity/ComponentActivity; no down-cast to + * [ChooserActivity]. Other components should be created here or supplied via Injection, and not + * referenced directly within ChooserActivity. This prevents circular dependencies from forming. If + * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described + * above in 'Incoming References', see [ChooserInitializer]. + * + * _Elsewhere_ + * + * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of + * referenced from an existing location. If not available for injection, the value should be + * constructed here, then provided to where it is needed. + */ +@ActivityScoped +@JavaInterop +class ChooserHelper +@Inject +constructor( + hostActivity: Activity, + private val activityResultRepo: ActivityResultRepository, + private val pendingSelectionCallbackRepo: PendingSelectionCallbackRepository, +) : DefaultLifecycleObserver { + // This is guaranteed by Hilt, since only a ComponentActivity is injectable. + private val activity: ComponentActivity = hostActivity as ComponentActivity + private val viewModel by activity.viewModels<ChooserViewModel>() + + // TODO: provide the following through an init object passed into [setInitialize] + private lateinit var activityInitializer: Runnable + /** Invoked when there are updates to ChooserRequest */ + var onChooserRequestChanged: Consumer<ChooserRequest> = Consumer {} + /** Invoked when there are a new change to payload selection */ + var onPendingSelection: Runnable = Runnable {} + + init { + activity.lifecycle.addObserver(this) + } + + /** + * Set the initialization hook for the host activity. + * + * This _must_ be called from [ChooserActivity.onCreate]. + */ + fun setInitializer(initializer: Runnable) { + check(activity.lifecycle.currentState == Lifecycle.State.INITIALIZED) { + "setInitializer must be called before onCreate returns" + } + activityInitializer = initializer + } + + /** Invoked by Lifecycle, after [ChooserActivity.onCreate] _returns_. */ + override fun onCreate(owner: LifecycleOwner) { + Log.i(TAG, "CREATE") + Log.i(TAG, "${viewModel.activityModel}") + + val callerUid: Int = viewModel.activityModel.launchedFromUid + if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { + Log.e(TAG, "Can't start a chooser from uid $callerUid") + activity.finish() + return + } + + when (val request = viewModel.initialRequest) { + is Valid -> initializeActivity(request) + is Invalid -> reportErrorsAndFinish(request) + } + + activity.lifecycleScope.launch { + activity.setResult(activityResultRepo.activityResult.filterNotNull().first()) + activity.finish() + } + + activity.lifecycleScope.launch { + val hasPendingCallbackFlow = + pendingSelectionCallbackRepo.pendingTargetIntent + .map { it != null } + .distinctUntilChanged() + .onEach { hasPendingCallback -> + if (hasPendingCallback) { + onPendingSelection.run() + } + } + activity.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.request + .combine(hasPendingCallbackFlow) { request, hasPendingCallback -> + request to hasPendingCallback + } + // only take ChooserRequest if there are no pending callbacks + .filter { !it.second } + .map { it.first } + .distinctUntilChanged(areEquivalent = { old, new -> old === new }) + .collect { onChooserRequestChanged.accept(it) } + } + } + } + + override fun onStart(owner: LifecycleOwner) { + Log.i(TAG, "START") + } + + override fun onResume(owner: LifecycleOwner) { + Log.i(TAG, "RESUME") + } + + override fun onPause(owner: LifecycleOwner) { + Log.i(TAG, "PAUSE") + } + + override fun onStop(owner: LifecycleOwner) { + Log.i(TAG, "STOP") + } + + override fun onDestroy(owner: LifecycleOwner) { + Log.i(TAG, "DESTROY") + } + + private fun reportErrorsAndFinish(request: Invalid<ChooserRequest>) { + request.errors.forEach { it.log(TAG) } + activity.finish() + } + + private fun initializeActivity(request: Valid<ChooserRequest>) { + request.warnings.forEach { it.log(TAG) } + activityInitializer.run() + } +} diff --git a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java b/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java deleted file mode 100644 index 5fbf03a0..00000000 --- a/java/src/com/android/intentresolver/ChooserIntegratedDeviceComponents.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.annotation.Nullable; -import android.content.ComponentName; -import android.content.Context; -import android.provider.Settings; -import android.text.TextUtils; - -import com.android.internal.annotations.VisibleForTesting; - -/** - * Helper to look up the components available on this device to handle assorted built-in actions - * like "Edit" that may be displayed for certain content/preview types. The components are queried - * when this record is instantiated, and are then immutable for a given instance. - * - * Because this describes the app's external execution environment, test methods may prefer to - * provide explicit values to override the default lookup logic. - */ -public class ChooserIntegratedDeviceComponents { - @Nullable - private final ComponentName mEditSharingComponent; - - @Nullable - private final ComponentName mNearbySharingComponent; - - /** Look up the integrated components available on this device. */ - public static ChooserIntegratedDeviceComponents get( - Context context, - SecureSettings secureSettings) { - return new ChooserIntegratedDeviceComponents( - getEditSharingComponent(context), - getNearbySharingComponent(context, secureSettings)); - } - - @VisibleForTesting - ChooserIntegratedDeviceComponents( - ComponentName editSharingComponent, ComponentName nearbySharingComponent) { - mEditSharingComponent = editSharingComponent; - mNearbySharingComponent = nearbySharingComponent; - } - - public ComponentName getEditSharingComponent() { - return mEditSharingComponent; - } - - public ComponentName getNearbySharingComponent() { - return mNearbySharingComponent; - } - - private static ComponentName getEditSharingComponent(Context context) { - String editorComponent = context.getApplicationContext().getString( - R.string.config_systemImageEditor); - return TextUtils.isEmpty(editorComponent) - ? null : ComponentName.unflattenFromString(editorComponent); - } - - private static ComponentName getNearbySharingComponent(Context context, - SecureSettings secureSettings) { - String nearbyComponent = secureSettings.getString( - context.getContentResolver(), Settings.Secure.NEARBY_SHARING_COMPONENT); - if (TextUtils.isEmpty(nearbyComponent)) { - nearbyComponent = context.getString(R.string.config_defaultNearbySharingComponent); - } - return TextUtils.isEmpty(nearbyComponent) - ? null : ComponentName.unflattenFromString(nearbyComponent); - } -} diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java index b1fa16b0..8b848e55 100644 --- a/java/src/com/android/intentresolver/ChooserListAdapter.java +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -19,7 +19,6 @@ package com.android.intentresolver; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; -import android.annotation.Nullable; import android.app.ActivityManager; import android.app.prediction.AppTarget; import android.content.ComponentName; @@ -38,17 +37,25 @@ import android.os.UserManager; import android.provider.DeviceConfig; import android.service.chooser.ChooserTarget; import android.text.Layout; +import android.text.TextUtils; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.DisplayResolveInfoAzInfoComparator; import com.android.intentresolver.chooser.MultiDisplayResolveInfo; import com.android.intentresolver.chooser.NotSelectableTargetInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.widget.BadgeTextView; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; @@ -56,10 +63,23 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.concurrent.Executor; import java.util.stream.Collectors; public class ChooserListAdapter extends ResolverListAdapter { + + /** + * Delegate interface for injecting a chooser-specific operation to be performed before handling + * a package-change event. This allows the "driver" invoking the package-change to be generic, + * with no knowledge specific to the chooser implementation. + */ + public interface PackageChangeCallback { + /** Perform any steps necessary before processing the package-change event. */ + void beforeHandlingPackagesChanged(); + } + private static final String TAG = "ChooserListAdapter"; private static final boolean DEBUG = false; @@ -77,23 +97,28 @@ public class ChooserListAdapter extends ResolverListAdapter { /** {@link #getBaseScore} */ public static final float SHORTCUT_TARGET_SCORE_BOOST = 90.f; - private final ChooserRequestParameters mChooserRequest; + private final Intent mReferrerFillInIntent; + private final int mMaxRankedTargets; - private final ChooserActivityLogger mChooserActivityLogger; + private final EventLog mEventLog; private final Set<TargetInfo> mRequestedIcons = new HashSet<>(); + @Nullable + private final PackageChangeCallback mPackageChangeCallback; + // 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<>(); private final ShortcutSelectionLogic mShortcutSelectionLogic; // Sorted list of DisplayResolveInfos for the alphabetical app section. - private List<DisplayResolveInfo> mSortedList = new ArrayList<>(); + private final List<DisplayResolveInfo> mSortedList = new ArrayList<>(); private final ItemRevealAnimationTracker mAnimationTracker = new ItemRevealAnimationTracker(); @@ -128,6 +153,49 @@ public class ChooserListAdapter extends ResolverListAdapter { } }; + private boolean mAnimateItems = true; + + public ChooserListAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + Intent referrerFillInIntent, + ResolverListCommunicator resolverListCommunicator, + PackageManager packageManager, + EventLog eventLog, + int maxRankedTargets, + UserHandle initialIntentsUserSpace, + TargetDataLoader targetDataLoader, + @Nullable PackageChangeCallback packageChangeCallback, + FeatureFlags featureFlags) { + this( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + referrerFillInIntent, + resolverListCommunicator, + packageManager, + eventLog, + maxRankedTargets, + initialIntentsUserSpace, + targetDataLoader, + packageChangeCallback, + AsyncTask.SERIAL_EXECUTOR, + context.getMainExecutor(), + featureFlags); + } + + @VisibleForTesting public ChooserListAdapter( Context context, List<Intent> payloadIntents, @@ -137,13 +205,17 @@ public class ChooserListAdapter extends ResolverListAdapter { ResolverListController resolverListController, UserHandle userHandle, Intent targetIntent, + Intent referrerFillInIntent, ResolverListCommunicator resolverListCommunicator, PackageManager packageManager, - ChooserActivityLogger chooserActivityLogger, - ChooserRequestParameters chooserRequest, + EventLog eventLog, int maxRankedTargets, UserHandle initialIntentsUserSpace, - TargetDataLoader targetDataLoader) { + TargetDataLoader targetDataLoader, + @Nullable PackageChangeCallback packageChangeCallback, + Executor bgExecutor, + Executor mainExecutor, + FeatureFlags featureFlags) { // Don't send the initial intents through the shared ResolverActivity path, // we want to separate them into a different section. super( @@ -157,15 +229,19 @@ public class ChooserListAdapter extends ResolverListAdapter { targetIntent, resolverListCommunicator, initialIntentsUserSpace, - targetDataLoader); + targetDataLoader, + bgExecutor, + mainExecutor); - mChooserRequest = chooserRequest; mMaxRankedTargets = maxRankedTargets; + mReferrerFillInIntent = referrerFillInIntent; mPlaceHolderTargetInfo = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context); mTargetDataLoader = targetDataLoader; + mPackageChangeCallback = packageChangeCallback; + mUseBadgeTextViewForLabels = featureFlags.bespokeLabelView(); createPlaceHolders(); - mChooserActivityLogger = chooserActivityLogger; + mEventLog = eventLog; mShortcutSelectionLogic = new ShortcutSelectionLogic( context.getResources().getInteger(R.integer.config_maxShortcutTargetsPerApp), DeviceConfig.getBoolean( @@ -226,17 +302,23 @@ public class ChooserListAdapter extends ResolverListAdapter { ri.icon = 0; } ri.userHandle = initialIntentsUserSpace; - // TODO: remove DisplayResolveInfo dependency on presentation getter - DisplayResolveInfo displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( - ii, ri, ii, mTargetDataLoader.createPresentationGetter(ri)); + DisplayResolveInfo displayResolveInfo = + DisplayResolveInfo.newDisplayResolveInfo(ii, ri, ii); mCallerTargets.add(displayResolveInfo); if (mCallerTargets.size() == MAX_SUGGESTED_APP_TARGETS) break; } } } + public void setAnimateItems(boolean animateItems) { + mAnimateItems = animateItems; + } + @Override public void handlePackagesChanged() { + if (mPackageChangeCallback != null) { + mPackageChangeCallback.beforeHandlingPackagesChanged(); + } if (DEBUG) { Log.d(TAG, "clearing queryTargets on package change"); } @@ -246,7 +328,7 @@ public class ChooserListAdapter extends ResolverListAdapter { } @Override - protected boolean rebuildList(boolean doPostProcessing) { + public boolean rebuildList(boolean doPostProcessing) { mAnimationTracker.reset(); mSortedList.clear(); boolean result = super.rebuildList(doPostProcessing); @@ -263,86 +345,138 @@ public class ChooserListAdapter extends ResolverListAdapter { @Override View onCreateView(ViewGroup parent) { - return mInflater.inflate(R.layout.resolve_grid_item, parent, false); + return mInflater.inflate( + mUseBadgeTextViewForLabels + ? R.layout.chooser_grid_item + : R.layout.resolve_grid_item, + parent, + false); + } + + @Override + public void onDestroy() { + super.onDestroy(); + notifyDataSetChanged(); } @VisibleForTesting @Override public void onBindView(View view, TargetInfo info, int position) { + view.setEnabled(!isDestroyed()); final ViewHolder holder = (ViewHolder) view.getTag(); + resetViewHolder(holder); + // Always remove the spacing listener, attach as needed to direct share targets below. + holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener); + if (info == null) { holder.icon.setImageDrawable(loadIconPlaceholder()); return; } - holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo()); - mAnimationTracker.animateLabel(holder.text, info); - if (holder.text2.getVisibility() == View.VISIBLE) { - mAnimationTracker.animateLabel(holder.text2, info); + final CharSequence displayLabel = Objects.requireNonNullElse(info.getDisplayLabel(), ""); + final CharSequence extendedInfo = Objects.requireNonNullElse(info.getExtendedInfo(), ""); + holder.bindLabel(displayLabel, extendedInfo); + if (mAnimateItems && !TextUtils.isEmpty(displayLabel)) { + mAnimationTracker.animateLabel(holder.text, info); } - holder.bindIcon(info); - if (info.getDisplayIconHolder().getDisplayIcon() != null) { - mAnimationTracker.animateIcon(holder.icon, info); - } else { - holder.icon.clearAnimation(); + if (mAnimateItems + && !TextUtils.isEmpty(extendedInfo) + && holder.text2.getVisibility() == View.VISIBLE) { + mAnimationTracker.animateLabel(holder.text2, info); } if (info.isSelectableTargetInfo()) { // direct share targets should append the application name for a better readout DisplayResolveInfo rInfo = info.getDisplayResolveInfo(); - CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; - CharSequence extendedInfo = info.getExtendedInfo(); - String contentDescription = String.join(" ", info.getDisplayLabel(), - extendedInfo != null ? extendedInfo : "", appName); - holder.updateContentDescription(contentDescription); + CharSequence appName = + Objects.requireNonNullElse(rInfo == null ? null : rInfo.getDisplayLabel(), ""); + String contentDescription = + String.join(" ", info.getDisplayLabel(), extendedInfo, appName); + if (info.isPinned()) { + contentDescription = String.join( + ". ", + contentDescription, + mContext.getResources().getString(R.string.pinned)); + } + updateContentDescription(holder, contentDescription); if (!info.hasDisplayIcon()) { loadDirectShareIcon((SelectableTargetInfo) info); } } else if (info.isDisplayResolveInfo()) { + if (info.isPinned()) { + updateContentDescription( + holder, + String.join( + ". ", + info.getDisplayLabel(), + mContext.getResources().getString(R.string.pinned))); + } DisplayResolveInfo dri = (DisplayResolveInfo) info; if (!dri.hasDisplayIcon()) { loadIcon(dri); } + if (!dri.hasDisplayLabel()) { + loadLabel(dri); + } } - // If target is loading, show a special placeholder shape in the label, make unclickable - if (info.isPlaceHolderTargetInfo()) { - final int maxWidth = mContext.getResources().getDimensionPixelSize( - R.dimen.chooser_direct_share_label_placeholder_max_width); - holder.text.setMaxWidth(maxWidth); - holder.text.setBackground(mContext.getResources().getDrawable( - R.drawable.chooser_direct_share_label_placeholder, mContext.getTheme())); - // Prevent rippling by removing background containing ripple - holder.itemView.setBackground(null); - } else { - holder.text.setMaxWidth(Integer.MAX_VALUE); - holder.text.setBackground(null); - holder.itemView.setBackground(holder.defaultItemViewBackground); + holder.bindIcon(info); + if (mAnimateItems && info.hasDisplayIcon()) { + mAnimationTracker.animateIcon(holder.icon, info); } - // Always remove the spacing listener, attach as needed to direct share targets below. - holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener); + if (info.isPlaceHolderTargetInfo()) { + bindPlaceholder(holder); + } if (info.isMultiDisplayResolveInfo()) { // If the target is grouped show an indicator - Drawable bkg = mContext.getDrawable(R.drawable.chooser_group_background); - holder.text.setPaddingRelative(0, 0, bkg.getIntrinsicWidth() /* end */, 0); - holder.text.setBackground(bkg); + bindGroupIndicator( + holder, + mContext.getDrawable(R.drawable.chooser_group_background)); } else if (info.isPinned() && (getPositionTargetType(position) == TARGET_STANDARD || getPositionTargetType(position) == TARGET_SERVICE)) { // If the appShare or directShare target is pinned and in the suggested row show a // pinned indicator - Drawable bkg = mContext.getDrawable(R.drawable.chooser_pinned_background); - holder.text.setPaddingRelative(bkg.getIntrinsicWidth() /* start */, 0, 0, 0); - holder.text.setBackground(bkg); + bindPinnedIndicator(holder, mContext.getDrawable(R.drawable.chooser_pinned_background)); holder.text.addOnLayoutChangeListener(mPinTextSpacingListener); + } + } + + private void resetViewHolder(ViewHolder holder) { + holder.reset(); + holder.itemView.setBackground(holder.defaultItemViewBackground); + + if (mUseBadgeTextViewForLabels) { + ((BadgeTextView) holder.text).setBadgeDrawable(null); + } + holder.text.setBackground(null); + holder.text.setPaddingRelative(0, 0, 0, 0); + } + + private void updateContentDescription(ViewHolder holder, String description) { + holder.itemView.setContentDescription(description); + } + + private void bindPlaceholder(ViewHolder holder) { + holder.itemView.setBackground(null); + } + + private void bindGroupIndicator(ViewHolder holder, Drawable indicator) { + if (mUseBadgeTextViewForLabels) { + ((BadgeTextView) holder.text).setBadgeDrawable(indicator); } else { - holder.text.setBackground(null); - holder.text.setPaddingRelative(0, 0, 0, 0); + holder.text.setPaddingRelative(0, 0, /*end = */indicator.getIntrinsicWidth(), 0); + holder.text.setBackground(indicator); } } + private void bindPinnedIndicator(ViewHolder holder, Drawable indicator) { + holder.text.setPaddingRelative(/*start = */indicator.getIntrinsicWidth(), 0, 0, 0); + holder.text.setBackground(indicator); + } + private void loadDirectShareIcon(SelectableTargetInfo info) { if (mRequestedIcons.add(info)) { mTargetDataLoader.loadDirectShareIcon( @@ -359,9 +493,23 @@ public class ChooserListAdapter extends ResolverListAdapter { } } - void updateAlphabeticalList() { - // TODO: this procedure seems like it should be relatively lightweight. Why does it need to - // run in an `AsyncTask`? + /** + * Group application targets + */ + public void updateAlphabeticalList() { + updateAlphabeticalList(() -> {}); + } + + /** + * Group application targets + */ + public void updateAlphabeticalList(Runnable onCompleted) { + final DisplayResolveInfoAzInfoComparator + comparator = new DisplayResolveInfoAzInfoComparator(mContext); + final List<DisplayResolveInfo> allTargets = new ArrayList<>(); + allTargets.addAll(getTargetsInCurrentDisplayList()); + allTargets.addAll(mCallerTargets); + new AsyncTask<Void, Void, List<DisplayResolveInfo>>() { @Override protected List<DisplayResolveInfo> doInBackground(Void... voids) { @@ -374,32 +522,39 @@ public class ChooserListAdapter extends ResolverListAdapter { } private List<DisplayResolveInfo> updateList() { - List<DisplayResolveInfo> allTargets = new ArrayList<>(); - allTargets.addAll(getTargetsInCurrentDisplayList()); - allTargets.addAll(mCallerTargets); + loadMissingLabels(allTargets); // Consolidate multiple targets from same app. return allTargets .stream() .collect(Collectors.groupingBy(target -> target.getResolvedComponentName().getPackageName() - + "#" + target.getDisplayLabel() - + '#' + ResolverActivity.getResolveInfoUserHandle( - target.getResolveInfo(), getUserHandle()).getIdentifier() + + "#" + target.getDisplayLabel() + + '#' + target.getResolveInfo().userHandle.getIdentifier() )) .values() .stream() .map(appTargets -> (appTargets.size() == 1) - ? appTargets.get(0) - : MultiDisplayResolveInfo.newMultiDisplayResolveInfo(appTargets)) - .sorted(new ChooserActivity.AzInfoComparator(mContext)) + ? appTargets.get(0) + : MultiDisplayResolveInfo.newMultiDisplayResolveInfo( + appTargets)) + .sorted(comparator) .collect(Collectors.toList()); } + @Override protected void onPostExecute(List<DisplayResolveInfo> newList) { - mSortedList = newList; + mSortedList.clear(); + mSortedList.addAll(newList); notifyDataSetChanged(); + onCompleted.run(); + } + + private void loadMissingLabels(List<DisplayResolveInfo> targets) { + for (DisplayResolveInfo target: targets) { + mTargetDataLoader.getOrLoadLabel(target); + } } }.execute(); } @@ -438,8 +593,14 @@ public class ChooserListAdapter extends ResolverListAdapter { return count; } + private static boolean hasSendAction(Intent intent) { + String action = intent.getAction(); + return Intent.ACTION_SEND.equals(action) + || Intent.ACTION_SEND_MULTIPLE.equals(action); + } + public int getServiceTargetCount() { - if (mChooserRequest.isSendActionTarget() && !ActivityManager.isLowRamDeviceStatic()) { + if (hasSendAction(getTargetIntent()) && !ActivityManager.isLowRamDeviceStatic()) { return Math.min(mServiceTargets.size(), mMaxRankedTargets); } @@ -553,7 +714,7 @@ public class ChooserListAdapter extends ResolverListAdapter { protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { // Checks if this info is already listed in callerTargets. for (TargetInfo existingInfo : mCallerTargets) { - if (mResolverListCommunicator.resolveInfoMatch( + if (ResolveInfoHelpers.resolveInfoMatch( dri.getResolveInfo(), existingInfo.getResolveInfo())) { return false; } @@ -577,7 +738,7 @@ public class ChooserListAdapter extends ResolverListAdapter { public void addServiceResults( @Nullable DisplayResolveInfo origTarget, List<ChooserTarget> targets, - @ChooserActivity.ShareTargetType int targetType, + int targetType, Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos, Map<ChooserTarget, AppTarget> directShareToAppTargets) { // Avoid inserting any potentially late results. @@ -594,8 +755,8 @@ public class ChooserListAdapter extends ResolverListAdapter { directShareToShortcutInfos, directShareToAppTargets, mContext.createContextAsUser(getUserHandle(), 0), - mChooserRequest.getTargetIntent(), - mChooserRequest.getReferrerFillInIntent(), + getTargetIntent(), + mReferrerFillInIntent, mMaxRankedTargets, mServiceTargets); if (isUpdated) { @@ -614,7 +775,7 @@ public class ChooserListAdapter extends ResolverListAdapter { */ public float getBaseScore( DisplayResolveInfo target, - @ChooserActivity.ShareTargetType int targetType) { + int targetType) { if (target == null) { return CALLER_TARGET_SCORE_BOOST; } @@ -634,7 +795,7 @@ public class ChooserListAdapter extends ResolverListAdapter { mServiceTargets.removeIf(o -> o.isPlaceHolderTargetInfo()); if (mServiceTargets.isEmpty()) { mServiceTargets.add(NotSelectableTargetInfo.newEmptyTargetInfo()); - mChooserActivityLogger.logSharesheetEmptyDirectShareRow(); + mEventLog.logSharesheetEmptyDirectShareRow(); } notifyDataSetChanged(); } @@ -644,29 +805,20 @@ public class ChooserListAdapter extends ResolverListAdapter { * in the head of input list and fill the tail with other elements in undetermined order. */ @Override - AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>> createSortingTask(boolean doPostProcessing) { - return new AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>>() { - @Override - protected List<ResolvedComponentInfo> doInBackground( - List<ResolvedComponentInfo>... params) { - Trace.beginSection("ChooserListAdapter#SortingTask"); - mResolverListController.topK(params[0], mMaxRankedTargets); - Trace.endSection(); - return params[0]; - } - @Override - protected void onPostExecute(List<ResolvedComponentInfo> sortedComponents) { - processSortedList(sortedComponents, doPostProcessing); - if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - notifyDataSetChanged(); - } - } - }; + @WorkerThread + protected void sortComponents(List<ResolvedComponentInfo> components) { + Trace.beginSection("ChooserListAdapter#SortingTask"); + mResolverListController.topK(components, mMaxRankedTargets); + Trace.endSection(); } + @Override + @MainThread + protected void onComponentsSorted( + @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) { + processSortedList(sortedComponents, doPostProcessing); + if (doPostProcessing) { + notifyDataSetChanged(); + } + } } diff --git a/java/src/com/android/intentresolver/ChooserListController.java b/java/src/com/android/intentresolver/ChooserListController.java new file mode 100644 index 00000000..48aa8be1 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserListController.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.UserHandle; + +import com.android.intentresolver.model.AbstractResolverComparator; + +import java.util.List; + +public class ChooserListController extends ResolverListController { + private final List<ComponentName> mFilteredComponents; + private final SharedPreferences mPinnedComponents; + + public ChooserListController( + Context context, + PackageManager pm, + Intent targetIntent, + String referrerPackageName, + int launchedFromUid, + AbstractResolverComparator resolverComparator, + UserHandle queryIntentsAsUser, + List<ComponentName> filteredComponents, + SharedPreferences pinnedComponents) { + super( + context, + pm, + targetIntent, + referrerPackageName, + launchedFromUid, + resolverComparator, + queryIntentsAsUser); + mFilteredComponents = filteredComponents; + mPinnedComponents = pinnedComponents; + } + + @Override + public boolean isComponentFiltered(ComponentName name) { + return mFilteredComponents.contains(name); + } + + @Override + public boolean isComponentPinned(ComponentName name) { + return mPinnedComponents.getBoolean(name.flattenToString(), false); + } +} diff --git a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java index 250b6827..d6688d90 100644 --- a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java +++ b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java @@ -16,20 +16,20 @@ package com.android.intentresolver; -import android.annotation.NonNull; import android.graphics.Rect; import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; +import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate; -class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { +public class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { private final Rect mTempRect = new Rect(); private final int[] mConsumed = new int[2]; - ChooserRecyclerViewAccessibilityDelegate(RecyclerView recyclerView) { + public ChooserRecyclerViewAccessibilityDelegate(RecyclerView recyclerView) { super(recyclerView); } diff --git a/java/src/com/android/intentresolver/ChooserRefinementManager.java b/java/src/com/android/intentresolver/ChooserRefinementManager.java index 2ebe48a6..5c828a8e 100644 --- a/java/src/com/android/intentresolver/ChooserRefinementManager.java +++ b/java/src/com/android/intentresolver/ChooserRefinementManager.java @@ -16,8 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; -import android.annotation.UiThread; import android.app.Activity; import android.app.Application; import android.content.Intent; @@ -28,22 +26,29 @@ import android.os.Parcel; import android.os.ResultReceiver; import android.util.Log; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import com.android.intentresolver.chooser.TargetInfo; +import dagger.hilt.android.lifecycle.HiltViewModel; + import java.util.List; import java.util.function.Consumer; +import javax.inject.Inject; + /** * Helper class to manage Sharesheet's "refinement" flow, where callers supply a "refinement * activity" that will be invoked when a target is selected, allowing the calling app to add - * additional extras and other refinements (subject to {@link Intent#filterEquals()}), e.g., to + * additional extras and other refinements (subject to {@link Intent#filterEquals}), e.g., to * convert the format of the payload, or lazy-download some data that was deferred in the original * call). */ +@HiltViewModel @UiThread public final class ChooserRefinementManager extends ViewModel { private static final String TAG = "ChooserRefinement"; @@ -54,22 +59,58 @@ public final class ChooserRefinementManager extends ViewModel { private boolean mConfigurationChangeInProgress = false; /** + * The types of selections that may be sent to refinement. + * + * The refinement flow results in a refined intent, but the interpretation of that intent + * depends on the type of selection that prompted the refinement. + */ + public enum RefinementType { + TARGET_INFO, // A normal (`TargetInfo`) target. + + // System actions derived from the refined intent (from `ChooserActionFactory`). + COPY_ACTION, + EDIT_ACTION + } + + /** * A token for the completion of a refinement process that can be consumed exactly once. */ public static class RefinementCompletion { private TargetInfo mTargetInfo; private boolean mConsumed; + private final RefinementType mType; - RefinementCompletion(TargetInfo targetInfo) { - mTargetInfo = targetInfo; + @Nullable + private final TargetInfo mOriginalTargetInfo; + + @Nullable + private final Intent mRefinedIntent; + + RefinementCompletion( + @Nullable RefinementType type, + @Nullable TargetInfo originalTargetInfo, + @Nullable Intent refinedIntent) { + mType = type; + mOriginalTargetInfo = originalTargetInfo; + mRefinedIntent = refinedIntent; + } + + public RefinementType getType() { + return mType; + } + + @Nullable + public TargetInfo getOriginalTargetInfo() { + return mOriginalTargetInfo; } /** * @return The output of the completed refinement process. Null if the process was aborted * or failed. */ - public TargetInfo getTargetInfo() { - return mTargetInfo; + @Nullable + public Intent getRefinedIntent() { + return mRefinedIntent; } /** @@ -88,6 +129,9 @@ public final class ChooserRefinementManager extends ViewModel { private MutableLiveData<RefinementCompletion> mRefinementCompletion = new MutableLiveData<>(); + @Inject + public ChooserRefinementManager() {} + public LiveData<RefinementCompletion> getRefinementCompletion() { return mRefinementCompletion; } @@ -97,14 +141,11 @@ public final class ChooserRefinementManager extends ViewModel { * @return true if the selection should wait for a now-started refinement flow, or false if it * can proceed by the default (non-refinement) logic. */ - public boolean maybeHandleSelection(TargetInfo selectedTarget, - IntentSender refinementIntentSender, Application application, Handler mainHandler) { - if (refinementIntentSender == null) { - return false; - } - if (selectedTarget.getAllSourceIntents().isEmpty()) { - return false; - } + public boolean maybeHandleSelection( + TargetInfo selectedTarget, + IntentSender refinementIntentSender, + Application application, + Handler mainHandler) { if (selectedTarget.isSuspended()) { // We expect all launches to fail for this target, so don't make the user go through the // refinement flow first. Besides, the default (non-refinement) handling displays a @@ -113,27 +154,57 @@ public final class ChooserRefinementManager extends ViewModel { return false; } + return maybeHandleSelection( + RefinementType.TARGET_INFO, + selectedTarget.getAllSourceIntents(), + selectedTarget, + refinementIntentSender, + application, + mainHandler); + } + + /** + * Delegate the user's selection of targets (with one or more matching {@code sourceIntents} to + * the refinement flow, if possible. + * @return true if the selection should wait for a now-started refinement flow, or false if it + * can proceed by the default (non-refinement) logic. + */ + public boolean maybeHandleSelection( + RefinementType refinementType, + List<Intent> sourceIntents, + @Nullable TargetInfo originalTargetInfo, + IntentSender refinementIntentSender, + Application application, + Handler mainHandler) { + // Our requests have a non-null `originalTargetInfo` in exactly the + // cases when `refinementType == TARGET_INFO`. + assert ((originalTargetInfo == null) == (refinementType == RefinementType.TARGET_INFO)); + + if (refinementIntentSender == null) { + return false; + } + if (sourceIntents.isEmpty()) { + return false; + } + destroy(); // Terminate any prior sessions. mRefinementResultReceiver = new RefinementResultReceiver( + refinementType, refinedIntent -> { destroy(); - - TargetInfo refinedTarget = - selectedTarget.tryToCloneWithAppliedRefinement(refinedIntent); - if (refinedTarget != null) { - mRefinementCompletion.setValue(new RefinementCompletion(refinedTarget)); - } else { - Log.e(TAG, "Failed to apply refinement to any matching source intent"); - mRefinementCompletion.setValue(new RefinementCompletion(null)); - } + mRefinementCompletion.setValue( + new RefinementCompletion( + refinementType, originalTargetInfo, refinedIntent)); }, () -> { destroy(); - mRefinementCompletion.setValue(new RefinementCompletion(null)); + mRefinementCompletion.setValue( + new RefinementCompletion( + refinementType, originalTargetInfo, null)); }, mainHandler); - Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, selectedTarget); + Intent refinementRequest = makeRefinementRequest(mRefinementResultReceiver, sourceIntents); try { refinementIntentSender.sendIntent(application, 0, refinementRequest, null, null); return true; @@ -159,7 +230,7 @@ public final class ChooserRefinementManager extends ViewModel { // into a valid Chooser session, so we'll treat it as a cancellation instead. Log.w(TAG, "Chooser resumed while awaiting refinement result; aborting"); destroy(); - mRefinementCompletion.setValue(new RefinementCompletion(null)); + mRefinementCompletion.setValue(new RefinementCompletion(null, null, null)); } } } @@ -179,9 +250,8 @@ public final class ChooserRefinementManager extends ViewModel { } private static Intent makeRefinementRequest( - RefinementResultReceiver resultReceiver, TargetInfo originalTarget) { + RefinementResultReceiver resultReceiver, List<Intent> sourceIntents) { final Intent fillIn = new Intent(); - final List<Intent> sourceIntents = originalTarget.getAllSourceIntents(); fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0)); final int sourceIntentCount = sourceIntents.size(); if (sourceIntentCount > 1) { @@ -196,16 +266,19 @@ public final class ChooserRefinementManager extends ViewModel { } private static class RefinementResultReceiver extends ResultReceiver { + private final RefinementType mType; private final Consumer<Intent> mOnSelectionRefined; private final Runnable mOnRefinementCancelled; private boolean mDestroyed; RefinementResultReceiver( + RefinementType type, Consumer<Intent> onSelectionRefined, Runnable onRefinementCancelled, Handler handler) { super(handler); + mType = type; mOnSelectionRefined = onSelectionRefined; mOnRefinementCancelled = onRefinementCancelled; } diff --git a/java/src/com/android/intentresolver/ChooserRequestParameters.java b/java/src/com/android/intentresolver/ChooserRequestParameters.java index 5157986b..06f56e3b 100644 --- a/java/src/com/android/intentresolver/ChooserRequestParameters.java +++ b/java/src/com/android/intentresolver/ChooserRequestParameters.java @@ -16,8 +16,7 @@ package com.android.intentresolver; -import android.annotation.NonNull; -import android.annotation.Nullable; + import android.content.ComponentName; import android.content.Intent; import android.content.IntentFilter; @@ -32,7 +31,9 @@ import android.text.TextUtils; import android.util.Log; import android.util.Pair; -import com.android.intentresolver.flags.FeatureFlagRepository; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.intentresolver.util.UriFilters; import com.google.common.collect.ImmutableList; @@ -41,6 +42,7 @@ 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; @@ -101,11 +103,13 @@ public class ChooserRequestParameters { @Nullable private final IntentFilter mTargetIntentFilter; + @Nullable + private final CharSequence mMetadataText; + public ChooserRequestParameters( final Intent clientIntent, String referrerPackageName, - final Uri referrer, - FeatureFlagRepository featureFlags) { + final Uri referrer) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); @@ -126,8 +130,14 @@ public class ChooserRequestParameters { mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer); - mChosenComponentSender = clientIntent.getParcelableExtra( - Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER); + mChosenComponentSender = + Optional.ofNullable( + clientIntent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER, + IntentSender.class)) + .orElse(clientIntent.getParcelableExtra( + Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER, + IntentSender.class)); + mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); @@ -148,6 +158,12 @@ public class ChooserRequestParameters { mChooserActions = getChooserActions(clientIntent); mModifyShareAction = getModifyShareAction(clientIntent); + + if (android.service.chooser.Flags.enableSharesheetMetadataExtra()) { + mMetadataText = clientIntent.getCharSequenceExtra(Intent.EXTRA_METADATA_TEXT); + } else { + mMetadataText = null; + } } public Intent getTargetIntent() { @@ -212,7 +228,7 @@ public class ChooserRequestParameters { /** * TODO: this returns a nullable array for convenience, but if the legacy APIs can be - * refactored, returning {@link mAdditionalTargets} directly is simpler and safer. + * refactored, returning {@link #mAdditionalTargets} directly is simpler and safer. */ @Nullable public Intent[] getAdditionalTargets() { @@ -226,7 +242,7 @@ public class ChooserRequestParameters { /** * TODO: this returns a nullable array for convenience, but if the legacy APIs can be - * refactored, returning {@link mInitialIntents} directly is simpler and safer. + * refactored, returning {@link #mInitialIntents} directly is simpler and safer. */ @Nullable public Intent[] getInitialIntents() { @@ -253,6 +269,11 @@ public class ChooserRequestParameters { return mTargetIntentFilter; } + @Nullable + public CharSequence getMetadataText() { + return mMetadataText; + } + private static boolean isSendAction(@Nullable String action) { return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); } @@ -288,7 +309,7 @@ public class ChooserRequestParameters { * 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 + * 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( @@ -371,7 +392,7 @@ public class ChooserRequestParameters { * the required type. If false, throw an {@link IllegalArgumentException} if the extra is * non-null but can't be assigned to variables of type {@code T}. * @param streamEmptyIfNull Whether to return an empty stream if the optional extra isn't - * present in the intent (or if it had the wrong type, but {@link warnOnTypeError} is true). + * 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. */ diff --git a/java/src/com/android/intentresolver/ChooserSelector.kt b/java/src/com/android/intentresolver/ChooserSelector.kt new file mode 100644 index 00000000..c1174e95 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserSelector.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.v2 + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import com.android.intentresolver.FeatureFlags +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint(BroadcastReceiver::class) +class ChooserSelector : Hilt_ChooserSelector() { + + @Inject lateinit var featureFlags: FeatureFlags + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + context.packageManager.setComponentEnabledSetting( + ComponentName(CHOOSER_PACKAGE, CHOOSER_PACKAGE + CHOOSER_CLASS), + if (featureFlags.modularFramework()) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + }, + /* flags = */ 0, + ) + } + } + + companion object { + private const val CHOOSER_PACKAGE = "com.android.intentresolver" + private const val CHOOSER_CLASS = ".v2.ChooserActivity" + } +} diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java index 2cfceeae..30e69c18 100644 --- a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java @@ -22,6 +22,7 @@ import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import android.os.UserHandle; +import androidx.annotation.NonNull; import androidx.fragment.app.FragmentManager; import com.android.intentresolver.chooser.DisplayResolveInfo; @@ -62,10 +63,11 @@ public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogF @Override public void onClick(DialogInterface dialog, int which) { mMultiDisplayResolveInfo.setSelected(which); - ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true); + ((StartsSelectedItem) getActivity()).startSelected(mParentWhich, false, true); dismiss(); } + @NonNull @Override protected CharSequence getItemLabel(DisplayResolveInfo dri) { final PackageManager pm = getContext().getPackageManager(); diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java index 4bfb21aa..ae80fad4 100644 --- a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -21,8 +21,6 @@ import static android.content.Context.ACTIVITY_SERVICE; import static java.util.stream.Collectors.toList; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.ActivityManager; import android.app.Dialog; import android.content.ComponentName; @@ -46,6 +44,8 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentManager; import androidx.recyclerview.widget.RecyclerView; @@ -205,7 +205,7 @@ public class ChooserTargetActionsDialogFragment extends DialogFragment } else { pinComponent(mTargetInfos.get(which).getResolvedComponentName()); } - ((ChooserActivity) getActivity()).handlePackagesChanged(); + ((PackagesChangedListener) getActivity()).handlePackagesChanged(); dismiss(); } diff --git a/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt b/java/src/com/android/intentresolver/ContentTypeHint.kt index 6bf7579e..f607e4ae 100644 --- a/java/src-release/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt +++ b/java/src/com/android/intentresolver/ContentTypeHint.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,11 +14,12 @@ * limitations under the License. */ -package com.android.intentresolver.flags +package com.android.intentresolver -import android.content.Context +import android.content.Intent -class FeatureFlagRepositoryFactory { - fun create(context: Context): FeatureFlagRepository = - ReleaseFeatureFlagRepository(DeviceConfigProxy()) +/** Enum reflecting the value of [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT]. */ +enum class ContentTypeHint { + NONE, + ALBUM, } diff --git a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt index b1178aa5..6a4fe65a 100644 --- a/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt +++ b/java/src/com/android/intentresolver/EnterTransitionAnimationDelegate.kt @@ -21,14 +21,14 @@ import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback import com.android.internal.annotations.VisibleForTesting +import java.util.function.Supplier import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.util.function.Supplier /** - * A helper class to track app's readiness for the scene transition animation. - * The app is ready when both the image is laid out and the drawer offset is calculated. + * A helper class to track app's readiness for the scene transition animation. The app is ready when + * both the image is laid out and the drawer offset is calculated. */ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) class EnterTransitionAnimationDelegate( @@ -45,21 +45,22 @@ class EnterTransitionAnimationDelegate( activity.setEnterSharedElementCallback( object : SharedElementCallback() { override fun onMapSharedElements( - names: MutableList<String>, sharedElements: MutableMap<String, View> + names: MutableList<String>, + sharedElements: MutableMap<String, View> ) { - this@EnterTransitionAnimationDelegate.onMapSharedElements( - names, sharedElements - ) + this@EnterTransitionAnimationDelegate.onMapSharedElements(names, sharedElements) } - }) + } + ) } fun postponeTransition() { activity.postponeEnterTransition() - timeoutJob = activity.lifecycleScope.launch { - delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong()) - onTimeout() - } + timeoutJob = + activity.lifecycleScope.launch { + delay(activity.resources.getInteger(R.integer.config_shortAnimTime).toLong()) + onTimeout() + } } private fun onTimeout() { @@ -110,8 +111,14 @@ class EnterTransitionAnimationDelegate( override fun onLayoutChange( v: View, - left: Int, top: Int, right: Int, bottom: Int, - oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int ) { v.removeOnLayoutChangeListener(this) startPostponedEnterTransition() diff --git a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java deleted file mode 100644 index a1c53402..00000000 --- a/java/src/com/android/intentresolver/GenericMultiProfilePagerAdapter.java +++ /dev/null @@ -1,235 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.annotation.Nullable; -import android.content.Context; -import android.os.UserHandle; -import android.util.Log; -import android.view.View; -import android.view.ViewGroup; - -import com.android.internal.annotations.VisibleForTesting; - -import com.google.common.collect.ImmutableList; - -import java.util.Optional; -import java.util.function.Function; -import java.util.function.Supplier; - -/** - * Implementation of {@link AbstractMultiProfilePagerAdapter} that consolidates the variation in - * existing implementations; most overrides were only to vary type signatures (which are better - * represented via generic types), and a few minor behavioral customizations are now implemented - * through small injectable delegate classes. - * TODO: now that the existing implementations are shown to be expressible in terms of this new - * generic type, merge up into the base class and simplify the public APIs. - * TODO: attempt to further restrict visibility in the methods we expose. - * TODO: deprecate and audit/fix usages of any methods that refer to the "active" or "inactive" - * adapters; these were marked {@link VisibleForTesting} and their usage seems like an accident - * waiting to happen since clients seem to make assumptions about which adapter will be "active" in - * a particular context, and more explicit APIs would make sure those were valid. - * TODO: consider renaming legacy methods (e.g. why do we know it's a "list", not just a "page"?) - * - * @param <PageViewT> the type of the widget that represents the contents of a page in this adapter - * @param <SinglePageAdapterT> the type of a "root" adapter class to be instantiated and included in - * the per-profile records. - * @param <ListAdapterT> the concrete type of a {@link ResolverListAdapter} implementation to - * control the contents of a given per-profile list. This is provided for convenience, since it must - * be possible to get the list adapter from the page adapter via our {@link mListAdapterExtractor}. - * - * TODO: this class doesn't make any explicit usage of the {@link ResolverListAdapter} API, so the - * type constraint can probably be dropped once the API is merged upwards and cleaned. - */ -class GenericMultiProfilePagerAdapter< - PageViewT extends ViewGroup, - SinglePageAdapterT, - ListAdapterT extends ResolverListAdapter> extends AbstractMultiProfilePagerAdapter { - - /** Delegate to set up a given adapter and page view to be used together. */ - public interface AdapterBinder<PageViewT, SinglePageAdapterT> { - /** - * The given {@code view} will be associated with the given {@code adapter}. Do any work - * necessary to configure them compatibly, introduce them to each other, etc. - */ - void bind(PageViewT view, SinglePageAdapterT adapter); - } - - private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor; - private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder; - private final Supplier<ViewGroup> mPageViewInflater; - private final Supplier<Optional<Integer>> mContainerBottomPaddingOverrideSupplier; - - private final ImmutableList<GenericProfileDescriptor<PageViewT, SinglePageAdapterT>> mItems; - - GenericMultiProfilePagerAdapter( - Context context, - Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor, - AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder, - ImmutableList<SinglePageAdapterT> adapters, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - Supplier<ViewGroup> pageViewInflater, - Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { - super( - context, - /* currentPage= */ defaultProfile, - emptyStateProvider, - workProfileQuietModeChecker, - workProfileUserHandle, - cloneProfileUserHandle); - - mListAdapterExtractor = listAdapterExtractor; - mAdapterBinder = adapterBinder; - mPageViewInflater = pageViewInflater; - mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; - - ImmutableList.Builder<GenericProfileDescriptor<PageViewT, SinglePageAdapterT>> items = - new ImmutableList.Builder<>(); - for (SinglePageAdapterT adapter : adapters) { - items.add(createProfileDescriptor(adapter)); - } - mItems = items.build(); - } - - private GenericProfileDescriptor<PageViewT, SinglePageAdapterT> - createProfileDescriptor(SinglePageAdapterT adapter) { - return new GenericProfileDescriptor<>(mPageViewInflater.get(), adapter); - } - - @Override - protected GenericProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) { - return mItems.get(pageIndex); - } - - @Override - public int getItemCount() { - return mItems.size(); - } - - public PageViewT getListViewForIndex(int index) { - return getItem(index).mView; - } - - @Override - @VisibleForTesting - public SinglePageAdapterT getAdapterForIndex(int index) { - return getItem(index).mAdapter; - } - - @Override - protected void setupListAdapter(int pageIndex) { - mAdapterBinder.bind(getListViewForIndex(pageIndex), getAdapterForIndex(pageIndex)); - } - - @Override - public ViewGroup instantiateItem(ViewGroup container, int position) { - setupListAdapter(position); - return super.instantiateItem(container, position); - } - - @Override - @Nullable - protected ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { - if (getPersonalListAdapter().getUserHandle().equals(userHandle) - || userHandle.equals(getCloneUserHandle())) { - return getPersonalListAdapter(); - } else if (getWorkListAdapter() != null - && getWorkListAdapter().getUserHandle().equals(userHandle)) { - return getWorkListAdapter(); - } - return null; - } - - @Override - @VisibleForTesting - public ListAdapterT getActiveListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(getCurrentPage())); - } - - @Override - @VisibleForTesting - public ListAdapterT getInactiveListAdapter() { - if (getCount() < 2) { - return null; - } - return mListAdapterExtractor.apply(getAdapterForIndex(1 - getCurrentPage())); - } - - @Override - public ListAdapterT getPersonalListAdapter() { - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_PERSONAL)); - } - - @Override - public ListAdapterT getWorkListAdapter() { - if (!hasAdapterForIndex(PROFILE_WORK)) { - return null; - } - return mListAdapterExtractor.apply(getAdapterForIndex(PROFILE_WORK)); - } - - @Override - protected SinglePageAdapterT getCurrentRootAdapter() { - return getAdapterForIndex(getCurrentPage()); - } - - @Override - protected PageViewT getActiveAdapterView() { - return getListViewForIndex(getCurrentPage()); - } - - @Override - protected PageViewT getInactiveAdapterView() { - if (getCount() < 2) { - return null; - } - return getListViewForIndex(1 - getCurrentPage()); - } - - @Override - protected void setupContainerPadding(View container) { - Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); - bottomPaddingOverride.ifPresent(paddingBottom -> - container.setPadding( - container.getPaddingLeft(), - container.getPaddingTop(), - container.getPaddingRight(), - paddingBottom)); - } - - private boolean hasAdapterForIndex(int pageIndex) { - return (pageIndex < getCount()); - } - - // TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" - // should be the owner of all per-profile data (especially now that the API is generic)? - private static class GenericProfileDescriptor<PageViewT, SinglePageAdapterT> extends - ProfileDescriptor { - private final SinglePageAdapterT mAdapter; - private final PageViewT mView; - - GenericProfileDescriptor(ViewGroup rootView, SinglePageAdapterT adapter) { - super(rootView); - mAdapter = adapter; - mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); - } - } -} diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java index 5e8945f1..db94c918 100644 --- a/java/src/com/android/intentresolver/IntentForwarderActivity.java +++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java @@ -20,10 +20,9 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTEN import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK; import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY; -import static com.android.intentresolver.ResolverActivity.EXTRA_CALLING_USER; -import static com.android.intentresolver.ResolverActivity.EXTRA_SELECTED_PROFILE; +import static com.android.intentresolver.ui.viewmodel.ResolverRequestReaderKt.EXTRA_CALLING_USER; +import static com.android.intentresolver.ui.viewmodel.ResolverRequestReaderKt.EXTRA_SELECTED_PROFILE; -import android.annotation.Nullable; import android.app.Activity; import android.app.ActivityThread; import android.app.AppGlobals; @@ -45,6 +44,9 @@ import android.provider.Settings; import android.util.Slog; import android.widget.Toast; +import androidx.annotation.Nullable; + +import com.android.intentresolver.profiles.MultiProfilePagerAdapter; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -253,9 +255,9 @@ public class IntentForwarderActivity extends Activity { private int findSelectedProfile(String className) { if (className.equals(FORWARD_INTENT_TO_PARENT)) { - return ChooserActivity.PROFILE_PERSONAL; + return MultiProfilePagerAdapter.PROFILE_PERSONAL; } else if (className.equals(FORWARD_INTENT_TO_MANAGED_PROFILE)) { - return ChooserActivity.PROFILE_WORK; + return MultiProfilePagerAdapter.PROFILE_WORK; } return -1; } @@ -309,7 +311,7 @@ public class IntentForwarderActivity extends Activity { * Check whether the intent can be forwarded to target user. Return the intent used for * forwarding if it can be forwarded, {@code null} otherwise. */ - static Intent canForward(Intent incomingIntent, int sourceUserId, int targetUserId, + public static Intent canForward(Intent incomingIntent, int sourceUserId, int targetUserId, IPackageManager packageManager, ContentResolver contentResolver) { Intent forwardIntent = new Intent(incomingIntent); forwardIntent.addFlags( diff --git a/java/src/com/android/intentresolver/IntentForwarding.kt b/java/src/com/android/intentresolver/IntentForwarding.kt new file mode 100644 index 00000000..c8f6cf41 --- /dev/null +++ b/java/src/com/android/intentresolver/IntentForwarding.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver + +import android.Manifest +import android.Manifest.permission.INTERACT_ACROSS_USERS +import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.PermissionChecker +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import com.android.intentresolver.data.repository.DevicePolicyResources +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG: String = "IntentForwarding" + +@Singleton +class IntentForwarding +@Inject +constructor( + private val resources: DevicePolicyResources, + private val userManager: UserManager, + private val packageManager: PackageManager +) { + + fun forwardMessageFor(intent: Intent): String? { + val contentUserHint = intent.contentUserHint + if ( + contentUserHint != UserHandle.USER_CURRENT && contentUserHint != UserHandle.myUserId() + ) { + val originUserInfo = userManager.getUserInfo(contentUserHint) + val originIsManaged = originUserInfo?.isManagedProfile ?: false + val targetIsManaged = userManager.isManagedProfile + return when { + originIsManaged && !targetIsManaged -> resources.forwardToPersonalMessage + !originIsManaged && targetIsManaged -> resources.forwardToWorkMessage + else -> null + } + } + return null + } + + private fun isPermissionGranted(permission: String, uid: Int) = + ActivityManager.checkComponentPermission( + /* permission = */ permission, + /* uid = */ uid, + /* owningUid= */ -1, + /* exported= */ true + ) + + /** + * Returns whether the package has the necessary permissions to interact across profiles on + * behalf of a given user. + * + * This means meeting the following condition: + * * The app's [ApplicationInfo.crossProfile] flag must be true, and at least one of the + * following conditions must be fulfilled + * * `Manifest.permission.INTERACT_ACROSS_USERS_FULL` granted. + * * `Manifest.permission.INTERACT_ACROSS_USERS` granted. + * * `Manifest.permission.INTERACT_ACROSS_PROFILES` granted, or the corresponding AppOps + * `android:interact_across_profiles` is set to "allow". + */ + fun canAppInteractAcrossProfiles(context: Context, packageName: String): Boolean { + val applicationInfo: ApplicationInfo + try { + applicationInfo = packageManager.getApplicationInfo(packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + Log.e(TAG, "Package $packageName does not exist on current user.") + return false + } + if (!applicationInfo.crossProfile) { + return false + } + + val packageUid = applicationInfo.uid + + if (isPermissionGranted(INTERACT_ACROSS_USERS_FULL, packageUid) == PERMISSION_GRANTED) { + return true + } + if (isPermissionGranted(INTERACT_ACROSS_USERS, packageUid) == PERMISSION_GRANTED) { + return true + } + return PermissionChecker.checkPermissionForPreflight( + context, + Manifest.permission.INTERACT_ACROSS_PROFILES, + PermissionChecker.PID_UNKNOWN, + packageUid, + packageName + ) == PERMISSION_GRANTED + } +} diff --git a/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt b/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt index d3e07c6b..7deb0d10 100644 --- a/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt +++ b/java/src/com/android/intentresolver/ItemRevealAnimationTracker.kt @@ -37,9 +37,7 @@ internal class ItemRevealAnimationTracker { fun animateLabel(view: View, info: TargetInfo) = animateView(view, info, labelProgress) private fun animateView(view: View, info: TargetInfo, map: MutableMap<TargetInfo, Record>) { - val record = map.getOrPut(info) { - Record() - } + val record = map.getOrPut(info) { Record() } if ((view.animation as? RevealAnimation)?.record === record) return view.clearAnimation() diff --git a/java/src/com/android/intentresolver/JavaFlowHelper.kt b/java/src/com/android/intentresolver/JavaFlowHelper.kt new file mode 100644 index 00000000..231cb809 --- /dev/null +++ b/java/src/com/android/intentresolver/JavaFlowHelper.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("JavaFlowHelper") + +package com.android.intentresolver + +import com.android.intentresolver.annotation.JavaInterop +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +@JavaInterop +fun <T> collect(scope: CoroutineScope, flow: Flow<T>, collector: Consumer<T>): Job = + scope.launch { flow.collect { collector.accept(it) } } diff --git a/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt b/java/src/com/android/intentresolver/MainApplication.kt index 1ddf7462..0a826629 100644 --- a/java/tests/src/com/android/intentresolver/RequireFeatureFlags.kt +++ b/java/src/com/android/intentresolver/MainApplication.kt @@ -16,8 +16,7 @@ package com.android.intentresolver -/** - * Specifies expected feature flag values for a test. - */ -@Target(AnnotationTarget.FUNCTION) -annotation class RequireFeatureFlags(val flags: Array<String>, val values: BooleanArray) +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp(Application::class) open class MainApplication : Hilt_MainApplication() diff --git a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java deleted file mode 100644 index a7b50f38..00000000 --- a/java/src/com/android/intentresolver/NoAppsAvailableEmptyStateProvider.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; - -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.content.pm.ResolveInfo; -import android.os.UserHandle; -import android.stats.devicepolicy.nano.DevicePolicyEnums; - -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; -import com.android.internal.R; - -import java.util.List; - -/** - * Chooser/ResolverActivity empty state provider that returns empty state which is shown when - * there are no apps available. - */ -public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { - - @NonNull - private final Context mContext; - @Nullable - private final UserHandle mWorkProfileUserHandle; - @Nullable - private final UserHandle mPersonalProfileUserHandle; - @NonNull - private final String mMetricsCategory; - @NonNull - private final UserHandle mTabOwnerUserHandleForLaunch; - - public NoAppsAvailableEmptyStateProvider(Context context, UserHandle workProfileUserHandle, - UserHandle personalProfileUserHandle, String metricsCategory, - UserHandle tabOwnerUserHandleForLaunch) { - mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mPersonalProfileUserHandle = personalProfileUserHandle; - mMetricsCategory = metricsCategory; - mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; - } - - @Nullable - @Override - @SuppressWarnings("ReferenceEquality") - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - UserHandle listUserHandle = resolverListAdapter.getUserHandle(); - - if (mWorkProfileUserHandle != null - && (mTabOwnerUserHandleForLaunch.equals(listUserHandle) - || !hasAppsInOtherProfile(resolverListAdapter))) { - - String title; - if (listUserHandle == mPersonalProfileUserHandle) { - title = mContext.getSystemService( - DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_PERSONAL_APPS, - () -> mContext.getString(R.string.resolver_no_personal_apps_available)); - } else { - title = mContext.getSystemService( - DevicePolicyManager.class).getResources().getString( - RESOLVER_NO_WORK_APPS, - () -> mContext.getString(R.string.resolver_no_work_apps_available)); - } - - return new NoAppsAvailableEmptyState( - title, mMetricsCategory, - /* isPersonalProfile= */ listUserHandle == mPersonalProfileUserHandle - ); - } else if (mWorkProfileUserHandle == null) { - // Return default empty state without tracking - return new DefaultEmptyState(); - } - - return null; - } - - private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { - if (mWorkProfileUserHandle == null) { - return false; - } - List<ResolvedComponentInfo> resolversForIntent = - adapter.getResolversForUser(mTabOwnerUserHandleForLaunch); - for (ResolvedComponentInfo info : resolversForIntent) { - ResolveInfo resolveInfo = info.getResolveInfoAt(0); - if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { - return true; - } - } - return false; - } - - public static class DefaultEmptyState implements EmptyState { - @Override - public boolean useDefaultEmptyView() { - return true; - } - } - - public static class NoAppsAvailableEmptyState implements EmptyState { - - @NonNull - private String mTitle; - - @NonNull - private String mMetricsCategory; - - private boolean mIsPersonalProfile; - - public NoAppsAvailableEmptyState(String title, String metricsCategory, - boolean isPersonalProfile) { - mTitle = title; - mMetricsCategory = metricsCategory; - mIsPersonalProfile = isPersonalProfile; - } - - @Nullable - @Override - public String getTitle() { - return mTitle; - } - - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger.createEvent( - DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) - .setStrings(mMetricsCategory) - .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile) - .write(); - } - } -} diff --git a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java deleted file mode 100644 index 6f72bb00..00000000 --- a/java/src/com/android/intentresolver/NoCrossProfileEmptyStateProvider.java +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.annotation.StringRes; -import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.os.UserHandle; - -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; - -/** - * Empty state provider that does not allow cross profile sharing, it will return a blocker - * in case if the profile of the current tab is not the same as the profile of the calling app. - */ -public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { - - private final UserHandle mPersonalProfileUserHandle; - private final EmptyState mNoWorkToPersonalEmptyState; - private final EmptyState mNoPersonalToWorkEmptyState; - private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; - private final UserHandle mTabOwnerUserHandleForLaunch; - - public NoCrossProfileEmptyStateProvider(UserHandle personalUserHandle, - EmptyState noWorkToPersonalEmptyState, - EmptyState noPersonalToWorkEmptyState, - CrossProfileIntentsChecker crossProfileIntentsChecker, - UserHandle tabOwnerUserHandleForLaunch) { - mPersonalProfileUserHandle = personalUserHandle; - mNoWorkToPersonalEmptyState = noWorkToPersonalEmptyState; - mNoPersonalToWorkEmptyState = noPersonalToWorkEmptyState; - mCrossProfileIntentsChecker = crossProfileIntentsChecker; - mTabOwnerUserHandleForLaunch = tabOwnerUserHandleForLaunch; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - boolean shouldShowBlocker = - !mTabOwnerUserHandleForLaunch.equals(resolverListAdapter.getUserHandle()) - && !mCrossProfileIntentsChecker - .hasCrossProfileIntents(resolverListAdapter.getIntents(), - mTabOwnerUserHandleForLaunch.getIdentifier(), - resolverListAdapter.getUserHandle().getIdentifier()); - - if (!shouldShowBlocker) { - return null; - } - - if (resolverListAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { - return mNoWorkToPersonalEmptyState; - } else { - return mNoPersonalToWorkEmptyState; - } - } - - - /** - * Empty state that gets strings from the device policy manager and tracks events into - * event logger of the device policy events. - */ - public static class DevicePolicyBlockerEmptyState implements EmptyState { - - @NonNull - private final Context mContext; - private final String mDevicePolicyStringTitleId; - @StringRes - private final int mDefaultTitleResource; - private final String mDevicePolicyStringSubtitleId; - @StringRes - private final int mDefaultSubtitleResource; - private final int mEventId; - @NonNull - private final String mEventCategory; - - public DevicePolicyBlockerEmptyState(Context context, String devicePolicyStringTitleId, - @StringRes int defaultTitleResource, String devicePolicyStringSubtitleId, - @StringRes int defaultSubtitleResource, - int devicePolicyEventId, String devicePolicyEventCategory) { - mContext = context; - mDevicePolicyStringTitleId = devicePolicyStringTitleId; - mDefaultTitleResource = defaultTitleResource; - mDevicePolicyStringSubtitleId = devicePolicyStringSubtitleId; - mDefaultSubtitleResource = defaultSubtitleResource; - mEventId = devicePolicyEventId; - mEventCategory = devicePolicyEventCategory; - } - - @Nullable - @Override - public String getTitle() { - return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( - mDevicePolicyStringTitleId, - () -> mContext.getString(mDefaultTitleResource)); - } - - @Nullable - @Override - public String getSubtitle() { - return mContext.getSystemService(DevicePolicyManager.class).getResources().getString( - mDevicePolicyStringSubtitleId, - () -> mContext.getString(mDefaultSubtitleResource)); - } - - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger.createEvent(mEventId) - .setStrings(mEventCategory) - .write(); - } - - @Override - public boolean shouldSkipDataRebuild() { - return true; - } - } -} diff --git a/java/tests/src/com/android/intentresolver/TestApplication.kt b/java/src/com/android/intentresolver/PackagesChangedListener.kt index 849cfbab..10f0bf51 100644 --- a/java/tests/src/com/android/intentresolver/TestApplication.kt +++ b/java/src/com/android/intentresolver/PackagesChangedListener.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,15 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.android.intentresolver -import android.app.Application -import android.content.Context -import android.os.UserHandle - -class TestApplication : Application() { - - // return the current context as a work profile doesn't really exist in these tests - override fun createContextAsUser(user: UserHandle, flags: Int): Context = this -}
\ No newline at end of file +/** A component which can be notified when packages have changed. */ +interface PackagesChangedListener { + /** Report that packages have changed. */ + fun handlePackagesChanged() +} diff --git a/java/src/com/android/intentresolver/ProfileAvailability.kt b/java/src/com/android/intentresolver/ProfileAvailability.kt new file mode 100644 index 00000000..43982727 --- /dev/null +++ b/java/src/com/android/intentresolver/ProfileAvailability.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import androidx.annotation.MainThread +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.shared.model.Profile +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +/** Provides availability status for profiles */ +@JavaInterop +class ProfileAvailability( + private val userInteractor: UserInteractor, + private val scope: CoroutineScope, + private val background: CoroutineDispatcher, +) { + /** Used by WorkProfilePausedEmptyStateProvider */ + var waitingToEnableProfile = false + private set + + /** Set by ChooserActivity to call onWorkProfileStatusUpdated */ + var onProfileStatusChange: Runnable? = null + + private var waitJob: Job? = null + + /** Query current profile availability. An unavailable profile is one which is not active. */ + @MainThread + fun isAvailable(profile: Profile?): Boolean { + return runBlocking(background) { + userInteractor.availability.map { it[profile] == true }.first() + } + } + + /** + * The number of profiles which are visible. All profiles count except for private which is + * hidden when locked. + */ + fun visibleProfileCount() = + runBlocking(background) { + val availability = userInteractor.availability.first() + val profiles = userInteractor.profiles.first() + profiles + .filter { + when (it.type) { + Profile.Type.PRIVATE -> availability[it] == true + else -> true + } + } + .size + } + + /** Used by WorkProfilePausedEmptyStateProvider */ + fun requestQuietModeState(profile: Profile, quietMode: Boolean) { + val enableProfile = !quietMode + + // Check if the profile is already in the correct state + if (isAvailable(profile) == enableProfile) { + return // No-op + } + + // Support existing code + if (enableProfile) { + waitingToEnableProfile = true + waitJob?.cancel() + + val job = + scope.launch { + // Wait for the profile to become available + userInteractor.availability.filter { it[profile] == true }.first() + } + job.invokeOnCompletion { + waitingToEnableProfile = false + onProfileStatusChange?.run() + } + waitJob = job + } + + // Apply the change + scope.launch { userInteractor.updateState(profile, enableProfile) } + } +} diff --git a/java/src/com/android/intentresolver/ProfileHelper.kt b/java/src/com/android/intentresolver/ProfileHelper.kt new file mode 100644 index 00000000..53a873a3 --- /dev/null +++ b/java/src/com/android/intentresolver/ProfileHelper.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.os.UserHandle +import androidx.annotation.MainThread +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.inject.IntentResolverFlags +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.User +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +@JavaInterop +@MainThread +class ProfileHelper +@Inject +constructor( + interactor: UserInteractor, + private val scope: CoroutineScope, + private val background: CoroutineDispatcher, + private val flags: IntentResolverFlags, +) { + private val launchedByHandle: UserHandle = interactor.launchedAs + + val launchedAsProfile by lazy { + runBlocking(background) { interactor.launchedAsProfile.first() } + } + val profiles by lazy { runBlocking(background) { interactor.profiles.first() } } + + // Map UserHandle back to a user within launchedByProfile + private val launchedByUser: User = + when (launchedByHandle) { + launchedAsProfile.primary.handle -> launchedAsProfile.primary + launchedAsProfile.clone?.handle -> requireNotNull(launchedAsProfile.clone) + else -> error("launchedByUser must be a member of launchedByProfile") + } + val launchedAsProfileType: Profile.Type = launchedAsProfile.type + + val personalProfile = profiles.single { it.type == Profile.Type.PERSONAL } + val workProfile = profiles.singleOrNull { it.type == Profile.Type.WORK } + val privateProfile = profiles.singleOrNull { it.type == Profile.Type.PRIVATE } + + val personalHandle = personalProfile.primary.handle + val workHandle = workProfile?.primary?.handle + val privateHandle = privateProfile?.primary?.handle + val cloneHandle = personalProfile.clone?.handle + + val isLaunchedAsCloneProfile = launchedByUser == launchedAsProfile.clone + + val cloneUserPresent = personalProfile.clone != null + val workProfilePresent = workProfile != null + val privateProfilePresent = privateProfile != null + + // Name retained for ease of review, to be renamed later + val tabOwnerUserHandleForLaunch = + if (launchedByUser.role == User.Role.CLONE) { + // When started by clone user, return the profile owner instead + launchedAsProfile.primary.handle + } else { + // Otherwise the launched user is used + launchedByUser.handle + } + + fun findProfile(handle: UserHandle): Profile? { + return profiles.firstOrNull { it.primary.handle == handle || it.clone?.handle == handle } + } + + fun findProfileType(handle: UserHandle): Profile.Type? = findProfile(handle)?.type + + // Name retained for ease of review, to be renamed later + fun getQueryIntentsHandle(handle: UserHandle): UserHandle? { + return if (isLaunchedAsCloneProfile && handle == personalHandle) { + cloneHandle + } else { + handle + } + } +} diff --git a/java/src/com/android/intentresolver/ResolvedComponentInfo.java b/java/src/com/android/intentresolver/ResolvedComponentInfo.java index ecb72cbf..aaa97c42 100644 --- a/java/src/com/android/intentresolver/ResolvedComponentInfo.java +++ b/java/src/com/android/intentresolver/ResolvedComponentInfo.java @@ -20,6 +20,8 @@ import android.content.ComponentName; import android.content.Intent; import android.content.pm.ResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; + import java.util.ArrayList; import java.util.List; @@ -86,7 +88,7 @@ public final class ResolvedComponentInfo { } /** - * @return whether this component was pinned by a call to {@link #setPinned()}. + * @return whether this component was pinned by a call to {@link #setPinned}. * TODO: consolidate sources of pinning data and/or document how this differs from other places * we make a "pinning" determination. */ diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java index 57871532..a402fc72 100644 --- a/java/src/com/android/intentresolver/ResolverActivity.java +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -16,41 +16,25 @@ package com.android.intentresolver; -import static android.Manifest.permission.INTERACT_ACROSS_PROFILES; -import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB; -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; -import static android.content.PermissionChecker.PID_UNKNOWN; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; -import static android.stats.devicepolicy.nano.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; +import static androidx.lifecycle.LifecycleKt.getCoroutineScope; + +import static com.android.intentresolver.ext.CreationExtrasExtKt.addDefaultArgs; import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED; -import android.annotation.Nullable; -import android.annotation.StringRes; -import android.annotation.UiThread; -import android.app.Activity; -import android.app.ActivityManager; +import static java.util.Objects.requireNonNull; + import android.app.ActivityThread; import android.app.VoiceInteractor.PickOptionRequest; import android.app.VoiceInteractor.PickOptionRequest.Option; import android.app.VoiceInteractor.Prompt; import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.PermissionChecker; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; @@ -58,7 +42,6 @@ import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.UserInfo; import android.content.res.Configuration; -import android.content.res.TypedArray; import android.graphics.Insets; import android.net.Uri; import android.os.Build; @@ -69,7 +52,6 @@ import android.os.StrictMode; import android.os.Trace; import android.os.UserHandle; import android.os.UserManager; -import android.provider.MediaStore; import android.provider.Settings; import android.stats.devicepolicy.DevicePolicyEnums; import android.text.TextUtils; @@ -91,31 +73,55 @@ import android.widget.ImageView; import android.widget.ListView; import android.widget.Space; import android.widget.TabHost; -import android.widget.TabWidget; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.viewmodel.CreationExtras; import androidx.viewpager.widget.ViewPager; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CompositeEmptyStateProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.MyUserIdProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; -import com.android.intentresolver.NoCrossProfileEmptyStateProvider.DevicePolicyBlockerEmptyState; import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.data.repository.DevicePolicyResources; +import com.android.intentresolver.domain.interactor.UserInteractor; +import com.android.intentresolver.emptystate.CompositeEmptyStateProvider; +import com.android.intentresolver.emptystate.CrossProfileIntentsChecker; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.emptystate.NoAppsAvailableEmptyStateProvider; +import com.android.intentresolver.emptystate.NoCrossProfileEmptyStateProvider; +import com.android.intentresolver.emptystate.WorkProfilePausedEmptyStateProvider; import com.android.intentresolver.icons.DefaultTargetDataLoader; import com.android.intentresolver.icons.TargetDataLoader; +import com.android.intentresolver.inject.Background; import com.android.intentresolver.model.ResolverRankerServiceResolverComparator; +import com.android.intentresolver.profiles.MultiProfilePagerAdapter; +import com.android.intentresolver.profiles.MultiProfilePagerAdapter.ProfileType; +import com.android.intentresolver.profiles.OnProfileSelectedListener; +import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.profiles.ResolverMultiProfilePagerAdapter; +import com.android.intentresolver.profiles.TabConfig; +import com.android.intentresolver.shared.model.Profile; +import com.android.intentresolver.ui.ActionTitle; +import com.android.intentresolver.ui.ProfilePagerResources; +import com.android.intentresolver.ui.model.ActivityModel; +import com.android.intentresolver.ui.model.ResolverRequest; +import com.android.intentresolver.ui.viewmodel.ResolverViewModel; import com.android.intentresolver.widget.ResolverDrawerLayout; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.content.PackageMonitor; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto; -import com.android.internal.util.LatencyTracker; + +import com.google.common.collect.ImmutableList; + +import dagger.hilt.android.AndroidEntryPoint; + +import kotlin.Pair; + +import kotlinx.coroutines.CoroutineDispatcher; import java.util.ArrayList; import java.util.Arrays; @@ -123,7 +129,8 @@ import java.util.Iterator; import java.util.List; import java.util.Objects; import java.util.Set; -import java.util.function.Supplier; + +import javax.inject.Inject; /** * This is a copy of ResolverActivity to support IntentResolver's ChooserActivity. This code is @@ -131,40 +138,34 @@ import java.util.function.Supplier; * frameworks/base/core/java/com/android/internal/app/ResolverActivity.java for that), the full * migration is not complete. */ -@UiThread -public class ResolverActivity extends FragmentActivity implements +@AndroidEntryPoint(FragmentActivity.class) +public class ResolverActivity extends Hilt_ResolverActivity implements ResolverListAdapter.ResolverListCommunicator { - public ResolverActivity() { - mIsIntentPicker = getClass().equals(ResolverActivity.class); - } - - protected ResolverActivity(boolean isIntentPicker) { - mIsIntentPicker = isIntentPicker; - } + @Inject @Background public CoroutineDispatcher mBackgroundDispatcher; + @Inject public UserInteractor mUserInteractor; + @Inject public ResolverHelper mResolverHelper; + @Inject public PackageManager mPackageManager; + @Inject public DevicePolicyResources mDevicePolicyResources; + @Inject public ProfilePagerResources mProfilePagerResources; + @Inject public IntentForwarding mIntentForwarding; + @Inject public FeatureFlags mFeatureFlags; + + private ResolverViewModel mViewModel; + private ResolverRequest mRequest; + private ProfileHelper mProfiles; + private ProfileAvailability mProfileAvailability; + protected TargetDataLoader mTargetDataLoader; + private boolean mResolvingHome; - private boolean mSafeForwardingMode; private Button mAlwaysButton; private Button mOnceButton; protected View mProfileView; private int mLastSelected = AbsListView.INVALID_POSITION; - private boolean mResolvingHome = false; - private String mProfileSwitchMessage; private int mLayoutId; - @VisibleForTesting - protected final ArrayList<Intent> mIntents = new ArrayList<>(); private PickTargetOptionRequest mPickOptionRequest; - private String mReferrerPackage; - private CharSequence mTitle; - private int mDefaultTitleResId; // Expected to be true if this object is ResolverActivity or is ResolverWrapperActivity. - private final boolean mIsIntentPicker; - - // Whether or not this activity supports choosing a default handler for the intent. - @VisibleForTesting - protected boolean mSupportsAlwaysUseOption; protected ResolverDrawerLayout mResolverDrawerLayout; - protected PackageManager mPm; private static final String TAG = "ResolverActivity"; private static final boolean DEBUG = false; @@ -175,139 +176,32 @@ public class ResolverActivity extends FragmentActivity implements protected Insets mSystemWindowInsets = null; private Space mFooterSpacer = null; - /** See {@link #setRetainInOnStop}. */ - private boolean mRetainInOnStop; - protected static final String METRICS_CATEGORY_RESOLVER = "intent_resolver"; - protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; /** Tracks if we should ignore future broadcasts telling us the work profile is enabled */ - private boolean mWorkProfileHasBeenEnabled = false; + private final boolean mWorkProfileHasBeenEnabled = false; - private static final String TAB_TAG_PERSONAL = "personal"; - private static final String TAB_TAG_WORK = "work"; + protected static final String TAB_TAG_PERSONAL = "personal"; + protected static final String TAB_TAG_WORK = "work"; private PackageMonitor mPersonalPackageMonitor; private PackageMonitor mWorkPackageMonitor; - @VisibleForTesting - protected AbstractMultiProfilePagerAdapter mMultiProfilePagerAdapter; - - protected WorkProfileAvailabilityManager mWorkProfileAvailability; + protected ResolverMultiProfilePagerAdapter mMultiProfilePagerAdapter; - // Intent extra for connected audio devices - public static final String EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device"; - - /** - * Integer extra to indicate which profile should be automatically selected. - * <p>Can only be used if there is a work profile. - * <p>Possible values can be either {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK}. - */ - protected static final String EXTRA_SELECTED_PROFILE = - "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE"; - - /** - * {@link UserHandle} extra to indicate the user of the user that the starting intent - * originated from. - * <p>This is not necessarily the same as {@link #getUserId()} or {@link UserHandle#myUserId()}, - * as there are edge cases when the intent resolver is launched in the other profile. - * For example, when we have 0 resolved apps in current profile and multiple resolved - * apps in the other profile, opening a link from the current profile launches the intent - * resolver in the other one. b/148536209 for more info. - */ - static final String EXTRA_CALLING_USER = - "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER"; - - protected static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL; - protected static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK; + public static final int PROFILE_PERSONAL = MultiProfilePagerAdapter.PROFILE_PERSONAL; + public static final int PROFILE_WORK = MultiProfilePagerAdapter.PROFILE_WORK; private UserHandle mHeaderCreatorUser; - // User handle annotations are lazy-initialized to ensure that they're computed exactly once - // (even though they can't be computed prior to activity creation). - // TODO: use a less ad-hoc pattern for lazy initialization (by switching to Dagger or - // introducing a common `LazySingletonSupplier` API, etc), and/or migrate all dependents to a - // new component whose lifecycle is limited to the "created" Activity (so that we can just hold - // the annotations as a `final` ivar, which is a better way to show immutability). - private Supplier<AnnotatedUserHandles> mLazyAnnotatedUserHandles = () -> { - final AnnotatedUserHandles result = AnnotatedUserHandles.forShareActivity(this); - mLazyAnnotatedUserHandles = () -> result; - return result; - }; - @Nullable private OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - protected final LatencyTracker mLatencyTracker = getLatencyTracker(); - - private enum ActionTitle { - VIEW(Intent.ACTION_VIEW, - R.string.whichViewApplication, - R.string.whichViewApplicationNamed, - R.string.whichViewApplicationLabel), - EDIT(Intent.ACTION_EDIT, - R.string.whichEditApplication, - R.string.whichEditApplicationNamed, - R.string.whichEditApplicationLabel), - SEND(Intent.ACTION_SEND, - R.string.whichSendApplication, - R.string.whichSendApplicationNamed, - R.string.whichSendApplicationLabel), - SENDTO(Intent.ACTION_SENDTO, - R.string.whichSendToApplication, - R.string.whichSendToApplicationNamed, - R.string.whichSendToApplicationLabel), - SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, - R.string.whichSendApplication, - R.string.whichSendApplicationNamed, - R.string.whichSendApplicationLabel), - CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, - R.string.whichImageCaptureApplication, - R.string.whichImageCaptureApplicationNamed, - R.string.whichImageCaptureApplicationLabel), - DEFAULT(null, - R.string.whichApplication, - R.string.whichApplicationNamed, - R.string.whichApplicationLabel), - HOME(Intent.ACTION_MAIN, - R.string.whichHomeApplication, - R.string.whichHomeApplicationNamed, - R.string.whichHomeApplicationLabel); - - // titles for layout that deals with http(s) intents - public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith; - public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith; - public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp; - public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp; - - public final String action; - public final int titleRes; - public final int namedTitleRes; - public final @StringRes int labelRes; - - ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) { - this.action = action; - this.titleRes = titleRes; - this.namedTitleRes = namedTitleRes; - this.labelRes = labelRes; - } - - public static ActionTitle forAction(String action) { - for (ActionTitle title : values()) { - if (title != HOME && action != null && action.equals(title.action)) { - return title; - } - } - return DEFAULT; - } - } - protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { return new PackageMonitor() { @Override public void onSomePackagesChanged() { listAdapter.handlePackagesChanged(); - updateProfileViewButton(); } @Override @@ -319,99 +213,169 @@ public class ResolverActivity extends FragmentActivity implements }; } + protected ActivityModel createActivityModel() { + return ActivityModel.createFrom(this); + } + + @NonNull @Override - protected void onCreate(Bundle savedInstanceState) { - // Use a specialized prompt when we're handling the 'Home' app startActivity() - final Intent intent = makeMyIntent(); - final Set<String> categories = intent.getCategories(); - if (Intent.ACTION_MAIN.equals(intent.getAction()) - && categories != null - && categories.size() == 1 - && categories.contains(Intent.CATEGORY_HOME)) { - // Note: this field is not set to true in the compatibility version. - mResolvingHome = true; - } + public CreationExtras getDefaultViewModelCreationExtras() { + return addDefaultArgs( + super.getDefaultViewModelCreationExtras(), + new Pair<>(ActivityModel.ACTIVITY_MODEL_KEY, createActivityModel())); + } - setSafeForwardingMode(true); + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Log.i(TAG, "onCreate"); + setTheme(R.style.Theme_DeviceDefault_Resolver); + mResolverHelper.setInitializer(this::initialize); + } - onCreate(savedInstanceState, intent, null, 0, null, null, true, createIconLoader()); + @Override + protected final void onStart() { + super.onStart(); + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); } - /** - * Compatibility version for other bundled services that use this overload without - * a default title resource - */ - protected void onCreate(Bundle savedInstanceState, Intent intent, - CharSequence title, Intent[] initialIntents, - List<ResolveInfo> rList, boolean supportsAlwaysUseOption) { - onCreate( - savedInstanceState, - intent, - title, - 0, - initialIntents, - rList, - supportsAlwaysUseOption, - createIconLoader()); + @Override + protected void onStop() { + super.onStop(); + + final Window window = this.getWindow(); + final WindowManager.LayoutParams attrs = window.getAttributes(); + attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + window.setAttributes(attrs); + + if (mRegistered) { + mPersonalPackageMonitor.unregister(); + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + final Intent intent = getIntent(); + if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() + && !mResolvingHome) { + // This resolver is in the unusual situation where it has been + // launched at the top of a new task. We don't let it be added + // to the recent tasks shown to the user, and we need to make sure + // that each time we are launched we get the correct launching + // uid (not re-using the same resolver from an old launching uid), + // so we will now finish ourself since being no longer visible, + // the user probably can't get back to us. + if (!isChangingConfigurations()) { + finish(); + } + } } - protected void onCreate( - Bundle savedInstanceState, - Intent intent, - CharSequence title, - int defaultTitleRes, - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean supportsAlwaysUseOption, - TargetDataLoader targetDataLoader) { - setTheme(appliedThemeResId()); - super.onCreate(savedInstanceState); + @Override + protected final void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); + } + } - // Determine whether we should show that intent is forwarded - // from managed profile to owner or other way around. - setProfileSwitchMessage(intent.getContentUserHint()); + @Override + protected final void onRestart() { + super.onRestart(); + if (!mRegistered) { + mPersonalPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false); + if (mProfiles.getWorkProfilePresent()) { + if (mWorkPackageMonitor == null) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); + } + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false); + } + mRegistered = true; + } + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + } - // Force computation of user handle annotations in order to validate the caller ID. (See the - // associated TODO comment to explain why this is structured as a lazy computation.) - AnnotatedUserHandles unusedReferenceToHandles = mLazyAnnotatedUserHandles.get(); + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mMultiProfilePagerAdapter != null + && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { + mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); + } + } - mWorkProfileAvailability = createWorkProfileAvailabilityManager(); + private void initialize() { + mViewModel = new ViewModelProvider(this).get(ResolverViewModel.class); + mRequest = mViewModel.getRequest().getValue(); - mPm = getPackageManager(); + mProfiles = new ProfileHelper( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher, + mFeatureFlags); - mReferrerPackage = getReferrerPackageName(); + mProfileAvailability = new ProfileAvailability( + mUserInteractor, + getCoroutineScope(getLifecycle()), + mBackgroundDispatcher); - // Add our initial intent as the first item, regardless of what else has already been added. - mIntents.add(0, new Intent(intent)); - mTitle = title; - mDefaultTitleResId = defaultTitleRes; + mProfileAvailability.setOnProfileStatusChange(this::onWorkProfileStatusUpdated); - mSupportsAlwaysUseOption = supportsAlwaysUseOption; + mResolvingHome = mRequest.isResolvingHome(); + mTargetDataLoader = new DefaultTargetDataLoader( + this, + getLifecycle(), + mRequest.isAudioCaptureDevice()); // The last argument of createResolverListAdapter is whether to do special handling // of the last used choice to highlight it in the list. We need to always // turn this off when running under voice interaction, since it results in // a more complicated UI that the current voice interaction flow is not able - // to handle. We also turn it off when the work tab is shown to simplify the UX. + // to handle. We also turn it off when multiple tabs are shown to simplify the UX. // We also turn it off when clonedProfile is present on the device, because we might have // different "last chosen" activities in the different profiles, and PackageManager doesn't // provide any more information to help us select between them. - boolean filterLastUsed = mSupportsAlwaysUseOption && !isVoiceInteraction() - && !shouldShowTabs() && !hasCloneProfile(); + boolean filterLastUsed = !isVoiceInteraction() + && !mProfiles.getWorkProfilePresent() && !mProfiles.getCloneUserPresent(); mMultiProfilePagerAdapter = createMultiProfilePagerAdapter( - initialIntents, rList, filterLastUsed, targetDataLoader); - if (configureContentView(targetDataLoader)) { + new Intent[0], + /* resolutionList = */ mRequest.getResolutionList(), + filterLastUsed + ); + if (configureContentView(mTargetDataLoader)) { return; } mPersonalPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getPersonalListAdapter()); mPersonalPackageMonitor.register( - this, getMainLooper(), getPersonalProfileUserHandle(), false); - if (shouldShowTabs()) { + this, + getMainLooper(), + mProfiles.getPersonalHandle(), + false + ); + if (mProfiles.getWorkProfilePresent()) { mWorkPackageMonitor = createPackageMonitor( mMultiProfilePagerAdapter.getWorkListAdapter()); - mWorkPackageMonitor.register(this, getMainLooper(), getWorkProfileUserHandle(), false); + mWorkPackageMonitor.register( + this, + getMainLooper(), + mProfiles.getWorkHandle(), + false + ); } mRegistered = true; @@ -425,7 +389,7 @@ public class ResolverActivity extends FragmentActivity implements } }); - boolean hasTouchScreen = getPackageManager() + boolean hasTouchScreen = mPackageManager .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); if (isVoiceInteraction() || !hasTouchScreen) { @@ -438,13 +402,7 @@ public class ResolverActivity extends FragmentActivity implements mResolverDrawerLayout = rdl; } - - mProfileView = findViewById(com.android.internal.R.id.profile_button); - if (mProfileView != null) { - mProfileView.setOnClickListener(this::onProfileClick); - updateProfileViewButton(); - } - + Intent intent = mViewModel.getRequest().getValue().getIntent(); final Set<String> categories = intent.getCategories(); MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() ? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED @@ -453,61 +411,47 @@ public class ResolverActivity extends FragmentActivity implements + (categories != null ? Arrays.toString(categories.toArray()) : "")); } - protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( + private void restore(@Nullable Bundle savedInstanceState) { + if (savedInstanceState != null) { + // onRestoreInstanceState + resetButtonBar(); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + if (viewPager != null) { + viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); + } + } + + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + + protected ResolverMultiProfilePagerAdapter createMultiProfilePagerAdapter( Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - AbstractMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; - if (shouldShowTabs()) { + List<ResolveInfo> resolutionList, + boolean filterLastUsed) { + ResolverMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; + if (mProfiles.getWorkProfilePresent()) { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForTwoProfiles( - initialIntents, rList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed); } else { resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( - initialIntents, rList, filterLastUsed, targetDataLoader); + initialIntents, resolutionList, filterLastUsed); } return resolverMultiProfilePagerAdapter; } protected EmptyStateProvider createBlockerEmptyStateProvider() { - final boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); + boolean shouldShowNoCrossProfileIntentsEmptyState = getUser().equals(getIntentUser()); if (!shouldShowNoCrossProfileIntentsEmptyState) { // Implementation that doesn't show any blockers return new EmptyStateProvider() {}; } - - final AbstractMultiProfilePagerAdapter.EmptyState - noWorkToPersonalEmptyState = - new DevicePolicyBlockerEmptyState(/* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_PERSONAL, - /* defaultSubtitleResource= */ - R.string.resolver_cant_access_personal_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, - /* devicePolicyEventCategory= */ - ResolverActivity.METRICS_CATEGORY_RESOLVER); - - final AbstractMultiProfilePagerAdapter.EmptyState noPersonalToWorkEmptyState = - new DevicePolicyBlockerEmptyState(/* context= */ this, - /* devicePolicyStringTitleId= */ RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, - /* defaultTitleResource= */ R.string.resolver_cross_profile_blocked, - /* devicePolicyStringSubtitleId= */ RESOLVER_CANT_ACCESS_WORK, - /* defaultSubtitleResource= */ - R.string.resolver_cant_access_work_apps_explanation, - /* devicePolicyEventId= */ RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, - /* devicePolicyEventCategory= */ - ResolverActivity.METRICS_CATEGORY_RESOLVER); - - return new NoCrossProfileEmptyStateProvider(getPersonalProfileUserHandle(), - noWorkToPersonalEmptyState, noPersonalToWorkEmptyState, - createCrossProfileIntentsChecker(), getTabOwnerUserHandleForLaunch()); - } - - protected int appliedThemeResId() { - return R.style.Theme_DeviceDefault_Resolver; + return new NoCrossProfileEmptyStateProvider( + mProfiles, + mDevicePolicyResources, + createCrossProfileIntentsChecker(), + /* isShare= */ false); } /** @@ -519,9 +463,7 @@ public class ResolverActivity extends FragmentActivity implements if (useLayoutWithDefault()) return true; View buttonBar = findViewById(com.android.internal.R.id.button_bar); - if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true; - - return false; + return buttonBar == null || buttonBar.getVisibility() == View.GONE; } protected void applyFooterView(int height) { @@ -529,12 +471,12 @@ public class ResolverActivity extends FragmentActivity implements mFooterSpacer = new Space(getApplicationContext()); } else { ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().removeFooterView(mFooterSpacer); + .getActiveAdapterView().removeFooterView(mFooterSpacer); } mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, - mSystemWindowInsets.bottom)); + mSystemWindowInsets.bottom)); ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .getActiveAdapterView().addFooterView(mFooterSpacer); + .getActiveAdapterView().addFooterView(mFooterSpacer); } protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { @@ -563,7 +505,7 @@ public class ResolverActivity extends FragmentActivity implements public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() + if (mProfiles.getWorkProfilePresent() && !useLayoutWithDefault() && !shouldUseMiniResolver()) { updateIntentPickerPaddings(); } @@ -578,52 +520,7 @@ public class ResolverActivity extends FragmentActivity implements return R.layout.resolver_list; } - @Override - protected void onStop() { - super.onStop(); - - final Window window = this.getWindow(); - final WindowManager.LayoutParams attrs = window.getAttributes(); - attrs.privateFlags &= ~SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; - window.setAttributes(attrs); - - if (mRegistered) { - mPersonalPackageMonitor.unregister(); - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - final Intent intent = getIntent(); - if ((intent.getFlags() & FLAG_ACTIVITY_NEW_TASK) != 0 && !isVoiceInteraction() - && !mResolvingHome && !mRetainInOnStop) { - // This resolver is in the unusual situation where it has been - // launched at the top of a new task. We don't let it be added - // to the recent tasks shown to the user, and we need to make sure - // that each time we are launched we get the correct launching - // uid (not re-using the same resolver from an old launching uid), - // so we will now finish ourself since being no longer visible, - // the user probably can't get back to us. - if (!isChangingConfigurations()) { - finish(); - } - } - // TODO: should we clean up the work-profile manager before we potentially finish() above? - mWorkProfileAvailability.unregisterWorkProfileStateReceiver(this); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (!isChangingConfigurations() && mPickOptionRequest != null) { - mPickOptionRequest.cancel(); - } - if (mMultiProfilePagerAdapter != null - && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { - mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); - } - } - + // referenced by layout XML: android:onClick="onButtonClick" public void onButtonClick(View v) { final int id = v.getId(); ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); @@ -642,9 +539,9 @@ public class ResolverActivity extends FragmentActivity implements ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() .resolveInfoForPosition(which, hasIndexBeenFiltered); if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { + String launcherName = ri.activityInfo.loadLabel(mPackageManager).toString(); Toast.makeText(this, - getWorkProfileNotSupportedMsg( - ri.activityInfo.loadLabel(getPackageManager()).toString()), + mDevicePolicyResources.getWorkProfileNotSupportedMessage(launcherName), Toast.LENGTH_LONG).show(); return; } @@ -655,15 +552,12 @@ public class ResolverActivity extends FragmentActivity implements return; } if (onTargetSelected(target, always)) { - if (always && mSupportsAlwaysUseOption) { + if (always) { MetricsLogger.action( this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_ALWAYS); - } else if (mSupportsAlwaysUseOption) { - MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); } else { MetricsLogger.action( - this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_TAP); + this, MetricsProto.MetricsEvent.ACTION_APP_DISAMBIG_JUST_ONCE); } MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() @@ -673,9 +567,6 @@ public class ResolverActivity extends FragmentActivity implements } } - /** - * Replace me in subclasses! - */ @Override // ResolverListCommunicator public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { return defIntent; @@ -684,7 +575,7 @@ public class ResolverActivity extends FragmentActivity implements protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { final ItemClickListener listener = new ItemClickListener(); setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); - if (shouldShowTabs() && mIsIntentPicker) { + if (mProfiles.getWorkProfilePresent()) { final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); if (rdl != null) { rdl.setMaxCollapsedHeight(getResources() @@ -699,9 +590,9 @@ public class ResolverActivity extends FragmentActivity implements final ResolveInfo ri = target.getResolveInfo(); final Intent intent = target != null ? target.getResolvedIntent() : null; - if (intent != null && (mSupportsAlwaysUseOption - || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()) - && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { + if (intent != null /*&& mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()*/ + && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() + != null) { // Build a reasonable intent filter, based on what matched. IntentFilter filter = new IntentFilter(); Intent filterIntent; @@ -743,7 +634,7 @@ public class ResolverActivity extends FragmentActivity implements // or "content:" schemes (see IntentFilter for the reason). if (cat != IntentFilter.MATCH_CATEGORY_TYPE || (!"file".equals(data.getScheme()) - && !"content".equals(data.getScheme()))) { + && !"content".equals(data.getScheme()))) { filter.addDataScheme(data.getScheme()); // Look through the resolved filter to determine which part @@ -801,7 +692,7 @@ public class ResolverActivity extends FragmentActivity implements } int bestMatch = 0; - for (int i=0; i<N; i++) { + for (int i = 0; i < N; i++) { ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter() .getUnfilteredResolveList().get(i).getResolveInfoAt(0); set[i] = new ComponentName(r.activityInfo.packageName, @@ -819,7 +710,7 @@ public class ResolverActivity extends FragmentActivity implements if (always) { final int userId = getUserId(); - final PackageManager pm = getPackageManager(); + final PackageManager pm = mPackageManager; // Set the preferred Activity pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent()); @@ -828,7 +719,8 @@ public class ResolverActivity extends FragmentActivity implements // Set default Browser if needed final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId); if (TextUtils.isEmpty(packageName)) { - pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, userId); + pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, + userId); } } } else { @@ -842,21 +734,11 @@ public class ResolverActivity extends FragmentActivity implements } } - if (target != null) { - safelyStartActivity(target); + safelyStartActivity(target); - // Rely on the ActivityManager to pop up a dialog regarding app suspension - // and return false - if (target.isSuspended()) { - return false; - } - } - - return true; - } - - public void onActivityStarted(TargetInfo cti) { - // Do nothing + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + return !target.isSuspended(); } @Override // ResolverListCommunicator @@ -868,58 +750,65 @@ public class ResolverActivity extends FragmentActivity implements return !target.isSuspended(); } - // TODO: this method takes an unused `UserHandle` because the override in `ChooserActivity` uses - // that data to set up other components as dependencies of the controller. In reality, these - // methods don't require polymorphism, because they're only invoked from within their respective - // concrete class; `ResolverActivity` will never call this method expecting to get a - // `ChooserListController` (subclass) result, because `ResolverActivity` only invokes this - // method as part of handling `createMultiProfilePagerAdapter()`, which is itself overridden in - // `ChooserActivity`. A future refactoring could better express the coupling between the adapter - // and controller types; in the meantime, structuring as an override (with matching signatures) - // shows that these methods are *structurally* related, and helps to prevent any regressions in - // the future if resolver *were* to make any (non-overridden) calls to a version that used a - // different signature (and thus didn't return the subclass type). @VisibleForTesting protected ResolverListController createListController(UserHandle userHandle) { ResolverRankerServiceResolverComparator resolverComparator = new ResolverRankerServiceResolverComparator( this, - getTargetIntent(), - getReferrerPackageName(), + mRequest.getIntent(), + mViewModel.getActivityModel().getReferrerPackage(), null, null, getResolverRankerServiceUserHandleList(userHandle), null); return new ResolverListController( this, - mPm, - getTargetIntent(), - getReferrerPackageName(), - getAnnotatedUserHandles().userIdOfCallingApp, + mPackageManager, + mRequest.getIntent(), + mViewModel.getActivityModel().getReferrerPackage(), + mViewModel.getActivityModel().getLaunchedFromUid(), resolverComparator, - getQueryIntentsUser(userHandle)); + mProfiles.getQueryIntentsHandle(userHandle)); } /** * Finishing procedures to be performed after the list has been rebuilt. * </p>Subclasses must call postRebuildListInternal at the end of postRebuildList. - * @param rebuildCompleted + * * @return <code>true</code> if the activity is finishing and creation should halt. */ protected boolean postRebuildList(boolean rebuildCompleted) { return postRebuildListInternal(rebuildCompleted); } - void onHorizontalSwipeStateChanged(int state) {} - /** * Callback called when user changes the profile tab. - * <p>This method is intended to be overridden by subclasses. */ - protected void onProfileTabSelected() { } + /* TODO: consider merging with the customized considerations of our implemented + * {@link MultiProfilePagerAdapter.OnProfileSelectedListener}. The only apparent distinctions + * between the respective listener callbacks would occur in the triggering patterns during init + * (when the `OnProfileSelectedListener` is registered after a possible tab-change), or possibly + * if there's some way to trigger an update in one model but not the other. If there's an + * initialization dependency, we can probably reason about it with confidence. If there's a + * discrepancy between the `TabHost` and pager-adapter data models, that inconsistency is + * likely to be a bug that would benefit from consolidation. + */ + protected void onProfileTabSelected(int currentPage) { + setupViewVisibilities(); + maybeLogProfileChange(); + if (mProfiles.getWorkProfilePresent()) { + // The device policy logger is only concerned with sessions that include a work profile. + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) + .setInt(currentPage) + .setStrings(getMetricsCategory()) + .write(); + } + } /** * Add a label to signify that the user can pick a different app. + * * @param adapter The adapter used to provide data to item views. */ public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { @@ -929,7 +818,7 @@ public class ResolverActivity extends FragmentActivity implements stub.setVisibility(View.VISIBLE); TextView textView = (TextView) LayoutInflater.from(this).inflate( R.layout.resolver_different_item_header, null, false); - if (shouldShowTabs()) { + if (mProfiles.getWorkProfilePresent()) { textView.setGravity(Gravity.CENTER); } stub.addView(textView); @@ -937,9 +826,6 @@ public class ResolverActivity extends FragmentActivity implements } protected void resetButtonBar() { - if (!mSupportsAlwaysUseOption) { - return; - } final ViewGroup buttonLayout = findViewById(com.android.internal.R.id.button_bar); if (buttonLayout == null) { Log.e(TAG, "Layout unexpectedly does not have a button bar"); @@ -981,56 +867,24 @@ public class ResolverActivity extends FragmentActivity implements } @Override // ResolverListCommunicator - public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { - if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { - if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle()) - && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { - // We have just turned on the work profile and entered the pass code to start it, - // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no - // point in reloading the list now, since the work profile user is still - // turning on. - return; - } - boolean listRebuilt = mMultiProfilePagerAdapter.rebuildActiveTab(true); - if (listRebuilt) { - ResolverListAdapter activeListAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); - activeListAdapter.notifyDataSetChanged(); - if (activeListAdapter.getCount() == 0 && !inactiveListAdapterHasItems()) { - // We no longer have any items... just finish the activity. - finish(); - } - } - } else { - mMultiProfilePagerAdapter.clearInactiveProfileCache(); + public final void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (!mMultiProfilePagerAdapter.onHandlePackagesChanged( + listAdapter, + mProfileAvailability.getWaitingToEnableProfile())) { + // We no longer have any items... just finish the activity. + finish(); } } protected void maybeLogProfileChange() {} - // @NonFinalForTesting - @VisibleForTesting - protected MyUserIdProvider createMyUserIdProvider() { - return new MyUserIdProvider(); - } - - // @NonFinalForTesting @VisibleForTesting protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { return new CrossProfileIntentsChecker(getContentResolver()); } - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - final UserHandle workUser = getWorkProfileUserHandle(); - - return new WorkProfileAvailabilityManager( - getSystemService(UserManager.class), - workUser, - this::onWorkProfileStatusUpdated); - } - - protected void onWorkProfileStatusUpdated() { - if (mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getWorkProfileUserHandle())) { + private void onWorkProfileStatusUpdated() { + if (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_WORK) { mMultiProfilePagerAdapter.rebuildActiveTab(true); } else { mMultiProfilePagerAdapter.clearInactiveProfileCache(); @@ -1043,48 +897,22 @@ public class ResolverActivity extends FragmentActivity implements Context context, List<Intent> payloadIntents, Intent[] initialIntents, - List<ResolveInfo> rList, + List<ResolveInfo> resolutionList, boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { - UserHandle initialIntentsUserSpace = isLaunchedAsCloneProfile() - && userHandle.equals(getPersonalProfileUserHandle()) - ? getCloneProfileUserHandle() : userHandle; + UserHandle userHandle) { + UserHandle initialIntentsUserSpace = mProfiles.getQueryIntentsHandle(userHandle); return new ResolverListAdapter( context, payloadIntents, initialIntents, - rList, + resolutionList, filterLastUsed, createListController(userHandle), userHandle, - getTargetIntent(), + mRequest.getIntent(), this, initialIntentsUserSpace, - targetDataLoader); - } - - private TargetDataLoader createIconLoader() { - Intent startIntent = getIntent(); - boolean isAudioCaptureDevice = - startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); - return new DefaultTargetDataLoader(this, getLifecycle(), isAudioCaptureDevice); - } - - private LatencyTracker getLatencyTracker() { - return LatencyTracker.getInstance(this); - } - - /** - * Get the string resource to be used as a label for the link to the resolver activity for an - * action. - * - * @param action The action to resolve - * - * @return The string resource to be used as a label - */ - public static @StringRes int getLabelRes(String action) { - return ActionTitle.forAction(action).labelRes; + mTargetDataLoader); } protected final EmptyStateProvider createEmptyStateProvider( @@ -1092,8 +920,10 @@ public class ResolverActivity extends FragmentActivity implements final EmptyStateProvider blockerEmptyStateProvider = createBlockerEmptyStateProvider(); final EmptyStateProvider workProfileOffEmptyStateProvider = - new WorkProfilePausedEmptyStateProvider(this, workProfileUserHandle, - mWorkProfileAvailability, + new WorkProfilePausedEmptyStateProvider( + this, + mProfiles, + mProfileAvailability, /* onSwitchOnWorkSelectedListener= */ () -> { if (mOnSwitchOnWorkSelectedListener != null) { @@ -1102,12 +932,11 @@ public class ResolverActivity extends FragmentActivity implements }, getMetricsCategory()); - final EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( - this, - workProfileUserHandle, - getPersonalProfileUserHandle(), + EmptyStateProvider noAppsEmptyStateProvider = new NoAppsAvailableEmptyStateProvider( + mProfiles, + mProfileAvailability, getMetricsCategory(), - getTabOwnerUserHandleForLaunch() + mProfilePagerResources ); // Return composite provider, the order matters (the higher, the more priority) @@ -1118,70 +947,52 @@ public class ResolverActivity extends FragmentActivity implements ); } - private Intent makeMyIntent() { - Intent intent = new Intent(getIntent()); - intent.setComponent(null); - // The resolver activity is set to be hidden from recent tasks. - // we don't want this attribute to be propagated to the next activity - // being launched. Note that if the original Intent also had this - // flag set, we are now losing it. That should be a very rare case - // and we can live with this. - intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); - return intent; - } - - /** - * Call {@link Activity#onCreate} without initializing anything further. This should - * only be used when the activity is about to be immediately finished to avoid wasting - * initializing steps and leaking resources. - */ - protected final void super_onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - private ResolverMultiProfilePagerAdapter - createResolverMultiProfilePagerAdapterForOneProfile( - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { - ResolverListAdapter adapter = createResolverListAdapter( + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List<ResolveInfo> resolutionList, + boolean filterLastUsed) { + ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mRequest.getPayloadIntents(), initialIntents, - rList, + resolutionList, filterLastUsed, - /* userHandle */ getPersonalProfileUserHandle(), - targetDataLoader); + /* userHandle */ mProfiles.getPersonalHandle() + ); return new ResolverMultiProfilePagerAdapter( /* context */ this, - adapter, + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + personalAdapter)), createEmptyStateProvider(/* workProfileUserHandle= */ null), /* workProfileQuietModeChecker= */ () -> false, + /* defaultProfile= */ PROFILE_PERSONAL, /* workProfileUserHandle= */ null, - getCloneProfileUserHandle()); + mProfiles.getCloneHandle()); } private UserHandle getIntentUser() { - return getIntent().hasExtra(EXTRA_CALLING_USER) - ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) - : getTabOwnerUserHandleForLaunch(); + return Objects.requireNonNullElse(mRequest.getCallingUser(), + mProfiles.getTabOwnerUserHandleForLaunch()); } private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - TargetDataLoader targetDataLoader) { + List<ResolveInfo> resolutionList, + boolean filterLastUsed) { // In the edge case when we have 0 apps in the current profile and >1 apps in the other, // the intent resolver is started in the other profile. Since this is the only case when // this happens, we check for it here and set the current profile's tab. int selectedProfile = getCurrentProfile(); UserHandle intentUser = getIntentUser(); - if (!getTabOwnerUserHandleForLaunch().equals(intentUser)) { - if (getPersonalProfileUserHandle().equals(intentUser)) { + if (!mProfiles.getTabOwnerUserHandleForLaunch().equals(intentUser)) { + if (mProfiles.getPersonalHandle().equals(intentUser)) { selectedProfile = PROFILE_PERSONAL; - } else if (getWorkProfileUserHandle().equals(intentUser)) { + } else if (mProfiles.getWorkHandle().equals(intentUser)) { selectedProfile = PROFILE_WORK; } } else { @@ -1195,119 +1006,68 @@ public class ResolverActivity extends FragmentActivity implements // resolver list. So filterLastUsed should be false for the other profile. ResolverListAdapter personalAdapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mRequest.getPayloadIntents(), selectedProfile == PROFILE_PERSONAL ? initialIntents : null, - rList, + resolutionList, (filterLastUsed && UserHandle.myUserId() - == getPersonalProfileUserHandle().getIdentifier()), - /* userHandle */ getPersonalProfileUserHandle(), - targetDataLoader); - UserHandle workProfileUserHandle = getWorkProfileUserHandle(); + == mProfiles.getPersonalHandle().getIdentifier()), + /* userHandle */ mProfiles.getPersonalHandle() + ); + UserHandle workProfileUserHandle = mProfiles.getWorkHandle(); ResolverListAdapter workAdapter = createResolverListAdapter( /* context */ this, - /* payloadIntents */ mIntents, + mRequest.getPayloadIntents(), selectedProfile == PROFILE_WORK ? initialIntents : null, - rList, + resolutionList, (filterLastUsed && UserHandle.myUserId() == workProfileUserHandle.getIdentifier()), - /* userHandle */ workProfileUserHandle, - targetDataLoader); + /* userHandle */ workProfileUserHandle + ); return new ResolverMultiProfilePagerAdapter( /* context */ this, - personalAdapter, - workAdapter, - createEmptyStateProvider(getWorkProfileUserHandle()), - () -> mWorkProfileAvailability.isQuietModeEnabled(), + ImmutableList.of( + new TabConfig<>( + PROFILE_PERSONAL, + mDevicePolicyResources.getPersonalTabLabel(), + mDevicePolicyResources.getPersonalTabAccessibilityLabel(), + TAB_TAG_PERSONAL, + personalAdapter), + new TabConfig<>( + PROFILE_WORK, + mDevicePolicyResources.getWorkTabLabel(), + mDevicePolicyResources.getWorkTabAccessibilityLabel(), + TAB_TAG_WORK, + workAdapter)), + createEmptyStateProvider(workProfileUserHandle), + /* Supplier<Boolean> (QuietMode enabled) == !(available) */ + () -> !(mProfiles.getWorkProfilePresent() + && mProfileAvailability.isAvailable( + requireNonNull(mProfiles.getWorkProfile()))), selectedProfile, - getWorkProfileUserHandle(), - getCloneProfileUserHandle()); + workProfileUserHandle, + mProfiles.getCloneHandle()); } /** * Returns {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} if the {@link * #EXTRA_SELECTED_PROFILE} extra was supplied, or {@code -1} if no extra was supplied. - * @throws IllegalArgumentException if the value passed to the {@link #EXTRA_SELECTED_PROFILE} - * extra is not {@link #PROFILE_PERSONAL} or {@link #PROFILE_WORK} */ final int getSelectedProfileExtra() { - int selectedProfile = -1; - if (getIntent().hasExtra(EXTRA_SELECTED_PROFILE)) { - selectedProfile = getIntent().getIntExtra(EXTRA_SELECTED_PROFILE, /* defValue = */ -1); - if (selectedProfile != PROFILE_PERSONAL && selectedProfile != PROFILE_WORK) { - throw new IllegalArgumentException(EXTRA_SELECTED_PROFILE + " has invalid value " - + selectedProfile + ". Must be either ResolverActivity.PROFILE_PERSONAL or " - + "ResolverActivity.PROFILE_WORK."); - } + Profile.Type selected = mRequest.getSelectedProfile(); + if (selected == null) { + return -1; + } + switch (selected) { + case PERSONAL: return PROFILE_PERSONAL; + case WORK: return PROFILE_WORK; + default: return -1; } - return selectedProfile; - } - - protected final @Profile int getCurrentProfile() { - return (getTabOwnerUserHandleForLaunch().equals(getPersonalProfileUserHandle()) - ? PROFILE_PERSONAL : PROFILE_WORK); - } - - protected final AnnotatedUserHandles getAnnotatedUserHandles() { - return mLazyAnnotatedUserHandles.get(); - } - - protected final UserHandle getPersonalProfileUserHandle() { - return getAnnotatedUserHandles().personalProfileUserHandle; - } - - // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. - // @NonFinalForTesting - @Nullable - protected UserHandle getWorkProfileUserHandle() { - return getAnnotatedUserHandles().workProfileUserHandle; - } - - // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. - @Nullable - protected UserHandle getCloneProfileUserHandle() { - return getAnnotatedUserHandles().cloneProfileUserHandle; - } - - // TODO: have tests override `getAnnotatedUserHandles()`, and make this method `final`. - protected UserHandle getTabOwnerUserHandleForLaunch() { - return getAnnotatedUserHandles().tabOwnerUserHandleForLaunch; - } - - protected UserHandle getUserHandleSharesheetLaunchedAs() { - return getAnnotatedUserHandles().userHandleSharesheetLaunchedAs; - } - - - private boolean hasWorkProfile() { - return getWorkProfileUserHandle() != null; - } - - private boolean hasCloneProfile() { - return getCloneProfileUserHandle() != null; } - protected final boolean isLaunchedAsCloneProfile() { - return hasCloneProfile() - && getUserHandleSharesheetLaunchedAs().equals(getCloneProfileUserHandle()); - } - - - protected final boolean shouldShowTabs() { - return hasWorkProfile(); - } - - protected final void onProfileClick(View v) { - final DisplayResolveInfo dri = - mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); - if (dri == null) { - return; - } - - // Do not show the profile switch message anymore. - mProfileSwitchMessage = null; - - onTargetSelected(dri, false); - finish(); + protected final @ProfileType int getCurrentProfile() { + UserHandle launchUser = mProfiles.getTabOwnerUserHandleForLaunch(); + UserHandle personalUser = mProfiles.getPersonalHandle(); + return launchUser.equals(personalUser) ? PROFILE_PERSONAL : PROFILE_WORK; } private void updateIntentPickerPaddings() { @@ -1326,12 +1086,15 @@ public class ResolverActivity extends FragmentActivity implements } private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { - if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { + // TODO: Test isolation bug, referencing getUser() will break tests with faked profiles + if (!mProfiles.getWorkProfilePresent() || currentUserHandle.equals(getUser())) { return; } DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) - .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle())) + .setBoolean( + currentUserHandle.equals( + mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory(), cti.isInDirectShareMetricsCategory() ? "direct_share" : "other_target") .write(); @@ -1362,91 +1125,7 @@ public class ResolverActivity extends FragmentActivity implements } final Option optionForChooserTarget(TargetInfo target, int index) { - return new Option(target.getDisplayLabel(), index); - } - - protected final void setAdditionalTargets(Intent[] intents) { - if (intents != null) { - for (Intent intent : intents) { - mIntents.add(intent); - } - } - } - - public final Intent getTargetIntent() { - return mIntents.isEmpty() ? null : mIntents.get(0); - } - - protected final String getReferrerPackageName() { - final Uri referrer = getReferrer(); - if (referrer != null && "android-app".equals(referrer.getScheme())) { - return referrer.getHost(); - } - return null; - } - - @Override // ResolverListCommunicator - public final void updateProfileViewButton() { - if (mProfileView == null) { - return; - } - - final DisplayResolveInfo dri = - mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile(); - if (dri != null && !shouldShowTabs()) { - mProfileView.setVisibility(View.VISIBLE); - View text = mProfileView.findViewById(com.android.internal.R.id.profile_button); - if (!(text instanceof TextView)) { - text = mProfileView.findViewById(com.android.internal.R.id.text1); - } - ((TextView) text).setText(dri.getDisplayLabel()); - } else { - mProfileView.setVisibility(View.GONE); - } - } - - private void setProfileSwitchMessage(int contentUserHint) { - if ((contentUserHint != UserHandle.USER_CURRENT) - && (contentUserHint != UserHandle.myUserId())) { - UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); - UserInfo originUserInfo = userManager.getUserInfo(contentUserHint); - boolean originIsManaged = originUserInfo != null ? originUserInfo.isManagedProfile() - : false; - boolean targetIsManaged = userManager.isManagedProfile(); - if (originIsManaged && !targetIsManaged) { - mProfileSwitchMessage = getForwardToPersonalMsg(); - } else if (!originIsManaged && targetIsManaged) { - mProfileSwitchMessage = getForwardToWorkMsg(); - } - } - } - - private String getForwardToPersonalMsg() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - FORWARD_INTENT_TO_PERSONAL, - () -> getString(R.string.forward_intent_to_owner)); - } - - private String getForwardToWorkMsg() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - FORWARD_INTENT_TO_WORK, - () -> getString(R.string.forward_intent_to_work)); - } - - /** - * Turn on launch mode that is safe to use when forwarding intents received from - * applications and running in system processes. This mode uses Activity.startActivityAsCaller - * instead of the normal Activity.startActivity for launching the activity selected - * by the user. - * - * <p>This mode is set to true by default if the activity is initialized through - * {@link #onCreate(android.os.Bundle)}. If a subclass calls one of the other onCreate - * methods, it is set to false by default. You must set it before calling one of the - * more detailed onCreate methods, so that it will be set correctly in the case where - * there is only one intent to resolve and it is thus started immediately.</p> - */ - public final void setSafeForwardingMode(boolean safeForwarding) { - mSafeForwardingMode = safeForwarding; + return new Option(getOrLoadDisplayLabel(target), index); } protected final CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { @@ -1462,73 +1141,15 @@ public class ResolverActivity extends FragmentActivity implements return getString(defaultTitleRes); } else { return named - ? getString(title.namedTitleRes, mMultiProfilePagerAdapter - .getActiveListAdapter().getFilteredItem().getDisplayLabel()) + ? getString( + title.namedTitleRes, + getOrLoadDisplayLabel( + mMultiProfilePagerAdapter + .getActiveListAdapter().getFilteredItem())) : getString(title.titleRes); } } - final void dismiss() { - if (!isFinishing()) { - finish(); - } - } - - @Override - protected final void onRestart() { - super.onRestart(); - if (!mRegistered) { - mPersonalPackageMonitor.register(this, getMainLooper(), - getPersonalProfileUserHandle(), false); - if (shouldShowTabs()) { - if (mWorkPackageMonitor == null) { - mWorkPackageMonitor = createPackageMonitor( - mMultiProfilePagerAdapter.getWorkListAdapter()); - } - mWorkPackageMonitor.register(this, getMainLooper(), - getWorkProfileUserHandle(), false); - } - mRegistered = true; - } - if (shouldShowTabs() && mWorkProfileAvailability.isWaitingToEnableWorkProfile()) { - if (mWorkProfileAvailability.isQuietModeEnabled()) { - mWorkProfileAvailability.markWorkProfileEnabledBroadcastReceived(); - } - } - mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); - updateProfileViewButton(); - } - - @Override - protected final void onStart() { - super.onStart(); - - this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); - if (shouldShowTabs()) { - mWorkProfileAvailability.registerWorkProfileStateReceiver(this); - } - } - - @Override - protected final void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - outState.putInt(LAST_SHOWN_TAB_KEY, viewPager.getCurrentItem()); - } - } - - @Override - protected final void onRestoreInstanceState(Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - resetButtonBar(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - if (viewPager != null) { - viewPager.setCurrentItem(savedInstanceState.getInt(LAST_SHOWN_TAB_KEY)); - } - mMultiProfilePagerAdapter.clearInactiveProfileCache(); - } - private boolean hasManagedProfile() { UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); if (userManager == null) { @@ -1550,7 +1171,7 @@ public class ResolverActivity extends FragmentActivity implements private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { try { - ApplicationInfo appInfo = getPackageManager().getApplicationInfo( + ApplicationInfo appInfo = mPackageManager.getApplicationInfo( resolveInfo.activityInfo.packageName, 0 /* default flags */); return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; } catch (NameNotFoundException e) { @@ -1568,7 +1189,8 @@ public class ResolverActivity extends FragmentActivity implements // In case of clonedProfile being active, we do not allow the 'Always' option in the // disambiguation dialog of Personal Profile as the package manager cannot distinguish // between cross-profile preferred activities. - if (hasCloneProfile() && (mMultiProfilePagerAdapter.getCurrentPage() == PROFILE_PERSONAL)) { + if (mProfiles.getCloneUserPresent() + && (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL)) { mAlwaysButton.setEnabled(false); return; } @@ -1594,41 +1216,28 @@ public class ResolverActivity extends FragmentActivity implements if (ri != null) { ActivityInfo activityInfo = ri.activityInfo; - boolean hasRecordPermission = - mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO, + boolean hasRecordPermission = mPackageManager + .checkPermission(android.Manifest.permission.RECORD_AUDIO, activityInfo.packageName) - == android.content.pm.PackageManager.PERMISSION_GRANTED; + == PackageManager.PERMISSION_GRANTED; if (!hasRecordPermission) { // OK, we know the record permission, is this a capture device - boolean hasAudioCapture = - getIntent().getBooleanExtra( - ResolverActivity.EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + boolean hasAudioCapture = mViewModel.getRequest().getValue().isAudioCaptureDevice(); enabled = !hasAudioCapture; } } mAlwaysButton.setEnabled(enabled); } - private String getWorkProfileNotSupportedMsg(String launcherName) { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_PROFILE_NOT_SUPPORTED, - () -> getString( - R.string.activity_resolver_work_profiles_support, - launcherName), - launcherName); - } - @Override // ResolverListCommunicator public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, boolean rebuildCompleted) { if (isAutolaunching()) { return; } - if (mIsIntentPicker) { - ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) - .setUseLayoutWithDefault(useLayoutWithDefault()); - } + mMultiProfilePagerAdapter.setUseLayoutWithDefault(useLayoutWithDefault()); + if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) { mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter); } else { @@ -1649,10 +1258,9 @@ public class ResolverActivity extends FragmentActivity implements /** Start the activity specified by the {@link TargetInfo}.*/ public final void safelyStartActivity(TargetInfo cti) { // In case cloned apps are present, we would want to start those apps in cloned user - // space, which will not be same as adaptor's userHandle. resolveInfo.userHandle + // space, which will not be same as the adapter's userHandle. resolveInfo.userHandle // identifies the correct user space in such cases. - UserHandle activityUserHandle = getResolveInfoUserHandle( - cti.getResolveInfo(), mMultiProfilePagerAdapter.getCurrentUserHandle()); + UserHandle activityUserHandle = cti.getResolveInfo().userHandle; safelyStartActivityAsUser(cti, activityUserHandle, null); } @@ -1678,45 +1286,6 @@ public class ResolverActivity extends FragmentActivity implements } } - @VisibleForTesting - protected void safelyStartActivityInternal( - TargetInfo cti, UserHandle user, @Nullable Bundle options) { - // If the target is suspended, the activity will not be successfully launched. - // Do not unregister from package manager updates in this case - if (!cti.isSuspended() && mRegistered) { - if (mPersonalPackageMonitor != null) { - mPersonalPackageMonitor.unregister(); - } - if (mWorkPackageMonitor != null) { - mWorkPackageMonitor.unregister(); - } - mRegistered = false; - } - // If needed, show that intent is forwarded - // from managed profile to owner or other way around. - if (mProfileSwitchMessage != null) { - Toast.makeText(this, mProfileSwitchMessage, Toast.LENGTH_LONG).show(); - } - if (!mSafeForwardingMode) { - if (cti.startAsUser(this, options, user)) { - onActivityStarted(cti); - maybeLogCrossProfileTargetLaunch(cti, user); - } - return; - } - try { - if (cti.startAsCaller(this, options, user.getIdentifier())) { - onActivityStarted(cti); - maybeLogCrossProfileTargetLaunch(cti, user); - } - } catch (RuntimeException e) { - Slog.wtf(TAG, - "Unable to launch as uid " + getAnnotatedUserHandles().userIdOfCallingApp - + " package " + getLaunchedFromPackage() + ", while running in " - + ActivityThread.currentProcessName(), e); - } - } - final void showTargetDetails(ResolveInfo ri) { Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.fromParts("package", ri.activityInfo.packageName, null)) @@ -1736,13 +1305,9 @@ public class ResolverActivity extends FragmentActivity implements Trace.beginSection("configureContentView"); // We partially rebuild the inactive adapter to determine if we should auto launch // isTabLoaded will be true here if the empty state screen is shown instead of the list. - boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildActiveTab(true) - || mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded(); - if (shouldShowTabs()) { - boolean rebuildInactiveCompleted = mMultiProfilePagerAdapter.rebuildInactiveTab(false) - || mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded(); - rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted; - } + // To date, we really only care about "partially rebuilding" tabs for work and/or personal. + boolean rebuildCompleted = + mMultiProfilePagerAdapter.rebuildTabs(mProfiles.getWorkProfilePresent()); if (shouldUseMiniResolver()) { configureMiniResolverContent(targetDataLoader); @@ -1756,7 +1321,8 @@ public class ResolverActivity extends FragmentActivity implements mLayoutId = getLayoutResource(); } setContentView(mLayoutId); - mMultiProfilePagerAdapter.setupViewPager(findViewById(com.android.internal.R.id.profile_pager)); + mMultiProfilePagerAdapter.setupViewPager( + findViewById(com.android.internal.R.id.profile_pager)); boolean result = postRebuildList(rebuildCompleted); Trace.endSection(); return result; @@ -1772,18 +1338,26 @@ public class ResolverActivity extends FragmentActivity implements mLayoutId = R.layout.miniresolver; setContentView(mLayoutId); - DisplayResolveInfo sameProfileResolveInfo = - mMultiProfilePagerAdapter.getActiveListAdapter().getFirstDisplayResolveInfo(); boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; - final ResolverListAdapter inactiveAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); + ResolverListAdapter sameProfileAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + + ResolverListAdapter inactiveAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + DisplayResolveInfo sameProfileResolveInfo = sameProfileAdapter.getFirstDisplayResolveInfo(); + final DisplayResolveInfo otherProfileResolveInfo = inactiveAdapter.getFirstDisplayResolveInfo(); // Load the icon asynchronously ImageView icon = findViewById(com.android.internal.R.id.icon); - targetDataLoader.loadAppTargetIcon( + targetDataLoader.getOrLoadAppTargetIcon( otherProfileResolveInfo, inactiveAdapter.getUserHandle(), (drawable) -> { @@ -1795,9 +1369,10 @@ public class ResolverActivity extends FragmentActivity implements ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( getResources().getString( - inWorkProfile ? R.string.miniresolver_open_in_personal + inWorkProfile + ? R.string.miniresolver_open_in_personal : R.string.miniresolver_open_in_work, - otherProfileResolveInfo.getDisplayLabel())); + getOrLoadDisplayLabel(otherProfileResolveInfo))); ((Button) findViewById(com.android.internal.R.id.use_same_profile_browser)).setText( inWorkProfile ? R.string.miniresolver_use_work_browser : R.string.miniresolver_use_personal_browser); @@ -1815,6 +1390,69 @@ public class ResolverActivity extends FragmentActivity implements }); } + private boolean isTwoPagePersonalAndWorkConfiguration() { + return (mMultiProfilePagerAdapter.getCount() == 2) + && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_PERSONAL) + && mMultiProfilePagerAdapter.hasPageForProfile(PROFILE_WORK); + } + + @VisibleForTesting + protected void safelyStartActivityInternal( + TargetInfo cti, UserHandle user, @Nullable Bundle options) { + // If the target is suspended, the activity will not be successfully launched. + // Do not unregister from package manager updates in this case + if (!cti.isSuspended() && mRegistered) { + if (mPersonalPackageMonitor != null) { + mPersonalPackageMonitor.unregister(); + } + if (mWorkPackageMonitor != null) { + mWorkPackageMonitor.unregister(); + } + mRegistered = false; + } + // If needed, show that intent is forwarded + // from managed profile to owner or other way around. + String profileSwitchMessage = + mIntentForwarding.forwardMessageFor(mRequest.getIntent()); + if (profileSwitchMessage != null) { + Toast.makeText(this, profileSwitchMessage, Toast.LENGTH_LONG).show(); + } + try { + if (cti.startAsCaller(this, options, user.getIdentifier())) { + maybeLogCrossProfileTargetLaunch(cti, user); + } + } catch (RuntimeException e) { + Slog.wtf(TAG, + "Unable to launch as uid " + + mViewModel.getActivityModel().getLaunchedFromUid() + + " package " + mViewModel.getActivityModel().getLaunchedFromPackage() + + ", while running in " + ActivityThread.currentProcessName(), e); + } + } + + /** + * Finishing procedures to be performed after the list has been rebuilt. + * @param rebuildCompleted + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + final boolean postRebuildListInternal(boolean rebuildCompleted) { + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + + // We only rebuild asynchronously when we have multiple elements to sort. In the case where + // we're already done, we can check if we should auto-launch immediately. + if (rebuildCompleted && maybeAutolaunchActivity()) { + return true; + } + + setupViewVisibilities(); + + if (mProfiles.getWorkProfilePresent()) { + setupProfileTabs(); + } + + return false; + } + /** * Mini resolver should be used when all of the following are true: * 1. This is the intent picker (ResolverActivity). @@ -1822,17 +1460,19 @@ public class ResolverActivity extends FragmentActivity implements * 3. The other profile has a single non-browser match. */ private boolean shouldUseMiniResolver() { - if (!mIsIntentPicker) { - return false; - } - if (mMultiProfilePagerAdapter.getActiveListAdapter() == null - || mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { + if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } + ResolverListAdapter sameProfileAdapter = - mMultiProfilePagerAdapter.getActiveListAdapter(); + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + ResolverListAdapter otherProfileAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); if (sameProfileAdapter.getDisplayResolveInfoCount() == 0) { Log.d(TAG, "No targets in the current profile"); @@ -1857,53 +1497,6 @@ public class ResolverActivity extends FragmentActivity implements return true; } - /** - * Finishing procedures to be performed after the list has been rebuilt. - * @param rebuildCompleted - * @return <code>true</code> if the activity is finishing and creation should halt. - */ - final boolean postRebuildListInternal(boolean rebuildCompleted) { - int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); - - // We only rebuild asynchronously when we have multiple elements to sort. In the case where - // we're already done, we can check if we should auto-launch immediately. - if (rebuildCompleted && maybeAutolaunchActivity()) { - return true; - } - - setupViewVisibilities(); - - if (shouldShowTabs()) { - setupProfileTabs(); - } - - return false; - } - - private int isPermissionGranted(String permission, int uid) { - return ActivityManager.checkComponentPermission(permission, uid, - /* owningUid= */-1, /* exported= */ true); - } - - /** - * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} - */ - private boolean maybeAutolaunchActivity() { - int numberOfProfiles = mMultiProfilePagerAdapter.getItemCount(); - if (numberOfProfiles == 1 && maybeAutolaunchIfSingleTarget()) { - return true; - } else if (numberOfProfiles == 2 - && mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded() - && mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded() - && maybeAutolaunchIfCrossProfileSupported()) { - // TODO(b/280988288): If the ChooserActivity is shown we should consider showing the - // correct intent-picker UIs (e.g., mini-resolver) if it was launched without - // ACTION_SEND. - return true; - } - return false; - } - private boolean maybeAutolaunchIfSingleTarget() { int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); if (count != 1) { @@ -1926,42 +1519,57 @@ public class ResolverActivity extends FragmentActivity implements } /** - * When we have a personal and a work profile, we auto launch in the following scenario: + * When we have just a personal and a work profile, we auto launch in the following scenario: * - There is 1 resolved target on each profile * - That target is the same app on both profiles * - The target app has permission to communicate cross profiles * - The target app has declared it supports cross-profile communication via manifest metadata */ private boolean maybeAutolaunchIfCrossProfileSupported() { - ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); - int count = activeListAdapter.getUnfilteredCount(); - if (count != 1) { + if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } + + ResolverListAdapter activeListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); + ResolverListAdapter inactiveListAdapter = - mMultiProfilePagerAdapter.getInactiveListAdapter(); - if (inactiveListAdapter.getUnfilteredCount() != 1) { + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); + + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { return false; } - TargetInfo activeProfileTarget = activeListAdapter - .targetInfoForPosition(0, false); + + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } + + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); - if (!Objects.equals(activeProfileTarget.getResolvedComponentName(), + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), inactiveProfileTarget.getResolvedComponentName())) { return false; } + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { return false; } + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); - if (!canAppInteractCrossProfiles(packageName)) { + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { return false; } DevicePolicyEventLogger .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) .setBoolean(activeListAdapter.getUserHandle() - .equals(getPersonalProfileUserHandle())) + .equals(mProfiles.getPersonalHandle())) .setStrings(getMetricsCategory()) .write(); safelyStartActivity(activeProfileTarget); @@ -1969,140 +1577,66 @@ public class ResolverActivity extends FragmentActivity implements return true; } + private boolean isAutolaunching() { + return !mRegistered && isFinishing(); + } + /** - * Returns whether the package has the necessary permissions to interact across profiles on - * behalf of a given user. - * - * <p>This means meeting the following condition: - * <ul> - * <li>The app's {@link ApplicationInfo#crossProfile} flag must be true, and at least - * one of the following conditions must be fulfilled</li> - * <li>{@code Manifest.permission.INTERACT_ACROSS_USERS_FULL} granted.</li> - * <li>{@code Manifest.permission.INTERACT_ACROSS_USERS} granted.</li> - * <li>{@code Manifest.permission.INTERACT_ACROSS_PROFILES} granted, or the corresponding - * AppOps {@code android:interact_across_profiles} is set to "allow".</li> - * </ul> - * + * @return {@code true} if a resolved target is autolaunched, otherwise {@code false} */ - private boolean canAppInteractCrossProfiles(String packageName) { - ApplicationInfo applicationInfo; - try { - applicationInfo = getPackageManager().getApplicationInfo(packageName, 0); - } catch (NameNotFoundException e) { - Log.e(TAG, "Package " + packageName + " does not exist on current user."); - return false; - } - if (!applicationInfo.crossProfile) { + private boolean maybeAutolaunchActivity() { + if (!isTwoPagePersonalAndWorkConfiguration()) { return false; } - int packageUid = applicationInfo.uid; + ResolverListAdapter activeListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getPersonalListAdapter() + : mMultiProfilePagerAdapter.getWorkListAdapter(); - if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL, - packageUid) == PackageManager.PERMISSION_GRANTED) { - return true; - } - if (isPermissionGranted(android.Manifest.permission.INTERACT_ACROSS_USERS, packageUid) - == PackageManager.PERMISSION_GRANTED) { - return true; - } - if (PermissionChecker.checkPermissionForPreflight(this, INTERACT_ACROSS_PROFILES, - PID_UNKNOWN, packageUid, packageName) == PackageManager.PERMISSION_GRANTED) { - return true; - } - return false; - } + ResolverListAdapter inactiveListAdapter = + (mMultiProfilePagerAdapter.getActiveProfile() == PROFILE_PERSONAL) + ? mMultiProfilePagerAdapter.getWorkListAdapter() + : mMultiProfilePagerAdapter.getPersonalListAdapter(); - private boolean isAutolaunching() { - return !mRegistered && isFinishing(); - } + if (!activeListAdapter.isTabLoaded() || !inactiveListAdapter.isTabLoaded()) { + return false; + } - private void setupProfileTabs() { - maybeHideDivider(); - TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); - tabHost.setup(); - ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); - viewPager.setSaveEnabled(false); - - Button personalButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - personalButton.setText(getPersonalTabLabel()); - personalButton.setContentDescription(getPersonalTabAccessibilityLabel()); - - TabHost.TabSpec tabSpec = tabHost.newTabSpec(TAB_TAG_PERSONAL) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(personalButton); - tabHost.addTab(tabSpec); - - Button workButton = (Button) getLayoutInflater().inflate( - R.layout.resolver_profile_tab_button, tabHost.getTabWidget(), false); - workButton.setText(getWorkTabLabel()); - workButton.setContentDescription(getWorkTabAccessibilityLabel()); - - tabSpec = tabHost.newTabSpec(TAB_TAG_WORK) - .setContent(com.android.internal.R.id.profile_pager) - .setIndicator(workButton); - tabHost.addTab(tabSpec); - - TabWidget tabWidget = tabHost.getTabWidget(); - tabWidget.setVisibility(View.VISIBLE); - updateActiveTabStyle(tabHost); - - tabHost.setOnTabChangedListener(tabId -> { - updateActiveTabStyle(tabHost); - if (TAB_TAG_PERSONAL.equals(tabId)) { - viewPager.setCurrentItem(0); - } else { - viewPager.setCurrentItem(1); - } - setupViewVisibilities(); - maybeLogProfileChange(); - onProfileTabSelected(); - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_SWITCH_TABS) - .setInt(viewPager.getCurrentItem()) - .setStrings(getMetricsCategory()) - .write(); - }); + if ((activeListAdapter.getUnfilteredCount() != 1) + || (inactiveListAdapter.getUnfilteredCount() != 1)) { + return false; + } - viewPager.setVisibility(View.VISIBLE); - tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage()); - mMultiProfilePagerAdapter.setOnProfileSelectedListener( - new AbstractMultiProfilePagerAdapter.OnProfileSelectedListener() { - @Override - public void onProfileSelected(int index) { - tabHost.setCurrentTab(index); - resetButtonBar(); - resetCheckedItem(); - } + TargetInfo activeProfileTarget = activeListAdapter.targetInfoForPosition(0, false); + TargetInfo inactiveProfileTarget = inactiveListAdapter.targetInfoForPosition(0, false); + if (!Objects.equals( + activeProfileTarget.getResolvedComponentName(), + inactiveProfileTarget.getResolvedComponentName())) { + return false; + } - @Override - public void onProfilePageStateChanged(int state) { - onHorizontalSwipeStateChanged(state); - } - }); - mOnSwitchOnWorkSelectedListener = () -> { - final View workTab = tabHost.getTabWidget().getChildAt(1); - workTab.setFocusable(true); - workTab.setFocusableInTouchMode(true); - workTab.requestFocus(); - }; - } + if (!shouldAutoLaunchSingleChoice(activeProfileTarget)) { + return false; + } - private String getPersonalTabLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_PERSONAL_TAB, () -> getString(R.string.resolver_personal_tab)); - } + String packageName = activeProfileTarget.getResolvedComponentName().getPackageName(); + if (!mIntentForwarding.canAppInteractAcrossProfiles(this, packageName)) { + return false; + } - private String getWorkTabLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab)); + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) + .setBoolean(activeListAdapter.getUserHandle() + .equals(mProfiles.getPersonalHandle())) + .setStrings(getMetricsCategory()) + .write(); + safelyStartActivity(activeProfileTarget); + finish(); + return true; } private void maybeHideDivider() { - if (!mIsIntentPicker) { - return; - } final View divider = findViewById(com.android.internal.R.id.divider); if (divider == null) { return; @@ -2111,41 +1645,9 @@ public class ResolverActivity extends FragmentActivity implements } private void resetCheckedItem() { - if (!mIsIntentPicker) { - return; - } mLastSelected = ListView.INVALID_POSITION; - ListView inactiveListView = (ListView) mMultiProfilePagerAdapter.getInactiveAdapterView(); - if (inactiveListView.getCheckedItemCount() > 0) { - inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); - } - } - - private String getPersonalTabAccessibilityLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_PERSONAL_TAB_ACCESSIBILITY, - () -> getString(R.string.resolver_personal_tab_accessibility)); - } - - private String getWorkTabAccessibilityLabel() { - return getSystemService(DevicePolicyManager.class).getResources().getString( - RESOLVER_WORK_TAB_ACCESSIBILITY, - () -> getString(R.string.resolver_work_tab_accessibility)); - } - - private static int getAttrColor(Context context, int attr) { - TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); - int colorAccent = ta.getColor(0, 0); - ta.recycle(); - return colorAccent; - } - - private void updateActiveTabStyle(TabHost tabHost) { - int currentTab = tabHost.getCurrentTab(); - TextView selected = (TextView) tabHost.getTabWidget().getChildAt(currentTab); - TextView unselected = (TextView) tabHost.getTabWidget().getChildAt(1 - currentTab); - selected.setSelected(true); - unselected.setSelected(false); + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .clearCheckedItemsInInactiveProfiles(); } private void setupViewVisibilities() { @@ -2173,10 +1675,7 @@ public class ResolverActivity extends FragmentActivity implements private void setupAdapterListView(ListView listView, ItemClickListener listener) { listView.setOnItemClickListener(listener); listView.setOnItemLongClickListener(listener); - - if (mSupportsAlwaysUseOption) { - listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); - } + listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); } /** @@ -2187,17 +1686,17 @@ public class ResolverActivity extends FragmentActivity implements && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { return; } - if (!shouldShowTabs() + if (!mProfiles.getWorkProfilePresent() && listAdapter.getCount() == 0 && listAdapter.getPlaceholderCount() == 0) { final TextView titleView = findViewById(com.android.internal.R.id.title); if (titleView != null) { titleView.setVisibility(View.GONE); } } - - CharSequence title = mTitle != null - ? mTitle - : getTitleForAction(getTargetIntent(), mDefaultTitleResId); + ResolverRequest request = mViewModel.getRequest().getValue(); + CharSequence title = mViewModel.getRequest().getValue().getTitle() != null + ? request.getTitle() + : getTitleForAction(request.getIntent(), 0); if (!TextUtils.isEmpty(title)) { final TextView titleView = findViewById(com.android.internal.R.id.title); @@ -2242,43 +1741,9 @@ public class ResolverActivity extends FragmentActivity implements public final boolean useLayoutWithDefault() { // We only use the default app layout when the profile of the active user has a // filtered item. We always show the same default app even in the inactive user profile. - boolean adapterForCurrentUserHasFilteredItem = - mMultiProfilePagerAdapter.getListAdapterForUserHandle( - getTabOwnerUserHandleForLaunch()).hasFilteredItem(); - return mSupportsAlwaysUseOption && adapterForCurrentUserHasFilteredItem; - } - - /** - * If {@code retainInOnStop} is set to true, we will not finish ourselves when onStop gets - * called and we are launched in a new task. - */ - protected final void setRetainInOnStop(boolean retainInOnStop) { - mRetainInOnStop = retainInOnStop; - } - - /** - * Check a simple match for the component of two ResolveInfos. - */ - @Override // ResolverListCommunicator - public final boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs) { - return lhs == null ? rhs == null - : lhs.activityInfo == null ? rhs.activityInfo == null - : Objects.equals(lhs.activityInfo.name, rhs.activityInfo.name) - && Objects.equals(lhs.activityInfo.packageName, rhs.activityInfo.packageName) - // Comparing against resolveInfo.userHandle in case cloned apps are present, - // as they will have the same activityInfo. - && Objects.equals( - getResolveInfoUserHandle(lhs, - mMultiProfilePagerAdapter.getActiveListAdapter().getUserHandle()), - getResolveInfoUserHandle(rhs, - mMultiProfilePagerAdapter.getActiveListAdapter().getUserHandle())); - } - - private boolean inactiveListAdapterHasItems() { - if (!shouldShowTabs()) { - return false; - } - return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0; + return mMultiProfilePagerAdapter.getListAdapterForUserHandle( + mProfiles.getTabOwnerUserHandleForLaunch() + ).hasFilteredItem(); } final class ItemClickListener implements AdapterView.OnItemClickListener, @@ -2335,11 +1800,37 @@ public class ResolverActivity extends FragmentActivity implements } - /** Determine whether a given match result is considered "specific" in our application. */ - public static final boolean isSpecificUriMatch(int match) { - match = (match & IntentFilter.MATCH_CATEGORY_MASK); - return match >= IntentFilter.MATCH_CATEGORY_HOST - && match <= IntentFilter.MATCH_CATEGORY_PATH; + private void setupProfileTabs() { + maybeHideDivider(); + + TabHost tabHost = findViewById(com.android.internal.R.id.profile_tabhost); + ViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + + mMultiProfilePagerAdapter.setupProfileTabs( + getLayoutInflater(), + tabHost, + viewPager, + R.layout.resolver_profile_tab_button, + com.android.internal.R.id.profile_pager, + () -> onProfileTabSelected(viewPager.getCurrentItem()), + new OnProfileSelectedListener() { + @Override + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { + resetButtonBar(); + resetCheckedItem(); + } + + @Override + public void onProfilePageStateChanged(int state) {} + }); + mOnSwitchOnWorkSelectedListener = () -> { + final View workTab = + tabHost.getTabWidget().getChildAt( + mMultiProfilePagerAdapter.getPageNumberForProfile(PROFILE_WORK)); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }; } static final class PickTargetOptionRequest extends PickOptionRequest { @@ -2383,7 +1874,7 @@ public class ResolverActivity extends FragmentActivity implements * {@link ResolverListController} configured for the provided {@code userHandle}. */ protected final UserHandle getQueryIntentsUser(UserHandle userHandle) { - return mLazyAnnotatedUserHandles.get().getQueryIntentsUser(userHandle); + return mProfiles.getQueryIntentsHandle(userHandle); } /** @@ -2403,19 +1894,18 @@ public class ResolverActivity extends FragmentActivity implements // Add clonedProfileUserHandle to the list only if we are: // a. Building the Personal Tab. // b. CloneProfile exists on the device. - if (userHandle.equals(getPersonalProfileUserHandle()) - && getCloneProfileUserHandle() != null) { - userList.add(getCloneProfileUserHandle()); + if (userHandle.equals(mProfiles.getPersonalHandle()) + && mProfiles.getCloneUserPresent()) { + userList.add(mProfiles.getCloneHandle()); } return userList; } - /** - * This function is temporary in nature, and its usages will be replaced with just - * resolveInfo.userHandle, once it is available, once sharesheet is stable. - */ - public static UserHandle getResolveInfoUserHandle(ResolveInfo resolveInfo, - UserHandle predictedHandle) { - return resolveInfo.userHandle; + private CharSequence getOrLoadDisplayLabel(TargetInfo info) { + if (info.isDisplayResolveInfo()) { + mTargetDataLoader.getOrLoadLabel((DisplayResolveInfo) info); + } + CharSequence displayLabel = info.getDisplayLabel(); + return displayLabel == null ? "" : displayLabel; } } diff --git a/java/src/com/android/intentresolver/ResolverHelper.kt b/java/src/com/android/intentresolver/ResolverHelper.kt new file mode 100644 index 00000000..d12ba7d5 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverHelper.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver + +import android.app.Activity +import android.os.UserHandle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import com.android.intentresolver.annotation.JavaInterop +import com.android.intentresolver.domain.interactor.UserInteractor +import com.android.intentresolver.inject.Background +import com.android.intentresolver.ui.model.ResolverRequest +import com.android.intentresolver.ui.viewmodel.ResolverViewModel +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.log +import dagger.hilt.android.scopes.ActivityScoped +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher + +private const val TAG: String = "ResolverHelper" + +/** + * __Purpose__ + * + * Cleanup aid. Provides a pathway to cleaner code. + * + * __Incoming References__ + * + * ResolverHelper must not expose any properties or functions directly back to ResolverActivity. If + * a value or operation is required by ResolverActivity, then it must be added to + * ResolverInitializer (or a new interface as appropriate) with ResolverActivity supplying a + * callback to receive it at the appropriate point. This enforces unidirectional control flow. + * + * __Outgoing References__ + * + * _ResolverActivity_ + * + * This class must only reference it's host as Activity/ComponentActivity; no down-cast to + * [ResolverActivity]. Other components should be created here or supplied via Injection, and not + * referenced directly from the activity. This prevents circular dependencies from forming. If + * necessary, during cleanup the dependency can be supplied back to ChooserActivity as described + * above in 'Incoming References', see [ResolverInitializer]. + * + * _Elsewhere_ + * + * Where possible, Singleton and ActivityScoped dependencies should be injected here instead of + * referenced from an existing location. If not available for injection, the value should be + * constructed here, then provided to where it is needed. + */ +@ActivityScoped +@JavaInterop +class ResolverHelper +@Inject +constructor( + hostActivity: Activity, + private val userInteractor: UserInteractor, + @Background private val background: CoroutineDispatcher, +) : DefaultLifecycleObserver { + // This is guaranteed by Hilt, since only a ComponentActivity is injectable. + private val activity: ComponentActivity = hostActivity as ComponentActivity + private val viewModel by activity.viewModels<ResolverViewModel>() + + private lateinit var activityInitializer: Runnable + + init { + activity.lifecycle.addObserver(this) + } + + /** + * Set the initialization hook for the host activity. + * + * This _must_ be called from [ResolverActivity.onCreate]. + */ + fun setInitializer(initializer: Runnable) { + if (activity.lifecycle.currentState != Lifecycle.State.INITIALIZED) { + error("setInitializer must be called before onCreate returns") + } + activityInitializer = initializer + } + + /** Invoked by Lifecycle, after Activity.onCreate() _returns_. */ + override fun onCreate(owner: LifecycleOwner) { + Log.i(TAG, "CREATE") + Log.i(TAG, "${viewModel.activityModel}") + + val callerUid: Int = viewModel.activityModel.launchedFromUid + if (callerUid < 0 || UserHandle.isIsolated(callerUid)) { + Log.e(TAG, "Can't start a resolver from uid $callerUid") + activity.finish() + return + } + + when (val request = viewModel.initialRequest) { + is Valid -> initializeActivity(request) + is Invalid -> reportErrorsAndFinish(request) + } + } + + private fun reportErrorsAndFinish(request: Invalid<ResolverRequest>) { + request.errors.forEach { it.log(TAG) } + activity.finish() + } + + private fun initializeActivity(request: Valid<ResolverRequest>) { + Log.d(TAG, "initializeActivity") + request.warnings.forEach { it.log(TAG) } + + activityInitializer.run() + } +} diff --git a/java/src/com/android/intentresolver/ResolverInfoHelpers.kt b/java/src/com/android/intentresolver/ResolverInfoHelpers.kt new file mode 100644 index 00000000..8d1d8658 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverInfoHelpers.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("ResolveInfoHelpers") + +package com.android.intentresolver + +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo + +fun resolveInfoMatch(lhs: ResolveInfo?, rhs: ResolveInfo?): Boolean = + (lhs === rhs) || + ((lhs != null && rhs != null) && + activityInfoMatch(lhs.activityInfo, rhs.activityInfo) && + // Comparing against resolveInfo.userHandle in case cloned apps are present, + // as they will have the same activityInfo. + lhs.userHandle == rhs.userHandle) + +private fun activityInfoMatch(lhs: ActivityInfo?, rhs: ActivityInfo?): Boolean = + (lhs === rhs) || + (lhs != null && rhs != null && lhs.name == rhs.name && lhs.packageName == rhs.packageName) diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java index 282a672f..5fd37d43 100644 --- a/java/src/com/android/intentresolver/ResolverListAdapter.java +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -16,8 +16,6 @@ package com.android.intentresolver; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; @@ -42,8 +40,14 @@ import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.icons.LabelInfo; import com.android.intentresolver.icons.TargetDataLoader; import com.android.internal.annotations.VisibleForTesting; @@ -53,6 +57,8 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; public class ResolverListAdapter extends BaseAdapter { private static final String TAG = "ResolverListAdapter"; @@ -63,7 +69,7 @@ public class ResolverListAdapter extends BaseAdapter { protected final Context mContext; protected final LayoutInflater mInflater; protected final ResolverListCommunicator mResolverListCommunicator; - protected final ResolverListController mResolverListController; + public final ResolverListController mResolverListController; private final List<Intent> mIntents; private final Intent[] mInitialIntents; @@ -75,6 +81,9 @@ public class ResolverListAdapter extends BaseAdapter { private final Set<DisplayResolveInfo> mRequestedIcons = new HashSet<>(); private final Set<DisplayResolveInfo> mRequestedLabels = new HashSet<>(); + private final Executor mBgExecutor; + private final Executor mCallbackExecutor; + private final AtomicBoolean mDestroyed = new AtomicBoolean(); private ResolveInfo mLastChosen; private DisplayResolveInfo mOtherProfile; @@ -86,7 +95,6 @@ public class ResolverListAdapter extends BaseAdapter { private int mLastChosenPosition = -1; private final boolean mFilterLastUsed; - private Runnable mPostListReadyRunnable; private boolean mIsTabLoaded; // Represents the UserSpace in which the Initial Intents should be resolved. private final UserHandle mInitialIntentsUserSpace; @@ -103,6 +111,37 @@ public class ResolverListAdapter extends BaseAdapter { ResolverListCommunicator resolverListCommunicator, UserHandle initialIntentsUserSpace, TargetDataLoader targetDataLoader) { + this( + context, + payloadIntents, + initialIntents, + rList, + filterLastUsed, + resolverListController, + userHandle, + targetIntent, + resolverListCommunicator, + initialIntentsUserSpace, + targetDataLoader, + AsyncTask.SERIAL_EXECUTOR, + runnable -> context.getMainThreadHandler().post(runnable)); + } + + @VisibleForTesting + public ResolverListAdapter( + Context context, + List<Intent> payloadIntents, + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed, + ResolverListController resolverListController, + UserHandle userHandle, + Intent targetIntent, + ResolverListCommunicator resolverListCommunicator, + UserHandle initialIntentsUserSpace, + TargetDataLoader targetDataLoader, + Executor bgExecutor, + Executor callbackExecutor) { mContext = context; mIntents = payloadIntents; mInitialIntents = initialIntents; @@ -117,6 +156,12 @@ public class ResolverListAdapter extends BaseAdapter { mTargetIntent = targetIntent; mResolverListCommunicator = resolverListCommunicator; mInitialIntentsUserSpace = initialIntentsUserSpace; + mBgExecutor = bgExecutor; + mCallbackExecutor = callbackExecutor; + } + + protected Intent getTargetIntent() { + return mTargetIntent; } public final DisplayResolveInfo getFirstDisplayResolveInfo() { @@ -189,18 +234,18 @@ public class ResolverListAdapter extends BaseAdapter { packageName, userHandle, action); } - List<ResolvedComponentInfo> getUnfilteredResolveList() { + public List<ResolvedComponentInfo> getUnfilteredResolveList() { return mUnfilteredResolveList; } /** * Rebuild the list of resolvers. When rebuilding is complete, queue the {@code onPostListReady} - * callback on the main handler with {@code rebuildCompleted} true. + * callback on the callback executor with {@code rebuildCompleted} true. * * In some cases some parts will need some asynchronous work to complete. Then this will first - * immediately queue {@code onPostListReady} (on the main handler) with {@code rebuildCompleted} - * false; only when the asynchronous work completes will this then go on to queue another - * {@code onPostListReady} callback with {@code rebuildCompleted} true. + * immediately queue {@code onPostListReady} (on the callback executor) with + * {@code rebuildCompleted} false; only when the asynchronous work completes will this then go + * on to queue another {@code onPostListReady} callback with {@code rebuildCompleted} true. * * The {@code doPostProcessing} parameter is used to specify whether to update the UI and * load additional targets (e.g. direct share) after the list has been rebuilt. We may choose @@ -212,7 +257,7 @@ public class ResolverListAdapter extends BaseAdapter { * with {@code rebuildCompleted} true at the end of some newly-launched asynchronous work. * Otherwise the callback is only queued once, with {@code rebuildCompleted} true. */ - protected boolean rebuildList(boolean doPostProcessing) { + public boolean rebuildList(boolean doPostProcessing) { Trace.beginSection("ResolverListAdapter#rebuildList"); mDisplayList.clear(); mIsTabLoaded = false; @@ -357,8 +402,8 @@ public class ResolverListAdapter extends BaseAdapter { otherProfileInfo, mPm, mTargetIntent, - mResolverListCommunicator, - mTargetDataLoader); + mResolverListCommunicator + ); } else { mOtherProfile = null; try { @@ -402,35 +447,42 @@ public class ResolverListAdapter extends BaseAdapter { // Send an "incomplete" list-ready while the async task is running. postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ false); - createSortingTask(doPostProcessing).execute(filteredResolveList); + mBgExecutor.execute(() -> { + if (isDestroyed()) { + return; + } + List<ResolvedComponentInfo> sortedComponents = null; + //TODO: the try-catch logic here is to formally match the AsyncTask's behavior. + // Empirically, we don't need it as in the case on an exception, the app will crash and + // `onComponentsSorted` won't be invoked. + try { + sortComponents(filteredResolveList); + sortedComponents = filteredResolveList; + } catch (Throwable t) { + Log.e(TAG, "Failed to sort components", t); + throw t; + } finally { + final List<ResolvedComponentInfo> result = sortedComponents; + mCallbackExecutor.execute(() -> onComponentsSorted(result, doPostProcessing)); + } + }); return false; } - AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>> createSortingTask(boolean doPostProcessing) { - return new AsyncTask<List<ResolvedComponentInfo>, - Void, - List<ResolvedComponentInfo>>() { - @Override - protected List<ResolvedComponentInfo> doInBackground( - List<ResolvedComponentInfo>... params) { - mResolverListController.sort(params[0]); - return params[0]; - } - @Override - protected void onPostExecute(List<ResolvedComponentInfo> sortedComponents) { - processSortedList(sortedComponents, doPostProcessing); - notifyDataSetChanged(); - if (doPostProcessing) { - mResolverListCommunicator.updateProfileViewButton(); - } - } - }; + @WorkerThread + protected void sortComponents(List<ResolvedComponentInfo> components) { + mResolverListController.sort(components); } - protected void processSortedList(List<ResolvedComponentInfo> sortedComponents, - boolean doPostProcessing) { + @MainThread + protected void onComponentsSorted( + @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) { + processSortedList(sortedComponents, doPostProcessing); + notifyDataSetChanged(); + } + + protected void processSortedList( + @Nullable List<ResolvedComponentInfo> sortedComponents, boolean doPostProcessing) { final int n = sortedComponents != null ? sortedComponents.size() : 0; Trace.beginSection("ResolverListAdapter#processSortedList:" + n); if (n != 0) { @@ -471,8 +523,7 @@ public class ResolverListAdapter extends BaseAdapter { ri, ri.loadLabel(mPm), null, - ii, - mTargetDataLoader.createPresentationGetter(ri))); + ii)); } } @@ -494,23 +545,23 @@ public class ResolverListAdapter extends BaseAdapter { /** * Some necessary methods for creating the list are initiated in onCreate and will also * determine the layout known. We therefore can't update the UI inline and post to the - * handler thread to update after the current task is finished. + * callback executor to update after the current task is finished. * @param doPostProcessing Whether to update the UI and load additional direct share targets * after the list has been rebuilt * @param rebuildCompleted Whether the list has been completely rebuilt */ - void postListReadyRunnable(boolean doPostProcessing, boolean rebuildCompleted) { - if (mPostListReadyRunnable == null) { - mPostListReadyRunnable = new Runnable() { - @Override - public void run() { - mResolverListCommunicator.onPostListReady(ResolverListAdapter.this, - doPostProcessing, rebuildCompleted); - mPostListReadyRunnable = null; + public void postListReadyRunnable(boolean doPostProcessing, boolean rebuildCompleted) { + Runnable listReadyRunnable = new Runnable() { + @Override + public void run() { + if (mDestroyed.get()) { + return; } - }; - mContext.getMainThreadHandler().post(mPostListReadyRunnable); - } + mResolverListCommunicator.onPostListReady(ResolverListAdapter.this, + doPostProcessing, rebuildCompleted); + } + }; + mCallbackExecutor.execute(listReadyRunnable); } private void addResolveInfoWithAlternates(ResolvedComponentInfo rci) { @@ -524,8 +575,7 @@ public class ResolverListAdapter extends BaseAdapter { final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( intent, add, - (replaceIntent != null) ? replaceIntent : defaultIntent, - mTargetDataLoader.createPresentationGetter(add)); + (replaceIntent != null) ? replaceIntent : defaultIntent); dri.setPinned(rci.isPinned()); if (rci.isPinned()) { Log.i(TAG, "Pinned item: " + rci.name); @@ -572,7 +622,7 @@ public class ResolverListAdapter extends BaseAdapter { protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { // Checks if this info is already listed in display. for (DisplayResolveInfo existingInfo : mDisplayList) { - if (mResolverListCommunicator + if (ResolveInfoHelpers .resolveInfoMatch(dri.getResolveInfo(), existingInfo.getResolveInfo())) { return false; } @@ -600,6 +650,7 @@ public class ResolverListAdapter extends BaseAdapter { return null; } + @Override public int getCount() { int totalSize = mDisplayList == null || mDisplayList.isEmpty() ? mPlaceholderCount : mDisplayList.size(); @@ -613,6 +664,7 @@ public class ResolverListAdapter extends BaseAdapter { return mDisplayList.size(); } + @Override @Nullable public TargetInfo getItem(int position) { if (mFilterLastUsed && mLastChosenPosition >= 0 && position >= mLastChosenPosition) { @@ -625,6 +677,7 @@ public class ResolverListAdapter extends BaseAdapter { } } + @Override public long getItemId(int position) { return position; } @@ -642,6 +695,7 @@ public class ResolverListAdapter extends BaseAdapter { return mDisplayList.get(index); } + @Override public final View getView(int position, View convertView, ViewGroup parent) { View view = convertView; if (view == null) { @@ -685,52 +739,53 @@ public class ResolverListAdapter extends BaseAdapter { holder.bindLabel("", ""); loadLabel(dri); } - holder.bindIcon(info); if (!dri.hasDisplayIcon()) { loadIcon(dri); } + holder.bindIcon(info); } } protected final void loadIcon(DisplayResolveInfo info) { if (mRequestedIcons.add(info)) { - mTargetDataLoader.loadAppTargetIcon( + Drawable icon = mTargetDataLoader.getOrLoadAppTargetIcon( info, getUserHandle(), - (drawable) -> onIconLoaded(info, drawable)); + (drawable) -> { + onIconLoaded(info, drawable); + notifyDataSetChanged(); + }); + if (icon != null) { + onIconLoaded(info, icon); + } } } private void onIconLoaded(DisplayResolveInfo displayResolveInfo, Drawable drawable) { - if (getOtherProfile() == displayResolveInfo) { - mResolverListCommunicator.updateProfileViewButton(); - } else if (!displayResolveInfo.hasDisplayIcon()) { + if (!displayResolveInfo.hasDisplayIcon()) { displayResolveInfo.getDisplayIconHolder().setDisplayIcon(drawable); - notifyDataSetChanged(); } } - private void loadLabel(DisplayResolveInfo info) { + protected final void loadLabel(DisplayResolveInfo info) { if (mRequestedLabels.add(info)) { mTargetDataLoader.loadLabel(info, (result) -> onLabelLoaded(info, result)); } } protected final void onLabelLoaded( - DisplayResolveInfo displayResolveInfo, CharSequence[] result) { + DisplayResolveInfo displayResolveInfo, LabelInfo result) { if (displayResolveInfo.hasDisplayLabel()) { return; } - displayResolveInfo.setDisplayLabel(result[0]); - displayResolveInfo.setExtendedInfo(result[1]); + displayResolveInfo.setDisplayLabel(result.getLabel()); + displayResolveInfo.setExtendedInfo(result.getSubLabel()); notifyDataSetChanged(); } public void onDestroy() { - if (mPostListReadyRunnable != null) { - mContext.getMainThreadHandler().removeCallbacks(mPostListReadyRunnable); - mPostListReadyRunnable = null; - } + mDestroyed.set(true); + if (mResolverListController != null) { mResolverListController.destroy(); } @@ -738,6 +793,10 @@ public class ResolverListAdapter extends BaseAdapter { mRequestedLabels.clear(); } + public final boolean isDestroyed() { + return mDestroyed.get(); + } + private static ColorMatrixColorFilter getSuspendedColorMatrix() { if (sSuspendedMatrixColorFilter == null) { @@ -765,10 +824,10 @@ public class ResolverListAdapter extends BaseAdapter { return mContext.getDrawable(R.drawable.resolver_icon_placeholder); } - void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { + public void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { final DisplayResolveInfo iconInfo = getFilteredItem(); if (iconInfo != null) { - mTargetDataLoader.loadAppTargetIcon( + mTargetDataLoader.getOrLoadAppTargetIcon( iconInfo, getUserHandle(), iconView::setImageDrawable); } } @@ -777,7 +836,7 @@ public class ResolverListAdapter extends BaseAdapter { return mUserHandle; } - protected List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) { + public final List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) { return mResolverListController.getResolversForIntentAsUser( /* shouldGetResolvedFilter= */ true, mResolverListCommunicator.shouldGetActivityMetadata(), @@ -786,15 +845,16 @@ public class ResolverListAdapter extends BaseAdapter { userHandle); } - protected List<Intent> getIntents() { + public List<Intent> getIntents() { + // TODO: immutable copy? return mIntents; } - protected boolean isTabLoaded() { + public boolean isTabLoaded() { return mIsTabLoaded; } - protected void markTabLoaded() { + public void markTabLoaded() { mIsTabLoaded = true; } @@ -828,8 +888,7 @@ public class ResolverListAdapter extends BaseAdapter { ResolvedComponentInfo resolvedComponentInfo, PackageManager pm, Intent targetIntent, - ResolverListCommunicator resolverListCommunicator, - TargetDataLoader targetDataLoader) { + ResolverListCommunicator resolverListCommunicator) { ResolveInfo resolveInfo = resolvedComponentInfo.getResolveInfoAt(0); Intent pOrigIntent = resolverListCommunicator.getReplacementIntent( @@ -838,36 +897,34 @@ public class ResolverListAdapter extends BaseAdapter { Intent replacementIntent = resolverListCommunicator.getReplacementIntent( resolveInfo.activityInfo, targetIntent); - TargetPresentationGetter presentationGetter = - targetDataLoader.createPresentationGetter(resolveInfo); - return DisplayResolveInfo.newDisplayResolveInfo( resolvedComponentInfo.getIntentAt(0), resolveInfo, resolveInfo.loadLabel(pm), resolveInfo.loadLabel(pm), - pOrigIntent != null ? pOrigIntent : replacementIntent, - presentationGetter); + pOrigIntent != null ? pOrigIntent : replacementIntent); } /** * Necessary methods to communicate between {@link ResolverListAdapter} * and {@link ResolverActivity}. */ - interface ResolverListCommunicator { - - boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs); + public interface ResolverListCommunicator { Intent getReplacementIntent(ActivityInfo activityInfo, Intent defIntent); + // ResolverListCommunicator + default void updateProfileViewButton() { + } + void onPostListReady(ResolverListAdapter listAdapter, boolean updateUi, boolean rebuildCompleted); void sendVoiceChoicesIfNeeded(); - void updateProfileViewButton(); - - boolean useLayoutWithDefault(); + default boolean useLayoutWithDefault() { + return false; + } boolean shouldGetActivityMetadata(); @@ -875,7 +932,9 @@ public class ResolverListAdapter extends BaseAdapter { * @return true to filter only apps that can handle * {@link android.content.Intent#CATEGORY_DEFAULT} intents */ - default boolean shouldGetOnlyDefaultActivities() { return true; }; + default boolean shouldGetOnlyDefaultActivities() { + return true; + } void onHandlePackagesChanged(ResolverListAdapter listAdapter); } @@ -887,12 +946,28 @@ public class ResolverListAdapter extends BaseAdapter { @VisibleForTesting public static class ViewHolder { public View itemView; - public Drawable defaultItemViewBackground; + public final Drawable defaultItemViewBackground; public TextView text; public TextView text2; public ImageView icon; + public final void reset() { + text.setText(""); + text.setMaxLines(2); + text.setMaxWidth(Integer.MAX_VALUE); + + text2.setVisibility(View.GONE); + text2.setText(""); + + itemView.setContentDescription(null); + itemView.setBackground(defaultItemViewBackground); + + icon.setImageDrawable(null); + icon.setColorFilter(null); + icon.clearAnimation(); + } + @VisibleForTesting public ViewHolder(View view) { itemView = view; @@ -921,10 +996,6 @@ public class ResolverListAdapter extends BaseAdapter { itemView.setContentDescription(null); } - public void updateContentDescription(String description) { - itemView.setContentDescription(description); - } - /** * Bind view holder to a TargetInfo. */ diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java index d5a5fedf..e88d766d 100644 --- a/java/src/com/android/intentresolver/ResolverListController.java +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -17,7 +17,6 @@ package com.android.intentresolver; -import android.annotation.WorkerThread; import android.app.ActivityManager; import android.app.AppGlobals; import android.content.ComponentName; @@ -31,6 +30,8 @@ import android.os.RemoteException; import android.os.UserHandle; import android.util.Log; +import androidx.annotation.WorkerThread; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.TargetInfo; import com.android.intentresolver.model.AbstractResolverComparator; @@ -254,7 +255,6 @@ public class ResolverListController { isComputed = true; } - @VisibleForTesting @WorkerThread public void sort(List<ResolvedComponentInfo> inputList) { try { @@ -273,7 +273,6 @@ public class ResolverListController { } } - @VisibleForTesting @WorkerThread public void topK(List<ResolvedComponentInfo> inputList, int k) { if (inputList == null || inputList.isEmpty() || k <= 0) { @@ -335,7 +334,7 @@ public class ResolverListController { && ai.name.equals(b.name.getClassName()); } - boolean isComponentFiltered(ComponentName componentName) { + public boolean isComponentFiltered(ComponentName componentName) { return false; } diff --git a/java/src/com/android/intentresolver/ResolverViewPager.java b/java/src/com/android/intentresolver/ResolverViewPager.java index 0804a2b8..891ace87 100644 --- a/java/src/com/android/intentresolver/ResolverViewPager.java +++ b/java/src/com/android/intentresolver/ResolverViewPager.java @@ -69,12 +69,18 @@ public class ResolverViewPager extends ViewPager { * Sets whether swiping sideways should happen. * <p>Note that swiping is always disabled for RTL layouts (b/159110029 for context). */ - void setSwipingEnabled(boolean swipingEnabled) { + public void setSwipingEnabled(boolean swipingEnabled) { mSwipingEnabled = swipingEnabled; } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { - return !isLayoutRtl() && mSwipingEnabled && super.onInterceptTouchEvent(ev); + return !isEnabled() + || (!isLayoutRtl() && mSwipingEnabled && super.onInterceptTouchEvent(ev)); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + return isEnabled() && super.onTouchEvent(ev); } } diff --git a/java/src/com/android/intentresolver/SecureSettings.kt b/java/src/com/android/intentresolver/SecureSettings.kt index a4853fd8..1e938895 100644 --- a/java/src/com/android/intentresolver/SecureSettings.kt +++ b/java/src/com/android/intentresolver/SecureSettings.kt @@ -19,9 +19,7 @@ package com.android.intentresolver import android.content.ContentResolver import android.provider.Settings -/** - * A proxy class for secure settings, for easier testing. - */ +/** A proxy class for secure settings, for easier testing. */ open class SecureSettings { open fun getString(resolver: ContentResolver, name: String): String? { return Settings.Secure.getString(resolver, name) diff --git a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java index 645b9391..2d5ec451 100644 --- a/java/src/com/android/intentresolver/ShortcutSelectionLogic.java +++ b/java/src/com/android/intentresolver/ShortcutSelectionLogic.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.app.prediction.AppTarget; import android.content.Context; import android.content.Intent; @@ -26,16 +25,24 @@ import android.content.pm.ShortcutInfo; import android.service.chooser.ChooserTarget; import android.util.Log; +import androidx.annotation.Nullable; + import com.android.intentresolver.chooser.DisplayResolveInfo; import com.android.intentresolver.chooser.SelectableTargetInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.ui.AppShortcutLimit; +import com.android.intentresolver.ui.EnforceShortcutLimit; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map; -class ShortcutSelectionLogic { +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class ShortcutSelectionLogic { private static final String TAG = "ShortcutSelectionLogic"; private static final boolean DEBUG = false; private static final float PINNED_SHORTCUT_TARGET_SCORE_BOOST = 1000.f; @@ -48,9 +55,10 @@ class ShortcutSelectionLogic { private final Comparator<ChooserTarget> mBaseTargetComparator = (lhs, rhs) -> Float.compare(rhs.getScore(), lhs.getScore()); - ShortcutSelectionLogic( - int maxShortcutTargetsPerApp, - boolean applySharingAppLimits) { + @Inject + public ShortcutSelectionLogic( + @AppShortcutLimit int maxShortcutTargetsPerApp, + @EnforceShortcutLimit boolean applySharingAppLimits) { mMaxShortcutTargetsPerApp = maxShortcutTargetsPerApp; mApplySharingAppLimits = applySharingAppLimits; } @@ -77,7 +85,7 @@ class ShortcutSelectionLogic { + targets.size() + " targets"); } - if (targets.size() == 0) { + if (targets.isEmpty()) { return false; } Collections.sort(targets, mBaseTargetComparator); diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java index ec5179ac..f4871e36 100644 --- a/java/src/com/android/intentresolver/SimpleIconFactory.java +++ b/java/src/com/android/intentresolver/SimpleIconFactory.java @@ -21,9 +21,6 @@ import static android.graphics.Paint.DITHER_FLAG; import static android.graphics.Paint.FILTER_BITMAP_FLAG; import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction; -import android.annotation.AttrRes; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.ActivityManager; import android.content.Context; import android.content.pm.PackageManager; @@ -50,6 +47,10 @@ import android.util.AttributeSet; import android.util.Pools.SynchronizedPool; import android.util.TypedValue; +import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.internal.annotations.VisibleForTesting; import org.xmlpull.v1.XmlPullParser; @@ -57,7 +58,6 @@ import org.xmlpull.v1.XmlPullParser; import java.nio.ByteBuffer; import java.util.Optional; - /** * @deprecated Use the Launcher3 Iconloaderlib at packages/apps/Launcher3/iconloaderlib. This class * is a temporary fork of Iconloader. It combines all necessary methods to render app icons that are @@ -719,10 +719,18 @@ public class SimpleIconFactory { } @Override - public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs) { } + public void inflate( + @NonNull Resources r, + @NonNull XmlPullParser parser, + @NonNull AttributeSet attrs) { + } @Override - public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs, Theme theme) { } + public void inflate( + @NonNull Resources r, + @NonNull XmlPullParser parser, + @NonNull AttributeSet attrs, Theme theme) { + } /** * Sets the scale associated with this drawable diff --git a/java/src/com/android/intentresolver/StartsSelectedItem.kt b/java/src/com/android/intentresolver/StartsSelectedItem.kt new file mode 100644 index 00000000..01cdf124 --- /dev/null +++ b/java/src/com/android/intentresolver/StartsSelectedItem.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver + +interface StartsSelectedItem { + /** Start the selected item. */ + fun startSelected(which: Int, always: Boolean, filtered: Boolean) +} diff --git a/java/src/com/android/intentresolver/TargetPresentationGetter.java b/java/src/com/android/intentresolver/TargetPresentationGetter.java index f8b36566..910c65c9 100644 --- a/java/src/com/android/intentresolver/TargetPresentationGetter.java +++ b/java/src/com/android/intentresolver/TargetPresentationGetter.java @@ -16,7 +16,6 @@ package com.android.intentresolver; -import android.annotation.Nullable; import android.content.Context; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; @@ -30,6 +29,8 @@ import android.os.UserHandle; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.Nullable; + /** * Loads the icon and label for the provided ApplicationInfo. Defaults to using the application icon * and label over any IntentFilter or Activity icon to increase user understanding, with an @@ -37,7 +38,7 @@ import android.util.Log; * resources over PackageManager loading mechanisms so badging can be done by iconloader. Uses * Strings to strip creative formatting. * - * Use one of the {@link TargetPresentationGetter#Factory} methods to create an instance of the + * Use one of the {@link TargetPresentationGetter.Factory} methods to create an instance of the * appropriate concrete type. * * TODO: once this component (and its tests) are merged, it should be possible to refactor and diff --git a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java deleted file mode 100644 index 2f3dfbd5..00000000 --- a/java/src/com/android/intentresolver/WorkProfilePausedEmptyStateProvider.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; - -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.app.admin.DevicePolicyEventLogger; -import android.app.admin.DevicePolicyManager; -import android.content.Context; -import android.os.UserHandle; -import android.stats.devicepolicy.nano.DevicePolicyEnums; - -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyState; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.EmptyStateProvider; -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.OnSwitchOnWorkSelectedListener; - -/** - * Chooser/ResolverActivity empty state provider that returns empty state which is shown when - * work profile is paused and we need to show a button to enable it. - */ -public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { - - private final UserHandle mWorkProfileUserHandle; - private final WorkProfileAvailabilityManager mWorkProfileAvailability; - private final String mMetricsCategory; - private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; - private final Context mContext; - - public WorkProfilePausedEmptyStateProvider(@NonNull Context context, - @Nullable UserHandle workProfileUserHandle, - @NonNull WorkProfileAvailabilityManager workProfileAvailability, - @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, - @NonNull String metricsCategory) { - mContext = context; - mWorkProfileUserHandle = workProfileUserHandle; - mWorkProfileAvailability = workProfileAvailability; - mMetricsCategory = metricsCategory; - mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; - } - - @Nullable - @Override - public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { - if (!resolverListAdapter.getUserHandle().equals(mWorkProfileUserHandle) - || !mWorkProfileAvailability.isQuietModeEnabled() - || resolverListAdapter.getCount() == 0) { - return null; - } - - final String title = mContext.getSystemService(DevicePolicyManager.class) - .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, - () -> mContext.getString(R.string.resolver_turn_on_work_apps)); - - return new WorkProfileOffEmptyState(title, (tab) -> { - tab.showSpinner(); - if (mOnSwitchOnWorkSelectedListener != null) { - mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); - } - mWorkProfileAvailability.requestQuietModeEnabled(false); - }, mMetricsCategory); - } - - public static class WorkProfileOffEmptyState implements EmptyState { - - private final String mTitle; - private final ClickListener mOnClick; - private final String mMetricsCategory; - - public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, - @NonNull String metricsCategory) { - mTitle = title; - mOnClick = onClick; - mMetricsCategory = metricsCategory; - } - - @Nullable - @Override - public String getTitle() { - return mTitle; - } - - @Nullable - @Override - public ClickListener getButtonClickListener() { - return mOnClick; - } - - @Override - public void onEmptyStateShown() { - DevicePolicyEventLogger - .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) - .setStrings(mMetricsCategory) - .write(); - } - } -} diff --git a/java/src/com/android/intentresolver/annotation/JavaInterop.kt b/java/src/com/android/intentresolver/annotation/JavaInterop.kt new file mode 100644 index 00000000..e268af98 --- /dev/null +++ b/java/src/com/android/intentresolver/annotation/JavaInterop.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.annotation + +/** + * Apply to code which exists specifically to easy integration with existing Java and Java APIs. + * + * The goal is to prevent usage from Kotlin when a more idiomatic alternative is available. + */ +@RequiresOptIn( + "This is a a property, function or class specifically supporting Java " + + "interoperability. Usage from Kotlin should be limited to interactions with Java." +) +annotation class JavaInterop diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java index 8b9bfb32..074537ef 100644 --- a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java @@ -16,6 +16,8 @@ package com.android.intentresolver.chooser; +import android.service.chooser.ChooserTarget; + import java.util.ArrayList; import java.util.Arrays; diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java index 09cf319f..5e44c53e 100644 --- a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -16,8 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.Activity; import android.content.ComponentName; import android.content.Intent; @@ -27,10 +25,10 @@ import android.content.pm.ResolveInfo; import android.os.Bundle; import android.os.UserHandle; -import com.android.intentresolver.TargetPresentationGetter; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; /** @@ -39,12 +37,11 @@ import java.util.List; */ public class DisplayResolveInfo implements TargetInfo { private final ResolveInfo mResolveInfo; - private CharSequence mDisplayLabel; - private CharSequence mExtendedInfo; + private volatile CharSequence mDisplayLabel; + private volatile CharSequence mExtendedInfo; private final Intent mResolvedIntent; private final List<Intent> mSourceIntents = new ArrayList<>(); private final boolean mIsSuspended; - private TargetPresentationGetter mPresentationGetter; private boolean mPinned = false; private final IconHolder mDisplayIconHolder = new SettableIconHolder(); @@ -52,15 +49,13 @@ public class DisplayResolveInfo implements TargetInfo { public static DisplayResolveInfo newDisplayResolveInfo( Intent originalIntent, ResolveInfo resolveInfo, - @NonNull Intent resolvedIntent, - @Nullable TargetPresentationGetter presentationGetter) { + @NonNull Intent resolvedIntent) { return newDisplayResolveInfo( originalIntent, resolveInfo, /* displayLabel=*/ null, /* extendedInfo=*/ null, - resolvedIntent, - presentationGetter); + resolvedIntent); } /** Create a new {@code DisplayResolveInfo} instance. */ @@ -69,15 +64,13 @@ public class DisplayResolveInfo implements TargetInfo { ResolveInfo resolveInfo, CharSequence displayLabel, CharSequence extendedInfo, - @NonNull Intent resolvedIntent, - @Nullable TargetPresentationGetter presentationGetter) { + @NonNull Intent resolvedIntent) { return new DisplayResolveInfo( originalIntent, resolveInfo, displayLabel, extendedInfo, - resolvedIntent, - presentationGetter); + resolvedIntent); } private DisplayResolveInfo( @@ -85,13 +78,11 @@ public class DisplayResolveInfo implements TargetInfo { ResolveInfo resolveInfo, CharSequence displayLabel, CharSequence extendedInfo, - @NonNull Intent resolvedIntent, - @Nullable TargetPresentationGetter presentationGetter) { + @NonNull Intent resolvedIntent) { mSourceIntents.add(originalIntent); mResolveInfo = resolveInfo; mDisplayLabel = displayLabel; mExtendedInfo = extendedInfo; - mPresentationGetter = presentationGetter; final ActivityInfo ai = mResolveInfo.activityInfo; mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; @@ -101,8 +92,7 @@ public class DisplayResolveInfo implements TargetInfo { private DisplayResolveInfo( DisplayResolveInfo other, - @Nullable Intent baseIntentToSend, - TargetPresentationGetter presentationGetter) { + @Nullable Intent baseIntentToSend) { mSourceIntents.addAll(other.getAllSourceIntents()); mResolveInfo = other.mResolveInfo; mIsSuspended = other.mIsSuspended; @@ -112,7 +102,6 @@ public class DisplayResolveInfo implements TargetInfo { mResolvedIntent = createResolvedIntent( baseIntentToSend == null ? other.mResolvedIntent : baseIntentToSend, mResolveInfo.activityInfo); - mPresentationGetter = presentationGetter; mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } @@ -124,7 +113,6 @@ public class DisplayResolveInfo implements TargetInfo { mDisplayLabel = other.mDisplayLabel; mExtendedInfo = other.mExtendedInfo; mResolvedIntent = other.mResolvedIntent; - mPresentationGetter = other.mPresentationGetter; mDisplayIconHolder.setDisplayIcon(other.mDisplayIconHolder.getDisplayIcon()); } @@ -147,10 +135,6 @@ public class DisplayResolveInfo implements TargetInfo { } public CharSequence getDisplayLabel() { - if (mDisplayLabel == null && mPresentationGetter != null) { - mDisplayLabel = mPresentationGetter.getLabel(); - mExtendedInfo = mPresentationGetter.getSubLabel(); - } return mDisplayLabel; } @@ -186,8 +170,7 @@ public class DisplayResolveInfo implements TargetInfo { return new DisplayResolveInfo( this, - TargetInfo.mergeRefinementIntoMatchingBaseIntent(matchingBase, proposedRefinement), - mPresentationGetter); + TargetInfo.mergeRefinementIntoMatchingBaseIntent(matchingBase, proposedRefinement)); } @Override @@ -197,7 +180,7 @@ public class DisplayResolveInfo implements TargetInfo { @Override public ArrayList<DisplayResolveInfo> getAllDisplayTargets() { - return new ArrayList<>(Arrays.asList(this)); + return new ArrayList<>(List.of(this)); } public void addAlternateSourceIntent(Intent alt) { @@ -213,6 +196,7 @@ public class DisplayResolveInfo implements TargetInfo { } @Override + @NonNull public ComponentName getResolvedComponentName() { return new ComponentName(mResolveInfo.activityInfo.packageName, mResolveInfo.activityInfo.name); diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java new file mode 100644 index 00000000..3462b726 --- /dev/null +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfoAzInfoComparator.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.chooser; + + +import android.content.Context; + +import java.text.Collator; +import java.util.Comparator; + +/** + * Sort intents alphabetically based on display label. + */ +public class DisplayResolveInfoAzInfoComparator implements Comparator<DisplayResolveInfo> { + Comparator<DisplayResolveInfo> mComparator; + public DisplayResolveInfoAzInfoComparator(Context context) { + Collator collator = Collator + .getInstance(context.getResources().getConfiguration().locale); + // Adding two stage comparator, first stage compares using displayLabel, next stage + // compares using resolveInfo.userHandle + mComparator = Comparator.comparing(DisplayResolveInfo::getDisplayLabel, collator) + .thenComparingInt(target -> target.getResolveInfo().userHandle.getIdentifier()); + } + + @Override + public int compare( + DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) { + return mComparator.compare(lhsp, rhsp); + } +} diff --git a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java index 10d4415a..50aaec0b 100644 --- a/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/ImmutableTargetInfo.java @@ -16,8 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.Activity; import android.app.prediction.AppTarget; import android.content.ComponentName; @@ -27,8 +25,11 @@ import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; import android.os.Bundle; import android.os.UserHandle; +import android.service.chooser.ChooserTarget; import android.util.HashedStringCache; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -43,7 +44,7 @@ import java.util.List; public final class ImmutableTargetInfo implements TargetInfo { private static final String TAG = "TargetInfo"; - /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics()}. */ + /** Delegate interface to implement {@link TargetInfo#getHashedTargetIdForMetrics}. */ public interface TargetHashProvider { /** Request a hash for the specified {@code target}. */ HashedStringCache.HashResult getHashedTargetIdForMetrics( @@ -53,15 +54,15 @@ public final class ImmutableTargetInfo implements TargetInfo { /** Delegate interface to request that the target be launched by a particular API. */ public interface TargetActivityStarter { /** - * Request that the delegate use the {@link Activity#startAsCaller()} API to launch the - * specified {@code target}. + * Request that the delegate use the {@link Activity#startActivityAsCaller} API to launch + * the specified {@code target}. * * @return true if the target was launched successfully. */ boolean startAsCaller(TargetInfo target, Activity activity, Bundle options, int userId); /** - * Request that the delegate use the {@link Activity#startAsUser()} API to launch the + * Request that the delegate use the {@link Activity#startActivityAsUser} API to launch the * specified {@code target}. * * @return true if the target was launched successfully. @@ -145,7 +146,7 @@ public final class ImmutableTargetInfo implements TargetInfo { /** * Configure an {@link Intent} to be built in to the output target as the "base intent to * send," which may be a refinement of any of our source targets. This is private because - * it's only used internally by {@link #tryToCloneWithAppliedRefinement()}; if it's ever + * it's only used internally by {@link #tryToCloneWithAppliedRefinement}; if it's ever * expanded, the builder should probably be responsible for enforcing the refinement check. */ private Builder setBaseIntentToSend(Intent baseIntent) { @@ -229,8 +230,8 @@ public final class ImmutableTargetInfo implements TargetInfo { /** * Configure the full list of source intents we could resolve for this target. This is - * effectively the same as calling {@link #setResolvedIntent()} with the first element of - * the list, and {@link #setAlternateSourceIntents()} with the remainder (or clearing those + * effectively the same as calling {@link #setResolvedIntent} with the first element of + * the list, and {@link #setAlternateSourceIntents} with the remainder (or clearing those * fields on the builder if there are no corresponding elements in the list). */ public Builder setAllSourceIntents(List<Intent> sourceIntents) { diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java index b97e6b45..95cb443e 100644 --- a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -17,10 +17,13 @@ package com.android.intentresolver.chooser; import android.app.Activity; +import android.content.ComponentName; import android.content.Intent; import android.os.Bundle; import android.os.UserHandle; +import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; @@ -121,6 +124,20 @@ public class MultiDisplayResolveInfo extends DisplayResolveInfo { } @Override + @NonNull + public ComponentName getResolvedComponentName() { + if (hasSelected()) { + return mTargetInfos.get(mSelected).getResolvedComponentName(); + } + // It is not expected to have this method be called on an unselected multi-display item. + // Call super to preserve the legacy (most likely erroneous) behavior. + Log.wtf( + "ChooserActivity", + "retrieving ResolvedComponentName from an unselected MultiDisplayResolveInfo"); + return super.getResolvedComponentName(); + } + + @Override public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { return mTargetInfos.get(mSelected).startAsUser(activity, options, user); } diff --git a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java index 6444e13b..46803a04 100644 --- a/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -16,7 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.Nullable; import android.app.Activity; import android.content.Context; import android.graphics.drawable.AnimatedVectorDrawable; @@ -24,6 +23,8 @@ import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; +import androidx.annotation.Nullable; + import com.android.intentresolver.R; import java.util.function.Supplier; diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java index 5766db0e..c4aa9021 100644 --- a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -16,7 +16,6 @@ package com.android.intentresolver.chooser; -import android.annotation.Nullable; import android.app.Activity; import android.app.prediction.AppTarget; import android.content.ComponentName; @@ -33,6 +32,8 @@ import android.text.SpannableStringBuilder; import android.util.HashedStringCache; import android.util.Log; +import androidx.annotation.Nullable; + import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import java.util.ArrayList; diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java index 9d793994..ba6c3c05 100644 --- a/java/src/com/android/intentresolver/chooser/TargetInfo.java +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -17,14 +17,15 @@ package com.android.intentresolver.chooser; -import android.annotation.Nullable; import android.app.Activity; import android.app.prediction.AppTarget; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.ResolveInfo; import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.UserHandle; @@ -32,6 +33,12 @@ import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.HashedStringCache; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserRefinementManager; +import com.android.intentresolver.ResolverActivity; + import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -187,9 +194,9 @@ public interface TargetInfo { * Attempt to apply a {@code proposedRefinement} that the {@link ChooserRefinementManager} * received from the caller's refinement flow. This may succeed only if the target has a source * intent that matches the filtering parameters of the proposed refinement (according to - * {@link Intent#filterEquals()}). Then the first such match is the "base intent," and the - * proposed refinement is merged into that base (via {@link Intent#fillIn()}; this can never - * result in a change to the {@link Intent#filterEquals()} status of the base, but may e.g. add + * {@link Intent#filterEquals}). Then the first such match is the "base intent," and the + * proposed refinement is merged into that base (via {@link Intent#fillIn}; this can never + * result in a change to the {@link Intent#filterEquals} status of the base, but may e.g. add * new "extras" that weren't previously given in the base intent). * * @return a copy of this {@link TargetInfo} where the "base intent to send" is the result of @@ -280,7 +287,7 @@ public interface TargetInfo { } /** - * @return the {@link ShortcutManager} data for any shortcut associated with this target. + * @return the {@link ShortcutInfo} for any shortcut associated with this target. */ @Nullable default ShortcutInfo getDirectShareShortcutInfo() { @@ -422,7 +429,7 @@ public interface TargetInfo { /** * @return true if this target should be logged with the "direct_share" metrics category in - * {@link ResolverActivity#maybeLogCrossProfileTargetLaunch()}. This is defined for legacy + * {@link ResolverActivity#maybeLogCrossProfileTargetLaunch}. This is defined for legacy * compatibility and is <em>not</em> likely to be a good indicator of whether this is actually a * "direct share" target (e.g. because it historically also applies to "empty" and "placeholder" * targets). diff --git a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt index 103e8bf4..dc36e584 100644 --- a/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/BasePreviewViewModel.kt @@ -16,16 +16,20 @@ package com.android.intentresolver.contentpreview +import android.content.Intent +import android.net.Uri import androidx.annotation.MainThread import androidx.lifecycle.ViewModel -import com.android.intentresolver.ChooserRequestParameters /** A contract for the preview view model. Added for testing. */ abstract class BasePreviewViewModel : ViewModel() { - @MainThread - abstract fun createOrReuseProvider( - chooserRequest: ChooserRequestParameters - ): PreviewDataProvider + @get:MainThread abstract val previewDataProvider: PreviewDataProvider + @get:MainThread abstract val imageLoader: ImageLoader - @MainThread abstract fun createOrReuseImageLoader(): 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 new file mode 100644 index 00000000..ce064cdf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import androidx.core.util.lruCache +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ViewModelOwned +import java.util.function.Consumer +import javax.inject.Inject +import javax.inject.Qualifier +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class PreviewMaxConcurrency + +/** + * Implementation of [ImageLoader]. + * + * Allows for cached or uncached loading of images and limits the number of concurrent requests. + * Requests are automatically cancelled when they are evicted from the cache. If image loading fails + * or the request is cancelled (e.g. by eviction), the returned [Bitmap] will be null. + */ +class CachingImagePreviewImageLoader +@Inject +constructor( + @ViewModelOwned private val scope: CoroutineScope, + @Background private val bgDispatcher: CoroutineDispatcher, + private val thumbnailLoader: ThumbnailLoader, + @PreviewCacheSize cacheSize: Int, + @PreviewMaxConcurrency maxConcurrency: Int, +) : ImageLoader { + + private val semaphore = Semaphore(maxConcurrency) + + private val cache = + lruCache( + maxSize = cacheSize, + create = { uri: Uri -> scope.async { loadUncachedImage(uri) } }, + onEntryRemoved = { evicted: Boolean, _, oldValue: Deferred<Bitmap?>, _ -> + // If removed due to eviction, cancel the coroutine, otherwise it is the + // responsibility + // of the caller of [cache.remove] to cancel the removed entry when done with it. + if (evicted) { + oldValue.cancel() + } + } + ) + + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) { + callerScope.launch { callback.accept(loadCachedImage(uri)) } + } + + override fun prePopulate(uris: List<Uri>) { + uris.take(cache.maxSize()).map { cache[it] } + } + + override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? { + return if (caching) { + loadCachedImage(uri) + } else { + loadUncachedImage(uri) + } + } + + private suspend fun loadUncachedImage(uri: Uri): Bitmap? = + withContext(bgDispatcher) { + runCatching { semaphore.withPermit { thumbnailLoader.invoke(uri) } } + .onFailure { + ensureActive() + Log.d(TAG, "Failed to load preview for $uri", it) + } + .getOrNull() + } + + private suspend fun loadCachedImage(uri: Uri): Bitmap? = + // [Deferred#await] is called in a [runCatching] block to catch + // [CancellationExceptions]s so that they don't cancel the calling coroutine/scope. + runCatching { cache[uri].await() }.getOrNull() + + companion object { + private const val TAG = "CachingImgPrevLoader" + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java index e8367c4e..4b955c49 100644 --- a/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUi.java @@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE; +import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION; import static com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT; import android.content.ClipData; @@ -26,17 +27,21 @@ import android.content.res.Resources; import android.net.Uri; import android.text.TextUtils; import android.view.LayoutInflater; +import android.view.View; import android.view.ViewGroup; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import androidx.lifecycle.Lifecycle; +import com.android.intentresolver.ContentTypeHint; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; +import kotlinx.coroutines.CoroutineScope; + import java.util.List; import java.util.function.Consumer; +import java.util.function.Supplier; /** * Collection of helpers for building the content preview UI displayed in @@ -45,7 +50,8 @@ import java.util.function.Consumer; */ public final class ChooserContentPreviewUi { - private final Lifecycle mLifecycle; + private final CoroutineScope mScope; + private final boolean mIsPayloadTogglingEnabled; /** * Delegate to build the default system action buttons to display in the preview layout, if/when @@ -72,7 +78,9 @@ public final class ChooserContentPreviewUi { * Provides a share modification action, if any. */ @Nullable - ActionRow.Action getModifyShareAction(); + default ActionRow.Action getModifyShareAction() { + return null; + } /** * <p> @@ -88,16 +96,25 @@ public final class ChooserContentPreviewUi { @VisibleForTesting final ContentPreviewUi mContentPreviewUi; + private final Supplier</*@Nullable*/ActionRow.Action> mModifyShareActionFactory; + private View mHeadlineParent; public ChooserContentPreviewUi( - Lifecycle lifecycle, + CoroutineScope scope, PreviewDataProvider previewData, Intent targetIntent, ImageLoader imageLoader, ActionFactory actionFactory, + Supplier</*@Nullable*/ActionRow.Action> modifyShareActionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - HeadlineGenerator headlineGenerator) { - mLifecycle = lifecycle; + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint, + @Nullable CharSequence metadata, + // TODO: replace with the FeatureFlag ref when v1 is gone + boolean isPayloadTogglingEnabled) { + mScope = scope; + mIsPayloadTogglingEnabled = isPayloadTogglingEnabled; + mModifyShareActionFactory = modifyShareActionFactory; mContentPreviewUi = createContentPreview( previewData, targetIntent, @@ -105,7 +122,10 @@ public final class ChooserContentPreviewUi { imageLoader, actionFactory, transitionElementStatusCallback, - headlineGenerator); + headlineGenerator, + contentTypeHint, + metadata + ); if (mContentPreviewUi.getType() != CONTENT_PREVIEW_IMAGE) { transitionElementStatusCallback.onAllTransitionElementsReady(); } @@ -118,58 +138,79 @@ public final class ChooserContentPreviewUi { ImageLoader imageLoader, ActionFactory actionFactory, TransitionElementStatusCallback transitionElementStatusCallback, - HeadlineGenerator headlineGenerator) { - + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint, + @Nullable CharSequence metadata + ) { int previewType = previewData.getPreviewType(); if (previewType == CONTENT_PREVIEW_TEXT) { return createTextPreview( - mLifecycle, + mScope, targetIntent, actionFactory, imageLoader, - headlineGenerator); + headlineGenerator, + contentTypeHint, + metadata + ); } if (previewType == CONTENT_PREVIEW_FILE) { FileContentPreviewUi fileContentPreviewUi = new FileContentPreviewUi( previewData.getUriCount(), actionFactory, - headlineGenerator); + headlineGenerator, + metadata + ); if (previewData.getUriCount() > 0) { - previewData.getFirstFileName( - mLifecycle, fileContentPreviewUi::setFirstFileName); + previewData.getFirstFileName(mScope, fileContentPreviewUi::setFirstFileName); } return fileContentPreviewUi; } + + if (previewType == CONTENT_PREVIEW_PAYLOAD_SELECTION && mIsPayloadTogglingEnabled) { + transitionElementStatusCallback.onAllTransitionElementsReady(); // TODO + return new ShareouselContentPreviewUi(); + } + boolean isSingleImageShare = previewData.getUriCount() == 1 - && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType()); + && typeClassifier.isImageType(previewData.getFirstFileInfo().getMimeType()); CharSequence text = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); if (!TextUtils.isEmpty(text)) { FilesPlusTextContentPreviewUi previewUi = new FilesPlusTextContentPreviewUi( - mLifecycle, + mScope, isSingleImageShare, previewData.getUriCount(), targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT), + targetIntent.getType(), actionFactory, imageLoader, typeClassifier, - headlineGenerator); + headlineGenerator, + metadata + ); if (previewData.getUriCount() > 0) { - previewData.getFileMetadataForImagePreview( - mLifecycle, previewUi::updatePreviewMetadata); + JavaFlowHelper.collectToList( + mScope, + previewData.getImagePreviewFileInfoFlow(), + previewUi::updatePreviewMetadata); } return previewUi; } - UnifiedContentPreviewUi unifiedContentPreviewUi = new UnifiedContentPreviewUi( + return new UnifiedContentPreviewUi( + mScope, isSingleImageShare, + targetIntent.getType(), actionFactory, imageLoader, typeClassifier, transitionElementStatusCallback, - headlineGenerator); - previewData.getFileMetadataForImagePreview(mLifecycle, unifiedContentPreviewUi::setFiles); - return unifiedContentPreviewUi; + previewData.getImagePreviewFileInfoFlow(), + previewData.getUriCount(), + headlineGenerator, + metadata + ); } public int getPreferredContentPreview() { @@ -181,19 +222,36 @@ public final class ChooserContentPreviewUi { * specified {@code intent}. */ public ViewGroup displayContentPreview( - Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + View headlineViewParent) { - return mContentPreviewUi.display(resources, layoutInflater, parent); + ViewGroup layout = + mContentPreviewUi.display(resources, layoutInflater, parent, headlineViewParent); + mHeadlineParent = headlineViewParent; + ContentPreviewUi.displayModifyShareAction(mHeadlineParent, mModifyShareActionFactory.get()); + return layout; + } + + /** + * Update Modify Share Action, if it is inflated. + */ + public void updateModifyShareAction() { + ContentPreviewUi.displayModifyShareAction(mHeadlineParent, mModifyShareActionFactory.get()); } private static TextContentPreviewUi createTextPreview( - Lifecycle lifecycle, + CoroutineScope scope, Intent targetIntent, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint, + @Nullable CharSequence metadata + ) { CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); - String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); + CharSequence previewTitle = targetIntent.getCharSequenceExtra(Intent.EXTRA_TITLE); ClipData previewData = targetIntent.getClipData(); Uri previewThumbnail = null; if (previewData != null) { @@ -202,13 +260,16 @@ public final class ChooserContentPreviewUi { previewThumbnail = previewDataItem.getUri(); } } + return new TextContentPreviewUi( - lifecycle, + scope, sharingText, previewTitle, + metadata, previewThumbnail, actionFactory, imageLoader, - headlineGenerator); + headlineGenerator, + contentTypeHint); } } diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java index ebab147d..79bb9d3c 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewType.java @@ -18,18 +18,20 @@ package com.android.intentresolver.contentpreview; import static java.lang.annotation.RetentionPolicy.SOURCE; -import android.annotation.IntDef; +import androidx.annotation.IntDef; import java.lang.annotation.Retention; @Retention(SOURCE) @IntDef({ContentPreviewType.CONTENT_PREVIEW_FILE, ContentPreviewType.CONTENT_PREVIEW_IMAGE, - ContentPreviewType.CONTENT_PREVIEW_TEXT}) + ContentPreviewType.CONTENT_PREVIEW_TEXT, + ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION}) public @interface ContentPreviewType { // Starting at 1 since 0 is considered "undefined" for some of the database transformations // of tron logs. int CONTENT_PREVIEW_IMAGE = 1; int CONTENT_PREVIEW_FILE = 2; int CONTENT_PREVIEW_TEXT = 3; + int CONTENT_PREVIEW_PAYLOAD_SELECTION = 4; } diff --git a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java index 07071236..8eaf3568 100644 --- a/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/ContentPreviewUi.java @@ -24,15 +24,20 @@ import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.ViewStub; import android.view.animation.DecelerateInterpolator; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ScrollableImagePreviewView; -abstract class ContentPreviewUi { +@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +public abstract class ContentPreviewUi { private static final int IMAGE_FADE_IN_MILLIS = 150; static final String TAG = "ChooserPreview"; @@ -40,7 +45,10 @@ abstract class ContentPreviewUi { public abstract int getType(); public abstract ViewGroup display( - Resources resources, LayoutInflater layoutInflater, ViewGroup parent); + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + View headlineViewParent); protected static void updateViewWithImage(ImageView imageView, Bitmap image) { if (image == null) { @@ -57,35 +65,56 @@ abstract class ContentPreviewUi { fadeAnim.start(); } - protected static void displayHeadline(ViewGroup layout, String headline) { - if (layout != null) { - TextView titleView = layout.findViewById(R.id.headline); - if (titleView != null) { - if (!TextUtils.isEmpty(headline)) { - titleView.setText(headline); - titleView.setVisibility(View.VISIBLE); - } else { - titleView.setVisibility(View.GONE); - } - } + protected static void inflateHeadline(View layout) { + ViewStub stub = layout.findViewById(R.id.chooser_headline_row_stub); + if (stub != null) { + stub.inflate(); } } - protected static void displayModifyShareAction( - ViewGroup layout, - ChooserContentPreviewUi.ActionFactory actionFactory) { - ActionRow.Action modifyShareAction = actionFactory.getModifyShareAction(); - if (modifyShareAction != null && layout != null) { - TextView modifyShareView = layout.findViewById(R.id.reselection_action); - if (modifyShareView != null) { - modifyShareView.setText(modifyShareAction.getLabel()); - modifyShareView.setVisibility(View.VISIBLE); - modifyShareView.setOnClickListener(view -> modifyShareAction.getOnClicked().run()); - } + protected static void displayHeadline(View layout, String headline) { + TextView titleView = layout == null ? null : layout.findViewById(R.id.headline); + if (titleView == null) { + return; + } + if (!TextUtils.isEmpty(headline)) { + titleView.setText(headline); + titleView.setVisibility(View.VISIBLE); + } else { + titleView.setVisibility(View.GONE); + } + } + + protected static void displayMetadata(View layout, @Nullable CharSequence metadata) { + TextView metadataView = layout == null ? null : layout.findViewById(R.id.metadata); + if (metadataView == null) { + return; + } + if (!TextUtils.isEmpty(metadata)) { + metadataView.setText(metadata); + metadataView.setVisibility(View.VISIBLE); + } else { + metadataView.setVisibility(View.GONE); + } + } + + static void displayModifyShareAction( + View layout, @Nullable ActionRow.Action modifyShareAction) { + TextView modifyShareView = + layout == null ? null : layout.findViewById(R.id.reselection_action); + if (modifyShareView == null) { + return; + } + if (modifyShareAction != null) { + modifyShareView.setText(modifyShareAction.getLabel()); + modifyShareView.setVisibility(View.VISIBLE); + modifyShareView.setOnClickListener(view -> modifyShareAction.getOnClicked().run()); + } else { + modifyShareView.setVisibility(View.GONE); } } - protected static ScrollableImagePreviewView.PreviewType getPreviewType( + static ScrollableImagePreviewView.PreviewType getPreviewType( MimeTypeClassifier typeClassifier, String mimeType) { if (mimeType == null) { return ScrollableImagePreviewView.PreviewType.File; diff --git a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java index 20758189..1749c6f7 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FileContentPreviewUi.java @@ -43,15 +43,20 @@ class FileContentPreviewUi extends ContentPreviewUi { private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final HeadlineGenerator mHeadlineGenerator; @Nullable + private final CharSequence mMetadata; + @Nullable private ViewGroup mContentPreview = null; FileContentPreviewUi( int fileCount, ChooserContentPreviewUi.ActionFactory actionFactory, - HeadlineGenerator headlineGenerator) { + HeadlineGenerator headlineGenerator, + @Nullable CharSequence metadata + ) { mFileCount = fileCount; mActionFactory = actionFactory; mHeadlineGenerator = headlineGenerator; + mMetadata = metadata; } @Override @@ -67,18 +72,25 @@ class FileContentPreviewUi extends ContentPreviewUi { } @Override - public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup layout = displayInternal(resources, layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory); - return layout; + public ViewGroup display( + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + View headlineViewParent) { + return displayInternal(resources, layoutInflater, parent, headlineViewParent); } private ViewGroup displayInternal( - Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + View headlineViewParent) { mContentPreview = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_file, parent, false); + inflateHeadline(headlineViewParent); - displayHeadline(mContentPreview, mHeadlineGenerator.getFilesHeadline(mFileCount)); + displayHeadline(headlineViewParent, mHeadlineGenerator.getFilesHeadline(mFileCount)); + displayMetadata(headlineViewParent, mMetadata); if (mFileCount == 0) { mContentPreview.setVisibility(View.GONE); diff --git a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt index fe35365b..16a948df 100644 --- a/java/src/com/android/intentresolver/contentpreview/FileInfo.kt +++ b/java/src/com/android/intentresolver/contentpreview/FileInfo.kt @@ -22,8 +22,11 @@ class FileInfo private constructor(val uri: Uri, val previewUri: Uri?, val mimeT @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class Builder(val uri: Uri) { var previewUri: Uri? = null + @Synchronized get private set + var mimeType: String? = null + @Synchronized get private set @Synchronized fun withPreviewUri(uri: Uri?): Builder = apply { previewUri = uri } diff --git a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java index 35990990..b50f5bc8 100644 --- a/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUi.java @@ -31,12 +31,13 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ScrollableImagePreviewView; +import kotlinx.coroutines.CoroutineScope; + import java.util.HashMap; import java.util.List; import java.util.function.Consumer; @@ -48,15 +49,20 @@ import java.util.function.Consumer; * file content). */ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { - private final Lifecycle mLifecycle; + private final CoroutineScope mScope; + @Nullable + private final String mIntentMimeType; private final CharSequence mText; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; private final MimeTypeClassifier mTypeClassifier; private final HeadlineGenerator mHeadlineGenerator; + @Nullable + private final CharSequence mMetadata; private final boolean mIsSingleImage; private final int mFileCount; private ViewGroup mContentPreviewView; + private View mHeadliveView; private boolean mIsMetadataUpdated = false; @Nullable private Uri mFirstFilePreviewUri; @@ -66,19 +72,22 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { private static final boolean SHOW_TOGGLE_CHECKMARK = false; FilesPlusTextContentPreviewUi( - Lifecycle lifecycle, + CoroutineScope scope, boolean isSingleImage, int fileCount, CharSequence text, + @Nullable String intentMimeType, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, MimeTypeClassifier typeClassifier, - HeadlineGenerator headlineGenerator) { - mLifecycle = lifecycle; + HeadlineGenerator headlineGenerator, + @Nullable CharSequence metadata) { if (isSingleImage && fileCount != 1) { throw new IllegalArgumentException( "fileCount = " + fileCount + " and isSingleImage = true"); } + mScope = scope; + mIntentMimeType = intentMimeType; mFileCount = fileCount; mIsSingleImage = isSingleImage; mText = text; @@ -86,6 +95,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { mImageLoader = imageLoader; mTypeClassifier = typeClassifier; mHeadlineGenerator = headlineGenerator; + mMetadata = metadata; } @Override @@ -94,10 +104,12 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { } @Override - public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory); - return layout; + public ViewGroup display( + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + View headlineViewParent) { + return displayInternal(layoutInflater, parent, headlineViewParent); } public void updatePreviewMetadata(List<FileInfo> files) { @@ -114,36 +126,49 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { mFirstFilePreviewUri = files.isEmpty() ? null : files.get(0).getPreviewUri(); mIsMetadataUpdated = true; if (mContentPreviewView != null) { - updateUiWithMetadata(mContentPreviewView); + updateUiWithMetadata(mContentPreviewView, mHeadliveView); } } - private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { + private ViewGroup displayInternal( + LayoutInflater layoutInflater, + ViewGroup parent, + View headlineViewParent) { mContentPreviewView = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_files_text, parent, false); + mHeadliveView = headlineViewParent; + inflateHeadline(mHeadliveView); final ActionRow actionRow = mContentPreviewView.findViewById(com.android.internal.R.id.chooser_action_row); List<ActionRow.Action> actions = mActionFactory.createCustomActions(); actionRow.setActions(actions); - if (mIsMetadataUpdated) { - updateUiWithMetadata(mContentPreviewView); - } else if (!mIsSingleImage) { + if (!mIsSingleImage) { mContentPreviewView.requireViewById(R.id.image_view).setVisibility(View.GONE); } + prepareTextPreview(mContentPreviewView, mHeadliveView, mActionFactory); + if (mIsMetadataUpdated) { + updateUiWithMetadata(mContentPreviewView, mHeadliveView); + } else { + updateHeadline( + mHeadliveView, + mFileCount, + mTypeClassifier.isImageType(mIntentMimeType), + mTypeClassifier.isVideoType(mIntentMimeType)); + } return mContentPreviewView; } - private void updateUiWithMetadata(ViewGroup contentPreviewView) { - prepareTextPreview(contentPreviewView, mActionFactory); - updateHeadline(contentPreviewView); + 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( - mLifecycle, + mScope, mFirstFilePreviewUri, bitmap -> { if (bitmap == null) { @@ -157,35 +182,38 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { } } - private void updateHeadline(ViewGroup contentPreview) { - CheckBox includeText = contentPreview.requireViewById(R.id.include_text_action); + private void updateHeadline( + View headlineView, int fileCount, boolean allImages, boolean allVideos) { + CheckBox includeText = headlineView.requireViewById(R.id.include_text_action); String headline; if (includeText.getVisibility() == View.VISIBLE && includeText.isChecked()) { - if (mAllImages) { - headline = mHeadlineGenerator.getImagesWithTextHeadline(mText, mFileCount); - } else if (mAllVideos) { - headline = mHeadlineGenerator.getVideosWithTextHeadline(mText, mFileCount); + if (allImages) { + headline = mHeadlineGenerator.getImagesWithTextHeadline(mText, fileCount); + } else if (allVideos) { + headline = mHeadlineGenerator.getVideosWithTextHeadline(mText, fileCount); } else { - headline = mHeadlineGenerator.getFilesWithTextHeadline(mText, mFileCount); + headline = mHeadlineGenerator.getFilesWithTextHeadline(mText, fileCount); } } else { - if (mAllImages) { - headline = mHeadlineGenerator.getImagesHeadline(mFileCount); - } else if (mAllVideos) { - headline = mHeadlineGenerator.getVideosHeadline(mFileCount); + if (allImages) { + headline = mHeadlineGenerator.getImagesHeadline(fileCount); + } else if (allVideos) { + headline = mHeadlineGenerator.getVideosHeadline(fileCount); } else { - headline = mHeadlineGenerator.getFilesHeadline(mFileCount); + headline = mHeadlineGenerator.getFilesHeadline(fileCount); } } - displayHeadline(contentPreview, headline); + displayHeadline(headlineView, headline); + displayMetadata(headlineView, mMetadata); } private void prepareTextPreview( ViewGroup contentPreview, + View headlineView, ChooserContentPreviewUi.ActionFactory actionFactory) { final TextView textView = contentPreview.requireViewById(R.id.content_preview_text); - CheckBox includeText = contentPreview.requireViewById(R.id.include_text_action); + CheckBox includeText = headlineView.requireViewById(R.id.include_text_action); boolean isLink = HttpUriMatcher.isHttpUri(mText.toString()); textView.setAutoLinkMask(isLink ? Linkify.WEB_URLS : 0); textView.setText(mText); @@ -201,7 +229,7 @@ class FilesPlusTextContentPreviewUi extends ContentPreviewUi { textView.setText(getNoTextString(contentPreview.getResources())); } shareTextAction.accept(!isChecked); - updateHeadline(contentPreview); + updateHeadline(headlineView, mFileCount, mAllImages, mAllVideos); }); if (SHOW_TOGGLE_CHECKMARK) { includeText.setVisibility(View.VISIBLE); diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt index 5f87c924..21308341 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGenerator.kt @@ -17,12 +17,14 @@ package com.android.intentresolver.contentpreview /** - * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief - * description of the content being shared. + * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief description + * of the content being shared. */ interface HeadlineGenerator { fun getTextHeadline(text: CharSequence): String + fun getAlbumHeadline(): String + fun getImagesWithTextHeadline(text: CharSequence, count: Int): String fun getVideosWithTextHeadline(text: CharSequence, count: Int): String diff --git a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt index 1aace8c3..e92d9bc6 100644 --- a/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt +++ b/java/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImpl.kt @@ -16,36 +16,69 @@ package com.android.intentresolver.contentpreview -import android.annotation.StringRes import android.content.Context -import com.android.intentresolver.R import android.util.PluralsMessageFormatter +import androidx.annotation.StringRes +import com.android.intentresolver.R +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject private const val PLURALS_COUNT = "count" /** - * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief - * description of the content being shared. + * HeadlineGenerator generates the text to show at the top of the sharesheet as a brief description + * of the content being shared. */ -class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { +class HeadlineGeneratorImpl +@Inject +constructor( + @ApplicationContext private val context: Context, +) : HeadlineGenerator { override fun getTextHeadline(text: CharSequence): String { return context.getString( - getTemplateResource(text, R.string.sharing_link, R.string.sharing_text)) + getTemplateResource(text, R.string.sharing_link, R.string.sharing_text) + ) + } + + override fun getAlbumHeadline(): String { + return context.getString(R.string.sharing_album) } override fun getImagesWithTextHeadline(text: CharSequence, count: Int): String { - return getPluralString(getTemplateResource( - text, R.string.sharing_images_with_link, R.string.sharing_images_with_text), count) + return getPluralString( + getTemplateResource( + text, + R.string.sharing_images_with_link, + R.string.sharing_images_with_text + ), + count + ) } override fun getVideosWithTextHeadline(text: CharSequence, count: Int): String { - return getPluralString(getTemplateResource( - text, R.string.sharing_videos_with_link, R.string.sharing_videos_with_text), count) + return getPluralString( + getTemplateResource( + text, + R.string.sharing_videos_with_link, + R.string.sharing_videos_with_text + ), + count + ) } override fun getFilesWithTextHeadline(text: CharSequence, count: Int): String { - return getPluralString(getTemplateResource( - text, R.string.sharing_files_with_link, R.string.sharing_files_with_text), count) + return getPluralString( + getTemplateResource( + text, + R.string.sharing_files_with_link, + R.string.sharing_files_with_text + ), + count + ) } override fun getImagesHeadline(count: Int): String { @@ -70,8 +103,16 @@ class HeadlineGeneratorImpl(private val context: Context) : HeadlineGenerator { @StringRes private fun getTemplateResource( - text: CharSequence, @StringRes linkResource: Int, @StringRes nonLinkResource: Int + text: CharSequence, + @StringRes linkResource: Int, + @StringRes nonLinkResource: Int ): Int { return if (text.toString().isHttpUri()) linkResource else nonLinkResource } } + +@Module +@InstallIn(SingletonComponent::class) +interface HeadlineGeneratorModule { + @Binds fun bind(impl: HeadlineGeneratorImpl): HeadlineGenerator +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt index 8d0fb84b..629651a3 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt @@ -18,8 +18,8 @@ package com.android.intentresolver.contentpreview import android.graphics.Bitmap import android.net.Uri -import androidx.lifecycle.Lifecycle import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope /** A content preview image loader. */ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitmap? { @@ -30,7 +30,7 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm * @param callback a callback that will be invoked with the loaded image or null if loading has * failed. */ - fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer<Bitmap?>) + fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) /** Prepopulate the image loader cache. */ fun prePopulate(uris: List<Uri>) diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt new file mode 100644 index 00000000..7035f765 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoaderModule.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.res.Resources +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@Module +@InstallIn(ActivityRetainedComponent::class) +interface ImageLoaderModule { + @Binds + @ActivityRetainedScoped + fun imageLoader(previewImageLoader: ImagePreviewImageLoader): ImageLoader + + @Binds + @ActivityRetainedScoped + fun thumbnailLoader(thumbnailLoader: ThumbnailLoaderImpl): ThumbnailLoader + + companion object { + @Provides + @ThumbnailSize + fun thumbnailSize(@ApplicationOwned resources: Resources): Int = + resources.getDimensionPixelSize(R.dimen.chooser_preview_image_max_dimen) + + @Provides @PreviewCacheSize fun cacheSize() = 16 + + @Provides @PreviewMaxConcurrency fun maxConcurrency() = 4 + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt index 22dd1125..fab7203e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoader.kt @@ -24,19 +24,31 @@ import android.util.Size import androidx.annotation.GuardedBy import androidx.annotation.VisibleForTesting import androidx.collection.LruCache -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope +import com.android.intentresolver.inject.Background import java.util.function.Consumer +import javax.inject.Inject +import javax.inject.Qualifier import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore private const val TAG = "ImagePreviewImageLoader" +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class ThumbnailSize + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class PreviewCacheSize + /** * Implements preview image loading for the content preview UI. Provides requests deduplication, * image caching, and a limit on the number of parallel loadings. @@ -54,6 +66,26 @@ constructor( private val contentResolverSemaphore: Semaphore, ) : ImageLoader { + @Inject + constructor( + @Background dispatcher: CoroutineDispatcher, + @ThumbnailSize thumbnailSize: Int, + contentResolver: ContentResolver, + @PreviewCacheSize cacheSize: Int, + ) : this( + CoroutineScope( + SupervisorJob() + + dispatcher + + CoroutineExceptionHandler { _, exception -> + Log.w(TAG, "Uncaught exception in ImageLoader", exception) + } + + CoroutineName("ImageLoader") + ), + thumbnailSize, + contentResolver, + cacheSize, + ) + constructor( scope: CoroutineScope, thumbnailSize: Int, @@ -70,8 +102,8 @@ constructor( override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = loadImageAsync(uri, caching) - override fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer<Bitmap?>) { - callerLifecycle.coroutineScope.launch { + override fun loadImage(callerScope: CoroutineScope, uri: Uri, callback: Consumer<Bitmap?>) { + callerScope.launch { val image = loadImageAsync(uri, caching = true) if (isActive) { callback.accept(image) diff --git a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt index 80232537..ac002ab6 100644 --- a/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt +++ b/java/src/com/android/intentresolver/contentpreview/IsHttpUri.kt @@ -15,13 +15,16 @@ */ @file:JvmName("HttpUriMatcher") + package com.android.intentresolver.contentpreview import java.net.URI internal fun String.isHttpUri() = - kotlin.runCatching { - URI(this).scheme.takeIf { scheme -> - "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0 + kotlin + .runCatching { + URI(this).scheme.takeIf { scheme -> + "http".compareTo(scheme, true) == 0 || "https".compareTo(scheme, true) == 0 + } } - }.getOrNull() != null + .getOrNull() != null diff --git a/java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt b/java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt new file mode 100644 index 00000000..b29c5774 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/JavaFlowHelper.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:JvmName("JavaFlowHelper") + +package com.android.intentresolver.contentpreview + +import com.android.intentresolver.widget.ScrollableImagePreviewView.Preview +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch + +internal fun mapFileIntoToPreview( + flow: Flow<FileInfo>, + typeClassifier: MimeTypeClassifier, + editAction: Runnable? +): Flow<Preview> = + flow + .filter { it.previewUri != null } + .map { fileInfo -> + Preview( + ContentPreviewUi.getPreviewType(typeClassifier, fileInfo.mimeType), + requireNotNull(fileInfo.previewUri), + editAction + ) + } + +internal fun <T> collectToList( + clientScope: CoroutineScope, + flow: Flow<T>, + callback: Consumer<List<T>> +) { + clientScope.launch { callback.accept(flow.toList()) } +} diff --git a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt index 90016932..924e6499 100644 --- a/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt +++ b/java/src/com/android/intentresolver/contentpreview/NoContextPreviewUi.kt @@ -19,13 +19,17 @@ package com.android.intentresolver.contentpreview import android.content.res.Resources import android.util.Log import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup internal class NoContextPreviewUi(private val type: Int) : ContentPreviewUi() { override fun getType(): Int = type override fun display( - resources: Resources?, layoutInflater: LayoutInflater?, parent: ViewGroup? + resources: Resources?, + layoutInflater: LayoutInflater?, + parent: ViewGroup?, + headlineViewParent: View, ): ViewGroup? { Log.e(TAG, "Unexpected content preview type: $type") return null diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt index 8ab3a272..96bb8258 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewDataProvider.kt @@ -18,7 +18,6 @@ package com.android.intentresolver.contentpreview import android.content.ContentInterface import android.content.Intent -import android.database.Cursor import android.media.MediaMetadata import android.net.Uri import android.provider.DocumentsContract @@ -29,23 +28,26 @@ import android.text.TextUtils import android.util.Log import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE +import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_PAYLOAD_SELECTION import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT import com.android.intentresolver.measurements.runTracing import com.android.intentresolver.util.ownedByCurrentUser import java.util.concurrent.atomic.AtomicInteger import java.util.function.Consumer +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.take import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull /** @@ -68,30 +70,51 @@ private const val TIMEOUT_MS = 1_000L */ @OpenForTesting open class PreviewDataProvider -@VisibleForTesting +@JvmOverloads constructor( + private val scope: CoroutineScope, private val targetIntent: Intent, + private val additionalContentUri: Uri?, private val contentResolver: ContentInterface, - private val typeClassifier: MimeTypeClassifier, - private val dispatcher: CoroutineDispatcher, + // TODO: replace with the ChooserServiceFlags ref when PreviewViewModel dependencies are sorted + // out + private val isPayloadTogglingEnabled: Boolean, + private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier, ) { - constructor( - targetIntent: Intent, - contentResolver: ContentInterface, - ) : this( - targetIntent, - contentResolver, - DefaultMimeTypeClassifier, - Dispatchers.IO, - ) private val records = targetIntent.contentUris.map { UriRecord(it) } + private val fileInfoSharedFlow: SharedFlow<FileInfo> by lazy { + // Alternatively, we could just use [shareIn()] on a [flow] -- and it would be, arguably, + // cleaner -- but we'd lost the ability to trace the traverse as [runTracing] does not + // generally work over suspend function invocations. + MutableSharedFlow<FileInfo>(replay = records.size).apply { + scope.launch { + runTracing("image-preview-metadata") { + for (record in records) { + tryEmit(FileInfo.Builder(record.uri).readFromRecord(record).build()) + } + } + } + } + } + /** returns number of shared URIs, see [Intent.EXTRA_STREAM] */ @get:OpenForTesting open val uriCount: Int get() = records.size + val uris: List<Uri> + get() = records.map { it.uri } + + /** + * Returns a [Flow] of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] and + * [FileInfo.previewUri] set (a data projection tailored for the image preview UI). + */ + @get:OpenForTesting + open val imagePreviewFileInfoFlow: Flow<FileInfo> + get() = fileInfoSharedFlow.take(records.size) + /** * Preview type to use. The type is determined asynchronously with a timeout; the fall-back * values is [ContentPreviewType.CONTENT_PREVIEW_FILE] @@ -106,16 +129,43 @@ constructor( * IMAGE, FILE, TEXT. */ if (!targetIntent.isSend || records.isEmpty()) { CONTENT_PREVIEW_TEXT + } else if (isPayloadTogglingEnabled && shouldShowPayloadSelection()) { + // TODO: replace with the proper flags injection + CONTENT_PREVIEW_PAYLOAD_SELECTION } else { - runBlocking(dispatcher) { - withTimeoutOrNull(TIMEOUT_MS) { - loadPreviewType() - } ?: CONTENT_PREVIEW_FILE + try { + runBlocking(scope.coroutineContext) { + withTimeoutOrNull(TIMEOUT_MS) { scope.async { loadPreviewType() }.await() } + ?: CONTENT_PREVIEW_FILE + } + } catch (e: CancellationException) { + Log.w( + ContentPreviewUi.TAG, + "An attempt to read preview type from a cancelled scope", + e + ) + CONTENT_PREVIEW_FILE } } } } + private fun shouldShowPayloadSelection(): Boolean { + val extraContentUri = additionalContentUri ?: return false + return runCatching { + val authority = extraContentUri.authority + records.firstOrNull { authority == it.uri.authority } == null + } + .onFailure { + Log.w( + ContentPreviewUi.TAG, + "Failed to check URI authorities; no payload toggling", + it + ) + } + .getOrDefault(false) + } + /** * The first shared URI's metadata. This call wait's for the data to be loaded and falls back to * a crude value if the data is not loaded within a time limit. @@ -123,46 +173,24 @@ constructor( open val firstFileInfo: FileInfo? by lazy { runTracing("first-uri-metadata") { records.firstOrNull()?.let { record -> - runBlocking(dispatcher) { - val builder = FileInfo.Builder(record.uri) - withTimeoutOrNull(TIMEOUT_MS) { - builder.readFromRecord(record) + val builder = FileInfo.Builder(record.uri) + try { + runBlocking(scope.coroutineContext) { + withTimeoutOrNull(TIMEOUT_MS) { + scope.async { builder.readFromRecord(record) }.await() + } } - builder.build() - } - } - } - } - - /** - * Returns a collection of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] - * and [FileInfo.previewUri] set (a data projection tailored for the image preview UI). - */ - @OpenForTesting - open fun getFileMetadataForImagePreview( - callerLifecycle: Lifecycle, - callback: Consumer<List<FileInfo>>, - ) { - callerLifecycle.coroutineScope.launch { - val result = withContext(dispatcher) { - getFileMetadataForImagePreview() - } - callback.accept(result) - } - } - - private fun getFileMetadataForImagePreview(): List<FileInfo> = - runTracing("image-preview-metadata") { - ArrayList<FileInfo>(records.size).also { result -> - for (record in records) { - result.add( - FileInfo.Builder(record.uri) - .readFromRecord(record) - .build() + } catch (e: CancellationException) { + Log.w( + ContentPreviewUi.TAG, + "An attempt to read first file info from a cancelled scope", + e ) } + builder.build() } } + } private fun FileInfo.Builder.readFromRecord(record: UriRecord): FileInfo.Builder { withMimeType(record.mimeType) @@ -181,14 +209,12 @@ constructor( * is not provided, derived from the URI. */ @Throws(IndexOutOfBoundsException::class) - fun getFirstFileName(callerLifecycle: Lifecycle, callback: Consumer<String>) { + fun getFirstFileName(callerScope: CoroutineScope, callback: Consumer<String>) { if (records.isEmpty()) { throw IndexOutOfBoundsException("There are no shared URIs") } - callerLifecycle.coroutineScope.launch { - val result = withContext(dispatcher) { - getFirstFileName() - } + callerScope.launch { + val result = scope.async { getFirstFileName() }.await() callback.accept(result) } } @@ -237,8 +263,7 @@ constructor( } resultDeferred.complete(CONTENT_PREVIEW_FILE) } - resultDeferred.await() - .also { job.cancel() } + resultDeferred.await().also { job.cancel() } } } @@ -251,8 +276,7 @@ constructor( val isImageType: Boolean get() = typeClassifier.isImageType(mimeType) val supportsImageType: Boolean by lazy { - contentResolver.getStreamTypesSafe(uri) - ?.firstOrNull(typeClassifier::isImageType) != null + contentResolver.getStreamTypesSafe(uri).firstOrNull(typeClassifier::isImageType) != null } val supportsThumbnail: Boolean get() = query.supportsThumbnail @@ -263,45 +287,47 @@ constructor( private val query by lazy { readQueryResult() } - private fun readQueryResult(): QueryResult { - val cursor = contentResolver.querySafe(uri) - ?.takeIf { it.moveToFirst() } - ?: return QueryResult() - - var flagColIdx = -1 - var displayIconUriColIdx = -1 - var nameColIndex = -1 - var titleColIndex = -1 - // TODO: double-check why Cursor#getColumnInded didn't work - cursor.columnNames.forEachIndexed { i, columnName -> - when (columnName) { - DocumentsContract.Document.COLUMN_FLAGS -> flagColIdx = i - MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI -> displayIconUriColIdx = i - OpenableColumns.DISPLAY_NAME -> nameColIndex = i - Downloads.Impl.COLUMN_TITLE -> titleColIndex = i + private fun readQueryResult(): QueryResult = + // TODO: rewrite using methods from UiMetadataHelpers.kt + contentResolver.querySafe(uri, METADATA_COLUMNS)?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + + var flagColIdx = -1 + var displayIconUriColIdx = -1 + var nameColIndex = -1 + var titleColIndex = -1 + // TODO: double-check why Cursor#getColumnInded didn't work + cursor.columnNames.forEachIndexed { i, columnName -> + when (columnName) { + DocumentsContract.Document.COLUMN_FLAGS -> flagColIdx = i + MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI -> displayIconUriColIdx = i + OpenableColumns.DISPLAY_NAME -> nameColIndex = i + Downloads.Impl.COLUMN_TITLE -> titleColIndex = i + } } - } - - val supportsThumbnail = - flagColIdx >= 0 && ((cursor.getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0) - var title = "" - if (nameColIndex >= 0) { - title = cursor.getString(nameColIndex) ?: "" - } - if (TextUtils.isEmpty(title) && titleColIndex >= 0) { - title = cursor.getString(titleColIndex) ?: "" - } + val supportsThumbnail = + flagColIdx >= 0 && + ((cursor.getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0) - val iconUri = - if (displayIconUriColIdx >= 0) { - cursor.getString(displayIconUriColIdx)?.let(Uri::parse) - } else { - null + var title = "" + if (nameColIndex >= 0) { + title = cursor.getString(nameColIndex) ?: "" + } + if (TextUtils.isEmpty(title) && titleColIndex >= 0) { + title = cursor.getString(titleColIndex) ?: "" } - return QueryResult(supportsThumbnail, title, iconUri) - } + val iconUri = + if (displayIconUriColIdx >= 0) { + cursor.getString(displayIconUriColIdx)?.let(Uri::parse) + } else { + null + } + + QueryResult(supportsThumbnail, title, iconUri) + } + ?: QueryResult() } private class QueryResult( @@ -344,51 +370,3 @@ private fun getFileName(uri: Uri): String { fileName.substring(index + 1) } } - -private fun ContentInterface.getTypeSafe(uri: Uri): String? = - runTracing("getType") { - try { - getType(uri) - } catch (e: SecurityException) { - logProviderPermissionWarning(uri, "mime type") - null - } catch (t: Throwable) { - Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t) - null - } - } - -private fun ContentInterface.getStreamTypesSafe(uri: Uri): Array<String>? = - runTracing("getStreamTypes") { - try { - getStreamTypes(uri, "*/*") - } catch (e: SecurityException) { - logProviderPermissionWarning(uri, "stream types") - null - } catch (t: Throwable) { - Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: $uri", t) - null - } - } - -private fun ContentInterface.querySafe(uri: Uri): Cursor? = - runTracing("query") { - try { - query(uri, METADATA_COLUMNS, null, null) - } catch (e: SecurityException) { - logProviderPermissionWarning(uri, "metadata") - null - } catch (t: Throwable) { - Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t) - null - } - } - -private fun logProviderPermissionWarning(uri: Uri, dataName: String) { - // The ContentResolver already logs the exception. Log something more informative. - Log.w( - ContentPreviewUi.TAG, - "Could not read $uri $dataName. If a preview is desired, call Intent#setClipData() to" + - " ensure that the sharesheet is given permission." - ) -} diff --git a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt index 331b0cb6..6a729945 100644 --- a/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/PreviewViewModel.kt @@ -17,44 +17,64 @@ package com.android.intentresolver.contentpreview import android.app.Application +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri import androidx.annotation.MainThread import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras -import com.android.intentresolver.ChooserRequestParameters import com.android.intentresolver.R +import com.android.intentresolver.inject.Background +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.plus -/** A trivial view model to keep a [PreviewDataProvider] instance over a configuration change */ -class PreviewViewModel(private val application: Application) : BasePreviewViewModel() { - private var previewDataProvider: PreviewDataProvider? = null - private var imageLoader: ImagePreviewImageLoader? = null +/** 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 - @MainThread - override fun createOrReuseProvider( - chooserRequest: ChooserRequestParameters - ): PreviewDataProvider = - previewDataProvider - ?: PreviewDataProvider(chooserRequest.targetIntent, application.contentResolver).also { - previewDataProvider = it - } + override val previewDataProvider by lazy { + val targetIntent = requireNotNull(this.targetIntent) { "Not initialized" } + PreviewDataProvider( + viewModelScope + dispatcher, + targetIntent, + additionalContentUri, + contentResolver, + isPayloadTogglingEnabled, + ) + } + + override val imageLoader by lazy { + ImagePreviewImageLoader( + viewModelScope + dispatcher, + thumbnailSize, + contentResolver, + cacheSize = 16 + ) + } + // TODO: make the view model injectable and inject these dependencies instead @MainThread - override fun createOrReuseImageLoader(): ImageLoader = - imageLoader - ?: ImagePreviewImageLoader( - viewModelScope + Dispatchers.IO, - thumbnailSize = - application.resources.getDimensionPixelSize( - R.dimen.chooser_preview_image_max_dimen - ), - application.contentResolver, - cacheSize = 16 - ) - .also { imageLoader = it } + 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 = @@ -63,7 +83,16 @@ class PreviewViewModel(private val application: Application) : BasePreviewViewMo override fun <T : ViewModel> create( modelClass: Class<T>, extras: CreationExtras - ): T = PreviewViewModel(checkNotNull(extras[APPLICATION_KEY])) as T + ): T { + val application: Application = checkNotNull(extras[APPLICATION_KEY]) + return PreviewViewModel( + application.contentResolver, + application.resources.getDimensionPixelSize( + R.dimen.chooser_preview_image_max_dimen + ) + ) + as T + } } } } diff --git a/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt new file mode 100644 index 00000000..57a51239 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ShareouselContentPreviewUi.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview + +import android.content.res.Resources +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.ui.composable.Shareousel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.ui.viewmodel.ChooserViewModel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +class ShareouselContentPreviewUi : ContentPreviewUi() { + + override fun getType(): Int = ContentPreviewType.CONTENT_PREVIEW_IMAGE + + override fun display( + resources: Resources, + layoutInflater: LayoutInflater, + parent: ViewGroup, + headlineViewParent: View, + ): ViewGroup = displayInternal(parent, headlineViewParent) + + private fun displayInternal(parent: ViewGroup, headlineViewParent: View): ViewGroup { + inflateHeadline(headlineViewParent) + return ComposeView(parent.context).apply { + setContent { + val vm: ChooserViewModel = viewModel() + val viewModel: ShareouselViewModel = vm.shareouselViewModel + + LaunchedEffect(viewModel) { bindHeader(viewModel, headlineViewParent) } + + MaterialTheme( + colorScheme = + if (isSystemInDarkTheme()) { + dynamicDarkColorScheme(LocalContext.current) + } else { + dynamicLightColorScheme(LocalContext.current) + }, + ) { + Shareousel(viewModel) + } + } + } + } + + private suspend fun bindHeader(viewModel: ShareouselViewModel, headlineViewParent: View) { + coroutineScope { + launch { bindHeadline(viewModel, headlineViewParent) } + launch { bindMetadataText(viewModel, headlineViewParent) } + } + } + + private suspend fun bindHeadline(viewModel: ShareouselViewModel, headlineViewParent: View) { + viewModel.headline.collect { headline -> + headlineViewParent.findViewById<TextView>(R.id.headline)?.apply { + if (headline.isNotBlank()) { + text = headline + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } + } + } + + private suspend fun bindMetadataText(viewModel: ShareouselViewModel, headlineViewParent: View) { + viewModel.metadataText.collect { metadata -> + headlineViewParent.findViewById<TextView>(R.id.metadata)?.apply { + if (metadata?.isNotBlank() == true) { + text = metadata + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java index c38ed03a..ae7ddcd9 100644 --- a/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/TextContentPreviewUi.java @@ -20,6 +20,7 @@ import static com.android.intentresolver.util.UriFilters.isOwnedByCurrentUser; import android.content.res.Resources; import android.net.Uri; +import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; @@ -28,38 +29,47 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; +import com.android.intentresolver.ContentTypeHint; import com.android.intentresolver.R; import com.android.intentresolver.widget.ActionRow; +import kotlinx.coroutines.CoroutineScope; + class TextContentPreviewUi extends ContentPreviewUi { - private final Lifecycle mLifecycle; + private final CoroutineScope mScope; @Nullable private final CharSequence mSharingText; @Nullable private final CharSequence mPreviewTitle; @Nullable + private final CharSequence mMetadata; + @Nullable private final Uri mPreviewThumbnail; private final ImageLoader mImageLoader; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final HeadlineGenerator mHeadlineGenerator; + private final ContentTypeHint mContentTypeHint; TextContentPreviewUi( - Lifecycle lifecycle, + CoroutineScope scope, @Nullable CharSequence sharingText, @Nullable CharSequence previewTitle, + @Nullable CharSequence metadata, @Nullable Uri previewThumbnail, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, - HeadlineGenerator headlineGenerator) { - mLifecycle = lifecycle; + HeadlineGenerator headlineGenerator, + ContentTypeHint contentTypeHint) { + mScope = scope; mSharingText = sharingText; mPreviewTitle = previewTitle; + mMetadata = metadata; mPreviewThumbnail = previewThumbnail; mImageLoader = imageLoader; mActionFactory = actionFactory; mHeadlineGenerator = headlineGenerator; + mContentTypeHint = contentTypeHint; } @Override @@ -68,17 +78,21 @@ class TextContentPreviewUi extends ContentPreviewUi { } @Override - public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory); - return layout; + public ViewGroup display( + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + View headlineViewParent) { + return displayInternal(layoutInflater, parent, headlineViewParent); } private ViewGroup displayInternal( LayoutInflater layoutInflater, - ViewGroup parent) { + ViewGroup parent, + View headlineViewParent) { ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_text, parent, false); + inflateHeadline(headlineViewParent); final ActionRow actionRow = contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); @@ -93,13 +107,9 @@ class TextContentPreviewUi extends ContentPreviewUi { TextView textView = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_text); - String text = mSharingText.toString(); - // If we're only previewing one line, then strip out newlines. - if (textView.getMaxLines() == 1) { - text = text.replace("\n", " "); - } - textView.setText(text); + textView.setText( + textView.getMaxLines() == 1 ? replaceLineBreaks(mSharingText) : mSharingText); TextView previewTitleView = contentPreviewLayout.findViewById( com.android.internal.R.id.content_preview_title); @@ -115,7 +125,7 @@ class TextContentPreviewUi extends ContentPreviewUi { previewThumbnailView.setVisibility(View.GONE); } else { mImageLoader.loadImage( - mLifecycle, + mScope, mPreviewThumbnail, (bitmap) -> updateViewWithImage( contentPreviewLayout.findViewById( @@ -131,8 +141,26 @@ class TextContentPreviewUi extends ContentPreviewUi { copyButton.setVisibility(View.GONE); } - displayHeadline(contentPreviewLayout, mHeadlineGenerator.getTextHeadline(mSharingText)); + String headlineText = (mContentTypeHint == ContentTypeHint.ALBUM) + ? mHeadlineGenerator.getAlbumHeadline() + : mHeadlineGenerator.getTextHeadline(mSharingText); + displayHeadline(headlineViewParent, headlineText); + displayMetadata(headlineViewParent, mMetadata); return contentPreviewLayout; } + + @Nullable + private static CharSequence replaceLineBreaks(@Nullable CharSequence text) { + if (text == null) { + return null; + } + SpannableStringBuilder string = new SpannableStringBuilder(text); + for (int i = 0, size = string.length(); i < size; i++) { + if (string.charAt(i) == '\n') { + string.replace(i, i + 1, " "); + } + } + return string; + } } diff --git a/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt new file mode 100644 index 00000000..9f1d50da --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/ThumbnailLoader.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.net.Uri +import android.util.Size +import javax.inject.Inject + +/** Interface for objects that can attempt load a [Bitmap] from a [Uri]. */ +interface ThumbnailLoader : suspend (Uri) -> Bitmap? + +/** Default implementation of [ThumbnailLoader]. */ +class ThumbnailLoaderImpl +@Inject +constructor( + private val contentResolver: ContentResolver, + @ThumbnailSize thumbnailSize: Int, +) : ThumbnailLoader { + + private val size = Size(thumbnailSize, thumbnailSize) + + override suspend fun invoke(uri: Uri): Bitmap = + contentResolver.loadThumbnail(uri, size, /* signal = */ null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java index 6385f2b6..88311016 100644 --- a/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java +++ b/java/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUi.java @@ -31,35 +31,55 @@ import com.android.intentresolver.widget.ActionRow; import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback; import com.android.intentresolver.widget.ScrollableImagePreviewView; -import java.util.ArrayList; +import kotlinx.coroutines.CoroutineScope; +import kotlinx.coroutines.flow.Flow; + import java.util.List; import java.util.Objects; class UnifiedContentPreviewUi extends ContentPreviewUi { private final boolean mShowEditAction; + @Nullable + private final String mIntentMimeType; private final ChooserContentPreviewUi.ActionFactory mActionFactory; private final ImageLoader mImageLoader; private final MimeTypeClassifier mTypeClassifier; private final TransitionElementStatusCallback mTransitionElementStatusCallback; private final HeadlineGenerator mHeadlineGenerator; @Nullable + private final CharSequence mMetadata; + private final Flow<FileInfo> mFileInfoFlow; + private final int mItemCount; + @Nullable private List<FileInfo> mFiles; @Nullable private ViewGroup mContentPreviewView; + private View mHeadlineView; UnifiedContentPreviewUi( + CoroutineScope scope, boolean isSingleImage, + @Nullable String intentMimeType, ChooserContentPreviewUi.ActionFactory actionFactory, ImageLoader imageLoader, MimeTypeClassifier typeClassifier, TransitionElementStatusCallback transitionElementStatusCallback, - HeadlineGenerator headlineGenerator) { + Flow<FileInfo> fileInfoFlow, + int itemCount, + HeadlineGenerator headlineGenerator, + @Nullable CharSequence metadata) { mShowEditAction = isSingleImage; + mIntentMimeType = intentMimeType; mActionFactory = actionFactory; mImageLoader = imageLoader; mTypeClassifier = typeClassifier; mTransitionElementStatusCallback = transitionElementStatusCallback; + mFileInfoFlow = fileInfoFlow; + mItemCount = itemCount; mHeadlineGenerator = headlineGenerator; + mMetadata = metadata; + + JavaFlowHelper.collectToList(scope, fileInfoFlow, this::setFiles); } @Override @@ -68,26 +88,31 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { } @Override - public ViewGroup display(Resources resources, LayoutInflater layoutInflater, ViewGroup parent) { - ViewGroup layout = displayInternal(layoutInflater, parent); - displayModifyShareAction(layout, mActionFactory); - return layout; + public ViewGroup display( + Resources resources, + LayoutInflater layoutInflater, + ViewGroup parent, + View headlineViewParent) { + return displayInternal(layoutInflater, parent, headlineViewParent); } - public void setFiles(List<FileInfo> files) { + private void setFiles(List<FileInfo> files) { mImageLoader.prePopulate(files.stream() .map(FileInfo::getPreviewUri) .filter(Objects::nonNull) .toList()); mFiles = files; if (mContentPreviewView != null) { - updatePreviewWithFiles(mContentPreviewView, files); + updatePreviewWithFiles(mContentPreviewView, mHeadlineView, files); } } - private ViewGroup displayInternal(LayoutInflater layoutInflater, ViewGroup parent) { + private ViewGroup displayInternal( + LayoutInflater layoutInflater, ViewGroup parent, View headlineViewParent) { mContentPreviewView = (ViewGroup) layoutInflater.inflate( R.layout.chooser_grid_preview_image, parent, false); + mHeadlineView = headlineViewParent; + inflateHeadline(mHeadlineView); final ActionRow actionRow = mContentPreviewView.findViewById(com.android.internal.R.id.chooser_action_row); @@ -96,17 +121,32 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { ScrollableImagePreviewView imagePreview = mContentPreviewView.requireViewById(R.id.scrollable_image_preview); + imagePreview.setImageLoader(mImageLoader); imagePreview.setOnNoPreviewCallback(() -> imagePreview.setVisibility(View.GONE)); imagePreview.setTransitionElementStatusCallback(mTransitionElementStatusCallback); + imagePreview.setPreviews( + JavaFlowHelper.mapFileIntoToPreview( + mFileInfoFlow, + mTypeClassifier, + mShowEditAction ? mActionFactory.getEditButtonRunnable() : null), + mItemCount); if (mFiles != null) { - updatePreviewWithFiles(mContentPreviewView, mFiles); + updatePreviewWithFiles(mContentPreviewView, mHeadlineView, mFiles); + } else { + displayHeadline( + mHeadlineView, + mItemCount, + mTypeClassifier.isImageType(mIntentMimeType), + mTypeClassifier.isVideoType(mIntentMimeType)); + imagePreview.setLoading(mItemCount); } return mContentPreviewView; } - private void updatePreviewWithFiles(ViewGroup contentPreviewView, List<FileInfo> files) { + private void updatePreviewWithFiles( + ViewGroup contentPreviewView, View headlineView, List<FileInfo> files) { final int count = files.size(); ScrollableImagePreviewView imagePreview = contentPreviewView.requireViewById(R.id.scrollable_image_preview); @@ -120,7 +160,6 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { return; } - List<ScrollableImagePreviewView.Preview> previews = new ArrayList<>(); boolean allImages = true; boolean allVideos = true; for (FileInfo fileInfo : files) { @@ -128,24 +167,20 @@ class UnifiedContentPreviewUi extends ContentPreviewUi { getPreviewType(mTypeClassifier, fileInfo.getMimeType()); allImages = allImages && previewType == ScrollableImagePreviewView.PreviewType.Image; allVideos = allVideos && previewType == ScrollableImagePreviewView.PreviewType.Video; - - if (fileInfo.getPreviewUri() != null) { - Runnable editAction = - mShowEditAction ? mActionFactory.getEditButtonRunnable() : null; - previews.add( - new ScrollableImagePreviewView.Preview( - previewType, fileInfo.getPreviewUri(), editAction)); - } } - imagePreview.setPreviews(previews, count - previews.size(), mImageLoader); + displayHeadline(headlineView, count, allImages, allVideos); + } + private void displayHeadline( + View layout, int count, boolean allImages, boolean allVideos) { if (allImages) { - displayHeadline(contentPreviewView, mHeadlineGenerator.getImagesHeadline(count)); + displayHeadline(layout, mHeadlineGenerator.getImagesHeadline(count)); } else if (allVideos) { - displayHeadline(contentPreviewView, mHeadlineGenerator.getVideosHeadline(count)); + displayHeadline(layout, mHeadlineGenerator.getVideosHeadline(count)); } else { - displayHeadline(contentPreviewView, mHeadlineGenerator.getFilesHeadline(count)); + displayHeadline(layout, mHeadlineGenerator.getFilesHeadline(count)); } + displayMetadata(layout, mMetadata); } } diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt new file mode 100644 index 00000000..c532b9a5 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataHelpers.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.ContentInterface +import android.database.Cursor +import android.media.MediaMetadata +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL +import android.provider.Downloads +import android.provider.MediaStore.MediaColumns.HEIGHT +import android.provider.MediaStore.MediaColumns.WIDTH +import android.provider.OpenableColumns +import android.text.TextUtils +import android.util.Log +import android.util.Size +import com.android.intentresolver.measurements.runTracing + +internal fun ContentInterface.getTypeSafe(uri: Uri): String? = + runTracing("getType") { + try { + getType(uri) + } catch (e: SecurityException) { + logProviderPermissionWarning(uri, "mime type") + null + } catch (t: Throwable) { + Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t) + null + } + } + +internal fun ContentInterface.getStreamTypesSafe(uri: Uri): Array<String?> = + runTracing("getStreamTypes") { + try { + getStreamTypes(uri, "*/*") ?: emptyArray() + } catch (e: SecurityException) { + logProviderPermissionWarning(uri, "stream types") + emptyArray<String?>() + } catch (t: Throwable) { + Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: $uri", t) + emptyArray<String?>() + } + } + +internal fun ContentInterface.querySafe(uri: Uri, columns: Array<String>): Cursor? = + runTracing("query") { + try { + query(uri, columns, null, null) + } catch (e: SecurityException) { + logProviderPermissionWarning(uri, "metadata") + null + } catch (t: Throwable) { + Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t) + null + } + } + +internal fun Cursor.readSupportsThumbnail(): Boolean = + runCatching { + val flagColIdx = columnNames.indexOf(DocumentsContract.Document.COLUMN_FLAGS) + flagColIdx >= 0 && ((getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0) + } + .getOrDefault(false) + +internal fun Cursor.readPreviewUri(): Uri? = + runCatching { + columnNames + .indexOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI) + .takeIf { it >= 0 } + ?.let { getString(it)?.let(Uri::parse) } + } + .getOrNull() + +fun Cursor.readSize(): Size? { + val widthIdx = columnNames.indexOf(WIDTH) + val heightIdx = columnNames.indexOf(HEIGHT) + return if (widthIdx < 0 || heightIdx < 0 || isNull(widthIdx) || isNull(heightIdx)) { + null + } else { + runCatching { + val width = getInt(widthIdx) + val height = getInt(heightIdx) + if (width >= 0 && height > 0) { + Size(width, height) + } else { + null + } + } + .getOrNull() + } +} + +internal fun Cursor.readTitle(): String = + runCatching { + var nameColIndex = -1 + var titleColIndex = -1 + // TODO: double-check why Cursor#getColumnInded didn't work + columnNames.forEachIndexed { i, columnName -> + when (columnName) { + OpenableColumns.DISPLAY_NAME -> nameColIndex = i + Downloads.Impl.COLUMN_TITLE -> titleColIndex = i + } + } + + var title = "" + if (nameColIndex >= 0) { + title = getString(nameColIndex) ?: "" + } + if (TextUtils.isEmpty(title) && titleColIndex >= 0) { + title = getString(titleColIndex) ?: "" + } + title + } + .getOrDefault("") + +private fun logProviderPermissionWarning(uri: Uri, dataName: String) { + // The ContentResolver already logs the exception. Log something more informative. + Log.w( + ContentPreviewUi.TAG, + "Could not read $uri $dataName. If a preview is desired, call Intent#setClipData() to" + + " ensure that the sharesheet is given permission." + ) +} diff --git a/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt new file mode 100644 index 00000000..4e403c22 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/UriMetadataReader.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview + +import android.content.ContentInterface +import android.media.MediaMetadata +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.MediaStore.MediaColumns +import android.util.Size +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject + +fun interface UriMetadataReader { + fun getMetadata(uri: Uri): FileInfo + fun readPreviewSize(uri: Uri): Size? = null +} + +class UriMetadataReaderImpl +@Inject +constructor( + private val contentResolver: ContentInterface, + private val typeClassifier: MimeTypeClassifier, +) : UriMetadataReader { + override fun getMetadata(uri: Uri): FileInfo { + val builder = FileInfo.Builder(uri) + val mimeType = contentResolver.getTypeSafe(uri) + builder.withMimeType(mimeType) + if ( + typeClassifier.isImageType(mimeType) || + contentResolver.supportsImageType(uri) || + contentResolver.supportsThumbnail(uri) + ) { + builder.withPreviewUri(uri) + return builder.build() + } + val previewUri = contentResolver.readPreviewUri(uri) + if (previewUri != null) { + builder.withPreviewUri(previewUri) + } + return builder.build() + } + + override fun readPreviewSize(uri: Uri): Size? = contentResolver.readPreviewSize(uri) + + private fun ContentInterface.supportsImageType(uri: Uri): Boolean = + getStreamTypesSafe(uri).firstOrNull { typeClassifier.isImageType(it) } != null + + private fun ContentInterface.supportsThumbnail(uri: Uri): Boolean = + querySafe(uri, arrayOf(DocumentsContract.Document.COLUMN_FLAGS))?.use { cursor -> + cursor.moveToFirst() && cursor.readSupportsThumbnail() + } + ?: false + + private fun ContentInterface.readPreviewUri(uri: Uri): Uri? = + querySafe(uri, arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI))?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.readPreviewUri() + } else { + null + } + } + + private fun ContentInterface.readPreviewSize(uri: Uri): Size? = + querySafe(uri, arrayOf(MediaColumns.WIDTH, MediaColumns.HEIGHT))?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.readSize() + } else { + null + } + } +} + +@Module +@InstallIn(SingletonComponent::class) +interface UriMetadataReaderModule { + + @Binds fun bind(impl: UriMetadataReaderImpl): UriMetadataReader + + companion object { + @Provides fun classifier(): MimeTypeClassifier = DefaultMimeTypeClassifier + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt new file mode 100644 index 00000000..b7945005 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/model/CustomActionModel.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.model + +import android.graphics.drawable.Icon + +/** Data model for a custom action the user can take. */ +data class CustomActionModel( + /** Label presented to the user identifying this action. */ + val label: CharSequence, + /** Icon presented to the user for this action. */ + val icon: Icon, + /** When invoked, performs this action. */ + val performAction: () -> Unit, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt new file mode 100644 index 00000000..c3bb88c8 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/ActivityResultRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** Tracks the result of the current activity. */ +@ActivityRetainedScoped +class ActivityResultRepository @Inject constructor() { + /** The result of the current activity, or `null` if the activity is still active. */ + val activityResult = MutableStateFlow<Int?>(null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt new file mode 100644 index 00000000..b104d4bf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/CursorPreviewsRepository.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Stores previews for Shareousel UI that have been cached locally from a remote + * [android.database.Cursor]. + */ +@ActivityRetainedScoped +class CursorPreviewsRepository @Inject constructor() { + /** Previews available for display within Shareousel. */ + val previewsModel = MutableStateFlow<PreviewsModel?>(null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt new file mode 100644 index 00000000..1745cd9c --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PendingSelectionCallbackRepository.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import android.content.Intent +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** Tracks active async communication with sharing app to notify of target intent update. */ +@ActivityRetainedScoped +class PendingSelectionCallbackRepository @Inject constructor() { + /** + * The target [Intent] that is has an active update request with the sharing app, or `null` if + * there is no active request. + */ + val pendingTargetIntent: MutableStateFlow<Intent?> = MutableStateFlow(null) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt new file mode 100644 index 00000000..48c06192 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/data/repository/PreviewSelectionsRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.data.repository + +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +/** Stores set of selected previews. */ +@ViewModelScoped +class PreviewSelectionsRepository @Inject constructor() { + val selections = MutableStateFlow(emptyList<PreviewModel>()) +} diff --git a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt index 5b5d769c..3aa0d567 100644 --- a/java/src/com/android/intentresolver/flags/FeatureFlagRepository.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/CursorResolver.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,12 +14,11 @@ * limitations under the License. */ -package com.android.intentresolver.flags +package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag +import com.android.intentresolver.util.cursor.CursorView -interface FeatureFlagRepository { - fun isEnabled(flag: UnreleasedFlag): Boolean - fun isEnabled(flag: ReleasedFlag): Boolean +/** Asynchronously retrieves a [CursorView]. */ +fun interface CursorResolver<out T> { + suspend fun getCursor(): CursorView<T>? } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt new file mode 100644 index 00000000..d9612696 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/cursor/PayloadToggleCursorResolver.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor + +import android.content.ContentInterface +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.service.chooser.AdditionalContentContract.Columns.URI +import androidx.core.os.bundleOf +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.readSize +import com.android.intentresolver.inject.AdditionalContent +import com.android.intentresolver.inject.ChooserIntent +import com.android.intentresolver.util.cursor.CursorView +import com.android.intentresolver.util.cursor.viewBy +import com.android.intentresolver.util.withCancellationSignal +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Inject +import javax.inject.Qualifier + +/** [CursorResolver] for the [CursorView] underpinning Shareousel. */ +class PayloadToggleCursorResolver +@Inject +constructor( + private val contentResolver: ContentInterface, + @AdditionalContent private val cursorUri: Uri, + @ChooserIntent private val chooserIntent: Intent, +) : CursorResolver<CursorRow?> { + override suspend fun getCursor(): CursorView<CursorRow?>? = withCancellationSignal { signal -> + runCatching { + contentResolver.query( + cursorUri, + // TODO: uncomment to start using that data + arrayOf(URI /*, WIDTH, HEIGHT*/), + bundleOf(Intent.EXTRA_INTENT to chooserIntent), + signal, + ) + } + .getOrNull() + ?.viewBy { readUri()?.let { uri -> CursorRow(uri, readSize()) } } + } + + private fun Cursor.readUri(): Uri? { + val uriIdx = columnNames.indexOf(URI) + if (uriIdx < 0) return null + return runCatching { + getString(uriIdx)?.let(Uri::parse)?.takeIf { it.authority != cursorUri.authority } + } + .getOrNull() + } + + @Module + @InstallIn(ViewModelComponent::class) + interface Binding { + @Binds + @PayloadToggle + fun bind(cursorResolver: PayloadToggleCursorResolver): CursorResolver<CursorRow?> + } +} + +/** [CursorResolver] for the [CursorView] underpinning Shareousel. */ +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PayloadToggle diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt new file mode 100644 index 00000000..faad5bbf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/CustomActionPendingIntentSender.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.app.ActivityOptions +import android.app.PendingIntent +import android.content.Context +import com.android.intentresolver.R +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject +import javax.inject.Qualifier + +/** [PendingIntentSender] for Shareousel custom actions. */ +class CustomActionPendingIntentSender +@Inject +constructor( + @ApplicationContext private val context: Context, +) : PendingIntentSender { + override fun send(pendingIntent: PendingIntent) { + pendingIntent.send( + /* context = */ null, + /* code = */ 0, + /* intent = */ null, + /* onFinished = */ null, + /* handler = */ null, + /* requiredPermission = */ null, + /* options = */ ActivityOptions.makeCustomAnimation( + context, + R.anim.slide_in_right, + R.anim.slide_out_left, + ) + .toBundle() + ) + } + + @Module + @InstallIn(SingletonComponent::class) + interface Binding { + @Binds + @CustomAction + fun bindSender(sender: CustomActionPendingIntentSender): PendingIntentSender + } +} + +/** [PendingIntentSender] for Shareousel custom actions. */ +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class CustomAction diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt new file mode 100644 index 00000000..d75884d5 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/InitialCustomActionsModule.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.app.PendingIntent +import android.service.chooser.ChooserAction +import android.util.Log +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +object InitialCustomActionsModule { + @Provides + fun initialCustomActionModels( + chooserActions: List<ChooserAction>, + @CustomAction pendingIntentSender: PendingIntentSender, + ): List<CustomActionModel> = chooserActions.map { it.toCustomActionModel(pendingIntentSender) } +} + +/** + * Returns a [CustomActionModel] that sends this [ChooserAction]'s + * [PendingIntent][ChooserAction.getAction]. + */ +fun ChooserAction.toCustomActionModel(pendingIntentSender: PendingIntentSender) = + CustomActionModel( + label = label, + icon = icon, + performAction = { + try { + pendingIntentSender.send(action) + } catch (_: PendingIntent.CanceledException) { + Log.d(TAG, "Custom action, $label, has been cancelled") + } + } + ) + +private const val TAG = "CustomShareActions" diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt new file mode 100644 index 00000000..23ba31ba --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/PendingIntentSender.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.app.PendingIntent + +/** Sends [PendingIntent]s. */ +fun interface PendingIntentSender { + fun send(pendingIntent: PendingIntent) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt new file mode 100644 index 00000000..4a2a6932 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/intent/TargetIntentModifier.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.intent + +import android.content.ClipData +import android.content.ClipDescription.compareMimeTypes +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_STREAM +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.TargetIntent +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +/** Modifies target intent based on current payload selection. */ +fun interface TargetIntentModifier<Item> { + fun intentFromSelection(selection: Collection<Item>): Intent +} + +class TargetIntentModifierImpl<Item>( + private val originalTargetIntent: Intent, + private val getUri: Item.() -> Uri, + private val getMimeType: Item.() -> String?, +) : TargetIntentModifier<Item> { + override fun intentFromSelection(selection: Collection<Item>): Intent { + val uris = selection.mapTo(ArrayList()) { it.getUri() } + val targetMimeType = + selection.fold(null) { target: String?, item: Item -> + updateMimeType(item.getMimeType(), target) + } + return Intent(originalTargetIntent).apply { + if (selection.size == 1) { + action = ACTION_SEND + putExtra(EXTRA_STREAM, selection.first().getUri()) + } else { + action = ACTION_SEND_MULTIPLE + putParcelableArrayListExtra(EXTRA_STREAM, uris) + } + type = targetMimeType + if (uris.isNotEmpty()) { + clipData = + ClipData("", arrayOf(targetMimeType), ClipData.Item(uris[0])).also { + for (i in 1 until uris.size) { + it.addItem(ClipData.Item(uris[i])) + } + } + } + } + } + + private fun updateMimeType(itemMimeType: String?, unitedMimeType: String?): String { + itemMimeType ?: return "*/*" + unitedMimeType ?: return itemMimeType + if (compareMimeTypes(itemMimeType, unitedMimeType)) return unitedMimeType + val slashIdx = unitedMimeType.indexOf('/') + if (slashIdx >= 0 && unitedMimeType.regionMatches(0, itemMimeType, 0, slashIdx + 1)) { + return buildString { + append(unitedMimeType.substring(0, slashIdx + 1)) + append('*') + } + } + return "*/*" + } +} + +@Module +@InstallIn(ViewModelComponent::class) +object TargetIntentModifierModule { + @Provides + fun targetIntentModifier( + @TargetIntent targetIntent: Intent, + ): TargetIntentModifier<PreviewModel> = + TargetIntentModifierImpl(targetIntent, { uri }, { mimeType }) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt new file mode 100644 index 00000000..953e91b3 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ChooserRequestInteractor.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.data.repository.ChooserRequestRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.map + +/** Stores the target intent of the share sheet, and custom actions derived from the intent. */ +class ChooserRequestInteractor +@Inject +constructor( + private val repository: ChooserRequestRepository, +) { + val targetIntent: Flow<Intent> + get() = repository.chooserRequest.map { it.targetIntent } + + val customActions: Flow<List<CustomActionModel>> + get() = repository.customActions.asSharedFlow() + + val metadataText: Flow<CharSequence?> + get() = repository.chooserRequest.map { it.metadataText } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt new file mode 100644 index 00000000..a0fc11c3 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CursorPreviewsInteractor.kt @@ -0,0 +1,337 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import android.service.chooser.AdditionalContentContract.CursorExtraKeys.POSITION +import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadedWindow +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowLeft +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.expandWindowRight +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.numLoadedPages +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowLeft +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.shiftWindowRight +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.FocusedItemIndex +import com.android.intentresolver.util.cursor.CursorView +import com.android.intentresolver.util.cursor.PagedCursor +import com.android.intentresolver.util.cursor.get +import com.android.intentresolver.util.cursor.paged +import com.android.intentresolver.util.mapParallel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Qualifier +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest + +/** Queries data from a remote cursor, and caches it locally for presentation in Shareousel. */ +class CursorPreviewsInteractor +@Inject +constructor( + private val interactor: SetCursorPreviewsInteractor, + @FocusedItemIndex private val focusedItemIdx: Int, + private val uriMetadataReader: UriMetadataReader, + @PageSize private val pageSize: Int, + @MaxLoadedPages private val maxLoadedPages: Int, +) { + + init { + check(pageSize > 0) { "pageSize must be greater than zero" } + } + + /** Start reading data from [uriCursor], and listen for requests to load more. */ + suspend fun launch(uriCursor: CursorView<CursorRow?>, initialPreviews: Iterable<PreviewModel>) { + // Unclaimed values from the initial selection set. Entries will be removed as the cursor is + // read, and any still present are inserted at the start / end of the cursor when it is + // reached by the user. + val unclaimedRecords: MutableUnclaimedMap = + initialPreviews + .asSequence() + .mapIndexed { i, m -> Pair(m.uri, Pair(i, m)) } + .toMap(ConcurrentHashMap()) + val pagedCursor: PagedCursor<CursorRow?> = uriCursor.paged(pageSize) + val startPosition = uriCursor.extras?.getInt(POSITION, 0) ?: 0 + val state = + loadToMaxPages( + initialState = readInitialState(pagedCursor, startPosition, unclaimedRecords), + pagedCursor = pagedCursor, + unclaimedRecords = unclaimedRecords, + ) + processLoadRequests(state, pagedCursor, unclaimedRecords) + } + + private suspend fun loadToMaxPages( + initialState: CursorWindow, + pagedCursor: PagedCursor<CursorRow?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + var state = initialState + val startPageNum = state.firstLoadedPageNum + while ((state.hasMoreLeft || state.hasMoreRight) && state.numLoadedPages < maxLoadedPages) { + interactor.setPreviews( + previews = state.merged.values.toList(), + startIndex = startPageNum, + hasMoreLeft = state.hasMoreLeft, + hasMoreRight = state.hasMoreRight, + ) + val loadedLeft = startPageNum - state.firstLoadedPageNum + val loadedRight = state.lastLoadedPageNum - startPageNum + state = + when { + state.hasMoreLeft && loadedLeft < loadedRight -> + state.loadMoreLeft(pagedCursor, unclaimedRecords) + state.hasMoreRight -> state.loadMoreRight(pagedCursor, unclaimedRecords) + else -> state.loadMoreLeft(pagedCursor, unclaimedRecords) + } + } + return state + } + + /** Loop forever, processing any loading requests from the UI and updating local cache. */ + private suspend fun processLoadRequests( + initialState: CursorWindow, + pagedCursor: PagedCursor<CursorRow?>, + unclaimedRecords: MutableUnclaimedMap, + ) { + var state = initialState + while (true) { + // Design note: in order to prevent load requests from the UI when it was displaying a + // previously-published dataset being accidentally associated with a recently-published + // one, we generate a new Flow of load requests for each dataset and only listen to + // those. + val loadingState: Flow<LoadDirection?> = + interactor.setPreviews( + previews = state.merged.values.toList(), + startIndex = 0, // TODO: actually track this as the window changes? + hasMoreLeft = state.hasMoreLeft, + hasMoreRight = state.hasMoreRight, + ) + state = loadingState.handleOneLoadRequest(state, pagedCursor, unclaimedRecords) + } + } + + /** + * Suspends until a single loading request has been handled, returning the new [CursorWindow] + * with the loaded data incorporated. + */ + private suspend fun Flow<LoadDirection?>.handleOneLoadRequest( + state: CursorWindow, + pagedCursor: PagedCursor<CursorRow?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow = + mapLatest { loadDirection -> + loadDirection?.let { + when (loadDirection) { + LoadDirection.Left -> state.loadMoreLeft(pagedCursor, unclaimedRecords) + LoadDirection.Right -> state.loadMoreRight(pagedCursor, unclaimedRecords) + } + } + } + .filterNotNull() + .first() + + /** + * Returns the initial [CursorWindow], with a single page loaded that contains the given + * [startPosition]. + */ + private suspend fun readInitialState( + cursor: PagedCursor<CursorRow?>, + startPosition: Int, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val startPageIdx = startPosition / pageSize + val hasMoreLeft = startPageIdx > 0 + val hasMoreRight = startPageIdx < cursor.count - 1 + val page: PreviewMap = buildMap { + if (!hasMoreLeft) { + // First read the initial page; this might claim some unclaimed Uris + val page = + cursor.getPageRows(startPageIdx)?.toPage(mutableMapOf(), unclaimedRecords) + // Now that unclaimed Uris are up-to-date, add them first. + putAllUnclaimedLeft(unclaimedRecords) + // Then add the loaded page + page?.let(::putAll) + } else { + cursor.getPageRows(startPageIdx)?.toPage(this, unclaimedRecords) + } + // Finally, add the remainder of the unclaimed Uris. + if (!hasMoreRight) { + putAllUnclaimedRight(unclaimedRecords) + } + } + return CursorWindow( + firstLoadedPageNum = startPageIdx, + lastLoadedPageNum = startPageIdx, + pages = listOf(page.keys), + merged = page, + hasMoreLeft = hasMoreLeft, + hasMoreRight = hasMoreRight, + ) + } + + private suspend fun CursorWindow.loadMoreRight( + cursor: PagedCursor<CursorRow?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val pageNum = lastLoadedPageNum + 1 + val hasMoreRight = pageNum < cursor.count - 1 + val newPage: PreviewMap = buildMap { + readAndPutPage(this@loadMoreRight, cursor, pageNum, unclaimedRecords) + if (!hasMoreRight) { + putAllUnclaimedRight(unclaimedRecords) + } + } + return if (numLoadedPages < maxLoadedPages) { + expandWindowRight(newPage, hasMoreRight) + } else { + shiftWindowRight(newPage, hasMoreRight) + } + } + + private suspend fun CursorWindow.loadMoreLeft( + cursor: PagedCursor<CursorRow?>, + unclaimedRecords: MutableUnclaimedMap, + ): CursorWindow { + val pageNum = firstLoadedPageNum - 1 + val hasMoreLeft = pageNum > 0 + val newPage: PreviewMap = buildMap { + if (!hasMoreLeft) { + // First read the page; this might claim some unclaimed Uris + val page = readPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords) + // Now that unclaimed URIs are up-to-date, add them first + putAllUnclaimedLeft(unclaimedRecords) + // Then add the loaded page + putAll(page) + } else { + readAndPutPage(this@loadMoreLeft, cursor, pageNum, unclaimedRecords) + } + } + return if (numLoadedPages < maxLoadedPages) { + expandWindowLeft(newPage, hasMoreLeft) + } else { + shiftWindowLeft(newPage, hasMoreLeft) + } + } + + private suspend fun readPage( + state: CursorWindow, + pagedCursor: PagedCursor<CursorRow?>, + pageNum: Int, + unclaimedRecords: MutableUnclaimedMap, + ): PreviewMap = + mutableMapOf<Uri, PreviewModel>() + .readAndPutPage(state, pagedCursor, pageNum, unclaimedRecords) + + private suspend fun <M : MutablePreviewMap> M.readAndPutPage( + state: CursorWindow, + pagedCursor: PagedCursor<CursorRow?>, + pageNum: Int, + unclaimedRecords: MutableUnclaimedMap, + ): M = + pagedCursor + .getPageRows(pageNum) // TODO: what do we do if the load fails? + ?.filter { it.uri !in state.merged } + ?.toPage(this, unclaimedRecords) + ?: this + + private suspend fun <M : MutablePreviewMap> Sequence<CursorRow>.toPage( + destination: M, + unclaimedRecords: MutableUnclaimedMap, + ): M = + // Restrict parallelism so as to not overload the metadata reader; anecdotally, too + // many parallel queries causes failures. + mapParallel(parallelism = 4) { row -> createPreviewModel(row, unclaimedRecords) } + .associateByTo(destination) { it.uri } + + private fun createPreviewModel( + row: CursorRow, + unclaimedRecords: MutableUnclaimedMap, + ): PreviewModel = + unclaimedRecords.remove(row.uri)?.second + ?: uriMetadataReader.getMetadata(row.uri).let { metadata -> + val size = + row.previewSize + ?: metadata.previewUri?.let { uriMetadataReader.readPreviewSize(it) } + PreviewModel( + uri = row.uri, + previewUri = metadata.previewUri, + mimeType = metadata.mimeType, + aspectRatio = size.aspectRatioOrDefault(1f), + ) + } + + private fun <M : MutablePreviewMap> M.putAllUnclaimedRight(unclaimed: UnclaimedMap): M = + putAllUnclaimedWhere(unclaimed) { it >= focusedItemIdx } + + private fun <M : MutablePreviewMap> M.putAllUnclaimedLeft(unclaimed: UnclaimedMap): M = + putAllUnclaimedWhere(unclaimed) { it < focusedItemIdx } +} + +private typealias CursorWindow = LoadedWindow<Uri, PreviewModel> + +/** + * Values from the initial selection set that have not yet appeared within the Cursor. These values + * are appended to the start/end of the cursor dataset, depending on their position relative to the + * initially focused value. + */ +private typealias UnclaimedMap = Map<Uri, Pair<Int, PreviewModel>> + +/** Mutable version of [UnclaimedMap]. */ +private typealias MutableUnclaimedMap = MutableMap<Uri, Pair<Int, PreviewModel>> + +private typealias MutablePreviewMap = MutableMap<Uri, PreviewModel> + +private typealias PreviewMap = Map<Uri, PreviewModel> + +private fun <M : MutablePreviewMap> M.putAllUnclaimedWhere( + unclaimedRecords: UnclaimedMap, + predicate: (Int) -> Boolean, +): M = + unclaimedRecords + .asSequence() + .filter { predicate(it.value.first) } + .map { it.key to it.value.second } + .toMap(this) + +private fun PagedCursor<CursorRow?>.getPageRows(pageNum: Int): Sequence<CursorRow>? = + get(pageNum)?.filterNotNull() + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class PageSize + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class MaxLoadedPages + +@Module +@InstallIn(SingletonComponent::class) +object ShareouselConstants { + @Provides @PageSize fun pageSize(): Int = 16 + + @Provides @MaxLoadedPages fun maxLoadedPages(): Int = 8 +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt new file mode 100644 index 00000000..e973e844 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/CustomActionsInteractor.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.app.Activity +import android.content.ContentResolver +import android.content.pm.PackageManager +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.ActivityResultRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ActionModel +import com.android.intentresolver.icon.toComposeIcon +import com.android.intentresolver.inject.Background +import com.android.intentresolver.logging.EventLog +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +class CustomActionsInteractor +@Inject +constructor( + private val activityResultRepo: ActivityResultRepository, + @Background private val bgDispatcher: CoroutineDispatcher, + private val contentResolver: ContentResolver, + private val eventLog: EventLog, + private val packageManager: PackageManager, + private val chooserRequestInteractor: ChooserRequestInteractor, +) { + /** List of [ActionModel] that can be presented in Shareousel. */ + val customActions: Flow<List<ActionModel>> + get() = + chooserRequestInteractor.customActions + .map { actions -> + actions.map { action -> + ActionModel( + label = action.label, + icon = action.icon.toComposeIcon(packageManager, contentResolver), + performAction = { index -> performAction(action, index) }, + ) + } + } + .flowOn(bgDispatcher) + .conflate() + + private fun performAction(action: CustomActionModel, index: Int) { + action.performAction() + eventLog.logCustomActionSelected(index) + activityResultRepo.activityResult.value = Activity.RESULT_OK + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt new file mode 100644 index 00000000..388cbc7e --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/FetchPreviewsInteractor.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.UriMetadataReader +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.CursorResolver +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.CursorRow +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.inject.ContentUris +import com.android.intentresolver.inject.FocusedItemIndex +import com.android.intentresolver.util.mapParallel +import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +/** Populates the data displayed in Shareousel. */ +class FetchPreviewsInteractor +@Inject +constructor( + private val setCursorPreviews: SetCursorPreviewsInteractor, + private val selectionRepository: PreviewSelectionsRepository, + private val cursorInteractor: CursorPreviewsInteractor, + @FocusedItemIndex private val focusedItemIdx: Int, + @ContentUris private val selectedItems: List<@JvmSuppressWildcards Uri>, + private val uriMetadataReader: UriMetadataReader, + @PayloadToggle private val cursorResolver: CursorResolver<@JvmSuppressWildcards CursorRow?>, +) { + suspend fun activate() = coroutineScope { + val cursor = async { cursorResolver.getCursor() } + val initialPreviewMap = getInitialPreviews() + selectionRepository.selections.value = initialPreviewMap + setCursorPreviews.setPreviews( + previews = initialPreviewMap, + startIndex = focusedItemIdx, + hasMoreLeft = false, + hasMoreRight = false, + ) + cursorInteractor.launch(cursor.await() ?: return@coroutineScope, initialPreviewMap) + } + + private suspend fun getInitialPreviews(): List<PreviewModel> = + selectedItems + // Restrict parallelism so as to not overload the metadata reader; anecdotally, too + // many parallel queries causes failures. + .mapParallel(parallelism = 4) { uri -> + val metadata = uriMetadataReader.getMetadata(uri) + PreviewModel( + uri = uri, + previewUri = metadata.previewUri, + mimeType = metadata.mimeType, + aspectRatio = + metadata.previewUri?.let { + uriMetadataReader.readPreviewSize(it).aspectRatioOrDefault(1f) + } + ?: 1f, + ) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt new file mode 100644 index 00000000..c202eabf --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/ProcessTargetIntentUpdatesInteractor.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.update.SelectionChangeCallback +import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest + +/** Communicates with the sharing application to notify of changes to the target intent. */ +class ProcessTargetIntentUpdatesInteractor +@Inject +constructor( + private val selectionCallback: SelectionChangeCallback, + private val repository: PendingSelectionCallbackRepository, + private val chooserRequestInteractor: UpdateChooserRequestInteractor, +) { + /** Listen for events and update state. */ + suspend fun activate() { + repository.pendingTargetIntent.collectLatest { targetIntent -> + targetIntent ?: return@collectLatest + selectionCallback.onSelectionChanged(targetIntent)?.let { update -> + chooserRequestInteractor.applyUpdate(targetIntent, update) + } + repository.pendingTargetIntent.compareAndSet(targetIntent, null) + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt new file mode 100644 index 00000000..55a995f5 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewInteractor.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.net.Uri +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** An individual preview in Shareousel. */ +class SelectablePreviewInteractor( + private val key: PreviewModel, + private val selectionInteractor: SelectionInteractor, +) { + val uri: Uri = key.uri + + /** Whether or not this preview is selected by the user. */ + val isSelected: Flow<Boolean> = selectionInteractor.selections.map { key in it } + + /** Sets whether this preview is selected by the user. */ + fun setSelected(isSelected: Boolean) { + if (isSelected) { + selectionInteractor.select(key) + } else { + selectionInteractor.unselect(key) + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt new file mode 100644 index 00000000..a578d0e2 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectablePreviewsInteractor.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +class SelectablePreviewsInteractor +@Inject +constructor( + private val previewsRepo: CursorPreviewsRepository, + private val selectionInteractor: SelectionInteractor, +) { + /** Keys of previews available for display in Shareousel. */ + val previews: Flow<PreviewsModel?> + get() = previewsRepo.previewsModel + + /** + * Returns a [SelectablePreviewInteractor] that can be used to interact with the individual + * preview associated with [key]. + */ + fun preview(key: PreviewModel) = SelectablePreviewInteractor(key, selectionInteractor) +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt new file mode 100644 index 00000000..13af92cb --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SelectionInteractor.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.MimeTypeClassifier +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PreviewSelectionsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.TargetIntentModifier +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.updateAndGet + +class SelectionInteractor +@Inject +constructor( + private val selectionsRepo: PreviewSelectionsRepository, + private val targetIntentModifier: TargetIntentModifier<PreviewModel>, + private val updateTargetIntentInteractor: UpdateTargetIntentInteractor, + private val mimeTypeClassifier: MimeTypeClassifier, +) { + /** List of selected previews. */ + val selections: StateFlow<List<PreviewModel>> + get() = selectionsRepo.selections + + /** Amount of selected previews. */ + val amountSelected: Flow<Int> = selectionsRepo.selections.map { it.size } + + val aggregateContentType: Flow<ContentType> = selections.map { aggregateContentType(it) } + + fun select(model: PreviewModel) { + updateChooserRequest(selectionsRepo.selections.updateAndGet { it + model }) + } + + fun unselect(model: PreviewModel) { + if (selectionsRepo.selections.value.size > 1) { + updateChooserRequest(selectionsRepo.selections.updateAndGet { it - model }) + } + } + + private fun updateChooserRequest(selections: List<PreviewModel>) { + val intent = targetIntentModifier.intentFromSelection(selections) + updateTargetIntentInteractor.updateTargetIntent(intent) + } + + private fun aggregateContentType( + items: List<PreviewModel>, + ): ContentType { + if (items.isEmpty()) { + return ContentType.Other + } + + var allImages = true + var allVideos = true + for (item in items) { + allImages = allImages && mimeTypeClassifier.isImageType(item.mimeType) + allVideos = allVideos && mimeTypeClassifier.isVideoType(item.mimeType) + + if (!allImages && !allVideos) { + break + } + } + + return when { + allImages -> ContentType.Image + allVideos -> ContentType.Video + else -> ContentType.Other + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt new file mode 100644 index 00000000..437bc942 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SetCursorPreviewsInteractor.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.CursorPreviewsRepository +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.LoadDirection +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Updates [CursorPreviewsRepository] with new previews. */ +class SetCursorPreviewsInteractor +@Inject +constructor(private val previewsRepo: CursorPreviewsRepository) { + /** Stores new [previews], and returns a flow of load requests triggered by Shareousel. */ + fun setPreviews( + previews: List<PreviewModel>, + startIndex: Int, + hasMoreLeft: Boolean, + hasMoreRight: Boolean, + ): Flow<LoadDirection?> { + val loadingState = MutableStateFlow<LoadDirection?>(null) + previewsRepo.previewsModel.value = + PreviewsModel( + previewModels = previews, + startIdx = startIndex, + loadMoreLeft = + if (hasMoreLeft) { + ({ loadingState.value = LoadDirection.Left }) + } else { + null + }, + loadMoreRight = + if (hasMoreRight) { + ({ loadingState.value = LoadDirection.Right }) + } else { + null + }, + ) + return loadingState.asStateFlow() + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt new file mode 100644 index 00000000..4cf10414 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/SizeExtensions.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.util.Size + +internal fun Size?.aspectRatioOrDefault(default: Float): Float = + when { + this == null -> default + width >= 0 && height > 0 -> width.toFloat() / height.toFloat() + else -> default + } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt new file mode 100644 index 00000000..dd16f0c1 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateChooserRequestInteractor.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.CustomAction +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.PendingIntentSender +import com.android.intentresolver.contentpreview.payloadtoggle.domain.intent.toCustomActionModel +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.onValue +import com.android.intentresolver.data.repository.ChooserRequestRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.update + +/** Updates the tracked chooser request. */ +class UpdateChooserRequestInteractor +@Inject +constructor( + private val repository: ChooserRequestRepository, + @CustomAction private val pendingIntentSender: PendingIntentSender, +) { + fun applyUpdate(targetIntent: Intent, update: ShareouselUpdate) { + repository.chooserRequest.update { current -> + current.copy( + targetIntent = targetIntent, + callerChooserTargets = + update.callerTargets.getOrDefault(current.callerChooserTargets), + modifyShareAction = + update.modifyShareAction.getOrDefault(current.modifyShareAction), + additionalTargets = update.alternateIntents.getOrDefault(current.additionalTargets), + chosenComponentSender = + update.resultIntentSender.getOrDefault(current.chosenComponentSender), + refinementIntentSender = + update.refinementIntentSender.getOrDefault(current.refinementIntentSender), + metadataText = update.metadataText.getOrDefault(current.metadataText), + chooserActions = update.customActions.getOrDefault(current.chooserActions), + ) + } + update.customActions.onValue { actions -> + repository.customActions.value = + actions.map { it.toCustomActionModel(pendingIntentSender) } + } + } + + fun setTargetIntent(targetIntent: Intent) { + repository.chooserRequest.update { it.copy(targetIntent = targetIntent) } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt new file mode 100644 index 00000000..d99d69ab --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/interactor/UpdateTargetIntentInteractor.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor + +import android.content.Intent +import com.android.intentresolver.contentpreview.payloadtoggle.data.repository.PendingSelectionCallbackRepository +import javax.inject.Inject + +class UpdateTargetIntentInteractor +@Inject +constructor( + private val repository: PendingSelectionCallbackRepository, + private val chooserRequestInteractor: UpdateChooserRequestInteractor, +) { + /** + * Updates the target intent for the chooser. This will kick off an asynchronous IPC with the + * sharing application, so that it can react to the new intent. + */ + fun updateTargetIntent(targetIntent: Intent) { + repository.pendingTargetIntent.value = targetIntent + chooserRequestInteractor.setTargetIntent(targetIntent) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt new file mode 100644 index 00000000..f69365d7 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ActionModel.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +import com.android.intentresolver.icon.ComposeIcon + +/** An action that the user can take, provided by the sharing application. */ +data class ActionModel( + /** Text shown for this action in the UI. */ + val label: CharSequence, + /** An optional [ComposeIcon] that will be displayed in the UI with this action. */ + val icon: ComposeIcon?, + /** + * Performs the action. The argument indicates the index in the UI that this action is shown. + */ + val performAction: (index: Int) -> Unit, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt new file mode 100644 index 00000000..f1d856ac --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/CursorRow.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +import android.net.Uri +import android.util.Size + +/** Represents additional content cursor row */ +data class CursorRow(val uri: Uri, val previewSize: Size?) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt new file mode 100644 index 00000000..23510f15 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadDirection.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +/** Specifies which side of the dataset is being loaded. */ +enum class LoadDirection { + Left, + Right, +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt new file mode 100644 index 00000000..e2e69852 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/LoadedWindow.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +/** A window of data loaded from a cursor. */ +data class LoadedWindow<K, V>( + /** First cursor page index loaded within this window. */ + val firstLoadedPageNum: Int, + /** Last cursor page index loaded within this window. */ + val lastLoadedPageNum: Int, + /** Keys of cursor data within this window, grouped by loaded page. */ + val pages: List<Set<K>>, + /** Merged set of all cursor data within this window. */ + val merged: Map<K, V>, + /** Is there more data to the left of this window? */ + val hasMoreLeft: Boolean, + /** Is there more data to the right of this window? */ + val hasMoreRight: Boolean, +) + +/** Number of loaded pages stored within this [LoadedWindow]. */ +val LoadedWindow<*, *>.numLoadedPages: Int + get() = (lastLoadedPageNum - firstLoadedPageNum) + 1 + +/** Inserts [newPage] to the right, and removes the leftmost page from the window. */ +fun <K, V> LoadedWindow<K, V>.shiftWindowRight( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum + 1, + lastLoadedPageNum = lastLoadedPageNum + 1, + pages = pages.drop(1) + listOf(newPage.keys), + merged = + buildMap { + putAll(merged) + pages.first().forEach(::remove) + putAll(newPage) + }, + hasMoreLeft = true, + hasMoreRight = hasMore, + ) + +/** Inserts [newPage] to the right, increasing the size of the window to accommodate it. */ +fun <K, V> LoadedWindow<K, V>.expandWindowRight( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum, + lastLoadedPageNum = lastLoadedPageNum + 1, + pages = pages + listOf(newPage.keys), + merged = merged + newPage, + hasMoreLeft = hasMoreLeft, + hasMoreRight = hasMore, + ) + +/** Inserts [newPage] to the left, and removes the rightmost page from the window. */ +fun <K, V> LoadedWindow<K, V>.shiftWindowLeft( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum - 1, + lastLoadedPageNum = lastLoadedPageNum - 1, + pages = listOf(newPage.keys) + pages.dropLast(1), + merged = + buildMap { + putAll(newPage) + putAll(merged - pages.last()) + }, + hasMoreLeft = hasMore, + hasMoreRight = true, + ) + +/** Inserts [newPage] to the left, increasing the size olf the window to accommodate it. */ +fun <K, V> LoadedWindow<K, V>.expandWindowLeft( + newPage: Map<K, V>, + hasMore: Boolean, +): LoadedWindow<K, V> = + LoadedWindow( + firstLoadedPageNum = firstLoadedPageNum - 1, + lastLoadedPageNum = lastLoadedPageNum, + pages = listOf(newPage.keys) + pages, + merged = newPage + merged, + hasMoreLeft = hasMore, + hasMoreRight = hasMoreRight, + ) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt new file mode 100644 index 00000000..821e88a5 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ShareouselUpdate.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +import android.content.Intent +import android.content.IntentSender +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget + +/** Sharing session updates provided by the sharing app from the payload change callback */ +data class ShareouselUpdate( + // for all properties, null value means no change + val customActions: ValueUpdate<List<ChooserAction>> = ValueUpdate.Absent, + val modifyShareAction: ValueUpdate<ChooserAction?> = ValueUpdate.Absent, + val alternateIntents: ValueUpdate<List<Intent>> = ValueUpdate.Absent, + val callerTargets: ValueUpdate<List<ChooserTarget>> = ValueUpdate.Absent, + val refinementIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent, + val resultIntentSender: ValueUpdate<IntentSender?> = ValueUpdate.Absent, + val metadataText: ValueUpdate<CharSequence?> = ValueUpdate.Absent, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt new file mode 100644 index 00000000..bad4eebe --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/model/ValueUpdate.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.model + +/** Represents an either updated value or the absence of it */ +sealed interface ValueUpdate<out T> { + data class Value<T>(val value: T) : ValueUpdate<T> + data object Absent : ValueUpdate<Nothing> +} + +/** Return encapsulated value if this instance represent Value or `default` if Absent */ +fun <T> ValueUpdate<T>.getOrDefault(default: T): T = + when (this) { + is ValueUpdate.Value -> value + is ValueUpdate.Absent -> default + } + +/** Executes the `block` with encapsulated value if this instance represents Value */ +inline fun <T> ValueUpdate<T>.onValue(block: (T) -> Unit) { + if (this is ValueUpdate.Value) { + block(value) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt new file mode 100644 index 00000000..1d34dc75 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/domain/update/SelectionChangeCallback.kt @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.domain.update + +import android.content.ContentInterface +import android.content.Intent +import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION +import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_TARGETS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_METADATA_TEXT +import android.content.IntentSender +import android.net.Uri +import android.os.Bundle +import android.service.chooser.AdditionalContentContract.MethodNames.ON_SELECTION_CHANGED +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ShareouselUpdate +import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate +import com.android.intentresolver.inject.AdditionalContent +import com.android.intentresolver.inject.ChooserIntent +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.ui.viewmodel.readAlternateIntents +import com.android.intentresolver.ui.viewmodel.readChooserActions +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.log +import com.android.intentresolver.validation.types.array +import com.android.intentresolver.validation.types.value +import com.android.intentresolver.validation.validateFrom +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import javax.inject.Inject +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val TAG = "SelectionChangeCallback" + +/** + * Encapsulates payload change callback invocation to the sharing app; handles callback arguments + * and result format mapping. + */ +fun interface SelectionChangeCallback { + suspend fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? +} + +class SelectionChangeCallbackImpl +@Inject +constructor( + @AdditionalContent private val uri: Uri, + @ChooserIntent private val chooserIntent: Intent, + private val contentResolver: ContentInterface, + private val flags: ChooserServiceFlags, +) : SelectionChangeCallback { + private val mutex = Mutex() + + override suspend fun onSelectionChanged(targetIntent: Intent): ShareouselUpdate? = + mutex + .withLock { + contentResolver.call( + requireNotNull(uri.authority) { "URI authority can not be null" }, + ON_SELECTION_CHANGED, + uri.toString(), + Bundle().apply { + putParcelable( + EXTRA_INTENT, + Intent(chooserIntent).apply { putExtra(EXTRA_INTENT, targetIntent) } + ) + } + ) + } + ?.let { bundle -> + return when (val result = readCallbackResponse(bundle, flags)) { + is Valid -> { + result.warnings.forEach { it.log(TAG) } + result.value + } + is Invalid -> { + result.errors.forEach { it.log(TAG) } + null + } + } + } +} + +private fun readCallbackResponse( + bundle: Bundle, + flags: ChooserServiceFlags +): ValidationResult<ShareouselUpdate> { + return validateFrom(bundle::get) { + // An error is treated as an empty collection or null as the presence of a value indicates + // an intention to change the old value implying that the old value is obsolete (and should + // not be used). + val customActions = + bundle.readValueUpdate(EXTRA_CHOOSER_CUSTOM_ACTIONS) { + readChooserActions() ?: emptyList() + } + val modifyShareAction = + bundle.readValueUpdate(EXTRA_CHOOSER_MODIFY_SHARE_ACTION) { key -> + optional(value<ChooserAction>(key)) + } + val alternateIntents = + bundle.readValueUpdate(EXTRA_ALTERNATE_INTENTS) { + readAlternateIntents() ?: emptyList() + } + val callerTargets = + bundle.readValueUpdate(EXTRA_CHOOSER_TARGETS) { key -> + optional(array<ChooserTarget>(key)) ?: emptyList() + } + val refinementIntentSender = + bundle.readValueUpdate(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER) { key -> + optional(value<IntentSender>(key)) + } + val resultIntentSender = + bundle.readValueUpdate(EXTRA_CHOOSER_RESULT_INTENT_SENDER) { key -> + optional(value<IntentSender>(key)) + } + val metadataText = + if (flags.enableSharesheetMetadataExtra()) { + bundle.readValueUpdate(EXTRA_METADATA_TEXT) { key -> + optional(value<CharSequence>(key)) + } + } else { + ValueUpdate.Absent + } + + ShareouselUpdate( + customActions, + modifyShareAction, + alternateIntents, + callerTargets, + refinementIntentSender, + resultIntentSender, + metadataText, + ) + } +} + +private inline fun <reified T> Bundle.readValueUpdate( + key: String, + block: (String) -> T +): ValueUpdate<T> = + if (containsKey(key)) { + ValueUpdate.Value(block(key)) + } else { + ValueUpdate.Absent + } + +@Module +@InstallIn(ViewModelComponent::class) +interface SelectionChangeCallbackModule { + @Binds fun bind(impl: SelectionChangeCallbackImpl): SelectionChangeCallback +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt new file mode 100644 index 00000000..3ef6d98f --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/ContentType.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.shared + +/** Type of the content being previewed. */ +enum class ContentType { + Image, + Video, + Other +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt new file mode 100644 index 00000000..85c70004 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewModel.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.shared.model + +import android.net.Uri + +/** An individual preview presented in Shareousel. */ +data class PreviewModel( + /** Uri for this item; if this preview is selected, this will be shared with the target app. */ + val uri: Uri, + /** Uri for the preview image. */ + val previewUri: Uri? = uri, + /** Mimetype for the data [uri] points to. */ + val mimeType: String?, + val aspectRatio: Float = 1f, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt new file mode 100644 index 00000000..1d3eb4b4 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/shared/model/PreviewsModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.shared.model + +/** A dataset of previews for Shareousel. */ +data class PreviewsModel( + /** All available [PreviewModel]s. */ + val previewModels: List<PreviewModel>, + /** Index into [previewModels] that should be initially displayed to the user. */ + val startIdx: Int, + /** + * Signals that more data should be loaded to the left of this dataset. A `null` value indicates + * that there is no more data to load in that direction. + */ + val loadMoreLeft: (() -> Unit)?, + /** + * Signals that more data should be loaded to the right of this dataset. A `null` value + * indicates that there is no more data to load in that direction. + */ + val loadMoreRight: (() -> Unit)?, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt new file mode 100644 index 00000000..8cf237da --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ComposeIconComposable.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable + +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Resources +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import com.android.intentresolver.icon.AdaptiveIcon +import com.android.intentresolver.icon.BitmapIcon +import com.android.intentresolver.icon.ComposeIcon +import com.android.intentresolver.icon.ResourceIcon + +@Composable +fun Image(icon: ComposeIcon, modifier: Modifier = Modifier, colorFilter: ColorFilter? = null) { + when (icon) { + is AdaptiveIcon -> Image(icon.wrapped, modifier, colorFilter = colorFilter) + is BitmapIcon -> + Image( + icon.bitmap.asImageBitmap(), + contentDescription = null, + modifier = modifier, + colorFilter = colorFilter + ) + is ResourceIcon -> { + val localContext = LocalContext.current + val wrappedContext: Context = + object : ContextWrapper(localContext) { + override fun getResources(): Resources = icon.res + } + CompositionLocalProvider(LocalContext provides wrappedContext) { + Image( + painterResource(icon.resId), + contentDescription = null, + modifier = modifier, + colorFilter = colorFilter + ) + } + } + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt new file mode 100644 index 00000000..a0be1a9b --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType + +@Composable +fun ShareouselCard( + image: @Composable () -> Unit, + contentType: ContentType, + selected: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier) { + image() + val topButtonPadding = 12.dp + Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { + SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) + if (contentType == ContentType.Video) { + AnimationIcon(modifier = Modifier.align(Alignment.TopEnd)) + } + } + } +} + +@Composable +private fun AnimationIcon(modifier: Modifier = Modifier) { + Icon( + painterResource(id = R.drawable.ic_play_circle_filled_24px), + contentDescription = null, // Video attribute described at a higher level. + tint = Color.White, + modifier = Modifier.size(20.dp).then(modifier) + ) +} + +@Composable +private fun SelectionIcon(selected: Boolean, modifier: Modifier = Modifier) { + if (selected) { + val bgColor = MaterialTheme.colorScheme.primary + Icon( + painter = painterResource(id = R.drawable.checkbox), + tint = MaterialTheme.colorScheme.onPrimary, + contentDescription = null, + modifier = + Modifier.shadow( + elevation = 50.dp, + spotColor = Color(0x40000000), + ambientColor = Color(0x40000000) + ) + .size(20.dp) + .drawBehind { + drawCircle(color = bgColor, radius = (this.size.width / 2f) - 1f) + } + .then(modifier) + ) + } else { + Box( + modifier = + Modifier.shadow( + elevation = 50.dp, + spotColor = Color(0x40000000), + ambientColor = Color(0x40000000), + ) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + shape = CircleShape + ) + .clip(CircleShape) + .size(20.dp) + .background(color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f)) + .then(modifier) + ) + } +} diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt new file mode 100644 index 00000000..02d997ae --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.systemGestureExclusion +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.intentresolver.R +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import kotlinx.coroutines.launch + +@Composable +fun Shareousel(viewModel: ShareouselViewModel) { + val keySet = viewModel.previews.collectAsStateWithLifecycle(null).value + if (keySet != null) { + Shareousel(viewModel, keySet) + } else { + Spacer( + Modifier.height(dimensionResource(R.dimen.chooser_preview_image_height_tall) + 64.dp) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) + } +} + +@Composable +private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) { + Column( + modifier = + Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + .padding(vertical = 16.dp), + ) { + PreviewCarousel(keySet, viewModel) + ActionCarousel(viewModel) + } +} + +@Composable +private fun PreviewCarousel( + previews: PreviewsModel, + viewModel: ShareouselViewModel, +) { + val centerIdx = previews.startIdx + val carouselState = rememberLazyListState(initialFirstVisibleItemIndex = centerIdx) + // TODO: start item needs to be centered, check out ScalingLazyColumn impl or see if + // HorizontalPager works for our use-case + LazyRow( + state = carouselState, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier.fillMaxWidth() + .height(dimensionResource(R.dimen.chooser_preview_image_height_tall)) + .systemGestureExclusion() + ) { + itemsIndexed(previews.previewModels, key = { _, model -> model.uri }) { index, model -> + ShareouselCard(viewModel.preview(index, model)) + } + } +} + +@Composable +private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { + val bitmap by viewModel.bitmap.collectAsStateWithLifecycle(initialValue = null) + val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) + val borderColor = MaterialTheme.colorScheme.primary + val scope = rememberCoroutineScope() + val contentDescription = + when (viewModel.contentType) { + ContentType.Image -> stringResource(R.string.selectable_image) + ContentType.Video -> stringResource(R.string.selectable_video) + else -> stringResource(R.string.selectable_item) + } + ShareouselCard( + image = { + // TODO: max ratio is actually equal to the viewport ratio + val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + bitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(aspectRatio), + ) + } + ?: run { + // TODO: look at ScrollableImagePreviewView.setLoading() + Box(modifier = Modifier.fillMaxHeight().aspectRatio(aspectRatio)) + } + }, + contentType = viewModel.contentType, + selected = selected, + modifier = + Modifier.thenIf(selected) { + Modifier.border( + width = 4.dp, + color = borderColor, + shape = RoundedCornerShape(size = 12.dp), + ) + } + .semantics { this.contentDescription = contentDescription } + .clip(RoundedCornerShape(size = 12.dp)) + .toggleable( + value = selected, + onValueChange = { scope.launch { viewModel.setSelected(it) } }, + ) + ) +} + +@Composable +private fun ActionCarousel(viewModel: ShareouselViewModel) { + val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList()) + if (actions.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.height(32.dp), + ) { + itemsIndexed(actions) { idx, actionViewModel -> + if (idx == 0) { + Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))) + } + ShareouselAction( + label = actionViewModel.label, + onClick = { actionViewModel.onClicked() }, + ) { + actionViewModel.icon?.let { + Image( + icon = it, + modifier = Modifier.size(16.dp), + colorFilter = ColorFilter.tint(LocalContentColor.current) + ) + } + } + if (idx == actions.size - 1) { + Spacer(Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))) + } + } + } + } +} + +@Composable +private fun ShareouselAction( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: (@Composable () -> Unit)? = null, +) { + AssistChip( + onClick = onClick, + label = { Text(label) }, + leadingIcon = leadingIcon, + border = null, + shape = RoundedCornerShape(1000.dp), // pill shape. + colors = + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + labelColor = MaterialTheme.colorScheme.onSurface, + leadingIconContentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = modifier, + ) +} + +inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = + if (condition) this.then(factory()) else this + +private const val MIN_ASPECT_RATIO = 0.4f +private const val MAX_ASPECT_RATIO = 2.5f diff --git a/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt index 4ddb0447..728c573b 100644 --- a/java/src-debug/com/android/intentresolver/flags/FeatureFlagRepositoryFactory.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ActionChipViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,17 +14,16 @@ * limitations under the License. */ -package com.android.intentresolver.flags +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel -import android.content.Context -import android.os.Handler -import android.os.Looper -import com.android.systemui.flags.FlagManager +import com.android.intentresolver.icon.ComposeIcon -class FeatureFlagRepositoryFactory { - fun create(context: Context): FeatureFlagRepository = - DebugFeatureFlagRepository( - FlagManager(context, Handler(Looper.getMainLooper())), - DeviceConfigProxy(), - ) -} +/** An action chip presented to the user underneath Shareousel. */ +data class ActionChipViewModel( + /** Text label. */ + val label: String, + /** Optional icon, displayed next to the text label. */ + val icon: ComposeIcon?, + /** Handles user clicks on this action in the UI. */ + val onClicked: () -> Unit, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt new file mode 100644 index 00000000..540229c9 --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel + +import android.graphics.Bitmap +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType +import kotlinx.coroutines.flow.Flow + +/** An individual preview within Shareousel. */ +data class ShareouselPreviewViewModel( + /** Image to be shared. */ + val bitmap: Flow<Bitmap?>, + /** Type of data to be shared. */ + val contentType: ContentType, + /** Whether this preview has been selected by the user. */ + val isSelected: Flow<Boolean>, + /** Sets whether this preview has been selected by the user. */ + val setSelected: suspend (Boolean) -> Unit, + val aspectRatio: Float, +) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt new file mode 100644 index 00000000..9d53b92a --- /dev/null +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel + +import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader +import com.android.intentresolver.contentpreview.HeadlineGenerator +import com.android.intentresolver.contentpreview.ImageLoader +import com.android.intentresolver.contentpreview.MimeTypeClassifier +import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel +import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel +import com.android.intentresolver.inject.ViewModelOwned +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.zip + +/** A dynamic carousel of selectable previews within share sheet. */ +data class ShareouselViewModel( + /** Text displayed at the top of the share sheet when Shareousel is present. */ + val headline: Flow<String>, + /** App-provided text shown beneath the headline. */ + val metadataText: Flow<CharSequence?>, + /** + * Previews which are available for presentation within Shareousel. Use [preview] to create a + * [ShareouselPreviewViewModel] for a given [PreviewModel]. + */ + val previews: Flow<PreviewsModel?>, + /** List of action chips presented underneath Shareousel. */ + val actions: Flow<List<ActionChipViewModel>>, + /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ + val preview: (index: Int, key: PreviewModel) -> ShareouselPreviewViewModel, +) + +@Module +@InstallIn(ViewModelComponent::class) +interface ShareouselViewModelModule { + + @Binds @PayloadToggle fun imageLoader(imageLoader: CachingImagePreviewImageLoader): ImageLoader + + companion object { + @Provides + fun create( + interactor: SelectablePreviewsInteractor, + @PayloadToggle imageLoader: ImageLoader, + actionsInteractor: CustomActionsInteractor, + headlineGenerator: HeadlineGenerator, + selectionInteractor: SelectionInteractor, + chooserRequestInteractor: ChooserRequestInteractor, + mimeTypeClassifier: MimeTypeClassifier, + // TODO: remove if possible + @ViewModelOwned scope: CoroutineScope, + ): ShareouselViewModel { + val keySet = + interactor.previews.stateIn( + scope, + SharingStarted.Eagerly, + initialValue = null, + ) + return ShareouselViewModel( + headline = + selectionInteractor.aggregateContentType.zip( + selectionInteractor.amountSelected + ) { contentType, numItems -> + when (contentType) { + ContentType.Other -> headlineGenerator.getFilesHeadline(numItems) + ContentType.Image -> headlineGenerator.getImagesHeadline(numItems) + ContentType.Video -> headlineGenerator.getVideosHeadline(numItems) + } + }, + metadataText = chooserRequestInteractor.metadataText, + previews = keySet, + actions = + actionsInteractor.customActions.map { actions -> + actions.mapIndexedNotNull { i, model -> + val icon = model.icon + val label = model.label + if (icon == null && label.isBlank()) { + null + } else { + ActionChipViewModel( + label = label.toString(), + icon = model.icon, + onClicked = { model.performAction(i) }, + ) + } + } + }, + preview = { index, key -> + 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 + } + ShareouselPreviewViewModel( + bitmap = flow { emit(key.previewUri?.let { imageLoader(it) }) }, + contentType = contentType, + isSelected = previewInteractor.isSelected, + setSelected = previewInteractor::setSelected, + aspectRatio = key.aspectRatio, + ) + }, + ) + } + } +} + +private fun PreviewsModel.maybeLoad(index: Int) { + when (index) { + previewModels.indices.firstOrNull() -> loadMoreLeft?.invoke() + previewModels.indices.lastOrNull() -> loadMoreRight?.invoke() + } +} diff --git a/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt b/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt new file mode 100644 index 00000000..cf31ea10 --- /dev/null +++ b/java/src/com/android/intentresolver/data/BroadcastSubscriber.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Handler +import android.os.UserHandle +import android.util.Log +import com.android.intentresolver.inject.Broadcast +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +private const val TAG = "BroadcastSubscriber" + +class BroadcastSubscriber +@Inject +constructor( + @ApplicationContext private val context: Context, + @Broadcast private val handler: Handler +) { + /** + * Returns a [callbackFlow] that, when collected, registers a broadcast receiver and emits a new + * value whenever broadcast matching _filter_ is received. The result value will be computed + * using [transform] and emitted if non-null. + */ + fun <T> createFlow( + filter: IntentFilter, + user: UserHandle, + transform: (Intent) -> T?, + ): Flow<T> = callbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + transform(intent)?.also { result -> + trySend(result).onFailure { Log.e(TAG, "Failed to send $result", it) } + } + ?: Log.w(TAG, "Ignored broadcast $intent") + } + } + + @Suppress("MissingPermission") + context.registerReceiverAsUser( + receiver, + user, + IntentFilter(filter), + null, + handler, + Context.RECEIVER_NOT_EXPORTED + ) + awaitClose { context.unregisterReceiver(receiver) } + } +} diff --git a/java/src/com/android/intentresolver/data/model/ChooserRequest.kt b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt new file mode 100644 index 00000000..045a17f6 --- /dev/null +++ b/java/src/com/android/intentresolver/data/model/ChooserRequest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.data.model + +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.ACTION_SEND +import android.content.Intent.ACTION_SEND_MULTIPLE +import android.content.Intent.EXTRA_REFERRER +import android.content.IntentFilter +import android.content.IntentSender +import android.net.Uri +import android.os.Bundle +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import androidx.annotation.StringRes +import com.android.intentresolver.ContentTypeHint +import com.android.intentresolver.ext.hasAction + +const val ANDROID_APP_SCHEME = "android-app" + +/** All of the things that are consumed from an incoming share Intent (+Extras). */ +data class ChooserRequest( + /** Required. Represents the content being sent. */ + val targetIntent: Intent, + + /** The action from [targetIntent] as retrieved with [Intent.getAction]. */ + val targetAction: String? = targetIntent.action, + + /** + * Whether [targetAction] is ACTION_SEND or ACTION_SEND_MULTIPLE. These are considered the + * canonical "Share" actions. When handling other actions, this flag controls behavioral and + * visual changes. + */ + val isSendActionTarget: Boolean = targetIntent.hasAction(ACTION_SEND, ACTION_SEND_MULTIPLE), + + /** The top-level content type as retrieved using [Intent.getType]. */ + val targetType: String? = targetIntent.type, + + /** The package name of the app which started the current activity instance. */ + val launchedFromPackage: String, + + /** A custom tile for the main UI. Ignored when the intent is ACTION_SEND(_MULTIPLE). */ + val title: CharSequence? = null, + + /** A String resource ID to load when [title] is null. */ + @get:StringRes val defaultTitleResource: Int = 0, + + /** + * The referrer value as received by the caller. It may have been supplied via [EXTRA_REFERRER] + * or synthesized from callerPackageName. This value is merged into outgoing intents. + */ + val referrer: Uri? = null, + + /** + * Choices to exclude from results. + * + * Any resolved intents with a component in this list will be omitted before presentation. + */ + val filteredComponentNames: List<ComponentName> = emptyList(), + + /** + * App provided shortcut share intents (aka "direct share targets") + * + * Normally share shortcuts are published and consumed using + * [ShortcutManager][android.content.pm.ShortcutManager]. This is an alternate channel to allow + * apps to directly inject the same information. + * + * Historical note: This option was initially integrated with other results from the + * ChooserTargetService API (since deprecated and removed), hence the name and data format. + * These are more correctly called "Share Shortcuts" now. + */ + val callerChooserTargets: List<ChooserTarget> = emptyList(), + + /** + * Actions the user may perform. These are presented as separate affordances from the main list + * of choices. Selecting a choice is a terminal action which results in finishing. The item + * limit is [MAX_CHOOSER_ACTIONS]. This may be further constrained as appropriate. + */ + val chooserActions: List<ChooserAction> = emptyList(), + + /** + * An action to start an Activity which for user updating of shared content. Selection is a + * terminal action, closing the current activity and launching the target of the action. + */ + val modifyShareAction: ChooserAction? = null, + + /** + * When false the host activity will be [finished][android.app.Activity.finish] when stopped. + */ + @get:JvmName("shouldRetainInOnStop") val shouldRetainInOnStop: Boolean = false, + + /** + * Intents which contain alternate representations of the content being shared. Any results from + * resolving these _alternate_ intents are included with the results of the primary intent as + * additional choices (e.g. share as image content vs. link to content). + */ + val additionalTargets: List<Intent> = emptyList(), + + /** + * Alternate [extras][Intent.getExtras] to substitute when launching a selected app. + * + * For a given app (by package name), the Bundle describes what parameters to substitute when + * that app is selected. + * + * // TODO: Map<String, Bundle> + */ + val replacementExtras: Bundle? = null, + + /** + * App-supplied choices to be presented first in the list. + * + * Custom labels and icons may be supplied using + * [LabeledIntent][android.content.pm.LabeledIntent]. + * + * Limit 2. + */ + val initialIntents: List<Intent> = emptyList(), + + /** + * Provides for callers to be notified when a component is selected. + * + * The selection is reported in the Intent as [Intent.EXTRA_CHOSEN_COMPONENT] with the + * [ComponentName] of the item. + */ + val chosenComponentSender: IntentSender? = null, + + /** + * Provides a mechanism for callers to post-process a target when a selection is made. + * + * The received intent will contain: + * * **EXTRA_INTENT** The chosen target + * * **EXTRA_ALTERNATE_INTENTS** Additional intents which also match the target + * * **EXTRA_RESULT_RECEIVER** A [ResultReceiver][android.os.ResultReceiver] providing a + * mechanism for the caller to return information. An updated intent to send must be included + * as [Intent.EXTRA_INTENT]. + */ + val refinementIntentSender: IntentSender? = null, + + /** + * Contains the text content to share supplied by the source app. + * + * TODO: Constrain length? + */ + val sharedText: CharSequence? = null, + + /** + * Supplied to + * [ShortcutManager.getShareTargets][android.content.pm.ShortcutManager.getShareTargets] to + * query for matching shortcuts. Specifically, only the [dataTypes][IntentFilter.hasDataType] + * are considered for matching share shortcuts currently. + */ + val shareTargetFilter: IntentFilter? = null, + + /** A URI for additional content */ + val additionalContentUri: Uri? = null, + + /** Focused item index (from target intent's STREAM_EXTRA) */ + val focusedItemPosition: Int = 0, + + /** Value for [Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT] on the incoming chooser intent. */ + val contentTypeHint: ContentTypeHint = ContentTypeHint.NONE, + + /** + * Metadata to be shown to the user as a part of the sharesheet window. + * + * Specified by the [Intent.EXTRA_METADATA_TEXT] + */ + val metadataText: CharSequence? = null, +) { + val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority + + fun getReferrerFillInIntent(): Intent { + return Intent().apply { + referrerPackage?.also { pkg -> + putExtra(EXTRA_REFERRER, Uri.parse("$ANDROID_APP_SCHEME://$pkg")) + } + } + } + + val payloadIntents = listOf(targetIntent) + additionalTargets +} diff --git a/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt b/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt new file mode 100644 index 00000000..14177b1b --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/ChooserRequestRepository.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import com.android.intentresolver.contentpreview.payloadtoggle.data.model.CustomActionModel +import com.android.intentresolver.data.model.ChooserRequest +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow + +@ViewModelScoped +class ChooserRequestRepository +@Inject +constructor( + initialRequest: ChooserRequest, + initialActions: List<CustomActionModel>, +) { + /** All information from the sharing application pertaining to the chooser. */ + val chooserRequest: MutableStateFlow<ChooserRequest> = MutableStateFlow(initialRequest) + + /** Custom actions from the sharing app to be presented in the chooser. */ + // NOTE: this could be derived directly from chooserRequest, but that would require working + // directly with PendingIntents, which complicates testing. + val customActions: MutableStateFlow<List<CustomActionModel>> = MutableStateFlow(initialActions) +} diff --git a/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt new file mode 100644 index 00000000..eb35a358 --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/DevicePolicyResources.kt @@ -0,0 +1,153 @@ +/* + * 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.data.repository + +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_PERSONAL +import android.app.admin.DevicePolicyResources.Strings.Core.FORWARD_INTENT_TO_WORK +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_PERSONAL_TAB_ACCESSIBILITY +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PROFILE_NOT_SUPPORTED +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB +import android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_TAB_ACCESSIBILITY +import android.content.res.Resources +import androidx.annotation.OpenForTesting +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import javax.inject.Inject +import javax.inject.Singleton + +@OpenForTesting +@Singleton +open class DevicePolicyResources +@Inject +constructor( + @ApplicationOwned private val resources: Resources, + devicePolicyManager: DevicePolicyManager +) { + private val policyResources = devicePolicyManager.resources + + val personalTabLabel by lazy { + requireNotNull( + policyResources.getString(RESOLVER_PERSONAL_TAB) { + resources.getString(R.string.resolver_personal_tab) + } + ) + } + + val workTabLabel by lazy { + requireNotNull( + policyResources.getString(RESOLVER_WORK_TAB) { + resources.getString(R.string.resolver_work_tab) + } + ) + } + + val personalTabAccessibilityLabel by lazy { + requireNotNull( + policyResources.getString(RESOLVER_PERSONAL_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_personal_tab_accessibility) + } + ) + } + + val workTabAccessibilityLabel by lazy { + requireNotNull( + policyResources.getString(RESOLVER_WORK_TAB_ACCESSIBILITY) { + resources.getString(R.string.resolver_work_tab_accessibility) + } + ) + } + + val forwardToPersonalMessage: String? = + devicePolicyManager.resources.getString(FORWARD_INTENT_TO_PERSONAL) { + resources.getString(R.string.forward_intent_to_owner) + } + + val forwardToWorkMessage by lazy { + requireNotNull( + policyResources.getString(FORWARD_INTENT_TO_WORK) { + resources.getString(R.string.forward_intent_to_work) + } + ) + } + + val noPersonalApps by lazy { + requireNotNull( + policyResources.getString(RESOLVER_NO_PERSONAL_APPS) { + resources.getString(R.string.resolver_no_personal_apps_available) + } + ) + } + + val noWorkApps by lazy { + requireNotNull( + policyResources.getString(RESOLVER_NO_WORK_APPS) { + resources.getString(R.string.resolver_no_work_apps_available) + } + ) + } + + open val crossProfileBlocked by lazy { + requireNotNull( + policyResources.getString(RESOLVER_CROSS_PROFILE_BLOCKED_TITLE) { + resources.getString(R.string.resolver_cross_profile_blocked) + } + ) + } + + open fun toPersonalBlockedByPolicyMessage(share: Boolean): String { + return if (share) { + resources.getString(R.string.resolver_cant_share_with_personal_apps_explanation) + } else { + resources.getString(R.string.resolver_cant_access_personal_apps_explanation) + } + } + + open fun toWorkBlockedByPolicyMessage(share: Boolean): String { + return if (share) { + resources.getString(R.string.resolver_cant_share_with_work_apps_explanation) + } else { + resources.getString(R.string.resolver_cant_access_work_apps_explanation) + } + } + + open fun toPrivateBlockedByPolicyMessage(share: Boolean): String { + return if (share) { + resources.getString(R.string.resolver_cant_share_with_private_apps_explanation) + } else { + resources.getString(R.string.resolver_cant_access_private_apps_explanation) + } + } + + fun getWorkProfileNotSupportedMessage(launcherName: String): String { + return requireNotNull( + policyResources.getString( + RESOLVER_WORK_PROFILE_NOT_SUPPORTED, + { + resources.getString( + R.string.activity_resolver_work_profiles_support, + launcherName + ) + }, + launcherName + ) + ) + } +} diff --git a/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt b/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt new file mode 100644 index 00000000..753df93e --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/UserInfoExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.pm.UserInfo +import com.android.intentresolver.shared.model.User +import com.android.intentresolver.shared.model.User.Role + +/** Maps the UserInfo to one of the defined [Roles][User.Role], if possible. */ +fun UserInfo.getSupportedUserRole(): Role? = + when { + isFull -> Role.PERSONAL + isManagedProfile -> Role.WORK + isCloneProfile -> Role.CLONE + isPrivateProfile -> Role.PRIVATE + else -> null + } + +/** + * Creates a [User], based on values from a [UserInfo]. + * + * ``` + * val users: List<User> = + * getEnabledProfiles(user).map(::toUser).filterNotNull() + * ``` + * + * @return a [User] if the [UserInfo] matched a supported [Role], otherwise null + */ +fun UserInfo.toUser(): User? { + return getSupportedUserRole()?.let { role -> User(userHandle.identifier, role) } +} diff --git a/java/src/com/android/intentresolver/data/repository/UserRepository.kt b/java/src/com/android/intentresolver/data/repository/UserRepository.kt new file mode 100644 index 00000000..6b5ff4ba --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/UserRepository.kt @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.Intent +import android.content.Intent.ACTION_MANAGED_PROFILE_AVAILABLE +import android.content.Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE +import android.content.Intent.ACTION_PROFILE_ADDED +import android.content.Intent.ACTION_PROFILE_AVAILABLE +import android.content.Intent.ACTION_PROFILE_REMOVED +import android.content.Intent.ACTION_PROFILE_UNAVAILABLE +import android.content.Intent.EXTRA_QUIET_MODE +import android.content.Intent.EXTRA_USER +import android.content.IntentFilter +import android.content.pm.UserInfo +import android.os.Build +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.android.intentresolver.data.BroadcastSubscriber +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.Main +import com.android.intentresolver.inject.ProfileParent +import com.android.intentresolver.shared.model.User +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext + +interface UserRepository { + /** + * A [Flow] user profile groups. Each list contains the context user along with all members of + * the profile group. This includes the (Full) parent user, if the context user is a profile. + */ + val users: Flow<List<User>> + + /** + * A [Flow] of availability. Only profile users may become unavailable. + * + * Availability is currently defined as not being in [quietMode][UserInfo.isQuietModeEnabled]. + */ + val availability: Flow<Map<User, Boolean>> + + /** + * Request that availability be updated to the requested state. This currently includes toggling + * quiet mode as needed. This may involve additional background actions, such as starting or + * stopping a profile user (along with their many associated processes). + * + * If successful, the change will be applied after the call returns and can be observed using + * [UserRepository.availability] for the given user. + * + * No actions are taken if the user is already in requested state. + * + * @throws IllegalArgumentException if called for an unsupported user type + */ + suspend fun requestState(user: User, available: Boolean) +} + +private const val TAG = "UserRepository" + +/** The delay between entering the cached process state and entering the frozen cgroup */ +private val cachedProcessFreezeDelay: Duration = 10.seconds + +/** How long to continue listening for user state broadcasts while unsubscribed */ +private val stateFlowTimeout = cachedProcessFreezeDelay - 2.seconds + +/** How long to retain the previous user state after the state flow stops. */ +private val stateCacheTimeout = 2.seconds + +internal data class UserWithState(val user: User, val available: Boolean) + +internal typealias UserStates = List<UserWithState> + +internal val userBroadcastActions = + setOf( + ACTION_PROFILE_ADDED, + ACTION_PROFILE_REMOVED, + + // Quiet mode enabled/disabled for managed + // From: UserController.broadcastProfileAvailabilityChanges + // In response to setQuietModeEnabled + ACTION_MANAGED_PROFILE_AVAILABLE, // quiet mode, sent for manage profiles only + ACTION_MANAGED_PROFILE_UNAVAILABLE, // quiet mode, sent for manage profiles only + + // Quiet mode toggled for profile type, requires flag 'android.os.allow_private_profile + // true' + ACTION_PROFILE_AVAILABLE, // quiet mode, + ACTION_PROFILE_UNAVAILABLE, // quiet mode, sent for any profile type + ) + +/** Tracks and publishes state for the parent user and associated profiles. */ +class UserRepositoryImpl +@VisibleForTesting +constructor( + private val profileParent: UserHandle, + private val userManager: UserManager, + /** A flow of events which represent user-state changes from [UserManager]. */ + private val userEvents: Flow<UserEvent>, + scope: CoroutineScope, + private val backgroundDispatcher: CoroutineDispatcher, +) : UserRepository { + @Inject + constructor( + @ProfileParent profileParent: UserHandle, + userManager: UserManager, + @Main scope: CoroutineScope, + @Background background: CoroutineDispatcher, + broadcastSubscriber: BroadcastSubscriber, + ) : this( + profileParent, + userManager, + userEvents = + broadcastSubscriber.createFlow( + createFilter(userBroadcastActions), + profileParent, + Intent::toUserEvent + ), + scope, + background, + ) + + private fun debugLog(msg: () -> String) { + if (Build.IS_USERDEBUG || Build.IS_ENG) { + Log.d(TAG, msg()) + } + } + + private fun errorLog(msg: String, caught: Throwable? = null) { + Log.e(TAG, msg, caught) + } + + /** + * An exception which indicates that an inconsistency exists between the user state map and the + * rest of the system. + */ + private class UserStateException( + override val message: String, + val event: UserEvent, + override val cause: Throwable? = null, + ) : RuntimeException("$message: event=$event", cause) + + private val sharingScope = CoroutineScope(scope.coroutineContext + backgroundDispatcher) + private val usersWithState: Flow<UserStates> = + userEvents + .onStart { emit(Initialize) } + .onEach { debugLog { "userEvent: $it" } } + .runningFold(emptyList(), ::handleEvent) + .distinctUntilChanged() + .onEach { debugLog { "userStateList: $it" } } + .stateIn( + sharingScope, + started = + WhileSubscribed( + stopTimeoutMillis = stateFlowTimeout.inWholeMilliseconds, + replayExpirationMillis = 0 + /** Immediately on stop */ + ), + listOf() + ) + .filterNot { it.isEmpty() } + + private suspend fun handleEvent(users: UserStates, event: UserEvent): UserStates { + return try { + // Handle an action by performing some operation, then returning a new map + when (event) { + is Initialize -> createNewUserStates(profileParent) + is ProfileAdded -> handleProfileAdded(event, users) + is ProfileRemoved -> handleProfileRemoved(event, users) + is AvailabilityChange -> handleAvailability(event, users) + is UnknownEvent -> { + debugLog { "Unhandled event: $event)" } + users + } + } + } catch (e: UserStateException) { + errorLog("An error occurred handling an event: ${e.event}") + errorLog("Attempting to recover...", e) + createNewUserStates(profileParent) + } + } + + override val users: Flow<List<User>> = + usersWithState.map { userStates -> userStates.map { it.user } }.distinctUntilChanged() + + override val availability: Flow<Map<User, Boolean>> = + usersWithState + .map { list -> list.associate { it.user to it.available } } + .distinctUntilChanged() + + override suspend fun requestState(user: User, available: Boolean) { + return withContext(backgroundDispatcher) { + debugLog { "requestQuietModeEnabled: ${!available} for user $user" } + userManager.requestQuietModeEnabled(/* enableQuietMode = */ !available, user.handle) + } + } + + private fun List<UserWithState>.update(handle: UserHandle, user: UserWithState) = + filter { it.user.id != handle.identifier } + user + + private fun handleAvailability(event: AvailabilityChange, current: UserStates): UserStates { + val userEntry = + current.firstOrNull { it.user.id == event.user.identifier } + ?: throw UserStateException("User was not present in the map", event) + return current.update(event.user, userEntry.copy(available = !event.quietMode)) + } + + private fun handleProfileRemoved(event: ProfileRemoved, current: UserStates): UserStates { + if (!current.any { it.user.id == event.user.identifier }) { + throw UserStateException("User was not present in the map", event) + } + return current.filter { it.user.id != event.user.identifier } + } + + private suspend fun handleProfileAdded(event: ProfileAdded, current: UserStates): UserStates { + val user = + try { + requireNotNull(readUser(event.user)) + } catch (e: Exception) { + throw UserStateException("Failed to read user from UserManager", event, e) + } + return current + UserWithState(user, true) + } + + private suspend fun createNewUserStates(user: UserHandle): UserStates { + val profiles = readProfileGroup(user) + return profiles.mapNotNull { userInfo -> + userInfo.toUser()?.let { user -> UserWithState(user, userInfo.isAvailable()) } + } + } + + private suspend fun readProfileGroup(member: UserHandle): List<UserInfo> { + return withContext(backgroundDispatcher) { + @Suppress("DEPRECATION") userManager.getEnabledProfiles(member.identifier) + } + .toList() + } + + /** Read [UserInfo] from [UserManager], or null if not found or an unsupported type. */ + private suspend fun readUser(user: UserHandle): User? { + val userInfo = + withContext(backgroundDispatcher) { userManager.getUserInfo(user.identifier) } + return userInfo?.let { info -> + info.getSupportedUserRole()?.let { role -> User(info.id, role) } + } + } +} + +/** A Model representing changes to profiles and availability */ +sealed interface UserEvent + +/** Used as a an initial value to trigger a fetch of all profile data. */ +data object Initialize : UserEvent + +/** A profile was added to the profile group. */ +data class ProfileAdded( + /** The handle for the added profile. */ + val user: UserHandle, +) : UserEvent + +/** A profile was removed from the profile group. */ +data class ProfileRemoved( + /** The handle for the removed profile. */ + val user: UserHandle, +) : UserEvent + +/** A profile has changed availability. */ +data class AvailabilityChange( + /** THe handle for the profile with availability change. */ + val user: UserHandle, + /** The new quietMode state. */ + val quietMode: Boolean = false, +) : UserEvent + +/** An unhandled event, logged and ignored. */ +data class UnknownEvent( + /** The broadcast intent action received */ + val action: String?, +) : UserEvent + +/** Used with [broadcastFlow] to transform a UserManager broadcast action into a [UserEvent]. */ +internal fun Intent.toUserEvent(): UserEvent { + val action = action + val user = extras?.getParcelable(EXTRA_USER, UserHandle::class.java) + val quietMode = extras?.getBoolean(EXTRA_QUIET_MODE, false) + return when (action) { + ACTION_PROFILE_ADDED -> ProfileAdded(requireNotNull(user)) + ACTION_PROFILE_REMOVED -> ProfileRemoved(requireNotNull(user)) + ACTION_MANAGED_PROFILE_UNAVAILABLE, + ACTION_MANAGED_PROFILE_AVAILABLE, + ACTION_PROFILE_AVAILABLE, + ACTION_PROFILE_UNAVAILABLE -> + AvailabilityChange(requireNotNull(user), requireNotNull(quietMode)) + else -> UnknownEvent(action) + } +} + +internal fun createFilter(actions: Iterable<String>): IntentFilter { + return IntentFilter().apply { actions.forEach(::addAction) } +} + +internal fun UserInfo?.isAvailable(): Boolean { + return this?.isQuietModeEnabled != true +} diff --git a/java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt b/java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt new file mode 100644 index 00000000..7109d6d4 --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/UserRepositoryModule.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.Context +import android.os.UserHandle +import android.os.UserManager +import com.android.intentresolver.inject.ApplicationUser +import com.android.intentresolver.inject.ProfileParent +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface UserRepositoryModule { + companion object { + @Provides + @Singleton + @ApplicationUser + fun applicationUser(@ApplicationContext context: Context): UserHandle = context.user + + @Provides + @Singleton + @ProfileParent + fun profileParent( + @ApplicationContext context: Context, + userManager: UserManager + ): UserHandle { + return userManager.getProfileParent(context.user) ?: context.user + } + } + + @Binds @Singleton fun userRepository(impl: UserRepositoryImpl): UserRepository +} diff --git a/java/src/com/android/intentresolver/data/repository/UserScopedService.kt b/java/src/com/android/intentresolver/data/repository/UserScopedService.kt new file mode 100644 index 00000000..10a33eb1 --- /dev/null +++ b/java/src/com/android/intentresolver/data/repository/UserScopedService.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.data.repository + +import android.content.Context +import android.os.UserHandle +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlin.reflect.KClass + +/** + * Provides instances of a [system service][Context.getSystemService] created with + * [the context of a specified user][Context.createContextAsUser]. + * + * Some services which have only `@UserHandleAware` APIs operate on the user id available from + * [Context.getUser], the context used to retrieve the service. This utility helps adapt a per-user + * API model to work in multi-user manner. + * + * Example usage: + * ``` + * @Provides + * fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService<UserManager> { + * return UserScopedServiceImpl(ctx, UserManager::class) + * } + * + * class MyUserHelper @Inject constructor( + * private val userMgr: UserScopedService<UserManager>, + * ) { + * fun isPrivateProfile(user: UserHandle): UserManager { + * return userMgr.forUser(user).isPrivateProfile() + * } + * } + * ``` + */ +fun interface UserScopedService<T> { + /** Create a service instance for the given user. */ + fun forUser(user: UserHandle): T +} + +class UserScopedServiceImpl<T : Any>( + @ApplicationContext private val context: Context, + private val serviceType: KClass<T>, +) : UserScopedService<T> { + override fun forUser(user: UserHandle): T { + val context = + if (context.user == user) { + context + } else { + context.createContextAsUser(user, 0) + } + return requireNotNull(context.getSystemService(serviceType.java)) + } +} diff --git a/java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt b/java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt new file mode 100644 index 00000000..2392a48d --- /dev/null +++ b/java/src/com/android/intentresolver/domain/interactor/UserInteractor.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.domain.interactor + +import android.os.UserHandle +import com.android.intentresolver.data.repository.UserRepository +import com.android.intentresolver.inject.ApplicationUser +import com.android.intentresolver.shared.model.Profile +import com.android.intentresolver.shared.model.Profile.Type +import com.android.intentresolver.shared.model.User +import com.android.intentresolver.shared.model.User.Role +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +/** The high level User interface. */ +class UserInteractor +@Inject +constructor( + private val userRepository: UserRepository, + /** The specific [User] of the application which started this one. */ + @ApplicationUser val launchedAs: UserHandle, +) { + /** The profile group associated with the launching app user. */ + val profiles: Flow<List<Profile>> = + userRepository.users.map { users -> + users.mapNotNull { user -> + when (user.role) { + // PERSONAL includes CLONE + Role.PERSONAL -> { + Profile(Type.PERSONAL, user, users.firstOrNull { it.role == Role.CLONE }) + } + Role.CLONE -> { + /* ignore, included above */ + null + } + // others map 1:1 + else -> Profile(profileFromRole(user.role), user) + } + } + } + + /** The [Profile] of the application which started this one. */ + val launchedAsProfile: Flow<Profile> = + profiles.map { profiles -> + // The launching user profile is the one with a primary id or clone id + // matching the application user id. By definition there must always be exactly + // one matching profile for the current user. + profiles.single { + it.primary.id == launchedAs.identifier || it.clone?.id == launchedAs.identifier + } + } + /** + * Provides a flow to report on the availability of profile. An unavailable profile may be + * hidden or appear disabled within the app. + */ + val availability: Flow<Map<Profile, Boolean>> = + combine(profiles, userRepository.availability) { profiles, availability -> + profiles.associateWith { availability.getOrDefault(it.primary, false) } + } + + /** + * Request the profile state be updated. In the case of enabling, the operation could take + * significant time and/or require user input. + */ + suspend fun updateState(profile: Profile, available: Boolean) { + userRepository.requestState(profile.primary, available) + } + + private fun profileFromRole(role: Role): Type = + when (role) { + Role.PERSONAL -> Type.PERSONAL + Role.CLONE -> Type.PERSONAL /* CLONE maps to PERSONAL */ + Role.PRIVATE -> Type.PRIVATE + Role.WORK -> Type.WORK + } +} diff --git a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.kt index b9047712..05062a4b 100644 --- a/java/tests/src/com/android/intentresolver/TestFeatureFlagRepository.kt +++ b/java/src/com/android/intentresolver/emptystate/CompositeEmptyStateProvider.kt @@ -13,19 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.android.intentresolver.emptystate -package com.android.intentresolver +import com.android.intentresolver.ResolverListAdapter -import com.android.intentresolver.flags.FeatureFlagRepository -import com.android.systemui.flags.BooleanFlag -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag - -class TestFeatureFlagRepository( - private val overrides: Map<BooleanFlag, Boolean> -) : FeatureFlagRepository { - override fun isEnabled(flag: UnreleasedFlag): Boolean = getValue(flag) - override fun isEnabled(flag: ReleasedFlag): Boolean = getValue(flag) +/** + * Empty state provider that combines multiple providers. Providers earlier in the list have + * priority, that is if there is a provider that returns non-null empty state then all further + * providers will be ignored. + */ +class CompositeEmptyStateProvider( + private vararg val providers: EmptyStateProvider, +) : EmptyStateProvider { - private fun getValue(flag: BooleanFlag) = overrides.getOrDefault(flag, flag.default) + override fun getEmptyState(resolverListAdapter: ResolverListAdapter): EmptyState? { + return providers.firstNotNullOfOrNull { it.getEmptyState(resolverListAdapter) } + } } diff --git a/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java b/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java new file mode 100644 index 00000000..2164e533 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/CrossProfileIntentsChecker.java @@ -0,0 +1,59 @@ +/* + * 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.emptystate; + +import android.annotation.NonNull; +import android.annotation.UserIdInt; +import android.app.AppGlobals; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.pm.IPackageManager; + +import com.android.intentresolver.IntentForwarderActivity; + +import java.util.List; + +/** + * Utility class to check if there are cross profile intents, it is in a separate class so + * it could be mocked in tests + */ +public class CrossProfileIntentsChecker { + + private final ContentResolver mContentResolver; + private final IPackageManager mPackageManager; + + public CrossProfileIntentsChecker(@NonNull ContentResolver contentResolver) { + this(contentResolver, AppGlobals.getPackageManager()); + } + + CrossProfileIntentsChecker( + @NonNull ContentResolver contentResolver, IPackageManager packageManager) { + mContentResolver = contentResolver; + mPackageManager = packageManager; + } + + /** + * Returns {@code true} if at least one of the provided {@code intents} can be forwarded + * from {@code source} (user id) to {@code target} (user id). + */ + public boolean hasCrossProfileIntents( + List<Intent> intents, @UserIdInt int source, @UserIdInt int target) { + return intents.stream().anyMatch(intent -> + null != IntentForwarderActivity.canForward(intent, source, target, + mPackageManager, mContentResolver)); + } +} + diff --git a/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt b/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt new file mode 100644 index 00000000..ea1a03cc --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/DefaultEmptyState.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.emptystate + +class DefaultEmptyState : EmptyState { + override fun useDefaultEmptyView() = true +} diff --git a/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java b/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java new file mode 100644 index 00000000..1cbc6175 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/DevicePolicyBlockerEmptyState.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.app.admin.DevicePolicyEventLogger; + +import androidx.annotation.Nullable; + +/** + * Empty state that gets strings from the device policy manager and tracks events into + * event logger of the device policy events. + */ +public class DevicePolicyBlockerEmptyState implements EmptyState { + private final String mTitle; + private final String mSubtitle; + private final int mEventId; + private final String mEventCategory; + + public DevicePolicyBlockerEmptyState( + String title, + String subtitle, + int devicePolicyEventId, + String devicePolicyEventCategory) { + mTitle = title; + mSubtitle = subtitle; + mEventId = devicePolicyEventId; + mEventCategory = devicePolicyEventCategory; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Nullable + @Override + public String getSubtitle() { + return mSubtitle; + } + + @Override + public void onEmptyStateShown() { + if (mEventId != -1) { + DevicePolicyEventLogger.createEvent(mEventId) + .setStrings(mEventCategory) + .write(); + } + } + + @Override + public boolean shouldSkipDataRebuild() { + return true; + } +} diff --git a/java/src/com/android/intentresolver/emptystate/EmptyState.java b/java/src/com/android/intentresolver/emptystate/EmptyState.java new file mode 100644 index 00000000..cde99fe1 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/EmptyState.java @@ -0,0 +1,78 @@ +/* + * 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.emptystate; + +import android.annotation.Nullable; + +/** + * Model for the "empty state"/"blocker" UI to display instead of a profile tab's normal contents. + */ +public interface EmptyState { + /** + * Get the title to show on the empty state. + */ + @Nullable + default String getTitle() { + return null; + } + + /** + * Get the subtitle string to show underneath the title on the empty state. + */ + @Nullable + default String getSubtitle() { + return null; + } + + /** + * Get the handler for an optional button associated with this empty state. If the result is + * non-null, the empty-state UI will be built with a button that dispatches this handler. + */ + @Nullable + default ClickListener getButtonClickListener() { + return null; + } + + /** + * Get whether to show the default UI for the empty state. If true, the UI will show the default + * blocker text ('No apps can perform this action') and style; title and subtitle are ignored. + */ + default boolean useDefaultEmptyView() { + return false; + } + + /** + * Returns true if for this empty state we should skip rebuilding of the apps list + * for this tab. + */ + default boolean shouldSkipDataRebuild() { + return false; + } + + /** + * Called when empty state is shown, could be used e.g. to track analytics events. + */ + default void onEmptyStateShown() {} + + interface ClickListener { + void onClick(TabControl currentTab); + } + + interface TabControl { + void showSpinner(); + } +} diff --git a/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java new file mode 100644 index 00000000..c3261287 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/EmptyStateProvider.java @@ -0,0 +1,37 @@ +/* + * 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.emptystate; + +import android.annotation.Nullable; + +import com.android.intentresolver.ResolverListAdapter; + +/** + * Returns an empty state to show for the current profile page (tab) if necessary. + * This could be used e.g. to show a blocker on a tab if device management policy doesn't + * allow to use it or there are no apps available. + */ +public interface EmptyStateProvider { + /** + * When a non-null empty state is returned the corresponding profile page will show + * this empty state + * @param resolverListAdapter the current adapter + */ + @Nullable + default EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + return null; + } +} diff --git a/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java new file mode 100644 index 00000000..7524f343 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/EmptyStateUiHelper.java @@ -0,0 +1,136 @@ +/* + * 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.emptystate; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Helper for building `MultiProfilePagerAdapter` tab UIs for profile tabs that are "blocked" by + * some empty-state status. + */ +public class EmptyStateUiHelper { + private final Supplier<Optional<Integer>> mContainerBottomPaddingOverrideSupplier; + private final View mEmptyStateView; + private final View mListView; + private final View mEmptyStateContainerView; + private final TextView mEmptyStateTitleView; + private final TextView mEmptyStateSubtitleView; + private final Button mEmptyStateButtonView; + private final View mEmptyStateProgressView; + private final View mEmptyStateEmptyView; + + public EmptyStateUiHelper( + ViewGroup rootView, + int listViewResourceId, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + mContainerBottomPaddingOverrideSupplier = containerBottomPaddingOverrideSupplier; + mEmptyStateView = + rootView.requireViewById(com.android.internal.R.id.resolver_empty_state); + mListView = rootView.requireViewById(listViewResourceId); + mEmptyStateContainerView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_container); + mEmptyStateTitleView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_title); + mEmptyStateSubtitleView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_subtitle); + mEmptyStateButtonView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_button); + mEmptyStateProgressView = mEmptyStateView.requireViewById( + com.android.internal.R.id.resolver_empty_state_progress); + mEmptyStateEmptyView = mEmptyStateView.requireViewById(com.android.internal.R.id.empty); + } + + /** + * Display the described empty state. + * @param emptyState the data describing the cause of this empty-state condition. + * @param buttonOnClick handler for a button that the user might be able to use to circumvent + * the empty-state condition. If null, no button will be displayed. + */ + public void showEmptyState(EmptyState emptyState, View.OnClickListener buttonOnClick) { + resetViewVisibilities(); + setupContainerPadding(); + + String title = emptyState.getTitle(); + if (title != null) { + mEmptyStateTitleView.setVisibility(View.VISIBLE); + mEmptyStateTitleView.setText(title); + } else { + mEmptyStateTitleView.setVisibility(View.GONE); + } + + String subtitle = emptyState.getSubtitle(); + if (subtitle != null) { + mEmptyStateSubtitleView.setVisibility(View.VISIBLE); + mEmptyStateSubtitleView.setText(subtitle); + } else { + mEmptyStateSubtitleView.setVisibility(View.GONE); + } + + mEmptyStateEmptyView.setVisibility( + emptyState.useDefaultEmptyView() ? View.VISIBLE : View.GONE); + // TODO: The EmptyState API says that if `useDefaultEmptyView()` is true, we'll ignore the + // state's specified title/subtitle; where (if anywhere) is that implemented? + + mEmptyStateButtonView.setVisibility(buttonOnClick != null ? View.VISIBLE : View.GONE); + mEmptyStateButtonView.setOnClickListener(buttonOnClick); + + // Don't show the main list view when we're showing an empty state. + mListView.setVisibility(View.GONE); + } + + /** Sets up the padding of the view containing the empty state screens. */ + public void setupContainerPadding() { + Optional<Integer> bottomPaddingOverride = mContainerBottomPaddingOverrideSupplier.get(); + bottomPaddingOverride.ifPresent(paddingBottom -> + mEmptyStateContainerView.setPadding( + mEmptyStateContainerView.getPaddingLeft(), + mEmptyStateContainerView.getPaddingTop(), + mEmptyStateContainerView.getPaddingRight(), + paddingBottom)); + } + + public void showSpinner() { + mEmptyStateTitleView.setVisibility(View.INVISIBLE); + // TODO: subtitle? + mEmptyStateButtonView.setVisibility(View.INVISIBLE); + mEmptyStateProgressView.setVisibility(View.VISIBLE); + mEmptyStateEmptyView.setVisibility(View.GONE); + } + + public void hide() { + mEmptyStateView.setVisibility(View.GONE); + mListView.setVisibility(View.VISIBLE); + } + + // TODO: this is exposed for testing so we can thoroughly prepare initial conditions that let us + // observe the resulting change. In reality it's only invoked as part of `showEmptyState()` and + // we could consider setting up narrower "realistic" preconditions to make assertions about the + // higher-level operation. + public void resetViewVisibilities() { + mEmptyStateTitleView.setVisibility(View.VISIBLE); + mEmptyStateSubtitleView.setVisibility(View.VISIBLE); + mEmptyStateButtonView.setVisibility(View.INVISIBLE); + mEmptyStateProgressView.setVisibility(View.GONE); + mEmptyStateEmptyView.setVisibility(View.GONE); + mEmptyStateView.setVisibility(View.VISIBLE); + } +} diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java new file mode 100644 index 00000000..b03c730a --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyState.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.app.admin.DevicePolicyEventLogger; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import androidx.annotation.NonNull; + +public class NoAppsAvailableEmptyState implements EmptyState { + + @NonNull + private final String mTitle; + + @NonNull + private final String mMetricsCategory; + + private final boolean mIsPersonalProfile; + + public NoAppsAvailableEmptyState(@NonNull String title, @NonNull String metricsCategory, + boolean isPersonalProfile) { + mTitle = title; + mMetricsCategory = metricsCategory; + mIsPersonalProfile = isPersonalProfile; + } + + @NonNull + @Override + public String getTitle() { + return mTitle; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger.createEvent( + DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) + .setStrings(mMetricsCategory) + .setBoolean(/*isPersonalProfile*/ mIsPersonalProfile) + .write(); + } +} diff --git a/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java new file mode 100644 index 00000000..b3d3e343 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/NoAppsAvailableEmptyStateProvider.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + + +import static com.android.intentresolver.shared.model.Profile.Type.PERSONAL; + +import static java.util.Objects.requireNonNull; + +import android.os.UserHandle; + +import androidx.annotation.NonNull; + +import com.android.intentresolver.ProfileAvailability; +import com.android.intentresolver.ProfileHelper; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.shared.model.Profile; +import com.android.intentresolver.ui.ProfilePagerResources; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * there are no apps available. + */ +public class NoAppsAvailableEmptyStateProvider implements EmptyStateProvider { + + @NonNull private final String mMetricsCategory; + private final ProfilePagerResources mProfilePagerResources; + private final ProfileHelper mProfileHelper; + private final ProfileAvailability mProfileAvailability; + + public NoAppsAvailableEmptyStateProvider( + ProfileHelper profileHelper, + ProfileAvailability profileAvailability, + @NonNull String metricsCategory, + ProfilePagerResources profilePagerResources) { + mProfileHelper = profileHelper; + mProfileAvailability = profileAvailability; + mMetricsCategory = metricsCategory; + mProfilePagerResources = profilePagerResources; + } + + @NonNull + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + UserHandle listUserHandle = resolverListAdapter.getUserHandle(); + if (mProfileAvailability.visibleProfileCount() == 1) { + return new DefaultEmptyState(); + } else { + Profile.Type profileType = + requireNonNull(mProfileHelper.findProfileType(listUserHandle)); + String title = mProfilePagerResources.noAppsMessage(profileType); + return new NoAppsAvailableEmptyState( + title, + mMetricsCategory, + /* isPersonalProfile= */ profileType == PERSONAL + ); + } + } +} diff --git a/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java new file mode 100644 index 00000000..0cf2ea45 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/NoCrossProfileEmptyStateProvider.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import static android.stats.devicepolicy.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; +import static android.stats.devicepolicy.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; + +import static com.android.intentresolver.ChooserActivity.METRICS_CATEGORY_CHOOSER; + +import static java.util.Objects.requireNonNull; + +import android.content.Intent; + +import androidx.annotation.Nullable; + +import com.android.intentresolver.ProfileHelper; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.data.repository.DevicePolicyResources; +import com.android.intentresolver.shared.model.Profile; + +import java.util.List; + +/** + * Empty state provider that informs about a lack of cross profile sharing. It will return + * an empty state in case there are no intents which can be forwarded to another profile. + */ +public class NoCrossProfileEmptyStateProvider implements EmptyStateProvider { + + private final ProfileHelper mProfileHelper; + private final DevicePolicyResources mDevicePolicyResources; + private final boolean mIsShare; + private final CrossProfileIntentsChecker mCrossProfileIntentsChecker; + + public NoCrossProfileEmptyStateProvider( + ProfileHelper profileHelper, + DevicePolicyResources devicePolicyResources, + CrossProfileIntentsChecker crossProfileIntentsChecker, + boolean isShare) { + mProfileHelper = profileHelper; + mDevicePolicyResources = devicePolicyResources; + mIsShare = isShare; + mCrossProfileIntentsChecker = crossProfileIntentsChecker; + } + + private boolean hasCrossProfileIntents(List<Intent> intents, Profile source, Profile target) { + if (source.getPrimary().getHandle().equals(target.getPrimary().getHandle())) { + return true; + } + // Note: Use of getPrimary() here also handles delegation of CLONE profile to parent. + return mCrossProfileIntentsChecker.hasCrossProfileIntents(intents, + source.getPrimary().getId(), target.getPrimary().getId()); + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter adapter) { + Profile launchedBy = mProfileHelper.getLaunchedAsProfile(); + Profile tabOwner = requireNonNull(mProfileHelper.findProfile(adapter.getUserHandle())); + + // When sharing into or out of Private profile, perform the check using the parent profile + // instead. (Hard-coded application of CROSS_PROFILE_CONTENT_SHARING_DELEGATE_FROM_PARENT) + + Profile effectiveSource = launchedBy; + Profile effectiveTarget = tabOwner; + + // Assumption baked into design: "Personal" profile is the parent of all other profiles. + if (launchedBy.getType() == Profile.Type.PRIVATE) { + effectiveSource = mProfileHelper.getPersonalProfile(); + } + + if (tabOwner.getType() == Profile.Type.PRIVATE) { + effectiveTarget = mProfileHelper.getPersonalProfile(); + } + + // Allow access to the tab when there is at least one target permitted to cross profiles. + if (hasCrossProfileIntents(adapter.getIntents(), effectiveSource, effectiveTarget)) { + return null; + } + + switch (tabOwner.getType()) { + case PERSONAL: + return new DevicePolicyBlockerEmptyState( + mDevicePolicyResources.getCrossProfileBlocked(), + mDevicePolicyResources.toPersonalBlockedByPolicyMessage(mIsShare), + RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL, + METRICS_CATEGORY_CHOOSER); + + case WORK: + return new DevicePolicyBlockerEmptyState( + mDevicePolicyResources.getCrossProfileBlocked(), + mDevicePolicyResources.toWorkBlockedByPolicyMessage(mIsShare), + RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK, + METRICS_CATEGORY_CHOOSER); + + case PRIVATE: + return new DevicePolicyBlockerEmptyState( + mDevicePolicyResources.getCrossProfileBlocked(), + mDevicePolicyResources.toPrivateBlockedByPolicyMessage(mIsShare), + /* Suppress log event. TODO: Define a new metrics event for this? */ -1, + METRICS_CATEGORY_CHOOSER); + } + return null; + } +} diff --git a/java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java b/java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java new file mode 100644 index 00000000..e9de3221 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/WorkProfileOffEmptyState.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import android.app.admin.DevicePolicyEventLogger; +import android.stats.devicepolicy.nano.DevicePolicyEnums; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class WorkProfileOffEmptyState implements EmptyState { + + private final String mTitle; + private final ClickListener mOnClick; + private final String mMetricsCategory; + + public WorkProfileOffEmptyState(String title, @NonNull ClickListener onClick, + @NonNull String metricsCategory) { + mTitle = title; + mOnClick = onClick; + mMetricsCategory = metricsCategory; + } + + @Nullable + @Override + public String getTitle() { + return mTitle; + } + + @Nullable + @Override + public ClickListener getButtonClickListener() { + return mOnClick; + } + + @Override + public void onEmptyStateShown() { + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) + .setStrings(mMetricsCategory) + .write(); + } +} diff --git a/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java new file mode 100644 index 00000000..f78d1ca2 --- /dev/null +++ b/java/src/com/android/intentresolver/emptystate/WorkProfilePausedEmptyStateProvider.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.emptystate; + +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; + +import static java.util.Objects.requireNonNull; + +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ProfileAvailability; +import com.android.intentresolver.ProfileHelper; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.profiles.OnSwitchOnWorkSelectedListener; +import com.android.intentresolver.shared.model.Profile; + +/** + * Chooser/ResolverActivity empty state provider that returns empty state which is shown when + * work profile is paused and we need to show a button to enable it. + */ +public class WorkProfilePausedEmptyStateProvider implements EmptyStateProvider { + + private final ProfileHelper mProfileHelper; + private final ProfileAvailability mProfileAvailability; + private final String mMetricsCategory; + private final OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + private final Context mContext; + + public WorkProfilePausedEmptyStateProvider(@NonNull Context context, + ProfileHelper profileHelper, + ProfileAvailability profileAvailability, + @Nullable OnSwitchOnWorkSelectedListener onSwitchOnWorkSelectedListener, + @NonNull String metricsCategory) { + mContext = context; + mProfileHelper = profileHelper; + mProfileAvailability = profileAvailability; + mMetricsCategory = metricsCategory; + mOnSwitchOnWorkSelectedListener = onSwitchOnWorkSelectedListener; + } + + @Nullable + @Override + public EmptyState getEmptyState(ResolverListAdapter resolverListAdapter) { + UserHandle userHandle = resolverListAdapter.getUserHandle(); + if (!mProfileHelper.getWorkProfilePresent()) { + return null; + } + Profile workProfile = requireNonNull(mProfileHelper.getWorkProfile()); + + // Policy: only show the "Work profile paused" state when: + // * provided list adapter is from the work profile + // * the list adapter is not empty + // * work profile quiet mode is _enabled_ (unavailable) + + if (!userHandle.equals(workProfile.getPrimary().getHandle()) + || resolverListAdapter.getCount() == 0 + || mProfileAvailability.isAvailable(workProfile)) { + return null; + } + + String title = mContext.getSystemService(DevicePolicyManager.class) + .getResources().getString(RESOLVER_WORK_PAUSED_TITLE, + () -> mContext.getString(R.string.resolver_turn_on_work_apps)); + + return new WorkProfileOffEmptyState(title, /* EmptyState.ClickListener */ (tab) -> { + tab.showSpinner(); + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + mProfileAvailability.requestQuietModeState(workProfile, false); + }, mMetricsCategory); + } + +} diff --git a/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt new file mode 100644 index 00000000..2ba08c90 --- /dev/null +++ b/java/src/com/android/intentresolver/ext/CreationExtrasExt.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ext + +import android.os.Bundle +import android.os.Parcelable +import androidx.core.os.bundleOf +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras + +/** + * Returns a new instance with additional [values] added to the existing default args Bundle (if + * present), otherwise adds a new entry with a copy of this bundle. + */ +fun CreationExtras.addDefaultArgs(vararg values: Pair<String, Parcelable>): CreationExtras { + val defaultArgs: Bundle = get(DEFAULT_ARGS_KEY) ?: Bundle() + defaultArgs.putAll(bundleOf(*values)) + return MutableCreationExtras(this).apply { set(DEFAULT_ARGS_KEY, defaultArgs) } +} diff --git a/java/src/com/android/intentresolver/ext/IntentExt.kt b/java/src/com/android/intentresolver/ext/IntentExt.kt new file mode 100644 index 00000000..127dbf86 --- /dev/null +++ b/java/src/com/android/intentresolver/ext/IntentExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ext + +import android.content.Intent +import java.util.function.Predicate + +/** Applies an operation on this Intent if matches the given filter. */ +inline fun Intent.ifMatch( + predicate: Predicate<Intent>, + crossinline block: Intent.() -> Unit +): Intent { + if (predicate.test(this)) { + apply(block) + } + return this +} + +/** True if the Intent has one of the specified actions. */ +fun Intent.hasAction(vararg actions: String): Boolean = action in actions + +/** True if the Intent has a specific component target */ +fun Intent.hasComponent(): Boolean = (component != null) + +/** True if the Intent has a single matching category. */ +fun Intent.hasSingleCategory(category: String) = categories.singleOrNull() == category + +/** True if the Intent is a SEND or SEND_MULTIPLE action. */ +fun Intent.hasSendAction() = hasAction(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE) + +/** True if the Intent resolves to the special Home (Launcher) component */ +fun Intent.isHomeIntent() = hasAction(Intent.ACTION_MAIN) && hasSingleCategory(Intent.CATEGORY_HOME) diff --git a/java/src/com/android/intentresolver/ext/ParcelExt.kt b/java/src/com/android/intentresolver/ext/ParcelExt.kt new file mode 100644 index 00000000..68ea600f --- /dev/null +++ b/java/src/com/android/intentresolver/ext/ParcelExt.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ext + +import android.os.Parcel + +inline fun <reified T> Parcel.requireParcelable(): T { + return requireNotNull(readParcelable<T>()) { "A non-value required from this parcel was null!" } +} + +inline fun <reified T> Parcel.readParcelable(): T? { + return readParcelable(T::class.java.classLoader, T::class.java) +} diff --git a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt b/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt deleted file mode 100644 index d1494fe7..00000000 --- a/java/src/com/android/intentresolver/flags/DeviceConfigProxy.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import android.provider.DeviceConfig -import com.android.systemui.flags.ParcelableFlag - -internal class DeviceConfigProxy { - fun isEnabled(flag: ParcelableFlag<Boolean>): Boolean? { - return runCatching { - val hasProperty = DeviceConfig.getProperty(flag.namespace, flag.name) != null - if (hasProperty) { - DeviceConfig.getBoolean(flag.namespace, flag.name, flag.default) - } else { - null - } - }.getOrDefault(null) - } -} diff --git a/java/src/com/android/intentresolver/flags/Flags.kt b/java/src/com/android/intentresolver/flags/Flags.kt deleted file mode 100644 index b303dd1a..00000000 --- a/java/src/com/android/intentresolver/flags/Flags.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.flags - -import com.android.systemui.flags.ReleasedFlag -import com.android.systemui.flags.UnreleasedFlag - -// Flag id, name and namespace should be kept in sync with [com.android.systemui.flags.Flags] to -// make the flags available in the flag flipper app (see go/sysui-flags). -// All flags added should be included in UnbundledChooserActivityTest.ALL_FLAGS. -object Flags { - private fun releasedFlag(id: Int, name: String) = - ReleasedFlag(id, name, "systemui") - - private fun unreleasedFlag(id: Int, name: String, teamfood: Boolean = false) = - UnreleasedFlag(id, name, "systemui", teamfood) -} diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index 77ae20f5..7cf9d2e9 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -32,12 +32,14 @@ import android.view.animation.DecelerateInterpolator; import android.widget.Space; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.FeatureFlags; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter.ViewHolder; -import com.android.internal.annotations.VisibleForTesting; import com.google.android.collect.Lists; @@ -47,7 +49,6 @@ import com.google.android.collect.Lists; * row level by this adapter but not on the item level. Individual targets within the row are * handled by {@link ChooserListAdapter} */ -@VisibleForTesting public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { /** @@ -65,15 +66,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. * out of `ChooserGridAdapter` altogether. */ public interface ChooserActivityDelegate { - /** @return whether we're showing a tabbed (multi-profile) UI. */ - boolean shouldShowTabs(); - - /** - * @return a content preview {@link View} that's appropriate for the caller's share - * content, constructed for display in the provided {@code parent} group. - */ - View buildContentPreview(ViewGroup parent); - /** Notify the client that the item with the selected {@code itemIndex} was selected. */ void onTargetSelected(int itemIndex); @@ -82,19 +74,10 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. * long-pressed. */ void onTargetLongPressed(int itemIndex); - - /** - * Notify the client that the provided {@code View} should be configured as the new - * "profile view" button. Callers should attach their own click listeners to implement - * behaviors on this view. - */ - void updateProfileViewButton(View newButtonFromProfileRow); } private static final int VIEW_TYPE_DIRECT_SHARE = 0; private static final int VIEW_TYPE_NORMAL = 1; - private static final int VIEW_TYPE_CONTENT_PREVIEW = 2; - private static final int VIEW_TYPE_PROFILE = 3; private static final int VIEW_TYPE_AZ_LABEL = 4; private static final int VIEW_TYPE_CALLER_AND_RANK = 5; private static final int VIEW_TYPE_FOOTER = 6; @@ -107,6 +90,9 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. private final boolean mShouldShowContentPreview; private final int mChooserWidthPixels; private final int mChooserRowTextOptionTranslatePixelSize; + private final FeatureFlags mFeatureFlags; + @Nullable + private RecyclerView mRecyclerView; private int mChooserTargetWidth = 0; @@ -119,7 +105,8 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. ChooserActivityDelegate chooserActivityDelegate, ChooserListAdapter wrappedAdapter, boolean shouldShowContentPreview, - int maxTargetsPerRow) { + int maxTargetsPerRow, + FeatureFlags featureFlags) { super(); mChooserActivityDelegate = chooserActivityDelegate; @@ -133,6 +120,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. mChooserWidthPixels = context.getResources().getDimensionPixelSize(R.dimen.chooser_width); mChooserRowTextOptionTranslatePixelSize = context.getResources().getDimensionPixelSize( R.dimen.chooser_row_text_option_translate); + mFeatureFlags = featureFlags; wrappedAdapter.registerDataSetObserver(new DataSetObserver() { @Override @@ -149,8 +137,25 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. }); } + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + mRecyclerView = recyclerView; + } + + @Override + public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { + mRecyclerView = null; + } + public void setFooterHeight(int height) { - mFooterHeight = height; + if (mFooterHeight != height) { + mFooterHeight = height; + if (mFeatureFlags.fixTargetListFooter()) { + // we always have at least one view, the footer, see getItemCount() and + // getFooterRowCount() + notifyItemChanged(getItemCount() - 1); + } + } } /** @@ -164,8 +169,10 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. return false; } - // Limit width to the maximum width of the chooser activity - width = Math.min(mChooserWidthPixels, width); + // 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) { @@ -178,9 +185,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. public int getRowCount() { return (int) ( - getSystemRowCount() - + getProfileRowCount() - + getServiceTargetRowCount() + getServiceTargetRowCount() + getCallerAndRankedTargetRowCount() + getAzLabelRowCount() + Math.ceil( @@ -189,35 +194,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. ); } - /** - * Whether the "system" row of targets is displayed. - * This area includes the content preview (if present) and action row. - */ - public int getSystemRowCount() { - // For the tabbed case we show the sticky content preview above the tabs, - // please refer to shouldShowStickyContentPreview - if (mChooserActivityDelegate.shouldShowTabs()) { - return 0; - } - - if (!mShouldShowContentPreview) { - return 0; - } - - if (mChooserListAdapter == null || mChooserListAdapter.getCount() == 0) { - return 0; - } - - return 1; - } - - public int getProfileRowCount() { - if (mChooserActivityDelegate.shouldShowTabs()) { - return 0; - } - return mChooserListAdapter.getOtherProfile() == null ? 0 : 1; - } - public int getFooterRowCount() { return 1; } @@ -248,38 +224,23 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. return -1; } - return getSystemRowCount() - + getProfileRowCount() - + getServiceTargetRowCount() + return getServiceTargetRowCount() + getCallerAndRankedTargetRowCount(); } @Override public int getItemCount() { - return getSystemRowCount() - + getProfileRowCount() - + getServiceTargetRowCount() + return getServiceTargetRowCount() + getCallerAndRankedTargetRowCount() + getAzLabelRowCount() + mChooserListAdapter.getAlphaTargetCount() + getFooterRowCount(); } + @NonNull @Override - public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { switch (viewType) { - case VIEW_TYPE_CONTENT_PREVIEW: - return new ItemViewHolder( - mChooserActivityDelegate.buildContentPreview(parent), - viewType, - null, - null); - case VIEW_TYPE_PROFILE: - return new ItemViewHolder( - createProfileView(parent), - viewType, - null, - null); case VIEW_TYPE_AZ_LABEL: return new ItemViewHolder( createAzLabelView(parent), @@ -302,7 +263,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. return new FooterViewHolder(sp, viewType); default: // Since we catch all possible viewTypes above, no chance this is being called. - return null; + throw new IllegalStateException("unmatched view type"); } } @@ -316,6 +277,15 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. mAzLabelVisibility = isVisible; int azRowPos = getAzLabelRowPosition(); if (azRowPos >= 0) { + if (mRecyclerView != null) { + for (int i = 0, size = mRecyclerView.getChildCount(); i < size; i++) { + View child = mRecyclerView.getChildAt(i); + if (mRecyclerView.getChildAdapterPosition(child) == azRowPos) { + child.setVisibility(isVisible ? View.VISIBLE : View.GONE); + } + } + return; + } notifyItemChanged(azRowPos); } } @@ -341,13 +311,8 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. @Override public int getItemViewType(int position) { - int count; - - int countSum = (count = getSystemRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW; - - countSum += (count = getProfileRowCount()); - if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE; + int count = 0; + int countSum = count; countSum += (count = getServiceTargetRowCount()); if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE; @@ -367,12 +332,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. return mChooserListAdapter.getPositionTargetType(getListPosition(position)); } - private View createProfileView(ViewGroup parent) { - View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false); - mChooserActivityDelegate.updateProfileViewButton(profileRow); - return profileRow; - } - private View createAzLabelView(ViewGroup parent) { return mLayoutInflater.inflate(R.layout.chooser_az_label_row, parent, false); } @@ -550,8 +509,6 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. } int getListPosition(int position) { - position -= getSystemRowCount() + getProfileRowCount(); - final int serviceCount = mChooserListAdapter.getServiceTargetCount(); final int serviceRows = (int) Math.ceil((float) serviceCount / mMaxTargetsPerRow); if (position < serviceRows) { diff --git a/java/src/com/android/intentresolver/icon/ComposeIcon.kt b/java/src/com/android/intentresolver/icon/ComposeIcon.kt new file mode 100644 index 00000000..dbea1e55 --- /dev/null +++ b/java/src/com/android/intentresolver/icon/ComposeIcon.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.icon + +import android.content.ContentResolver +import android.content.pm.PackageManager +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.Icon +import java.io.File +import java.io.FileInputStream + +sealed interface ComposeIcon + +data class BitmapIcon(val bitmap: Bitmap) : ComposeIcon + +data class ResourceIcon(val resId: Int, val res: Resources) : ComposeIcon + +@JvmInline value class AdaptiveIcon(val wrapped: ComposeIcon) : ComposeIcon + +fun Icon.toComposeIcon(pm: PackageManager, resolver: ContentResolver): ComposeIcon? { + return when (type) { + Icon.TYPE_BITMAP -> BitmapIcon(bitmap) + Icon.TYPE_RESOURCE -> pm.resourcesForPackage(resPackage)?.let { ResourceIcon(resId, it) } + Icon.TYPE_DATA -> + BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength)) + Icon.TYPE_URI -> uriIcon(resolver) + Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap)) + Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) } + else -> error("unexpected icon type: $type") + } +} + +fun Icon.toComposeIcon(resources: Resources?, resolver: ContentResolver): ComposeIcon? { + return when (type) { + Icon.TYPE_BITMAP -> BitmapIcon(bitmap) + Icon.TYPE_RESOURCE -> resources?.let { ResourceIcon(resId, resources) } + Icon.TYPE_DATA -> + BitmapIcon(BitmapFactory.decodeByteArray(dataBytes, dataOffset, dataLength)) + Icon.TYPE_URI -> uriIcon(resolver) + Icon.TYPE_ADAPTIVE_BITMAP -> AdaptiveIcon(BitmapIcon(bitmap)) + Icon.TYPE_URI_ADAPTIVE_BITMAP -> uriIcon(resolver)?.let { AdaptiveIcon(it) } + else -> error("unexpected icon type: $type") + } +} + +// TODO: this is probably constant and doesn't need to be re-queried for each icon +fun PackageManager.resourcesForPackage(pkgName: String): Resources? { + return if (pkgName == "android") { + Resources.getSystem() + } else { + runCatching { + this@resourcesForPackage.getApplicationInfo( + pkgName, + PackageManager.MATCH_UNINSTALLED_PACKAGES or + PackageManager.GET_SHARED_LIBRARY_FILES + ) + } + .getOrNull() + ?.let { ai -> getResourcesForApplication(ai) } + } +} + +private fun Icon.uriIcon(resolver: ContentResolver): BitmapIcon? { + return runCatching { + when (uri.scheme) { + ContentResolver.SCHEME_CONTENT, + ContentResolver.SCHEME_FILE -> resolver.openInputStream(uri) + else -> FileInputStream(File(uriString)) + } + } + .getOrNull() + ?.let { inStream -> BitmapIcon(BitmapFactory.decodeStream(inStream)) } +} diff --git a/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt new file mode 100644 index 00000000..b3054231 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/CachingTargetDataLoader.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.icons + +import android.content.ComponentName +import android.graphics.drawable.Drawable +import android.os.UserHandle +import androidx.collection.LruCache +import com.android.intentresolver.chooser.DisplayResolveInfo +import com.android.intentresolver.chooser.SelectableTargetInfo +import java.util.function.Consumer +import javax.annotation.concurrent.GuardedBy +import javax.inject.Qualifier + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.BINARY) annotation class Caching + +private typealias IconCache = LruCache<ComponentName, Drawable> + +class CachingTargetDataLoader( + private val targetDataLoader: TargetDataLoader, + private val cacheSize: Int = 100, +) : TargetDataLoader() { + @GuardedBy("self") private val perProfileIconCache = HashMap<UserHandle, IconCache>() + + override fun getOrLoadAppTargetIcon( + info: DisplayResolveInfo, + userHandle: UserHandle, + callback: Consumer<Drawable> + ): Drawable? { + val cacheKey = info.toCacheKey() + return getCachedAppIcon(cacheKey, userHandle) + ?: targetDataLoader.getOrLoadAppTargetIcon(info, userHandle) { drawable -> + getProfileIconCache(userHandle).put(cacheKey, drawable) + callback.accept(drawable) + } + } + + override fun loadDirectShareIcon( + info: SelectableTargetInfo, + userHandle: UserHandle, + callback: Consumer<Drawable> + ) = targetDataLoader.loadDirectShareIcon(info, userHandle, callback) + + override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) = + targetDataLoader.loadLabel(info, callback) + + override fun getOrLoadLabel(info: DisplayResolveInfo) = targetDataLoader.getOrLoadLabel(info) + + private fun getCachedAppIcon(component: ComponentName, userHandle: UserHandle): Drawable? = + getProfileIconCache(userHandle)[component] + + private fun getProfileIconCache(userHandle: UserHandle): IconCache = + synchronized(perProfileIconCache) { + perProfileIconCache.getOrPut(userHandle) { IconCache(cacheSize) } + } + + private fun DisplayResolveInfo.toCacheKey() = + ComponentName( + resolveInfo.activityInfo.packageName, + resolveInfo.activityInfo.name, + ) +} diff --git a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt index 0e4d0209..1a724d73 100644 --- a/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/DefaultTargetDataLoader.kt @@ -18,7 +18,6 @@ package com.android.intentresolver.icons import android.app.ActivityManager import android.content.Context -import android.content.pm.ResolveInfo import android.graphics.drawable.Drawable import android.os.AsyncTask import android.os.UserHandle @@ -63,11 +62,11 @@ class DefaultTargetDataLoader( ) } - override fun loadAppTargetIcon( + override fun getOrLoadAppTargetIcon( info: DisplayResolveInfo, userHandle: UserHandle, callback: Consumer<Drawable>, - ) { + ): Drawable? { val taskId = nextTaskId.getAndIncrement() LoadIconTask(context, info, userHandle, presentationFactory) { result -> removeTask(taskId) @@ -75,6 +74,7 @@ class DefaultTargetDataLoader( } .also { addTask(taskId, it) } .executeOnExecutor(executor) + return null } override fun loadDirectShareIcon( @@ -95,7 +95,7 @@ class DefaultTargetDataLoader( .executeOnExecutor(executor) } - override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<Array<CharSequence?>>) { + override fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) { val taskId = nextTaskId.getAndIncrement() LoadLabelTask(context, info, isAudioCaptureDevice, presentationFactory) { result -> removeTask(taskId) @@ -105,8 +105,14 @@ class DefaultTargetDataLoader( .executeOnExecutor(executor) } - override fun createPresentationGetter(info: ResolveInfo): TargetPresentationGetter = - presentationFactory.makePresentationGetter(info) + override fun getOrLoadLabel(info: DisplayResolveInfo) { + if (!info.hasDisplayLabel()) { + val result = + LoadLabelTask.loadLabel(context, info, isAudioCaptureDevice, presentationFactory) + info.displayLabel = result.label + info.extendedInfo = result.subLabel + } + } private fun addTask(id: Int, task: AsyncTask<*, *, *>) { synchronized(activeTasks) { activeTasks.put(id, task) } diff --git a/java/src/com/android/intentresolver/icons/LabelInfo.kt b/java/src/com/android/intentresolver/icons/LabelInfo.kt new file mode 100644 index 00000000..4b60d607 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/LabelInfo.kt @@ -0,0 +1,19 @@ +/* + * 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.icons + +data class LabelInfo(val label: CharSequence?, val subLabel: CharSequence?) diff --git a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java index 6aee69b5..0f135d63 100644 --- a/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java +++ b/java/src/com/android/intentresolver/icons/LoadDirectShareIconTask.java @@ -16,7 +16,6 @@ package com.android.intentresolver.icons; -import android.annotation.Nullable; import android.content.ComponentName; import android.content.Context; import android.content.pm.ActivityInfo; @@ -30,6 +29,7 @@ import android.graphics.drawable.Icon; import android.os.Trace; import android.util.Log; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.android.intentresolver.SimpleIconFactory; diff --git a/java/src/com/android/intentresolver/icons/LoadIconTask.java b/java/src/com/android/intentresolver/icons/LoadIconTask.java index 37ce4093..75132208 100644 --- a/java/src/com/android/intentresolver/icons/LoadIconTask.java +++ b/java/src/com/android/intentresolver/icons/LoadIconTask.java @@ -24,7 +24,6 @@ import android.os.Trace; import android.os.UserHandle; import android.util.Log; -import com.android.intentresolver.ResolverActivity; import com.android.intentresolver.TargetPresentationGetter; import com.android.intentresolver.chooser.DisplayResolveInfo; @@ -64,8 +63,7 @@ class LoadIconTask extends BaseLoadIconTask { protected final Drawable loadIconForResolveInfo(ResolveInfo ri) { // Load icons based on userHandle from ResolveInfo. If in work profile/clone profile, icons // should be badged. - return mPresentationFactory.makePresentationGetter(ri) - .getIcon(ResolverActivity.getResolveInfoUserHandle(ri, mUserHandle)); + return mPresentationFactory.makePresentationGetter(ri).getIcon(ri.userHandle); } } diff --git a/java/src/com/android/intentresolver/icons/LoadLabelTask.java b/java/src/com/android/intentresolver/icons/LoadLabelTask.java index a0867b8e..6d443f78 100644 --- a/java/src/com/android/intentresolver/icons/LoadLabelTask.java +++ b/java/src/com/android/intentresolver/icons/LoadLabelTask.java @@ -28,16 +28,16 @@ import com.android.intentresolver.chooser.DisplayResolveInfo; import java.util.function.Consumer; -class LoadLabelTask extends AsyncTask<Void, Void, CharSequence[]> { +class LoadLabelTask extends AsyncTask<Void, Void, LabelInfo> { private final Context mContext; private final DisplayResolveInfo mDisplayResolveInfo; private final boolean mIsAudioCaptureDevice; protected final TargetPresentationGetter.Factory mPresentationFactory; - private final Consumer<CharSequence[]> mCallback; + private final Consumer<LabelInfo> mCallback; LoadLabelTask(Context context, DisplayResolveInfo dri, boolean isAudioCaptureDevice, TargetPresentationGetter.Factory presentationFactory, - Consumer<CharSequence[]> callback) { + Consumer<LabelInfo> callback) { mContext = context; mDisplayResolveInfo = dri; mIsAudioCaptureDevice = isAudioCaptureDevice; @@ -46,49 +46,52 @@ class LoadLabelTask extends AsyncTask<Void, Void, CharSequence[]> { } @Override - protected CharSequence[] doInBackground(Void... voids) { + protected LabelInfo doInBackground(Void... voids) { try { Trace.beginSection("app-label"); - return loadLabel(); + return loadLabel( + mContext, mDisplayResolveInfo, mIsAudioCaptureDevice, mPresentationFactory); } finally { Trace.endSection(); } } - private CharSequence[] loadLabel() { - TargetPresentationGetter pg = mPresentationFactory.makePresentationGetter( - mDisplayResolveInfo.getResolveInfo()); + static LabelInfo loadLabel( + Context context, + DisplayResolveInfo displayResolveInfo, + boolean isAudioCaptureDevice, + TargetPresentationGetter.Factory presentationFactory) { + TargetPresentationGetter pg = presentationFactory.makePresentationGetter( + displayResolveInfo.getResolveInfo()); - if (mIsAudioCaptureDevice) { + if (isAudioCaptureDevice) { // This is an audio capture device, so check record permissions - ActivityInfo activityInfo = mDisplayResolveInfo.getResolveInfo().activityInfo; + ActivityInfo activityInfo = displayResolveInfo.getResolveInfo().activityInfo; String packageName = activityInfo.packageName; int uid = activityInfo.applicationInfo.uid; boolean hasRecordPermission = PermissionChecker.checkPermissionForPreflight( - mContext, + context, android.Manifest.permission.RECORD_AUDIO, -1, uid, packageName) == android.content.pm.PackageManager.PERMISSION_GRANTED; if (!hasRecordPermission) { // Doesn't have record permission, so warn the user - return new CharSequence[]{ + return new LabelInfo( pg.getLabel(), - mContext.getString(R.string.usb_device_resolve_prompt_warn) - }; + context.getString(R.string.usb_device_resolve_prompt_warn)); } } - return new CharSequence[]{ + return new LabelInfo( pg.getLabel(), - pg.getSubLabel() - }; + pg.getSubLabel()); } @Override - protected void onPostExecute(CharSequence[] result) { + protected void onPostExecute(LabelInfo result) { mCallback.accept(result); } } diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt index 50f731f8..7789df44 100644 --- a/java/src/com/android/intentresolver/icons/TargetDataLoader.kt +++ b/java/src/com/android/intentresolver/icons/TargetDataLoader.kt @@ -16,10 +16,8 @@ package com.android.intentresolver.icons -import android.content.pm.ResolveInfo import android.graphics.drawable.Drawable import android.os.UserHandle -import com.android.intentresolver.TargetPresentationGetter import com.android.intentresolver.chooser.DisplayResolveInfo import com.android.intentresolver.chooser.SelectableTargetInfo import java.util.function.Consumer @@ -27,11 +25,11 @@ import java.util.function.Consumer /** A target data loader contract. Added to support testing. */ abstract class TargetDataLoader { /** Load an app target icon */ - abstract fun loadAppTargetIcon( + abstract fun getOrLoadAppTargetIcon( info: DisplayResolveInfo, userHandle: UserHandle, callback: Consumer<Drawable>, - ) + ): Drawable? /** Load a shortcut icon */ abstract fun loadDirectShareIcon( @@ -41,10 +39,8 @@ abstract class TargetDataLoader { ) /** Load target label */ - abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer<Array<CharSequence?>>) + abstract fun loadLabel(info: DisplayResolveInfo, callback: Consumer<LabelInfo>) - /** Create a presentation getter to be used with a [DisplayResolveInfo] */ - // TODO: get rid of DisplayResolveInfo's dependency on the presentation getter and remove this - // method. - abstract fun createPresentationGetter(info: ResolveInfo): TargetPresentationGetter + /** Loads DisplayResolveInfo's display label synchronously, if needed */ + abstract fun getOrLoadLabel(info: DisplayResolveInfo) } diff --git a/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt new file mode 100644 index 00000000..9c0acb11 --- /dev/null +++ b/java/src/com/android/intentresolver/icons/TargetDataLoaderModule.kt @@ -0,0 +1,44 @@ +/* + * 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.icons + +import android.content.Context +import androidx.lifecycle.Lifecycle +import com.android.intentresolver.inject.ActivityOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +object TargetDataLoaderModule { + @Provides + @ActivityScoped + fun targetDataLoader( + @ActivityContext context: Context, + @ActivityOwned lifecycle: Lifecycle, + ): TargetDataLoader = DefaultTargetDataLoader(context, lifecycle, isAudioCaptureDevice = false) + + @Provides + @ActivityScoped + @Caching + fun cachingTargetDataLoader(targetDataLoader: TargetDataLoader): TargetDataLoader = + CachingTargetDataLoader(targetDataLoader) +} diff --git a/java/src/com/android/intentresolver/inject/ActivityModelModule.kt b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt new file mode 100644 index 00000000..bbd25eb7 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ActivityModelModule.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.content.Intent +import android.net.Uri +import android.service.chooser.ChooserAction +import androidx.lifecycle.SavedStateHandle +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.viewmodel.readChooserRequest +import com.android.intentresolver.util.ownedByCurrentUser +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Qualifier + +@Module +@InstallIn(ViewModelComponent::class) +object ActivityModelModule { + @Provides + fun provideActivityModel(savedStateHandle: SavedStateHandle): ActivityModel = + requireNotNull(savedStateHandle[ActivityModel.ACTIVITY_MODEL_KEY]) { + "ActivityModel missing in SavedStateHandle! (${ActivityModel.ACTIVITY_MODEL_KEY})" + } + + @Provides + @ChooserIntent + fun chooserIntent(activityModel: ActivityModel): Intent = activityModel.intent + + @Provides + @ViewModelScoped + fun provideInitialRequest( + activityModel: ActivityModel, + flags: ChooserServiceFlags, + ): ValidationResult<ChooserRequest> = readChooserRequest(activityModel, flags) + + @Provides + fun provideChooserRequest( + initialRequest: ValidationResult<ChooserRequest>, + ): ChooserRequest = + requireNotNull((initialRequest as? Valid)?.value) { + "initialRequest is Invalid, no chooser request available" + } + + @Provides + @TargetIntent + fun targetIntent(chooserReq: ValidationResult<ChooserRequest>): Intent = + requireNotNull((chooserReq as? Valid)?.value?.targetIntent) { "no target intent available" } + + @Provides + fun customActions(chooserReq: ValidationResult<ChooserRequest>): List<ChooserAction> = + requireNotNull((chooserReq as? Valid)?.value?.chooserActions) { + "no chooser actions available" + } + + @Provides + @ViewModelScoped + @ContentUris + fun selectedUris(chooserRequest: ValidationResult<ChooserRequest>): List<Uri> = + requireNotNull((chooserRequest as? Valid)?.value?.targetIntent?.contentUris?.toList()) { + "no selected uris available" + } + + @Provides + @FocusedItemIndex + fun focusedItemIndex(chooserReq: ValidationResult<ChooserRequest>): Int = + requireNotNull((chooserReq as? Valid)?.value?.focusedItemPosition) { + "no focused item position available" + } + + @Provides + @AdditionalContent + fun additionalContentUri(chooserReq: ValidationResult<ChooserRequest>): Uri = + requireNotNull((chooserReq as? Valid)?.value?.additionalContentUri) { + "no additional content uri available" + } +} + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class FocusedItemIndex + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AdditionalContent + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ChooserIntent + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ContentUris + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class TargetIntent + +private val Intent.contentUris: Sequence<Uri> + get() = sequence { + if (Intent.ACTION_SEND == action) { + getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + ?.takeIf { it.ownedByCurrentUser } + ?.let { yield(it) } + } else { + getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri -> + if (uri.ownedByCurrentUser) { + yield(uri) + } + } + } + } diff --git a/java/src/com/android/intentresolver/inject/ActivityModule.kt b/java/src/com/android/intentresolver/inject/ActivityModule.kt new file mode 100644 index 00000000..21bfe4c6 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ActivityModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.app.Activity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import kotlinx.coroutines.CoroutineScope + +@Module +@InstallIn(ActivityComponent::class) +object ActivityModule { + + @Provides + @ActivityOwned + fun lifecycle(activity: Activity): Lifecycle { + check(activity is LifecycleOwner) { "activity must implement LifecycleOwner" } + return activity.lifecycle + } + + @Provides + @ActivityOwned + fun activityScope(activity: Activity): CoroutineScope { + check(activity is LifecycleOwner) { "activity must implement LifecycleOwner" } + return activity.lifecycleScope + } +} diff --git a/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt new file mode 100644 index 00000000..5fbdf090 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ConcurrencyModule.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.os.Process +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +// thread +private const val BROADCAST_SLOW_DISPATCH_THRESHOLD = 1000L +private const val BROADCAST_SLOW_DELIVERY_THRESHOLD = 1000L + +@Module +@InstallIn(SingletonComponent::class) +object ConcurrencyModule { + + @Provides @Main fun mainDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate + + /** Injectable alternative to [MainScope()][kotlinx.coroutines.MainScope] */ + @Provides + @Singleton + @Main + fun mainCoroutineScope(@Main mainDispatcher: CoroutineDispatcher) = + CoroutineScope(SupervisorJob() + mainDispatcher) + + @Provides @Background fun backgroundDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Singleton + @Broadcast + fun provideBroadcastLooper(): Looper { + val thread = HandlerThread("BroadcastReceiver", Process.THREAD_PRIORITY_BACKGROUND) + thread.start() + thread.looper.setSlowLogThresholdMs( + BROADCAST_SLOW_DISPATCH_THRESHOLD, + BROADCAST_SLOW_DELIVERY_THRESHOLD + ) + return thread.looper + } + + /** Provide a BroadcastReceiver Executor (for sending and receiving broadcasts). */ + @Provides + @Singleton + @Broadcast + fun provideBroadcastHandler(@Broadcast looper: Looper): Handler { + return Handler(looper) + } +} diff --git a/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt new file mode 100644 index 00000000..d7be67db --- /dev/null +++ b/java/src/com/android/intentresolver/inject/FeatureFlagsModule.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.service.chooser.FeatureFlagsImpl as ChooserServiceFlagsImpl +import com.android.intentresolver.FeatureFlagsImpl as IntentResolverFlagsImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +typealias IntentResolverFlags = com.android.intentresolver.FeatureFlags + +typealias FakeIntentResolverFlags = com.android.intentresolver.FakeFeatureFlagsImpl + +typealias ChooserServiceFlags = android.service.chooser.FeatureFlags + +typealias FakeChooserServiceFlags = android.service.chooser.FakeFeatureFlagsImpl + +@Module +@InstallIn(SingletonComponent::class) +object FeatureFlagsModule { + + @Provides fun intentResolverFlags(): IntentResolverFlags = IntentResolverFlagsImpl() + + @Provides fun chooserServiceFlags(): ChooserServiceFlags = ChooserServiceFlagsImpl() +} diff --git a/java/src/com/android/intentresolver/inject/Qualifiers.kt b/java/src/com/android/intentresolver/inject/Qualifiers.kt new file mode 100644 index 00000000..77315cac --- /dev/null +++ b/java/src/com/android/intentresolver/inject/Qualifiers.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import javax.inject.Qualifier + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ActivityOwned + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ViewModelOwned + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationOwned + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ApplicationUser + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Broadcast + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ProfileParent + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Background + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Default + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class Main diff --git a/java/src/com/android/intentresolver/inject/SingletonModule.kt b/java/src/com/android/intentresolver/inject/SingletonModule.kt new file mode 100644 index 00000000..af054625 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/SingletonModule.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import android.content.Context +import com.android.intentresolver.logging.EventLogImpl +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object SingletonModule { + @Provides @Singleton fun instanceIdSequence() = EventLogImpl.newIdSequence() + + @Provides + @Reusable + @ApplicationOwned + fun resources(@ApplicationContext context: Context) = context.resources +} diff --git a/java/src/com/android/intentresolver/inject/SystemServices.kt b/java/src/com/android/intentresolver/inject/SystemServices.kt new file mode 100644 index 00000000..2a123dc7 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/SystemServices.kt @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.inject + +import android.app.ActivityManager +import android.app.admin.DevicePolicyManager +import android.app.prediction.AppPredictionManager +import android.content.ClipboardManager +import android.content.ContentInterface +import android.content.ContentResolver +import android.content.Context +import android.content.pm.LauncherApps +import android.content.pm.ShortcutManager +import android.os.UserManager +import android.view.WindowManager +import androidx.core.content.getSystemService +import com.android.intentresolver.data.repository.UserScopedService +import com.android.intentresolver.data.repository.UserScopedServiceImpl +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +inline fun <reified T> Context.requireSystemService(): T { + return checkNotNull(getSystemService()) +} + +@Module +@InstallIn(SingletonComponent::class) +class ActivityManagerModule { + @Provides + fun activityManager(@ApplicationContext ctx: Context): ActivityManager = + ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class ClipboardManagerModule { + @Provides + fun clipboardManager(@ApplicationContext ctx: Context): ClipboardManager = + ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +interface ContentResolverModule { + @Binds fun bindContentInterface(cr: ContentResolver): ContentInterface + + companion object { + @Provides + fun contentResolver(@ApplicationContext ctx: Context) = requireNotNull(ctx.contentResolver) + } +} + +@Module +@InstallIn(SingletonComponent::class) +class DevicePolicyManagerModule { + @Provides + fun devicePolicyManager(@ApplicationContext ctx: Context): DevicePolicyManager = + ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class LauncherAppsModule { + @Provides + fun launcherApps(@ApplicationContext ctx: Context): LauncherApps = ctx.requireSystemService() +} + +@Module +@InstallIn(SingletonComponent::class) +class PackageManagerModule { + @Provides + fun packageManager(@ApplicationContext ctx: Context) = requireNotNull(ctx.packageManager) +} + +@Module +@InstallIn(SingletonComponent::class) +class PredictionManagerModule { + @Provides + fun scopedPredictionManager( + @ApplicationContext ctx: Context, + ): UserScopedService<AppPredictionManager> { + return UserScopedServiceImpl(ctx, AppPredictionManager::class) + } +} + +@Module +@InstallIn(SingletonComponent::class) +class ShortcutManagerModule { + @Provides + fun shortcutManager(@ApplicationContext ctx: Context): ShortcutManager { + return ctx.requireSystemService() + } + + @Provides + fun scopedShortcutManager( + @ApplicationContext ctx: Context, + ): UserScopedService<ShortcutManager> { + return UserScopedServiceImpl(ctx, ShortcutManager::class) + } +} + +@Module +@InstallIn(SingletonComponent::class) +class UserManagerModule { + @Provides + fun userManager(@ApplicationContext ctx: Context): UserManager = ctx.requireSystemService() + + @Provides + fun scopedUserManager(@ApplicationContext ctx: Context): UserScopedService<UserManager> { + return UserScopedServiceImpl(ctx, UserManager::class) + } +} + +@Module +@InstallIn(SingletonComponent::class) +class WindowManagerModule { + @Provides + fun windowManager(@ApplicationContext ctx: Context): WindowManager = ctx.requireSystemService() +} diff --git a/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt b/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt new file mode 100644 index 00000000..4dda2653 --- /dev/null +++ b/java/src/com/android/intentresolver/inject/ViewModelCoroutineScopeModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.inject + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel + +@Module +@InstallIn(ViewModelComponent::class) +object ViewModelCoroutineScopeModule { + @Provides + @ViewModelScoped + @ViewModelOwned + fun viewModelScope(@Main dispatcher: CoroutineDispatcher, lifecycle: ViewModelLifecycle) = + lifecycle.asCoroutineScope(dispatcher) +} + +fun ViewModelLifecycle.asCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) = + CoroutineScope(context).also { addOnClearedListener { it.cancel() } } diff --git a/java/src/com/android/intentresolver/logging/EventLog.kt b/java/src/com/android/intentresolver/logging/EventLog.kt new file mode 100644 index 00000000..476bd4bf --- /dev/null +++ b/java/src/com/android/intentresolver/logging/EventLog.kt @@ -0,0 +1,74 @@ +/* + * 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.logging + +import android.net.Uri +import android.util.HashedStringCache + +/** Logs notable events during ShareSheet usage. */ +interface EventLog { + + companion object { + const val SELECTION_TYPE_SERVICE = 1 + const val SELECTION_TYPE_APP = 2 + const val SELECTION_TYPE_STANDARD = 3 + const val SELECTION_TYPE_COPY = 4 + const val SELECTION_TYPE_NEARBY = 5 + const val SELECTION_TYPE_EDIT = 6 + const val SELECTION_TYPE_MODIFY_SHARE = 7 + const val SELECTION_TYPE_CUSTOM_ACTION = 8 + } + + fun logChooserActivityShown(isWorkProfile: Boolean, targetMimeType: String?, systemCost: Long) + + fun logShareStarted( + packageName: String?, + mimeType: String?, + appProvidedDirect: Int, + appProvidedApp: Int, + isWorkprofile: Boolean, + previewType: Int, + intent: String?, + customActionCount: Int, + modifyShareActionProvided: Boolean + ) + + fun logCustomActionSelected(positionPicked: Int) + fun logShareTargetSelected( + targetType: Int, + packageName: String?, + positionPicked: Int, + directTargetAlsoRanked: Int, + numCallerProvided: Int, + directTargetHashed: HashedStringCache.HashResult?, + isPinned: Boolean, + successfullySelected: Boolean, + selectionCost: Long + ) + + 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() +} diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/logging/EventLogImpl.java index 1f606f26..39d23865 100644 --- a/java/src/com/android/intentresolver/ChooserActivityLogger.java +++ b/java/src/com/android/intentresolver/logging/EventLogImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 The Android Open Source Project + * 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. @@ -14,9 +14,8 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.logging; -import android.annotation.Nullable; import android.content.Intent; import android.metrics.LogMaker; import android.net.Uri; @@ -24,6 +23,9 @@ import android.provider.MediaStore; import android.util.HashedStringCache; import android.util.Log; +import androidx.annotation.Nullable; + +import com.android.intentresolver.ChooserActivity; import com.android.intentresolver.contentpreview.ContentPreviewType; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.logging.InstanceId; @@ -31,84 +33,42 @@ import com.android.internal.logging.InstanceIdSequence; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEvent; import com.android.internal.logging.UiEventLogger; -import com.android.internal.logging.UiEventLoggerImpl; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.util.FrameworkStatsLog; +import javax.inject.Inject; + /** * Helper for writing Sharesheet atoms to statsd log. - * @hide */ -public class ChooserActivityLogger { +public class EventLogImpl implements EventLog { private static final String TAG = "ChooserActivity"; private static final boolean DEBUG = true; - public static final int SELECTION_TYPE_SERVICE = 1; - public static final int SELECTION_TYPE_APP = 2; - public static final int SELECTION_TYPE_STANDARD = 3; - public static final int SELECTION_TYPE_COPY = 4; - public static final int SELECTION_TYPE_NEARBY = 5; - public static final int SELECTION_TYPE_EDIT = 6; - public static final int SELECTION_TYPE_MODIFY_SHARE = 7; - public static final int SELECTION_TYPE_CUSTOM_ACTION = 8; - - /** - * This shim is provided only for testing. In production, clients will only ever use a - * {@link DefaultFrameworkStatsLogger}. - */ - @VisibleForTesting - interface FrameworkStatsLogger { - /** Overload to use for logging {@code FrameworkStatsLog.SHARESHEET_STARTED}. */ - void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - String mimeType, - int numAppProvidedDirectTargets, - int numAppProvidedAppTargets, - boolean isWorkProfile, - int previewType, - int intentType, - int numCustomActions, - boolean modifyShareActionProvided); - - /** Overload to use for logging {@code FrameworkStatsLog.RANKING_SELECTED}. */ - void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - int positionPicked, - boolean isPinned); - } - private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13); - // A small per-notification ID, used for statsd logging. - // TODO: consider precomputing and storing as final. - private static InstanceIdSequence sInstanceIdSequence; - private InstanceId mInstanceId; + private final InstanceId mInstanceId; private final UiEventLogger mUiEventLogger; private final FrameworkStatsLogger mFrameworkStatsLogger; private final MetricsLogger mMetricsLogger; - public ChooserActivityLogger() { - this(new UiEventLoggerImpl(), new DefaultFrameworkStatsLogger(), new MetricsLogger()); + public static InstanceIdSequence newIdSequence() { + return new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); } - @VisibleForTesting - ChooserActivityLogger( - UiEventLogger uiEventLogger, - FrameworkStatsLogger frameworkLogger, - MetricsLogger metricsLogger) { + @Inject + public EventLogImpl(UiEventLogger uiEventLogger, FrameworkStatsLogger frameworkLogger, + MetricsLogger metricsLogger, InstanceId instanceId) { mUiEventLogger = uiEventLogger; mFrameworkStatsLogger = frameworkLogger; mMetricsLogger = metricsLogger; + mInstanceId = instanceId; } + /** Records metrics for the start time of the {@link ChooserActivity}. */ + @Override public void logChooserActivityShown( boolean isWorkProfile, String targetMimeType, long systemCost) { mMetricsLogger.write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN) @@ -119,6 +79,7 @@ public class ChooserActivityLogger { } /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */ + @Override public void logShareStarted( String packageName, String mimeType, @@ -132,7 +93,7 @@ public class ChooserActivityLogger { mFrameworkStatsLogger.write(FrameworkStatsLog.SHARESHEET_STARTED, /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(), /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* mime_type = 4 */ mimeType, /* num_app_provided_direct_targets = 5 */ appProvidedDirect, /* num_app_provided_app_targets = 6 */ appProvidedApp, @@ -148,12 +109,13 @@ public class ChooserActivityLogger { * * @param positionPicked index of the custom action within the list of custom actions. */ + @Override public void logCustomActionSelected(int positionPicked) { mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, /* event_id = 1 */ SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId(), /* package_name = 2 */ null, - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* position_picked = 4 */ positionPicked, /* is_pinned = 5 */ false); } @@ -163,6 +125,7 @@ public class ChooserActivityLogger { * TODO: document parameters and/or consider breaking up by targetType so we don't have to * support an overly-generic signature. */ + @Override public void logShareTargetSelected( int targetType, String packageName, @@ -176,7 +139,7 @@ public class ChooserActivityLogger { mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), /* package_name = 2 */ packageName, - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* position_picked = 4 */ positionPicked, /* is_pinned = 5 */ isPinned); @@ -208,6 +171,7 @@ public class ChooserActivityLogger { } /** Log when direct share targets were received. */ + @Override public void logDirectShareTargetReceived(int category, int latency) { mMetricsLogger.write(new LogMaker(category).setSubtype(latency)); } @@ -216,12 +180,14 @@ public class ChooserActivityLogger { * Log when we display a preview UI of the specified {@code previewType} as part of our * Sharesheet session. */ + @Override public void logActionShareWithPreview(int previewType) { mMetricsLogger.write( new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW).setSubtype(previewType)); } /** Log when the user selects an action button with the specified {@code targetType}. */ + @Override public void logActionSelected(int targetType) { if (targetType == SELECTION_TYPE_COPY) { LogMaker targetLogMaker = new LogMaker( @@ -231,12 +197,13 @@ public class ChooserActivityLogger { mFrameworkStatsLogger.write(FrameworkStatsLog.RANKING_SELECTED, /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), /* package_name = 2 */ "", - /* instance_id = 3 */ getInstanceId().getId(), + /* instance_id = 3 */ mInstanceId.getId(), /* position_picked = 4 */ -1, /* is_pinned = 5 */ false); } /** Log a warning that we couldn't display the content preview from the supplied {@code uri}. */ + @Override public void logContentPreviewWarning(Uri uri) { // The ContentResolver already logs the exception. Log something more informative. Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " @@ -247,55 +214,63 @@ public class ChooserActivityLogger { } /** Logs a UiEventReported event for the system sharesheet being triggered by the user. */ + @Override public void logSharesheetTriggered() { - log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, mInstanceId); } /** Logs a UiEventReported event for the system sharesheet completing loading app targets. */ + @Override public void logSharesheetAppLoadComplete() { - log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, mInstanceId); } /** * Logs a UiEventReported event for the system sharesheet completing loading service targets. */ + @Override public void logSharesheetDirectLoadComplete() { - log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, mInstanceId); } /** * Logs a UiEventReported event for the system sharesheet timing out loading service targets. */ + @Override public void logSharesheetDirectLoadTimeout() { - log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, mInstanceId); } /** * Logs a UiEventReported event for the system sharesheet switching * between work and main profile. */ + @Override public void logSharesheetProfileChanged() { - log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, mInstanceId); } /** Logs a UiEventReported event for the system sharesheet getting expanded or collapsed. */ + @Override public void logSharesheetExpansionChanged(boolean isCollapsed) { log(isCollapsed ? SharesheetStandardEvent.SHARESHEET_COLLAPSED : - SharesheetStandardEvent.SHARESHEET_EXPANDED, getInstanceId()); + SharesheetStandardEvent.SHARESHEET_EXPANDED, mInstanceId); } /** * Logs a UiEventReported event for the system sharesheet app share ranking timing out. */ + @Override public void logSharesheetAppShareRankingTimeout() { - log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, mInstanceId); } /** * Logs a UiEventReported event for the system sharesheet when direct share row is empty. */ + @Override public void logSharesheetEmptyDirectShareRow() { - log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, getInstanceId()); + log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, mInstanceId); } /** @@ -312,19 +287,6 @@ public class ChooserActivityLogger { } /** - * @return A unique {@link InstanceId} to join across events recorded by this logger instance. - */ - private InstanceId getInstanceId() { - if (mInstanceId == null) { - if (sInstanceIdSequence == null) { - sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); - } - mInstanceId = sInstanceIdSequence.newInstanceId(); - } - return mInstanceId; - } - - /** * The UiEvent enums that this class can log. */ enum SharesheetStartedEvent implements UiEventLogger.UiEventEnum { @@ -417,7 +379,9 @@ public class ChooserActivityLogger { @UiEvent(doc = "Sharesheet app share ranking timed out.") SHARESHEET_APP_SHARE_RANKING_TIMEOUT(831), @UiEvent(doc = "Sharesheet empty direct share row.") - SHARESHEET_EMPTY_DIRECT_SHARE_ROW(828); + SHARESHEET_EMPTY_DIRECT_SHARE_ROW(828), + @UiEvent(doc = "Shareousel payload item toggled") + SHARESHEET_PAYLOAD_TOGGLED(1662); private final int mId; SharesheetStandardEvent(int id) { @@ -487,52 +451,4 @@ public class ChooserActivityLogger { return 0; } } - - private static class DefaultFrameworkStatsLogger implements FrameworkStatsLogger { - @Override - public void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - String mimeType, - int numAppProvidedDirectTargets, - int numAppProvidedAppTargets, - boolean isWorkProfile, - int previewType, - int intentType, - int numCustomActions, - boolean modifyShareActionProvided) { - FrameworkStatsLog.write( - frameworkEventId, - /* event_id = 1 */ appEventId, - /* package_name = 2 */ packageName, - /* instance_id = 3 */ instanceId, - /* mime_type = 4 */ mimeType, - /* num_app_provided_direct_targets */ numAppProvidedDirectTargets, - /* num_app_provided_app_targets */ numAppProvidedAppTargets, - /* is_workprofile */ isWorkProfile, - /* previewType = 8 */ previewType, - /* intentType = 9 */ intentType, - /* num_provided_custom_actions = 10 */ numCustomActions, - /* modify_share_action_provided = 11 */ modifyShareActionProvided); - } - - @Override - public void write( - int frameworkEventId, - int appEventId, - String packageName, - int instanceId, - int positionPicked, - boolean isPinned) { - FrameworkStatsLog.write( - frameworkEventId, - /* event_id = 1 */ appEventId, - /* package_name = 2 */ packageName, - /* instance_id = 3 */ instanceId, - /* position_picked = 4 */ positionPicked, - /* is_pinned = 5 */ isPinned); - } - } } diff --git a/java/src/com/android/intentresolver/logging/EventLogModule.kt b/java/src/com/android/intentresolver/logging/EventLogModule.kt new file mode 100644 index 00000000..73af7d37 --- /dev/null +++ b/java/src/com/android/intentresolver/logging/EventLogModule.kt @@ -0,0 +1,46 @@ +/* + * 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.logging + +import com.android.internal.logging.InstanceId +import com.android.internal.logging.InstanceIdSequence +import com.android.internal.logging.MetricsLogger +import com.android.internal.logging.UiEventLogger +import com.android.internal.logging.UiEventLoggerImpl +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@Module +@InstallIn(ActivityRetainedComponent::class) +interface EventLogModule { + + @Binds @ActivityRetainedScoped fun eventLog(value: EventLogImpl): EventLog + + companion object { + @Provides + fun instanceId(sequence: InstanceIdSequence): InstanceId = sequence.newInstanceId() + + @Provides fun uiEventLogger(): UiEventLogger = UiEventLoggerImpl() + + @Provides fun frameworkLogger(): FrameworkStatsLogger = object : FrameworkStatsLogger {} + + @Provides fun metricsLogger(): MetricsLogger = MetricsLogger() + } +} diff --git a/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt b/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt new file mode 100644 index 00000000..6508d305 --- /dev/null +++ b/java/src/com/android/intentresolver/logging/FrameworkStatsLogger.kt @@ -0,0 +1,75 @@ +/* + * 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.logging + +import com.android.internal.util.FrameworkStatsLog + +/** A documenting annotation for FrameworkStatsLog methods and their associated UiEvents. */ +internal annotation class ForUiEvent(vararg val uiEventId: Int) + +/** Isolates the specific method signatures to use for each of the logged UiEvents. */ +interface FrameworkStatsLogger { + + @ForUiEvent(FrameworkStatsLog.SHARESHEET_STARTED) + fun write( + frameworkEventId: Int, + appEventId: Int, + packageName: String?, + instanceId: Int, + mimeType: String?, + numAppProvidedDirectTargets: Int, + numAppProvidedAppTargets: Int, + isWorkProfile: Boolean, + previewType: Int, + intentType: Int, + numCustomActions: Int, + modifyShareActionProvided: Boolean, + ) { + FrameworkStatsLog.write( + frameworkEventId, /* event_id = 1 */ + appEventId, /* package_name = 2 */ + packageName, /* instance_id = 3 */ + instanceId, /* mime_type = 4 */ + mimeType, /* num_app_provided_direct_targets */ + numAppProvidedDirectTargets, /* num_app_provided_app_targets */ + numAppProvidedAppTargets, /* is_workprofile */ + isWorkProfile, /* previewType = 8 */ + previewType, /* intentType = 9 */ + intentType, /* num_provided_custom_actions = 10 */ + numCustomActions, /* modify_share_action_provided = 11 */ + modifyShareActionProvided + ) + } + + @ForUiEvent(FrameworkStatsLog.RANKING_SELECTED) + fun write( + frameworkEventId: Int, + appEventId: Int, + packageName: String?, + instanceId: Int, + positionPicked: Int, + isPinned: Boolean, + ) { + FrameworkStatsLog.write( + frameworkEventId, /* event_id = 1 */ + appEventId, /* package_name = 2 */ + packageName, /* instance_id = 3 */ + instanceId, /* position_picked = 4 */ + positionPicked, /* is_pinned = 5 */ + isPinned + ) + } +} diff --git a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java index bc54e01e..4871ef4d 100644 --- a/java/src/com/android/intentresolver/model/AbstractResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AbstractResolverComparator.java @@ -16,11 +16,11 @@ package com.android.intentresolver.model; -import android.annotation.Nullable; import android.app.usage.UsageStatsManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.os.BadParcelableException; @@ -30,12 +30,14 @@ import android.os.Message; import android.os.UserHandle; import android.util.Log; -import com.android.intentresolver.ChooserActivityLogger; +import androidx.annotation.Nullable; + import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.ResolverActivity; +import com.android.intentresolver.ResolverListController; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.logging.EventLog; -import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -72,9 +74,10 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC private static final int WATCHDOG_TIMEOUT_MILLIS = 500; private final Comparator<ResolveInfo> mAzComparator; - private ChooserActivityLogger mChooserActivityLogger; + private EventLog mEventLog; protected final Handler mHandler = new Handler(Looper.getMainLooper()) { + @Override public void handleMessage(Message msg) { switch (msg.what) { case RANKER_SERVICE_RESULT: @@ -94,8 +97,8 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC } mHandler.removeMessages(RANKER_SERVICE_RESULT); afterCompute(); - if (mChooserActivityLogger != null) { - mChooserActivityLogger.logSharesheetAppShareRankingTimeout(); + if (mEventLog != null) { + mEventLog.logSharesheetAppShareRankingTimeout(); } break; @@ -132,7 +135,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC user, (UsageStatsManager) userContext.getSystemService(Context.USAGE_STATS_SERVICE)); } - mAzComparator = new AzInfoComparator(launchedFromContext); + mAzComparator = new ResolveInfoAzInfoComparator(launchedFromContext); mPromoteToFirst = promoteToFirst; } @@ -161,12 +164,12 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC mAfterCompute = afterCompute; } - void setChooserActivityLogger(ChooserActivityLogger chooserActivityLogger) { - mChooserActivityLogger = chooserActivityLogger; + void setEventLog(EventLog eventLog) { + mEventLog = eventLog; } - ChooserActivityLogger getChooserActivityLogger() { - return mChooserActivityLogger; + EventLog getEventLog() { + return mEventLog; } protected final void afterCompute() { @@ -200,8 +203,8 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC } if (mHttp) { - final boolean lhsSpecific = ResolverActivity.isSpecificUriMatch(lhs.match); - final boolean rhsSpecific = ResolverActivity.isSpecificUriMatch(rhs.match); + final boolean lhsSpecific = isSpecificUriMatch(lhs.match); + final boolean rhsSpecific = isSpecificUriMatch(rhs.match); if (lhsSpecific != rhsSpecific) { return lhsSpecific ? -1 : 1; } @@ -223,13 +226,20 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC return compare(lhs, rhs); } + /** Determine whether a given match result is considered "specific" in our application. */ + public static final boolean isSpecificUriMatch(int match) { + match = (match & IntentFilter.MATCH_CATEGORY_MASK); + return match >= IntentFilter.MATCH_CATEGORY_HOST + && match <= IntentFilter.MATCH_CATEGORY_PATH; + } + /** * Delegated to when used as a {@link Comparator<ResolvedComponentInfo>} if there is not a * special case. The {@link ResolveInfo ResolveInfos} are the first {@link ResolveInfo} in * {@link ResolvedComponentInfo#getResolveInfoAt(int)} from the parameters of {@link * #compare(ResolvedComponentInfo, ResolvedComponentInfo)} */ - abstract int compare(ResolveInfo lhs, ResolveInfo rhs); + public abstract int compare(ResolveInfo lhs, ResolveInfo rhs); /** * Computes features for each target. This will be called before calls to {@link @@ -245,7 +255,7 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC } /** Implementation of compute called after {@link #beforeCompute()}. */ - abstract void doCompute(List<ResolvedComponentInfo> targets); + public abstract void doCompute(List<ResolvedComponentInfo> targets); /** * Returns the score that was calculated for the corresponding {@link ResolvedComponentInfo} @@ -254,12 +264,12 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC public abstract float getScore(TargetInfo targetInfo); /** Handles result message sent to mHandler. */ - abstract void handleResultMessage(Message message); + public abstract void handleResultMessage(Message message); /** * Reports to UsageStats what was chosen. */ - public final void updateChooserCounts(String packageName, UserHandle user, String action) { + public void updateChooserCounts(String packageName, UserHandle user, String action) { if (mUsmMap.containsKey(user)) { mUsmMap.get(user).reportChooserSelection( packageName, @@ -303,24 +313,4 @@ public abstract class AbstractResolverComparator implements Comparator<ResolvedC mAfterCompute = null; } - /** - * Sort intents alphabetically based on package name. - */ - class AzInfoComparator implements Comparator<ResolveInfo> { - Collator mCollator; - AzInfoComparator(Context context) { - mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); - } - - @Override - public int compare(ResolveInfo lhsp, ResolveInfo rhsp) { - if (lhsp == null) { - return -1; - } else if (rhsp == null) { - return 1; - } - return mCollator.compare(lhsp.activityInfo.packageName, rhsp.activityInfo.packageName); - } - } - } diff --git a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java index ba054731..c6de3260 100644 --- a/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/AppPredictionServiceResolverComparator.java @@ -18,7 +18,6 @@ package com.android.intentresolver.model; import static android.app.prediction.AppTargetEvent.ACTION_LAUNCH; -import android.annotation.Nullable; import android.app.prediction.AppPredictor; import android.app.prediction.AppTarget; import android.app.prediction.AppTargetEvent; @@ -31,9 +30,12 @@ import android.os.Message; import android.os.UserHandle; import android.util.Log; -import com.android.intentresolver.ChooserActivityLogger; +import androidx.annotation.Nullable; + import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.logging.EventLog; +import com.android.intentresolver.shortcuts.ScopedAppTargetListCallback; import com.google.android.collect.Lists; @@ -72,7 +74,7 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp String referrerPackage, AppPredictor appPredictor, UserHandle user, - ChooserActivityLogger chooserActivityLogger, + EventLog eventLog, @Nullable ComponentName promoteToFirst) { super(context, intent, Lists.newArrayList(user), promoteToFirst); mContext = context; @@ -80,17 +82,17 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp mAppPredictor = appPredictor; mUser = user; mReferrerPackage = referrerPackage; - setChooserActivityLogger(chooserActivityLogger); + setEventLog(eventLog); mComparatorModel = buildUpdatedModel(); } @Override - int compare(ResolveInfo lhs, ResolveInfo rhs) { + public int compare(ResolveInfo lhs, ResolveInfo rhs) { return mComparatorModel.getComparator().compare(lhs, rhs); } @Override - void doCompute(List<ResolvedComponentInfo> targets) { + public void doCompute(List<ResolvedComponentInfo> targets) { if (targets.isEmpty()) { mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT); return; @@ -105,33 +107,48 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp .setClassName(target.name.getClassName()) .build()); } - mAppPredictor.sortTargets(appTargets, Executors.newSingleThreadExecutor(), - sortedAppTargets -> { - if (sortedAppTargets.isEmpty()) { - Log.i(TAG, "AppPredictionService disabled. Using resolver."); - // APS for chooser is disabled. Fallback to resolver. - mResolverRankerService = - new ResolverRankerServiceResolverComparator( - mContext, - mIntent, - mReferrerPackage, - () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), - getChooserActivityLogger(), - mUser, - mPromoteToFirst); - mComparatorModel = buildUpdatedModel(); - mResolverRankerService.compute(targets); - } else { - Log.i(TAG, "AppPredictionService response received"); - // Skip sending to Handler which takes extra time to dispatch messages. - handleResult(sortedAppTargets); - } - } - ); + try { + mAppPredictor.sortTargets( + appTargets, + Executors.newSingleThreadExecutor(), + new ScopedAppTargetListCallback( + mContext, + sortedAppTargets -> { + onAppTargetsSorted(targets, sortedAppTargets); + return kotlin.Unit.INSTANCE; + }).toConsumer() + ); + } catch (IllegalStateException e) { + Log.w(TAG, "Couldn't sort targets with AppPredictionService", e); + } + } + + private void onAppTargetsSorted( + List<ResolvedComponentInfo> targets, List<AppTarget> sortedAppTargets) { + if (sortedAppTargets.isEmpty()) { + Log.i(TAG, "AppPredictionService disabled. Using resolver."); + // APS for chooser is disabled. Fallback to resolver. + mResolverRankerService = + new ResolverRankerServiceResolverComparator( + mContext, + mIntent, + mReferrerPackage, + () -> mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT), + getEventLog(), + mUser, + mPromoteToFirst); + mComparatorModel = buildUpdatedModel(); + mResolverRankerService.compute(targets); + } else { + Log.i(TAG, "AppPredictionService response received"); + // Skip sending to Handler which takes extra time to dispatch + // messages. + handleResult(sortedAppTargets); + } } @Override - void handleResultMessage(Message msg) { + public void handleResultMessage(Message msg) { // Null value is okay if we have defaulted to the ResolverRankerService. if (msg.what == RANKER_SERVICE_RESULT && msg.obj != null) { final List<AppTarget> sortedAppTargets = (List<AppTarget>) msg.obj; @@ -279,8 +296,12 @@ public class AppPredictionServiceResolverComparator extends AbstractResolverComp new AppTarget.Builder(targetId, targetComponent.getPackageName(), mUser) .setClassName(targetComponent.getClassName()) .build(); - mAppPredictor.notifyAppTargetEvent( - new AppTargetEvent.Builder(appTarget, ACTION_LAUNCH).build()); + try { + mAppPredictor.notifyAppTargetEvent( + new AppTargetEvent.Builder(appTarget, ACTION_LAUNCH).build()); + } catch (IllegalStateException e) { + Log.w(TAG, "Couldn't send feedback to AppPredictionService", e); + } } } } diff --git a/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java b/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java new file mode 100644 index 00000000..411d0c6e --- /dev/null +++ b/java/src/com/android/intentresolver/model/ResolveInfoAzInfoComparator.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.model; + +import android.content.Context; +import android.content.pm.ResolveInfo; + +import java.text.Collator; +import java.util.Comparator; + +/** + * Sort intents alphabetically based on package name. + */ +public class ResolveInfoAzInfoComparator<T extends ResolveInfo> implements Comparator<T> { + Collator mCollator; + + public ResolveInfoAzInfoComparator(Context context) { + mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); + } + + @Override + public int compare(ResolveInfo lhsp, ResolveInfo rhsp) { + if (lhsp == null) { + return -1; + } else if (rhsp == null) { + return 1; + } + return mCollator.compare(lhsp.activityInfo.packageName, rhsp.activityInfo.packageName); + } +} diff --git a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java index ebaffc36..963091b5 100644 --- a/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java +++ b/java/src/com/android/intentresolver/model/ResolverRankerServiceResolverComparator.java @@ -17,7 +17,6 @@ package com.android.intentresolver.model; -import android.annotation.Nullable; import android.app.usage.UsageStats; import android.content.ComponentName; import android.content.Context; @@ -29,6 +28,7 @@ import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.metrics.LogMaker; +import android.os.Handler; import android.os.IBinder; import android.os.Message; import android.os.RemoteException; @@ -39,14 +39,17 @@ import android.service.resolver.ResolverRankerService; import android.service.resolver.ResolverTarget; import android.util.Log; -import com.android.intentresolver.ChooserActivityLogger; +import androidx.annotation.Nullable; + import com.android.intentresolver.ResolvedComponentInfo; import com.android.intentresolver.chooser.TargetInfo; +import com.android.intentresolver.logging.EventLog; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.google.android.collect.Lists; +import java.lang.ref.WeakReference; import java.text.Collator; import java.util.ArrayList; import java.util.Comparator; @@ -101,10 +104,10 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom * the userSpace provided by context. */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, - String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, UserHandle targetUserSpace, - ComponentName promoteToFirst) { - this(launchedFromContext, intent, referrerPackage, afterCompute, chooserActivityLogger, + String referrerPackage, Runnable afterCompute, + EventLog eventLog, UserHandle targetUserSpace, + ComponentName promoteToFirst) { + this(launchedFromContext, intent, referrerPackage, afterCompute, eventLog, Lists.newArrayList(targetUserSpace), promoteToFirst); } @@ -117,9 +120,8 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom * different from the userSpace provided by context. */ public ResolverRankerServiceResolverComparator(Context launchedFromContext, Intent intent, - String referrerPackage, Runnable afterCompute, - ChooserActivityLogger chooserActivityLogger, List<UserHandle> targetUserSpaceList, - @Nullable ComponentName promoteToFirst) { + String referrerPackage, Runnable afterCompute, EventLog eventLog, + List<UserHandle> targetUserSpaceList, @Nullable ComponentName promoteToFirst) { super(launchedFromContext, intent, targetUserSpaceList, promoteToFirst); mCollator = Collator.getInstance( launchedFromContext.getResources().getConfiguration().locale); @@ -139,7 +141,7 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom mAction = intent.getAction(); mRankerServiceName = new ComponentName(mContext, this.getClass()); setCallBack(afterCompute); - setChooserActivityLogger(chooserActivityLogger); + setEventLog(eventLog); mComparatorModel = buildUpdatedModel(); } @@ -392,20 +394,7 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom } public final IResolverRankerResult resolverRankerResult = - new IResolverRankerResult.Stub() { - @Override - public void sendResult(List<ResolverTarget> targets) throws RemoteException { - if (DEBUG) { - Log.d(TAG, "Sending Result back to Resolver: " + targets); - } - synchronized (mLock) { - final Message msg = Message.obtain(); - msg.what = RANKER_SERVICE_RESULT; - msg.obj = targets; - mHandler.sendMessage(msg); - } - } - }; + new ResolverRankerResultCallback(mLock, mHandler); @Override public void onServiceConnected(ComponentName name, IBinder service) { @@ -437,6 +426,32 @@ public class ResolverRankerServiceResolverComparator extends AbstractResolverCom } } + private static class ResolverRankerResultCallback extends IResolverRankerResult.Stub { + private final Object mLock; + private final WeakReference<Handler> mHandlerRef; + + private ResolverRankerResultCallback(Object lock, Handler handler) { + mLock = lock; + mHandlerRef = new WeakReference<>(handler); + } + + @Override + public void sendResult(List<ResolverTarget> targets) throws RemoteException { + if (DEBUG) { + Log.d(TAG, "Sending Result back to Resolver: " + targets); + } + synchronized (mLock) { + final Message msg = Message.obtain(); + msg.what = RANKER_SERVICE_RESULT; + msg.obj = targets; + Handler handler = mHandlerRef.get(); + if (handler != null) { + handler.sendMessage(msg); + } + } + } + } + @Override void beforeCompute() { super.beforeCompute(); diff --git a/java/src/com/android/intentresolver/platform/AppPredictionModule.kt b/java/src/com/android/intentresolver/platform/AppPredictionModule.kt new file mode 100644 index 00000000..415d5f7d --- /dev/null +++ b/java/src/com/android/intentresolver/platform/AppPredictionModule.kt @@ -0,0 +1,42 @@ +/* + * 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.platform + +import android.content.pm.PackageManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AppPredictionAvailable + +@Module +@InstallIn(SingletonComponent::class) +object AppPredictionModule { + + /** Eventually replaced with: Optional<AppPredictionRepository>, etc. */ + @Provides + @Singleton + @AppPredictionAvailable + fun isAppPredictionAvailable(packageManager: PackageManager): Boolean { + return packageManager.appPredictionServicePackageName != null + } +} diff --git a/java/src/com/android/intentresolver/platform/ImageEditorModule.kt b/java/src/com/android/intentresolver/platform/ImageEditorModule.kt new file mode 100644 index 00000000..24257968 --- /dev/null +++ b/java/src/com/android/intentresolver/platform/ImageEditorModule.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +import android.content.ComponentName +import android.content.res.Resources +import androidx.annotation.StringRes +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import javax.inject.Qualifier +import javax.inject.Singleton + +internal fun Resources.componentName(@StringRes resId: Int): ComponentName? { + check(getResourceTypeName(resId) == "string") { "resId must be a string" } + return ComponentName.unflattenFromString(getString(resId)) +} + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class ImageEditor + +@Module +@InstallIn(SingletonComponent::class) +object ImageEditorModule { + /** + * The name of the preferred Activity to launch for editing images. This is added to Intents to + * edit images using Intent.ACTION_EDIT. + */ + @Provides + @Singleton + @ImageEditor + fun imageEditorComponent(@ApplicationOwned resources: Resources) = + Optional.ofNullable(resources.componentName(R.string.config_systemImageEditor)) +} diff --git a/java/src/com/android/intentresolver/platform/NearbyShareModule.kt b/java/src/com/android/intentresolver/platform/NearbyShareModule.kt new file mode 100644 index 00000000..6cb30b41 --- /dev/null +++ b/java/src/com/android/intentresolver/platform/NearbyShareModule.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +import android.content.ComponentName +import android.content.res.Resources +import android.provider.Settings.Secure.NEARBY_SHARING_COMPONENT +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Optional +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class NearbyShare + +@Module +@InstallIn(SingletonComponent::class) +object NearbyShareModule { + + @Provides + @Singleton + @NearbyShare + fun nearbyShareComponent(@ApplicationOwned resources: Resources, settings: SecureSettings) = + Optional.ofNullable( + ComponentName.unflattenFromString( + settings.getString(NEARBY_SHARING_COMPONENT)?.ifEmpty { null } + ?: resources.getString(R.string.config_defaultNearbySharingComponent), + ) + ) +} diff --git a/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt b/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt new file mode 100644 index 00000000..0c802c97 --- /dev/null +++ b/java/src/com/android/intentresolver/platform/PlatformSecureSettings.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +import android.content.ContentResolver +import android.provider.Settings +import javax.inject.Inject + +/** + * Implements [SecureSettings] backed by Settings.Secure and a ContentResolver. + * + * These methods make Binder calls and may block, so use on the Main thread should be avoided. + */ +class PlatformSecureSettings @Inject constructor(private val resolver: ContentResolver) : + SecureSettings { + + override fun getString(name: String): String? { + return Settings.Secure.getString(resolver, name) + } + + override fun getInt(name: String): Int? { + return runCatching { Settings.Secure.getInt(resolver, name) }.getOrNull() + } + + override fun getLong(name: String): Long? { + return runCatching { Settings.Secure.getLong(resolver, name) }.getOrNull() + } + + override fun getFloat(name: String): Float? { + return runCatching { Settings.Secure.getFloat(resolver, name) }.getOrNull() + } +} diff --git a/java/src/com/android/intentresolver/platform/SecureSettings.kt b/java/src/com/android/intentresolver/platform/SecureSettings.kt new file mode 100644 index 00000000..8a1dc531 --- /dev/null +++ b/java/src/com/android/intentresolver/platform/SecureSettings.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +import android.provider.Settings.SettingNotFoundException + +/** + * A component which provides access to values from [android.provider.Settings.Secure]. + * + * All methods return nullable types instead of throwing [SettingNotFoundException] which yields + * cleaner, more idiomatic Kotlin code: + * + * // apply a default: val foo = settings.getInt(FOO) ?: DEFAULT_FOO + * + * // assert if missing: val required = settings.getInt(REQUIRED_VALUE) ?: error("required value + * missing") + */ +interface SecureSettings { + + fun getString(name: String): String? + + fun getInt(name: String): Int? + + fun getLong(name: String): Long? + + fun getFloat(name: String): Float? +} diff --git a/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt b/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt new file mode 100644 index 00000000..fa3ee4fe --- /dev/null +++ b/java/src/com/android/intentresolver/platform/SecureSettingsModule.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.platform + +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface SecureSettingsModule { + + @Binds @Reusable fun secureSettings(settings: PlatformSecureSettings): SecureSettings +} diff --git a/java/src/com/android/intentresolver/profiles/AdapterBinder.java b/java/src/com/android/intentresolver/profiles/AdapterBinder.java new file mode 100644 index 00000000..f92a140f --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/AdapterBinder.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +/** + * Delegate to set up a given adapter and page view to be used together. + * + * @param <PageViewT> (as in {@link MultiProfilePagerAdapter}). + * @param <SinglePageAdapterT> (as in {@link MultiProfilePagerAdapter}). + */ +public interface AdapterBinder<PageViewT, SinglePageAdapterT> { + /** + * The given {@code view} will be associated with the given {@code adapter}. Do any work + * necessary to configure them compatibly, introduce them to each other, etc. + */ + void bind(PageViewT view, SinglePageAdapterT adapter); +} diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java index c159243e..8aee0da1 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/profiles/ChooserMultiProfilePagerAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.profiles; import android.content.Context; import android.os.UserHandle; @@ -25,9 +25,12 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.PagerAdapter; +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.ChooserRecyclerViewAccessibilityDelegate; +import com.android.intentresolver.R; +import com.android.intentresolver.emptystate.EmptyStateProvider; import com.android.intentresolver.grid.ChooserGridAdapter; import com.android.intentresolver.measurements.Tracer; -import com.android.internal.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -37,48 +40,26 @@ import java.util.function.Supplier; /** * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. */ -@VisibleForTesting -public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAdapter< +public class ChooserMultiProfilePagerAdapter extends MultiProfilePagerAdapter< RecyclerView, ChooserGridAdapter, ChooserListAdapter> { private static final int SINGLE_CELL_SPAN_SIZE = 1; private final ChooserProfileAdapterBinder mAdapterBinder; private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - ChooserMultiProfilePagerAdapter( + public ChooserMultiProfilePagerAdapter( Context context, - ChooserGridAdapter adapter, + ImmutableList<TabConfig<ChooserGridAdapter>> tabs, EmptyStateProvider emptyStateProvider, Supplier<Boolean> workProfileQuietModeChecker, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, int maxTargetsPerRow) { this( context, new ChooserProfileAdapterBinder(maxTargetsPerRow), - ImmutableList.of(adapter), - emptyStateProvider, - workProfileQuietModeChecker, - /* defaultProfile= */ 0, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context)); - } - - ChooserMultiProfilePagerAdapter( - Context context, - ChooserGridAdapter personalAdapter, - ChooserGridAdapter workAdapter, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle, - int maxTargetsPerRow) { - this( - context, - new ChooserProfileAdapterBinder(maxTargetsPerRow), - ImmutableList.of(personalAdapter, workAdapter), + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, @@ -90,24 +71,23 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda private ChooserMultiProfilePagerAdapter( Context context, ChooserProfileAdapterBinder adapterBinder, - ImmutableList<ChooserGridAdapter> gridAdapters, + ImmutableList<TabConfig<ChooserGridAdapter>> tabs, EmptyStateProvider emptyStateProvider, Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { super( - context, - gridAdapter -> gridAdapter.getListAdapter(), + gridAdapter -> gridAdapter.getListAdapter(), adapterBinder, - gridAdapters, + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, workProfileUserHandle, cloneProfileUserHandle, - () -> makeProfileView(context), + () -> makeProfileView(context), bottomPaddingOverrideSupplier); mAdapterBinder = adapterBinder; mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; @@ -119,6 +99,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda public void setEmptyStateBottomOffset(int bottomOffset) { mBottomPaddingOverrideSupplier.setEmptyStateBottomOffset(bottomOffset); + setupContainerPadding(); } /** @@ -127,14 +108,14 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda */ public void setIsCollapsed(boolean isCollapsed) { for (int i = 0, size = getItemCount(); i < size; i++) { - getAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); + getPageAdapterForIndex(i).setAzLabelVisibility(!isCollapsed); } } private static ViewGroup makeProfileView(Context context) { LayoutInflater inflater = LayoutInflater.from(context); - ViewGroup rootView = (ViewGroup) inflater.inflate( - R.layout.chooser_list_per_profile, null, false); + ViewGroup rootView = + (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false); RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); recyclerView.setAccessibilityDelegateCompat( new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); @@ -142,19 +123,36 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda } @Override - boolean rebuildActiveTab(boolean doPostProcessing) { + public boolean onHandlePackagesChanged( + ChooserListAdapter listAdapter, boolean waitingToEnableWorkProfile) { + // TODO: why do we need to do the extra `notifyDataSetChanged()` in (only) the Chooser case? + getActiveListAdapter().notifyDataSetChanged(); + return super.onHandlePackagesChanged(listAdapter, waitingToEnableWorkProfile); + } + + @Override + protected final boolean rebuildTab(ChooserListAdapter listAdapter, boolean doPostProcessing) { if (doPostProcessing) { - Tracer.INSTANCE.beginAppTargetLoadingSection(getActiveListAdapter().getUserHandle()); + Tracer.INSTANCE.beginAppTargetLoadingSection(listAdapter.getUserHandle()); } - return super.rebuildActiveTab(doPostProcessing); + return super.rebuildTab(listAdapter, doPostProcessing); } - @Override - boolean rebuildInactiveTab(boolean doPostProcessing) { - if (getItemCount() != 1 && doPostProcessing) { - Tracer.INSTANCE.beginAppTargetLoadingSection(getInactiveListAdapter().getUserHandle()); + /** Apply the specified {@code height} as the footer in each tab's adapter. */ + public void setFooterHeightInEveryAdapter(int height) { + for (int i = 0; i < getItemCount(); ++i) { + getPageAdapterForIndex(i).setFooterHeight(height); + } + } + + /** Cleanup system resources */ + public void destroy() { + for (int i = 0, count = getItemCount(); i < count; i++) { + ChooserGridAdapter adapter = getPageAdapterForIndex(i); + if (adapter != null) { + adapter.getListAdapter().onDestroy(); + } } - return super.rebuildInactiveTab(doPostProcessing); } private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> { @@ -169,6 +167,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda mBottomOffset = bottomOffset; } + @Override public Optional<Integer> get() { int initialBottomPadding = mContext.getResources().getDimensionPixelSize( R.dimen.resolver_empty_state_container_padding_bottom); diff --git a/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java new file mode 100644 index 00000000..11a6caca --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/MultiProfilePagerAdapter.java @@ -0,0 +1,705 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.profiles; + +import android.annotation.Nullable; +import android.os.Trace; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TabHost; +import android.widget.TextView; + +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyState; +import com.android.intentresolver.emptystate.EmptyStateProvider; +import com.android.intentresolver.shared.model.Profile; +import com.android.internal.annotations.VisibleForTesting; + +import com.google.common.collect.ImmutableList; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Skeletal {@link PagerAdapter} implementation for a UI with per-profile tabs (as in Sharesheet). + * + * @param <PageViewT> the type of the widget that represents the contents of a page in this adapter + * @param <SinglePageAdapterT> the type of a "root" adapter class to be instantiated and included in + * the per-profile records. + * @param <ListAdapterT> the concrete type of a {@link ResolverListAdapter} implementation to + * control the contents of a given per-profile list. This is provided for convenience, since it must + * be possible to get the list adapter from the page adapter via our + * <code>mListAdapterExtractor</code>. + */ +public class MultiProfilePagerAdapter< + PageViewT extends ViewGroup, + SinglePageAdapterT, + ListAdapterT extends ResolverListAdapter> extends PagerAdapter { + + public static final int PROFILE_PERSONAL = Profile.Type.PERSONAL.ordinal(); + public static final int PROFILE_WORK = Profile.Type.WORK.ordinal(); + + // Removed, must be constants. This is only used for linting anyway. + // @IntDef({PROFILE_PERSONAL, PROFILE_WORK}) + public @interface ProfileType {} + + private final Function<SinglePageAdapterT, ListAdapterT> mListAdapterExtractor; + private final AdapterBinder<PageViewT, SinglePageAdapterT> mAdapterBinder; + private final Supplier<ViewGroup> mPageViewInflater; + + private final ImmutableList<ProfileDescriptor<PageViewT, SinglePageAdapterT>> mItems; + + private final EmptyStateProvider mEmptyStateProvider; + private final UserHandle mWorkProfileUserHandle; + private final UserHandle mCloneProfileUserHandle; + private final Supplier<Boolean> mWorkProfileQuietModeChecker; // True when work is quiet. + + private final Set<Integer> mLoadedPages; + private int mCurrentPage; + private OnProfileSelectedListener mOnProfileSelectedListener; + + protected MultiProfilePagerAdapter( + Function<SinglePageAdapterT, ListAdapterT> listAdapterExtractor, + AdapterBinder<PageViewT, SinglePageAdapterT> adapterBinder, + ImmutableList<TabConfig<SinglePageAdapterT>> tabs, + EmptyStateProvider emptyStateProvider, + Supplier<Boolean> workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle, + Supplier<ViewGroup> pageViewInflater, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + mLoadedPages = new HashSet<>(); + mWorkProfileUserHandle = workProfileUserHandle; + mCloneProfileUserHandle = cloneProfileUserHandle; + mEmptyStateProvider = emptyStateProvider; + mWorkProfileQuietModeChecker = workProfileQuietModeChecker; + + mListAdapterExtractor = listAdapterExtractor; + mAdapterBinder = adapterBinder; + mPageViewInflater = pageViewInflater; + + ImmutableList.Builder<ProfileDescriptor<PageViewT, SinglePageAdapterT>> items = + new ImmutableList.Builder<>(); + for (TabConfig<SinglePageAdapterT> tab : tabs) { + // TODO: consider representing tabConfig in a different data structure that can ensure + // uniqueness of their profile assignments (while still respecting the client's + // requested tab order). + items.add( + createProfileDescriptor( + tab.mProfile, + tab.mTabLabel, + tab.mTabAccessibilityLabel, + tab.mTabTag, + tab.mPageAdapter, + containerBottomPaddingOverrideSupplier)); + } + mItems = items.build(); + + mCurrentPage = + hasPageForProfile(defaultProfile) ? getPageNumberForProfile(defaultProfile) : 0; + } + + private ProfileDescriptor<PageViewT, SinglePageAdapterT> createProfileDescriptor( + @ProfileType int profile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + SinglePageAdapterT adapter, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + return new ProfileDescriptor<>( + profile, + tabLabel, + tabAccessibilityLabel, + tabTag, + mPageViewInflater.get(), + adapter, + containerBottomPaddingOverrideSupplier); + } + + private boolean hasPageForIndex(int pageIndex) { + return (pageIndex >= 0) && (pageIndex < getCount()); + } + + public final boolean hasPageForProfile(@ProfileType int profile) { + return hasPageForIndex(getPageNumberForProfile(profile)); + } + + private @ProfileType int getProfileForPageNumber(int position) { + if (hasPageForIndex(position)) { + return mItems.get(position).mProfile; + } + return -1; + } + + public int getPageNumberForProfile(@ProfileType int profile) { + for (int i = 0; i < mItems.size(); ++i) { + if (profile == mItems.get(i).mProfile) { + return i; + } + } + return -1; + } + + private ListAdapterT getListAdapterForPageNumber(int pageNumber) { + SinglePageAdapterT pageAdapter = getPageAdapterForIndex(pageNumber); + if (pageAdapter == null) { + return null; + } + return mListAdapterExtractor.apply(pageAdapter); + } + + private @ProfileType int getProfileForUserHandle(UserHandle userHandle) { + if (userHandle.equals(getCloneUserHandle())) { + // TODO: can we push this special case elsewhere -- e.g., when we check against each + // list adapter's user handle in the loop below, could we instead ask the list adapter + // whether it "represents" the queried user handle, and have the personal list adapter + // return true because it knows it's also associated with the clone profile? Or if we + // don't want to make modifications to the list adapter, maybe we could at least specify + // it in our per-page configuration data that we use to build our tabs/pages, and then + // maintain the relevant bookkeeping in our own ProfileDescriptor? + return PROFILE_PERSONAL; + } + for (int i = 0; i < mItems.size(); ++i) { + ListAdapterT listAdapter = getListAdapterForPageNumber(i); + if (listAdapter.getUserHandle().equals(userHandle)) { + return mItems.get(i).mProfile; + } + } + return -1; + } + + private int getPageNumberForUserHandle(UserHandle userHandle) { + return getPageNumberForProfile(getProfileForUserHandle(userHandle)); + } + + /** + * Returns the {@link ListAdapterT} instance of the profile that represents + * <code>userHandle</code>. If there is no such adapter for the specified + * <code>userHandle</code>, returns {@code null}. + * <p>For example, if there is a work profile on the device with user id 10, calling this method + * with <code>UserHandle.of(10)</code> returns the work profile {@link ListAdapterT}. + */ + @Nullable + public final ListAdapterT getListAdapterForUserHandle(UserHandle userHandle) { + return getListAdapterForPageNumber(getPageNumberForUserHandle(userHandle)); + } + + @Nullable + private ProfileDescriptor<PageViewT, SinglePageAdapterT> getDescriptorForUserHandle( + UserHandle userHandle) { + return getItem(getPageNumberForUserHandle(userHandle)); + } + + private int getPageNumberForTabTag(String tag) { + for (int i = 0; i < mItems.size(); ++i) { + if (Objects.equals(mItems.get(i).mTabTag, tag)) { + return i; + } + } + return -1; + } + + private void updateActiveTabStyle(TabHost tabHost) { + int currentTab = tabHost.getCurrentTab(); + + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + // TODO: can we avoid this downcast by pushing our knowledge of the intended view type + // somewhere else? + TextView tabText = (TextView) tabHost.getTabWidget().getChildAt(pageNumber); + tabText.setSelected(currentTab == pageNumber); + } + } + + public void setupProfileTabs( + LayoutInflater layoutInflater, + TabHost tabHost, + ViewPager viewPager, + int tabButtonLayoutResId, + int tabPageContentViewId, + Runnable onTabChangeListener, + OnProfileSelectedListener clientOnProfileSelectedListener) { + tabHost.setup(); + tabHost.getTabWidget().removeAllViews(); + viewPager.setSaveEnabled(false); + + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = mItems.get(pageNumber); + Button profileButton = (Button) layoutInflater.inflate( + tabButtonLayoutResId, tabHost.getTabWidget(), false); + profileButton.setText(descriptor.mTabLabel); + profileButton.setContentDescription(descriptor.mTabAccessibilityLabel); + + TabHost.TabSpec profileTabSpec = tabHost.newTabSpec(descriptor.mTabTag) + .setContent(tabPageContentViewId) + .setIndicator(profileButton); + tabHost.addTab(profileTabSpec); + } + + tabHost.getTabWidget().setVisibility(View.VISIBLE); + + updateActiveTabStyle(tabHost); + + tabHost.setOnTabChangedListener(tabTag -> { + updateActiveTabStyle(tabHost); + + int pageNumber = getPageNumberForTabTag(tabTag); + if (pageNumber >= 0) { + viewPager.setCurrentItem(pageNumber); + } + onTabChangeListener.run(); + }); + + viewPager.setVisibility(View.VISIBLE); + tabHost.setCurrentTab(getCurrentPage()); + mOnProfileSelectedListener = + new OnProfileSelectedListener() { + @Override + public void onProfilePageSelected(@ProfileType int profileId, int pageNumber) { + tabHost.setCurrentTab(pageNumber); + clientOnProfileSelectedListener.onProfilePageSelected( + profileId, pageNumber); + } + + @Override + public void onProfilePageStateChanged(int state) { + clientOnProfileSelectedListener.onProfilePageStateChanged(state); + } + }; + } + + /** + * Sets this instance of this class as {@link ViewPager}'s {@link PagerAdapter} and sets + * an {@link ViewPager.OnPageChangeListener} where it keeps track of the currently displayed + * page and rebuilds the list. + */ + public void setupViewPager(ViewPager viewPager) { + viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { + @Override + public void onPageSelected(int position) { + MultiProfilePagerAdapter.this.onPageSelected(position); + } + + @Override + public void onPageScrollStateChanged(int state) { + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfilePageStateChanged(state); + } + } + }); + viewPager.setAdapter(this); + viewPager.setCurrentItem(mCurrentPage); + mLoadedPages.add(mCurrentPage); + } + + private void onPageSelected(int position) { + mCurrentPage = position; + if (!mLoadedPages.contains(position)) { + rebuildActiveTab(true); + mLoadedPages.add(position); + } + if (mOnProfileSelectedListener != null) { + mOnProfileSelectedListener.onProfilePageSelected( + getProfileForPageNumber(position), position); + } + } + + public void clearInactiveProfileCache() { + forEachInactivePage(pageNumber -> mLoadedPages.remove(pageNumber)); + } + + @Override + public final ViewGroup instantiateItem(ViewGroup container, int position) { + setupListAdapter(position); + final ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = getItem(position); + container.addView(descriptor.mRootView); + return descriptor.mRootView; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object view) { + container.removeView((View) view); + } + + @Override + public int getCount() { + return getItemCount(); + } + + public int getCurrentPage() { + return mCurrentPage; + } + + /** + * Set active adapter page. A support method for the poayload reselection logic. + */ + public void setCurrentPage(int page) { + onPageSelected(page); + } + + public final @ProfileType int getActiveProfile() { + return getProfileForPageNumber(getCurrentPage()); + } + + @VisibleForTesting + public UserHandle getCurrentUserHandle() { + return getActiveListAdapter().getUserHandle(); + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return view == object; + } + + @Override + public CharSequence getPageTitle(int position) { + return null; + } + + public UserHandle getCloneUserHandle() { + return mCloneProfileUserHandle; + } + + /** + * Returns the {@link ProfileDescriptor} relevant to the given <code>pageIndex</code>. + * <ul> + * <li>For a device with only one user, <code>pageIndex</code> value of + * <code>0</code> would return the personal profile {@link ProfileDescriptor}.</li> + * <li>For a device with a work profile, <code>pageIndex</code> value of <code>0</code> would + * return the personal profile {@link ProfileDescriptor}, and <code>pageIndex</code> value of + * <code>1</code> would return the work profile {@link ProfileDescriptor}.</li> + * </ul> + */ + @Nullable + private ProfileDescriptor<PageViewT, SinglePageAdapterT> getItem(int pageIndex) { + if (!hasPageForIndex(pageIndex)) { + return null; + } + return mItems.get(pageIndex); + } + + private ViewGroup getEmptyStateView(int pageIndex) { + return getItem(pageIndex).getEmptyStateView(); + } + + public ViewGroup getActiveEmptyStateView() { + return getEmptyStateView(getCurrentPage()); + } + + /** + * Returns the number of {@link ProfileDescriptor} objects. + * <p>For a normal consumer device with only one user returns <code>1</code>. + * <p>For a device with a work profile returns <code>2</code>. + */ + public final int getItemCount() { + return mItems.size(); + } + + public final PageViewT getListViewForIndex(int index) { + return getItem(index).getView(); + } + + /** + * Returns the adapter of the list view for the relevant page specified by + * <code>pageIndex</code>. + * <p>This method is meant to be implemented with an implementation-specific return type + * depending on the adapter type. + */ + @VisibleForTesting + public final SinglePageAdapterT getPageAdapterForIndex(int index) { + if (!hasPageForIndex(index)) { + return null; + } + return getItem(index).getAdapter(); + } + + /** + * Performs view-related initialization procedures for the adapter specified + * by <code>pageIndex</code>. + */ + public final void setupListAdapter(int pageIndex) { + mAdapterBinder.bind(getListViewForIndex(pageIndex), getPageAdapterForIndex(pageIndex)); + } + + /** + * Returns the {@link ListAdapterT} instance of the profile that is currently visible + * to the user. + * <p>For example, if the user is viewing the work tab in the share sheet, this method returns + * the work profile {@link ListAdapterT}. + */ + @VisibleForTesting + public final ListAdapterT getActiveListAdapter() { + return getListAdapterForPageNumber(getCurrentPage()); + } + + public final ListAdapterT getPersonalListAdapter() { + return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_PERSONAL)); + } + + @Nullable + public final ListAdapterT getWorkListAdapter() { + if (!hasPageForProfile(PROFILE_WORK)) { + return null; + } + return getListAdapterForPageNumber(getPageNumberForProfile(PROFILE_WORK)); + } + + public final SinglePageAdapterT getCurrentRootAdapter() { + return getPageAdapterForIndex(getCurrentPage()); + } + + public final PageViewT getActiveAdapterView() { + return getListViewForIndex(getCurrentPage()); + } + + private boolean anyAdapterHasItems() { + for (int i = 0; i < mItems.size(); ++i) { + ListAdapterT listAdapter = getListAdapterForPageNumber(i); + if (listAdapter.getCount() > 0) { + return true; + } + } + return false; + } + + public void refreshPackagesInAllTabs() { + // TODO: it's unclear if this legacy logic really requires the active tab to be rebuilt + // first, or if we could just iterate over the tabs in arbitrary order. + getActiveListAdapter().handlePackagesChanged(); + forEachInactivePage(page -> getListAdapterForPageNumber(page).handlePackagesChanged()); + } + + /** + * Notify that there has been a package change which could potentially modify the set of targets + * that should be shown in the specified {@code listAdapter}. This <em>may</em> result in + * "rebuilding" the target list for that adapter. + * + * @param listAdapter an adapter that may need to be updated after the package-change event. + * @param waitingToEnableWorkProfile whether we've turned on the work profile, but haven't yet + * seen an {@code ACTION_USER_UNLOCKED} broadcast. In this case we skip the rebuild of any + * work-profile adapter because we wouldn't expect meaningful results -- but another rebuild + * will be prompted when we eventually get the broadcast. + * + * @return whether we're able to proceed with a Sharesheet session after processing this + * package-change event. If false, we were able to rebuild the targets but determined that there + * aren't any we could present in the UI without the app looking broken, so we should just quit. + */ + public boolean onHandlePackagesChanged( + ListAdapterT listAdapter, boolean waitingToEnableWorkProfile) { + if (listAdapter == getActiveListAdapter()) { + if (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) + && waitingToEnableWorkProfile) { + // We have just turned on the work profile and entered the passcode to start it, + // now we are waiting to receive the ACTION_USER_UNLOCKED broadcast. There is no + // point in reloading the list now, since the work profile user is still turning on. + return true; + } + + boolean listRebuilt = rebuildActiveTab(true); + if (listRebuilt) { + listAdapter.notifyDataSetChanged(); + } + + // TODO: shouldn't we check that the inactive tabs are built before declaring that we + // have to quit for lack of items? + return anyAdapterHasItems(); + } else { + clearInactiveProfileCache(); + return true; + } + } + + /** + * Fully-rebuild the active tab and, if specified, partially-rebuild any other inactive tabs. + */ + public boolean rebuildTabs(boolean includePartialRebuildOfInactiveTabs) { + // TODO: we may be able to determine `includePartialRebuildOfInactiveTabs` ourselves as + // a function of our own instance state. OTOH the purpose of this "partial rebuild" is to + // be able to evaluate the intermediate state of one particular profile tab (i.e. work + // profile) that may not generalize well when we have other "inactive tabs." I.e., either we + // rebuild *all* the inactive tabs just to evaluate some auto-launch conditions that only + // depend on personal and/or work tabs, or we have to explicitly specify the ones we care + // about. It's not the pager-adapter's business to know "which ones we care about," so maybe + // they should be rebuilt lazily when-and-if it comes up (e.g. during the evaluation of + // autolaunch conditions). + boolean rebuildCompleted = rebuildActiveTab(true) || getActiveListAdapter().isTabLoaded(); + if (includePartialRebuildOfInactiveTabs) { + // Per legacy logic, avoid short-circuiting (TODO: why? possibly so that we *start* + // loading the inactive tabs even if we're still waiting on the active tab to finish?). + boolean completedRebuildingInactiveTabs = rebuildInactiveTabs(false); + rebuildCompleted = rebuildCompleted && completedRebuildingInactiveTabs; + } + return rebuildCompleted; + } + + /** + * Rebuilds the tab that is currently visible to the user. + * <p>Returns {@code true} if rebuild has completed. + */ + public final boolean rebuildActiveTab(boolean doPostProcessing) { + Trace.beginSection("MultiProfilePagerAdapter#rebuildActiveTab"); + boolean result = rebuildTab(getActiveListAdapter(), doPostProcessing); + Trace.endSection(); + return result; + } + + /** + * Rebuilds any tabs that are not currently visible to the user. + * <p>Returns {@code true} if rebuild has completed in all inactive tabs. + */ + private boolean rebuildInactiveTabs(boolean doPostProcessing) { + Trace.beginSection("MultiProfilePagerAdapter#rebuildInactiveTab"); + AtomicBoolean allRebuildsComplete = new AtomicBoolean(true); + forEachInactivePage(pageNumber -> { + // Evaluate the rebuild for every inactive page, even if we've already seen some adapter + // return an "incomplete" status (i.e., even if `allRebuildsComplete` is already false) + // and so we already know we'll end up returning false for the batch. + // TODO: any particular reason the per-page legacy logic was set up in this order, or + // could we possibly short-circuit the rebuild if the tab is already "loaded"? + ListAdapterT inactiveAdapter = getListAdapterForPageNumber(pageNumber); + boolean rebuildInactivePageCompleted = + rebuildTab(inactiveAdapter, doPostProcessing) || inactiveAdapter.isTabLoaded(); + if (!rebuildInactivePageCompleted) { + allRebuildsComplete.set(false); + } + }); + Trace.endSection(); + return allRebuildsComplete.get(); + } + + protected void forEachPage(Consumer<Integer> pageNumberHandler) { + for (int pageNumber = 0; pageNumber < getItemCount(); ++pageNumber) { + pageNumberHandler.accept(pageNumber); + } + } + + protected void forEachInactivePage(Consumer<Integer> inactivePageNumberHandler) { + forEachPage(pageNumber -> { + if (pageNumber != getCurrentPage()) { + inactivePageNumberHandler.accept(pageNumber); + } + }); + } + + protected boolean rebuildTab(ListAdapterT activeListAdapter, boolean doPostProcessing) { + if (shouldSkipRebuild(activeListAdapter)) { + activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); + return false; + } + return activeListAdapter.rebuildList(doPostProcessing); + } + + private boolean shouldSkipRebuild(ListAdapterT activeListAdapter) { + EmptyState emptyState = mEmptyStateProvider.getEmptyState(activeListAdapter); + return emptyState != null && emptyState.shouldSkipDataRebuild(); + } + + /** + * The empty state screens are shown according to their priority: + * <ol> + * <li>(highest priority) cross-profile disabled by policy (handled in + * {@link #rebuildTab(ListAdapterT, boolean)})</li> + * <li>no apps available</li> + * <li>(least priority) work is off</li> + * </ol> + * + * The intention is to prevent the user from having to turn + * the work profile on if there will not be any apps resolved + * anyway. + * + * TODO: move this comment to the place where we configure our composite provider. + */ + public void showEmptyResolverListEmptyState(ListAdapterT listAdapter) { + final EmptyState emptyState = mEmptyStateProvider.getEmptyState(listAdapter); + + if (emptyState == null) { + return; + } + + emptyState.onEmptyStateShown(); + + View.OnClickListener clickListener = null; + + if (emptyState.getButtonClickListener() != null) { + clickListener = v -> emptyState.getButtonClickListener().onClick(() -> { + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = + getDescriptorForUserHandle(listAdapter.getUserHandle()); + descriptor.mEmptyStateUi.showSpinner(); + }); + } + + showEmptyState(listAdapter, emptyState, clickListener); + } + + private void showEmptyState( + ListAdapterT activeListAdapter, + EmptyState emptyState, + View.OnClickListener buttonOnClick) { + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = + getDescriptorForUserHandle(activeListAdapter.getUserHandle()); + descriptor.mEmptyStateUi.showEmptyState(emptyState, buttonOnClick); + activeListAdapter.markTabLoaded(); + } + + /** + * Sets up the padding of the view containing the empty state screens for the current adapter + * view. + */ + protected final void setupContainerPadding() { + getItem(getCurrentPage()).setupContainerPadding(); + } + + public void showListView(ListAdapterT activeListAdapter) { + ProfileDescriptor<PageViewT, SinglePageAdapterT> descriptor = + getDescriptorForUserHandle(activeListAdapter.getUserHandle()); + descriptor.mEmptyStateUi.hide(); + } + + /** + * @return whether any "inactive" tab's adapter would show an empty-state screen in our current + * application state. + */ + public final boolean shouldShowEmptyStateScreenInAnyInactiveAdapter() { + AtomicBoolean anyEmpty = new AtomicBoolean(false); + // TODO: The "inactive" condition is legacy logic. Could we simplify and ask "any"? + forEachInactivePage(pageNumber -> { + if (shouldShowEmptyStateScreen(getListAdapterForPageNumber(pageNumber))) { + anyEmpty.set(true); + } + }); + return anyEmpty.get(); + } + + public boolean shouldShowEmptyStateScreen(ListAdapterT listAdapter) { + int count = listAdapter.getUnfilteredCount(); + return (count == 0 && listAdapter.getPlaceholderCount() == 0) + || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) + && mWorkProfileQuietModeChecker.get()); + } + +} diff --git a/java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java b/java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java new file mode 100644 index 00000000..e6299954 --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/OnProfileSelectedListener.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +import androidx.viewpager.widget.ViewPager; + +/** Listener interface for changes between the per-profile UI tabs. */ +public interface OnProfileSelectedListener { + /** + * Callback for when the user changes the active tab. + * <p>This callback is only called when the intent resolver or share sheet shows + * more than one profile. + * + * @param profileId the ID of the newly-selected profile, e.g. {@link #PROFILE_PERSONAL} + * if the personal profile tab was selected or {@link #PROFILE_WORK} if the + * work profile tab + * was selected. + */ + void onProfilePageSelected(@MultiProfilePagerAdapter.ProfileType int profileId, int pageNumber); + + + /** + * Callback for when the scroll state changes. Useful for discovering when the user begins + * dragging, when the pager is automatically settling to the current page, or when it is + * fully stopped/idle. + * + * @param state {@link ViewPager#SCROLL_STATE_IDLE}, {@link ViewPager#SCROLL_STATE_DRAGGING} + * or {@link ViewPager#SCROLL_STATE_SETTLING} + * @see ViewPager.OnPageChangeListener#onPageScrollStateChanged + */ + void onProfilePageStateChanged(int state); +} diff --git a/java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java b/java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java new file mode 100644 index 00000000..7989551a --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/OnSwitchOnWorkSelectedListener.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +/** + * Listener for when the user switches on the work profile from the work tab. + */ +public interface OnSwitchOnWorkSelectedListener { + /** + * Callback for when the user switches on the work profile from the work tab. + */ + void onSwitchOnWorkSelected(); +} diff --git a/java/src/com/android/intentresolver/profiles/ProfileDescriptor.java b/java/src/com/android/intentresolver/profiles/ProfileDescriptor.java new file mode 100644 index 00000000..61c7c670 --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/ProfileDescriptor.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +import android.view.ViewGroup; + +import com.android.intentresolver.emptystate.EmptyStateUiHelper; + +import java.util.Optional; +import java.util.function.Supplier; + +// TODO: `ChooserActivity` also has a per-profile record type. Maybe the "multi-profile pager" +// should be the owner of all per-profile data (especially now that the API is generic)? +class ProfileDescriptor<PageViewT, SinglePageAdapterT> { + final @MultiProfilePagerAdapter.ProfileType int mProfile; + final String mTabLabel; + final String mTabAccessibilityLabel; + final String mTabTag; + + final ViewGroup mRootView; + final EmptyStateUiHelper mEmptyStateUi; + + // TODO: post-refactoring, we may not need to retain these ivars directly (since they may + // be encapsulated within the `EmptyStateUiHelper`?). + private final ViewGroup mEmptyStateView; + + private final SinglePageAdapterT mAdapter; + + public SinglePageAdapterT getAdapter() { + return mAdapter; + } + + public PageViewT getView() { + return mView; + } + + private final PageViewT mView; + + ProfileDescriptor( + @MultiProfilePagerAdapter.ProfileType int forProfile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + ViewGroup rootView, + SinglePageAdapterT adapter, + Supplier<Optional<Integer>> containerBottomPaddingOverrideSupplier) { + mProfile = forProfile; + mTabLabel = tabLabel; + mTabAccessibilityLabel = tabAccessibilityLabel; + mTabTag = tabTag; + mRootView = rootView; + mAdapter = adapter; + mEmptyStateView = rootView.findViewById(com.android.internal.R.id.resolver_empty_state); + mView = (PageViewT) rootView.findViewById(com.android.internal.R.id.resolver_list); + mEmptyStateUi = new EmptyStateUiHelper( + rootView, + com.android.internal.R.id.resolver_list, + containerBottomPaddingOverrideSupplier); + } + + protected ViewGroup getEmptyStateView() { + return mEmptyStateView; + } + + public void setupContainerPadding() { + mEmptyStateUi.setupContainerPadding(); + } +} diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java index 85d97ad5..0c669510 100644 --- a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/profiles/ResolverMultiProfilePagerAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 The Android Open Source Project + * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.android.intentresolver; +package com.android.intentresolver.profiles; import android.content.Context; import android.os.UserHandle; @@ -24,7 +24,9 @@ import android.widget.ListView; import androidx.viewpager.widget.PagerAdapter; -import com.android.internal.annotations.VisibleForTesting; +import com.android.intentresolver.R; +import com.android.intentresolver.ResolverListAdapter; +import com.android.intentresolver.emptystate.EmptyStateProvider; import com.google.common.collect.ImmutableList; @@ -34,40 +36,20 @@ import java.util.function.Supplier; /** * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. */ -@VisibleForTesting public class ResolverMultiProfilePagerAdapter extends - GenericMultiProfilePagerAdapter<ListView, ResolverListAdapter, ResolverListAdapter> { + MultiProfilePagerAdapter<ListView, ResolverListAdapter, ResolverListAdapter> { private final BottomPaddingOverrideSupplier mBottomPaddingOverrideSupplier; - ResolverMultiProfilePagerAdapter( - Context context, - ResolverListAdapter adapter, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { + public ResolverMultiProfilePagerAdapter(Context context, + ImmutableList<TabConfig<ResolverListAdapter>> tabs, + EmptyStateProvider emptyStateProvider, + Supplier<Boolean> workProfileQuietModeChecker, + @ProfileType int defaultProfile, + UserHandle workProfileUserHandle, + UserHandle cloneProfileUserHandle) { this( context, - ImmutableList.of(adapter), - emptyStateProvider, - workProfileQuietModeChecker, - /* defaultProfile= */ 0, - workProfileUserHandle, - cloneProfileUserHandle, - new BottomPaddingOverrideSupplier()); - } - - ResolverMultiProfilePagerAdapter(Context context, - ResolverListAdapter personalAdapter, - ResolverListAdapter workAdapter, - EmptyStateProvider emptyStateProvider, - Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, - UserHandle workProfileUserHandle, - UserHandle cloneProfileUserHandle) { - this( - context, - ImmutableList.of(personalAdapter, workAdapter), + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, @@ -78,18 +60,17 @@ public class ResolverMultiProfilePagerAdapter extends private ResolverMultiProfilePagerAdapter( Context context, - ImmutableList<ResolverListAdapter> listAdapters, + ImmutableList<TabConfig<ResolverListAdapter>> tabs, EmptyStateProvider emptyStateProvider, Supplier<Boolean> workProfileQuietModeChecker, - @Profile int defaultProfile, + @ProfileType int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { super( - context, listAdapter -> listAdapter, (listView, bindAdapter) -> listView.setAdapter(bindAdapter), - listAdapters, + tabs, emptyStateProvider, workProfileQuietModeChecker, defaultProfile, @@ -105,6 +86,17 @@ public class ResolverMultiProfilePagerAdapter extends mBottomPaddingOverrideSupplier.setUseLayoutWithDefault(useLayoutWithDefault); } + /** Un-check any item(s) that may be checked in any of our inactive adapter(s). */ + public void clearCheckedItemsInInactiveProfiles() { + // TODO: The "inactive" condition is legacy logic. Could we simplify and clear-all? + forEachInactivePage(pageNumber -> { + ListView inactiveListView = getListViewForIndex(pageNumber); + if (inactiveListView.getCheckedItemCount() > 0) { + inactiveListView.setItemChecked(inactiveListView.getCheckedItemPosition(), false); + } + }); + } + private static class BottomPaddingOverrideSupplier implements Supplier<Optional<Integer>> { private boolean mUseLayoutWithDefault; diff --git a/java/src/com/android/intentresolver/profiles/TabConfig.java b/java/src/com/android/intentresolver/profiles/TabConfig.java new file mode 100644 index 00000000..320f069a --- /dev/null +++ b/java/src/com/android/intentresolver/profiles/TabConfig.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.profiles; + +public class TabConfig<PageAdapterT> { + final @MultiProfilePagerAdapter.ProfileType int mProfile; + final String mTabLabel; + final String mTabAccessibilityLabel; + final String mTabTag; + final PageAdapterT mPageAdapter; + + public TabConfig( + @MultiProfilePagerAdapter.ProfileType int profile, + String tabLabel, + String tabAccessibilityLabel, + String tabTag, + PageAdapterT pageAdapter) { + mProfile = profile; + mTabLabel = tabLabel; + mTabAccessibilityLabel = tabAccessibilityLabel; + mTabTag = tabTag; + mPageAdapter = pageAdapter; + } +} diff --git a/java/src/com/android/intentresolver/shared/model/Profile.kt b/java/src/com/android/intentresolver/shared/model/Profile.kt new file mode 100644 index 00000000..c557c151 --- /dev/null +++ b/java/src/com/android/intentresolver/shared/model/Profile.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.shared.model + +import com.android.intentresolver.shared.model.Profile.Type + +/** + * Associates [users][User] into a [Type] instance. + * + * This is a simple abstraction which combines a primary [user][User] with an optional + * [cloned apps][User.Role.CLONE] user. This encapsulates the cloned app user id, while still being + * available where needed. + */ +data class Profile( + val type: Type, + val primary: User, + /** + * An optional [User] of which contains second instances of some applications installed for the + * personal user. This value may only be supplied when creating the PERSONAL profile. + */ + val clone: User? = null +) { + + init { + clone?.apply { + require(primary.role == User.Role.PERSONAL) { + "clone is not supported for profile=${this@Profile.type} / primary=$primary" + } + require(role == User.Role.CLONE) { "clone is not a clone user ($this)" } + } + } + + enum class Type { + PERSONAL, + WORK, + PRIVATE + } +} diff --git a/java/src/com/android/intentresolver/shared/model/User.kt b/java/src/com/android/intentresolver/shared/model/User.kt new file mode 100644 index 00000000..b544a390 --- /dev/null +++ b/java/src/com/android/intentresolver/shared/model/User.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.shared.model + +import android.annotation.UserIdInt +import android.os.UserHandle + +/** + * A User represents the owner of a distinct set of content. + * * maps 1:1 to a UserHandle or UserId (Int) value. + * * refers to either [Full][Type.FULL], or a [Profile][Type.PROFILE] user, as indicated by the + * [type] property. + * + * See + * [Users for system developers](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/os/Users.md) + * + * ``` + * val users = listOf( + * User(id = 0, role = PERSONAL), + * User(id = 10, role = WORK), + * User(id = 11, role = CLONE), + * User(id = 12, role = PRIVATE), + * ) + * ``` + */ +data class User( + @UserIdInt val id: Int, + val role: Role, +) { + val handle: UserHandle = UserHandle.of(id) + + enum class Role { + PERSONAL, + PRIVATE, + WORK, + CLONE + } +} diff --git a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt index 82f40b91..c7bd0336 100644 --- a/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt +++ b/java/src/com/android/intentresolver/shortcuts/AppPredictorFactory.kt @@ -31,37 +31,39 @@ private const val SHARED_TEXT_KEY = "shared_text" /** * A factory to create an AppPredictor instance for a profile, if available. + * * @param context, application context - * @param sharedText, a shared text associated with the Chooser's target intent - * (see [android.content.Intent.EXTRA_TEXT]). - * Will be mapped to app predictor's "shared_text" parameter. - * @param targetIntentFilter, an IntentFilter to match direct share targets against. - * Will be mapped app predictor's "intent_filter" parameter. + * @param sharedText, a shared text associated with the Chooser's target intent (see + * [android.content.Intent.EXTRA_TEXT]). Will be mapped to app predictor's "shared_text" + * parameter. + * @param targetIntentFilter, an IntentFilter to match direct share targets against. Will be mapped + * app predictor's "intent_filter" parameter. */ class AppPredictorFactory( private val context: Context, private val sharedText: String?, - private val targetIntentFilter: IntentFilter? + private val targetIntentFilter: IntentFilter?, + private val appPredictionAvailable: Boolean, ) { - private val mIsComponentAvailable = - context.packageManager.appPredictionServicePackageName != null - /** * Creates an AppPredictor instance for a profile or `null` if app predictor is not available. */ fun create(userHandle: UserHandle): AppPredictor? { - if (!mIsComponentAvailable) return null + if (!appPredictionAvailable) return null val contextAsUser = context.createContextAsUser(userHandle, 0 /* flags */) - val extras = Bundle().apply { - putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter) - putString(SHARED_TEXT_KEY, sharedText) - } - val appPredictionContext = AppPredictionContext.Builder(contextAsUser) - .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE) - .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT) - .setExtras(extras) - .build() - return contextAsUser.getSystemService(AppPredictionManager::class.java) + val extras = + Bundle().apply { + putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, targetIntentFilter) + putString(SHARED_TEXT_KEY, sharedText) + } + val appPredictionContext = + AppPredictionContext.Builder(contextAsUser) + .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE) + .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT) + .setExtras(extras) + .build() + return contextAsUser + .getSystemService(AppPredictionManager::class.java) ?.createAppPredictionSession(appPredictionContext) } } diff --git a/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt b/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt new file mode 100644 index 00000000..9606a6a1 --- /dev/null +++ b/java/src/com/android/intentresolver/shortcuts/ScopedAppTargetListCallback.kt @@ -0,0 +1,58 @@ +/* + * 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.shortcuts + +import android.app.prediction.AppPredictor +import android.app.prediction.AppTarget +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.coroutineScope +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch + +/** + * A memory leak workaround for b/290971946. Drops the references to the actual [callback] when the + * [scope] is cancelled allowing it to be garbage-collected (and only leaking this instance). + */ +class ScopedAppTargetListCallback( + scope: CoroutineScope?, + callback: (List<AppTarget>) -> Unit, +) { + + @Volatile private var callbackRef: ((List<AppTarget>) -> Unit)? = callback + + constructor( + context: Context, + callback: (List<AppTarget>) -> Unit, + ) : this((context as? LifecycleOwner)?.lifecycle?.coroutineScope, callback) + + init { + scope?.launch { awaitCancellation() }?.invokeOnCompletion { callbackRef = null } + } + + private fun notifyCallback(result: List<AppTarget>) { + callbackRef?.invoke(result) + } + + fun toConsumer(): Consumer<MutableList<AppTarget>?> = + Consumer<MutableList<AppTarget>?> { notifyCallback(it ?: emptyList()) } + + fun toAppPredictorCallback(): AppPredictor.Callback = + AppPredictor.Callback { notifyCallback(it) } +} diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt index 3ffbe039..08230d90 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutLoader.kt @@ -35,14 +35,13 @@ import androidx.annotation.MainThread import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope 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.function.Consumer import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.BufferOverflow @@ -50,6 +49,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** @@ -58,14 +58,14 @@ import kotlinx.coroutines.launch * A ShortcutLoader instance can be viewed as a per-profile singleton hot stream of shortcut * updates. The shortcut loading is triggered in the constructor or by the [reset] method, the * processing happens on the [dispatcher] and the result is delivered through the [callback] on the - * default [lifecycle]'s dispatcher, the main thread. + * default [scope]'s dispatcher, the main thread. */ @OpenForTesting open class ShortcutLoader @VisibleForTesting constructor( private val context: Context, - private val lifecycle: Lifecycle, + private val scope: CoroutineScope, private val appPredictor: AppPredictorProxy?, private val userHandle: UserHandle, private val isPersonalProfile: Boolean, @@ -75,7 +75,9 @@ constructor( ) { private val shortcutToChooserTargetConverter = ShortcutToChooserTargetConverter() private val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager - private val appPredictorCallback = AppPredictor.Callback { onAppPredictorCallback(it) } + private val appPredictorCallback = + ScopedAppTargetListCallback(scope) { onAppPredictorCallback(it) }.toAppPredictorCallback() + private val appTargetSource = MutableSharedFlow<Array<DisplayResolveInfo>?>( replay = 1, @@ -84,19 +86,19 @@ constructor( private val shortcutSource = MutableSharedFlow<ShortcutData?>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val isDestroyed - get() = !lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) + get() = !scope.isActive @MainThread constructor( context: Context, - lifecycle: Lifecycle, + scope: CoroutineScope, appPredictor: AppPredictor?, userHandle: UserHandle, targetIntentFilter: IntentFilter?, callback: Consumer<Result> ) : this( context, - lifecycle, + scope, appPredictor?.let { AppPredictorProxy(it) }, userHandle, userHandle == UserHandle.of(ActivityManager.getCurrentUser()), @@ -107,7 +109,7 @@ constructor( init { appPredictor?.registerPredictionUpdates(dispatcher.asExecutor(), appPredictorCallback) - lifecycle.coroutineScope + scope .launch { appTargetSource .combine(shortcutSource) { appTargets, shortcutData -> @@ -135,12 +137,13 @@ constructor( reset() } - /** Clear application targets (see [updateAppTargets] and initiate shrtcuts loading. */ - fun reset() { + /** Clear application targets (see [updateAppTargets] and initiate shortcuts loading. */ + @OpenForTesting + open fun reset() { Log.d(TAG, "reset shortcut loader for user $userHandle") appTargetSource.tryEmit(null) shortcutSource.tryEmit(null) - lifecycle.coroutineScope.launch(dispatcher) { loadShortcuts() } + scope.launch(dispatcher) { loadShortcuts() } } /** @@ -183,6 +186,11 @@ constructor( // Default to just querying ShortcutManager if AppPredictor not present. if (targetIntentFilter == null) { Log.d(TAG, "skip querying ShortcutManager for $userHandle") + sendShareShortcutInfoList( + emptyList(), + isFromAppPredictor = false, + appPredictorTargets = null + ) return } Log.d(TAG, "query ShortcutManager for user $userHandle") diff --git a/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java index a37d6558..31929948 100644 --- a/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java +++ b/java/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverter.java @@ -16,8 +16,6 @@ package com.android.intentresolver.shortcuts; -import android.annotation.NonNull; -import android.annotation.Nullable; import android.app.prediction.AppTarget; import android.content.Intent; import android.content.pm.ShortcutInfo; @@ -25,6 +23,9 @@ import android.content.pm.ShortcutManager; import android.os.Bundle; import android.service.chooser.ChooserTarget; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; diff --git a/java/src/com/android/intentresolver/ui/ActionTitle.java b/java/src/com/android/intentresolver/ui/ActionTitle.java new file mode 100644 index 00000000..1cc96fa9 --- /dev/null +++ b/java/src/com/android/intentresolver/ui/ActionTitle.java @@ -0,0 +1,88 @@ +/* + * 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.ui; + +import android.content.Intent; +import android.provider.MediaStore; + +import androidx.annotation.StringRes; + +import com.android.intentresolver.R; + +/** + * Provides a set of related resources for different use cases. + */ +public enum ActionTitle { + VIEW(Intent.ACTION_VIEW, + R.string.whichViewApplication, + R.string.whichViewApplicationNamed, + R.string.whichViewApplicationLabel), + EDIT(Intent.ACTION_EDIT, + R.string.whichEditApplication, + R.string.whichEditApplicationNamed, + R.string.whichEditApplicationLabel), + SEND(Intent.ACTION_SEND, + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), + SENDTO(Intent.ACTION_SENDTO, + R.string.whichSendToApplication, + R.string.whichSendToApplicationNamed, + R.string.whichSendToApplicationLabel), + SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, + R.string.whichSendApplication, + R.string.whichSendApplicationNamed, + R.string.whichSendApplicationLabel), + CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, + R.string.whichImageCaptureApplication, + R.string.whichImageCaptureApplicationNamed, + R.string.whichImageCaptureApplicationLabel), + DEFAULT(null, + R.string.whichApplication, + R.string.whichApplicationNamed, + R.string.whichApplicationLabel), + HOME(Intent.ACTION_MAIN, + R.string.whichHomeApplication, + R.string.whichHomeApplicationNamed, + R.string.whichHomeApplicationLabel); + + // titles for layout that deals with http(s) intents + public static final int BROWSABLE_TITLE_RES = R.string.whichOpenLinksWith; + public static final int BROWSABLE_HOST_TITLE_RES = R.string.whichOpenHostLinksWith; + public static final int BROWSABLE_HOST_APP_TITLE_RES = R.string.whichOpenHostLinksWithApp; + public static final int BROWSABLE_APP_TITLE_RES = R.string.whichOpenLinksWithApp; + + public final String action; + public final int titleRes; + public final int namedTitleRes; + public final @StringRes int labelRes; + + ActionTitle(String action, int titleRes, int namedTitleRes, @StringRes int labelRes) { + this.action = action; + this.titleRes = titleRes; + this.namedTitleRes = namedTitleRes; + this.labelRes = labelRes; + } + + public static ActionTitle forAction(String action) { + for (ActionTitle title : values()) { + if (title != HOME && action != null && action.equals(title.action)) { + return title; + } + } + return DEFAULT; + } +} diff --git a/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt b/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt new file mode 100644 index 00000000..0d07af8f --- /dev/null +++ b/java/src/com/android/intentresolver/ui/ProfilePagerResources.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui + +import android.content.res.Resources +import com.android.intentresolver.R +import com.android.intentresolver.data.repository.DevicePolicyResources +import com.android.intentresolver.inject.ApplicationOwned +import com.android.intentresolver.shared.model.Profile +import javax.inject.Inject + +class ProfilePagerResources +@Inject +constructor( + @ApplicationOwned private val resources: Resources, + private val devicePolicyResources: DevicePolicyResources +) { + private val privateTabLabel by lazy { resources.getString(R.string.resolver_private_tab) } + + private val privateTabAccessibilityLabel by lazy { + resources.getString(R.string.resolver_private_tab_accessibility) + } + + fun profileTabLabel(profile: Profile.Type): String { + return when (profile) { + Profile.Type.PERSONAL -> devicePolicyResources.personalTabLabel + Profile.Type.WORK -> devicePolicyResources.workTabLabel + Profile.Type.PRIVATE -> privateTabLabel + } + } + + fun profileTabAccessibilityLabel(type: Profile.Type): String { + return when (type) { + Profile.Type.PERSONAL -> devicePolicyResources.personalTabAccessibilityLabel + Profile.Type.WORK -> devicePolicyResources.workTabAccessibilityLabel + Profile.Type.PRIVATE -> privateTabAccessibilityLabel + } + } + + fun noAppsMessage(type: Profile.Type): String { + return when (type) { + Profile.Type.PERSONAL -> devicePolicyResources.noPersonalApps + Profile.Type.WORK -> devicePolicyResources.noWorkApps + Profile.Type.PRIVATE -> resources.getString(R.string.resolver_no_private_apps_available) + } + } +} diff --git a/java/src/com/android/intentresolver/ui/ShareResultSender.kt b/java/src/com/android/intentresolver/ui/ShareResultSender.kt new file mode 100644 index 00000000..7be2076e --- /dev/null +++ b/java/src/com/android/intentresolver/ui/ShareResultSender.kt @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui + +import android.app.Activity +import android.app.compat.CompatChanges +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.service.chooser.ChooserResult +import android.service.chooser.ChooserResult.CHOOSER_RESULT_COPY +import android.service.chooser.ChooserResult.CHOOSER_RESULT_EDIT +import android.service.chooser.ChooserResult.CHOOSER_RESULT_SELECTED_COMPONENT +import android.service.chooser.ChooserResult.CHOOSER_RESULT_UNKNOWN +import android.service.chooser.ChooserResult.ResultType +import android.util.Log +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.inject.Main +import com.android.intentresolver.ui.model.ShareAction +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.qualifiers.ActivityContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "ShareResultSender" + +/** Reports the result of a share to another process across binder, via an [IntentSender] */ +interface ShareResultSender { + /** Reports user selection of an activity to launch from the provided choices. */ + fun onComponentSelected(component: ComponentName, directShare: Boolean) + + /** Reports user invocation of a built-in system action. See [ShareAction]. */ + fun onActionSelected(action: ShareAction) +} + +@AssistedFactory +interface ShareResultSenderFactory { + fun create(callerUid: Int, chosenComponentSender: IntentSender): ShareResultSenderImpl +} + +/** Dispatches Intents via IntentSender */ +fun interface IntentSenderDispatcher { + fun dispatchIntent(intentSender: IntentSender, intent: Intent) +} + +class ShareResultSenderImpl( + private val flags: ChooserServiceFlags, + @Main private val scope: CoroutineScope, + @Background val backgroundDispatcher: CoroutineDispatcher, + private val callerUid: Int, + private val resultSender: IntentSender, + private val intentDispatcher: IntentSenderDispatcher +) : ShareResultSender { + @AssistedInject + constructor( + @ActivityContext context: Context, + flags: ChooserServiceFlags, + @Main scope: CoroutineScope, + @Background backgroundDispatcher: CoroutineDispatcher, + @Assisted callerUid: Int, + @Assisted chosenComponentSender: IntentSender, + ) : this( + flags, + scope, + backgroundDispatcher, + callerUid, + chosenComponentSender, + IntentSenderDispatcher { sender, intent -> sender.dispatchIntent(context, intent) } + ) + + override fun onComponentSelected(component: ComponentName, directShare: Boolean) { + Log.i(TAG, "onComponentSelected: $component directShare=$directShare") + scope.launch { + val intent = createChosenComponentIntent(component, directShare) + intentDispatcher.dispatchIntent(resultSender, intent) + } + } + + override fun onActionSelected(action: ShareAction) { + Log.i(TAG, "onActionSelected: $action") + scope.launch { + if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { + @ResultType val chosenAction = shareActionToChooserResult(action) + val intent: Intent = createSelectedActionIntent(chosenAction) + intentDispatcher.dispatchIntent(resultSender, intent) + } else { + Log.i(TAG, "Not sending SelectedAction") + } + } + } + + private suspend fun createChosenComponentIntent( + component: ComponentName, + direct: Boolean, + ): Intent { + // Add extra with component name for backwards compatibility. + val intent: Intent = Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component) + + // Add ChooserResult value for Android V+ + if (flags.enableChooserResult() && chooserResultSupported(callerUid)) { + intent.putExtra( + Intent.EXTRA_CHOOSER_RESULT, + ChooserResult(CHOOSER_RESULT_SELECTED_COMPONENT, component, direct) + ) + } else { + Log.i(TAG, "Not including ${Intent.EXTRA_CHOOSER_RESULT}") + } + return intent + } + + @ResultType + private fun shareActionToChooserResult(action: ShareAction) = + when (action) { + ShareAction.SYSTEM_COPY -> CHOOSER_RESULT_COPY + ShareAction.SYSTEM_EDIT -> CHOOSER_RESULT_EDIT + ShareAction.APPLICATION_DEFINED -> CHOOSER_RESULT_UNKNOWN + } + + private fun createSelectedActionIntent(@ResultType result: Int): Intent { + return Intent().putExtra(Intent.EXTRA_CHOOSER_RESULT, ChooserResult(result, null, false)) + } + + private suspend fun chooserResultSupported(uid: Int): Boolean { + return withContext(backgroundDispatcher) { + // background -> Binder call to system_server + CompatChanges.isChangeEnabled(ChooserResult.SEND_CHOOSER_RESULT, uid) + } + } +} + +private fun IntentSender.dispatchIntent(context: Context, intent: Intent) { + try { + sendIntent( + /* context = */ context, + /* code = */ Activity.RESULT_OK, + /* intent = */ intent, + /* onFinished = */ null, + /* handler = */ null + ) + } catch (e: IntentSender.SendIntentException) { + Log.e(TAG, "Failed to send intent to IntentSender", e) + } +} diff --git a/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt b/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt new file mode 100644 index 00000000..7239198e --- /dev/null +++ b/java/src/com/android/intentresolver/ui/ShortcutPolicyModule.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui + +import android.content.res.Resources +import android.provider.DeviceConfig +import com.android.intentresolver.R +import com.android.intentresolver.inject.ApplicationOwned +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AppShortcutLimit + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class EnforceShortcutLimit + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class ShortcutRowLimit + +@Module +@InstallIn(SingletonComponent::class) +object ShortcutPolicyModule { + /** + * Defines the limit for the number of shortcut targets provided for any single app. + * + * This value applies to both results from Shortcut-service and app-provided targets on a + * per-package basis. + */ + @Provides + @Singleton + @AppShortcutLimit + fun appShortcutLimit(@ApplicationOwned resources: Resources): Int { + return resources.getInteger(R.integer.config_maxShortcutTargetsPerApp) + } + + /** + * Once this value is no longer necessary it should be replaced in tests with simply replacing + * [AppShortcutLimit]: + * ``` + * @BindValue + * @AppShortcutLimit + * var shortcutLimit = Int.MAX_VALUE + * ``` + */ + @Provides + @Singleton + @EnforceShortcutLimit + fun applyShortcutLimit(): Boolean { + return DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + true + ) + } + + /** + * Defines the limit for the number of shortcuts presented within the direct share row. + * + * This value applies to all displayed direct share targets, including those from Shortcut + * service as well as app-provided targets. + */ + @Provides + @Singleton + @ShortcutRowLimit + fun shortcutRowLimit(@ApplicationOwned resources: Resources): Int { + return resources.getInteger(R.integer.config_chooser_max_targets_per_row) + } +} diff --git a/java/src/com/android/intentresolver/ui/model/ActivityModel.kt b/java/src/com/android/intentresolver/ui/model/ActivityModel.kt new file mode 100644 index 00000000..4bcdd69b --- /dev/null +++ b/java/src/com/android/intentresolver/ui/model/ActivityModel.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ui.model + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import com.android.intentresolver.data.model.ANDROID_APP_SCHEME +import com.android.intentresolver.ext.readParcelable +import com.android.intentresolver.ext.requireParcelable +import java.util.Objects + +/** Contains Activity-scope information about the state when started. */ +data class ActivityModel( + /** The [Intent] received by the app */ + val intent: Intent, + /** The identifier for the sending app and user */ + val launchedFromUid: Int, + /** The package of the sending app */ + val launchedFromPackage: String, + /** The referrer as supplied to the activity. */ + val referrer: Uri? +) : Parcelable { + constructor( + source: Parcel + ) : this( + intent = source.requireParcelable(), + launchedFromUid = source.readInt(), + launchedFromPackage = requireNotNull(source.readString()), + referrer = source.readParcelable() + ) + + /** A package name from referrer, if it is an android-app URI */ + val referrerPackage = referrer?.takeIf { it.scheme == ANDROID_APP_SCHEME }?.authority + + override fun describeContents() = 0 /* flags */ + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeParcelable(intent, flags) + dest.writeInt(launchedFromUid) + dest.writeString(launchedFromPackage) + dest.writeParcelable(referrer, flags) + } + + companion object { + const val ACTIVITY_MODEL_KEY = "com.android.intentresolver.ACTIVITY_MODEL" + + @JvmField + @Suppress("unused") + val CREATOR = + object : Parcelable.Creator<ActivityModel> { + override fun newArray(size: Int) = arrayOfNulls<ActivityModel>(size) + override fun createFromParcel(source: Parcel) = ActivityModel(source) + } + + @JvmStatic + fun createFrom(activity: Activity): ActivityModel { + return ActivityModel( + activity.intent, + activity.launchedFromUid, + Objects.requireNonNull<String>(activity.launchedFromPackage), + activity.referrer + ) + } + } +} diff --git a/java/src/com/android/intentresolver/ui/model/ResolverRequest.kt b/java/src/com/android/intentresolver/ui/model/ResolverRequest.kt new file mode 100644 index 00000000..363c413d --- /dev/null +++ b/java/src/com/android/intentresolver/ui/model/ResolverRequest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui.model + +import android.content.Intent +import android.content.pm.ResolveInfo +import android.os.UserHandle +import com.android.intentresolver.ext.isHomeIntent +import com.android.intentresolver.shared.model.Profile + +/** All of the things that are consumed from an incoming Intent Resolution request (+Extras). */ +data class ResolverRequest( + /** The intent to be resolved to a target. */ + val intent: Intent, + + /** + * Supplied by the system to indicate which profile should be selected by default. This is + * required since ResolverActivity may be launched as either the originating OR target user when + * resolving a cross profile intent. + * + * Valid values are: [PERSONAL][Profile.Type.PERSONAL] and [WORK][Profile.Type.WORK] and null + * when the intent is not a forwarded cross-profile intent. + */ + val selectedProfile: Profile.Type?, + + /** + * When handing a cross profile forwarded intent, this is the user which started the original + * intent. This is required to allow ResolverActivity to be launched as the target user under + * some conditions. + */ + val callingUser: UserHandle?, + + /** + * Indicates if resolving actions for a connected device which has audio capture capability + * (e.g. is a USB Microphone). + * + * When used to handle a connected device, ResolverActivity uses this signal to present a + * warning when a resolved application does not hold the RECORD_AUDIO permission. (If selected + * the app would be able to capture audio directly via the device, bypassing audio API + * permissions.) + */ + val isAudioCaptureDevice: Boolean = false, + + /** A list of a resolved activity targets. This list overrides normal intent resolution. */ + val resolutionList: List<ResolveInfo>? = null, + + /** A customized title for the resolver interface. */ + val title: String? = null, +) { + val isResolvingHome = intent.isHomeIntent() + + /** For compatibility with existing code shared between chooser/resolver. */ + val payloadIntents: List<Intent> = listOf(intent) +} diff --git a/java/src/com/android/intentresolver/ui/model/ShareAction.kt b/java/src/com/android/intentresolver/ui/model/ShareAction.kt new file mode 100644 index 00000000..4d727b9a --- /dev/null +++ b/java/src/com/android/intentresolver/ui/model/ShareAction.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui.model + +enum class ShareAction { + SYSTEM_COPY, + SYSTEM_EDIT, + APPLICATION_DEFINED +} diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt new file mode 100644 index 00000000..a9b6de7e --- /dev/null +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserRequestReader.kt @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ui.viewmodel + +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.EXTRA_ALTERNATE_INTENTS +import android.content.Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS +import android.content.Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION +import android.content.Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER +import android.content.Intent.EXTRA_CHOOSER_TARGETS +import android.content.Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER +import android.content.Intent.EXTRA_EXCLUDE_COMPONENTS +import android.content.Intent.EXTRA_INITIAL_INTENTS +import android.content.Intent.EXTRA_INTENT +import android.content.Intent.EXTRA_METADATA_TEXT +import android.content.Intent.EXTRA_REPLACEMENT_EXTRAS +import android.content.Intent.EXTRA_TEXT +import android.content.Intent.EXTRA_TITLE +import android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK +import android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT +import android.content.IntentFilter +import android.content.IntentSender +import android.net.Uri +import android.os.Bundle +import android.service.chooser.ChooserAction +import android.service.chooser.ChooserTarget +import com.android.intentresolver.ChooserActivity +import com.android.intentresolver.ContentTypeHint +import com.android.intentresolver.R +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.ext.hasSendAction +import com.android.intentresolver.ext.ifMatch +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.util.hasValidIcon +import com.android.intentresolver.validation.Validation +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.types.IntentOrUri +import com.android.intentresolver.validation.types.array +import com.android.intentresolver.validation.types.value +import com.android.intentresolver.validation.validateFrom + +private const val MAX_CHOOSER_ACTIONS = 5 +private const val MAX_INITIAL_INTENTS = 2 + +internal fun Intent.maybeAddSendActionFlags() = + ifMatch(Intent::hasSendAction) { + addFlags(FLAG_ACTIVITY_NEW_DOCUMENT) + addFlags(FLAG_ACTIVITY_MULTIPLE_TASK) + } + +fun readChooserRequest( + model: ActivityModel, + flags: ChooserServiceFlags +): ValidationResult<ChooserRequest> { + val extras = model.intent.extras ?: Bundle() + @Suppress("DEPRECATION") + return validateFrom(extras::get) { + val targetIntent = required(IntentOrUri(EXTRA_INTENT)).maybeAddSendActionFlags() + + val isSendAction = targetIntent.hasSendAction() + + val additionalTargets = readAlternateIntents() ?: emptyList() + + val replacementExtras = optional(value<Bundle>(EXTRA_REPLACEMENT_EXTRAS)) + + val (customTitle, defaultTitleResource) = + if (isSendAction) { + ignored( + value<CharSequence>(EXTRA_TITLE), + "deprecated in P. You may wish to set a preview title by using EXTRA_TITLE " + + "property of the wrapped EXTRA_INTENT." + ) + null to R.string.chooseActivity + } else { + val custom = optional(value<CharSequence>(EXTRA_TITLE)) + custom to (custom?.let { 0 } ?: R.string.chooseActivity) + } + + val initialIntents = + optional(array<Intent>(EXTRA_INITIAL_INTENTS))?.take(MAX_INITIAL_INTENTS)?.map { + it.maybeAddSendActionFlags() + } + ?: emptyList() + + val chosenComponentSender = + optional(value<IntentSender>(EXTRA_CHOOSER_RESULT_INTENT_SENDER)) + ?: optional(value<IntentSender>(EXTRA_CHOSEN_COMPONENT_INTENT_SENDER)) + + val refinementIntentSender = + optional(value<IntentSender>(EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER)) + + val filteredComponents = + optional(array<ComponentName>(EXTRA_EXCLUDE_COMPONENTS)) ?: emptyList() + + @Suppress("DEPRECATION") + val callerChooserTargets = + optional(array<ChooserTarget>(EXTRA_CHOOSER_TARGETS)) ?: emptyList() + + val retainInOnStop = + optional(value<Boolean>(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP)) ?: false + + val sharedText = optional(value<CharSequence>(EXTRA_TEXT)) + + val chooserActions = readChooserActions() ?: emptyList() + + val modifyShareAction = optional(value<ChooserAction>(EXTRA_CHOOSER_MODIFY_SHARE_ACTION)) + + val additionalContentUri: Uri? + val focusedItemPos: Int + if (isSendAction && flags.chooserPayloadToggling()) { + additionalContentUri = optional(value<Uri>(Intent.EXTRA_CHOOSER_ADDITIONAL_CONTENT_URI)) + focusedItemPos = optional(value<Int>(Intent.EXTRA_CHOOSER_FOCUSED_ITEM_POSITION)) ?: 0 + } else { + additionalContentUri = null + focusedItemPos = 0 + } + + val contentTypeHint = + if (flags.chooserAlbumText()) { + when (optional(value<Int>(Intent.EXTRA_CHOOSER_CONTENT_TYPE_HINT))) { + Intent.CHOOSER_CONTENT_TYPE_ALBUM -> ContentTypeHint.ALBUM + else -> ContentTypeHint.NONE + } + } else { + ContentTypeHint.NONE + } + + val metadataText = + if (flags.enableSharesheetMetadataExtra()) { + optional(value<CharSequence>(EXTRA_METADATA_TEXT)) + } else { + null + } + + ChooserRequest( + targetIntent = targetIntent, + targetAction = targetIntent.action, + isSendActionTarget = isSendAction, + targetType = targetIntent.type, + launchedFromPackage = + requireNotNull(model.launchedFromPackage) { + "launch.fromPackage was null, See Activity.getLaunchedFromPackage()" + }, + title = customTitle, + defaultTitleResource = defaultTitleResource, + referrer = model.referrer, + filteredComponentNames = filteredComponents, + callerChooserTargets = callerChooserTargets, + chooserActions = chooserActions, + modifyShareAction = modifyShareAction, + shouldRetainInOnStop = retainInOnStop, + additionalTargets = additionalTargets, + replacementExtras = replacementExtras, + initialIntents = initialIntents, + chosenComponentSender = chosenComponentSender, + refinementIntentSender = refinementIntentSender, + sharedText = sharedText, + shareTargetFilter = targetIntent.toShareTargetFilter(), + additionalContentUri = additionalContentUri, + focusedItemPosition = focusedItemPos, + contentTypeHint = contentTypeHint, + metadataText = metadataText, + ) + } +} + +fun Validation.readAlternateIntents(): List<Intent>? = + optional(array<Intent>(EXTRA_ALTERNATE_INTENTS))?.map { it.maybeAddSendActionFlags() } + +fun Validation.readChooserActions(): List<ChooserAction>? = + optional(array<ChooserAction>(EXTRA_CHOOSER_CUSTOM_ACTIONS)) + ?.filter { hasValidIcon(it) } + ?.take(MAX_CHOOSER_ACTIONS) + +private fun Intent.toShareTargetFilter(): IntentFilter? { + return type?.let { + IntentFilter().apply { + action?.also { addAction(it) } + addDataType(it) + } + } +} diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt new file mode 100644 index 00000000..c9cae3db --- /dev/null +++ b/java/src/com/android/intentresolver/ui/viewmodel/ChooserViewModel.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.intentresolver.ui.viewmodel + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.FetchPreviewsInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ProcessTargetIntentUpdatesInteractor +import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel +import com.android.intentresolver.data.model.ChooserRequest +import com.android.intentresolver.data.repository.ChooserRequestRepository +import com.android.intentresolver.inject.Background +import com.android.intentresolver.inject.ChooserServiceFlags +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import dagger.Lazy +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +private const val TAG = "ChooserViewModel" + +@HiltViewModel +class ChooserViewModel +@Inject +constructor( + args: SavedStateHandle, + private val shareouselViewModelProvider: Lazy<ShareouselViewModel>, + private val processUpdatesInteractor: Lazy<ProcessTargetIntentUpdatesInteractor>, + private val fetchPreviewsInteractor: Lazy<FetchPreviewsInteractor>, + @Background private val bgDispatcher: CoroutineDispatcher, + private val flags: ChooserServiceFlags, + /** + * Provided only for the express purpose of early exit in the event of an invalid request. + * + * Note: [request] can only be safely accessed after checking if this value is [Valid]. + */ + val initialRequest: ValidationResult<ChooserRequest>, + private val chooserRequestRepository: Lazy<ChooserRequestRepository>, +) : ViewModel() { + + /** Parcelable-only references provided from the creating Activity */ + val activityModel: ActivityModel = + requireNotNull(args[ACTIVITY_MODEL_KEY]) { + "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" + } + + val shareouselViewModel: ShareouselViewModel by lazy { + // TODO: consolidate this logic, this would require a consolidated preview view model but + // for now just postpone starting the payload selection preview machinery until it's needed + assert(flags.chooserPayloadToggling()) { + "An attempt to use payload selection preview with the disabled flag" + } + + viewModelScope.launch(bgDispatcher) { processUpdatesInteractor.get().activate() } + viewModelScope.launch(bgDispatcher) { fetchPreviewsInteractor.get().activate() } + shareouselViewModelProvider.get() + } + + /** + * A [StateFlow] of [ChooserRequest]. + * + * Note: Only safe to access after checking if [initialRequest] is [Valid]. + */ + val request: StateFlow<ChooserRequest> + get() = chooserRequestRepository.get().chooserRequest.asStateFlow() + + init { + if (initialRequest is Invalid) { + Log.w(TAG, "initialRequest is Invalid, initialization failed") + } + } +} diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt new file mode 100644 index 00000000..856d9fdd --- /dev/null +++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverRequestReader.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui.viewmodel + +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.Profile +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.model.ResolverRequest +import com.android.intentresolver.validation.Validation +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.types.value +import com.android.intentresolver.validation.validateFrom + +const val EXTRA_CALLING_USER = "com.android.internal.app.ResolverActivity.EXTRA_CALLING_USER" +const val EXTRA_SELECTED_PROFILE = + "com.android.internal.app.ResolverActivity.EXTRA_SELECTED_PROFILE" +const val EXTRA_IS_AUDIO_CAPTURE_DEVICE = "is_audio_capture_device" + +fun readResolverRequest(launch: ActivityModel): ValidationResult<ResolverRequest> { + @Suppress("DEPRECATION") + return validateFrom((launch.intent.extras ?: Bundle())::get) { + val callingUser = optional(value<UserHandle>(EXTRA_CALLING_USER)) + val selectedProfile = checkSelectedProfile() + val audioDevice = optional(value<Boolean>(EXTRA_IS_AUDIO_CAPTURE_DEVICE)) ?: false + ResolverRequest(launch.intent, selectedProfile, callingUser, audioDevice) + } +} + +private fun Validation.checkSelectedProfile(): Profile.Type? { + return when (val selected = optional(value<Int>(EXTRA_SELECTED_PROFILE))) { + null -> null + PROFILE_PERSONAL -> Profile.Type.PERSONAL + PROFILE_WORK -> Profile.Type.WORK + else -> + error( + EXTRA_SELECTED_PROFILE + + " has invalid value ($selected)." + + " Must be either ResolverActivity.PROFILE_PERSONAL ($PROFILE_PERSONAL)" + + " or ResolverActivity.PROFILE_WORK ($PROFILE_WORK)." + ) + } +} diff --git a/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt new file mode 100644 index 00000000..a3dc58a6 --- /dev/null +++ b/java/src/com/android/intentresolver/ui/viewmodel/ResolverViewModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.ui.viewmodel + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.android.intentresolver.ui.model.ActivityModel +import com.android.intentresolver.ui.model.ActivityModel.Companion.ACTIVITY_MODEL_KEY +import com.android.intentresolver.ui.model.ResolverRequest +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.Valid +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +private const val TAG = "ResolverViewModel" + +@HiltViewModel +class ResolverViewModel @Inject constructor(args: SavedStateHandle) : ViewModel() { + + /** Parcelable-only references provided from the creating Activity */ + val activityModel: ActivityModel = + requireNotNull(args[ACTIVITY_MODEL_KEY]) { + "ActivityModel missing in SavedStateHandle! ($ACTIVITY_MODEL_KEY)" + } + + /** + * Provided only for the express purpose of early exit in the event of an invalid request. + * + * Note: [request] can only be safely accessed after checking if this value is [Valid]. + */ + internal val initialRequest = readResolverRequest(activityModel) + + private lateinit var _request: MutableStateFlow<ResolverRequest> + + /** + * A [StateFlow] of [ResolverRequest]. + * + * Note: Only safe to access after checking if [initialRequest] is [Valid]. + */ + lateinit var request: StateFlow<ResolverRequest> + private set + + init { + when (initialRequest) { + is Valid -> { + _request = MutableStateFlow(initialRequest.value) + request = _request.asStateFlow() + } + is Invalid -> Log.w(TAG, "initialRequest is Invalid, initialization failed") + } + } +} diff --git a/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt b/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt new file mode 100644 index 00000000..e89cb5ca --- /dev/null +++ b/java/src/com/android/intentresolver/util/CancellationSignalUtils.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import android.os.CancellationSignal +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * Invokes [block] with a [CancellationSignal] that is bound to this coroutine's lifetime; if this + * coroutine is cancelled, then [CancellationSignal.cancel] is promptly invoked. + */ +suspend fun <R> withCancellationSignal(block: suspend (signal: CancellationSignal) -> R): R = + coroutineScope { + val signal = CancellationSignal() + val signalJob = + launch(start = CoroutineStart.UNDISPATCHED) { + try { + awaitCancellation() + } finally { + signal.cancel() + } + } + block(signal).also { signalJob.cancel() } + } diff --git a/java/src/com/android/intentresolver/util/Flow.kt b/java/src/com/android/intentresolver/util/Flow.kt index 1155b9fe..598379f3 100644 --- a/java/src/com/android/intentresolver/util/Flow.kt +++ b/java/src/com/android/intentresolver/util/Flow.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.launch * latest value is emitted. * * Example: - * * ```kotlin * flow { * emit(1) // t=0ms @@ -70,10 +69,11 @@ fun <T> Flow<T>.throttle(periodMs: Long): Flow<T> = channelFlow { // We create delayJob to allow cancellation during the delay period delayJob = launch { delay(timeUntilNextEmit) - sendJob = outerScope.launch(start = CoroutineStart.UNDISPATCHED) { - send(it) - previousEmitTimeMs = SystemClock.elapsedRealtime() - } + sendJob = + outerScope.launch(start = CoroutineStart.UNDISPATCHED) { + send(it) + previousEmitTimeMs = SystemClock.elapsedRealtime() + } } } else { send(it) diff --git a/java/src/com/android/intentresolver/util/ParallelIteration.kt b/java/src/com/android/intentresolver/util/ParallelIteration.kt new file mode 100644 index 00000000..70c46c47 --- /dev/null +++ b/java/src/com/android/intentresolver/util/ParallelIteration.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.yield + +/** Like [Iterable.map] but executes each [block] invocation in a separate coroutine. */ +suspend fun <A, B> Iterable<A>.mapParallel( + parallelism: Int? = null, + block: suspend (A) -> B, +): List<B> = + parallelism?.let { permits -> + withSemaphore(permits = permits) { mapParallel { withPermit { block(it) } } } + } + ?: mapParallel(block) + +/** Like [Iterable.map] but executes each [block] invocation in a separate coroutine. */ +suspend fun <A, B> Sequence<A>.mapParallel( + parallelism: Int? = null, + block: suspend (A) -> B, +): List<B> = asIterable().mapParallel(parallelism, block) + +private suspend fun <A, B> Iterable<A>.mapParallel(block: suspend (A) -> B): List<B> = + coroutineScope { + map { + async { + yield() + block(it) + } + } + .awaitAll() + } diff --git a/java/src/com/android/intentresolver/util/SyncUtils.kt b/java/src/com/android/intentresolver/util/SyncUtils.kt new file mode 100644 index 00000000..eaebc6ea --- /dev/null +++ b/java/src/com/android/intentresolver/util/SyncUtils.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore + +/** + * Constructs a [Semaphore] for usage within [block], useful for launching a lot of work in parallel + * that needs some synchronization. + */ +inline fun <R> withSemaphore(permits: Int, block: Semaphore.() -> R): R = + Semaphore(permits).run(block) + +/** + * Constructs a [Mutex] for usage within [block], useful for launching a lot of work in parallel + * that needs some synchronization. + */ +inline fun <R> withMutex(block: Mutex.() -> R): R = Mutex().run(block) diff --git a/java/src/com/android/intentresolver/util/cursor/CursorView.kt b/java/src/com/android/intentresolver/util/cursor/CursorView.kt new file mode 100644 index 00000000..eca7d335 --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/CursorView.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util.cursor + +import android.database.Cursor + +/** A [Cursor] that holds values of [E] for each row. */ +interface CursorView<out E> : Cursor { + /** + * Reads the current row from this [CursorView]. A result of `null` indicates that the row could + * not be read / value could not be produced. + */ + fun readRow(): E? +} + +/** + * Returns a [CursorView] from the given [Cursor], and a function [readRow] used to produce the + * value for a single row. + */ +fun <E> Cursor.viewBy(readRow: Cursor.() -> E): CursorView<E> = + object : CursorView<E>, Cursor by this@viewBy { + override fun readRow(): E? = immobilized().readRow() + } + +/** Returns a [CursorView] that begins (index 0) at [newStartIndex] of the given cursor. */ +fun <E> CursorView<E>.startAt(newStartIndex: Int): CursorView<E> = + object : CursorView<E>, Cursor by (this@startAt as Cursor).startAt(newStartIndex) { + override fun readRow(): E? = this@startAt.readRow() + } + +/** Returns a [CursorView] that is truncated to contain only [count] elements. */ +fun <E> CursorView<E>.limit(count: Int): CursorView<E> = + object : CursorView<E>, Cursor by (this@limit as Cursor).limit(count) { + override fun readRow(): E? = this@limit.readRow() + } + +/** Retrieves a single row at index [idx] from the [CursorView]. */ +operator fun <E> CursorView<E>.get(idx: Int): E? = if (moveToPosition(idx)) readRow() else null + +/** Returns a [Sequence] that iterates over the [CursorView] returning each row. */ +fun <E> CursorView<E>.asSequence(): Sequence<E?> = sequence { + for (i in 0 until count) { + yield(get(i)) + } +} diff --git a/java/src/com/android/intentresolver/util/cursor/Cursors.kt b/java/src/com/android/intentresolver/util/cursor/Cursors.kt new file mode 100644 index 00000000..ce768f3b --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/Cursors.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util.cursor + +import android.database.Cursor +import android.database.CursorWrapper + +/** Returns a Cursor that is truncated to contain only [count] elements. */ +fun Cursor.limit(count: Int): Cursor = + object : CursorWrapper(this) { + override fun getCount(): Int = minOf(count, super.getCount()) + + override fun getPosition(): Int = super.getPosition().coerceAtMost(count) + + override fun moveToLast(): Boolean = super.moveToPosition(getCount() - 1) + + override fun isFirst(): Boolean = getCount() != 0 && super.isFirst() + + override fun isLast(): Boolean = getCount() != 0 && super.getPosition() == getCount() - 1 + + override fun isAfterLast(): Boolean = getCount() == 0 || super.getPosition() >= getCount() + + override fun isBeforeFirst(): Boolean = getCount() == 0 || super.isBeforeFirst() + + override fun moveToNext(): Boolean = super.moveToNext() && position < getCount() + + override fun moveToPosition(position: Int): Boolean = + super.moveToPosition(position) && position < getCount() + } + +/** Returns a Cursor that begins (index 0) at [newStartIndex] of the given Cursor. */ +fun Cursor.startAt(newStartIndex: Int): Cursor = + object : CursorWrapper(this) { + override fun getCount(): Int = (super.getCount() - newStartIndex).coerceAtLeast(0) + + override fun getPosition(): Int = (super.getPosition() - newStartIndex).coerceAtLeast(-1) + + override fun moveToFirst(): Boolean = super.moveToPosition(newStartIndex) + + override fun moveToNext(): Boolean = super.moveToNext() && position < count + + override fun moveToPrevious(): Boolean = super.moveToPrevious() && position >= 0 + + override fun moveToPosition(position: Int): Boolean = + super.moveToPosition(position + newStartIndex) && position >= 0 + + override fun isFirst(): Boolean = count != 0 && super.getPosition() == newStartIndex + + override fun isLast(): Boolean = count != 0 && super.isLast() + + override fun isBeforeFirst(): Boolean = count == 0 || super.getPosition() < newStartIndex + + override fun isAfterLast(): Boolean = count == 0 || super.isAfterLast() + } + +/** Returns a read-only non-movable view into the given Cursor. */ +fun Cursor.immobilized(): Cursor = + object : CursorWrapper(this) { + private val unsupported: Nothing + get() = error("unsupported") + + override fun moveToFirst(): Boolean = unsupported + + override fun moveToLast(): Boolean = unsupported + + override fun move(offset: Int): Boolean = unsupported + + override fun moveToPosition(position: Int): Boolean = unsupported + + override fun moveToNext(): Boolean = unsupported + + override fun moveToPrevious(): Boolean = unsupported + } diff --git a/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt b/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt new file mode 100644 index 00000000..6e4318dc --- /dev/null +++ b/java/src/com/android/intentresolver/util/cursor/PagedCursor.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.util.cursor + +import android.database.Cursor + +/** A [CursorView] that produces chunks/pages from an underlying cursor. */ +interface PagedCursor<out E> : CursorView<Sequence<E?>> { + /** The configured size of each page produced by this cursor. */ + val pageSize: Int +} + +/** Returns a [PagedCursor] that produces pages of data from the given [CursorView]. */ +fun <E> CursorView<E>.paged(pageSize: Int): PagedCursor<E> = + object : PagedCursor<E>, Cursor by this@paged { + + init { + check(pageSize > 0) { "pageSize must be greater than 0" } + } + + override val pageSize: Int = pageSize + + override fun getCount(): Int = + this@paged.count.let { it / pageSize + minOf(1, it % pageSize) } + + override fun getPosition(): Int = + (this@paged.position / pageSize).let { if (this@paged.position < 0) it - 1 else it } + + override fun moveToNext(): Boolean = moveToPosition(position + 1) + + override fun moveToPrevious(): Boolean = moveToPosition(position - 1) + + override fun moveToPosition(position: Int): Boolean = + this@paged.moveToPosition(position * pageSize) + + override fun readRow(): Sequence<E?> = + this@paged.startAt(position * pageSize).limit(pageSize).asSequence() + } diff --git a/java/src/com/android/intentresolver/validation/Findings.kt b/java/src/com/android/intentresolver/validation/Findings.kt new file mode 100644 index 00000000..0d62017f --- /dev/null +++ b/java/src/com/android/intentresolver/validation/Findings.kt @@ -0,0 +1,120 @@ +/* + * 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.validation + +import android.util.Log +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING +import kotlin.reflect.KClass + +sealed interface Finding { + val importance: Importance + val message: String +} + +enum class Importance { + CRITICAL, + WARNING, +} + +val Finding.logcatPriority + get() = + when (importance) { + CRITICAL -> Log.ERROR + WARNING -> Log.WARN + } + +fun Finding.log(tag: String) { + Log.println(logcatPriority, tag, message) +} + +private fun formatMessage(key: String? = null, msg: String) = buildString { + key?.also { append("['$key']: ") } + append(msg) +} + +data class IgnoredValue( + val key: String, + val reason: String, +) : Finding { + override val importance = WARNING + + override val message: String + get() = formatMessage(key, "Ignored. $reason") +} + +data class NoValue( + val key: String, + override val importance: Importance, + val allowedType: KClass<*>, +) : Finding { + + override val message: String + get() = + formatMessage( + key, + if (importance == CRITICAL) { + "expected value of ${allowedType.simpleName}, " + "but no value was present" + } else { + "no ${allowedType.simpleName} value present" + } + ) +} + +data class WrongElementType( + val key: String, + override val importance: Importance, + val container: KClass<*>, + val actualType: KClass<*>, + val expectedType: KClass<*> +) : Finding { + override val message: String + get() = + formatMessage( + key, + "${container.simpleName} expected with elements of " + + "${expectedType.simpleName} " + + "but found ${actualType.simpleName} values instead" + ) +} + +data class ValueIsWrongType( + val key: String, + override val importance: Importance, + val actualType: KClass<*>, + val allowedTypes: List<KClass<*>>, +) : Finding { + + override val message: String + get() = + formatMessage( + key, + "expected value of ${allowedTypes.map(KClass<*>::simpleName)} " + + "but was ${actualType.simpleName}" + ) +} + +data class UncaughtException(val thrown: Throwable, val key: String? = null) : Finding { + override val importance: Importance + get() = CRITICAL + override val message: String + get() = + formatMessage( + key, + "An unhandled exception was caught during validation: " + + thrown.stackTraceToString() + ) +} diff --git a/java/src/com/android/intentresolver/validation/Validation.kt b/java/src/com/android/intentresolver/validation/Validation.kt new file mode 100644 index 00000000..6ba62e57 --- /dev/null +++ b/java/src/com/android/intentresolver/validation/Validation.kt @@ -0,0 +1,137 @@ +/* + * 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.validation + +import com.android.intentresolver.validation.Importance.CRITICAL +import com.android.intentresolver.validation.Importance.WARNING + +/** + * Provides a mechanism for validating a result from a set of properties. + * + * The results of validation are provided as [findings]. + */ +interface Validation { + val findings: List<Finding> + + /** + * Require a valid property. + * + * If [property] is not valid, this [Validation] will be immediately completed as [Invalid]. + * + * @param property the required property + * @return a valid **T** + */ + @Throws(InvalidResultError::class) fun <T> required(property: Validator<T>): T + + /** + * Request an optional value for a property. + * + * If [property] is not valid, this [Validation] will be immediately completed as [Invalid]. + * + * @param property the required property + * @return a valid **T** + */ + fun <T> optional(property: Validator<T>): T? + + /** + * Report a property as __ignored__. + * + * The presence of any value will report a warning citing [reason]. + */ + fun <T> ignored(property: Validator<T>, reason: String) +} + +/** Performs validation for a specific key -> value pair. */ +interface Validator<T> { + val key: String + + /** + * Performs validation on a specific value from [source]. + * + * @param source a source for reading the property value. Values are intentionally untyped + * (Any?) to avoid upstream code from making type assertions through type inference. Types are + * asserted later using a [Validator]. + * @param importance the importance of any findings + */ + fun validate(source: (String) -> Any?, importance: Importance): ValidationResult<T> +} + +internal class InvalidResultError internal constructor() : Error() + +/** + * Perform a number of validations on the source, assembling and returning a Result. + * + * When an exception is thrown by [validate], it is caught here. In response, a failed + * [ValidationResult] is returned containing a [CRITICAL] [Finding] for the exception. + * + * @param validate perform validations and return a [ValidationResult] + */ +fun <T> validateFrom(source: (String) -> Any?, validate: Validation.() -> T): ValidationResult<T> { + val validation = ValidationImpl(source) + return runCatching { validate(validation) } + .fold( + onSuccess = { result -> Valid(result, validation.findings) }, + onFailure = { + when (it) { + // A validator has interrupted validation. Return the findings. + is InvalidResultError -> Invalid(validation.findings) + + // Some other exception was thrown from [validate], + else -> Invalid(error = UncaughtException(it)) + } + } + ) +} + +private class ValidationImpl(val source: (String) -> Any?) : Validation { + override val findings = mutableListOf<Finding>() + + override fun <T> optional(property: Validator<T>): T? = validate(property, WARNING) + + override fun <T> required(property: Validator<T>): T { + return validate(property, CRITICAL) ?: throw InvalidResultError() + } + + override fun <T> ignored(property: Validator<T>, reason: String) { + val result = property.validate(source, WARNING) + if (result is Valid) { + // Note: Any warnings about the value itself (result.findings) are ignored. + findings += IgnoredValue(property.key, reason) + } + } + + private fun <T> validate(property: Validator<T>, importance: Importance): T? { + return runCatching { property.validate(source, importance) } + .fold( + onSuccess = { result -> + return when (result) { + is Valid -> { + findings += result.warnings + result.value + } + is Invalid -> { + findings += result.errors + null + } + } + }, + onFailure = { + findings += UncaughtException(it, property.key) + null + } + ) + } +} diff --git a/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt b/java/src/com/android/intentresolver/validation/ValidationResult.kt index 7e588f98..9685c70d 100644 --- a/java/tests/src/com/android/intentresolver/TestLifecycleOwner.kt +++ b/java/src/com/android/intentresolver/validation/ValidationResult.kt @@ -13,21 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.android.intentresolver.validation -package com.android.intentresolver +sealed interface ValidationResult<T> -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry +data class Valid<T>(val value: T, val warnings: List<Finding> = emptyList()) : ValidationResult<T> { + constructor(value: T, warning: Finding) : this(value, listOf(warning)) +} -internal class TestLifecycleOwner : LifecycleOwner { - private val lifecycleRegistry = LifecycleRegistry.createUnsafe(this) - - override val lifecycle: Lifecycle get() = lifecycleRegistry - - var state: Lifecycle.State - get() = lifecycle.currentState - set(value) { - lifecycleRegistry.currentState = value - } -}
\ No newline at end of file +data class Invalid<T>(val errors: List<Finding> = emptyList()) : ValidationResult<T> { + constructor(error: Finding) : this(listOf(error)) +} diff --git a/java/src/com/android/intentresolver/validation/types/IntentOrUri.kt b/java/src/com/android/intentresolver/validation/types/IntentOrUri.kt new file mode 100644 index 00000000..74c48a23 --- /dev/null +++ b/java/src/com/android/intentresolver/validation/types/IntentOrUri.kt @@ -0,0 +1,62 @@ +/* + * 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.validation.types + +import android.content.Intent +import android.net.Uri +import com.android.intentresolver.validation.Importance +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.Validator +import com.android.intentresolver.validation.ValueIsWrongType + +class IntentOrUri(override val key: String) : Validator<Intent> { + + override fun validate( + source: (String) -> Any?, + importance: Importance + ): ValidationResult<Intent> { + return when (val value = source(key)) { + // An intent, return it. + is Intent -> Valid(value) + + // A Uri was supplied. + // Unfortunately, converting Uri -> Intent requires a toString(). + is Uri -> Valid(Intent.parseUri(value.toString(), Intent.URI_INTENT_SCHEME)) + + // No value present. + null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, Intent::class)) + } + + // Some other type. + else -> { + return Invalid( + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(Intent::class, Uri::class) + ) + ) + } + } + } +} diff --git a/java/src/com/android/intentresolver/validation/types/ParceledArray.kt b/java/src/com/android/intentresolver/validation/types/ParceledArray.kt new file mode 100644 index 00000000..5150ec5e --- /dev/null +++ b/java/src/com/android/intentresolver/validation/types/ParceledArray.kt @@ -0,0 +1,84 @@ +/* + * 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.validation.types + +import com.android.intentresolver.validation.Importance +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.Validator +import com.android.intentresolver.validation.ValueIsWrongType +import com.android.intentresolver.validation.WrongElementType +import kotlin.reflect.KClass +import kotlin.reflect.cast + +class ParceledArray<T : Any>( + override val key: String, + private val elementType: KClass<T>, +) : Validator<List<T>> { + + override fun validate( + source: (String) -> Any?, + importance: Importance + ): ValidationResult<List<T>> { + return when (val value: Any? = source(key)) { + // No value present. + null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, elementType)) + } + // A parcel does not transfer the element type information for parcelable + // arrays. This leads to a restored type of Array<Parcelable>, which is + // incompatible with Array<T : Parcelable>. + + // To handle this safely, treat as Array<*>, assert contents of the expected + // parcelable type, and return as a list. + + is Array<*> -> { + val invalid = value.filterNotNull().firstOrNull { !elementType.isInstance(it) } + when (invalid) { + // No invalid elements, result is ok. + null -> Valid(value.map { elementType.cast(it) }) + + // At least one incorrect element type found. + else -> + Invalid( + WrongElementType( + key, + importance, + actualType = invalid::class, + container = Array::class, + expectedType = elementType + ) + ) + } + } + + // The value is not an Array at all. + else -> + Invalid( + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(elementType) + ) + ) + } + } +} diff --git a/java/src/com/android/intentresolver/validation/types/SimpleValue.kt b/java/src/com/android/intentresolver/validation/types/SimpleValue.kt new file mode 100644 index 00000000..64299e11 --- /dev/null +++ b/java/src/com/android/intentresolver/validation/types/SimpleValue.kt @@ -0,0 +1,60 @@ +/* + * 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.validation.types + +import com.android.intentresolver.validation.Importance +import com.android.intentresolver.validation.Invalid +import com.android.intentresolver.validation.NoValue +import com.android.intentresolver.validation.Valid +import com.android.intentresolver.validation.ValidationResult +import com.android.intentresolver.validation.Validator +import com.android.intentresolver.validation.ValueIsWrongType +import kotlin.reflect.KClass +import kotlin.reflect.cast + +class SimpleValue<T : Any>( + override val key: String, + private val expected: KClass<T>, +) : Validator<T> { + + override fun validate(source: (String) -> Any?, importance: Importance): ValidationResult<T> { + val value: Any? = source(key) + return when { + // The value is present and of the expected type. + expected.isInstance(value) -> return Valid(expected.cast(value)) + + // No value is present. + value == null -> + when (importance) { + Importance.WARNING -> Invalid() // No warnings if optional, but missing + Importance.CRITICAL -> Invalid(NoValue(key, importance, expected)) + } + + // The value is some other type. + else -> + Invalid( + listOf( + ValueIsWrongType( + key, + importance, + actualType = value::class, + allowedTypes = listOf(expected) + ) + ) + ) + } + } +} diff --git a/java/src/com/android/intentresolver/validation/types/Validators.kt b/java/src/com/android/intentresolver/validation/types/Validators.kt new file mode 100644 index 00000000..1049f045 --- /dev/null +++ b/java/src/com/android/intentresolver/validation/types/Validators.kt @@ -0,0 +1,26 @@ +/* + * 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.validation.types + +import com.android.intentresolver.validation.Validator + +inline fun <reified T : Any> value(key: String): Validator<T> { + return SimpleValue(key, T::class) +} + +inline fun <reified T : Any> array(key: String): Validator<List<T>> { + return ParceledArray(key, T::class) +} diff --git a/java/src/com/android/intentresolver/widget/ActionRow.kt b/java/src/com/android/intentresolver/widget/ActionRow.kt index 6764d3ae..c1f03751 100644 --- a/java/src/com/android/intentresolver/widget/ActionRow.kt +++ b/java/src/com/android/intentresolver/widget/ActionRow.kt @@ -22,7 +22,9 @@ import android.graphics.drawable.Drawable interface ActionRow { fun setActions(actions: List<Action>) - class Action @JvmOverloads constructor( + class Action + @JvmOverloads + constructor( // TODO: apparently, IDs set to this field are used in unit tests only; evaluate whether we // get rid of them val id: Int = ID_NULL, diff --git a/java/src/com/android/intentresolver/widget/BadgeTextView.kt b/java/src/com/android/intentresolver/widget/BadgeTextView.kt new file mode 100644 index 00000000..6674d92d --- /dev/null +++ b/java/src/com/android/intentresolver/widget/BadgeTextView.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.widget + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.Gravity +import android.widget.TextView + +/** + * A TextView that supports a badge at the end of the text. If the text, when centered in the view, + * leaves enough room for the badge, the badge is just displayed at the end of the view. Otherwise, + * the necessary amount of space for the badge is reserved and the text gets centered in the + * remaining free space. + */ +class BadgeTextView : TextView { + constructor(context: Context) : this(context, null) + + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int + ) : this(context, attrs, defStyleAttr, 0) + + constructor( + context: Context?, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) { + super.setGravity(Gravity.CENTER) + defaultPaddingLeft = paddingLeft + defaultPaddingRight = paddingRight + } + + private var defaultPaddingLeft = 0 + private var defaultPaddingRight = 0 + + override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { + super.setPadding(left, top, right, bottom) + defaultPaddingLeft = paddingLeft + defaultPaddingRight = paddingRight + } + + override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) { + super.setPaddingRelative(start, top, end, bottom) + defaultPaddingLeft = paddingLeft + defaultPaddingRight = paddingRight + } + + /** Sets end-sided badge. */ + var badgeDrawable: Drawable? = null + set(value) { + if (field !== value) { + field = value + super.setBackground(value) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.setPadding(defaultPaddingLeft, paddingTop, defaultPaddingRight, paddingBottom) + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val badge = badgeDrawable ?: return + if (badge.intrinsicWidth <= paddingEnd) return + var maxLineWidth = 0f + for (i in 0 until layout.lineCount) { + maxLineWidth = maxOf(maxLineWidth, layout.getLineWidth(i)) + } + val sideSpace = (measuredWidth - maxLineWidth) / 2 + if (sideSpace < badge.intrinsicWidth) { + super.setPaddingRelative( + paddingStart, + paddingTop, + paddingEnd + badge.intrinsicWidth - sideSpace.toInt(), + paddingBottom + ) + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } + + override fun setBackground(background: Drawable?) { + badgeDrawable = null + super.setBackground(background) + } + + override fun setGravity(gravity: Int): Unit = error("Not supported") +} diff --git a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt new file mode 100644 index 00000000..e86de888 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import androidx.core.view.ScrollingView +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 + +/** + * A narrowly tailored [NestedScrollView] to be used inside [ResolverDrawerLayout] and help to + * orchestrate content preview scrolling. It expects one [LinearLayout] child with + * [LinearLayout.VERTICAL] orientation. If the child has more than one child, the first its child + * will be made scrollable (it is expected to be a content preview view). + */ +class ChooserNestedScrollView : NestedScrollView { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int + ) : super(context, attrs, defStyleAttr) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val content = + getChildAt(0) as? LinearLayout ?: error("Exactly one child, LinerLayout, is expected") + require(content.orientation == LinearLayout.VERTICAL) { "VERTICAL orientation is expected" } + require(MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) { + "Expected to have an exact width" + } + + val lp = content.layoutParams ?: error("LayoutParams is missing") + val contentWidthSpec = + getChildMeasureSpec( + widthMeasureSpec, + paddingLeft + content.marginLeft + content.marginRight + paddingRight, + lp.width + ) + val contentHeightSpec = + getChildMeasureSpec( + heightMeasureSpec, + paddingTop + content.marginTop + content.marginBottom + paddingBottom, + lp.height + ) + content.measure(contentWidthSpec, contentHeightSpec) + + if (content.childCount > 1) { + // We expect that the first child should be scrollable up + val child = content.getChildAt(0) + val height = + MeasureSpec.getSize(heightMeasureSpec) + + child.measuredHeight + + child.marginTop + + child.marginBottom + + content.measure( + contentWidthSpec, + MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)) + ) + } + setMeasuredDimension( + MeasureSpec.getSize(widthMeasureSpec), + minOf( + MeasureSpec.getSize(heightMeasureSpec), + paddingTop + + content.marginTop + + content.measuredHeight + + content.marginBottom + + paddingBottom + ) + ) + } + + override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) { + // let the parent scroll + super.onNestedPreScroll(target, dx, dy, consumed, type) + // scroll ourselves, if recycler has not scrolled + val delta = dy - consumed[1] + if (delta > 0 && target is ScrollingView && !target.canScrollVertically(-1)) { + val preScrollY = scrollY + scrollBy(0, delta) + consumed[1] += scrollY - preScrollY + } + } +} diff --git a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt index 3f0458ee..55418c49 100644 --- a/java/src/com/android/intentresolver/widget/ImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ImagePreviewView.kt @@ -24,15 +24,16 @@ interface ImagePreviewView { /** * [ImagePreviewView] progressively prepares views for shared element transition and reports - * each successful preparation with [onTransitionElementReady] call followed by - * closing [onAllTransitionElementsReady] invocation. Thus the overall invocation pattern is - * zero or more [onTransitionElementReady] calls followed by the final - * [onAllTransitionElementsReady] call. + * each successful preparation with [onTransitionElementReady] call followed by closing + * [onAllTransitionElementsReady] invocation. Thus the overall invocation pattern is zero or + * more [onTransitionElementReady] calls followed by the final [onAllTransitionElementsReady] + * call. */ interface TransitionElementStatusCallback { /** - * Invoked when a view for a shared transition animation element is ready i.e. the image - * is loaded and the view is laid out. + * Invoked when a view for a shared transition animation element is ready i.e. the image is + * loaded and the view is laid out. + * * @param name shared element name. */ fun onTransitionElementReady(name: String) diff --git a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt index a7906001..a8aa633b 100644 --- a/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt +++ b/java/src/com/android/intentresolver/widget/RecyclerViewExtensions.kt @@ -26,10 +26,10 @@ internal val RecyclerView.areAllChildrenVisible: Boolean val first = getChildAt(0) val last = getChildAt(count - 1) val itemCount = adapter?.itemCount ?: 0 - return getChildAdapterPosition(first) == 0 - && getChildAdapterPosition(last) == itemCount - 1 - && isFullyVisible(first) - && isFullyVisible(last) + return getChildAdapterPosition(first) == 0 && + getChildAdapterPosition(last) == itemCount - 1 && + isFullyVisible(first) && + isFullyVisible(last) } private fun RecyclerView.isFullyVisible(view: View): Boolean = diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java index de76a1d2..2c8140d9 100644 --- a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -19,7 +19,6 @@ package com.android.intentresolver.widget; import static android.content.res.Resources.ID_NULL; -import android.annotation.IdRes; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; @@ -45,6 +44,10 @@ import android.view.animation.AnimationUtils; import android.widget.AbsListView; import android.widget.OverScroller; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ScrollingView; import androidx.recyclerview.widget.RecyclerView; import com.android.intentresolver.R; @@ -131,6 +134,9 @@ public class ResolverDrawerLayout extends ViewGroup { private AbsListView mNestedListChild; private RecyclerView mNestedRecyclerChild; + @Nullable + private final ScrollablePreviewFlingLogicDelegate mFlingLogicDelegate; + private final ViewTreeObserver.OnTouchModeChangeListener mTouchModeChangeListener = new ViewTreeObserver.OnTouchModeChangeListener() { @Override @@ -167,6 +173,12 @@ public class ResolverDrawerLayout extends ViewGroup { mIgnoreOffsetTopLimitViewId = a.getResourceId( R.styleable.ResolverDrawerLayout_ignoreOffsetTopLimit, ID_NULL); } + mFlingLogicDelegate = + a.getBoolean( + R.styleable.ResolverDrawerLayout_useScrollablePreviewNestedFlingLogic, + false) + ? new ScrollablePreviewFlingLogicDelegate() {} + : null; a.recycle(); mScrollIndicatorDrawable = mContext.getDrawable( @@ -832,6 +844,9 @@ public class ResolverDrawerLayout extends ViewGroup { @Override public boolean onNestedPreFling(View target, float velocityX, float velocityY) { + if (mFlingLogicDelegate != null) { + return mFlingLogicDelegate.onNestedPreFling(this, target, velocityX, velocityY); + } if (!getShowAtTop() && velocityY > mMinFlingVelocity && mCollapseOffset != 0) { smoothScrollTo(0, velocityY); return true; @@ -841,9 +856,12 @@ public class ResolverDrawerLayout extends ViewGroup { @Override public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { + if (mFlingLogicDelegate != null) { + return mFlingLogicDelegate.onNestedFling(this, target, velocityX, velocityY, consumed); + } // TODO: find a more suitable way to fix it. // RecyclerView started reporting `consumed` as true whenever a scrolling is enabled, - // previously the value was based whether the fling can be performed in given direction + // previously the value was based on whether the fling can be performed in given direction // i.e. whether it is at the top or at the bottom. isRecyclerViewAtTheTop method is a // workaround that restores the legacy functionality. boolean shouldConsume = (Math.abs(velocityY) > mMinFlingVelocity) @@ -885,6 +903,13 @@ public class ResolverDrawerLayout extends ViewGroup { && firstChild.getTop() >= recyclerView.getPaddingTop(); } + private static boolean isFlingTargetAtTop(View target) { + if (target instanceof ScrollingView) { + return !target.canScrollVertically(-1); + } + return false; + } + private boolean performAccessibilityActionCommon(int action) { switch (action) { case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: @@ -974,7 +999,7 @@ public class ResolverDrawerLayout extends ViewGroup { } @Override - public void onDrawForeground(Canvas canvas) { + public void onDrawForeground(@NonNull Canvas canvas) { if (mScrollIndicatorDrawable != null) { mScrollIndicatorDrawable.draw(canvas); } @@ -1299,4 +1324,74 @@ public class ResolverDrawerLayout extends ViewGroup { } return mMetricsLogger; } + + /** + * Controlled by + * {@link com.android.intentresolver.Flags#FLAG_SCROLLABLE_PREVIEW} + */ + private interface ScrollablePreviewFlingLogicDelegate { + default boolean onNestedPreFling( + ResolverDrawerLayout drawer, View target, float velocityX, float velocityY) { + boolean shouldScroll = !drawer.getShowAtTop() && velocityY > drawer.mMinFlingVelocity + && drawer.mCollapseOffset != 0; + if (shouldScroll) { + drawer.smoothScrollTo(0, velocityY); + return true; + } + boolean shouldDismiss = (Math.abs(velocityY) > drawer.mMinFlingVelocity) + && velocityY < 0 + && isFlingTargetAtTop(target); + if (shouldDismiss) { + if (drawer.getShowAtTop()) { + drawer.smoothScrollTo(drawer.mCollapsibleHeight, velocityY); + } else { + if (drawer.isDismissable() + && drawer.mCollapseOffset > drawer.mCollapsibleHeight) { + drawer.smoothScrollTo(drawer.mHeightUsed, velocityY); + drawer.mDismissOnScrollerFinished = true; + } else { + drawer.smoothScrollTo(drawer.mCollapsibleHeight, velocityY); + } + } + return true; + } + return false; + } + + default boolean onNestedFling( + ResolverDrawerLayout drawer, + View target, + float velocityX, + float velocityY, + boolean consumed) { + // TODO: find a more suitable way to fix it. + // RecyclerView started reporting `consumed` as true whenever a scrolling is enabled, + // previously the value was based on whether the fling can be performed in given + // direction i.e. whether it is at the top or at the bottom. isRecyclerViewAtTheTop + // method is a workaround that restores the legacy functionality. + boolean shouldConsume = (Math.abs(velocityY) > drawer.mMinFlingVelocity) && !consumed; + if (shouldConsume) { + if (drawer.getShowAtTop()) { + if (drawer.isDismissable() && velocityY > 0) { + drawer.abortAnimation(); + drawer.dismiss(); + } else { + drawer.smoothScrollTo( + velocityY < 0 ? drawer.mCollapsibleHeight : 0, velocityY); + } + } else { + if (drawer.isDismissable() + && velocityY < 0 + && drawer.mCollapseOffset > drawer.mCollapsibleHeight) { + drawer.smoothScrollTo(drawer.mHeightUsed, velocityY); + drawer.mDismissOnScrollerFinished = true; + } else { + drawer.smoothScrollTo( + velocityY > 0 ? 0 : drawer.mCollapsibleHeight, velocityY); + } + } + } + return shouldConsume; + } + } } diff --git a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt index 583a2887..7fe16091 100644 --- a/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt +++ b/java/src/com/android/intentresolver/widget/ScrollableImagePreviewView.kt @@ -26,11 +26,16 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.Animation.AnimationListener +import android.view.animation.DecelerateInterpolator import android.widget.ImageView import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.intentresolver.R @@ -39,14 +44,13 @@ import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatu import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch -import kotlinx.coroutines.plus +import kotlinx.coroutines.suspendCancellableCoroutine private const val TRANSITION_NAME = "screenshot_preview_image" private const val PLURALS_COUNT = "count" @@ -67,7 +71,6 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { defStyleAttr: Int ) : super(context, attrs, defStyleAttr) { layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - adapter = Adapter(context) context .obtainStyledAttributes(attrs, R.styleable.ScrollableImagePreviewView, defStyleAttr, 0) @@ -100,11 +103,14 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { ) .toInt() } - addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing)) + super.addItemDecoration(SpacingDecoration(innerSpacing, outerSpacing)) maxWidthHint = a.getDimensionPixelSize(R.styleable.ScrollableImagePreviewView_maxWidthHint, -1) } + val itemAnimator = ItemAnimator() + super.setItemAnimator(itemAnimator) + super.setAdapter(Adapter(context, itemAnimator.getAddDuration())) } private var batchLoader: BatchPreviewLoader? = null @@ -127,7 +133,7 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { isMeasured = true updateMaxWidthHint(widthSpec) updateMaxAspectRatio() - batchLoader?.loadAspectRatios(getMaxWidth(), this::updatePreviewSize) + maybeLoadAspectRatios() } } @@ -145,6 +151,17 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { ) } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + batchLoader?.totalItemCount?.let(previewAdapter::reset) + maybeLoadAspectRatios() + } + + override fun onDetachedFromWindow() { + batchLoader?.cancel() + super.onDetachedFromWindow() + } + override fun setTransitionElementStatusCallback(callback: TransitionElementStatusCallback?) { previewAdapter.transitionStatusElementCallback = callback } @@ -158,32 +175,46 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { return null } - fun setPreviews(previews: List<Preview>, otherItemCount: Int, imageLoader: CachingImageLoader) { - previewAdapter.reset(0, imageLoader) + override fun setAdapter(adapter: RecyclerView.Adapter<*>?) { + error("This method is not supported") + } + + override fun setItemAnimator(animator: RecyclerView.ItemAnimator?) { + error("This method is not supported") + } + + fun setImageLoader(imageLoader: CachingImageLoader) { + previewAdapter.imageLoader = imageLoader + } + + fun setLoading(totalItemCount: Int) { + previewAdapter.reset(totalItemCount) + } + + fun setPreviews(previews: Flow<Preview>, totalItemCount: Int) { + previewAdapter.reset(totalItemCount) batchLoader?.cancel() batchLoader = BatchPreviewLoader( - imageLoader, - previews, - otherItemCount, - onReset = { totalItemCount -> - previewAdapter.reset(totalItemCount, imageLoader) - }, - onUpdate = previewAdapter::addPreviews, - onCompletion = { - if (!previewAdapter.hasPreviews) { - onNoPreviewCallback?.run() - } - } - ) - .apply { - if (isMeasured) { - loadAspectRatios( - getMaxWidth(), - this@ScrollableImagePreviewView::updatePreviewSize - ) + previewAdapter.imageLoader ?: error("Image loader is not set"), + previews, + totalItemCount, + onUpdate = previewAdapter::addPreviews, + onCompletion = { + batchLoader = null + if (!previewAdapter.hasPreviews) { + onNoPreviewCallback?.run() } + previewAdapter.markLoaded() } + ) + maybeLoadAspectRatios() + } + + private fun maybeLoadAspectRatios() { + if (isMeasured && isAttachedToWindow()) { + batchLoader?.let { it.loadAspectRatios(getMaxWidth(), this::updatePreviewSize) } + } } var onNoPreviewCallback: Runnable? = null @@ -254,7 +285,10 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { File } - private class Adapter(private val context: Context) : RecyclerView.Adapter<ViewHolder>() { + private class Adapter( + private val context: Context, + private val fadeInDurationMs: Long, + ) : RecyclerView.Adapter<ViewHolder>() { private val previews = ArrayList<Preview>() private val imagePreviewDescription = context.resources.getString(R.string.image_preview_a11y_description) @@ -262,10 +296,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { context.resources.getString(R.string.video_preview_a11y_description) private val filePreviewDescription = context.resources.getString(R.string.file_preview_a11y_description) - private var imageLoader: CachingImageLoader? = null + var imageLoader: CachingImageLoader? = null private var firstImagePos = -1 private var totalItemCount: Int = 0 + private var isLoading = false private val hasOtherItem get() = previews.size < totalItemCount val hasPreviews: Boolean @@ -273,65 +308,86 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { var transitionStatusElementCallback: TransitionElementStatusCallback? = null - fun reset(totalItemCount: Int, imageLoader: CachingImageLoader) { - this.imageLoader = imageLoader + fun reset(totalItemCount: Int) { firstImagePos = -1 previews.clear() this.totalItemCount = maxOf(0, totalItemCount) + isLoading = this.totalItemCount > 0 notifyDataSetChanged() } + fun markLoaded() { + if (!isLoading) return + isLoading = false + if (hasOtherItem) { + notifyItemChanged(previews.size) + } else { + notifyItemRemoved(previews.size) + } + } + fun addPreviews(newPreviews: Collection<Preview>) { if (newPreviews.isEmpty()) return val insertPos = previews.size val hadOtherItem = hasOtherItem + val oldItemCount = getItemCount() previews.addAll(newPreviews) if (firstImagePos < 0) { val pos = newPreviews.indexOfFirst { it.type == PreviewType.Image } if (pos >= 0) firstImagePos = insertPos + pos } - notifyItemRangeInserted(insertPos, newPreviews.size) - when { - hadOtherItem && previews.size >= totalItemCount -> { - notifyItemRemoved(previews.size) + if (insertPos == 0) { + if (oldItemCount > 0) { + notifyItemRangeRemoved(0, oldItemCount) } - !hadOtherItem && previews.size < totalItemCount -> { - notifyItemInserted(previews.size) + notifyItemRangeInserted(insertPos, getItemCount()) + } else { + notifyItemRangeInserted(insertPos, newPreviews.size) + when { + hadOtherItem && !hasOtherItem -> { + notifyItemRemoved(previews.size) + } + !hadOtherItem && hasOtherItem -> { + notifyItemInserted(previews.size) + } + else -> notifyItemChanged(previews.size) } } } override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): ViewHolder { val view = LayoutInflater.from(context).inflate(itemType, parent, false) - return if (itemType == R.layout.image_preview_other_item) { - OtherItemViewHolder(view) - } else { - PreviewViewHolder( - view, - imagePreviewDescription, - videoPreviewDescription, - filePreviewDescription, - ) + return when (itemType) { + R.layout.image_preview_other_item -> OtherItemViewHolder(view) + R.layout.image_preview_loading_item -> LoadingItemViewHolder(view) + else -> + PreviewViewHolder( + view, + imagePreviewDescription, + videoPreviewDescription, + filePreviewDescription, + ) } } - override fun getItemCount(): Int = previews.size + if (hasOtherItem) 1 else 0 + override fun getItemCount(): Int = previews.size + if (isLoading || hasOtherItem) 1 else 0 - override fun getItemViewType(position: Int): Int { - return if (position == previews.size) { - R.layout.image_preview_other_item - } else { - R.layout.image_preview_image_item + override fun getItemViewType(position: Int): Int = + when { + position == previews.size && isLoading -> R.layout.image_preview_loading_item + position == previews.size -> R.layout.image_preview_other_item + else -> R.layout.image_preview_image_item } - } override fun onBindViewHolder(vh: ViewHolder, position: Int) { when (vh) { is OtherItemViewHolder -> vh.bind(totalItemCount - previews.size) + is LoadingItemViewHolder -> vh.bind() is PreviewViewHolder -> vh.bind( previews[position], imageLoader ?: error("ImageLoader is missing"), + fadeInDurationMs, isSharedTransitionElement = position == firstImagePos, previewReadyCallback = if ( @@ -382,10 +438,13 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { fun bind( preview: Preview, imageLoader: CachingImageLoader, + fadeInDurationMs: Long, isSharedTransitionElement: Boolean, previewReadyCallback: ((String) -> Unit)? ) { image.setImageDrawable(null) + image.alpha = 1f + image.clearAnimation() (image.layoutParams as? ConstraintLayout.LayoutParams)?.let { params -> params.dimensionRatio = preview.aspectRatioString } @@ -419,11 +478,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } resetScope().launch { loadImage(preview, imageLoader) - if (preview.type == PreviewType.Image) { - previewReadyCallback?.let { callback -> - image.waitForPreDraw() - callback(TRANSITION_NAME) - } + if (preview.type == PreviewType.Image && previewReadyCallback != null) { + image.waitForPreDraw() + previewReadyCallback(TRANSITION_NAME) + } else if (image.isAttachedToWindow()) { + fadeInPreview(fadeInDurationMs) } } } @@ -439,8 +498,32 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { image.setImageBitmap(bitmap) } + private suspend fun fadeInPreview(durationMs: Long) = + suspendCancellableCoroutine { continuation -> + val animation = + AlphaAnimation(0f, 1f).apply { + duration = durationMs + interpolator = DecelerateInterpolator() + setAnimationListener( + object : AnimationListener { + override fun onAnimationStart(animation: Animation?) = Unit + override fun onAnimationRepeat(animation: Animation?) = Unit + + override fun onAnimationEnd(animation: Animation?) { + continuation.resumeWith(Result.success(Unit)) + } + } + ) + } + image.startAnimation(animation) + continuation.invokeOnCancellation { + image.clearAnimation() + image.alpha = 1f + } + } + private fun resetScope(): CoroutineScope = - (MainScope() + Dispatchers.Main.immediate).also { + CoroutineScope(Dispatchers.Main.immediate).also { scope?.cancel() scope = it } @@ -466,6 +549,11 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { override fun unbind() = Unit } + private class LoadingItemViewHolder(view: View) : ViewHolder(view) { + fun bind() = Unit + override fun unbind() = Unit + } + private class SpacingDecoration(private val innerSpacing: Int, private val outerSpacing: Int) : ItemDecoration() { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) { @@ -482,30 +570,89 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { } } + /** + * ItemAnimator to handle a special case of addng first image items into the view. The view is + * used with wrap_content width spec thus after adding the first views it, generally, changes + * its size and position breaking the animation. This class handles that by preserving loading + * idicator position in this special case. + */ + private inner class ItemAnimator() : DefaultItemAnimator() { + private var animatedVH: ViewHolder? = null + private var originalTranslation = 0f + + override fun recordPreLayoutInformation( + state: State, + viewHolder: RecyclerView.ViewHolder, + changeFlags: Int, + payloads: MutableList<Any> + ): ItemHolderInfo { + return super.recordPreLayoutInformation(state, viewHolder, changeFlags, payloads).let { + holderInfo -> + if (viewHolder is LoadingItemViewHolder && getChildCount() == 1) { + LoadingItemHolderInfo(holderInfo, parentLeft = left) + } else { + holderInfo + } + } + } + + override fun animateDisappearance( + viewHolder: RecyclerView.ViewHolder, + preLayoutInfo: ItemHolderInfo, + postLayoutInfo: ItemHolderInfo? + ): Boolean { + if (viewHolder is LoadingItemViewHolder && preLayoutInfo is LoadingItemHolderInfo) { + val view = viewHolder.itemView + animatedVH = viewHolder + originalTranslation = view.getTranslationX() + view.setTranslationX( + (preLayoutInfo.parentLeft - left + preLayoutInfo.left).toFloat() - view.left + ) + } + return super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo) + } + + override fun onRemoveFinished(viewHolder: RecyclerView.ViewHolder) { + if (animatedVH === viewHolder) { + viewHolder.itemView.setTranslationX(originalTranslation) + animatedVH = null + } + super.onRemoveFinished(viewHolder) + } + + private inner class LoadingItemHolderInfo( + holderInfo: ItemHolderInfo, + val parentLeft: Int, + ) : ItemHolderInfo() { + init { + left = holderInfo.left + top = holderInfo.top + right = holderInfo.right + bottom = holderInfo.bottom + changeFlags = holderInfo.changeFlags + } + } + } + @VisibleForTesting class BatchPreviewLoader( private val imageLoader: CachingImageLoader, - previews: List<Preview>, - otherItemCount: Int, - private val onReset: (Int) -> Unit, + private val previews: Flow<Preview>, + val totalItemCount: Int, private val onUpdate: (List<Preview>) -> Unit, private val onCompletion: () -> Unit, ) { - private val previews: List<Preview> = - if (previews is RandomAccess) previews else ArrayList(previews) - private val totalItemCount = previews.size + otherItemCount - private var scope: CoroutineScope? = MainScope() + Dispatchers.Main.immediate + private var scope: CoroutineScope = createScope() + + private fun createScope() = CoroutineScope(Dispatchers.Main.immediate) fun cancel() { - scope?.cancel() - scope = null + scope.cancel() + scope = createScope() } fun loadAspectRatios(maxWidth: Int, previewSizeUpdater: (Preview, Int, Int) -> Int) { - val scope = this.scope ?: return - // -1 encodes that the preview has not been processed, - // 0 means failed, > 0 is a preview width - val previewWidths = IntArray(previews.size) { -1 } + val previewInfos = ArrayList<PreviewWidthInfo>(totalItemCount) var blockStart = 0 // inclusive var blockEnd = 0 // exclusive @@ -514,26 +661,16 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { val updateEvent = Any() val completedEvent = Any() - // throttle adapter updates using flow; the flow first emits when enough preview - // elements is loaded to fill the viewport and then each time a subsequent block of - // previews is loaded + // collects updates from [reportFlow] throttling adapter updates; scope.launch(Dispatchers.Main) { reportFlow .takeWhile { it !== completedEvent } .throttle(ADAPTER_UPDATE_INTERVAL_MS) - .onCompletion { cause -> - if (cause == null) { - onCompletion() - } - } .collect { - if (blockStart == 0) { - onReset(totalItemCount) - } val updates = ArrayList<Preview>(blockEnd - blockStart) while (blockStart < blockEnd) { - if (previewWidths[blockStart] > 0) { - updates.add(previews[blockStart]) + if (previewInfos[blockStart].width > 0) { + updates.add(previewInfos[blockStart].preview) } blockStart++ } @@ -541,57 +678,64 @@ class ScrollableImagePreviewView : RecyclerView, ImagePreviewView { onUpdate(updates) } } + onCompletion() } + // Collects [previews] flow and loads aspect ratios, emits updates into [reportFlow] + // when a next sequential block of preview aspect ratios is loaded: initially emits when + // enough preview elements is loaded to fill the viewport. scope.launch { var blockWidth = 0 var isFirstBlock = true - var nextIdx = 0 - List<Job>(4) { - launch { - while (true) { - val i = nextIdx++ - if (i >= previews.size) break - val preview = previews[i] - - previewWidths[i] = - runCatching { - // TODO: decide on adding a timeout - imageLoader(preview.uri, isFirstBlock)?.let { bitmap -> - previewSizeUpdater( - preview, - bitmap.width, - bitmap.height - ) - } - ?: 0 - } - .getOrDefault(0) - - if (blockEnd != i) continue - while ( - blockEnd < previewWidths.size && previewWidths[blockEnd] >= 0 - ) { - blockWidth += previewWidths[blockEnd] - blockEnd++ - } - if (isFirstBlock) { - if (blockWidth >= maxWidth) { - isFirstBlock = false - // notify that the preview now can be displayed - reportFlow.emit(updateEvent) + + val jobs = ArrayList<Job>() + previews.collect { preview -> + val i = previewInfos.size + val pair = PreviewWidthInfo(preview) + previewInfos.add(pair) + + val job = launch { + pair.width = + runCatching { + // TODO: decide on adding a timeout. The worst case I can + // 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 -> + previewSizeUpdater(preview, bitmap.width, bitmap.height) } - } else { - reportFlow.emit(updateEvent) + ?: 0 } + .getOrDefault(0) + + if (i == blockEnd) { + while ( + blockEnd < previewInfos.size && previewInfos[blockEnd].width >= 0 + ) { + blockWidth += previewInfos[blockEnd].width + blockEnd++ + } + if (isFirstBlock && blockWidth >= maxWidth) { + isFirstBlock = false + } + if (!isFirstBlock) { + reportFlow.emit(updateEvent) } } } - .joinAll() + jobs.add(job) + } + jobs.joinAll() // in case all previews have failed to load reportFlow.emit(updateEvent) reportFlow.emit(completedEvent) } } } + + private class PreviewWidthInfo(val preview: Preview) { + // -1 encodes that the preview has not been processed, + // 0 means failed, > 0 is a preview width + var width: Int = -1 + } } diff --git a/java/src/com/android/intentresolver/widget/ViewExtensions.kt b/java/src/com/android/intentresolver/widget/ViewExtensions.kt index 11b7c146..d19933f5 100644 --- a/java/src/com/android/intentresolver/widget/ViewExtensions.kt +++ b/java/src/com/android/intentresolver/widget/ViewExtensions.kt @@ -19,21 +19,26 @@ package com.android.intentresolver.widget import android.util.Log import android.view.View import androidx.core.view.OneShotPreDrawListener -import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.suspendCancellableCoroutine internal suspend fun View.waitForPreDraw(): Unit = suspendCancellableCoroutine { continuation -> val isResumed = AtomicBoolean(false) - val callback = OneShotPreDrawListener.add( - this, - Runnable { - if (isResumed.compareAndSet(false, true)) { - continuation.resumeWith(Result.success(Unit)) - } else { - // it's not really expected but in some unknown corner-case let's not crash - Log.e("waitForPreDraw", "An attempt to resume a completed coroutine", Exception()) + val callback = + OneShotPreDrawListener.add( + this, + Runnable { + if (isResumed.compareAndSet(false, true)) { + continuation.resumeWith(Result.success(Unit)) + } else { + // it's not really expected but in some unknown corner-case let's not crash + Log.e( + "waitForPreDraw", + "An attempt to resume a completed coroutine", + Exception() + ) + } } - } - ) + ) continuation.invokeOnCancellation { callback.removeListener() } } diff --git a/java/tests/Android.bp b/java/tests/Android.bp deleted file mode 100644 index c381d0a8..00000000 --- a/java/tests/Android.bp +++ /dev/null @@ -1,39 +0,0 @@ -package { - // See: http://go/android-license-faq - default_applicable_licenses: ["packages_modules_IntentResolver_license"], -} - -android_test { - name: "IntentResolverUnitTests", - - // Include all test java files. - srcs: ["src/**/*.java", "src/**/*.kt"], - - libs: [ - "android.test.runner", - "android.test.base", - "android.test.mock", - "framework", - "framework-res", - ], - - static_libs: [ - "IntentResolver-core", - "androidx.test.rules", - "androidx.test.ext.junit", - "androidx.test.espresso.contrib", - "mockito-target-minus-junit4", - "androidx.test.espresso.core", - "androidx.lifecycle_lifecycle-common-java8", - "androidx.lifecycle_lifecycle-extensions", - "androidx.lifecycle_lifecycle-runtime-ktx", - "truth-prebuilt", - "testables", - "kotlinx_coroutines_test", - ], - test_suites: ["general-tests"], - sdk_version: "core_platform", - compile_multilib: "both", - - dont_merge_manifests: true, -} diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml deleted file mode 100644 index 05830c4c..00000000 --- a/java/tests/AndroidManifest.xml +++ /dev/null @@ -1,43 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2021 The Android Open Source Project - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. ---> - -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.android.intentresolver.tests"> - - <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30" /> - - <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS_FULL"/> - <uses-permission android:name="android.permission.QUERY_USERS"/> - <uses-permission android:name="android.permission.READ_CLIPBOARD_IN_BACKGROUND"/> - <uses-permission android:name="android.permission.WRITE_DEVICE_CONFIG"/> - <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" /> - - <application android:name="com.android.intentresolver.TestApplication"> - <uses-library android:name="android.test.runner" /> - <activity android:name="com.android.intentresolver.ChooserWrapperActivity" /> - <activity android:name="com.android.intentresolver.ResolverWrapperActivity" /> - <provider - android:authorities="com.android.intentresolver.tests" - android:name="com.android.intentresolver.TestContentProvider" - android:grantUriPermissions="true" /> - </application> - - <instrumentation android:name="android.testing.TestableInstrumentation" - android:targetPackage="com.android.intentresolver.tests" - android:label="Tests for IntentResolver"> - </instrumentation> - -</manifest> diff --git a/java/tests/AndroidTest.xml b/java/tests/AndroidTest.xml deleted file mode 100644 index d1d77c10..00000000 --- a/java/tests/AndroidTest.xml +++ /dev/null @@ -1,28 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2021 The Android Open Source Project - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. ---> -<configuration description="Run IntentResolver Tests."> - <target_preparer class="com.android.tradefed.targetprep.TestAppInstallSetup"> - <option name="test-file-name" value="IntentResolverUnitTests.apk" /> - </target_preparer> - - <option name="test-suite-tag" value="apct" /> - <option name="test-tag" value="IntentResolverUnitTests" /> - <test class="com.android.tradefed.testtype.AndroidJUnitTest" > - <option name="package" value="com.android.intentresolver.tests" /> - <option name="runner" value="android.testing.TestableInstrumentation" /> - <option name="hidden-api-checks" value="false"/> - </test> -</configuration> diff --git a/java/tests/res/drawable/test320x240.png b/java/tests/res/drawable/test320x240.png Binary files differdeleted file mode 100644 index 9b5800da..00000000 --- a/java/tests/res/drawable/test320x240.png +++ /dev/null diff --git a/java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt b/java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt deleted file mode 100644 index cd2fbc7a..00000000 --- a/java/tests/src/com/android/intentresolver/AnnotatedUserHandlesTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.os.UserHandle - -import com.google.common.truth.Truth.assertThat - -import org.junit.Test - -class AnnotatedUserHandlesTest { - - @Test - fun testBasicProperties() { // Fields that are reflected back w/o logic. - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(116)) - .setPersonalProfileUserHandle(UserHandle.of(117)) - .setWorkProfileUserHandle(UserHandle.of(118)) - .setCloneProfileUserHandle(UserHandle.of(119)) - .build() - - assertThat(info.userIdOfCallingApp).isEqualTo(42) - assertThat(info.userHandleSharesheetLaunchedAs.identifier).isEqualTo(116) - assertThat(info.personalProfileUserHandle.identifier).isEqualTo(117) - assertThat(info.workProfileUserHandle?.identifier).isEqualTo(118) - assertThat(info.cloneProfileUserHandle?.identifier).isEqualTo(119) - } - - @Test - fun testWorkTabInitiallySelectedWhenLaunchedFromWorkProfile() { - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setPersonalProfileUserHandle(UserHandle.of(101)) - .setWorkProfileUserHandle(UserHandle.of(202)) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(202)) - .build() - - assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(202) - } - - @Test - fun testPersonalTabInitiallySelectedWhenLaunchedFromPersonalProfile() { - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setPersonalProfileUserHandle(UserHandle.of(101)) - .setWorkProfileUserHandle(UserHandle.of(202)) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(101)) - .build() - - assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(101) - } - - @Test - fun testPersonalTabInitiallySelectedWhenLaunchedFromOtherProfile() { - val info = AnnotatedUserHandles.newBuilder() - .setUserIdOfCallingApp(42) - .setPersonalProfileUserHandle(UserHandle.of(101)) - .setWorkProfileUserHandle(UserHandle.of(202)) - .setUserHandleSharesheetLaunchedAs(UserHandle.of(303)) - .build() - - assertThat(info.tabOwnerUserHandleForLaunch.identifier).isEqualTo(101) - } -} diff --git a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt b/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt deleted file mode 100644 index 8d994f08..00000000 --- a/java/tests/src/com/android/intentresolver/ChooserActionFactoryTest.kt +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.app.Activity -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.res.Resources -import android.graphics.drawable.Icon -import android.service.chooser.ChooserAction -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.collect.ImmutableList -import com.google.common.truth.Truth.assertThat -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.function.Consumer -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito - -@RunWith(AndroidJUnit4::class) -class ChooserActionFactoryTest { - private val context = InstrumentationRegistry.getInstrumentation().getContext() - - private val logger = mock<ChooserActivityLogger>() - private val actionLabel = "Action label" - private val modifyShareLabel = "Modify share" - private val testAction = "com.android.intentresolver.testaction" - private val countdown = CountDownLatch(1) - private val testReceiver: BroadcastReceiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - // Just doing at most a single countdown per test. - countdown.countDown() - } - } - private val resultConsumer = - object : Consumer<Int> { - var latestReturn = Integer.MIN_VALUE - - override fun accept(resultCode: Int) { - latestReturn = resultCode - } - } - - @Before - fun setup() { - context.registerReceiver(testReceiver, IntentFilter(testAction)) - } - - @After - fun teardown() { - context.unregisterReceiver(testReceiver) - } - - @Test - fun testCreateCustomActions() { - val factory = createFactory() - - val customActions = factory.createCustomActions() - - assertThat(customActions.size).isEqualTo(1) - assertThat(customActions[0].label).isEqualTo(actionLabel) - - // click it - customActions[0].onClicked.run() - - Mockito.verify(logger).logCustomActionSelected(eq(0)) - assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) - // Verify the pending intent has been called - countdown.await(500, TimeUnit.MILLISECONDS) - } - - @Test - fun testNoModifyShareAction() { - val factory = createFactory(includeModifyShare = false) - - assertThat(factory.modifyShareAction).isNull() - } - - @Test - fun testModifyShareAction() { - val factory = createFactory(includeModifyShare = true) - - val action = factory.modifyShareAction ?: error("Modify share action should not be null") - action.onClicked.run() - - Mockito.verify(logger) - .logActionSelected(eq(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE)) - assertEquals(Activity.RESULT_OK, resultConsumer.latestReturn) - // Verify the pending intent has been called - countdown.await(500, TimeUnit.MILLISECONDS) - } - - @Test - fun nonSendAction_noCopyRunnable() { - val targetIntent = - Intent(Intent.ACTION_SEND_MULTIPLE).apply { - putExtra(Intent.EXTRA_TEXT, "Text to show") - } - - val chooserRequest = - mock<ChooserRequestParameters> { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } - val testSubject = - ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - {}, - ) - assertThat(testSubject.copyButtonRunnable).isNull() - } - - @Test - fun sendActionNoText_noCopyRunnable() { - val targetIntent = Intent(Intent.ACTION_SEND) - - val chooserRequest = - mock<ChooserRequestParameters> { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } - val testSubject = - ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - {}, - ) - assertThat(testSubject.copyButtonRunnable).isNull() - } - - @Test - fun sendActionWithText_nonNullCopyRunnable() { - val targetIntent = Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Text") } - - val chooserRequest = - mock<ChooserRequestParameters> { - whenever(this.targetIntent).thenReturn(targetIntent) - whenever(chooserActions).thenReturn(ImmutableList.of()) - } - val testSubject = - ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - {}, - ) - assertThat(testSubject.copyButtonRunnable).isNotNull() - } - - private fun createFactory(includeModifyShare: Boolean = false): ChooserActionFactory { - val testPendingIntent = PendingIntent.getActivity(context, 0, Intent(testAction), 0) - val targetIntent = Intent() - val action = - ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - actionLabel, - testPendingIntent - ) - .build() - val chooserRequest = mock<ChooserRequestParameters>() - whenever(chooserRequest.targetIntent).thenReturn(targetIntent) - whenever(chooserRequest.chooserActions).thenReturn(ImmutableList.of(action)) - - if (includeModifyShare) { - val modifyShare = - ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - modifyShareLabel, - testPendingIntent - ) - .build() - whenever(chooserRequest.modifyShareAction).thenReturn(modifyShare) - } - - return ChooserActionFactory( - context, - chooserRequest, - mock(), - logger, - {}, - { null }, - mock(), - resultConsumer - ) - } -} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java deleted file mode 100644 index aa42c24c..00000000 --- a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerTest.java +++ /dev/null @@ -1,422 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static com.google.common.truth.Truth.assertThat; - -import static org.mockito.AdditionalMatchers.gt; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; - -import android.content.Intent; -import android.metrics.LogMaker; - -import com.android.intentresolver.ChooserActivityLogger.FrameworkStatsLogger; -import com.android.intentresolver.ChooserActivityLogger.SharesheetStandardEvent; -import com.android.intentresolver.ChooserActivityLogger.SharesheetStartedEvent; -import com.android.intentresolver.ChooserActivityLogger.SharesheetTargetSelectedEvent; -import com.android.intentresolver.contentpreview.ContentPreviewType; -import com.android.internal.logging.InstanceId; -import com.android.internal.logging.MetricsLogger; -import com.android.internal.logging.UiEventLogger; -import com.android.internal.logging.UiEventLogger.UiEventEnum; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import com.android.internal.util.FrameworkStatsLog; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; - -@RunWith(MockitoJUnitRunner.class) -public final class ChooserActivityLoggerTest { - @Mock private UiEventLogger mUiEventLog; - @Mock private FrameworkStatsLogger mFrameworkLog; - @Mock private MetricsLogger mMetricsLogger; - - private ChooserActivityLogger mChooserLogger; - - @Before - public void setUp() { - //Mockito.reset(mUiEventLog, mFrameworkLog, mMetricsLogger); - mChooserLogger = new ChooserActivityLogger(mUiEventLog, mFrameworkLog, mMetricsLogger); - } - - @After - public void tearDown() { - verifyNoMoreInteractions(mUiEventLog); - verifyNoMoreInteractions(mFrameworkLog); - verifyNoMoreInteractions(mMetricsLogger); - } - - @Test - public void testLogChooserActivityShown_personalProfile() { - final boolean isWorkProfile = false; - final String mimeType = "application/TestType"; - final long systemCost = 456; - - mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost); - - ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - - assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN); - assertThat(event.getSubtype()).isEqualTo(MetricsEvent.PARENT_PROFILE); - assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType); - assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS)) - .isEqualTo(systemCost); - } - - @Test - public void testLogChooserActivityShown_workProfile() { - final boolean isWorkProfile = true; - final String mimeType = "application/TestType"; - final long systemCost = 456; - - mChooserLogger.logChooserActivityShown(isWorkProfile, mimeType, systemCost); - - ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - - assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN); - assertThat(event.getSubtype()).isEqualTo(MetricsEvent.MANAGED_PROFILE); - assertThat(event.getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE)).isEqualTo(mimeType); - assertThat(event.getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS)) - .isEqualTo(systemCost); - } - - @Test - public void testLogShareStarted() { - final String packageName = "com.test.foo"; - final String mimeType = "text/plain"; - final int appProvidedDirectTargets = 123; - final int appProvidedAppTargets = 456; - final boolean workProfile = true; - final int previewType = ContentPreviewType.CONTENT_PREVIEW_FILE; - final String intentAction = Intent.ACTION_SENDTO; - final int numCustomActions = 3; - final boolean modifyShareProvided = true; - - mChooserLogger.logShareStarted( - packageName, - mimeType, - appProvidedDirectTargets, - appProvidedAppTargets, - workProfile, - previewType, - intentAction, - numCustomActions, - modifyShareProvided); - - verify(mFrameworkLog).write( - eq(FrameworkStatsLog.SHARESHEET_STARTED), - eq(SharesheetStartedEvent.SHARE_STARTED.getId()), - eq(packageName), - /* instanceId=*/ gt(0), - eq(mimeType), - eq(appProvidedDirectTargets), - eq(appProvidedAppTargets), - eq(workProfile), - eq(FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE), - eq(FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO), - /* custom actions provided */ eq(numCustomActions), - /* reselection action provided */ eq(modifyShareProvided)); - } - - @Test - public void testLogShareTargetSelected() { - final int targetType = ChooserActivityLogger.SELECTION_TYPE_SERVICE; - final String packageName = "com.test.foo"; - final int positionPicked = 123; - final int directTargetAlsoRanked = -1; - final int callerTargetCount = 0; - final boolean isPinned = true; - final boolean isSuccessfullySelected = true; - final long selectionCost = 456; - - mChooserLogger.logShareTargetSelected( - targetType, - packageName, - positionPicked, - directTargetAlsoRanked, - callerTargetCount, - /* directTargetHashed= */ null, - isPinned, - isSuccessfullySelected, - selectionCost); - - verify(mFrameworkLog).write( - eq(FrameworkStatsLog.RANKING_SELECTED), - eq(SharesheetTargetSelectedEvent.SHARESHEET_SERVICE_TARGET_SELECTED.getId()), - eq(packageName), - /* instanceId=*/ gt(0), - eq(positionPicked), - eq(isPinned)); - - ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - assertThat(event.getCategory()).isEqualTo( - MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET); - assertThat(event.getSubtype()).isEqualTo(positionPicked); - } - - @Test - public void testLogActionSelected() { - mChooserLogger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); - - verify(mFrameworkLog).write( - eq(FrameworkStatsLog.RANKING_SELECTED), - eq(SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId()), - eq(""), - /* instanceId=*/ gt(0), - eq(-1), - eq(false)); - - ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - assertThat(event.getCategory()).isEqualTo( - MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET); - assertThat(event.getSubtype()).isEqualTo(1); - } - - @Test - public void testLogCustomActionSelected() { - final int position = 4; - mChooserLogger.logCustomActionSelected(position); - - verify(mFrameworkLog).write( - eq(FrameworkStatsLog.RANKING_SELECTED), - eq(SharesheetTargetSelectedEvent.SHARESHEET_CUSTOM_ACTION_SELECTED.getId()), - any(), anyInt(), eq(position), eq(false)); - } - - @Test - public void testLogDirectShareTargetReceived() { - final int category = MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER; - final int latency = 123; - - mChooserLogger.logDirectShareTargetReceived(category, latency); - - ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - assertThat(event.getCategory()).isEqualTo(category); - assertThat(event.getSubtype()).isEqualTo(latency); - } - - @Test - public void testLogActionShareWithPreview() { - final int previewType = ContentPreviewType.CONTENT_PREVIEW_TEXT; - - mChooserLogger.logActionShareWithPreview(previewType); - - ArgumentCaptor<LogMaker> eventCaptor = ArgumentCaptor.forClass(LogMaker.class); - verify(mMetricsLogger).write(eventCaptor.capture()); - LogMaker event = eventCaptor.getValue(); - assertThat(event.getCategory()).isEqualTo(MetricsEvent.ACTION_SHARE_WITH_PREVIEW); - assertThat(event.getSubtype()).isEqualTo(previewType); - } - - @Test - public void testLogSharesheetTriggered() { - mChooserLogger.logSharesheetTriggered(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_TRIGGERED), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetAppLoadComplete() { - mChooserLogger.logSharesheetAppLoadComplete(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetDirectLoadComplete() { - mChooserLogger.logSharesheetDirectLoadComplete(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE), - eq(0), - isNull(), - any()); - } - - @Test - public void testLogSharesheetDirectLoadTimeout() { - mChooserLogger.logSharesheetDirectLoadTimeout(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetProfileChanged() { - mChooserLogger.logSharesheetProfileChanged(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetExpansionChanged_collapsed() { - mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ true); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_COLLAPSED), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetExpansionChanged_expanded() { - mChooserLogger.logSharesheetExpansionChanged(/* isCollapsed=*/ false); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_EXPANDED), eq(0), isNull(), any()); - } - - @Test - public void testLogSharesheetAppShareRankingTimeout() { - mChooserLogger.logSharesheetAppShareRankingTimeout(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT), - eq(0), - isNull(), - any()); - } - - @Test - public void testLogSharesheetEmptyDirectShareRow() { - mChooserLogger.logSharesheetEmptyDirectShareRow(); - verify(mUiEventLog).logWithInstanceId( - eq(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW), - eq(0), - isNull(), - any()); - } - - @Test - public void testDifferentLoggerInstancesUseDifferentInstanceIds() { - ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class); - ChooserActivityLogger chooserLogger2 = - new ChooserActivityLogger(mUiEventLog, mFrameworkLog, mMetricsLogger); - - final int targetType = ChooserActivityLogger.SELECTION_TYPE_COPY; - final String packageName = "com.test.foo"; - final int positionPicked = 123; - final int directTargetAlsoRanked = -1; - final int callerTargetCount = 0; - final boolean isPinned = true; - final boolean isSuccessfullySelected = true; - final long selectionCost = 456; - - mChooserLogger.logShareTargetSelected( - targetType, - packageName, - positionPicked, - directTargetAlsoRanked, - callerTargetCount, - /* directTargetHashed= */ null, - isPinned, - isSuccessfullySelected, - selectionCost); - - chooserLogger2.logShareTargetSelected( - targetType, - packageName, - positionPicked, - directTargetAlsoRanked, - callerTargetCount, - /* directTargetHashed= */ null, - isPinned, - isSuccessfullySelected, - selectionCost); - - verify(mFrameworkLog, times(2)).write( - anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); - - int id1 = idIntCaptor.getAllValues().get(0); - int id2 = idIntCaptor.getAllValues().get(1); - - assertThat(id1).isGreaterThan(0); - assertThat(id2).isGreaterThan(0); - assertThat(id1).isNotEqualTo(id2); - } - - @Test - public void testUiAndFrameworkEventsUseSameInstanceIdForSameLoggerInstance() { - ArgumentCaptor<Integer> idIntCaptor = ArgumentCaptor.forClass(Integer.class); - ArgumentCaptor<InstanceId> idObjectCaptor = ArgumentCaptor.forClass(InstanceId.class); - - final int targetType = ChooserActivityLogger.SELECTION_TYPE_COPY; - final String packageName = "com.test.foo"; - final int positionPicked = 123; - final int directTargetAlsoRanked = -1; - final int callerTargetCount = 0; - final boolean isPinned = true; - final boolean isSuccessfullySelected = true; - final long selectionCost = 456; - - mChooserLogger.logShareTargetSelected( - targetType, - packageName, - positionPicked, - directTargetAlsoRanked, - callerTargetCount, - /* directTargetHashed= */ null, - isPinned, - isSuccessfullySelected, - selectionCost); - - verify(mFrameworkLog).write( - anyInt(), anyInt(), anyString(), idIntCaptor.capture(), anyInt(), anyBoolean()); - - mChooserLogger.logSharesheetTriggered(); - verify(mUiEventLog).logWithInstanceId( - any(UiEventEnum.class), anyInt(), any(), idObjectCaptor.capture()); - - assertThat(idIntCaptor.getValue()).isGreaterThan(0); - assertThat(idObjectCaptor.getValue().getId()).isEqualTo(idIntCaptor.getValue()); - } - - @Test - public void testTargetSelectionCategories() { - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_SERVICE)) - .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET); - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_APP)) - .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET); - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_STANDARD)) - .isEqualTo(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET); - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_COPY)).isEqualTo(0); - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_NEARBY)).isEqualTo(0); - assertThat(ChooserActivityLogger.getTargetSelectionCategory( - ChooserActivityLogger.SELECTION_TYPE_EDIT)).isEqualTo(0); - } -} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java deleted file mode 100644 index ce96ef63..00000000 --- a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import android.content.pm.PackageManager; -import android.content.res.Resources; -import android.database.Cursor; -import android.os.UserHandle; - -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.contentpreview.ImageLoader; -import com.android.intentresolver.flags.FeatureFlagRepository; -import com.android.intentresolver.shortcuts.ShortcutLoader; - -import java.util.function.Consumer; -import java.util.function.Function; - -import kotlin.jvm.functions.Function2; - -/** - * Singleton providing overrides to be applied by any {@code IChooserWrapper} used in testing. - * We cannot directly mock the activity created since instrumentation creates it, so instead we use - * this singleton to modify behavior. - */ -public class ChooserActivityOverrideData { - private static ChooserActivityOverrideData sInstance = null; - - public static ChooserActivityOverrideData getInstance() { - if (sInstance == null) { - sInstance = new ChooserActivityOverrideData(); - } - return sInstance; - } - - @SuppressWarnings("Since15") - public Function<PackageManager, PackageManager> createPackageManager; - public Function<TargetInfo, Boolean> onSafelyStartInternalCallback; - public Function<TargetInfo, Boolean> onSafelyStartCallback; - public Function2<UserHandle, Consumer<ShortcutLoader.Result>, ShortcutLoader> - shortcutLoaderFactory = (userHandle, callback) -> null; - public ChooserActivity.ChooserListController resolverListController; - public ChooserActivity.ChooserListController workResolverListController; - public Boolean isVoiceInteraction; - public Cursor resolverCursor; - public boolean resolverForceException; - public ImageLoader imageLoader; - public ChooserActivityLogger chooserActivityLogger; - public int alternateProfileSetting; - public Resources resources; - public UserHandle workProfileUserHandle; - public UserHandle cloneProfileUserHandle; - public UserHandle tabOwnerUserHandleForLaunch; - public boolean hasCrossProfileIntents; - public boolean isQuietModeEnabled; - public Integer myUserId; - public WorkProfileAvailabilityManager mWorkProfileAvailability; - public CrossProfileIntentsChecker mCrossProfileIntentsChecker; - public PackageManager packageManager; - public FeatureFlagRepository featureFlagRepository; - - public void reset() { - onSafelyStartInternalCallback = null; - isVoiceInteraction = null; - createPackageManager = null; - imageLoader = null; - resolverCursor = null; - resolverForceException = false; - resolverListController = mock(ChooserActivity.ChooserListController.class); - workResolverListController = mock(ChooserActivity.ChooserListController.class); - chooserActivityLogger = mock(ChooserActivityLogger.class); - alternateProfileSetting = 0; - resources = null; - workProfileUserHandle = null; - cloneProfileUserHandle = null; - tabOwnerUserHandleForLaunch = null; - hasCrossProfileIntents = true; - isQuietModeEnabled = false; - myUserId = null; - packageManager = null; - mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { - @Override - public boolean isQuietModeEnabled() { - return isQuietModeEnabled; - } - - @Override - public boolean isWorkProfileUserUnlocked() { - return true; - } - - @Override - public void requestQuietModeEnabled(boolean enabled) { - isQuietModeEnabled = enabled; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() {} - - @Override - public boolean isWaitingToEnableWorkProfile() { - return false; - } - }; - shortcutLoaderFactory = ((userHandle, resultConsumer) -> null); - - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); - when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) - .thenAnswer(invocation -> hasCrossProfileIntents); - featureFlagRepository = null; - } - - private ChooserActivityOverrideData() {} -} - diff --git a/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt b/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt deleted file mode 100644 index 9a5dabdb..00000000 --- a/java/tests/src/com/android/intentresolver/ChooserIntegratedDeviceComponentsTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.content.ComponentName -import android.provider.Settings -import android.testing.TestableContext -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ChooserIntegratedDeviceComponentsTest { - private val secureSettings = mock<SecureSettings>() - private val testableContext = - TestableContext(InstrumentationRegistry.getInstrumentation().getContext()) - - @Test - fun testEditorAndNearby() { - val resources = testableContext.getOrCreateTestableResources() - - resources.addOverride(R.string.config_systemImageEditor, "") - resources.addOverride(R.string.config_defaultNearbySharingComponent, "") - - var components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) - - assertThat(components.editSharingComponent).isNull() - assertThat(components.nearbySharingComponent).isNull() - - val editor = ComponentName.unflattenFromString("com.android/com.android.Editor") - val nearby = ComponentName.unflattenFromString("com.android/com.android.nearby") - - resources.addOverride(R.string.config_systemImageEditor, editor?.flattenToString()) - resources.addOverride( - R.string.config_defaultNearbySharingComponent, nearby?.flattenToString()) - - components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) - - assertThat(components.editSharingComponent).isEqualTo(editor) - assertThat(components.nearbySharingComponent).isEqualTo(nearby) - - val anotherNearby = - ComponentName.unflattenFromString("com.android/com.android.another_nearby") - whenever( - secureSettings.getString( - any(), - eq(Settings.Secure.NEARBY_SHARING_COMPONENT) - ) - ).thenReturn(anotherNearby?.flattenToString()) - - components = ChooserIntegratedDeviceComponents.get(testableContext, secureSettings) - - assertThat(components.nearbySharingComponent).isEqualTo(anotherNearby) - } -} diff --git a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt b/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt deleted file mode 100644 index 4612b430..00000000 --- a/java/tests/src/com/android/intentresolver/ChooserListAdapterTest.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.content.ComponentName -import android.content.Intent -import android.content.pm.PackageManager -import android.content.pm.PackageManager.ResolveInfoFlags -import android.os.UserHandle -import android.view.View -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.TextView -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.chooser.DisplayResolveInfo -import com.android.intentresolver.chooser.SelectableTargetInfo -import com.android.intentresolver.chooser.TargetInfo -import com.android.intentresolver.icons.TargetDataLoader -import com.android.internal.R -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito.times -import org.mockito.Mockito.verify - -@RunWith(AndroidJUnit4::class) -class ChooserListAdapterTest { - private val userHandle: UserHandle = - InstrumentationRegistry.getInstrumentation().targetContext.user - - private val packageManager = - mock<PackageManager> { - whenever(resolveActivity(any(), any<ResolveInfoFlags>())).thenReturn(mock()) - } - private val context = InstrumentationRegistry.getInstrumentation().context - private val resolverListController = mock<ResolverListController>() - private val chooserActivityLogger = mock<ChooserActivityLogger>() - private val mTargetDataLoader = mock<TargetDataLoader>() - - private val testSubject by lazy { - ChooserListAdapter( - context, - emptyList(), - emptyArray(), - emptyList(), - false, - resolverListController, - userHandle, - Intent(), - mock(), - packageManager, - chooserActivityLogger, - mock(), - 0, - null, - mTargetDataLoader - ) - } - - @Before - fun setup() { - // ChooserListAdapter reads DeviceConfig and needs a permission for that. - InstrumentationRegistry.getInstrumentation() - .uiAutomation - .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG") - } - - @Test - fun testDirectShareTargetLoadingIconIsStarted() { - val view = createView() - val viewHolder = ResolverListAdapter.ViewHolder(view) - view.tag = viewHolder - val targetInfo = createSelectableTargetInfo() - testSubject.onBindView(view, targetInfo, 0) - - verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any()) - } - - @Test - fun onBindView_DirectShareTargetIconAndLabelLoadedOnlyOnce() { - val view = createView() - val viewHolderOne = ResolverListAdapter.ViewHolder(view) - view.tag = viewHolderOne - val targetInfo = createSelectableTargetInfo() - testSubject.onBindView(view, targetInfo, 0) - - val viewHolderTwo = ResolverListAdapter.ViewHolder(view) - view.tag = viewHolderTwo - - testSubject.onBindView(view, targetInfo, 0) - - verify(mTargetDataLoader, times(1)).loadDirectShareIcon(any(), any(), any()) - } - - @Test - fun onBindView_AppTargetIconAndLabelLoadedOnlyOnce() { - val view = createView() - val viewHolderOne = ResolverListAdapter.ViewHolder(view) - view.tag = viewHolderOne - val targetInfo = - DisplayResolveInfo.newDisplayResolveInfo( - Intent(), - ResolverDataProvider.createResolveInfo(2, 0, userHandle), - null, - "extended info", - Intent(), - /* resolveInfoPresentationGetter= */ null - ) - testSubject.onBindView(view, targetInfo, 0) - - val viewHolderTwo = ResolverListAdapter.ViewHolder(view) - view.tag = viewHolderTwo - - testSubject.onBindView(view, targetInfo, 0) - - verify(mTargetDataLoader, times(1)).loadAppTargetIcon(any(), any(), any()) - } - - private fun createSelectableTargetInfo(): TargetInfo = - SelectableTargetInfo.newSelectableTargetInfo( - /* sourceInfo = */ DisplayResolveInfo.newDisplayResolveInfo( - Intent(), - ResolverDataProvider.createResolveInfo(2, 0, userHandle), - "label", - "extended info", - Intent(), - /* resolveInfoPresentationGetter= */ null - ), - /* backupResolveInfo = */ mock(), - /* resolvedIntent = */ Intent(), - /* chooserTarget = */ createChooserTarget( - "Target", - 0.5f, - ComponentName("pkg", "Class"), - "id-1" - ), - /* modifiedScore = */ 1f, - /* shortcutInfo = */ createShortcutInfo("id-1", ComponentName("pkg", "Class"), 1), - /* appTarget */ null, - /* referrerFillInIntent = */ Intent() - ) - - private fun createView(): View { - val view = FrameLayout(context) - TextView(context).apply { - id = R.id.text1 - view.addView(this) - } - TextView(context).apply { - id = R.id.text2 - view.addView(this) - } - ImageView(context).apply { - id = R.id.icon - view.addView(this) - } - return view - } -} diff --git a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt b/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt deleted file mode 100644 index 61ac0c21..00000000 --- a/java/tests/src/com/android/intentresolver/ChooserRefinementManagerTest.kt +++ /dev/null @@ -1,242 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.app.Activity -import android.app.Application -import android.content.Intent -import android.content.IntentSender -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.os.ResultReceiver -import androidx.lifecycle.Observer -import androidx.test.annotation.UiThreadTest -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.android.intentresolver.ChooserRefinementManager.RefinementCompletion -import com.android.intentresolver.chooser.ImmutableTargetInfo -import com.android.intentresolver.chooser.TargetInfo -import com.google.common.truth.Truth.assertThat -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentCaptor -import org.mockito.Mockito - -@RunWith(AndroidJUnit4::class) -@UiThreadTest -class ChooserRefinementManagerTest { - private val refinementManager = ChooserRefinementManager() - private val intentSender = mock<IntentSender>() - private val application = mock<Application>() - private val exampleSourceIntents = - listOf(Intent(Intent.ACTION_VIEW), Intent(Intent.ACTION_EDIT)) - private val exampleTargetInfo = - ImmutableTargetInfo.newBuilder().setAllSourceIntents(exampleSourceIntents).build() - - private val completionObserver = - object : Observer<RefinementCompletion> { - val failureCountDown = CountDownLatch(1) - val successCountDown = CountDownLatch(1) - var latestTargetInfo: TargetInfo? = null - - override fun onChanged(completion: RefinementCompletion) { - if (completion.consume()) { - val targetInfo = completion.targetInfo - if (targetInfo == null) { - failureCountDown.countDown() - } else { - latestTargetInfo = targetInfo - successCountDown.countDown() - } - } - } - } - - /** Synchronously executes post() calls. */ - private class FakeHandler(looper: Looper) : Handler(looper) { - override fun sendMessageAtTime(msg: Message, uptimeMillis: Long): Boolean { - dispatchMessage(msg) - return true - } - } - - @Before - fun setup() { - refinementManager.refinementCompletion.observeForever(completionObserver) - } - - @Test - fun testTypicalRefinementFlow() { - assertThat( - refinementManager.maybeHandleSelection( - exampleTargetInfo, - intentSender, - application, - FakeHandler(checkNotNull(Looper.myLooper())) - ) - ) - .isTrue() - - val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) - Mockito.verify(intentSender) - .sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null)) - - val intent = intentCaptor.value - assertThat(intent?.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)) - .isEqualTo(exampleSourceIntents[0]) - - val alternates = - intent?.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS, Intent::class.java) - assertThat(alternates?.size).isEqualTo(1) - assertThat(alternates?.get(0)).isEqualTo(exampleSourceIntents[1]) - - // Complete the refinement - val receiver = - intent?.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver::class.java) - val bundle = Bundle().apply { putParcelable(Intent.EXTRA_INTENT, exampleSourceIntents[0]) } - receiver?.send(Activity.RESULT_OK, bundle) - - assertThat(completionObserver.successCountDown.await(1000, TimeUnit.MILLISECONDS)).isTrue() - assertThat(completionObserver.latestTargetInfo?.resolvedIntent?.action) - .isEqualTo(Intent.ACTION_VIEW) - } - - @Test - fun testRefinementCancelled() { - assertThat( - refinementManager.maybeHandleSelection( - exampleTargetInfo, - intentSender, - application, - FakeHandler(checkNotNull(Looper.myLooper())) - ) - ) - .isTrue() - - val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) - Mockito.verify(intentSender) - .sendIntent(any(), eq(0), intentCaptor.capture(), eq(null), eq(null)) - - val intent = intentCaptor.value - - // Complete the refinement - val receiver = - intent?.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver::class.java) - val bundle = Bundle().apply { putParcelable(Intent.EXTRA_INTENT, exampleSourceIntents[0]) } - receiver?.send(Activity.RESULT_CANCELED, bundle) - - assertThat(completionObserver.failureCountDown.await(1000, TimeUnit.MILLISECONDS)).isTrue() - } - - @Test - fun testMaybeHandleSelection_noSourceIntents() { - assertThat( - refinementManager.maybeHandleSelection( - ImmutableTargetInfo.newBuilder().build(), - intentSender, - application, - FakeHandler(checkNotNull(Looper.myLooper())) - ) - ) - .isFalse() - } - - @Test - fun testMaybeHandleSelection_suspended() { - val targetInfo = - ImmutableTargetInfo.newBuilder() - .setAllSourceIntents(exampleSourceIntents) - .setIsSuspended(true) - .build() - - assertThat( - refinementManager.maybeHandleSelection( - targetInfo, - intentSender, - application, - FakeHandler(checkNotNull(Looper.myLooper())) - ) - ) - .isFalse() - } - - @Test - fun testMaybeHandleSelection_noIntentSender() { - assertThat( - refinementManager.maybeHandleSelection( - exampleTargetInfo, - /* IntentSender */ null, - application, - FakeHandler(checkNotNull(Looper.myLooper())) - ) - ) - .isFalse() - } - - @Test - fun testConfigurationChangeDuringRefinement() { - assertThat( - refinementManager.maybeHandleSelection( - exampleTargetInfo, - intentSender, - application, - FakeHandler(checkNotNull(Looper.myLooper())) - ) - ) - .isTrue() - - refinementManager.onActivityStop(/* config changing = */ true) - refinementManager.onActivityResume() - - assertThat(completionObserver.failureCountDown.count).isEqualTo(1) - } - - @Test - fun testResumeDuringRefinement() { - assertThat( - refinementManager.maybeHandleSelection( - exampleTargetInfo, - intentSender, - application, - FakeHandler(checkNotNull(Looper.myLooper())!!) - ) - ) - .isTrue() - - refinementManager.onActivityStop(/* config changing = */ false) - // Resume during refinement but not during a config change, so finish the activity. - refinementManager.onActivityResume() - - // Call should be synchronous, don't need to await for this one. - assertThat(completionObserver.failureCountDown.count).isEqualTo(0) - } - - @Test - fun testRefinementCompletion() { - val refinementCompletion = RefinementCompletion(exampleTargetInfo) - assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo) - assertThat(refinementCompletion.consume()).isTrue() - assertThat(refinementCompletion.targetInfo).isEqualTo(exampleTargetInfo) - - // can only consume once. - assertThat(refinementCompletion.consume()).isFalse() - } -} diff --git a/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt b/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt deleted file mode 100644 index 331d1c21..00000000 --- a/java/tests/src/com/android/intentresolver/ChooserRequestParametersTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.app.PendingIntent -import android.content.Intent -import android.graphics.drawable.Icon -import android.net.Uri -import android.service.chooser.ChooserAction -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ChooserRequestParametersTest { - val flags = TestFeatureFlagRepository(mapOf()) - - @Test - fun testChooserActions() { - val actionCount = 3 - val intent = Intent(Intent.ACTION_SEND) - val actions = createChooserActions(actionCount) - val chooserIntent = - Intent(Intent.ACTION_CHOOSER).apply { - putExtra(Intent.EXTRA_INTENT, intent) - putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, actions) - } - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags) - assertThat(request.chooserActions).containsExactlyElementsIn(actions).inOrder() - } - - @Test - fun testChooserActions_empty() { - val intent = Intent(Intent.ACTION_SEND) - val chooserIntent = - Intent(Intent.ACTION_CHOOSER).apply { putExtra(Intent.EXTRA_INTENT, intent) } - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags) - assertThat(request.chooserActions).isEmpty() - } - - @Test - fun testChooserActions_tooMany() { - val intent = Intent(Intent.ACTION_SEND) - val chooserActions = createChooserActions(10) - val chooserIntent = - Intent(Intent.ACTION_CHOOSER).apply { - putExtra(Intent.EXTRA_INTENT, intent) - putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, chooserActions) - } - - val request = ChooserRequestParameters(chooserIntent, "", Uri.EMPTY, flags) - - val expectedActions = chooserActions.sliceArray(0 until 5) - assertThat(request.chooserActions).containsExactlyElementsIn(expectedActions).inOrder() - } - - private fun createChooserActions(count: Int): Array<ChooserAction> { - return Array(count) { i -> createChooserAction("$i") } - } - - private fun createChooserAction(label: CharSequence): ChooserAction { - val icon = Icon.createWithContentUri("content://org.package.app/image") - val pendingIntent = - PendingIntent.getBroadcast( - InstrumentationRegistry.getInstrumentation().getTargetContext(), - 0, - Intent("TESTACTION"), - PendingIntent.FLAG_IMMUTABLE - ) - return ChooserAction.Builder(icon, label, pendingIntent).build() - } -} diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java deleted file mode 100644 index 6ac6b6d3..00000000 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ /dev/null @@ -1,293 +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.annotation.Nullable; -import android.app.prediction.AppPredictor; -import android.app.usage.UsageStatsManager; -import android.content.ComponentName; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.content.res.Resources; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.os.UserHandle; - -import androidx.lifecycle.ViewModelProvider; - -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.flags.FeatureFlagRepository; -import com.android.intentresolver.grid.ChooserGridAdapter; -import com.android.intentresolver.icons.TargetDataLoader; -import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; - -import java.util.List; -import java.util.function.Consumer; - -/** - * Simple wrapper around chooser activity to be able to initiate it under test. For more - * information, see {@code com.android.internal.app.ChooserWrapperActivity}. - */ -public class ChooserWrapperActivity - extends com.android.intentresolver.ChooserActivity implements IChooserWrapper { - static final ChooserActivityOverrideData sOverrides = ChooserActivityOverrideData.getInstance(); - private UsageStatsManager mUsm; - - // ResolverActivity (the base class of ChooserActivity) inspects the launched-from UID at - // onCreate and needs to see some non-negative value in the test. - @Override - public int getLaunchedFromUid() { - return 1234; - } - - @Override - public ChooserListAdapter createChooserListAdapter( - Context context, - List<Intent> payloadIntents, - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - ResolverListController resolverListController, - UserHandle userHandle, - Intent targetIntent, - ChooserRequestParameters chooserRequest, - int maxTargetsPerRow, - TargetDataLoader targetDataLoader) { - PackageManager packageManager = - sOverrides.packageManager == null ? context.getPackageManager() - : sOverrides.packageManager; - return new ChooserListAdapter( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - createListController(userHandle), - userHandle, - targetIntent, - this, - packageManager, - getChooserActivityLogger(), - chooserRequest, - maxTargetsPerRow, - userHandle, - targetDataLoader); - } - - @Override - public ChooserListAdapter getAdapter() { - return mChooserMultiProfilePagerAdapter.getActiveListAdapter(); - } - - @Override - public ChooserListAdapter getPersonalListAdapter() { - return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)) - .getListAdapter(); - } - - @Override - public ChooserListAdapter getWorkListAdapter() { - if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { - return null; - } - return ((ChooserGridAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)) - .getListAdapter(); - } - - @Override - public boolean getIsSelected() { - return mIsSuccessfullySelected; - } - - @Override - protected ChooserIntegratedDeviceComponents getIntegratedDeviceComponents() { - return new ChooserIntegratedDeviceComponents( - /* editSharingComponent=*/ null, - // An arbitrary pre-installed activity that handles this type of intent: - /* nearbySharingComponent=*/ new ComponentName( - "com.google.android.apps.messaging", - ".ui.conversationlist.ShareIntentActivity")); - } - - @Override - public UsageStatsManager getUsageStatsManager() { - if (mUsm == null) { - mUsm = getSystemService(UsageStatsManager.class); - } - return mUsm; - } - - @Override - public boolean isVoiceInteraction() { - if (sOverrides.isVoiceInteraction != null) { - return sOverrides.isVoiceInteraction; - } - return super.isVoiceInteraction(); - } - - @Override - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - if (sOverrides.mCrossProfileIntentsChecker != null) { - return sOverrides.mCrossProfileIntentsChecker; - } - return super.createCrossProfileIntentsChecker(); - } - - @Override - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - if (sOverrides.mWorkProfileAvailability != null) { - return sOverrides.mWorkProfileAvailability; - } - return super.createWorkProfileAvailabilityManager(); - } - - @Override - public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, - @Nullable Bundle options) { - if (sOverrides.onSafelyStartInternalCallback != null - && sOverrides.onSafelyStartInternalCallback.apply(cti)) { - return; - } - super.safelyStartActivityInternal(cti, user, options); - } - - @Override - protected ChooserListController createListController(UserHandle userHandle) { - if (userHandle == UserHandle.SYSTEM) { - return sOverrides.resolverListController; - } - return sOverrides.workResolverListController; - } - - @Override - public PackageManager getPackageManager() { - if (sOverrides.createPackageManager != null) { - return sOverrides.createPackageManager.apply(super.getPackageManager()); - } - return super.getPackageManager(); - } - - @Override - public Resources getResources() { - if (sOverrides.resources != null) { - return sOverrides.resources; - } - return super.getResources(); - } - - @Override - protected ViewModelProvider.Factory createPreviewViewModelFactory() { - return TestContentPreviewViewModel.Companion.wrap( - super.createPreviewViewModelFactory(), - sOverrides.imageLoader); - } - - @Override - public ChooserActivityLogger getChooserActivityLogger() { - return sOverrides.chooserActivityLogger; - } - - @Override - public Cursor queryResolver(ContentResolver resolver, Uri uri) { - if (sOverrides.resolverCursor != null) { - return sOverrides.resolverCursor; - } - - if (sOverrides.resolverForceException) { - throw new SecurityException("Test exception handling"); - } - - return super.queryResolver(resolver, uri); - } - - @Override - protected boolean isWorkProfile() { - if (sOverrides.alternateProfileSetting != 0) { - return sOverrides.alternateProfileSetting == MetricsEvent.MANAGED_PROFILE; - } - return super.isWorkProfile(); - } - - @Override - public DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri, - CharSequence pLabel, CharSequence pInfo, Intent replacementIntent, - @Nullable TargetPresentationGetter resolveInfoPresentationGetter) { - return DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - pri, - pLabel, - pInfo, - replacementIntent, - resolveInfoPresentationGetter); - } - - @Override - protected UserHandle getWorkProfileUserHandle() { - return sOverrides.workProfileUserHandle; - } - - @Override - public UserHandle getCurrentUserHandle() { - return mMultiProfilePagerAdapter.getCurrentUserHandle(); - } - - @Override - protected UserHandle getTabOwnerUserHandleForLaunch() { - if (sOverrides.tabOwnerUserHandleForLaunch == null) { - return super.getTabOwnerUserHandleForLaunch(); - } - return sOverrides.tabOwnerUserHandleForLaunch; - } - - @Override - public Context createContextAsUser(UserHandle user, int flags) { - // return the current context as a work profile doesn't really exist in these tests - return getApplicationContext(); - } - - @Override - protected ShortcutLoader createShortcutLoader( - Context context, - AppPredictor appPredictor, - UserHandle userHandle, - IntentFilter targetIntentFilter, - Consumer<ShortcutLoader.Result> callback) { - ShortcutLoader shortcutLoader = - sOverrides.shortcutLoaderFactory.invoke(userHandle, callback); - if (shortcutLoader != null) { - return shortcutLoader; - } - return super.createShortcutLoader( - context, appPredictor, userHandle, targetIntentFilter, callback); - } - - @Override - protected FeatureFlagRepository createFeatureFlagRepository() { - if (sOverrides.featureFlagRepository != null) { - return sOverrides.featureFlagRepository; - } - return super.createFeatureFlagRepository(); - } -} diff --git a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt b/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt deleted file mode 100644 index 9ea9dfa7..00000000 --- a/java/tests/src/com/android/intentresolver/EnterTransitionAnimationDelegateTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.content.res.Resources -import android.view.View -import android.view.Window -import androidx.activity.ComponentActivity -import androidx.lifecycle.Lifecycle -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify - -private const val TIMEOUT_MS = 200 - -@OptIn(ExperimentalCoroutinesApi::class) -class EnterTransitionAnimationDelegateTest { - private val elementName = "shared-element" - private val scheduler = TestCoroutineScheduler() - private val dispatcher = StandardTestDispatcher(scheduler) - private val lifecycleOwner = TestLifecycleOwner() - - private val transitionTargetView = mock<View> { - // avoid the request-layout path in the delegate - whenever(isInLayout).thenReturn(true) - } - - private val windowMock = mock<Window>() - private val resourcesMock = mock<Resources> { - whenever(getInteger(anyInt())).thenReturn(TIMEOUT_MS) - } - private val activity = mock<ComponentActivity> { - whenever(lifecycle).thenReturn(lifecycleOwner.lifecycle) - whenever(resources).thenReturn(resourcesMock) - whenever(isActivityTransitionRunning).thenReturn(true) - whenever(window).thenReturn(windowMock) - } - - private val testSubject = EnterTransitionAnimationDelegate(activity) { - transitionTargetView - } - - @Before - fun setup() { - Dispatchers.setMain(dispatcher) - lifecycleOwner.state = Lifecycle.State.CREATED - } - - @After - fun cleanup() { - lifecycleOwner.state = Lifecycle.State.DESTROYED - Dispatchers.resetMain() - } - - @Test - fun test_postponeTransition_timeout() { - testSubject.postponeTransition() - testSubject.markOffsetCalculated() - - scheduler.advanceTimeBy(TIMEOUT_MS + 1L) - verify(activity, times(1)).startPostponedEnterTransition() - verify(windowMock, never()).setWindowAnimations(anyInt()) - } - - @Test - fun test_postponeTransition_animation_resumes_only_once() { - testSubject.postponeTransition() - testSubject.markOffsetCalculated() - testSubject.onTransitionElementReady(elementName) - testSubject.markOffsetCalculated() - testSubject.onTransitionElementReady(elementName) - - scheduler.advanceTimeBy(TIMEOUT_MS + 1L) - verify(activity, times(1)).startPostponedEnterTransition() - } - - @Test - fun test_postponeTransition_resume_animation_conditions() { - testSubject.postponeTransition() - verify(activity, never()).startPostponedEnterTransition() - - testSubject.markOffsetCalculated() - verify(activity, never()).startPostponedEnterTransition() - - testSubject.onAllTransitionElementsReady() - verify(activity, times(1)).startPostponedEnterTransition() - } -} diff --git a/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt b/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt deleted file mode 100644 index 3fa01bcc..00000000 --- a/java/tests/src/com/android/intentresolver/FeatureFlagRule.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import com.android.systemui.flags.BooleanFlag -import org.junit.rules.TestRule -import org.junit.runner.Description -import org.junit.runners.model.Statement - -/** - * Ignores tests annotated with [RequireFeatureFlags] which flag requirements does not - * meet in the active flag set. - * @param flags active flag set - */ -internal class FeatureFlagRule(flags: Map<BooleanFlag, Boolean>) : TestRule { - private val flags = flags.entries.fold(HashMap<String, Boolean>()) { map, (key, value) -> - map.apply { - put(key.name, value) - } - } - private val skippingStatement = object : Statement() { - override fun evaluate() = Unit - } - - override fun apply(base: Statement, description: Description): Statement { - val annotation = description.annotations.firstOrNull { - it is RequireFeatureFlags - } as? RequireFeatureFlags - ?: return base - - if (annotation.flags.size != annotation.values.size) { - error("${description.className}#${description.methodName}: inconsistent number of" + - " flags and values in $annotation") - } - for (i in annotation.flags.indices) { - val flag = annotation.flags[i] - val value = annotation.values[i] - if (flags.getOrDefault(flag, !value) != value) return skippingStatement - } - return base - } -} diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java deleted file mode 100644 index af897a47..00000000 --- a/java/tests/src/com/android/intentresolver/IChooserWrapper.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import android.annotation.Nullable; -import android.app.usage.UsageStatsManager; -import android.content.Intent; -import android.content.pm.ResolveInfo; -import android.os.UserHandle; - -import com.android.intentresolver.chooser.DisplayResolveInfo; - -import java.util.concurrent.Executor; - -/** - * Test-only extended API capabilities that an instrumented ChooserActivity subclass provides in - * order to expose the internals for override/inspection. Implementations should apply the overrides - * specified by the {@code ChooserActivityOverrideData} singleton. - */ -public interface IChooserWrapper { - ChooserListAdapter getAdapter(); - ChooserListAdapter getPersonalListAdapter(); - ChooserListAdapter getWorkListAdapter(); - boolean getIsSelected(); - UsageStatsManager getUsageStatsManager(); - DisplayResolveInfo createTestDisplayResolveInfo(Intent originalIntent, ResolveInfo pri, - CharSequence pLabel, CharSequence pInfo, Intent replacementIntent, - @Nullable TargetPresentationGetter resolveInfoPresentationGetter); - UserHandle getCurrentUserHandle(); - ChooserActivityLogger getChooserActivityLogger(); - Executor getMainExecutor(); -} diff --git a/java/tests/src/com/android/intentresolver/MatcherUtils.java b/java/tests/src/com/android/intentresolver/MatcherUtils.java deleted file mode 100644 index 6168968b..00000000 --- a/java/tests/src/com/android/intentresolver/MatcherUtils.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import org.hamcrest.BaseMatcher; -import org.hamcrest.Description; -import org.hamcrest.Matcher; - -/** - * Utils for helping with more customized matching options, for example matching the first - * occurrence of a set criteria. - */ -public class MatcherUtils { - - /** - * Returns a {@link Matcher} which only matches the first occurrence of a set criteria. - */ - static <T> Matcher<T> first(final Matcher<T> matcher) { - return new BaseMatcher<T>() { - boolean isFirstMatch = true; - - @Override - public boolean matches(final Object item) { - if (isFirstMatch && matcher.matches(item)) { - isFirstMatch = false; - return true; - } - return false; - } - - @Override - public void describeTo(final Description description) { - description.appendText("Returns the first matching item"); - } - }; - } -} diff --git a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt b/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt deleted file mode 100644 index aaa7a282..00000000 --- a/java/tests/src/com/android/intentresolver/MockitoKotlinHelpers.kt +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -/** - * Kotlin versions of popular mockito methods that can return null in situations when Kotlin expects - * a non-null value. Kotlin will throw an IllegalStateException when this takes place ("x must not - * be null"). To fix this, we can use methods that modify the return type to be nullable. This - * causes Kotlin to skip the null checks. - * Cloned from frameworks/base/packages/SystemUI/tests/utils/src/com/android/systemui/util/mockito/KotlinMockitoHelpers.kt - */ - -import org.mockito.ArgumentCaptor -import org.mockito.ArgumentMatcher -import org.mockito.ArgumentMatchers -import org.mockito.Mockito -import org.mockito.stubbing.OngoingStubbing - -/** - * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when - * null is returned. - * - * Generic T is nullable because implicitly bounded by Any?. - */ -fun <T> eq(obj: T): T = Mockito.eq<T>(obj) - -/** - * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when - * null is returned. - * - * Generic T is nullable because implicitly bounded by Any?. - */ -fun <T> any(type: Class<T>): T = Mockito.any<T>(type) -inline fun <reified T> any(): T = any(T::class.java) - -/** - * Returns Mockito.argThat() as nullable type to avoid java.lang.IllegalStateException when - * null is returned. - * - * Generic T is nullable because implicitly bounded by Any?. - */ -fun <T> argThat(matcher: ArgumentMatcher<T>): T = Mockito.argThat(matcher) - -/** - * Kotlin type-inferred version of Mockito.nullable() - */ -inline fun <reified T> nullable(): T? = Mockito.nullable(T::class.java) - -/** - * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException - * when null is returned. - * - * Generic T is nullable because implicitly bounded by Any?. - */ -fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture() - -/** - * Helper function for creating an argumentCaptor in kotlin. - * - * Generic T is nullable because implicitly bounded by Any?. - */ -inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> = - ArgumentCaptor.forClass(T::class.java) - -/** - * Helper function for creating new mocks, without the need to pass in a [Class] instance. - * - * Generic T is nullable because implicitly bounded by Any?. - * - * @param apply builder function to simplify stub configuration by improving type inference. - */ -inline fun <reified T : Any> mock(apply: T.() -> Unit = {}): T = Mockito.mock(T::class.java) - .apply(apply) - -/** - * Helper function for stubbing methods without the need to use backticks. - * - * @see Mockito.when - */ -fun <T> whenever(methodCall: T): OngoingStubbing<T> = Mockito.`when`(methodCall) - -/** - * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when - * kotlin tests are mocking kotlin objects and the methods take non-null parameters: - * - * java.lang.NullPointerException: capture() must not be null - */ -class KotlinArgumentCaptor<T> constructor(clazz: Class<T>) { - private val wrapped: ArgumentCaptor<T> = ArgumentCaptor.forClass(clazz) - fun capture(): T = wrapped.capture() - val value: T - get() = wrapped.value - val allValues: List<T> - get() = wrapped.allValues -} - -/** - * Helper function for creating an argumentCaptor in kotlin. - * - * Generic T is nullable because implicitly bounded by Any?. - */ -inline fun <reified T : Any> kotlinArgumentCaptor(): KotlinArgumentCaptor<T> = - KotlinArgumentCaptor(T::class.java) - -/** - * Helper function for creating and using a single-use ArgumentCaptor in kotlin. - * - * val captor = argumentCaptor<Foo>() - * verify(...).someMethod(captor.capture()) - * val captured = captor.value - * - * becomes: - * - * val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) } - * - * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException. - */ -inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() -> Unit): T = - kotlinArgumentCaptor<T>().apply { block() }.value - -/** - * Variant of [withArgCaptor] for capturing multiple arguments. - * - * val captor = argumentCaptor<Foo>() - * verify(...).someMethod(captor.capture()) - * val captured: List<Foo> = captor.allValues - * - * becomes: - * - * val capturedList = captureMany<Foo> { verify(...).someMethod(capture()) } - */ -inline fun <reified T : Any> captureMany(block: KotlinArgumentCaptor<T>.() -> Unit): List<T> = - kotlinArgumentCaptor<T>().apply{ block() }.allValues - -inline fun <reified T> anyOrNull() = ArgumentMatchers.argThat(ArgumentMatcher<T?> { true }) diff --git a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java b/java/tests/src/com/android/intentresolver/ResolverActivityTest.java deleted file mode 100644 index 7233fd3d..00000000 --- a/java/tests/src/com/android/intentresolver/ResolverActivityTest.java +++ /dev/null @@ -1,1100 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.swipeUp; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isEnabled; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; - -import static com.android.intentresolver.MatcherUtils.first; -import static com.android.intentresolver.ResolverWrapperActivity.sOverrides; - -import static org.hamcrest.CoreMatchers.allOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.not; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.fail; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - -import android.content.Intent; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.os.RemoteException; -import android.os.UserHandle; -import android.text.TextUtils; -import android.view.View; -import android.widget.RelativeLayout; -import android.widget.TextView; - -import androidx.test.InstrumentationRegistry; -import androidx.test.espresso.Espresso; -import androidx.test.espresso.NoMatchingViewException; -import androidx.test.rule.ActivityTestRule; -import androidx.test.runner.AndroidJUnit4; - -import com.android.intentresolver.widget.ResolverDrawerLayout; - -import com.google.android.collect.Lists; - -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.List; - -/** - * Resolver activity instrumentation tests - */ -@RunWith(AndroidJUnit4.class) -public class ResolverActivityTest { - - private static final UserHandle PERSONAL_USER_HANDLE = androidx.test.platform.app - .InstrumentationRegistry.getInstrumentation().getTargetContext().getUser(); - protected Intent getConcreteIntentForLaunch(Intent clientIntent) { - clientIntent.setClass( - androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().getTargetContext(), - ResolverWrapperActivity.class); - return clientIntent; - } - - @Rule - public ActivityTestRule<ResolverWrapperActivity> mActivityRule = - new ActivityTestRule<>(ResolverWrapperActivity.class, false, false); - - @Before - public void setup() { - // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the - // permissions we require (which we'll read from the manifest at runtime). - androidx.test.platform.app.InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() - .adoptShellPermissionIdentity(); - - sOverrides.reset(); - } - - @Test - public void twoOptionsAndUserSelectsOne() throws InterruptedException { - Intent sendIntent = createSendImageIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2, - PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Ignore // Failing - b/144929805 - @Test - public void setMaxHeight() throws Exception { - Intent sendIntent = createSendImageIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2, - PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - waitForIdle(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); - final int initialResolverHeight = viewPager.getHeight(); - - activity.runOnUiThread(() -> { - ResolverDrawerLayout layout = (ResolverDrawerLayout) - activity.findViewById( - com.android.internal.R.id.contentPanel); - ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight - = initialResolverHeight - 1; - // Force a relayout - layout.invalidate(); - layout.requestLayout(); - }); - waitForIdle(); - assertThat("Drawer should be capped at maxHeight", - viewPager.getHeight() == (initialResolverHeight - 1)); - - activity.runOnUiThread(() -> { - ResolverDrawerLayout layout = (ResolverDrawerLayout) - activity.findViewById( - com.android.internal.R.id.contentPanel); - ((ResolverDrawerLayout.LayoutParams) viewPager.getLayoutParams()).maxHeight - = initialResolverHeight + 1; - // Force a relayout - layout.invalidate(); - layout.requestLayout(); - }); - waitForIdle(); - assertThat("Drawer should not change height if its height is less than maxHeight", - viewPager.getHeight() == initialResolverHeight); - } - - @Ignore // Failing - b/144929805 - @Test - public void setShowAtTopToTrue() throws Exception { - Intent sendIntent = createSendImageIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2, - PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - waitForIdle(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - final View viewPager = activity.findViewById(com.android.internal.R.id.profile_pager); - final View divider = activity.findViewById(com.android.internal.R.id.divider); - final RelativeLayout profileView = - (RelativeLayout) activity.findViewById(com.android.internal.R.id.profile_button) - .getParent(); - assertThat("Drawer should show at bottom by default", - profileView.getBottom() + divider.getHeight() == viewPager.getTop() - && profileView.getTop() > 0); - - activity.runOnUiThread(() -> { - ResolverDrawerLayout layout = (ResolverDrawerLayout) - activity.findViewById( - com.android.internal.R.id.contentPanel); - layout.setShowAtTop(true); - }); - waitForIdle(); - assertThat("Drawer should show at top with new attribute", - profileView.getBottom() + divider.getHeight() == viewPager.getTop() - && profileView.getTop() == 0); - } - - @Test - public void hasLastChosenActivity() throws Exception { - Intent sendIntent = createSendImageIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2, - PERSONAL_USER_HANDLE); - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - // The other entry is filtered to the last used slot - assertThat(activity.getAdapter().getCount(), is(1)); - assertThat(activity.getAdapter().getPlaceholderCount(), is(1)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - onView(withId(com.android.internal.R.id.button_once)).perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test - public void hasOtherProfileOneOption() throws Exception { - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, - PERSONAL_USER_HANDLE); - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - - ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); - Intent sendIntent = createSendImageIntent(); - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - // The other entry is filtered to the last used slot - assertThat(activity.getAdapter().getCount(), is(1)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - // Make a stable copy of the components as the original list may be modified - List<ResolvedComponentInfo> stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10, - PERSONAL_USER_HANDLE); - // We pick the first one as there is another one in the work profile side - onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test - public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { - Intent sendIntent = createSendImageIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - // Confirm that the button bar is disabled by default - onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); - - // Make a stable copy of the components as the original list may be modified - List<ResolvedComponentInfo> stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); - - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)).perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - - @Test - public void hasLastChosenActivityAndOtherProfile() throws Exception { - // In this case we prefer the other profile and don't display anything about the last - // chosen activity. - Intent sendIntent = createSendImageIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - // Confirm that the button bar is disabled by default - onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); - - // Make a stable copy of the components as the original list may be modified - List<ResolvedComponentInfo> stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); - - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)).perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test - public void testWorkTab_displayedWhenWorkProfileUserAvailable() { - Intent sendIntent = createSendImageIntent(); - markWorkProfileUserAvailable(); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - onView(withId(com.android.internal.R.id.tabs)).check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { - Intent sendIntent = createSendImageIntent(); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - onView(withId(com.android.internal.R.id.tabs)).check(matches(not(isDisplayed()))); - } - - @Test - public void testWorkTab_workTabListPopulatedBeforeGoingToTab() throws InterruptedException { - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId = */ 10, - PERSONAL_USER_HANDLE); - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, - new ArrayList<>(workResolvedComponentInfos)); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); - // The work list adapter must be populated in advance before tapping the other tab - assertThat(activity.getWorkListAdapter().getCount(), is(4)); - } - - @Test - public void testWorkTab_workTabUsesExpectedAdapter() { - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - markWorkProfileUserAvailable(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); - assertThat(activity.getWorkListAdapter().getCount(), is(4)); - } - - @Test - public void testWorkTab_personalTabUsesExpectedAdapter() { - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, PERSONAL_USER_HANDLE); - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); - assertThat(activity.getPersonalListAdapter().getCount(), is(2)); - } - - @Test - public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - waitForIdle(); - assertThat(activity.getWorkListAdapter().getCount(), is(4)); - } - - @Test - public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - waitForIdle(); - onView(first(allOf(withText(workResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - - waitForIdle(); - assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); - } - - @Test - public void testWorkTab_noPersonalApps_workTabHasExpectedNumberOfTargets() - throws InterruptedException { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - - waitForIdle(); - assertThat(activity.getWorkListAdapter().getCount(), is(4)); - } - - @Test - public void testWorkTab_headerIsVisibleInPersonalTab() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createOpenWebsiteIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - TextView headerText = activity.findViewById(com.android.internal.R.id.title); - String initialText = headerText.getText().toString(); - assertFalse("Header text is empty.", initialText.isEmpty()); - assertThat(headerText.getVisibility(), is(View.VISIBLE)); - } - - @Test - public void testWorkTab_switchTabs_headerStaysSame() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(1, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createOpenWebsiteIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - TextView headerText = activity.findViewById(com.android.internal.R.id.title); - String initialText = headerText.getText().toString(); - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - - waitForIdle(); - String currentText = headerText.getText().toString(); - assertThat(headerText.getVisibility(), is(View.VISIBLE)); - assertThat(String.format("Header text is not the same when switching tabs, personal profile" - + " header was %s but work profile header is %s", initialText, currentText), - TextUtils.equals(initialText, currentText)); - } - - @Test - public void testWorkTab_noPersonalApps_canStartWorkApps() - throws InterruptedException { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId= */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - waitForIdle(); - onView(first(allOf( - withText(workResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), - isDisplayed()))) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - - assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); - } - - @Test - public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { - markWorkProfileUserAvailable(); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, - sOverrides.workProfileUserHandle); - sOverrides.hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_workProfileDisabled_emptyStateShown() { - markWorkProfileUserAvailable(); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, - sOverrides.workProfileUserHandle); - sOverrides.isQuietModeEnabled = true; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_turn_on_work_apps)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - sOverrides.isQuietModeEnabled = true; - sOverrides.hasCrossProfileIntents = false; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testMiniResolver() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(1, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(1, sOverrides.workProfileUserHandle); - // Personal profile only has a browser - personalResolvedComponentInfos.get(0).getResolveInfoAt(0).handleAllWebDataURI = true; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.open_cross_profile)).check(matches(isDisplayed())); - } - - @Test - public void testMiniResolver_noCurrentProfileTarget() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(0, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(1, sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - // Need to ensure mini resolver doesn't trigger here. - assertNotMiniResolver(); - } - - private void assertNotMiniResolver() { - try { - onView(withId(com.android.internal.R.id.open_cross_profile)) - .check(matches(isDisplayed())); - } catch (NoMatchingViewException e) { - return; - } - fail("Mini resolver present but shouldn't be"); - } - - @Test - public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3, PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0, sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - sOverrides.isQuietModeEnabled = true; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { - markWorkProfileUserAvailable(); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10, - PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, - sOverrides.workProfileUserHandle); - sOverrides.hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - ResolveInfo[] chosen = new ResolveInfo[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - chosen[0] = result.first.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - assertNull(chosen[0]); - } - - @Test - public void testLayoutWithDefault_withWorkTab_neverShown() throws RemoteException { - markWorkProfileUserAvailable(); - - // In this case we prefer the other profile and don't display anything about the last - // chosen activity. - Intent sendIntent = createSendImageIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsForTest(2, PERSONAL_USER_HANDLE); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(1).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - // The other entry is filtered to the last used slot - assertThat(activity.getAdapter().hasFilteredItem(), is(false)); - assertThat(activity.getAdapter().getCount(), is(2)); - assertThat(activity.getAdapter().getPlaceholderCount(), is(2)); - } - - @Test - public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { - // enable cloneProfile - markCloneProfileUserAvailable(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); - setupResolverControllers(resolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle(), is(activity.getPersonalProfileUserHandle())); - assertThat(activity.getAdapter().getCount(), is(3)); - } - - @Test - public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { - markWorkProfileUserAvailable(); - // enable cloneProfile - markCloneProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4, - sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle(), is(activity.getPersonalProfileUserHandle())); - assertThat(activity.getAdapter().getCount(), is(3)); - } - - @Test - public void testClonedProfilePresent_layoutWithDefault_neverShown() throws Exception { - // enable cloneProfile - markCloneProfileUserAvailable(); - Intent sendIntent = createSendImageIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 2, - PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - Espresso.registerIdlingResources(activity.getLabelIdlingResource()); - waitForIdle(); - - assertThat(activity.getAdapter().hasFilteredItem(), is(false)); - assertThat(activity.getAdapter().getCount(), is(2)); - assertThat(activity.getAdapter().getPlaceholderCount(), is(2)); - } - - @Test - public void testClonedProfilePresent_alwaysButtonDisabled() throws Exception { - // enable cloneProfile - markCloneProfileUserAvailable(); - Intent sendIntent = createSendImageIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); - - setupResolverControllers(resolvedComponentInfos); - when(sOverrides.resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - - // Confirm that the button bar is disabled by default - onView(withId(com.android.internal.R.id.button_once)).check(matches(not(isEnabled()))); - onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); - - // Make a stable copy of the components as the original list may be modified - List<ResolvedComponentInfo> stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, PERSONAL_USER_HANDLE); - - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - - onView(withId(com.android.internal.R.id.button_once)).check(matches(isEnabled())); - onView(withId(com.android.internal.R.id.button_always)).check(matches(not(isEnabled()))); - } - - @Test - public void testClonedProfilePresent_personalProfileActivityIsStartedInCorrectUser() - throws Exception { - markWorkProfileUserAvailable(); - // enable cloneProfile - markCloneProfileUserAvailable(); - - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(3, sOverrides.workProfileUserHandle); - sOverrides.hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - selectedActivityUserHandle[0] = result.second; - return true; - }; - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(first(allOf(withText(personalResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - - assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); - } - - @Test - public void testClonedProfilePresent_workProfileActivityIsStartedInCorrectUser() - throws Exception { - markWorkProfileUserAvailable(); - // enable cloneProfile - markCloneProfileUserAvailable(); - - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(3, sOverrides.workProfileUserHandle); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - sendIntent.setType("TestType"); - final UserHandle[] selectedActivityUserHandle = new UserHandle[1]; - sOverrides.onSafelyStartInternalCallback = result -> { - selectedActivityUserHandle[0] = result.second; - return true; - }; - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)) - .perform(click()); - waitForIdle(); - onView(first(allOf(withText(workResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), isCompletelyDisplayed()))) - .perform(click()); - onView(withId(com.android.internal.R.id.button_once)) - .perform(click()); - waitForIdle(); - - assertThat(selectedActivityUserHandle[0], is(activity.getAdapter().getUserHandle())); - } - - @Test - public void testClonedProfilePresent_personalProfileResolverComparatorHasCorrectUsers() - throws Exception { - // enable cloneProfile - markCloneProfileUserAvailable(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle); - setupResolverControllers(resolvedComponentInfos); - Intent sendIntent = createSendImageIntent(); - - final ResolverWrapperActivity activity = mActivityRule.launchActivity(sendIntent); - waitForIdle(); - List<UserHandle> result = activity - .getResolverRankerServiceUserHandleList(PERSONAL_USER_HANDLE); - - assertThat(result.containsAll(Lists.newArrayList(PERSONAL_USER_HANDLE, - sOverrides.cloneProfileUserHandle)), is(true)); - } - - private Intent createSendImageIntent() { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.setType("image/jpeg"); - return sendIntent; - } - - private Intent createOpenWebsiteIntent() { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_VIEW); - sendIntent.setData(Uri.parse("https://google.com")); - return sendIntent; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults, - UserHandle resolvedForUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsWithCloneProfileForTest( - int numberOfResults, - UserHandle resolvedForPersonalUser, - UserHandle resolvedForClonedUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < 1; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForPersonalUser)); - } - for (int i = 1; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForClonedUser)); - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, - UserHandle resolvedForUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, - resolvedForUser)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, int userId, UserHandle resolvedForUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add( - ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, - resolvedForUser)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - } - return infoList; - } - - private void waitForIdle() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private void markWorkProfileUserAvailable() { - ResolverWrapperActivity.sOverrides.workProfileUserHandle = UserHandle.of(10); - } - - private void setupResolverControllers( - List<ResolvedComponentInfo> personalResolvedComponentInfos) { - setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); - } - - private void markCloneProfileUserAvailable() { - ResolverWrapperActivity.sOverrides.cloneProfileUserHandle = UserHandle.of(11); - } - - private void setupResolverControllers( - List<ResolvedComponentInfo> personalResolvedComponentInfos, - List<ResolvedComponentInfo> workResolvedComponentInfos) { - when(sOverrides.resolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.of(10)))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); - } -} diff --git a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java deleted file mode 100644 index 688dd867..00000000 --- a/java/tests/src/com/android/intentresolver/ResolverDataProvider.java +++ /dev/null @@ -1,255 +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.Context; -import android.content.Intent; -import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.content.res.Resources; -import android.os.UserHandle; -import android.test.mock.MockContext; -import android.test.mock.MockPackageManager; -import android.test.mock.MockResources; - -/** - * Utility class used by resolver tests to create mock data - */ -public class ResolverDataProvider { - - static private int USER_SOMEONE_ELSE = 10; - - static ResolvedComponentInfo createResolvedComponentInfo(int i) { - return new ResolvedComponentInfo( - createComponentName(i), - createResolverIntent(i), - createResolveInfo(i, UserHandle.USER_CURRENT)); - } - - static ResolvedComponentInfo createResolvedComponentInfo(int i, - UserHandle resolvedForUser) { - return new ResolvedComponentInfo( - createComponentName(i), - createResolverIntent(i), - createResolveInfo(i, UserHandle.USER_CURRENT, resolvedForUser)); - } - - static ResolvedComponentInfo createResolvedComponentInfo( - ComponentName componentName, Intent intent) { - return new ResolvedComponentInfo( - componentName, - intent, - createResolveInfo(componentName, UserHandle.USER_CURRENT)); - } - - static ResolvedComponentInfo createResolvedComponentInfo( - ComponentName componentName, Intent intent, UserHandle resolvedForUser) { - return new ResolvedComponentInfo( - componentName, - intent, - createResolveInfo(componentName, UserHandle.USER_CURRENT, resolvedForUser)); - } - - static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) { - return new ResolvedComponentInfo( - createComponentName(i), - createResolverIntent(i), - createResolveInfo(i, USER_SOMEONE_ELSE)); - } - - static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, - UserHandle resolvedForUser) { - return new ResolvedComponentInfo( - createComponentName(i), - createResolverIntent(i), - createResolveInfo(i, USER_SOMEONE_ELSE, resolvedForUser)); - } - - static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, int userId) { - return new ResolvedComponentInfo( - createComponentName(i), - createResolverIntent(i), - createResolveInfo(i, userId)); - } - - static ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, - int userId, UserHandle resolvedForUser) { - return new ResolvedComponentInfo( - createComponentName(i), - createResolverIntent(i), - createResolveInfo(i, userId, resolvedForUser)); - } - - public static ComponentName createComponentName(int i) { - final String name = "component" + i; - return new ComponentName("foo.bar." + name, name); - } - - public static ResolveInfo createResolveInfo(int i, int userId) { - final ResolveInfo resolveInfo = new ResolveInfo(); - resolveInfo.activityInfo = createActivityInfo(i); - resolveInfo.targetUserId = userId; - return resolveInfo; - } - public static ResolveInfo createResolveInfo(int i, int userId, UserHandle resolvedForUser) { - final ResolveInfo resolveInfo = new ResolveInfo(); - resolveInfo.activityInfo = createActivityInfo(i); - resolveInfo.targetUserId = userId; - resolveInfo.userHandle = resolvedForUser; - return resolveInfo; - } - - public static ResolveInfo createResolveInfo(ComponentName componentName, int userId) { - final ResolveInfo resolveInfo = new ResolveInfo(); - resolveInfo.activityInfo = createActivityInfo(componentName); - resolveInfo.targetUserId = userId; - return resolveInfo; - } - - public static ResolveInfo createResolveInfo(ComponentName componentName, int userId, - UserHandle resolvedForUser) { - final ResolveInfo resolveInfo = new ResolveInfo(); - resolveInfo.activityInfo = createActivityInfo(componentName); - resolveInfo.targetUserId = userId; - resolveInfo.userHandle = resolvedForUser; - return resolveInfo; - } - - static ActivityInfo createActivityInfo(int i) { - ActivityInfo ai = new ActivityInfo(); - ai.name = "activity_name" + i; - ai.packageName = "foo_bar" + i; - ai.enabled = true; - ai.exported = true; - ai.permission = null; - ai.applicationInfo = createApplicationInfo(); - return ai; - } - - static ActivityInfo createActivityInfo(ComponentName componentName) { - ActivityInfo ai = new ActivityInfo(); - ai.name = componentName.getClassName(); - ai.packageName = componentName.getPackageName(); - ai.enabled = true; - ai.exported = true; - ai.permission = null; - ai.applicationInfo = createApplicationInfo(); - ai.applicationInfo.packageName = componentName.getPackageName(); - return ai; - } - - static ApplicationInfo createApplicationInfo() { - ApplicationInfo ai = new ApplicationInfo(); - ai.name = "app_name"; - ai.packageName = "foo.bar"; - ai.enabled = true; - return ai; - } - - static class PackageManagerMockedInfo { - public Context ctx; - public ApplicationInfo appInfo; - public ActivityInfo activityInfo; - public ResolveInfo resolveInfo; - public String setAppLabel; - public String setActivityLabel; - public String setResolveInfoLabel; - } - - /** Create a {@link PackageManagerMockedInfo} with all distinct labels. */ - static PackageManagerMockedInfo createPackageManagerMockedInfo(boolean hasOverridePermission) { - return createPackageManagerMockedInfo( - hasOverridePermission, "app_label", "activity_label", "resolve_info_label"); - } - - static PackageManagerMockedInfo createPackageManagerMockedInfo( - boolean hasOverridePermission, - String appLabel, - String activityLabel, - String resolveInfoLabel) { - MockContext ctx = new MockContext() { - @Override - public PackageManager getPackageManager() { - return new MockPackageManager() { - @Override - public int checkPermission(String permName, String pkgName) { - if (hasOverridePermission) return PERMISSION_GRANTED; - return PERMISSION_DENIED; - } - }; - } - - @Override - public Resources getResources() { - return new MockResources() { - @Override - public String getString(int id) throws NotFoundException { - if (id == 1) return appLabel; - if (id == 2) return activityLabel; - if (id == 3) return resolveInfoLabel; - return null; - } - }; - } - }; - - ApplicationInfo appInfo = new ApplicationInfo() { - @Override - public CharSequence loadLabel(PackageManager pm) { - return appLabel; - } - }; - appInfo.labelRes = 1; - - ActivityInfo activityInfo = new ActivityInfo() { - @Override - public CharSequence loadLabel(PackageManager pm) { - return activityLabel; - } - }; - activityInfo.labelRes = 2; - activityInfo.applicationInfo = appInfo; - - ResolveInfo resolveInfo = new ResolveInfo() { - @Override - public CharSequence loadLabel(PackageManager pm) { - return resolveInfoLabel; - } - }; - resolveInfo.activityInfo = activityInfo; - resolveInfo.resolvePackageName = "super.fake.packagename"; - resolveInfo.labelRes = 3; - - PackageManagerMockedInfo mockedInfo = new PackageManagerMockedInfo(); - mockedInfo.activityInfo = activityInfo; - mockedInfo.appInfo = appInfo; - mockedInfo.ctx = ctx; - mockedInfo.resolveInfo = resolveInfo; - mockedInfo.setAppLabel = appLabel; - mockedInfo.setActivityLabel = activityLabel; - mockedInfo.setResolveInfoLabel = resolveInfoLabel; - - return mockedInfo; - } - - static Intent createResolverIntent(int i) { - return new Intent("intentAction" + i); - } -} diff --git a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java b/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java deleted file mode 100644 index 401ede26..00000000 --- a/java/tests/src/com/android/intentresolver/ResolverWrapperActivity.java +++ /dev/null @@ -1,294 +0,0 @@ -/* - * Copyright (C) 2017 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import android.annotation.Nullable; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.os.UserHandle; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.test.espresso.idling.CountingIdlingResource; - -import com.android.intentresolver.AbstractMultiProfilePagerAdapter.CrossProfileIntentsChecker; -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.chooser.SelectableTargetInfo; -import com.android.intentresolver.chooser.TargetInfo; -import com.android.intentresolver.icons.TargetDataLoader; - -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; - -/* - * Simple wrapper around chooser activity to be able to initiate it under test - */ -public class ResolverWrapperActivity extends ResolverActivity { - static final OverrideData sOverrides = new OverrideData(); - - private final CountingIdlingResource mLabelIdlingResource = - new CountingIdlingResource("LoadLabelTask"); - - public ResolverWrapperActivity() { - super(/* isIntentPicker= */ true); - } - - // ResolverActivity inspects the launched-from UID at onCreate and needs to see some - // non-negative value in the test. - @Override - public int getLaunchedFromUid() { - return 1234; - } - - public CountingIdlingResource getLabelIdlingResource() { - return mLabelIdlingResource; - } - - @Override - public ResolverListAdapter createResolverListAdapter( - Context context, - List<Intent> payloadIntents, - Intent[] initialIntents, - List<ResolveInfo> rList, - boolean filterLastUsed, - UserHandle userHandle, - TargetDataLoader targetDataLoader) { - return new ResolverListAdapter( - context, - payloadIntents, - initialIntents, - rList, - filterLastUsed, - createListController(userHandle), - userHandle, - payloadIntents.get(0), // TODO: extract upstream - this, - userHandle, - new TargetDataLoaderWrapper(targetDataLoader, mLabelIdlingResource)); - } - - @Override - protected CrossProfileIntentsChecker createCrossProfileIntentsChecker() { - if (sOverrides.mCrossProfileIntentsChecker != null) { - return sOverrides.mCrossProfileIntentsChecker; - } - return super.createCrossProfileIntentsChecker(); - } - - @Override - protected WorkProfileAvailabilityManager createWorkProfileAvailabilityManager() { - if (sOverrides.mWorkProfileAvailability != null) { - return sOverrides.mWorkProfileAvailability; - } - return super.createWorkProfileAvailabilityManager(); - } - - ResolverListAdapter getAdapter() { - return mMultiProfilePagerAdapter.getActiveListAdapter(); - } - - ResolverListAdapter getPersonalListAdapter() { - return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(0)); - } - - ResolverListAdapter getWorkListAdapter() { - if (mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { - return null; - } - return ((ResolverListAdapter) mMultiProfilePagerAdapter.getAdapterForIndex(1)); - } - - @Override - public boolean isVoiceInteraction() { - if (sOverrides.isVoiceInteraction != null) { - return sOverrides.isVoiceInteraction; - } - return super.isVoiceInteraction(); - } - - @Override - public void safelyStartActivityInternal(TargetInfo cti, UserHandle user, - @Nullable Bundle options) { - if (sOverrides.onSafelyStartInternalCallback != null - && sOverrides.onSafelyStartInternalCallback.apply(new Pair<>(cti, user))) { - return; - } - super.safelyStartActivityInternal(cti, user, options); - } - - @Override - protected ResolverListController createListController(UserHandle userHandle) { - if (userHandle == UserHandle.SYSTEM) { - return sOverrides.resolverListController; - } - return sOverrides.workResolverListController; - } - - @Override - public PackageManager getPackageManager() { - if (sOverrides.createPackageManager != null) { - return sOverrides.createPackageManager.apply(super.getPackageManager()); - } - return super.getPackageManager(); - } - - protected UserHandle getCurrentUserHandle() { - return mMultiProfilePagerAdapter.getCurrentUserHandle(); - } - - @Override - protected UserHandle getWorkProfileUserHandle() { - return sOverrides.workProfileUserHandle; - } - - @Override - protected UserHandle getCloneProfileUserHandle() { - return sOverrides.cloneProfileUserHandle; - } - - @Override - public void startActivityAsUser(Intent intent, Bundle options, UserHandle user) { - super.startActivityAsUser(intent, options, user); - } - - @Override - protected List<UserHandle> getResolverRankerServiceUserHandleListInternal(UserHandle - userHandle) { - return super.getResolverRankerServiceUserHandleListInternal(userHandle); - } - - /** - * We cannot directly mock the activity created since instrumentation creates it. - * <p> - * Instead, we use static instances of this object to modify behavior. - */ - static class OverrideData { - @SuppressWarnings("Since15") - public Function<PackageManager, PackageManager> createPackageManager; - public Function<Pair<TargetInfo, UserHandle>, Boolean> onSafelyStartInternalCallback; - public ResolverListController resolverListController; - public ResolverListController workResolverListController; - public Boolean isVoiceInteraction; - public UserHandle workProfileUserHandle; - public UserHandle cloneProfileUserHandle; - public UserHandle tabOwnerUserHandleForLaunch; - public Integer myUserId; - public boolean hasCrossProfileIntents; - public boolean isQuietModeEnabled; - public WorkProfileAvailabilityManager mWorkProfileAvailability; - public CrossProfileIntentsChecker mCrossProfileIntentsChecker; - - public void reset() { - onSafelyStartInternalCallback = null; - isVoiceInteraction = null; - createPackageManager = null; - resolverListController = mock(ResolverListController.class); - workResolverListController = mock(ResolverListController.class); - workProfileUserHandle = null; - cloneProfileUserHandle = null; - tabOwnerUserHandleForLaunch = null; - myUserId = null; - hasCrossProfileIntents = true; - isQuietModeEnabled = false; - - mWorkProfileAvailability = new WorkProfileAvailabilityManager(null, null, null) { - @Override - public boolean isQuietModeEnabled() { - return isQuietModeEnabled; - } - - @Override - public boolean isWorkProfileUserUnlocked() { - return true; - } - - @Override - public void requestQuietModeEnabled(boolean enabled) { - isQuietModeEnabled = enabled; - } - - @Override - public void markWorkProfileEnabledBroadcastReceived() {} - - @Override - public boolean isWaitingToEnableWorkProfile() { - return false; - } - }; - - mCrossProfileIntentsChecker = mock(CrossProfileIntentsChecker.class); - when(mCrossProfileIntentsChecker.hasCrossProfileIntents(any(), anyInt(), anyInt())) - .thenAnswer(invocation -> hasCrossProfileIntents); - } - } - - private static class TargetDataLoaderWrapper extends TargetDataLoader { - private final TargetDataLoader mTargetDataLoader; - private final CountingIdlingResource mLabelIdlingResource; - - private TargetDataLoaderWrapper( - TargetDataLoader targetDataLoader, CountingIdlingResource labelIdlingResource) { - mTargetDataLoader = targetDataLoader; - mLabelIdlingResource = labelIdlingResource; - } - - @Override - public void loadAppTargetIcon( - @NonNull DisplayResolveInfo info, - @NonNull UserHandle userHandle, - @NonNull Consumer<Drawable> callback) { - mTargetDataLoader.loadAppTargetIcon(info, userHandle, callback); - } - - @Override - public void loadDirectShareIcon( - @NonNull SelectableTargetInfo info, - @NonNull UserHandle userHandle, - @NonNull Consumer<Drawable> callback) { - mTargetDataLoader.loadDirectShareIcon(info, userHandle, callback); - } - - @Override - public void loadLabel( - @NonNull DisplayResolveInfo info, - @NonNull Consumer<CharSequence[]> callback) { - mLabelIdlingResource.increment(); - mTargetDataLoader.loadLabel( - info, - (result) -> { - mLabelIdlingResource.decrement(); - callback.accept(result); - }); - } - - @NonNull - @Override - public TargetPresentationGetter createPresentationGetter(@NonNull ResolveInfo info) { - return mTargetDataLoader.createPresentationGetter(info); - } - } -} diff --git a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt b/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt deleted file mode 100644 index 9ddeed84..00000000 --- a/java/tests/src/com/android/intentresolver/ShortcutSelectionLogicTest.kt +++ /dev/null @@ -1,313 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.pm.ResolveInfo -import android.content.pm.ShortcutInfo -import android.os.UserHandle -import android.service.chooser.ChooserTarget -import com.android.intentresolver.chooser.DisplayResolveInfo -import com.android.intentresolver.chooser.TargetInfo -import androidx.test.filters.SmallTest -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -private const val PACKAGE_A = "package.a" -private const val PACKAGE_B = "package.b" -private const val CLASS_NAME = "./MainActivity" - -@SmallTest -class ShortcutSelectionLogicTest { - private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser() - - private val packageTargets = HashMap<String, Array<ChooserTarget>>().apply { - arrayOf(PACKAGE_A, PACKAGE_B).forEach { pkg -> - // shortcuts in reverse priority order - val targets = Array(3) { i -> - createChooserTarget( - "Shortcut $i", - (i + 1).toFloat() / 10f, - ComponentName(pkg, CLASS_NAME), - pkg.shortcutId(i), - ) - } - this[pkg] = targets - } - } - - private val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( - Intent(), - ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), - "label", - "extended info", - Intent(), - /* resolveInfoPresentationGetter= */ null) - - private val otherBaseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( - Intent(), - ResolverDataProvider.createResolveInfo(4, 0, PERSONAL_USER_HANDLE), - "label 2", - "extended info 2", - Intent(), - /* resolveInfoPresentationGetter= */ null) - - private operator fun Map<String, Array<ChooserTarget>>.get(pkg: String, idx: Int) = - this[pkg]?.get(idx) ?: error("missing package $pkg") - - @Test - fun testAddShortcuts_no_limits() { - val serviceResults = ArrayList<TargetInfo>() - val sc1 = packageTargets[PACKAGE_A, 0] - val sc2 = packageTargets[PACKAGE_A, 1] - val testSubject = ShortcutSelectionLogic( - /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ false - ) - - val isUpdated = testSubject.addServiceResults( - /* origTarget = */ baseDisplayInfo, - /* origTargetScore = */ 0.1f, - /* targets = */ listOf(sc1, sc2), - /* isShortcutResult = */ true, - /* directShareToShortcutInfos = */ emptyMap(), - /* directShareToAppTargets = */ emptyMap(), - /* userContext = */ mock(), - /* targetIntent = */ mock(), - /* refererFillInIntent = */ mock(), - /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults - ) - - assertTrue("Updates are expected", isUpdated) - assertShortcutsInOrder( - listOf(sc2, sc1), - serviceResults, - "Two shortcuts are expected as we do not apply per-app shortcut limit" - ) - } - - @Test - fun testAddShortcuts_same_package_with_per_package_limit() { - val serviceResults = ArrayList<TargetInfo>() - val sc1 = packageTargets[PACKAGE_A, 0] - val sc2 = packageTargets[PACKAGE_A, 1] - val testSubject = ShortcutSelectionLogic( - /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ true - ) - - val isUpdated = testSubject.addServiceResults( - /* origTarget = */ baseDisplayInfo, - /* origTargetScore = */ 0.1f, - /* targets = */ listOf(sc1, sc2), - /* isShortcutResult = */ true, - /* directShareToShortcutInfos = */ emptyMap(), - /* directShareToAppTargets = */ emptyMap(), - /* userContext = */ mock(), - /* targetIntent = */ mock(), - /* refererFillInIntent = */ mock(), - /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults - ) - - assertTrue("Updates are expected", isUpdated) - assertShortcutsInOrder( - listOf(sc2), - serviceResults, - "One shortcut is expected as we apply per-app shortcut limit" - ) - } - - @Test - fun testAddShortcuts_same_package_no_per_app_limit_with_target_limit() { - val serviceResults = ArrayList<TargetInfo>() - val sc1 = packageTargets[PACKAGE_A, 0] - val sc2 = packageTargets[PACKAGE_A, 1] - val testSubject = ShortcutSelectionLogic( - /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ false - ) - - val isUpdated = testSubject.addServiceResults( - /* origTarget = */ baseDisplayInfo, - /* origTargetScore = */ 0.1f, - /* targets = */ listOf(sc1, sc2), - /* isShortcutResult = */ true, - /* directShareToShortcutInfos = */ emptyMap(), - /* directShareToAppTargets = */ emptyMap(), - /* userContext = */ mock(), - /* targetIntent = */ mock(), - /* refererFillInIntent = */ mock(), - /* maxRankedTargets = */ 1, - /* serviceTargets = */ serviceResults - ) - - assertTrue("Updates are expected", isUpdated) - assertShortcutsInOrder( - listOf(sc2), - serviceResults, - "One shortcut is expected as we apply overall shortcut limit" - ) - } - - @Test - fun testAddShortcuts_different_packages_with_per_package_limit() { - val serviceResults = ArrayList<TargetInfo>() - val pkgAsc1 = packageTargets[PACKAGE_A, 0] - val pkgAsc2 = packageTargets[PACKAGE_A, 1] - val pkgBsc1 = packageTargets[PACKAGE_B, 0] - val pkgBsc2 = packageTargets[PACKAGE_B, 1] - val testSubject = ShortcutSelectionLogic( - /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ true - ) - - testSubject.addServiceResults( - /* origTarget = */ baseDisplayInfo, - /* origTargetScore = */ 0.1f, - /* targets = */ listOf(pkgAsc1, pkgAsc2), - /* isShortcutResult = */ true, - /* directShareToShortcutInfos = */ emptyMap(), - /* directShareToAppTargets = */ emptyMap(), - /* userContext = */ mock(), - /* targetIntent = */ mock(), - /* refererFillInIntent = */ mock(), - /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults - ) - testSubject.addServiceResults( - /* origTarget = */ otherBaseDisplayInfo, - /* origTargetScore = */ 0.2f, - /* targets = */ listOf(pkgBsc1, pkgBsc2), - /* isShortcutResult = */ true, - /* directShareToShortcutInfos = */ emptyMap(), - /* directShareToAppTargets = */ emptyMap(), - /* userContext = */ mock(), - /* targetIntent = */ mock(), - /* refererFillInIntent = */ mock(), - /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults - ) - - assertShortcutsInOrder( - listOf(pkgBsc2, pkgAsc2), - serviceResults, - "Two shortcuts are expected as we apply per-app shortcut limit" - ) - } - - @Test - fun testAddShortcuts_pinned_shortcut() { - val serviceResults = ArrayList<TargetInfo>() - val sc1 = packageTargets[PACKAGE_A, 0] - val sc2 = packageTargets[PACKAGE_A, 1] - val testSubject = ShortcutSelectionLogic( - /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ false - ) - - val isUpdated = testSubject.addServiceResults( - /* origTarget = */ baseDisplayInfo, - /* origTargetScore = */ 0.1f, - /* targets = */ listOf(sc1, sc2), - /* isShortcutResult = */ true, - /* directShareToShortcutInfos = */ mapOf( - sc1 to createShortcutInfo( - PACKAGE_A.shortcutId(1), - sc1.componentName, 1).apply { - addFlags(ShortcutInfo.FLAG_PINNED) - } - ), - /* directShareToAppTargets = */ emptyMap(), - /* userContext = */ mock(), - /* targetIntent = */ mock(), - /* refererFillInIntent = */ mock(), - /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults - ) - - assertTrue("Updates are expected", isUpdated) - assertShortcutsInOrder( - listOf(sc1, sc2), - serviceResults, - "Two shortcuts are expected as we do not apply per-app shortcut limit" - ) - } - - @Test - fun test_available_caller_shortcuts_count_is_limited() { - val serviceResults = ArrayList<TargetInfo>() - val sc1 = packageTargets[PACKAGE_A, 0] - val sc2 = packageTargets[PACKAGE_A, 1] - val sc3 = packageTargets[PACKAGE_A, 2] - val testSubject = ShortcutSelectionLogic( - /* maxShortcutTargetsPerApp = */ 1, - /* applySharingAppLimits = */ true - ) - val context = mock<Context> { - whenever(packageManager).thenReturn(mock()) - } - - testSubject.addServiceResults( - /* origTarget = */ baseDisplayInfo, - /* origTargetScore = */ 0f, - /* targets = */ listOf(sc1, sc2, sc3), - /* isShortcutResult = */ false, - /* directShareToShortcutInfos = */ emptyMap(), - /* directShareToAppTargets = */ emptyMap(), - /* userContext = */ context, - /* targetIntent = */ mock(), - /* refererFillInIntent = */ mock(), - /* maxRankedTargets = */ 4, - /* serviceTargets = */ serviceResults - ) - - assertShortcutsInOrder( - listOf(sc3, sc2), - serviceResults, - "At most two caller-provided shortcuts are allowed" - ) - } - - // TODO: consider renaming. Not all `ChooserTarget`s are "shortcuts" and many of our test cases - // add results with `isShortcutResult = false` and `directShareToShortcutInfos = emptyMap()`. - private fun assertShortcutsInOrder( - expected: List<ChooserTarget>, actual: List<TargetInfo>, msg: String? = "" - ) { - assertEquals(msg, expected.size, actual.size) - for (i in expected.indices) { - assertEquals( - "Unexpected item at position $i", - expected[i].componentName, - actual[i].chooserTargetComponentName - ) - assertEquals( - "Unexpected item at position $i", - expected[i].title, - actual[i].displayLabel - ) - } - } - - private fun String.shortcutId(id: Int) = "$this.$id" -} diff --git a/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt b/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt deleted file mode 100644 index e62672a3..00000000 --- a/java/tests/src/com/android/intentresolver/TargetPresentationGetterTest.kt +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import com.android.intentresolver.ResolverDataProvider -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -/** - * Unit tests for the various implementations of {@link TargetPresentationGetter}. - * TODO: consider expanding to cover icon logic (not just labels/sublabels). - * TODO: these are conceptually "acceptance tests" that provide comprehensive coverage of the - * apparent variations in the legacy implementation. The tests probably don't have to be so - * exhaustive if we're able to impose a simpler design on the implementation. - */ -class TargetPresentationGetterTest { - fun makeResolveInfoPresentationGetter( - withSubstitutePermission: Boolean, - appLabel: String, - activityLabel: String, - resolveInfoLabel: String): TargetPresentationGetter { - val testPackageInfo = ResolverDataProvider.createPackageManagerMockedInfo( - withSubstitutePermission, appLabel, activityLabel, resolveInfoLabel) - val factory = TargetPresentationGetter.Factory(testPackageInfo.ctx, 100) - return factory.makePresentationGetter(testPackageInfo.resolveInfo) - } - - fun makeActivityInfoPresentationGetter( - withSubstitutePermission: Boolean, - appLabel: String?, - activityLabel: String?): TargetPresentationGetter { - val testPackageInfo = ResolverDataProvider.createPackageManagerMockedInfo( - withSubstitutePermission, appLabel, activityLabel, "") - val factory = TargetPresentationGetter.Factory(testPackageInfo.ctx, 100) - return factory.makePresentationGetter(testPackageInfo.activityInfo) - } - - @Test - fun testActivityInfoLabels_noSubstitutePermission_distinctRequestedLabelAndSublabel() { - val presentationGetter = makeActivityInfoPresentationGetter( - false, "app_label", "activity_label") - assertThat(presentationGetter.getLabel()).isEqualTo("app_label") - assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label") - } - - @Test - fun testActivityInfoLabels_noSubstitutePermission_sameRequestedLabelAndSublabel() { - val presentationGetter = makeActivityInfoPresentationGetter( - false, "app_label", "app_label") - assertThat(presentationGetter.getLabel()).isEqualTo("app_label") - // Without the substitute permission, there's no logic to dedupe the labels. - // TODO: this matches our observations in the legacy code, but is it the right behavior? It - // seems like {@link ResolverListAdapter.ViewHolder#bindLabel()} has some logic to dedupe in - // the UI at least, but maybe that logic should be pulled back to the "presentation"? - assertThat(presentationGetter.getSubLabel()).isEqualTo("app_label") - } - - @Test - fun testActivityInfoLabels_noSubstitutePermission_nullRequestedLabel() { - val presentationGetter = makeActivityInfoPresentationGetter(false, null, "activity_label") - assertThat(presentationGetter.getLabel()).isNull() - assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label") - } - - @Test - fun testActivityInfoLabels_noSubstitutePermission_emptyRequestedLabel() { - val presentationGetter = makeActivityInfoPresentationGetter(false, "", "activity_label") - assertThat(presentationGetter.getLabel()).isEqualTo("") - assertThat(presentationGetter.getSubLabel()).isEqualTo("activity_label") - } - - @Test - fun testActivityInfoLabels_noSubstitutePermission_emptyRequestedSublabel() { - val presentationGetter = makeActivityInfoPresentationGetter(false, "app_label", "") - assertThat(presentationGetter.getLabel()).isEqualTo("app_label") - // Without the substitute permission, empty sublabels are passed through as-is. - assertThat(presentationGetter.getSubLabel()).isEqualTo("") - } - - @Test - fun testActivityInfoLabels_withSubstitutePermission_distinctRequestedLabelAndSublabel() { - val presentationGetter = makeActivityInfoPresentationGetter( - true, "app_label", "activity_label") - assertThat(presentationGetter.getLabel()).isEqualTo("activity_label") - // With the substitute permission, the same ("activity") label is requested as both the label - // and sublabel, even though the other value ("app_label") was distinct. Thus this behaves the - // same as a dupe. - assertThat(presentationGetter.getSubLabel()).isEqualTo(null) - } - - @Test - fun testActivityInfoLabels_withSubstitutePermission_sameRequestedLabelAndSublabel() { - val presentationGetter = makeActivityInfoPresentationGetter( - true, "app_label", "app_label") - assertThat(presentationGetter.getLabel()).isEqualTo("app_label") - // With the substitute permission, duped sublabels get converted to nulls. - assertThat(presentationGetter.getSubLabel()).isNull() - } - - @Test - fun testActivityInfoLabels_withSubstitutePermission_nullRequestedLabel() { - val presentationGetter = makeActivityInfoPresentationGetter(true, "app_label", null) - assertThat(presentationGetter.getLabel()).isEqualTo("app_label") - // With the substitute permission, null inputs are a special case that produces null outputs - // (i.e., they're not simply passed-through from the inputs). - assertThat(presentationGetter.getSubLabel()).isNull() - } - - @Test - fun testActivityInfoLabels_withSubstitutePermission_emptyRequestedLabel() { - val presentationGetter = makeActivityInfoPresentationGetter(true, "app_label", "") - // Empty "labels" are taken as-is and (unlike nulls) don't prompt a fallback to the sublabel. - // Thus (as in the previous case with substitute permission & "distinct" labels), this is - // treated as a dupe. - assertThat(presentationGetter.getLabel()).isEqualTo("") - assertThat(presentationGetter.getSubLabel()).isNull() - } - - @Test - fun testActivityInfoLabels_withSubstitutePermission_emptyRequestedSublabel() { - val presentationGetter = makeActivityInfoPresentationGetter(true, "", "activity_label") - assertThat(presentationGetter.getLabel()).isEqualTo("activity_label") - // With the substitute permission, empty sublabels get converted to nulls. - assertThat(presentationGetter.getSubLabel()).isNull() - } - - @Test - fun testResolveInfoLabels_noSubstitutePermission_distinctRequestedLabelAndSublabel() { - val presentationGetter = makeResolveInfoPresentationGetter( - false, "app_label", "activity_label", "resolve_info_label") - assertThat(presentationGetter.getLabel()).isEqualTo("app_label") - assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label") - } - - @Test - fun testResolveInfoLabels_noSubstitutePermission_sameRequestedLabelAndSublabel() { - val presentationGetter = makeResolveInfoPresentationGetter( - false, "app_label", "activity_label", "app_label") - assertThat(presentationGetter.getLabel()).isEqualTo("app_label") - // Without the substitute permission, there's no logic to dedupe the labels. - // TODO: this matches our observations in the legacy code, but is it the right behavior? It - // seems like {@link ResolverListAdapter.ViewHolder#bindLabel()} has some logic to dedupe in - // the UI at least, but maybe that logic should be pulled back to the "presentation"? - assertThat(presentationGetter.getSubLabel()).isEqualTo("app_label") - } - - @Test - fun testResolveInfoLabels_noSubstitutePermission_emptyRequestedSublabel() { - val presentationGetter = makeResolveInfoPresentationGetter( - false, "app_label", "activity_label", "") - assertThat(presentationGetter.getLabel()).isEqualTo("app_label") - // Without the substitute permission, empty sublabels are passed through as-is. - assertThat(presentationGetter.getSubLabel()).isEqualTo("") - } - - @Test - fun testResolveInfoLabels_withSubstitutePermission_distinctRequestedLabelAndSublabel() { - val presentationGetter = makeResolveInfoPresentationGetter( - true, "app_label", "activity_label", "resolve_info_label") - assertThat(presentationGetter.getLabel()).isEqualTo("activity_label") - assertThat(presentationGetter.getSubLabel()).isEqualTo("resolve_info_label") - } - - @Test - fun testResolveInfoLabels_withSubstitutePermission_sameRequestedLabelAndSublabel() { - val presentationGetter = makeResolveInfoPresentationGetter( - true, "app_label", "activity_label", "activity_label") - assertThat(presentationGetter.getLabel()).isEqualTo("activity_label") - // With the substitute permission, duped sublabels get converted to nulls. - assertThat(presentationGetter.getSubLabel()).isNull() - } - - @Test - fun testResolveInfoLabels_withSubstitutePermission_emptyRequestedSublabel() { - val presentationGetter = makeResolveInfoPresentationGetter( - true, "app_label", "activity_label", "") - assertThat(presentationGetter.getLabel()).isEqualTo("activity_label") - // With the substitute permission, empty sublabels get converted to nulls. - assertThat(presentationGetter.getSubLabel()).isNull() - } - - @Test - fun testResolveInfoLabels_withSubstitutePermission_emptyRequestedLabelAndSublabel() { - val presentationGetter = makeResolveInfoPresentationGetter( - true, "app_label", "", "") - assertThat(presentationGetter.getLabel()).isEqualTo("") - // With the substitute permission, empty sublabels get converted to nulls. - assertThat(presentationGetter.getSubLabel()).isNull() - } -} diff --git a/java/tests/src/com/android/intentresolver/TestContentPreviewViewModel.kt b/java/tests/src/com/android/intentresolver/TestContentPreviewViewModel.kt deleted file mode 100644 index d239f612..00000000 --- a/java/tests/src/com/android/intentresolver/TestContentPreviewViewModel.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.CreationExtras -import com.android.intentresolver.contentpreview.BasePreviewViewModel -import com.android.intentresolver.contentpreview.ImageLoader -import com.android.intentresolver.contentpreview.PreviewDataProvider - -/** A test content preview model that supports image loader override. */ -class TestContentPreviewViewModel( - private val viewModel: BasePreviewViewModel, - private val imageLoader: ImageLoader? = null, -) : BasePreviewViewModel() { - override fun createOrReuseProvider( - chooserRequest: ChooserRequestParameters - ): PreviewDataProvider = viewModel.createOrReuseProvider(chooserRequest) - - override fun createOrReuseImageLoader(): ImageLoader = - imageLoader ?: viewModel.createOrReuseImageLoader() - - companion object { - fun wrap( - factory: ViewModelProvider.Factory, - imageLoader: ImageLoader?, - ): ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel> create( - modelClass: Class<T>, - extras: CreationExtras - ): T { - return TestContentPreviewViewModel( - factory.create(modelClass, extras) as BasePreviewViewModel, - imageLoader, - ) as T - } - } - } -} diff --git a/java/tests/src/com/android/intentresolver/TestContentProvider.kt b/java/tests/src/com/android/intentresolver/TestContentProvider.kt deleted file mode 100644 index b3b53baa..00000000 --- a/java/tests/src/com/android/intentresolver/TestContentProvider.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.content.ContentProvider -import android.content.ContentValues -import android.database.Cursor -import android.net.Uri - -class TestContentProvider : ContentProvider() { - override fun query( - uri: Uri, - projection: Array<out String>?, - selection: String?, - selectionArgs: Array<out String>?, - sortOrder: String? - ): Cursor? = null - - override fun getType(uri: Uri): String? - = runCatching { - uri.getQueryParameter("mimeType") - }.getOrNull() - - override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String>? - = runCatching { - uri.getQueryParameter("streamType")?.let { arrayOf(it) } - }.getOrNull() - - override fun insert(uri: Uri, values: ContentValues?): Uri? = null - - override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0 - - override fun update( - uri: Uri, - values: ContentValues?, - selection: String?, - selectionArgs: Array<out String>? - ): Int = 0 - - override fun onCreate(): Boolean = true -}
\ No newline at end of file diff --git a/java/tests/src/com/android/intentresolver/TestHelpers.kt b/java/tests/src/com/android/intentresolver/TestHelpers.kt deleted file mode 100644 index 5b583fef..00000000 --- a/java/tests/src/com/android/intentresolver/TestHelpers.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.app.prediction.AppTarget -import android.app.prediction.AppTargetId -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.pm.ShortcutInfo -import android.content.pm.ShortcutManager.ShareShortcutInfo -import android.os.Bundle -import android.service.chooser.ChooserTarget -import org.mockito.Mockito.`when` as whenever - -internal fun createShareShortcutInfo( - id: String, - componentName: ComponentName, - rank: Int -): ShareShortcutInfo = - ShareShortcutInfo( - createShortcutInfo(id, componentName, rank), - componentName - ) - -internal fun createShortcutInfo( - id: String, - componentName: ComponentName, - rank: Int -): ShortcutInfo { - val context = mock<Context>() - whenever(context.packageName).thenReturn(componentName.packageName) - return ShortcutInfo.Builder(context, id) - .setShortLabel("Short Label $id") - .setLongLabel("Long Label $id") - .setActivity(componentName) - .setRank(rank) - .build() -} - -internal fun createAppTarget(shortcutInfo: ShortcutInfo) = - AppTarget( - AppTargetId(shortcutInfo.id), - shortcutInfo, - shortcutInfo.activity?.className ?: error("missing activity info") - ) - -fun createChooserTarget( - title: String, score: Float, componentName: ComponentName, shortcutId: String -): ChooserTarget = - ChooserTarget( - title, - null, - score, - componentName, - Bundle().apply { putString(Intent.EXTRA_SHORTCUT_ID, shortcutId) } - ) diff --git a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt b/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt deleted file mode 100644 index bf87ed8a..00000000 --- a/java/tests/src/com/android/intentresolver/TestPreviewImageLoader.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver - -import android.graphics.Bitmap -import android.net.Uri -import androidx.lifecycle.Lifecycle -import com.android.intentresolver.contentpreview.ImageLoader -import java.util.function.Consumer - -internal class TestPreviewImageLoader(private val bitmaps: Map<Uri, Bitmap>) : ImageLoader { - override fun loadImage(callerLifecycle: Lifecycle, uri: Uri, callback: Consumer<Bitmap?>) { - callback.accept(bitmaps[uri]) - } - - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = bitmaps[uri] - - override fun prePopulate(uris: List<Uri>) = Unit -} diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java deleted file mode 100644 index 3ddd4394..00000000 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ /dev/null @@ -1,2971 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static android.app.Activity.RESULT_OK; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.longClick; -import static androidx.test.espresso.action.ViewActions.swipeUp; -import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; - -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_DEFAULT; -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; -import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; -import static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST; -import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; -import static com.android.intentresolver.MatcherUtils.first; - -import static com.google.common.truth.Truth.assertThat; - -import static junit.framework.Assert.assertNull; - -import static org.hamcrest.CoreMatchers.allOf; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.not; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.app.PendingIntent; -import android.app.usage.UsageStatsManager; -import android.content.BroadcastReceiver; -import android.content.ClipData; -import android.content.ClipDescription; -import android.content.ClipboardManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.ActivityInfo; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager.ShareShortcutInfo; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.drawable.Icon; -import android.net.Uri; -import android.os.Bundle; -import android.os.UserHandle; -import android.provider.DeviceConfig; -import android.service.chooser.ChooserAction; -import android.service.chooser.ChooserTarget; -import android.util.HashedStringCache; -import android.util.Pair; -import android.util.SparseArray; -import android.view.View; -import android.view.WindowManager; - -import androidx.annotation.CallSuper; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.test.espresso.contrib.RecyclerViewActions; -import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; -import androidx.test.espresso.matcher.ViewMatchers; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.rule.ActivityTestRule; - -import com.android.intentresolver.chooser.DisplayResolveInfo; -import com.android.intentresolver.contentpreview.ImageLoader; -import com.android.intentresolver.shortcuts.ShortcutLoader; -import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; -import com.android.internal.logging.nano.MetricsProto.MetricsEvent; -import com.android.systemui.flags.BooleanFlag; - -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.Matchers; -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.RuleChain; -import org.junit.rules.TestRule; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.function.Function; - -/** - * Instrumentation tests for the IntentResolver module's Sharesheet (ChooserActivity). - * TODO: remove methods that supported running these tests against arbitrary ChooserActivity - * subclasses. Those were left over from an earlier version where IntentResolver's ChooserActivity - * inherited from the framework version at com.android.internal.app.ChooserActivity, and this test - * file inherited from the framework's version as well. Once the migration to the IntentResolver - * package is complete, that aspect of the test design can revert to match the style of the - * framework tests prior to ag/16482932. - * TODO: this can simply be renamed to "ChooserActivityTest" if that's ever unambiguous (i.e., if - * there's no risk of confusion with the framework tests that currently share the same name). - */ -@RunWith(Parameterized.class) -public class UnbundledChooserActivityTest { - - /* -------- - * Subclasses should copy the following section verbatim (or alternatively could specify some - * additional @Parameterized.Parameters, as long as the correct parameters are used to - * initialize the ChooserActivityTest). The subclasses should also be @RunWith the - * `Parameterized` runner. - * -------- - */ - - private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser(); - private static final Function<PackageManager, PackageManager> DEFAULT_PM = pm -> pm; - private static final Function<PackageManager, PackageManager> NO_APP_PREDICTION_SERVICE_PM = - pm -> { - PackageManager mock = Mockito.spy(pm); - when(mock.getAppPredictionServicePackageName()).thenReturn(null); - return mock; - }; - - private static final List<BooleanFlag> ALL_FLAGS = - Arrays.asList(); - - private static final Map<BooleanFlag, Boolean> ALL_FLAGS_OFF = - createAllFlagsOverride(false); - private static final Map<BooleanFlag, Boolean> ALL_FLAGS_ON = - createAllFlagsOverride(true); - - @Parameterized.Parameters - public static Collection packageManagers() { - if (ALL_FLAGS.isEmpty()) { - // No flags to toggle between, so just two configurations. - return Arrays.asList(new Object[][] { - // Default PackageManager and all flags off - { DEFAULT_PM, ALL_FLAGS_OFF}, - // No App Prediction Service and all flags off - { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_OFF }, - }); - } - return Arrays.asList(new Object[][] { - // Default PackageManager and all flags off - { DEFAULT_PM, ALL_FLAGS_OFF}, - // Default PackageManager and all flags on - { DEFAULT_PM, ALL_FLAGS_ON}, - // No App Prediction Service and all flags off - { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_OFF }, - // No App Prediction Service and all flags on - { NO_APP_PREDICTION_SERVICE_PM, ALL_FLAGS_ON } - }); - } - - private static Map<BooleanFlag, Boolean> createAllFlagsOverride(boolean value) { - HashMap<BooleanFlag, Boolean> overrides = new HashMap<>(ALL_FLAGS.size()); - for (BooleanFlag flag : ALL_FLAGS) { - overrides.put(flag, value); - } - return overrides; - } - - /* -------- - * Subclasses can override the following methods to customize test behavior. - * -------- - */ - - /** - * Perform any necessary per-test initialization steps (subclasses may add additional steps - * before and/or after calling up to the superclass implementation). - */ - @CallSuper - protected void setup() { - // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the - // permissions we require (which we'll read from the manifest at runtime). - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() - .adoptShellPermissionIdentity(); - - cleanOverrideData(); - ChooserActivityOverrideData.getInstance().featureFlagRepository = - new TestFeatureFlagRepository(mFlags); - } - - /** - * Given an intent that was constructed in a test, perform any additional configuration to - * specify the appropriate concrete ChooserActivity subclass. The activity launched by this - * intent must descend from android.intentresolver.ChooserActivity (for our ActivityTestRule), and - * must also implement the android.intentresolver.IChooserWrapper interface (since test code will - * assume the ability to make unsafe downcasts). - */ - protected Intent getConcreteIntentForLaunch(Intent clientIntent) { - clientIntent.setClass( - InstrumentationRegistry.getInstrumentation().getTargetContext(), - com.android.intentresolver.ChooserWrapperActivity.class); - return clientIntent; - } - - /** - * Whether {@code #testIsAppPredictionServiceAvailable} should verify the behavior after - * changing the availability conditions at runtime. In the unbundled chooser, the availability - * is cached at start and will never be re-evaluated. - * TODO: remove when we no longer want to test the system's on-the-fly evaluation. - */ - protected boolean shouldTestTogglingAppPredictionServiceAvailabilityAtRuntime() { - return false; - } - - /* -------- - * The code in this section is unorthodox and can be simplified/reverted when we no longer need - * to support the parallel chooser implementations. - * -------- - */ - - @Rule - public final TestRule mRule; - - // Shared test code references the activity under test as ChooserActivity, the common ancestor - // of any (inheritance-based) chooser implementation. For testing purposes, that activity will - // usually be cast to IChooserWrapper to expose instrumentation. - private ActivityTestRule<ChooserActivity> mActivityRule = - new ActivityTestRule<>(ChooserActivity.class, false, false) { - @Override - public ChooserActivity launchActivity(Intent clientIntent) { - return super.launchActivity(getConcreteIntentForLaunch(clientIntent)); - } - }; - - @Before - public final void doPolymorphicSetup() { - // The base class needs a @Before-annotated setup for when it runs against the system - // chooser, while subclasses need to be able to specify their own setup behavior. Notably - // the unbundled chooser, running in user-space, needs to take additional steps before it - // can run #cleanOverrideData() (which writes to DeviceConfig). - setup(); - } - - /* -------- - * Subclasses can ignore the remaining code and inherit the full suite of tests. - * -------- - */ - - private static final String TEST_MIME_TYPE = "application/TestType"; - - private static final int CONTENT_PREVIEW_IMAGE = 1; - private static final int CONTENT_PREVIEW_FILE = 2; - private static final int CONTENT_PREVIEW_TEXT = 3; - - private final Function<PackageManager, PackageManager> mPackageManagerOverride; - private final Map<BooleanFlag, Boolean> mFlags; - - - public UnbundledChooserActivityTest( - Function<PackageManager, PackageManager> packageManagerOverride, - Map<BooleanFlag, Boolean> flags) { - mPackageManagerOverride = packageManagerOverride; - mFlags = flags; - - mRule = RuleChain - .outerRule(new FeatureFlagRule(flags)) - .around(mActivityRule); - } - - private void setDeviceConfigProperty( - @NonNull String propertyName, - @NonNull String value) { - // TODO: consider running with {@link #runWithShellPermissionIdentity()} to more narrowly - // request WRITE_DEVICE_CONFIG permissions if we get rid of the broad grant we currently - // configure in {@link #setup()}. - // TODO: is it really appropriate that this is always set with makeDefault=true? - boolean valueWasSet = DeviceConfig.setProperty( - DeviceConfig.NAMESPACE_SYSTEMUI, - propertyName, - value, - true /* makeDefault */); - if (!valueWasSet) { - throw new IllegalStateException( - "Could not set " + propertyName + " to " + value); - } - } - - public void cleanOverrideData() { - ChooserActivityOverrideData.getInstance().reset(); - ChooserActivityOverrideData.getInstance().createPackageManager = mPackageManagerOverride; - - setDeviceConfigProperty( - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(true)); - } - - @Test - public void customTitle() throws InterruptedException { - Intent viewIntent = createViewTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - final IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity( - Intent.createChooser(viewIntent, "chooser test")); - - waitForIdle(); - assertThat(activity.getAdapter().getCount(), is(2)); - assertThat(activity.getAdapter().getServiceTargetCount(), is(0)); - onView(withId(android.R.id.title)).check(matches(withText("chooser test"))); - } - - @Test - public void customTitleIgnoredForSendIntents() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); - waitForIdle(); - onView(withId(android.R.id.title)) - .check(matches(withText(R.string.whichSendApplication))); - } - - @Test - public void emptyTitle() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(android.R.id.title)) - .check(matches(withText(R.string.whichSendApplication))); - } - - @Test - public void emptyPreviewTitleAndThumbnail() throws InterruptedException { - Intent sendIntent = createSendTextIntentWithPreview(null, null); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(not(isDisplayed()))); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(not(isDisplayed()))); - } - - @Test - public void visiblePreviewTitleWithoutThumbnail() throws InterruptedException { - String previewTitle = "My Content Preview Title"; - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, null); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(withText(previewTitle))); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(not(isDisplayed()))); - } - - @Test - public void visiblePreviewTitleWithInvalidThumbnail() throws InterruptedException { - String previewTitle = "My Content Preview Title"; - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, - Uri.parse("tel:(+49)12345789")); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(not(isDisplayed()))); - } - - @Test - public void visiblePreviewTitleAndThumbnail() throws InterruptedException { - String previewTitle = "My Content Preview Title"; - Uri uri = Uri.parse( - "android.resource://com.android.frameworks.coretests/" - + R.drawable.test320x240); - Intent sendIntent = createSendTextIntentWithPreview(previewTitle, uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(com.android.internal.R.id.content_preview_title)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.content_preview_thumbnail)) - .check(matches(isDisplayed())); - } - - @Test @Ignore - public void twoOptionsAndUserSelectsOne() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - assertThat(activity.getAdapter().getCount(), is(2)); - onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void fourOptionsStackedIntoOneTarget() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - - // create just enough targets to ensure the a-z list should be shown - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(1); - - // next create 4 targets in a single app that should be stacked into a single target - String packageName = "xxx.yyy"; - String appName = "aaa"; - ComponentName cn = new ComponentName(packageName, appName); - Intent intent = new Intent("fakeIntent"); - List<ResolvedComponentInfo> infosToStack = new ArrayList<>(); - for (int i = 0; i < 4; i++) { - ResolveInfo resolveInfo = ResolverDataProvider.createResolveInfo(i, - UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); - resolveInfo.activityInfo.applicationInfo.name = appName; - resolveInfo.activityInfo.applicationInfo.packageName = packageName; - resolveInfo.activityInfo.packageName = packageName; - resolveInfo.activityInfo.name = "ccc" + i; - infosToStack.add(new ResolvedComponentInfo(cn, intent, resolveInfo)); - } - resolvedComponentInfos.addAll(infosToStack); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // expect 1 unique targets + 1 group + 4 ranked app targets - assertThat(activity.getAdapter().getCount(), is(6)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - onView(allOf(withText(appName), hasSibling(withText("")))).perform(click()); - waitForIdle(); - - // clicking will launch a dialog to choose the activity within the app - onView(withText(appName)).check(matches(isDisplayed())); - int i = 0; - for (ResolvedComponentInfo rci: infosToStack) { - onView(withText("ccc" + i)).check(matches(isDisplayed())); - ++i; - } - } - - @Test @Ignore - public void updateChooserCountsAndModelAfterUserSelection() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - UsageStatsManager usm = activity.getUsageStatsManager(); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .topK(any(List.class), anyInt()); - assertThat(activity.getIsSelected(), is(false)); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - return true; - }; - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - DisplayResolveInfo testDri = - activity.createTestDisplayResolveInfo(sendIntent, toChoose, "testLabel", "testInfo", - sendIntent, /* resolveInfoPresentationGetter */ null); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .updateChooserCounts(Mockito.anyString(), any(UserHandle.class), - Mockito.anyString()); - verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) - .updateModel(testDri); - assertThat(activity.getIsSelected(), is(true)); - } - - @Ignore // b/148158199 - @Test - public void noResultsFromPackageManager() { - setupResolverControllers(null); - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - final IChooserWrapper wrapper = (IChooserWrapper) activity; - - waitForIdle(); - assertThat(activity.isFinishing(), is(false)); - - onView(withId(android.R.id.empty)).check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.profile_pager)).check(matches(not(isDisplayed()))); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> wrapper.getAdapter().handlePackagesChanged() - ); - // backward compatibility. looks like we finish when data is empty after package change - assertThat(activity.isFinishing(), is(true)); - } - - @Test - public void autoLaunchSingleResult() throws InterruptedException { - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(1); - setupResolverControllers(resolvedComponentInfos); - - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - assertThat(chosen[0], is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat(activity.isFinishing(), is(true)); - } - - @Test @Ignore - public void hasOtherProfileOneOption() { - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest(4); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - markWorkProfileUserAvailable(); - - ResolveInfo toChoose = personalResolvedComponentInfos.get(1).getResolveInfoAt(0); - Intent sendIntent = createSendTextIntent(); - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(1)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - // Make a stable copy of the components as the original list may be modified - List<ResolvedComponentInfo> stableCopy = - createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10); - waitForIdle(); - - onView(first(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name))) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void hasOtherProfileTwoOptionsAndUserSelectsOne() throws Exception { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - when(ChooserActivityOverrideData.getInstance().resolverListController.getLastChosen()) - .thenReturn(resolvedComponentInfos.get(0).getResolveInfoAt(0)); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the other profile slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - // Make a stable copy of the components as the original list may be modified - List<ResolvedComponentInfo> stableCopy = - createResolvedComponentsForTestWithOtherProfile(3); - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test @Ignore - public void hasLastChosenActivityAndOtherProfile() throws Exception { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3); - ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // The other entry is filtered to the last used slot - assertThat(activity.getAdapter().getCount(), is(2)); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - // Make a stable copy of the components as the original list may be modified - List<ResolvedComponentInfo> stableCopy = - createResolvedComponentsForTestWithOtherProfile(3); - onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(toChoose)); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_ExcludeText() { - Uri uri = createTestContentProviderUri(null, "image/png"); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); - - AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().hasExtra(Intent.EXTRA_TEXT)).isFalse(); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_RemoveAndAddBackText() { - Uri uri = createTestContentProviderUri("application/pdf", "image/png"); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - final String text = "https://google.com/search?q=google"; - sendIntent.putExtra(Intent.EXTRA_TEXT, text); - - List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - onView(withId(R.id.content_preview_text)).check(matches(withText("File only"))); - - onView(withId(R.id.include_text_action)) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.content_preview_text)).check(matches(withText(text))); - - AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); - } - - @Test - @Ignore("b/285309527") - public void testFilePlusTextSharing_TextExclusionDoesNotAffectAlternativeIntent() { - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - Intent alternativeIntent = createSendTextIntent(); - final String text = "alternative intent"; - alternativeIntent.putExtra(Intent.EXTRA_TEXT, text); - - List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - alternativeIntent, PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - AtomicReference<Intent> launchedIntentRef = new AtomicReference<>(); - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - launchedIntentRef.set(targetInfo.getTargetIntent()); - return true; - }; - - onView(withText(resolvedComponentInfos.get(1).getResolveInfoAt(0).activityInfo.name)) - .perform(click()); - waitForIdle(); - assertThat(launchedIntentRef.get().getStringExtra(Intent.EXTRA_TEXT)).isEqualTo(text); - } - - @Test - @Ignore("b/285309527") - public void testImagePlusTextSharing_failedThumbnailAndExcludedText_textChanges() { - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); - sendIntent.putExtra(Intent.EXTRA_TEXT, "https://google.com/search?q=google"); - - List<ResolvedComponentInfo> resolvedComponentInfos = Arrays.asList( - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.imageviewer", "ImageTarget"), - sendIntent, PERSONAL_USER_HANDLE), - ResolverDataProvider.createResolvedComponentInfo( - new ComponentName("org.textviewer", "UriTarget"), - new Intent("VIEW_TEXT"), PERSONAL_USER_HANDLE) - ); - - setupResolverControllers(resolvedComponentInfos); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.include_text_action)) - .check(matches(isDisplayed())) - .perform(click()); - waitForIdle(); - - onView(withId(R.id.image_view)) - .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); - onView(withId(R.id.content_preview_text)) - .check(matches(allOf(isDisplayed(), withText("Image only")))); - } - - @Test - public void copyTextToClipboard() throws Exception { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.copy)).check(matches(isDisplayed())); - onView(withId(R.id.copy)).perform(click()); - ClipboardManager clipboard = (ClipboardManager) activity.getSystemService( - Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboard.getPrimaryClip(); - assertThat("testing intent sending", is(clipData.getItemAt(0).getText())); - - ClipDescription clipDescription = clipData.getDescription(); - assertThat("text/plain", is(clipDescription.getMimeType(0))); - - assertEquals(mActivityRule.getActivityResult().getResultCode(), RESULT_OK); - } - - @Test - public void copyTextToClipboardLogging() { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(R.id.copy)).check(matches(isDisplayed())); - onView(withId(R.id.copy)).perform(click()); - - ChooserActivityLogger logger = activity.getChooserActivityLogger(); - verify(logger, times(1)).logActionSelected(eq(ChooserActivityLogger.SELECTION_TYPE_COPY)); - } - - @Test - @Ignore - public void testNearbyShareLogging() throws Exception { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.chooser_nearby_button)) - .check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.chooser_nearby_button)).perform(click()); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - - - @Test @Ignore - public void testEditImageLogs() { - Uri uri = createTestContentProviderUri("image/png", null); - Intent sendIntent = createSendImageIntent(uri); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.chooser_edit_button)).check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.chooser_edit_button)).perform(click()); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - - @Test - public void oneVisibleImagePreview() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createWideBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getAdapter().getItemCount(), is(1)); - assertThat(recyclerView.getChildCount(), is(1)); - View imageView = recyclerView.getChildAt(0); - Rect rect = new Rect(); - boolean isPartiallyVisible = imageView.getGlobalVisibleRect(rect); - assertThat( - "image preview view is not fully visible", - isPartiallyVisible - && rect.width() == imageView.getWidth() - && rect.height() == imageView.getHeight()); - }); - } - - @Test - public void allThumbnailsFailedToLoad_hidePreview() { - Uri uri = createTestContentProviderUri("image/jpg", null); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - new TestPreviewImageLoader(Collections.emptyMap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); - } - - @Test - public void testManyVisibleImagePreview_ScrollableImagePreview() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.scrollable_image_preview)) - .perform(RecyclerViewActions.scrollToLastPosition()) - .check((view, exception) -> { - if (exception != null) { - throw exception; - } - RecyclerView recyclerView = (RecyclerView) view; - assertThat(recyclerView.getAdapter().getItemCount(), is(uris.size())); - }); - } - - @Test - public void testImageAndTextPreview() { - final Uri uri = createTestContentProviderUri("image/png", null); - final String sharedText = "text-" + System.currentTimeMillis(); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withText(sharedText)) - .check(matches(isDisplayed())); - } - - @Test - public void testTextPreviewWhenTextIsSharedWithMultipleImages() { - final Uri uri = createTestContentProviderUri("image/png", null); - final String sharedText = "text-" + System.currentTimeMillis(); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - sendIntent.putExtra(Intent.EXTRA_TEXT, sharedText); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - Mockito.any(UserHandle.class))) - .thenReturn(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withText(sharedText)).check(matches(isDisplayed())); - } - - @Test - public void testOnCreateLogging() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - ChooserActivityLogger logger = activity.getChooserActivityLogger(); - waitForIdle(); - - verify(logger).logChooserActivityShown(eq(false), eq(TEST_MIME_TYPE), anyLong()); - } - - @Test - public void testOnCreateLoggingFromWorkProfile() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ChooserActivityOverrideData.getInstance().alternateProfileSetting = - MetricsEvent.MANAGED_PROFILE; - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); - ChooserActivityLogger logger = activity.getChooserActivityLogger(); - waitForIdle(); - - verify(logger).logChooserActivityShown(eq(true), eq(TEST_MIME_TYPE), anyLong()); - } - - @Test - public void testEmptyPreviewLogging() { - Intent sendIntent = createSendTextIntentWithPreview(null, null); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity( - Intent.createChooser(sendIntent, "empty preview logger test")); - ChooserActivityLogger logger = activity.getChooserActivityLogger(); - waitForIdle(); - - verify(logger).logChooserActivityShown(eq(false), eq(null), anyLong()); - } - - @Test - public void testTitlePreviewLogging() { - Intent sendIntent = createSendTextIntentWithPreview("TestTitle", null); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // Second invocation is from onCreate - ChooserActivityLogger logger = activity.getChooserActivityLogger(); - Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_TEXT)); - } - - @Test - public void testImagePreviewLogging() { - Uri uri = createTestContentProviderUri("image/png", null); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - ChooserActivityOverrideData.getInstance().imageLoader = - createImageLoader(uri, createBitmap()); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - ChooserActivityLogger logger = activity.getChooserActivityLogger(); - Mockito.verify(logger, times(1)).logActionShareWithPreview(eq(CONTENT_PREVIEW_IMAGE)); - } - - @Test - public void oneVisibleFilePreview() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - - @Test - public void moreThanOneVisibleFilePreview() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 2 more files"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void contentProviderThrowSecurityException() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - ChooserActivityOverrideData.getInstance().resolverForceException = true; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void contentProviderReturnsNoColumns() throws InterruptedException { - Uri uri = Uri.parse("content://com.android.frameworks.coretests/app.pdf"); - - ArrayList<Uri> uris = new ArrayList<>(); - uris.add(uri); - uris.add(uri); - - Intent sendIntent = createSendUriIntentWithPreview(uris); - - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Cursor cursor = mock(Cursor.class); - when(cursor.getCount()).thenReturn(1); - Mockito.doNothing().when(cursor).close(); - when(cursor.moveToFirst()).thenReturn(true); - when(cursor.getColumnIndex(Mockito.anyString())).thenReturn(-1); - - ChooserActivityOverrideData.getInstance().resolverCursor = cursor; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - onView(withId(R.id.content_preview_filename)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_filename)).check(matches(withText("app.pdf"))); - onView(withId(R.id.content_preview_more_files)).check(matches(isDisplayed())); - onView(withId(R.id.content_preview_more_files)).check(matches(withText("+ 1 more file"))); - onView(withId(R.id.content_preview_file_icon)).check(matches(isDisplayed())); - } - - @Test - public void testGetBaseScore() { - final float testBaseScore = 0.89f; - - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getScore(Mockito.isA(DisplayResolveInfo.class))) - .thenReturn(testBaseScore); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - final DisplayResolveInfo testDri = - activity.createTestDisplayResolveInfo( - sendIntent, - ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null); - final ChooserListAdapter adapter = activity.getAdapter(); - - assertThat(adapter.getBaseScore(null, 0), is(CALLER_TARGET_SCORE_BOOST)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_DEFAULT), is(testBaseScore)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_CHOOSER_TARGET), is(testBaseScore)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE), - is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); - assertThat(adapter.getBaseScore(testDri, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER), - is(testBaseScore * SHORTCUT_TARGET_SCORE_BOOST)); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test - public void testDirectTargetSelectionLogging() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor<DisplayResolveInfo[]> appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List<ChooserTarget> serviceTargets = createDirectShareTargets(1, ""); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - ArgumentCaptor<HashedStringCache.HashResult> hashCaptor = - ArgumentCaptor.forClass(HashedStringCache.HashResult.class); - verify(activity.getChooserActivityLogger(), times(1)).logShareTargetSelected( - eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), - /* packageName= */ any(), - /* positionPicked= */ anyInt(), - /* directTargetAlsoRanked= */ eq(-1), - /* numCallerProvided= */ anyInt(), - /* directTargetHashed= */ hashCaptor.capture(), - /* isPinned= */ anyBoolean(), - /* successfullySelected= */ anyBoolean(), - /* selectionCost= */ anyLong()); - String hashedName = hashCaptor.getValue().hashedString; - assertThat( - "Hash is not predictable but must be obfuscated", - hashedName, is(not(name))); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test - public void testDirectTargetLoggingWithRankedAppTarget() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor<DisplayResolveInfo[]> appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List<ChooserTarget> serviceTargets = createDirectShareTargets( - 1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - verify(activity.getChooserActivityLogger(), times(1)).logShareTargetSelected( - eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), - /* packageName= */ any(), - /* positionPicked= */ anyInt(), - /* directTargetAlsoRanked= */ eq(0), - /* numCallerProvided= */ anyInt(), - /* directTargetHashed= */ any(), - /* isPinned= */ anyBoolean(), - /* successfullySelected= */ anyBoolean(), - /* selectionCost= */ anyLong()); - } - - @Test - public void testShortcutTargetWithApplyAppLimits() { - // Set up resources - ChooserActivityOverrideData.getInstance().resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - when( - ChooserActivityOverrideData - .getInstance() - .resources - .getInteger(R.integer.config_maxShortcutTargetsPerApp)) - .thenReturn(1); - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) mActivityRule - .launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor<DisplayResolveInfo[]> appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List<ChooserTarget> serviceTargets = createDirectShareTargets( - 2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly one selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat( - "The display label must match", - activeAdapter.getItem(0).getDisplayLabel(), - is("testTitle0")); - } - - @Test - public void testShortcutTargetWithoutApplyAppLimits() { - setDeviceConfigProperty( - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(false)); - // Set up resources - ChooserActivityOverrideData.getInstance().resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - when( - ChooserActivityOverrideData - .getInstance() - .resources - .getInteger(R.integer.config_maxShortcutTargetsPerApp)) - .thenReturn(1); - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor<DisplayResolveInfo[]> appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List<ChooserTarget> serviceTargets = createDirectShareTargets( - 2, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 4 targets (2 apps, 2 direct)", - activeAdapter.getCount(), - is(4)); - assertThat( - "Chooser should have exactly two selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(2)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activeAdapter.getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - assertThat( - "The display label must match", - activeAdapter.getItem(0).getDisplayLabel(), - is("testTitle0")); - assertThat( - "The display label must match", - activeAdapter.getItem(1).getDisplayLabel(), - is("testTitle1")); - } - - @Test - public void testLaunchWithCallerProvidedTarget() { - setDeviceConfigProperty( - SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, - Boolean.toString(false)); - // Set up resources - ChooserActivityOverrideData.getInstance().resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - when( - ChooserActivityOverrideData - .getInstance() - .resources - .getInteger(R.integer.config_maxShortcutTargetsPerApp)) - .thenReturn(1); - - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos, resolvedComponentInfos); - markWorkProfileUserAvailable(); - - // set caller-provided target - Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); - String callerTargetLabel = "Caller Target"; - ChooserTarget[] targets = new ChooserTarget[] { - new ChooserTarget( - callerTargetLabel, - Icon.createWithBitmap(createBitmap()), - 0.1f, - resolvedComponentInfos.get(0).name, - new Bundle()) - }; - chooserIntent.putExtra(Intent.EXTRA_CHOOSER_TARGETS, targets); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - createShortcutLoaderFactory(); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor<DisplayResolveInfo[]> appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)).updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - ShortcutLoader.Result result = new ShortcutLoader.Result( - true, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[0], - new HashMap<>(), - new HashMap<>()); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - final ChooserListAdapter activeAdapter = activity.getAdapter(); - assertThat( - "Chooser should have 3 targets (2 apps, 1 direct)", - activeAdapter.getCount(), - is(3)); - assertThat( - "Chooser should have exactly two selectable direct target", - activeAdapter.getSelectableServiceTargetCount(), - is(1)); - assertThat( - "The display label must match", - activeAdapter.getItem(0).getDisplayLabel(), - is(callerTargetLabel)); - - // Switch to work profile and ensure that the target *doesn't* show up there. - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - for (int i = 0; i < activity.getWorkListAdapter().getCount(); i++) { - assertThat( - "Chooser target should not show up in opposite profile", - activity.getWorkListAdapter().getItem(i).getDisplayLabel(), - not(callerTargetLabel)); - } - } - - @Test - public void testLaunchWithCustomAction() throws InterruptedException { - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); - final String customActionLabel = "Custom Action"; - final String testAction = "test-broadcast-receiver-action"; - Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); - chooserIntent.putExtra( - Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, - new ChooserAction[] { - new ChooserAction.Builder( - Icon.createWithResource("", Resources.ID_NULL), - customActionLabel, - PendingIntent.getBroadcast( - testContext, - 123, - new Intent(testAction), - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT)) - .build() - }); - // Start activity - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - final CountDownLatch broadcastInvoked = new CountDownLatch(1); - BroadcastReceiver testReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - broadcastInvoked.countDown(); - } - }; - testContext.registerReceiver(testReceiver, new IntentFilter(testAction)); - - try { - onView(withText(customActionLabel)).perform(click()); - broadcastInvoked.await(); - } finally { - testContext.unregisterReceiver(testReceiver); - } - } - - @Test - public void testLaunchWithShareModification() throws InterruptedException { - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - Context testContext = InstrumentationRegistry.getInstrumentation().getContext(); - final String modifyShareAction = "test-broadcast-receiver-action"; - Intent chooserIntent = Intent.createChooser(createSendTextIntent(), null); - String label = "modify share"; - PendingIntent pendingIntent = PendingIntent.getBroadcast( - testContext, - 123, - new Intent(modifyShareAction), - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); - ChooserAction action = new ChooserAction.Builder(Icon.createWithBitmap( - createBitmap()), label, pendingIntent).build(); - chooserIntent.putExtra( - Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, - action); - // Start activity - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - final CountDownLatch broadcastInvoked = new CountDownLatch(1); - BroadcastReceiver testReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - broadcastInvoked.countDown(); - } - }; - testContext.registerReceiver(testReceiver, new IntentFilter(modifyShareAction)); - - try { - onView(withText(label)).perform(click()); - broadcastInvoked.await(); - } finally { - testContext.unregisterReceiver(testReceiver); - } - } - - @Test - public void testUpdateMaxTargetsPerRow_columnCountIsUpdated() throws InterruptedException { - updateMaxTargetsPerRowResource(/* targetsPerRow= */ 4); - givenAppTargets(/* appCount= */ 16); - Intent sendIntent = createSendTextIntent(); - final ChooserActivity activity = - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - - updateMaxTargetsPerRowResource(/* targetsPerRow= */ 6); - InstrumentationRegistry.getInstrumentation() - .runOnMainSync(() -> activity.onConfigurationChanged( - InstrumentationRegistry.getInstrumentation() - .getContext().getResources().getConfiguration())); - - waitForIdle(); - onView(withId(com.android.internal.R.id.resolver_list)) - .check(matches(withGridColumnCount(6))); - } - - // This test is too long and too slow and should not be taken as an example for future tests. - @Test @Ignore - public void testDirectTargetLoggingWithAppTargetNotRankedPortrait() - throws InterruptedException { - testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_PORTRAIT, 4); - } - - @Test @Ignore - public void testDirectTargetLoggingWithAppTargetNotRankedLandscape() - throws InterruptedException { - testDirectTargetLoggingWithAppTargetNotRanked(Configuration.ORIENTATION_LANDSCAPE, 8); - } - - private void testDirectTargetLoggingWithAppTargetNotRanked( - int orientation, int appTargetsExpected) { - Configuration configuration = - new Configuration(InstrumentationRegistry.getInstrumentation().getContext() - .getResources().getConfiguration()); - configuration.orientation = orientation; - - ChooserActivityOverrideData.getInstance().resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - when( - ChooserActivityOverrideData - .getInstance() - .resources - .getConfiguration()) - .thenReturn(configuration); - - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(15); - setupResolverControllers(resolvedComponentInfos); - - // Create direct share target - List<ChooserTarget> serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0, PERSONAL_USER_HANDLE); - - // Start activity - final IChooserWrapper wrapper = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - // Insert the direct share target - Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); - directShareToShortcutInfos.put(serviceTargets.get(0), null); - InstrumentationRegistry.getInstrumentation().runOnMainSync( - () -> wrapper.getAdapter().addServiceResults( - wrapper.createTestDisplayResolveInfo(sendIntent, - ri, - "testLabel", - "testInfo", - sendIntent, - /* resolveInfoPresentationGetter */ null), - serviceTargets, - TARGET_TYPE_CHOOSER_TARGET, - directShareToShortcutInfos, - /* directShareToAppTargets */ null) - ); - - assertThat( - String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)", - appTargetsExpected + 16, appTargetsExpected), - wrapper.getAdapter().getCount(), is(appTargetsExpected + 16)); - assertThat("Chooser should have exactly one selectable direct target", - wrapper.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat("The resolver info must match the resolver info used to create the target", - wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - ChooserActivityLogger logger = wrapper.getChooserActivityLogger(); - verify(logger, times(1)).logShareTargetSelected( - eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), - /* packageName= */ any(), - /* positionPicked= */ anyInt(), - // The packages sholdn't match for app target and direct target: - /* directTargetAlsoRanked= */ eq(-1), - /* numCallerProvided= */ anyInt(), - /* directTargetHashed= */ any(), - /* isPinned= */ anyBoolean(), - /* successfullySelected= */ anyBoolean(), - /* selectionCost= */ anyLong()); - } - - @Test - public void testWorkTab_displayedWhenWorkProfileUserAvailable() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - markWorkProfileUserAvailable(); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - onView(withId(android.R.id.tabs)).check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - onView(withId(android.R.id.tabs)).check(matches(not(isDisplayed()))); - } - - @Test - public void testWorkTab_eachTabUsesExpectedAdapter() { - int personalProfileTargets = 3; - int otherProfileTargets = 1; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile( - personalProfileTargets + otherProfileTargets, /* userID */ 10); - int workProfileTargets = 4; - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest( - workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - markWorkProfileUserAvailable(); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(0)); - onView(withText(R.string.resolver_work_tab)).perform(click()); - assertThat(activity.getCurrentUserHandle().getIdentifier(), is(10)); - assertThat(activity.getPersonalListAdapter().getCount(), is(personalProfileTargets)); - assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); - } - - @Test - public void testWorkTab_workProfileHasExpectedNumberOfTargets() throws InterruptedException { - markWorkProfileUserAvailable(); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); - } - - @Test @Ignore - public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - int workProfileTargets = 4; - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(first(allOf( - withText(workResolvedComponentInfos.get(0) - .getResolveInfoAt(0).activityInfo.applicationInfo.name), - isDisplayed()))) - .perform(click()); - waitForIdle(); - assertThat(chosen[0], is(workResolvedComponentInfos.get(0).getResolveInfoAt(0))); - } - - @Test - public void testWorkTab_crossProfileIntentsDisabled_personalToWork_emptyStateShown() { - markWorkProfileUserAvailable(); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_workProfileDisabled_emptyStateShown() { - markWorkProfileUserAvailable(); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_turn_on_work_apps)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Ignore // b/220067877 - @Test - public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test @Ignore("b/222124533") - public void testAppTargetLogging() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // TODO(b/222124533): other test cases use a timeout to make sure that the UI is fully - // populated; without one, this test flakes. Ideally we should address the need for a - // timeout everywhere instead of introducing one to fix this particular test. - - assertThat(activity.getAdapter().getCount(), is(2)); - onView(withId(com.android.internal.R.id.profile_button)).check(doesNotExist()); - - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); - onView(withText(toChoose.activityInfo.name)) - .perform(click()); - waitForIdle(); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test - public void testDirectTargetLogging() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor<DisplayResolveInfo[]> appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)) - .updateAppTargets(appTargets.capture()); - - // send shortcuts - assertThat( - "Wrong number of app targets", - appTargets.getValue().length, - is(resolvedComponentInfos.size())); - List<ChooserTarget> serviceTargets = createDirectShareTargets(1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - // TODO: test another value as well - false, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - assertThat("Chooser should have 3 targets (2 apps, 1 direct)", - activity.getAdapter().getCount(), is(3)); - assertThat("Chooser should have exactly one selectable direct target", - activity.getAdapter().getSelectableServiceTargetCount(), is(1)); - assertThat( - "The resolver info must match the resolver info used to create the target", - activity.getAdapter().getItem(0).getResolveInfo(), - is(resolvedComponentInfos.get(0).getResolveInfoAt(0))); - - // Click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)) - .perform(click()); - waitForIdle(); - - ChooserActivityLogger logger = activity.getChooserActivityLogger(); - ArgumentCaptor<Integer> typeCaptor = ArgumentCaptor.forClass(Integer.class); - verify(logger, times(1)).logShareTargetSelected( - eq(ChooserActivityLogger.SELECTION_TYPE_SERVICE), - /* packageName= */ any(), - /* positionPicked= */ anyInt(), - /* directTargetAlsoRanked= */ anyInt(), - /* numCallerProvided= */ anyInt(), - /* directTargetHashed= */ any(), - /* isPinned= */ anyBoolean(), - /* successfullySelected= */ anyBoolean(), - /* selectionCost= */ anyLong()); - } - - @Test - public void testDirectTargetPinningDialog() { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // create test shortcut loader factory, remember loaders and their callbacks - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - // verify that ShortcutLoader was queried - ArgumentCaptor<DisplayResolveInfo[]> appTargets = - ArgumentCaptor.forClass(DisplayResolveInfo[].class); - verify(shortcutLoaders.get(0).first, times(1)) - .updateAppTargets(appTargets.capture()); - - // send shortcuts - List<ChooserTarget> serviceTargets = createDirectShareTargets( - 1, - resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); - ShortcutLoader.Result result = new ShortcutLoader.Result( - // TODO: test another value as well - false, - appTargets.getValue(), - new ShortcutLoader.ShortcutResultInfo[] { - new ShortcutLoader.ShortcutResultInfo( - appTargets.getValue()[0], - serviceTargets - ) - }, - new HashMap<>(), - new HashMap<>() - ); - activity.getMainExecutor().execute(() -> shortcutLoaders.get(0).second.accept(result)); - waitForIdle(); - - // Long-click on the direct target - String name = serviceTargets.get(0).getTitle().toString(); - onView(withText(name)).perform(longClick()); - waitForIdle(); - - onView(withId(R.id.chooser_dialog_content)).check(matches(isDisplayed())); - } - - @Test @Ignore - public void testEmptyDirectRowLogging() throws InterruptedException { - Intent sendIntent = createSendTextIntent(); - // We need app targets for direct targets to get displayed - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - setupResolverControllers(resolvedComponentInfos); - - // Start activity - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - - // Thread.sleep shouldn't be a thing in an integration test but it's - // necessary here because of the way the code is structured - Thread.sleep(3000); - - assertThat("Chooser should have 2 app targets", - activity.getAdapter().getCount(), is(2)); - assertThat("Chooser should have no direct targets", - activity.getAdapter().getSelectableServiceTargetCount(), is(0)); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Ignore // b/220067877 - @Test - public void testCopyTextToClipboardLogging() throws Exception { - Intent sendIntent = createSendTextIntent(); - List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); - - setupResolverControllers(resolvedComponentInfos); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); - waitForIdle(); - - onView(withId(com.android.internal.R.id.chooser_copy_button)).check(matches(isDisplayed())); - onView(withId(com.android.internal.R.id.chooser_copy_button)).perform(click()); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test @Ignore("b/222124533") - public void testSwitchProfileLogging() throws InterruptedException { - markWorkProfileUserAvailable(); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withText(R.string.resolver_personal_tab)).perform(click()); - waitForIdle(); - - // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. - } - - @Test - public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_doesNotAutoLaunch() { - markWorkProfileUserAvailable(); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); - waitForIdle(); - - assertNull(chosen[0]); - } - - @Test - public void testOneInitialIntent_noAutolaunch() { - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(1); - setupResolverControllers(personalResolvedComponentInfos); - Intent chooserIntent = createChooserIntent(createSendTextIntent(), - new Intent[] {new Intent("action.fake")}); - ResolveInfo[] chosen = new ResolveInfo[1]; - ChooserActivityOverrideData.getInstance().onSafelyStartInternalCallback = targetInfo -> { - chosen[0] = targetInfo.getResolveInfo(); - return true; - }; - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - ResolveInfo ri = createFakeResolveInfo(); - when( - ChooserActivityOverrideData - .getInstance().packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(ri); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - assertNull(chosen[0]); - assertThat(activity - .getPersonalListAdapter().getCallerTargetCount(), is(1)); - } - - @Test - public void testWorkTab_withInitialIntents_workTabDoesNotIncludePersonalInitialIntents() { - markWorkProfileUserAvailable(); - int workProfileTargets = 1; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(createSendTextIntent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - assertThat(activity.getPersonalListAdapter().getCallerTargetCount(), is(2)); - assertThat(activity.getWorkListAdapter().getCallerTargetCount(), is(0)); - } - - @Test - public void testWorkTab_xProfileIntentsDisabled_personalToWork_nonSendIntent_emptyStateShown() { - markWorkProfileUserAvailable(); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets); - ChooserActivityOverrideData.getInstance().hasCrossProfileIntents = false; - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); - - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - } - - @Test - public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(0); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent[] initialIntents = { - new Intent("action.fake1"), - new Intent("action.fake2") - }; - Intent chooserIntent = createChooserIntent(new Intent(), initialIntents); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(createFakeResolveInfo()); - - mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - onView(withText(R.string.resolver_no_work_apps_available)) - .check(matches(isDisplayed())); - } - - @Test - public void testDeduplicateCallerTargetRankedTarget() { - // Create 4 ranked app targets. - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(4); - setupResolverControllers(personalResolvedComponentInfos); - // Create caller target which is duplicate with one of app targets - Intent chooserIntent = createChooserIntent(createSendTextIntent(), - new Intent[] {new Intent("action.fake")}); - ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); - ResolveInfo ri = ResolverDataProvider.createResolveInfo(0, - UserHandle.USER_CURRENT, PERSONAL_USER_HANDLE); - when( - ChooserActivityOverrideData - .getInstance() - .packageManager - .resolveActivity(any(Intent.class), any())) - .thenReturn(ri); - waitForIdle(); - - IChooserWrapper activity = (IChooserWrapper) mActivityRule.launchActivity(chooserIntent); - waitForIdle(); - - // Total 4 targets (1 caller target, 3 ranked targets) - assertThat(activity.getAdapter().getCount(), is(4)); - assertThat(activity.getAdapter().getCallerTargetCount(), is(1)); - assertThat(activity.getAdapter().getRankedTargetCount(), is(3)); - } - - @Test - public void test_query_shortcut_loader_for_the_selected_tab() { - markWorkProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(3); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - ShortcutLoader personalProfileShortcutLoader = mock(ShortcutLoader.class); - ShortcutLoader workProfileShortcutLoader = mock(ShortcutLoader.class); - final SparseArray<ShortcutLoader> shortcutLoaders = new SparseArray<>(); - shortcutLoaders.put(0, personalProfileShortcutLoader); - shortcutLoaders.put(10, workProfileShortcutLoader); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> shortcutLoaders.get(userHandle.getIdentifier(), null); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); - waitForIdle(); - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - waitForIdle(); - - verify(personalProfileShortcutLoader, times(1)).updateAppTargets(any()); - - onView(withText(R.string.resolver_work_tab)).perform(click()); - waitForIdle(); - - verify(workProfileShortcutLoader, times(1)).updateAppTargets(any()); - } - - @Test - public void testClonedProfilePresent_personalAdapterIsSetWithPersonalProfile() { - // enable cloneProfile - markCloneProfileUserAvailable(); - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsWithCloneProfileForTest( - 3, - PERSONAL_USER_HANDLE, - ChooserActivityOverrideData.getInstance().cloneProfileUserHandle); - setupResolverControllers(resolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - - final IChooserWrapper activity = (IChooserWrapper) mActivityRule - .launchActivity(Intent.createChooser(sendIntent, "personalProfileTest")); - waitForIdle(); - - assertThat(activity.getPersonalListAdapter().getUserHandle(), is(PERSONAL_USER_HANDLE)); - assertThat(activity.getAdapter().getCount(), is(3)); - } - - @Test - public void testClonedProfilePresent_personalTabUsesExpectedAdapter() { - markWorkProfileUserAvailable(); - markCloneProfileUserAvailable(); - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTest(3); - List<ResolvedComponentInfo> workResolvedComponentInfos = createResolvedComponentsForTest( - 4); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - Intent sendIntent = createSendTextIntent(); - sendIntent.setType(TEST_MIME_TYPE); - - - final IChooserWrapper activity = (IChooserWrapper) - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "multi tab test")); - waitForIdle(); - - assertThat(activity.getCurrentUserHandle(), is(PERSONAL_USER_HANDLE)); - } - - private Intent createChooserIntent(Intent intent, Intent[] initialIntents) { - Intent chooserIntent = new Intent(); - chooserIntent.setAction(Intent.ACTION_CHOOSER); - chooserIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - chooserIntent.putExtra(Intent.EXTRA_TITLE, "some title"); - chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); - chooserIntent.setType("text/plain"); - if (initialIntents != null) { - chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, initialIntents); - } - return chooserIntent; - } - - /* This is a "test of a test" to make sure that our inherited test class - * is successfully configured to operate on the unbundled-equivalent - * ChooserWrapperActivity. - * - * TODO: remove after unbundling is complete. - */ - @Test - public void testWrapperActivityHasExpectedConcreteType() { - final ChooserActivity activity = mActivityRule.launchActivity( - Intent.createChooser(new Intent("ACTION_FOO"), "foo")); - waitForIdle(); - assertThat(activity).isInstanceOf(com.android.intentresolver.ChooserWrapperActivity.class); - } - - private ResolveInfo createFakeResolveInfo() { - ResolveInfo ri = new ResolveInfo(); - ri.activityInfo = new ActivityInfo(); - ri.activityInfo.name = "FakeActivityName"; - ri.activityInfo.packageName = "fake.package.name"; - ri.activityInfo.applicationInfo = new ApplicationInfo(); - ri.activityInfo.applicationInfo.packageName = "fake.package.name"; - ri.userHandle = UserHandle.CURRENT; - return ri; - } - - private Intent createSendTextIntent() { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.setType("text/plain"); - return sendIntent; - } - - private Intent createSendImageIntent(Uri imageThumbnail) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_STREAM, imageThumbnail); - sendIntent.setType("image/png"); - if (imageThumbnail != null) { - ClipData.Item clipItem = new ClipData.Item(imageThumbnail); - sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); - } - - return sendIntent; - } - - private Uri createTestContentProviderUri( - @Nullable String mimeType, @Nullable String streamType) { - String packageName = - InstrumentationRegistry.getInstrumentation().getContext().getPackageName(); - Uri.Builder builder = Uri.parse("content://" + packageName + "/image.png") - .buildUpon(); - if (mimeType != null) { - builder.appendQueryParameter("mimeType", mimeType); - } - if (streamType != null) { - builder.appendQueryParameter("streamType", streamType); - } - return builder.build(); - } - - private Intent createSendTextIntentWithPreview(String title, Uri imageThumbnail) { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.putExtra(Intent.EXTRA_TITLE, title); - if (imageThumbnail != null) { - ClipData.Item clipItem = new ClipData.Item(imageThumbnail); - sendIntent.setClipData(new ClipData("Clip Label", new String[]{"image/png"}, clipItem)); - } - - return sendIntent; - } - - private Intent createSendUriIntentWithPreview(ArrayList<Uri> uris) { - Intent sendIntent = new Intent(); - - if (uris.size() > 1) { - sendIntent.setAction(Intent.ACTION_SEND_MULTIPLE); - sendIntent.putExtra(Intent.EXTRA_STREAM, uris); - } else { - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_STREAM, uris.get(0)); - } - - return sendIntent; - } - - private Intent createViewTextIntent() { - Intent viewIntent = new Intent(); - viewIntent.setAction(Intent.ACTION_VIEW); - viewIntent.putExtra(Intent.EXTRA_TEXT, "testing intent viewing"); - return viewIntent; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, PERSONAL_USER_HANDLE)); - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsWithCloneProfileForTest( - int numberOfResults, - UserHandle resolvedForPersonalUser, - UserHandle resolvedForClonedUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < 1; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForPersonalUser)); - } - for (int i = 1; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - resolvedForClonedUser)); - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( - int numberOfResults) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, - PERSONAL_USER_HANDLE)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - PERSONAL_USER_HANDLE)); - } - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, int userId) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - if (i == 0) { - infoList.add( - ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, - PERSONAL_USER_HANDLE)); - } else { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, - PERSONAL_USER_HANDLE)); - } - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTestWithUserId( - int numberOfResults, int userId) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId, - PERSONAL_USER_HANDLE)); - } - return infoList; - } - - private List<ChooserTarget> createDirectShareTargets(int numberOfResults, String packageName) { - Icon icon = Icon.createWithBitmap(createBitmap()); - String testTitle = "testTitle"; - List<ChooserTarget> targets = new ArrayList<>(); - for (int i = 0; i < numberOfResults; i++) { - ComponentName componentName; - if (packageName.isEmpty()) { - componentName = ResolverDataProvider.createComponentName(i); - } else { - componentName = new ComponentName(packageName, packageName + ".class"); - } - ChooserTarget tempTarget = new ChooserTarget( - testTitle + i, - icon, - (float) (1 - ((i + 1) / 10.0)), - componentName, - null); - targets.add(tempTarget); - } - return targets; - } - - private void waitForIdle() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private Bitmap createBitmap() { - return createBitmap(200, 200); - } - - private Bitmap createWideBitmap() { - WindowManager windowManager = InstrumentationRegistry.getInstrumentation() - .getTargetContext() - .getSystemService(WindowManager.class); - int width = 3000; - if (windowManager != null) { - Rect bounds = windowManager.getMaximumWindowMetrics().getBounds(); - width = bounds.width() + 200; - } - return createBitmap(width, 100); - } - - private Bitmap createBitmap(int width, int height) { - Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - - Paint paint = new Paint(); - paint.setColor(Color.RED); - paint.setStyle(Paint.Style.FILL); - canvas.drawPaint(paint); - - paint.setColor(Color.WHITE); - paint.setAntiAlias(true); - paint.setTextSize(14.f); - paint.setTextAlign(Paint.Align.CENTER); - canvas.drawText("Hi!", (width / 2.f), (height / 2.f), paint); - - return bitmap; - } - - private List<ShareShortcutInfo> createShortcuts(Context context) { - Intent testIntent = new Intent("TestIntent"); - - List<ShareShortcutInfo> shortcuts = new ArrayList<>(); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut1") - .setIntent(testIntent).setShortLabel("label1").setRank(3).build(), // 0 2 - new ComponentName("package1", "class1"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut2") - .setIntent(testIntent).setShortLabel("label2").setRank(7).build(), // 1 3 - new ComponentName("package2", "class2"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut3") - .setIntent(testIntent).setShortLabel("label3").setRank(1).build(), // 2 0 - new ComponentName("package3", "class3"))); - shortcuts.add(new ShareShortcutInfo( - new ShortcutInfo.Builder(context, "shortcut4") - .setIntent(testIntent).setShortLabel("label4").setRank(3).build(), // 3 2 - new ComponentName("package4", "class4"))); - - return shortcuts; - } - - private void markWorkProfileUserAvailable() { - ChooserActivityOverrideData.getInstance().workProfileUserHandle = UserHandle.of(10); - } - - private void markCloneProfileUserAvailable() { - ChooserActivityOverrideData.getInstance().cloneProfileUserHandle = UserHandle.of(11); - } - - private void setupResolverControllers( - List<ResolvedComponentInfo> personalResolvedComponentInfos) { - setupResolverControllers(personalResolvedComponentInfos, new ArrayList<>()); - } - - private void setupResolverControllers( - List<ResolvedComponentInfo> personalResolvedComponentInfos, - List<ResolvedComponentInfo> workResolvedComponentInfos) { - when( - ChooserActivityOverrideData - .getInstance() - .resolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when( - ChooserActivityOverrideData - .getInstance() - .workResolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when( - ChooserActivityOverrideData - .getInstance() - .workResolverListController - .getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.of(10)))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); - } - - private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { - return new GridRecyclerSpanCountMatcher(Matchers.is(columnCount)); - } - - private static class GridRecyclerSpanCountMatcher extends - BoundedDiagnosingMatcher<View, RecyclerView> { - - private final Matcher<Integer> mIntegerMatcher; - - private GridRecyclerSpanCountMatcher(Matcher<Integer> integerMatcher) { - super(RecyclerView.class); - this.mIntegerMatcher = integerMatcher; - } - - @Override - protected void describeMoreTo(Description description) { - description.appendText("RecyclerView grid layout span count to match: "); - this.mIntegerMatcher.describeTo(description); - } - - @Override - protected boolean matchesSafely(RecyclerView view, Description mismatchDescription) { - int spanCount = ((GridLayoutManager) view.getLayoutManager()).getSpanCount(); - if (this.mIntegerMatcher.matches(spanCount)) { - return true; - } else { - mismatchDescription.appendText("RecyclerView grid layout span count was ") - .appendValue(spanCount); - return false; - } - } - } - - private void givenAppTargets(int appCount) { - List<ResolvedComponentInfo> resolvedComponentInfos = - createResolvedComponentsForTest(appCount); - setupResolverControllers(resolvedComponentInfos); - } - - private void updateMaxTargetsPerRowResource(int targetsPerRow) { - ChooserActivityOverrideData.getInstance().resources = Mockito.spy( - InstrumentationRegistry.getInstrumentation().getContext().getResources()); - when( - ChooserActivityOverrideData - .getInstance() - .resources - .getInteger(R.integer.config_chooser_max_targets_per_row)) - .thenReturn(targetsPerRow); - } - - private SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> - createShortcutLoaderFactory() { - SparseArray<Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>>> shortcutLoaders = - new SparseArray<>(); - ChooserActivityOverrideData.getInstance().shortcutLoaderFactory = - (userHandle, callback) -> { - Pair<ShortcutLoader, Consumer<ShortcutLoader.Result>> pair = - new Pair<>(mock(ShortcutLoader.class), callback); - shortcutLoaders.put(userHandle.getIdentifier(), pair); - return pair.first; - }; - return shortcutLoaders; - } - - private static ImageLoader createImageLoader(Uri uri, Bitmap bitmap) { - return new TestPreviewImageLoader(Collections.singletonMap(uri, bitmap)); - } -} diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java deleted file mode 100644 index 92bccb7d..00000000 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityWorkProfileTest.java +++ /dev/null @@ -1,473 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver; - -import static android.testing.PollingCheck.waitFor; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.swipeUp; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isSelected; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; - -import static com.android.intentresolver.ChooserWrapperActivity.sOverrides; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.NO_BLOCKER; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_ACCESS_BLOCKER; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.PERSONAL_PROFILE_SHARE_BLOCKER; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_ACCESS_BLOCKER; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.ExpectedBlocker.WORK_PROFILE_SHARE_BLOCKER; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.PERSONAL; -import static com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab.WORK; - -import static org.hamcrest.CoreMatchers.not; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - -import android.companion.DeviceFilter; -import android.content.Intent; -import android.os.UserHandle; - -import androidx.test.InstrumentationRegistry; -import androidx.test.espresso.NoMatchingViewException; -import androidx.test.rule.ActivityTestRule; - -import com.android.intentresolver.UnbundledChooserActivityWorkProfileTest.TestCase.Tab; - -import junit.framework.AssertionFailedError; - -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.mockito.Mockito; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; - -@DeviceFilter.MediumType -@RunWith(Parameterized.class) -public class UnbundledChooserActivityWorkProfileTest { - - private static final UserHandle PERSONAL_USER_HANDLE = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser(); - private static final UserHandle WORK_USER_HANDLE = UserHandle.of(10); - - @Rule - public ActivityTestRule<ChooserWrapperActivity> mActivityRule = - new ActivityTestRule<>(ChooserWrapperActivity.class, false, - false); - private final TestCase mTestCase; - - public UnbundledChooserActivityWorkProfileTest(TestCase testCase) { - mTestCase = testCase; - } - - @Before - public void cleanOverrideData() { - // TODO: use the other form of `adoptShellPermissionIdentity()` where we explicitly list the - // permissions we require (which we'll read from the manifest at runtime). - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() - .adoptShellPermissionIdentity(); - - sOverrides.reset(); - } - - @Test - public void testBlocker() { - setUpPersonalAndWorkComponentInfos(); - sOverrides.hasCrossProfileIntents = mTestCase.hasCrossProfileIntents(); - sOverrides.tabOwnerUserHandleForLaunch = mTestCase.getMyUserHandle(); - - launchActivity(mTestCase.getIsSendAction()); - switchToTab(mTestCase.getTab()); - - switch (mTestCase.getExpectedBlocker()) { - case NO_BLOCKER: - assertNoBlockerDisplayed(); - break; - case PERSONAL_PROFILE_SHARE_BLOCKER: - assertCantSharePersonalAppsBlockerDisplayed(); - break; - case WORK_PROFILE_SHARE_BLOCKER: - assertCantShareWorkAppsBlockerDisplayed(); - break; - case PERSONAL_PROFILE_ACCESS_BLOCKER: - assertCantAccessPersonalAppsBlockerDisplayed(); - break; - case WORK_PROFILE_ACCESS_BLOCKER: - assertCantAccessWorkAppsBlockerDisplayed(); - break; - } - } - - @Parameterized.Parameters(name = "{0}") - public static Collection tests() { - return Arrays.asList( - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ WORK_PROFILE_SHARE_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ PERSONAL_PROFILE_SHARE_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ true, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ WORK, - /* expectedBlocker= */ WORK_PROFILE_ACCESS_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ WORK_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ PERSONAL_PROFILE_ACCESS_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ true, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ), - new TestCase( - /* isSendAction= */ false, - /* hasCrossProfileIntents= */ false, - /* myUserHandle= */ PERSONAL_USER_HANDLE, - /* tab= */ PERSONAL, - /* expectedBlocker= */ NO_BLOCKER - ) - ); - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( - int numberOfResults, int userId, UserHandle resolvedForUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add( - ResolverDataProvider - .createResolvedComponentInfoWithOtherId(i, userId, resolvedForUser)); - } - return infoList; - } - - private List<ResolvedComponentInfo> createResolvedComponentsForTest(int numberOfResults, - UserHandle resolvedForUser) { - List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); - for (int i = 0; i < numberOfResults; i++) { - infoList.add(ResolverDataProvider.createResolvedComponentInfo(i, resolvedForUser)); - } - return infoList; - } - - private void setUpPersonalAndWorkComponentInfos() { - markWorkProfileUserAvailable(); - int workProfileTargets = 4; - List<ResolvedComponentInfo> personalResolvedComponentInfos = - createResolvedComponentsForTestWithOtherProfile(3, - /* userId */ WORK_USER_HANDLE.getIdentifier(), PERSONAL_USER_HANDLE); - List<ResolvedComponentInfo> workResolvedComponentInfos = - createResolvedComponentsForTest(workProfileTargets, WORK_USER_HANDLE); - setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); - } - - private void setupResolverControllers( - List<ResolvedComponentInfo> personalResolvedComponentInfos, - List<ResolvedComponentInfo> workResolvedComponentInfos) { - when(sOverrides.resolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(UserHandle.SYSTEM))) - .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); - when(sOverrides.workResolverListController.getResolversForIntentAsUser( - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.anyBoolean(), - Mockito.isA(List.class), - eq(WORK_USER_HANDLE))) - .thenReturn(new ArrayList<>(workResolvedComponentInfos)); - } - - private void waitForIdle() { - InstrumentationRegistry.getInstrumentation().waitForIdleSync(); - } - - private void markWorkProfileUserAvailable() { - ChooserWrapperActivity.sOverrides.workProfileUserHandle = WORK_USER_HANDLE; - } - - private void assertCantAccessWorkAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_access_work_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertCantAccessPersonalAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_access_personal_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertCantShareWorkAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_share_with_work_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertCantSharePersonalAppsBlockerDisplayed() { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(isDisplayed())); - onView(withText(R.string.resolver_cant_share_with_personal_apps_explanation)) - .check(matches(isDisplayed())); - } - - private void assertNoBlockerDisplayed() { - try { - onView(withText(R.string.resolver_cross_profile_blocked)) - .check(matches(not(isDisplayed()))); - } catch (NoMatchingViewException ignored) { - } - } - - private void switchToTab(Tab tab) { - final int stringId = tab == Tab.WORK ? R.string.resolver_work_tab - : R.string.resolver_personal_tab; - - waitFor(() -> { - onView(withText(stringId)).perform(click()); - waitForIdle(); - - try { - onView(withText(stringId)).check(matches(isSelected())); - return true; - } catch (AssertionFailedError e) { - return false; - } - }); - - onView(withId(com.android.internal.R.id.contentPanel)) - .perform(swipeUp()); - waitForIdle(); - } - - private Intent createTextIntent(boolean isSendAction) { - Intent sendIntent = new Intent(); - if (isSendAction) { - sendIntent.setAction(Intent.ACTION_SEND); - } - sendIntent.putExtra(Intent.EXTRA_TEXT, "testing intent sending"); - sendIntent.setType("text/plain"); - return sendIntent; - } - - private void launchActivity(boolean isSendAction) { - Intent sendIntent = createTextIntent(isSendAction); - mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Test")); - waitForIdle(); - } - - public static class TestCase { - private final boolean mIsSendAction; - private final boolean mHasCrossProfileIntents; - private final UserHandle mMyUserHandle; - private final Tab mTab; - private final ExpectedBlocker mExpectedBlocker; - - public enum ExpectedBlocker { - NO_BLOCKER, - PERSONAL_PROFILE_SHARE_BLOCKER, - WORK_PROFILE_SHARE_BLOCKER, - PERSONAL_PROFILE_ACCESS_BLOCKER, - WORK_PROFILE_ACCESS_BLOCKER - } - - public enum Tab { - WORK, - PERSONAL - } - - public TestCase(boolean isSendAction, boolean hasCrossProfileIntents, - UserHandle myUserHandle, Tab tab, ExpectedBlocker expectedBlocker) { - mIsSendAction = isSendAction; - mHasCrossProfileIntents = hasCrossProfileIntents; - mMyUserHandle = myUserHandle; - mTab = tab; - mExpectedBlocker = expectedBlocker; - } - - public boolean getIsSendAction() { - return mIsSendAction; - } - - public boolean hasCrossProfileIntents() { - return mHasCrossProfileIntents; - } - - public UserHandle getMyUserHandle() { - return mMyUserHandle; - } - - public Tab getTab() { - return mTab; - } - - public ExpectedBlocker getExpectedBlocker() { - return mExpectedBlocker; - } - - @Override - public String toString() { - StringBuilder result = new StringBuilder("test"); - - if (mTab == WORK) { - result.append("WorkTab_"); - } else { - result.append("PersonalTab_"); - } - - if (mIsSendAction) { - result.append("sendAction_"); - } else { - result.append("notSendAction_"); - } - - if (mHasCrossProfileIntents) { - result.append("hasCrossProfileIntents_"); - } else { - result.append("doesNotHaveCrossProfileIntents_"); - } - - if (mMyUserHandle.equals(PERSONAL_USER_HANDLE)) { - result.append("myUserIsPersonal_"); - } else { - result.append("myUserIsWork_"); - } - - if (mExpectedBlocker == ExpectedBlocker.NO_BLOCKER) { - result.append("thenNoBlocker"); - } else if (mExpectedBlocker == PERSONAL_PROFILE_ACCESS_BLOCKER) { - result.append("thenAccessBlockerOnPersonalProfile"); - } else if (mExpectedBlocker == PERSONAL_PROFILE_SHARE_BLOCKER) { - result.append("thenShareBlockerOnPersonalProfile"); - } else if (mExpectedBlocker == WORK_PROFILE_ACCESS_BLOCKER) { - result.append("thenAccessBlockerOnWorkProfile"); - } else if (mExpectedBlocker == WORK_PROFILE_SHARE_BLOCKER) { - result.append("thenShareBlockerOnWorkProfile"); - } - - return result.toString(); - } - } -} diff --git a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt deleted file mode 100644 index f3ca76a9..00000000 --- a/java/tests/src/com/android/intentresolver/chooser/ImmutableTargetInfoTest.kt +++ /dev/null @@ -1,503 +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 - *3 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.chooser - -import android.app.Activity -import android.app.prediction.AppTarget -import android.app.prediction.AppTargetId -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ResolveInfo -import android.os.Bundle -import android.os.UserHandle -import com.android.intentresolver.createShortcutInfo -import com.android.intentresolver.mock -import com.android.intentresolver.ResolverActivity -import com.android.intentresolver.ResolverDataProvider -import com.google.common.truth.Truth.assertThat -import org.junit.Test -import androidx.test.platform.app.InstrumentationRegistry - -class ImmutableTargetInfoTest { - private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser() - - private val resolvedIntent = Intent("resolved") - private val targetIntent = Intent("target") - private val referrerFillInIntent = Intent("referrer_fillin") - private val resolvedComponentName = ComponentName("resolved", "component") - private val chooserTargetComponentName = ComponentName("chooser", "target") - private val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0, PERSONAL_USER_HANDLE) - private val displayLabel: CharSequence = "Display Label" - private val extendedInfo: CharSequence = "Extended Info" - private val displayIconHolder: TargetInfo.IconHolder = mock() - private val sourceIntent1 = Intent("source1") - private val sourceIntent2 = Intent("source2") - private val displayTarget1 = DisplayResolveInfo.newDisplayResolveInfo( - Intent("display1"), - ResolverDataProvider.createResolveInfo(2, 0, PERSONAL_USER_HANDLE), - "display1 label", - "display1 extended info", - Intent("display1_resolved"), - /* resolveInfoPresentationGetter= */ null) - private val displayTarget2 = DisplayResolveInfo.newDisplayResolveInfo( - Intent("display2"), - ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE), - "display2 label", - "display2 extended info", - Intent("display2_resolved"), - /* resolveInfoPresentationGetter= */ null) - private val directShareShortcutInfo = createShortcutInfo( - "shortcutid", ResolverDataProvider.createComponentName(4), 4) - private val directShareAppTarget = AppTarget( - AppTargetId("apptargetid"), - "test.directshare", - "target", - UserHandle.CURRENT) - private val displayResolveInfo = DisplayResolveInfo.newDisplayResolveInfo( - Intent("displayresolve"), - ResolverDataProvider.createResolveInfo(5, 0, PERSONAL_USER_HANDLE), - "displayresolve label", - "displayresolve extended info", - Intent("display_resolved"), - /* resolveInfoPresentationGetter= */ null) - private val hashProvider: ImmutableTargetInfo.TargetHashProvider = mock() - - @Test - fun testBasicProperties() { // Fields that are reflected back w/o logic. - // TODO: we could consider passing copies of all the values into the builder so that we can - // verify that they're not mutated (e.g. no extras added to the intents). For now that - // should be obvious from the implementation. - val info = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(resolvedIntent) - .setTargetIntent(targetIntent) - .setReferrerFillInIntent(referrerFillInIntent) - .setResolvedComponentName(resolvedComponentName) - .setChooserTargetComponentName(chooserTargetComponentName) - .setResolveInfo(resolveInfo) - .setDisplayLabel(displayLabel) - .setExtendedInfo(extendedInfo) - .setDisplayIconHolder(displayIconHolder) - .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2)) - .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) - .setIsSuspended(true) - .setIsPinned(true) - .setModifiedScore(42.0f) - .setDirectShareShortcutInfo(directShareShortcutInfo) - .setDirectShareAppTarget(directShareAppTarget) - .setDisplayResolveInfo(displayResolveInfo) - .setHashProvider(hashProvider) - .build() - - assertThat(info.resolvedIntent).isEqualTo(resolvedIntent) - assertThat(info.targetIntent).isEqualTo(targetIntent) - assertThat(info.referrerFillInIntent).isEqualTo(referrerFillInIntent) - assertThat(info.resolvedComponentName).isEqualTo(resolvedComponentName) - assertThat(info.chooserTargetComponentName).isEqualTo(chooserTargetComponentName) - assertThat(info.resolveInfo).isEqualTo(resolveInfo) - assertThat(info.displayLabel).isEqualTo(displayLabel) - assertThat(info.extendedInfo).isEqualTo(extendedInfo) - assertThat(info.displayIconHolder).isEqualTo(displayIconHolder) - assertThat(info.allSourceIntents).containsExactly( - resolvedIntent, sourceIntent1, sourceIntent2) - assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2) - assertThat(info.isSuspended).isTrue() - assertThat(info.isPinned).isTrue() - assertThat(info.modifiedScore).isEqualTo(42.0f) - assertThat(info.directShareShortcutInfo).isEqualTo(directShareShortcutInfo) - assertThat(info.directShareAppTarget).isEqualTo(directShareAppTarget) - assertThat(info.displayResolveInfo).isEqualTo(displayResolveInfo) - assertThat(info.isEmptyTargetInfo).isFalse() - assertThat(info.isPlaceHolderTargetInfo).isFalse() - assertThat(info.isNotSelectableTargetInfo).isFalse() - assertThat(info.isSelectableTargetInfo).isFalse() - assertThat(info.isChooserTargetInfo).isFalse() - assertThat(info.isMultiDisplayResolveInfo).isFalse() - assertThat(info.isDisplayResolveInfo).isFalse() - assertThat(info.hashProvider).isEqualTo(hashProvider) - } - - @Test - fun testToBuilderPreservesBasicProperties() { - // Note this is set up exactly as in `testBasicProperties`, but the assertions will be made - // against a *copy* of the object instead. - val infoToCopyFrom = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(resolvedIntent) - .setTargetIntent(targetIntent) - .setReferrerFillInIntent(referrerFillInIntent) - .setResolvedComponentName(resolvedComponentName) - .setChooserTargetComponentName(chooserTargetComponentName) - .setResolveInfo(resolveInfo) - .setDisplayLabel(displayLabel) - .setExtendedInfo(extendedInfo) - .setDisplayIconHolder(displayIconHolder) - .setAlternateSourceIntents(listOf(sourceIntent1, sourceIntent2)) - .setAllDisplayTargets(listOf(displayTarget1, displayTarget2)) - .setIsSuspended(true) - .setIsPinned(true) - .setModifiedScore(42.0f) - .setDirectShareShortcutInfo(directShareShortcutInfo) - .setDirectShareAppTarget(directShareAppTarget) - .setDisplayResolveInfo(displayResolveInfo) - .setHashProvider(hashProvider) - .build() - - val info = infoToCopyFrom.toBuilder().build() - - assertThat(info.resolvedIntent).isEqualTo(resolvedIntent) - assertThat(info.targetIntent).isEqualTo(targetIntent) - assertThat(info.referrerFillInIntent).isEqualTo(referrerFillInIntent) - assertThat(info.resolvedComponentName).isEqualTo(resolvedComponentName) - assertThat(info.chooserTargetComponentName).isEqualTo(chooserTargetComponentName) - assertThat(info.resolveInfo).isEqualTo(resolveInfo) - assertThat(info.displayLabel).isEqualTo(displayLabel) - assertThat(info.extendedInfo).isEqualTo(extendedInfo) - assertThat(info.displayIconHolder).isEqualTo(displayIconHolder) - assertThat(info.allSourceIntents).containsExactly( - resolvedIntent, sourceIntent1, sourceIntent2) - assertThat(info.allDisplayTargets).containsExactly(displayTarget1, displayTarget2) - assertThat(info.isSuspended).isTrue() - assertThat(info.isPinned).isTrue() - assertThat(info.modifiedScore).isEqualTo(42.0f) - assertThat(info.directShareShortcutInfo).isEqualTo(directShareShortcutInfo) - assertThat(info.directShareAppTarget).isEqualTo(directShareAppTarget) - assertThat(info.displayResolveInfo).isEqualTo(displayResolveInfo) - assertThat(info.isEmptyTargetInfo).isFalse() - assertThat(info.isPlaceHolderTargetInfo).isFalse() - assertThat(info.isNotSelectableTargetInfo).isFalse() - assertThat(info.isSelectableTargetInfo).isFalse() - assertThat(info.isChooserTargetInfo).isFalse() - assertThat(info.isMultiDisplayResolveInfo).isFalse() - assertThat(info.isDisplayResolveInfo).isFalse() - assertThat(info.hashProvider).isEqualTo(hashProvider) - } - - @Test - fun testBaseIntentToSend_defaultsToResolvedIntent() { - val info = ImmutableTargetInfo.newBuilder().setResolvedIntent(resolvedIntent).build() - assertThat(info.baseIntentToSend.filterEquals(resolvedIntent)).isTrue() - } - - @Test - fun testBaseIntentToSend_fillsInFromReferrerIntent() { - val originalIntent = Intent() - originalIntent.setPackage("original") - - val referrerFillInIntent = Intent("REFERRER_FILL_IN") - referrerFillInIntent.setPackage("referrer") - - val info = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .setReferrerFillInIntent(referrerFillInIntent) - .build() - - assertThat(info.baseIntentToSend.getPackage()).isEqualTo("original") // Only fill if empty. - assertThat(info.baseIntentToSend.action).isEqualTo("REFERRER_FILL_IN") - } - - @Test - fun testBaseIntentToSend_fillsInFromRefinementIntent() { - val originalIntent = Intent() - originalIntent.putExtra("ORIGINAL", true) - - val refinementIntent = Intent() - refinementIntent.putExtra("REFINEMENT", true) - - val originalInfo = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .build() - val info = checkNotNull(originalInfo.tryToCloneWithAppliedRefinement(refinementIntent)) - - assertThat(info?.baseIntentToSend?.getBooleanExtra("ORIGINAL", false)).isTrue() - assertThat(info?.baseIntentToSend?.getBooleanExtra("REFINEMENT", false)).isTrue() - } - - @Test - fun testBaseIntentToSend_twoFillInSourcesFavorsRefinementRequest() { - val originalIntent = Intent("REFINE_ME") - originalIntent.setPackage("original") - - val referrerFillInIntent = Intent("REFERRER_FILL_IN") - referrerFillInIntent.setPackage("referrer_pkg") - referrerFillInIntent.setType("test/referrer") - - val infoWithReferrerFillIn = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .setReferrerFillInIntent(referrerFillInIntent) - .build() - - val refinementIntent = Intent("REFINE_ME") - refinementIntent.setPackage("original") // Has to match for refinement. - - val info = - checkNotNull(infoWithReferrerFillIn.tryToCloneWithAppliedRefinement(refinementIntent)) - - assertThat(info?.baseIntentToSend?.getPackage()).isEqualTo("original") // Set all along. - assertThat(info?.baseIntentToSend?.action).isEqualTo("REFINE_ME") // Refinement wins. - assertThat(info?.baseIntentToSend?.type).isEqualTo("test/referrer") // Left for referrer. - } - - @Test - fun testBaseIntentToSend_doubleRefinementPreservesReferrerFillInButNotOriginalRefinement() { - val originalIntent = Intent("REFINE_ME") - val referrerFillInIntent = Intent("REFERRER_FILL_IN") - referrerFillInIntent.putExtra("TEST", "REFERRER") - val refinementIntent1 = Intent("REFINE_ME") - refinementIntent1.putExtra("TEST1", "1") - val refinementIntent2 = Intent("REFINE_ME") - refinementIntent2.putExtra("TEST2", "2") - - val originalInfo = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .setReferrerFillInIntent(referrerFillInIntent) - .build() - - val refined1 = checkNotNull(originalInfo.tryToCloneWithAppliedRefinement(refinementIntent1)) - // Cloned clone. - val refined2 = checkNotNull(refined1.tryToCloneWithAppliedRefinement(refinementIntent2)) - - // Both clones get the same values filled in from the referrer intent. - assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER") - assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST")).isEqualTo("REFERRER") - // Each clone has the respective value that was set in their own refinement request. - assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST1")).isEqualTo("1") - assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST2")).isEqualTo("2") - // The clones don't have the data from each other's refinements, even though the intent - // field is empty (thus able to be populated by filling-in). - assertThat(refined1?.baseIntentToSend?.getStringExtra("TEST2")).isNull() - assertThat(refined2?.baseIntentToSend?.getStringExtra("TEST1")).isNull() - } - - @Test - fun testBaseIntentToSend_refinementToAlternateSourceIntent() { - val originalIntent = Intent("DONT_REFINE_ME") - originalIntent.putExtra("originalIntent", true) - val mismatchedAlternate = Intent("DOESNT_MATCH") - mismatchedAlternate.putExtra("mismatchedAlternate", true) - val targetAlternate = Intent("REFINE_ME") - targetAlternate.putExtra("targetAlternate", true) - val extraMatch = Intent("REFINE_ME") - extraMatch.putExtra("extraMatch", true) - - val originalInfo = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .setAllSourceIntents(listOf( - originalIntent, mismatchedAlternate, targetAlternate, extraMatch)) - .build() - - val refinement = Intent("REFINE_ME") // First match is `targetAlternate` - refinement.putExtra("refinement", true) - - val refinedResult = checkNotNull(originalInfo.tryToCloneWithAppliedRefinement(refinement)) - assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("refinement", false)).isTrue() - assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("targetAlternate", false)) - .isTrue() - // None of the other source intents got merged in (not even the later one that matched): - assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("originalIntent", false)) - .isFalse() - assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("mismatchedAlternate", false)) - .isFalse() - assertThat(refinedResult?.baseIntentToSend?.getBooleanExtra("extraMatch", false)).isFalse() - } - - @Test - fun testBaseIntentToSend_noSourceIntentMatchingProposedRefinement() { - val originalIntent = Intent("DONT_REFINE_ME") - originalIntent.putExtra("originalIntent", true) - val mismatchedAlternate = Intent("DOESNT_MATCH") - mismatchedAlternate.putExtra("mismatchedAlternate", true) - - val originalInfo = ImmutableTargetInfo.newBuilder() - .setResolvedIntent(originalIntent) - .setAllSourceIntents(listOf(originalIntent, mismatchedAlternate)) - .build() - - val refinement = Intent("PROPOSED_REFINEMENT") - assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() - } - - @Test - fun testLegacySubclassRelationships_empty() { - val info = ImmutableTargetInfo.newBuilder() - .setLegacyType(ImmutableTargetInfo.LegacyTargetType.EMPTY_TARGET_INFO) - .build() - - assertThat(info.isEmptyTargetInfo).isTrue() - assertThat(info.isPlaceHolderTargetInfo).isFalse() - assertThat(info.isNotSelectableTargetInfo).isTrue() - assertThat(info.isSelectableTargetInfo).isFalse() - assertThat(info.isChooserTargetInfo).isTrue() - assertThat(info.isMultiDisplayResolveInfo).isFalse() - assertThat(info.isDisplayResolveInfo).isFalse() - } - - @Test - fun testLegacySubclassRelationships_placeholder() { - val info = ImmutableTargetInfo.newBuilder() - .setLegacyType(ImmutableTargetInfo.LegacyTargetType.PLACEHOLDER_TARGET_INFO) - .build() - - assertThat(info.isEmptyTargetInfo).isFalse() - assertThat(info.isPlaceHolderTargetInfo).isTrue() - assertThat(info.isNotSelectableTargetInfo).isTrue() - assertThat(info.isSelectableTargetInfo).isFalse() - assertThat(info.isChooserTargetInfo).isTrue() - assertThat(info.isMultiDisplayResolveInfo).isFalse() - assertThat(info.isDisplayResolveInfo).isFalse() - } - - @Test - fun testLegacySubclassRelationships_selectable() { - val info = ImmutableTargetInfo.newBuilder() - .setLegacyType(ImmutableTargetInfo.LegacyTargetType.SELECTABLE_TARGET_INFO) - .build() - - assertThat(info.isEmptyTargetInfo).isFalse() - assertThat(info.isPlaceHolderTargetInfo).isFalse() - assertThat(info.isNotSelectableTargetInfo).isFalse() - assertThat(info.isSelectableTargetInfo).isTrue() - assertThat(info.isChooserTargetInfo).isTrue() - assertThat(info.isMultiDisplayResolveInfo).isFalse() - assertThat(info.isDisplayResolveInfo).isFalse() - } - - @Test - fun testLegacySubclassRelationships_displayResolveInfo() { - val info = ImmutableTargetInfo.newBuilder() - .setLegacyType(ImmutableTargetInfo.LegacyTargetType.DISPLAY_RESOLVE_INFO) - .build() - - assertThat(info.isEmptyTargetInfo).isFalse() - assertThat(info.isPlaceHolderTargetInfo).isFalse() - assertThat(info.isNotSelectableTargetInfo).isFalse() - assertThat(info.isSelectableTargetInfo).isFalse() - assertThat(info.isChooserTargetInfo).isFalse() - assertThat(info.isMultiDisplayResolveInfo).isFalse() - assertThat(info.isDisplayResolveInfo).isTrue() - } - - @Test - fun testLegacySubclassRelationships_multiDisplayResolveInfo() { - val info = ImmutableTargetInfo.newBuilder() - .setLegacyType(ImmutableTargetInfo.LegacyTargetType.MULTI_DISPLAY_RESOLVE_INFO) - .build() - - assertThat(info.isEmptyTargetInfo).isFalse() - assertThat(info.isPlaceHolderTargetInfo).isFalse() - assertThat(info.isNotSelectableTargetInfo).isFalse() - assertThat(info.isSelectableTargetInfo).isFalse() - assertThat(info.isChooserTargetInfo).isFalse() - assertThat(info.isMultiDisplayResolveInfo).isTrue() - assertThat(info.isDisplayResolveInfo).isTrue() - } - - @Test - fun testActivityStarter_correctNumberOfInvocations_startAsCaller() { - val activityStarter = object : TestActivityStarter() { - override fun startAsUser( - target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle - ): Boolean { - throw RuntimeException("Wrong API used: startAsUser") - } - } - - val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() - val activity: ResolverActivity = mock() - val options = Bundle() - options.putInt("TEST_KEY", 1) - - info.startAsCaller(activity, options, 42) - - assertThat(activityStarter.totalInvocations).isEqualTo(1) - assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info) - assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity) - assertThat(activityStarter.lastInvocationOptions).isEqualTo(options) - assertThat(activityStarter.lastInvocationUserId).isEqualTo(42) - assertThat(activityStarter.lastInvocationAsCaller).isTrue() - } - - @Test - fun testActivityStarter_correctNumberOfInvocations_startAsUser() { - val activityStarter = object : TestActivityStarter() { - override fun startAsCaller( - target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { - throw RuntimeException("Wrong API used: startAsCaller") - } - } - - val info = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() - val activity: Activity = mock() - val options = Bundle() - options.putInt("TEST_KEY", 1) - - info.startAsUser(activity, options, UserHandle.of(42)) - - assertThat(activityStarter.totalInvocations).isEqualTo(1) - assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info) - assertThat(activityStarter.lastInvocationActivity).isEqualTo(activity) - assertThat(activityStarter.lastInvocationOptions).isEqualTo(options) - assertThat(activityStarter.lastInvocationUserId).isEqualTo(42) - assertThat(activityStarter.lastInvocationAsCaller).isFalse() - } - - @Test - fun testActivityStarter_invokedWithRespectiveTargetInfoAfterCopy() { - val activityStarter = TestActivityStarter() - val info1 = ImmutableTargetInfo.newBuilder().setActivityStarter(activityStarter).build() - val info2 = info1.toBuilder().build() - - info1.startAsCaller(mock(), Bundle(), 42) - assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info1) - info2.startAsCaller(mock(), Bundle(), 42) - assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2) - info2.startAsUser(mock(), Bundle(), UserHandle.of(42)) - assertThat(activityStarter.lastInvocationTargetInfo).isEqualTo(info2) - - assertThat(activityStarter.totalInvocations).isEqualTo(3) // Instance is still shared. - } -} - -private open class TestActivityStarter : ImmutableTargetInfo.TargetActivityStarter { - var totalInvocations = 0 - var lastInvocationTargetInfo: TargetInfo? = null - var lastInvocationActivity: Activity? = null - var lastInvocationOptions: Bundle? = null - var lastInvocationUserId: Integer? = null - var lastInvocationAsCaller = false - - override fun startAsCaller( - target: TargetInfo, activity: Activity, options: Bundle, userId: Int): Boolean { - ++totalInvocations - lastInvocationTargetInfo = target - lastInvocationActivity = activity - lastInvocationOptions = options - lastInvocationUserId = Integer(userId) - lastInvocationAsCaller = true - return true - } - - override fun startAsUser( - target: TargetInfo, activity: Activity, options: Bundle, user: UserHandle): Boolean { - ++totalInvocations - lastInvocationTargetInfo = target - lastInvocationActivity = activity - lastInvocationOptions = options - lastInvocationUserId = Integer(user.identifier) - lastInvocationAsCaller = false - return true - } -} diff --git a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt b/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt deleted file mode 100644 index 78e0c3ee..00000000 --- a/java/tests/src/com/android/intentresolver/chooser/TargetInfoTest.kt +++ /dev/null @@ -1,399 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.chooser - -import android.app.prediction.AppTarget -import android.app.prediction.AppTargetId -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ActivityInfo -import android.content.pm.ResolveInfo -import android.graphics.drawable.AnimatedVectorDrawable -import android.os.UserHandle -import android.test.UiThreadTest -import androidx.test.platform.app.InstrumentationRegistry -import com.android.intentresolver.ResolverDataProvider -import com.android.intentresolver.createChooserTarget -import com.android.intentresolver.createShortcutInfo -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.any -import org.mockito.Mockito.never -import org.mockito.Mockito.spy -import org.mockito.Mockito.times -import org.mockito.Mockito.verify - -class TargetInfoTest { - private val PERSONAL_USER_HANDLE: UserHandle = InstrumentationRegistry - .getInstrumentation().getTargetContext().getUser() - - private val context = InstrumentationRegistry.getInstrumentation().getContext() - - @Before - fun setup() { - // SelectableTargetInfo reads DeviceConfig and needs a permission for that. - InstrumentationRegistry - .getInstrumentation() - .getUiAutomation() - .adoptShellPermissionIdentity("android.permission.READ_DEVICE_CONFIG") - } - - @Test - fun testNewEmptyTargetInfo() { - val info = NotSelectableTargetInfo.newEmptyTargetInfo() - assertThat(info.isEmptyTargetInfo()).isTrue() - assertThat(info.isChooserTargetInfo()).isTrue() // From legacy inheritance model. - assertThat(info.hasDisplayIcon()).isFalse() - assertThat(info.getDisplayIconHolder().getDisplayIcon()).isNull() - } - - @UiThreadTest // AnimatedVectorDrawable needs to start from a thread with a Looper. - @Test - fun testNewPlaceholderTargetInfo() { - val info = NotSelectableTargetInfo.newPlaceHolderTargetInfo(context) - assertThat(info.isPlaceHolderTargetInfo).isTrue() - assertThat(info.isChooserTargetInfo).isTrue() // From legacy inheritance model. - assertThat(info.hasDisplayIcon()).isTrue() - assertThat(info.displayIconHolder.displayIcon) - .isInstanceOf(AnimatedVectorDrawable::class.java) - // TODO: assert that the animation is pre-started/running (IIUC this requires synchronizing - // with some "render thread" per the `AnimatedVectorDrawable` docs). I believe this is - // possible using `AnimatorTestRule` but I couldn't find any sample usage in Kotlin nor get - // it working myself. - } - - @Test - fun testNewSelectableTargetInfo() { - val resolvedIntent = Intent() - val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( - resolvedIntent, - ResolverDataProvider.createResolveInfo(1, 0, PERSONAL_USER_HANDLE), - "label", - "extended info", - resolvedIntent, - /* resolveInfoPresentationGetter= */ null) - val chooserTarget = createChooserTarget( - "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id") - val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3) - val appTarget = AppTarget( - AppTargetId("id"), - chooserTarget.componentName.packageName, - chooserTarget.componentName.className, - UserHandle.CURRENT) - - val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( - baseDisplayInfo, - mock(), - resolvedIntent, - chooserTarget, - 0.1f, - shortcutInfo, - appTarget, - mock(), - ) - assertThat(targetInfo.isSelectableTargetInfo).isTrue() - assertThat(targetInfo.isChooserTargetInfo).isTrue() // From legacy inheritance model. - assertThat(targetInfo.displayResolveInfo).isSameInstanceAs(baseDisplayInfo) - assertThat(targetInfo.chooserTargetComponentName).isEqualTo(chooserTarget.componentName) - assertThat(targetInfo.directShareShortcutId).isEqualTo(shortcutInfo.id) - assertThat(targetInfo.directShareShortcutInfo).isSameInstanceAs(shortcutInfo) - assertThat(targetInfo.directShareAppTarget).isSameInstanceAs(appTarget) - assertThat(targetInfo.resolvedIntent).isSameInstanceAs(resolvedIntent) - // TODO: make more meaningful assertions about the behavior of a selectable target. - } - - @Test - fun test_SelectableTargetInfo_componentName_no_source_info() { - val chooserTarget = createChooserTarget( - "title", 0.3f, ResolverDataProvider.createComponentName(1), "test_shortcut_id") - val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(2), 3) - val appTarget = AppTarget( - AppTargetId("id"), - chooserTarget.componentName.packageName, - chooserTarget.componentName.className, - UserHandle.CURRENT) - val pkgName = "org.package" - val className = "MainActivity" - val backupResolveInfo = ResolveInfo().apply { - activityInfo = ActivityInfo().apply { - packageName = pkgName - name = className - } - } - - val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( - null, - backupResolveInfo, - mock(), - chooserTarget, - 0.1f, - shortcutInfo, - appTarget, - mock(), - ) - assertThat(targetInfo.resolvedComponentName).isEqualTo(ComponentName(pkgName, className)) - } - - @Test - fun testSelectableTargetInfo_noSourceIntentMatchingProposedRefinement() { - val resolvedIntent = Intent("DONT_REFINE_ME") - resolvedIntent.putExtra("resolvedIntent", true) - - val baseDisplayInfo = DisplayResolveInfo.newDisplayResolveInfo( - resolvedIntent, - ResolverDataProvider.createResolveInfo(1, 0), - "label", - "extended info", - resolvedIntent, - /* resolveInfoPresentationGetter= */ null) - val chooserTarget = createChooserTarget( - "title", 0.3f, ResolverDataProvider.createComponentName(2), "test_shortcut_id") - val shortcutInfo = createShortcutInfo("id", ResolverDataProvider.createComponentName(3), 3) - val appTarget = AppTarget( - AppTargetId("id"), - chooserTarget.componentName.packageName, - chooserTarget.componentName.className, - UserHandle.CURRENT) - - val targetInfo = SelectableTargetInfo.newSelectableTargetInfo( - baseDisplayInfo, - mock(), - resolvedIntent, - chooserTarget, - 0.1f, - shortcutInfo, - appTarget, - mock(), - ) - - val refinement = Intent("PROPOSED_REFINEMENT") - assertThat(targetInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() - } - - @Test - fun testNewDisplayResolveInfo() { - val intent = Intent(Intent.ACTION_SEND) - intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") - intent.setType("text/plain") - - val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE) - - val targetInfo = DisplayResolveInfo.newDisplayResolveInfo( - intent, - resolveInfo, - "label", - "extended info", - intent, - /* resolveInfoPresentationGetter= */ null) - assertThat(targetInfo.isDisplayResolveInfo()).isTrue() - assertThat(targetInfo.isMultiDisplayResolveInfo()).isFalse() - assertThat(targetInfo.isChooserTargetInfo()).isFalse() - } - - @Test - fun test_DisplayResolveInfo_refinementToAlternateSourceIntent() { - val originalIntent = Intent("DONT_REFINE_ME") - originalIntent.putExtra("originalIntent", true) - val mismatchedAlternate = Intent("DOESNT_MATCH") - mismatchedAlternate.putExtra("mismatchedAlternate", true) - val targetAlternate = Intent("REFINE_ME") - targetAlternate.putExtra("targetAlternate", true) - val extraMatch = Intent("REFINE_ME") - extraMatch.putExtra("extraMatch", true) - - val originalInfo = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - ResolverDataProvider.createResolveInfo(3, 0), - "label", - "extended info", - originalIntent, - /* resolveInfoPresentationGetter= */ null) - originalInfo.addAlternateSourceIntent(mismatchedAlternate) - originalInfo.addAlternateSourceIntent(targetAlternate) - originalInfo.addAlternateSourceIntent(extraMatch) - - val refinement = Intent("REFINE_ME") // First match is `targetAlternate` - refinement.putExtra("refinement", true) - - val refinedResult = checkNotNull(originalInfo.tryToCloneWithAppliedRefinement(refinement)) - // Note `DisplayResolveInfo` targets merge refinements directly into their `resolvedIntent`. - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("refinement", false)).isTrue() - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("targetAlternate", false)) - .isTrue() - // None of the other source intents got merged in (not even the later one that matched): - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("originalIntent", false)) - .isFalse() - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("mismatchedAlternate", false)) - .isFalse() - assertThat(refinedResult?.resolvedIntent?.getBooleanExtra("extraMatch", false)).isFalse() - } - - @Test - fun testDisplayResolveInfo_noSourceIntentMatchingProposedRefinement() { - val originalIntent = Intent("DONT_REFINE_ME") - originalIntent.putExtra("originalIntent", true) - val mismatchedAlternate = Intent("DOESNT_MATCH") - mismatchedAlternate.putExtra("mismatchedAlternate", true) - - val originalInfo = DisplayResolveInfo.newDisplayResolveInfo( - originalIntent, - ResolverDataProvider.createResolveInfo(3, 0), - "label", - "extended info", - originalIntent, - /* resolveInfoPresentationGetter= */ null) - originalInfo.addAlternateSourceIntent(mismatchedAlternate) - - val refinement = Intent("PROPOSED_REFINEMENT") - assertThat(originalInfo.tryToCloneWithAppliedRefinement(refinement)).isNull() - } - - @Test - fun testNewMultiDisplayResolveInfo() { - val intent = Intent(Intent.ACTION_SEND) - intent.putExtra(Intent.EXTRA_TEXT, "testing intent sending") - intent.setType("text/plain") - - val resolveInfo = ResolverDataProvider.createResolveInfo(3, 0, PERSONAL_USER_HANDLE) - val firstTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( - intent, - resolveInfo, - "label 1", - "extended info 1", - intent, - /* resolveInfoPresentationGetter= */ null) - val secondTargetInfo = DisplayResolveInfo.newDisplayResolveInfo( - intent, - resolveInfo, - "label 2", - "extended info 2", - intent, - /* resolveInfoPresentationGetter= */ null) - - val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - listOf(firstTargetInfo, secondTargetInfo)) - - assertThat(multiTargetInfo.isMultiDisplayResolveInfo()).isTrue() - assertThat(multiTargetInfo.isDisplayResolveInfo()).isTrue() // From legacy inheritance. - assertThat(multiTargetInfo.isChooserTargetInfo()).isFalse() - - assertThat(multiTargetInfo.getExtendedInfo()).isNull() - - assertThat(multiTargetInfo.getAllDisplayTargets()) - .containsExactly(firstTargetInfo, secondTargetInfo) - - assertThat(multiTargetInfo.hasSelected()).isFalse() - assertThat(multiTargetInfo.getSelectedTarget()).isNull() - - multiTargetInfo.setSelected(1) - - assertThat(multiTargetInfo.hasSelected()).isTrue() - assertThat(multiTargetInfo.getSelectedTarget()).isEqualTo(secondTargetInfo) - - val refined = multiTargetInfo.tryToCloneWithAppliedRefinement(intent) - assertThat(refined).isInstanceOf(MultiDisplayResolveInfo::class.java) - assertThat((refined as MultiDisplayResolveInfo).hasSelected()) - .isEqualTo(multiTargetInfo.hasSelected()) - - // TODO: consider exercising activity-start behavior. - // TODO: consider exercising DisplayResolveInfo base class behavior. - } - - @Test - fun testNewMultiDisplayResolveInfo_getAllSourceIntents_fromSelectedTarget() { - val sendImage = Intent("SEND").apply { type = "image/png" } - val sendUri = Intent("SEND").apply { type = "text/uri" } - - val resolveInfo = ResolverDataProvider.createResolveInfo(1, 0) - - val imageOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo( - sendImage, - resolveInfo, - "Send Image", - "Sends only images", - sendImage, - /* resolveInfoPresentationGetter= */ null) - - val textOnlyTarget = DisplayResolveInfo.newDisplayResolveInfo( - sendUri, - resolveInfo, - "Send Text", - "Sends only text", - sendUri, - /* resolveInfoPresentationGetter= */ null) - - val imageOrTextTarget = DisplayResolveInfo.newDisplayResolveInfo( - sendImage, - resolveInfo, - "Send Image or Text", - "Sends images or text", - sendImage, - /* resolveInfoPresentationGetter= */ null - ).apply { - addAlternateSourceIntent(sendUri) - } - - val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - listOf(imageOnlyTarget, textOnlyTarget, imageOrTextTarget) - ) - - multiTargetInfo.setSelected(0) - assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOnlyTarget) - assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOnlyTarget.allSourceIntents) - - multiTargetInfo.setSelected(1) - assertThat(multiTargetInfo.selectedTarget).isEqualTo(textOnlyTarget) - assertThat(multiTargetInfo.allSourceIntents).isEqualTo(textOnlyTarget.allSourceIntents) - - multiTargetInfo.setSelected(2) - assertThat(multiTargetInfo.selectedTarget).isEqualTo(imageOrTextTarget) - assertThat(multiTargetInfo.allSourceIntents).isEqualTo(imageOrTextTarget.allSourceIntents) - } - - @Test - fun testNewMultiDisplayResolveInfo_tryToCloneWithAppliedRefinement_delegatedToSelectedTarget() { - val refined = Intent("SEND") - val sendImage = Intent("SEND") - val targetOne = spy( - DisplayResolveInfo.newDisplayResolveInfo( - sendImage, - ResolverDataProvider.createResolveInfo(1, 0), - "Target One", - "Target One", - sendImage, - /* resolveInfoPresentationGetter= */ null - ) - ) - val targetTwo = mock<DisplayResolveInfo> { - whenever(tryToCloneWithAppliedRefinement(any())).thenReturn(this) - } - - val multiTargetInfo = MultiDisplayResolveInfo.newMultiDisplayResolveInfo( - listOf(targetOne, targetTwo) - ) - - multiTargetInfo.setSelected(1) - assertThat(multiTargetInfo.selectedTarget).isEqualTo(targetTwo) - - multiTargetInfo.tryToCloneWithAppliedRefinement(refined) - verify(targetTwo, times(1)).tryToCloneWithAppliedRefinement(refined) - verify(targetOne, never()).tryToCloneWithAppliedRefinement(any()) - } -} diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt deleted file mode 100644 index 9bfd2052..00000000 --- a/java/tests/src/com/android/intentresolver/contentpreview/ChooserContentPreviewUiTest.kt +++ /dev/null @@ -1,146 +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.graphics.Bitmap -import android.net.Uri -import androidx.lifecycle.Lifecycle -import com.android.intentresolver.any -import com.android.intentresolver.contentpreview.ChooserContentPreviewUi.ActionFactory -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import com.android.intentresolver.widget.ActionRow -import com.android.intentresolver.widget.ImagePreviewView -import com.google.common.truth.Truth.assertThat -import java.util.function.Consumer -import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify - -class ChooserContentPreviewUiTest { - private val lifecycle = mock<Lifecycle>() - private val previewData = mock<PreviewDataProvider>() - private val headlineGenerator = mock<HeadlineGenerator>() - private val imageLoader = - object : ImageLoader { - override fun loadImage( - callerLifecycle: Lifecycle, - uri: Uri, - callback: Consumer<Bitmap?>, - ) { - callback.accept(null) - } - override fun prePopulate(uris: List<Uri>) = Unit - override suspend fun invoke(uri: Uri, caching: Boolean): Bitmap? = null - } - private val actionFactory = - object : ActionFactory { - override fun getCopyButtonRunnable(): Runnable? = null - override fun getEditButtonRunnable(): Runnable? = null - override fun createCustomActions(): List<ActionRow.Action> = emptyList() - override fun getModifyShareAction(): ActionRow.Action? = null - override fun getExcludeSharedTextAction(): Consumer<Boolean> = Consumer<Boolean> {} - } - private val transitionCallback = mock<ImagePreviewView.TransitionElementStatusCallback>() - - @Test - fun test_textPreviewType_useTextPreviewUi() { - whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_TEXT) - val testSubject = - ChooserContentPreviewUi( - lifecycle, - previewData, - Intent(Intent.ACTION_VIEW), - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) - assertThat(testSubject.mContentPreviewUi).isInstanceOf(TextContentPreviewUi::class.java) - verify(transitionCallback, times(1)).onAllTransitionElementsReady() - } - - @Test - fun test_filePreviewType_useFilePreviewUi() { - whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_FILE) - val testSubject = - ChooserContentPreviewUi( - lifecycle, - previewData, - Intent(Intent.ACTION_SEND), - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - assertThat(testSubject.mContentPreviewUi).isInstanceOf(FileContentPreviewUi::class.java) - verify(transitionCallback, times(1)).onAllTransitionElementsReady() - } - - @Test - fun test_imagePreviewTypeWithText_useFilePlusTextPreviewUi() { - val uri = Uri.parse("content://org.pkg.app/img.png") - whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - whenever(previewData.uriCount).thenReturn(2) - whenever(previewData.firstFileInfo) - .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) - val testSubject = - ChooserContentPreviewUi( - lifecycle, - previewData, - Intent(Intent.ACTION_SEND).apply { putExtra(Intent.EXTRA_TEXT, "Shared text") }, - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ) - assertThat(testSubject.mContentPreviewUi) - .isInstanceOf(FilesPlusTextContentPreviewUi::class.java) - verify(previewData, times(1)).getFileMetadataForImagePreview(any(), any()) - verify(transitionCallback, times(1)).onAllTransitionElementsReady() - } - - @Test - fun test_imagePreviewTypeWithoutText_useImagePreviewUi() { - val uri = Uri.parse("content://org.pkg.app/img.png") - whenever(previewData.previewType).thenReturn(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - whenever(previewData.uriCount).thenReturn(2) - whenever(previewData.firstFileInfo) - .thenReturn(FileInfo.Builder(uri).withPreviewUri(uri).withMimeType("image/png").build()) - val testSubject = - ChooserContentPreviewUi( - lifecycle, - previewData, - Intent(Intent.ACTION_SEND), - imageLoader, - actionFactory, - transitionCallback, - headlineGenerator, - ) - assertThat(testSubject.preferredContentPreview) - .isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - assertThat(testSubject.mContentPreviewUi).isInstanceOf(UnifiedContentPreviewUi::class.java) - verify(previewData, times(1)).getFileMetadataForImagePreview(any(), any()) - verify(transitionCallback, never()).onAllTransitionElementsReady() - } -} diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt deleted file mode 100644 index 6db53a9e..00000000 --- a/java/tests/src/com/android/intentresolver/contentpreview/ContentPreviewUiTest.kt +++ /dev/null @@ -1,41 +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 com.android.intentresolver.widget.ScrollableImagePreviewView.PreviewType -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class ContentPreviewUiTest { - @Test - fun testPreviewTypes() { - val typeClassifier = - object : MimeTypeClassifier { - override fun isImageType(type: String?) = (type == "image") - override fun isVideoType(type: String?) = (type == "video") - } - - assertThat(ContentPreviewUi.getPreviewType(typeClassifier, "image")) - .isEqualTo(PreviewType.Image) - assertThat(ContentPreviewUi.getPreviewType(typeClassifier, "video")) - .isEqualTo(PreviewType.Video) - assertThat(ContentPreviewUi.getPreviewType(typeClassifier, "other")) - .isEqualTo(PreviewType.File) - assertThat(ContentPreviewUi.getPreviewType(typeClassifier, null)) - .isEqualTo(PreviewType.File) - } -} diff --git a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt deleted file mode 100644 index a65280e5..00000000 --- a/java/tests/src/com/android/intentresolver/contentpreview/HeadlineGeneratorImplTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (C) 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.contentpreview - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Test -import org.junit.runner.RunWith -import com.google.common.truth.Truth.assertThat - -@RunWith(AndroidJUnit4::class) -class HeadlineGeneratorImplTest { - @Test - fun testHeadlineGeneration() { - val generator = HeadlineGeneratorImpl( - InstrumentationRegistry.getInstrumentation().getTargetContext()) - val str = "Some string" - val url = "http://www.google.com" - - assertThat(generator.getTextHeadline(str)).isEqualTo("Sharing text") - assertThat(generator.getTextHeadline(url)).isEqualTo("Sharing link") - - assertThat(generator.getImagesWithTextHeadline(str, 1)).isEqualTo("Sharing image with text") - assertThat(generator.getImagesWithTextHeadline(url, 1)).isEqualTo("Sharing image with link") - assertThat(generator.getImagesWithTextHeadline(str, 5)).isEqualTo("Sharing 5 images with text") - assertThat(generator.getImagesWithTextHeadline(url, 5)).isEqualTo("Sharing 5 images with link") - - assertThat(generator.getVideosWithTextHeadline(str, 1)).isEqualTo("Sharing video with text") - assertThat(generator.getVideosWithTextHeadline(url, 1)).isEqualTo("Sharing video with link") - assertThat(generator.getVideosWithTextHeadline(str, 5)).isEqualTo("Sharing 5 videos with text") - assertThat(generator.getVideosWithTextHeadline(url, 5)).isEqualTo("Sharing 5 videos with link") - - assertThat(generator.getFilesWithTextHeadline(str, 1)).isEqualTo("Sharing file with text") - assertThat(generator.getFilesWithTextHeadline(url, 1)).isEqualTo("Sharing file with link") - assertThat(generator.getFilesWithTextHeadline(str, 5)).isEqualTo("Sharing 5 files with text") - assertThat(generator.getFilesWithTextHeadline(url, 5)).isEqualTo("Sharing 5 files with link") - - assertThat(generator.getImagesHeadline(1)).isEqualTo("Sharing image") - assertThat(generator.getImagesHeadline(4)).isEqualTo("Sharing 4 images") - - assertThat(generator.getVideosHeadline(1)).isEqualTo("Sharing video") - assertThat(generator.getVideosHeadline(4)).isEqualTo("Sharing 4 videos") - - assertThat(generator.getFilesHeadline(1)).isEqualTo("Sharing 1 file") - assertThat(generator.getFilesHeadline(4)).isEqualTo("Sharing 4 files") - } -} diff --git a/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt deleted file mode 100644 index 6e57c289..00000000 --- a/java/tests/src/com/android/intentresolver/contentpreview/ImagePreviewImageLoaderTest.kt +++ /dev/null @@ -1,366 +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.ContentResolver -import android.graphics.Bitmap -import android.net.Uri -import android.util.Size -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope -import com.android.intentresolver.TestLifecycleOwner -import com.android.intentresolver.any -import com.android.intentresolver.anyOrNull -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import java.util.ArrayDeque -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit.MILLISECONDS -import java.util.concurrent.TimeUnit.SECONDS -import java.util.concurrent.atomic.AtomicInteger -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Runnable -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.yield -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify - -@OptIn(ExperimentalCoroutinesApi::class) -class ImagePreviewImageLoaderTest { - private val imageSize = Size(300, 300) - private val uriOne = Uri.parse("content://org.package.app/image-1.png") - private val uriTwo = Uri.parse("content://org.package.app/image-2.png") - private val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) - private val contentResolver = - mock<ContentResolver> { - whenever(loadThumbnail(any(), any(), anyOrNull())).thenReturn(bitmap) - } - private val lifecycleOwner = TestLifecycleOwner() - private val dispatcher = UnconfinedTestDispatcher() - private lateinit var testSubject: ImagePreviewImageLoader - - @Before - fun setup() { - Dispatchers.setMain(dispatcher) - lifecycleOwner.state = Lifecycle.State.CREATED - // create test subject after we've updated the lifecycle dispatcher - testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - } - - @After - fun cleanup() { - lifecycleOwner.state = Lifecycle.State.DESTROYED - Dispatchers.resetMain() - } - - @Test - fun prePopulate_cachesImagesUpToTheCacheSize() = runTest { - testSubject.prePopulate(listOf(uriOne, uriTwo)) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, never()).loadThumbnail(uriTwo, imageSize, null) - - testSubject(uriOne) - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } - - @Test - fun invoke_returnCachedImageWhenCalledTwice() = runTest { - testSubject(uriOne) - testSubject(uriOne) - - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_whenInstructed_doesNotCache() = runTest { - testSubject(uriOne, false) - testSubject(uriOne, false) - - verify(contentResolver, times(2)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_overlappedRequests_Deduplicate() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - scheduler.advanceUntilIdle() - } - - verify(contentResolver, times(1)).loadThumbnail(any(), any(), anyOrNull()) - } - - @Test - fun invoke_oldRecordsEvictedFromTheCache() = runTest { - testSubject(uriOne) - testSubject(uriTwo) - testSubject(uriTwo) - testSubject(uriOne) - - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - verify(contentResolver, times(1)).loadThumbnail(uriTwo, imageSize, null) - } - - @Test - fun invoke_doNotCacheNulls() = runTest { - whenever(contentResolver.loadThumbnail(any(), any(), anyOrNull())).thenReturn(null) - testSubject(uriOne) - testSubject(uriOne) - - verify(contentResolver, times(2)).loadThumbnail(uriOne, imageSize, null) - } - - @Test(expected = CancellationException::class) - fun invoke_onClosedImageLoaderScope_throwsCancellationException() = runTest { - lifecycleOwner.state = Lifecycle.State.DESTROYED - testSubject(uriOne) - } - - @Test(expected = CancellationException::class) - fun invoke_imageLoaderScopeClosedMidflight_throwsCancellationException() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - val deferred = async(start = UNDISPATCHED) { testSubject(uriOne, false) } - lifecycleOwner.state = Lifecycle.State.DESTROYED - scheduler.advanceUntilIdle() - deferred.await() - } - } - - @Test - fun invoke_multipleCallsWithDifferentCacheInstructions_cachingPrevails() = runTest { - val scheduler = TestCoroutineScheduler() - val dispatcher = StandardTestDispatcher(scheduler) - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - ) - coroutineScope { - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - launch(start = UNDISPATCHED) { testSubject(uriOne, true) } - scheduler.advanceUntilIdle() - } - testSubject(uriOne, true) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - } - - @Test - fun invoke_semaphoreGuardsContentResolverCalls() = runTest { - val contentResolver = - mock<ContentResolver> { - whenever(loadThumbnail(any(), any(), anyOrNull())) - .thenThrow(SecurityException("test")) - } - val acquireCount = AtomicInteger() - val releaseCount = AtomicInteger() - val testSemaphore = - object : Semaphore { - override val availablePermits: Int - get() = error("Unexpected invocation") - - override suspend fun acquire() { - acquireCount.getAndIncrement() - } - - override fun tryAcquire(): Boolean { - error("Unexpected invocation") - } - - override fun release() { - releaseCount.getAndIncrement() - } - } - - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - testSemaphore, - ) - testSubject(uriOne, false) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - assertThat(acquireCount.get()).isEqualTo(1) - assertThat(releaseCount.get()).isEqualTo(1) - } - - @Test - fun invoke_semaphoreIsReleasedAfterContentResolverFailure() = runTest { - val semaphoreDeferred = CompletableDeferred<Unit>() - val releaseCount = AtomicInteger() - val testSemaphore = - object : Semaphore { - override val availablePermits: Int - get() = error("Unexpected invocation") - - override suspend fun acquire() { - semaphoreDeferred.await() - } - - override fun tryAcquire(): Boolean { - error("Unexpected invocation") - } - - override fun release() { - releaseCount.getAndIncrement() - } - } - - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher, - imageSize.width, - contentResolver, - cacheSize = 1, - testSemaphore, - ) - launch(start = UNDISPATCHED) { testSubject(uriOne, false) } - - verify(contentResolver, never()).loadThumbnail(any(), any(), anyOrNull()) - - semaphoreDeferred.complete(Unit) - - verify(contentResolver, times(1)).loadThumbnail(uriOne, imageSize, null) - assertThat(releaseCount.get()).isEqualTo(1) - } - - @Test - fun invoke_multipleSimultaneousCalls_limitOnNumberOfSimultaneousOutgoingCallsIsRespected() { - val requestCount = 4 - val thumbnailCallsCdl = CountDownLatch(requestCount) - val pendingThumbnailCalls = ArrayDeque<CountDownLatch>() - val contentResolver = - mock<ContentResolver> { - whenever(loadThumbnail(any(), any(), anyOrNull())).thenAnswer { - val latch = CountDownLatch(1) - synchronized(pendingThumbnailCalls) { pendingThumbnailCalls.offer(latch) } - thumbnailCallsCdl.countDown() - latch.await() - bitmap - } - } - val name = "LoadImage" - val maxSimultaneousRequests = 2 - val threadsStartedCdl = CountDownLatch(requestCount) - val dispatcher = NewThreadDispatcher(name) { threadsStartedCdl.countDown() } - val testSubject = - ImagePreviewImageLoader( - lifecycleOwner.lifecycle.coroutineScope + dispatcher + CoroutineName(name), - imageSize.width, - contentResolver, - cacheSize = 1, - maxSimultaneousRequests, - ) - runTest { - repeat(requestCount) { - launch { testSubject(Uri.parse("content://org.pkg.app/image-$it.png")) } - } - yield() - // wait for all requests to be dispatched - assertThat(threadsStartedCdl.await(5, SECONDS)).isTrue() - - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } - - pendingThumbnailCalls.poll()?.countDown() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isFalse() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } - - pendingThumbnailCalls.poll()?.countDown() - assertThat(thumbnailCallsCdl.await(100, MILLISECONDS)).isTrue() - synchronized(pendingThumbnailCalls) { - assertThat(pendingThumbnailCalls.size).isEqualTo(maxSimultaneousRequests) - } - for (cdl in pendingThumbnailCalls) { - cdl.countDown() - } - } - } -} - -private class NewThreadDispatcher( - private val coroutineName: String, - private val launchedCallback: () -> Unit -) : CoroutineDispatcher() { - override fun isDispatchNeeded(context: CoroutineContext): Boolean = true - - override fun dispatch(context: CoroutineContext, block: Runnable) { - Thread { - if (coroutineName == context[CoroutineName.Key]?.name) { - launchedCallback() - } - block.run() - } - .start() - } -} diff --git a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt deleted file mode 100644 index 145b89ad..00000000 --- a/java/tests/src/com/android/intentresolver/contentpreview/PreviewDataProviderTest.kt +++ /dev/null @@ -1,351 +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.ContentInterface -import android.content.Intent -import android.database.MatrixCursor -import android.media.MediaMetadata -import android.net.Uri -import android.provider.DocumentsContract -import androidx.lifecycle.Lifecycle -import com.android.intentresolver.TestLifecycleOwner -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.any -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify - -@OptIn(ExperimentalCoroutinesApi::class) -class PreviewDataProviderTest { - private val contentResolver = mock<ContentInterface>() - private val mimeTypeClassifier = DefaultMimeTypeClassifier - - private val lifecycleOwner = TestLifecycleOwner() - private val dispatcher = UnconfinedTestDispatcher() - - @Before - fun setup() { - Dispatchers.setMain(dispatcher) - lifecycleOwner.state = Lifecycle.State.CREATED - } - - @After - fun cleanup() { - lifecycleOwner.state = Lifecycle.State.DESTROYED - Dispatchers.resetMain() - } - - @Test - fun test_nonSendIntentAction_resolvesToTextPreviewUiSynchronously() { - val targetIntent = Intent(Intent.ACTION_VIEW) - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) - verify(contentResolver, never()).getType(any()) - } - - @Test - fun test_sendSingleTextFileWithoutPreview_resolvesToFilePreviewUi() { - val uri = Uri.parse("content://org.pkg.app/notes.txt") - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { - putExtra(Intent.EXTRA_STREAM, uri) - type = "text/plain" - } - whenever(contentResolver.getType(uri)).thenReturn("text/plain") - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - verify(contentResolver, times(1)).getType(any()) - } - - @Test - fun test_sendIntentWithoutUris_resolvesToTextPreviewUiSynchronously() { - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { - type = "image/png" - } - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_TEXT) - verify(contentResolver, never()).getType(any()) - } - - @Test - fun test_sendSingleImage_resolvesToImagePreviewUi() { - val uri = Uri.parse("content://org.pkg.app/image.png") - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getType(uri)).thenReturn("image/png") - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri) - verify(contentResolver, times(1)).getType(any()) - } - - @Test - fun test_sendSingleNonImage_resolvesToFilePreviewUi() { - val uri = Uri.parse("content://org.pkg.app/paper.pdf") - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getType(uri)).thenReturn("application/pdf") - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - assertThat(testSubject.firstFileInfo?.previewUri).isNull() - verify(contentResolver, times(1)).getType(any()) - } - - @Test - fun test_sendSingleImageWithFailingGetType_resolvesToFilePreviewUi() { - val uri = Uri.parse("content://org.pkg.app/image.png") - val targetIntent = - Intent(Intent.ACTION_SEND) - .apply { - type = "image/png" - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getType(uri)).thenThrow(SecurityException("test failure")) - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - assertThat(testSubject.firstFileInfo?.previewUri).isNull() - verify(contentResolver, times(1)).getType(any()) - } - - @Test - fun test_sendSingleImageWithFailingMetadata_resolvesToFilePreviewUi() { - val uri = Uri.parse("content://org.pkg.app/image.png") - val targetIntent = - Intent(Intent.ACTION_SEND) - .apply { - type = "image/png" - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getStreamTypes(uri, "*/*")) - .thenThrow(SecurityException("test failure")) - whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) - .thenThrow(SecurityException("test failure")) - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - assertThat(testSubject.firstFileInfo?.previewUri).isNull() - verify(contentResolver, times(1)).getType(any()) - } - - @Test - fun test_SingleNonImageUriWithImageTypeInGetStreamTypes_useImagePreviewUi() { - val uri = Uri.parse("content://org.pkg.app/paper.pdf") - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getStreamTypes(uri, "*/*")) - .thenReturn(arrayOf("application/pdf", "image/png")) - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri) - verify(contentResolver, times(1)).getType(any()) - } - - @Test - fun test_SingleNonImageUriWithThumbnailFlag_useImagePreviewUi() { - testMetadataToImagePreview( - columns = arrayOf(DocumentsContract.Document.COLUMN_FLAGS), - values = - arrayOf( - DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL or - DocumentsContract.Document.FLAG_SUPPORTS_METADATA - ) - ) - } - - @Test - fun test_SingleNonImageUriWithMetadataIconUri_useImagePreviewUi() { - testMetadataToImagePreview( - columns = arrayOf(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI), - values = arrayOf("content://org.pkg.app/test.pdf?thumbnail"), - ) - } - - private fun testMetadataToImagePreview(columns: Array<String>, values: Array<Any>) { - val uri = Uri.parse("content://org.pkg.app/test.pdf") - val targetIntent = Intent(Intent.ACTION_SEND) - .apply { - putExtra(Intent.EXTRA_STREAM, uri) - } - whenever(contentResolver.getType(uri)).thenReturn("application/pdf") - whenever(contentResolver.query(uri, METADATA_COLUMNS, null, null)) - .thenReturn(MatrixCursor(columns).apply { addRow(values) }) - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - assertThat(testSubject.uriCount).isEqualTo(1) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri) - assertThat(testSubject.firstFileInfo?.previewUri).isNotNull() - verify(contentResolver, times(1)).getType(any()) - } - - @Test - fun test_multipleImageUri_useImagePreviewUi() { - val uri1 = Uri.parse("content://org.pkg.app/test.png") - val uri2 = Uri.parse("content://org.pkg.app/test.jpg") - val targetIntent = - Intent(Intent.ACTION_SEND_MULTIPLE) - .apply { - putExtra( - Intent.EXTRA_STREAM, - ArrayList<Uri>().apply { - add(uri1) - add(uri2) - } - ) - } - whenever(contentResolver.getType(uri1)).thenReturn("image/png") - whenever(contentResolver.getType(uri2)).thenReturn("image/jpeg") - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - assertThat(testSubject.uriCount).isEqualTo(2) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1) - assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1) - // preview type can be determined by the first URI type - verify(contentResolver, times(1)).getType(any()) - } - - @Test - fun test_SomeImageUri_useImagePreviewUi() { - val uri1 = Uri.parse("content://org.pkg.app/test.png") - val uri2 = Uri.parse("content://org.pkg.app/test.pdf") - whenever(contentResolver.getType(uri1)).thenReturn("image/png") - whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") - val targetIntent = - Intent(Intent.ACTION_SEND_MULTIPLE) - .apply { - putExtra( - Intent.EXTRA_STREAM, - ArrayList<Uri>().apply { - add(uri1) - add(uri2) - } - ) - } - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - assertThat(testSubject.uriCount).isEqualTo(2) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1) - assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1) - // preview type can be determined by the first URI type - verify(contentResolver, times(1)).getType(any()) - } - - @Test - fun test_someNonImageUriWithPreview_useImagePreviewUi() { - val uri1 = Uri.parse("content://org.pkg.app/test.mp4") - val uri2 = Uri.parse("content://org.pkg.app/test.pdf") - val targetIntent = - Intent(Intent.ACTION_SEND_MULTIPLE) - .apply { - putExtra( - Intent.EXTRA_STREAM, - ArrayList<Uri>().apply { - add(uri1) - add(uri2) - } - ) - } - whenever(contentResolver.getType(uri1)).thenReturn("video/mpeg4") - whenever(contentResolver.getStreamTypes(uri1, "*/*")).thenReturn(arrayOf("image/png")) - whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_IMAGE) - assertThat(testSubject.uriCount).isEqualTo(2) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1) - assertThat(testSubject.firstFileInfo?.previewUri).isEqualTo(uri1) - verify(contentResolver, times(2)).getType(any()) - } - - @Test - fun test_allNonImageUrisWithoutPreview_useFilePreviewUi() { - val uri1 = Uri.parse("content://org.pkg.app/test.html") - val uri2 = Uri.parse("content://org.pkg.app/test.pdf") - val targetIntent = - Intent(Intent.ACTION_SEND_MULTIPLE) - .apply { - putExtra( - Intent.EXTRA_STREAM, - ArrayList<Uri>().apply { - add(uri1) - add(uri2) - } - ) - } - whenever(contentResolver.getType(uri1)).thenReturn("text/html") - whenever(contentResolver.getType(uri2)).thenReturn("application/pdf") - val testSubject = - PreviewDataProvider(targetIntent, contentResolver, mimeTypeClassifier, dispatcher) - - assertThat(testSubject.previewType).isEqualTo(ContentPreviewType.CONTENT_PREVIEW_FILE) - assertThat(testSubject.uriCount).isEqualTo(2) - assertThat(testSubject.firstFileInfo?.uri).isEqualTo(uri1) - assertThat(testSubject.firstFileInfo?.previewUri).isNull() - verify(contentResolver, times(2)).getType(any()) - } -} diff --git a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java b/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java deleted file mode 100644 index 5f0ead7b..00000000 --- a/java/tests/src/com/android/intentresolver/model/AbstractResolverComparatorTest.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.model; - -import static junit.framework.Assert.assertEquals; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.pm.ActivityInfo; -import android.content.pm.ResolveInfo; -import android.os.Message; - -import androidx.test.InstrumentationRegistry; - -import com.android.intentresolver.ResolvedComponentInfo; -import com.android.intentresolver.chooser.TargetInfo; - -import com.google.android.collect.Lists; - -import org.junit.Test; - -import java.util.List; - -public class AbstractResolverComparatorTest { - - @Test - public void testPinned() { - ResolvedComponentInfo r1 = createResolvedComponentInfo( - new ComponentName("package", "class")); - r1.setPinned(true); - - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("zackage", "zlass")); - - Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, null); - - assertEquals("Pinned ranks over unpinned", -1, comparator.compare(r1, r2)); - assertEquals("Unpinned ranks under pinned", 1, comparator.compare(r2, r1)); - } - - @Test - public void testBothPinned() { - ResolvedComponentInfo r1 = createResolvedComponentInfo( - new ComponentName("package", "class")); - r1.setPinned(true); - - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("zackage", "zlass")); - r2.setPinned(true); - - Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, null); - - assertEquals("Both pinned should rank alphabetically", -1, comparator.compare(r1, r2)); - } - - @Test - public void testPromoteToFirst() { - ComponentName promoteToFirst = new ComponentName("promoted-package", "class"); - ResolvedComponentInfo r1 = createResolvedComponentInfo(promoteToFirst); - - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("package", "class")); - - Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, promoteToFirst); - - assertEquals("PromoteToFirst ranks over non-cemented", -1, comparator.compare(r1, r2)); - assertEquals("Non-cemented ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); - } - - @Test - public void testPromoteToFirstOverPinned() { - ComponentName cementedComponent = new ComponentName("promoted-package", "class"); - ResolvedComponentInfo r1 = createResolvedComponentInfo(cementedComponent); - - ResolvedComponentInfo r2 = createResolvedComponentInfo( - new ComponentName("package", "class")); - r2.setPinned(true); - - Context context = InstrumentationRegistry.getTargetContext(); - AbstractResolverComparator comparator = getTestComparator(context, cementedComponent); - - assertEquals("PromoteToFirst ranks over pinned", -1, comparator.compare(r1, r2)); - assertEquals("Pinned ranks under PromoteToFirst", 1, comparator.compare(r2, r1)); - } - - private ResolvedComponentInfo createResolvedComponentInfo(ComponentName component) { - ResolveInfo info = new ResolveInfo(); - info.activityInfo = new ActivityInfo(); - info.activityInfo.packageName = component.getPackageName(); - info.activityInfo.name = component.getClassName(); - return new ResolvedComponentInfo(component, new Intent(), info); - } - - private AbstractResolverComparator getTestComparator( - Context context, ComponentName promoteToFirst) { - Intent intent = new Intent(); - - AbstractResolverComparator testComparator = - new AbstractResolverComparator(context, intent, - Lists.newArrayList(context.getUser()), promoteToFirst) { - - @Override - int compare(ResolveInfo lhs, ResolveInfo rhs) { - // Used for testing pinning, so we should never get here --- the overrides - // should determine the result instead. - return 1; - } - - @Override - void doCompute(List<ResolvedComponentInfo> targets) {} - - @Override - public float getScore(TargetInfo targetInfo) { - return 0; - } - - @Override - void handleResultMessage(Message message) {} - }; - return testComparator; - } - -} diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt deleted file mode 100644 index 742aac71..00000000 --- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutLoaderTest.kt +++ /dev/null @@ -1,463 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.shortcuts - -import android.app.prediction.AppPredictor -import android.content.ComponentName -import android.content.Context -import android.content.IntentFilter -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import android.content.pm.PackageManager.ApplicationInfoFlags -import android.content.pm.ShortcutManager -import android.os.UserHandle -import android.os.UserManager -import androidx.lifecycle.Lifecycle -import androidx.test.filters.SmallTest -import com.android.intentresolver.TestLifecycleOwner -import com.android.intentresolver.any -import com.android.intentresolver.argumentCaptor -import com.android.intentresolver.capture -import com.android.intentresolver.chooser.DisplayResolveInfo -import com.android.intentresolver.createAppTarget -import com.android.intentresolver.createShareShortcutInfo -import com.android.intentresolver.createShortcutInfo -import com.android.intentresolver.mock -import com.android.intentresolver.whenever -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.atLeastOnce -import org.mockito.Mockito.never -import org.mockito.Mockito.times -import org.mockito.Mockito.verify -import java.util.function.Consumer - -@OptIn(ExperimentalCoroutinesApi::class) -@SmallTest -class ShortcutLoaderTest { - private val appInfo = ApplicationInfo().apply { - enabled = true - flags = 0 - } - private val pm = mock<PackageManager> { - whenever(getApplicationInfo(any(), any<ApplicationInfoFlags>())).thenReturn(appInfo) - } - private val userManager = mock<UserManager> { - whenever(isUserRunning(any<UserHandle>())).thenReturn(true) - whenever(isUserUnlocked(any<UserHandle>())).thenReturn(true) - whenever(isQuietModeEnabled(any<UserHandle>())).thenReturn(false) - } - private val context = mock<Context> { - whenever(packageManager).thenReturn(pm) - whenever(createContextAsUser(any(), anyInt())).thenReturn(this) - whenever(getSystemService(Context.USER_SERVICE)).thenReturn(userManager) - } - private val scheduler = TestCoroutineScheduler() - private val dispatcher = UnconfinedTestDispatcher(scheduler) - private val lifecycleOwner = TestLifecycleOwner() - private val intentFilter = mock<IntentFilter>() - private val appPredictor = mock<ShortcutLoader.AppPredictorProxy>() - private val callback = mock<Consumer<ShortcutLoader.Result>>() - private val componentName = ComponentName("pkg", "Class") - private val appTarget = mock<DisplayResolveInfo> { - whenever(resolvedComponentName).thenReturn(componentName) - } - private val appTargets = arrayOf(appTarget) - private val matchingShortcutInfo = createShortcutInfo("id-0", componentName, 1) - - @Before - fun setup() { - Dispatchers.setMain(dispatcher) - lifecycleOwner.state = Lifecycle.State.CREATED - } - - @After - fun cleanup() { - lifecycleOwner.state = Lifecycle.State.DESTROYED - Dispatchers.resetMain() - } - - @Test - fun test_loadShortcutsWithAppPredictor_resultIntegrity() { - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) - - testSubject.updateAppTargets(appTargets) - - val matchingAppTarget = createAppTarget(matchingShortcutInfo) - val shortcuts = listOf( - matchingAppTarget, - // an AppTarget that does not belong to any resolved application; should be ignored - createAppTarget( - createShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - ) - val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>() - verify(appPredictor, atLeastOnce()) - .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) - appPredictorCallbackCaptor.value.onTargetsAvailable(shortcuts) - - val resultCaptor = argumentCaptor<ShortcutLoader.Result>() - verify(callback, times(1)).accept(capture(resultCaptor)) - - val result = resultCaptor.value - assertTrue("An app predictor result is expected", result.isFromAppPredictor) - assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) - assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) - assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) - for (shortcut in result.shortcutsByApp[0].shortcuts) { - assertEquals( - "Wrong AppTarget in the cache", - matchingAppTarget, - result.directShareAppTargetCache[shortcut] - ) - assertEquals( - "Wrong ShortcutInfo in the cache", - matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] - ) - } - } - - @Test - fun test_loadShortcutsWithShortcutManager_resultIntegrity() { - val shortcutManagerResult = listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } - whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - null, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) - - testSubject.updateAppTargets(appTargets) - - val resultCaptor = argumentCaptor<ShortcutLoader.Result>() - verify(callback, times(1)).accept(capture(resultCaptor)) - - val result = resultCaptor.value - assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) - assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) - assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) - assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) - for (shortcut in result.shortcutsByApp[0].shortcuts) { - assertTrue( - "AppTargets are not expected the cache of a ShortcutManager result", - result.directShareAppTargetCache.isEmpty() - ) - assertEquals( - "Wrong ShortcutInfo in the cache", - matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] - ) - } - } - - @Test - fun test_appPredictorReturnsEmptyList_fallbackToShortcutManager() { - val shortcutManagerResult = listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } - whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) - - testSubject.updateAppTargets(appTargets) - - verify(appPredictor, times(1)).requestPredictionUpdate() - val appPredictorCallbackCaptor = argumentCaptor<AppPredictor.Callback>() - verify(appPredictor, times(1)) - .registerPredictionUpdates(any(), capture(appPredictorCallbackCaptor)) - appPredictorCallbackCaptor.value.onTargetsAvailable(emptyList()) - - val resultCaptor = argumentCaptor<ShortcutLoader.Result>() - verify(callback, times(1)).accept(capture(resultCaptor)) - - val result = resultCaptor.value - assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) - assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) - assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) - assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) - for (shortcut in result.shortcutsByApp[0].shortcuts) { - assertTrue( - "AppTargets are not expected the cache of a ShortcutManager result", - result.directShareAppTargetCache.isEmpty() - ) - assertEquals( - "Wrong ShortcutInfo in the cache", - matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] - ) - } - } - - @Test - fun test_appPredictor_requestPredictionUpdateFailure_fallbackToShortcutManager() { - val shortcutManagerResult = listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } - whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - whenever(appPredictor.requestPredictionUpdate()) - .thenThrow(IllegalStateException("Test exception")) - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) - - testSubject.updateAppTargets(appTargets) - - verify(appPredictor, times(1)).requestPredictionUpdate() - - val resultCaptor = argumentCaptor<ShortcutLoader.Result>() - verify(callback, times(1)).accept(capture(resultCaptor)) - - val result = resultCaptor.value - assertFalse("An ShortcutManager result is expected", result.isFromAppPredictor) - assertArrayEquals("Wrong input app targets in the result", appTargets, result.appTargets) - assertEquals("Wrong shortcut count", 1, result.shortcutsByApp.size) - assertEquals("Wrong app target", appTarget, result.shortcutsByApp[0].appTarget) - for (shortcut in result.shortcutsByApp[0].shortcuts) { - assertTrue( - "AppTargets are not expected the cache of a ShortcutManager result", - result.directShareAppTargetCache.isEmpty() - ) - assertEquals( - "Wrong ShortcutInfo in the cache", - matchingShortcutInfo, - result.directShareShortcutInfoCache[shortcut] - ) - } - } - - @Test - fun test_ShortcutLoader_shortcutsRequestedIndependentlyFromAppTargets() { - ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) - - verify(appPredictor, times(1)).requestPredictionUpdate() - verify(callback, never()).accept(any()) - } - - @Test - fun test_ShortcutLoader_noResultsWithoutAppTargets() { - val shortcutManagerResult = listOf( - ShortcutManager.ShareShortcutInfo(matchingShortcutInfo, componentName), - // mismatching shortcut - createShareShortcutInfo("id-1", ComponentName("mismatching.pkg", "Class"), 1) - ) - val shortcutManager = mock<ShortcutManager> { - whenever(getShareTargets(intentFilter)).thenReturn(shortcutManagerResult) - } - whenever(context.getSystemService(Context.SHORTCUT_SERVICE)).thenReturn(shortcutManager) - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - null, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) - - verify(shortcutManager, times(1)).getShareTargets(any()) - verify(callback, never()).accept(any()) - - testSubject.reset() - - verify(shortcutManager, times(2)).getShareTargets(any()) - verify(callback, never()).accept(any()) - - testSubject.updateAppTargets(appTargets) - - verify(shortcutManager, times(2)).getShareTargets(any()) - verify(callback, times(1)).accept(any()) - } - - @Test - fun test_OnLifecycleDestroyed_unsubscribeFromAppPredictor() { - ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - UserHandle.of(0), - true, - intentFilter, - dispatcher, - callback - ) - - verify(appPredictor, never()).unregisterPredictionUpdates(any()) - - lifecycleOwner.state = Lifecycle.State.DESTROYED - - verify(appPredictor, times(1)).unregisterPredictionUpdates(any()) - } - - @Test - fun test_workProfileNotRunning_doNotCallServices() { - testDisabledWorkProfileDoNotCallSystem(isUserRunning = false) - } - - @Test - fun test_workProfileLocked_doNotCallServices() { - testDisabledWorkProfileDoNotCallSystem(isUserUnlocked = false) - } - - @Test - fun test_workProfileQuiteModeEnabled_doNotCallServices() { - testDisabledWorkProfileDoNotCallSystem(isQuietModeEnabled = true) - } - - @Test - fun test_mainProfileNotRunning_callServicesAnyway() { - testAlwaysCallSystemForMainProfile(isUserRunning = false) - } - - @Test - fun test_mainProfileLocked_callServicesAnyway() { - testAlwaysCallSystemForMainProfile(isUserUnlocked = false) - } - - @Test - fun test_mainProfileQuiteModeEnabled_callServicesAnyway() { - testAlwaysCallSystemForMainProfile(isQuietModeEnabled = true) - } - - private fun testDisabledWorkProfileDoNotCallSystem( - isUserRunning: Boolean = true, - isUserUnlocked: Boolean = true, - isQuietModeEnabled: Boolean = false - ) { - val userHandle = UserHandle.of(10) - with(userManager) { - whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) - whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) - whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) - } - whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); - val appPredictor = mock<ShortcutLoader.AppPredictorProxy>() - val callback = mock<Consumer<ShortcutLoader.Result>>() - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - userHandle, - false, - intentFilter, - dispatcher, - callback - ) - - testSubject.updateAppTargets(arrayOf<DisplayResolveInfo>(mock())) - - verify(appPredictor, never()).requestPredictionUpdate() - } - - private fun testAlwaysCallSystemForMainProfile( - isUserRunning: Boolean = true, - isUserUnlocked: Boolean = true, - isQuietModeEnabled: Boolean = false - ) { - val userHandle = UserHandle.of(10) - with(userManager) { - whenever(isUserRunning(userHandle)).thenReturn(isUserRunning) - whenever(isUserUnlocked(userHandle)).thenReturn(isUserUnlocked) - whenever(isQuietModeEnabled(userHandle)).thenReturn(isQuietModeEnabled) - } - whenever(context.getSystemService(Context.USER_SERVICE)).thenReturn(userManager); - val appPredictor = mock<ShortcutLoader.AppPredictorProxy>() - val callback = mock<Consumer<ShortcutLoader.Result>>() - val testSubject = ShortcutLoader( - context, - lifecycleOwner.lifecycle, - appPredictor, - userHandle, - true, - intentFilter, - dispatcher, - callback - ) - - testSubject.updateAppTargets(arrayOf<DisplayResolveInfo>(mock())) - - verify(appPredictor, times(1)).requestPredictionUpdate() - } -} diff --git a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt b/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt deleted file mode 100644 index e0de005d..00000000 --- a/java/tests/src/com/android/intentresolver/shortcuts/ShortcutToChooserTargetConverterTest.kt +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright (C) 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.intentresolver.shortcuts - -import android.app.prediction.AppTarget -import android.content.ComponentName -import android.content.Intent -import android.content.pm.ShortcutInfo -import android.content.pm.ShortcutManager.ShareShortcutInfo -import android.service.chooser.ChooserTarget -import com.android.intentresolver.createAppTarget -import com.android.intentresolver.createShareShortcutInfo -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Test - -private const val PACKAGE = "org.package" - -class ShortcutToChooserTargetConverterTest { - private val testSubject = ShortcutToChooserTargetConverter() - private val ranks = arrayOf(3 ,7, 1 ,3) - private val shortcuts = ranks - .foldIndexed(ArrayList<ShareShortcutInfo>(ranks.size)) { i, acc, rank -> - val id = i + 1 - acc.add( - createShareShortcutInfo( - id = "id-$i", - componentName = ComponentName(PACKAGE, "Class$id"), - rank, - ) - ) - acc - } - - @Test - fun testConvertToChooserTarget_predictionService() { - val appTargets = shortcuts.map { createAppTarget(it.shortcutInfo) } - val expectedOrderAllShortcuts = intArrayOf(0, 1, 2, 3) - val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.98f, 0.97f) - val appTargetCache = HashMap<ChooserTarget, AppTarget>() - val shortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>() - - var chooserTargets = testSubject.convertToChooserTarget( - shortcuts, - shortcuts, - appTargets, - appTargetCache, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, - chooserTargets, - expectedOrderAllShortcuts, - expectedScoreAllShortcuts, - ) - assertAppTargetCache(chooserTargets, appTargetCache) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - - val subset = shortcuts.subList(1, shortcuts.size) - val expectedOrderSubset = intArrayOf(1, 2, 3) - val expectedScoreSubset = floatArrayOf(0.99f, 0.98f, 0.97f) - appTargetCache.clear() - shortcutInfoCache.clear() - - chooserTargets = testSubject.convertToChooserTarget( - subset, - shortcuts, - appTargets, - appTargetCache, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, - chooserTargets, - expectedOrderSubset, - expectedScoreSubset, - ) - assertAppTargetCache(chooserTargets, appTargetCache) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - } - - @Test - fun testConvertToChooserTarget_shortcutManager() { - val testSubject = ShortcutToChooserTargetConverter() - val expectedOrderAllShortcuts = intArrayOf(2, 0, 3, 1) - val expectedScoreAllShortcuts = floatArrayOf(1.0f, 0.99f, 0.99f, 0.98f) - val shortcutInfoCache = HashMap<ChooserTarget, ShortcutInfo>() - - var chooserTargets = testSubject.convertToChooserTarget( - shortcuts, - shortcuts, - null, - null, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, chooserTargets, - expectedOrderAllShortcuts, expectedScoreAllShortcuts - ) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - - val subset: MutableList<ShareShortcutInfo> = java.util.ArrayList() - subset.add(shortcuts[1]) - subset.add(shortcuts[2]) - subset.add(shortcuts[3]) - val expectedOrderSubset = intArrayOf(2, 3, 1) - val expectedScoreSubset = floatArrayOf(1.0f, 0.99f, 0.98f) - shortcutInfoCache.clear() - - chooserTargets = testSubject.convertToChooserTarget( - subset, - shortcuts, - null, - null, - shortcutInfoCache, - ) - - assertCorrectShortcutToChooserTargetConversion( - shortcuts, chooserTargets, - expectedOrderSubset, expectedScoreSubset - ) - assertShortcutInfoCache(chooserTargets, shortcutInfoCache) - } - - private fun assertCorrectShortcutToChooserTargetConversion( - shortcuts: List<ShareShortcutInfo>, - chooserTargets: List<ChooserTarget>, - expectedOrder: IntArray, - expectedScores: FloatArray, - ) { - assertEquals("Unexpected ChooserTarget count", expectedOrder.size, chooserTargets.size) - for (i in chooserTargets.indices) { - val ct = chooserTargets[i] - val si = shortcuts[expectedOrder[i]].shortcutInfo - val cn = shortcuts[expectedOrder[i]].targetComponent - assertEquals(si.id, ct.intentExtras.getString(Intent.EXTRA_SHORTCUT_ID)) - assertEquals(si.label, ct.title) - assertEquals(expectedScores[i], ct.score) - assertEquals(cn, ct.componentName) - } - } - - private fun assertAppTargetCache( - chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, AppTarget> - ) { - for (ct in chooserTargets) { - val target = cache[ct] - assertNotNull("AppTarget is missing", target) - } - } - - private fun assertShortcutInfoCache( - chooserTargets: List<ChooserTarget>, cache: Map<ChooserTarget, ShortcutInfo> - ) { - for (ct in chooserTargets) { - val si = cache[ct] - assertNotNull("AppTarget is missing", si) - } - } -} diff --git a/java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt b/java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt deleted file mode 100644 index 18218064..00000000 --- a/java/tests/src/com/android/intentresolver/util/UriFiltersTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.android.intentresolver.util - -import android.app.PendingIntent -import android.content.IIntentReceiver -import android.content.IIntentSender -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.drawable.Icon -import android.net.Uri -import android.os.Binder -import android.os.Bundle -import android.os.IBinder -import android.os.UserHandle -import android.service.chooser.ChooserAction -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class UriFiltersTest { - - @Test - fun uri_ownedByCurrentUser_noUserId() { - val uri = Uri.parse("content://media/images/12345") - assertTrue("Uri without userId should always return true", uri.ownedByCurrentUser) - } - - @Test - fun uri_ownedByCurrentUser_selfUserId() { - val uri = Uri.parse("content://${UserHandle.myUserId()}@media/images/12345") - assertTrue("Uri with own userId should return true", uri.ownedByCurrentUser) - } - - @Test - fun uri_ownedByCurrentUser_otherUserId() { - val otherUserId = UserHandle.myUserId() + 10 - val uri = Uri.parse("content://${otherUserId}@media/images/12345") - assertFalse("Uri with other userId should return false", uri.ownedByCurrentUser) - } - - @Test - fun chooserAction_hasValidIcon_bitmap() = - smallBitmap().use { - val icon = Icon.createWithBitmap(it) - val action = actionWithIcon(icon) - assertTrue("No uri, assumed valid", hasValidIcon(action)) - } - - @Test - fun chooserAction_hasValidIcon_uri() { - val icon = Icon.createWithContentUri("content://provider/content/12345") - assertTrue("No userId in uri, uri is valid", hasValidIcon(actionWithIcon(icon))) - } - @Test - fun chooserAction_hasValidIcon_uri_unowned() { - val userId = UserHandle.myUserId() + 10 - val icon = Icon.createWithContentUri("content://${userId}@provider/content/12345") - assertFalse("uri userId references a different user", hasValidIcon(actionWithIcon(icon))) - } - - private fun smallBitmap() = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) - - private fun mockAction(): PendingIntent { - return PendingIntent( - object : IIntentSender { - override fun asBinder(): IBinder = Binder() - override fun send( - code: Int, - intent: Intent?, - resolvedType: String?, - whitelistToken: IBinder?, - finishedReceiver: IIntentReceiver?, - requiredPermission: String?, - options: Bundle? - ) { - /* empty */ - } - } - ) - } - - private fun actionWithIcon(icon: Icon): ChooserAction { - return ChooserAction.Builder(icon, "", mockAction()).build() - } - - /** Unconditionally recycles the [Bitmap] after running the given block */ - private fun Bitmap.use(block: (Bitmap) -> Unit) = - try { - block(this) - } finally { - recycle() - } -} diff --git a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt b/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt deleted file mode 100644 index e65cba5f..00000000 --- a/java/tests/src/com/android/intentresolver/widget/BatchPreviewLoaderTest.kt +++ /dev/null @@ -1,215 +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.widget - -import android.graphics.Bitmap -import android.net.Uri -import com.android.intentresolver.captureMany -import com.android.intentresolver.mock -import com.android.intentresolver.widget.ScrollableImagePreviewView.BatchPreviewLoader -import com.android.intentresolver.widget.ScrollableImagePreviewView.Preview -import com.android.intentresolver.widget.ScrollableImagePreviewView.PreviewType -import com.android.intentresolver.withArgCaptor -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.atLeast -import org.mockito.Mockito.times -import org.mockito.Mockito.verify - -@OptIn(ExperimentalCoroutinesApi::class) -class BatchPreviewLoaderTest { - private val dispatcher = UnconfinedTestDispatcher() - private val testScope = CoroutineScope(dispatcher) - private val onCompletion = mock<() -> Unit>() - private val onReset = mock<(Int) -> Unit>() - private val onUpdate = mock<(List<Preview>) -> Unit>() - - @Before - fun setup() { - Dispatchers.setMain(dispatcher) - } - - @After - fun cleanup() { - testScope.cancel() - Dispatchers.resetMain() - } - - @Test - fun test_allImagesWithinViewPort_oneUpdate() { - val imageLoader = TestImageLoader(testScope) - val uriOne = createUri(1) - val uriTwo = createUri(2) - imageLoader.setUriLoadingOrder(succeed(uriTwo), succeed(uriOne)) - val testSubject = - BatchPreviewLoader( - imageLoader, - previews(uriOne, uriTwo), - 0, - onReset, - onUpdate, - onCompletion - ) - testSubject.loadAspectRatios(200) { _, _, _ -> 100 } - dispatcher.scheduler.advanceUntilIdle() - - verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(2) - val list = withArgCaptor { verify(onUpdate, times(1)).invoke(capture()) }.map { it.uri } - assertThat(list).containsExactly(uriOne, uriTwo).inOrder() - } - - @Test - fun test_allImagesWithinViewPortOneFailed_failedPreviewIsNotUpdated() { - val imageLoader = TestImageLoader(testScope) - val uriOne = createUri(1) - val uriTwo = createUri(2) - val uriThree = createUri(3) - imageLoader.setUriLoadingOrder(succeed(uriThree), fail(uriTwo), succeed(uriOne)) - val testSubject = - BatchPreviewLoader( - imageLoader, - previews(uriOne, uriTwo, uriThree), - 0, - onReset, - onUpdate, - onCompletion - ) - testSubject.loadAspectRatios(200) { _, _, _ -> 100 } - dispatcher.scheduler.advanceUntilIdle() - - verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(3) - val list = withArgCaptor { verify(onUpdate, times(1)).invoke(capture()) }.map { it.uri } - assertThat(list).containsExactly(uriOne, uriThree).inOrder() - } - - @Test - fun test_imagesLoadedNotInOrder_updatedInOrder() { - val imageLoader = TestImageLoader(testScope) - val uris = Array(10) { createUri(it) } - val loadingOrder = - Array(uris.size) { i -> - val uriIdx = - when { - i % 2 == 1 -> i - 1 - i % 2 == 0 && i < uris.size - 1 -> i + 1 - else -> i - } - succeed(uris[uriIdx]) - } - imageLoader.setUriLoadingOrder(*loadingOrder) - val testSubject = - BatchPreviewLoader(imageLoader, previews(*uris), 0, onReset, onUpdate, onCompletion) - testSubject.loadAspectRatios(200) { _, _, _ -> 100 } - dispatcher.scheduler.advanceUntilIdle() - - verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(uris.size) - val list = - captureMany { verify(onUpdate, atLeast(1)).invoke(capture()) } - .fold(ArrayList<Preview>()) { acc, update -> acc.apply { addAll(update) } } - .map { it.uri } - assertThat(list).containsExactly(*uris).inOrder() - } - - @Test - fun test_imagesLoadedNotInOrderSomeFailed_updatedInOrder() { - val imageLoader = TestImageLoader(testScope) - val uris = Array(10) { createUri(it) } - val loadingOrder = - Array(uris.size) { i -> - val uriIdx = - when { - i % 2 == 1 -> i - 1 - i % 2 == 0 && i < uris.size - 1 -> i + 1 - else -> i - } - if (uriIdx % 2 == 0) fail(uris[uriIdx]) else succeed(uris[uriIdx]) - } - val expectedUris = Array(uris.size / 2) { createUri(it * 2 + 1) } - imageLoader.setUriLoadingOrder(*loadingOrder) - val testSubject = - BatchPreviewLoader(imageLoader, previews(*uris), 0, onReset, onUpdate, onCompletion) - testSubject.loadAspectRatios(200) { _, _, _ -> 100 } - dispatcher.scheduler.advanceUntilIdle() - - verify(onCompletion, times(1)).invoke() - verify(onReset, times(1)).invoke(uris.size) - val list = - captureMany { verify(onUpdate, atLeast(1)).invoke(capture()) } - .fold(ArrayList<Preview>()) { acc, update -> acc.apply { addAll(update) } } - .map { it.uri } - assertThat(list).containsExactly(*expectedUris).inOrder() - } - - private fun createUri(idx: Int): Uri = Uri.parse("content://org.pkg.app/image-$idx.png") - - private fun fail(uri: Uri) = uri to false - private fun succeed(uri: Uri) = uri to true - private fun previews(vararg uris: Uri) = - uris.fold(ArrayList<Preview>(uris.size)) { acc, uri -> - acc.apply { add(Preview(PreviewType.Image, uri, editAction = null)) } - } -} - -private class TestImageLoader(scope: CoroutineScope) : suspend (Uri, Boolean) -> Bitmap? { - private val loadingOrder = ArrayDeque<Pair<Uri, Boolean>>() - private val pendingRequests = LinkedHashMap<Uri, CompletableDeferred<Bitmap?>>() - private val flow = MutableSharedFlow<Unit>(replay = 1) - private val bitmap by lazy { Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) } - - init { - scope.launch { - flow.collect { - while (true) { - val (nextUri, isLoaded) = loadingOrder.firstOrNull() ?: break - val deferred = pendingRequests.remove(nextUri) ?: break - loadingOrder.removeFirst() - deferred.complete(if (isLoaded) bitmap else null) - } - if (loadingOrder.isEmpty()) { - pendingRequests.forEach { (uri, deferred) -> deferred.complete(bitmap) } - pendingRequests.clear() - } - } - } - } - - fun setUriLoadingOrder(vararg uris: Pair<Uri, Boolean>) { - loadingOrder.clear() - loadingOrder.addAll(uris) - } - - override suspend fun invoke(uri: Uri, cache: Boolean): Bitmap? { - val deferred = pendingRequests.getOrPut(uri) { CompletableDeferred() } - flow.tryEmit(Unit) - return deferred.await() - } -} |