diff options
90 files changed, 20570 insertions, 49 deletions
@@ -45,6 +45,7 @@ android_library { static_libs: [ "androidx.annotation_annotation", + "unsupportedappusage", ], plugins: ["java_api_finder"], @@ -66,6 +67,12 @@ android_app { static_libs: [ "IntentResolver-core", ], + optimize: { + // TODO: consider re-enabling after setting up Proguard rules to + // preserve the name of the ChooserGridLayoutManager class, which is + // referenced by name in the chooser_list_per_profile layout XML. + enabled: false, + }, apex_available: [ "//apex_available:platform", "com.android.intentresolver", diff --git a/java/res/drawable/bottomsheet_background.xml b/java/res/drawable/bottomsheet_background.xml new file mode 100644 index 00000000..60fd7e21 --- /dev/null +++ b/java/res/drawable/bottomsheet_background.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2018 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android"> + <corners + android:topLeftRadius="@*android:dimen/config_bottomDialogCornerRadius" + android:topRightRadius="@*android:dimen/config_bottomDialogCornerRadius"/> + <solid android:color="?android:attr/colorBackground" /> +</shape> diff --git a/java/res/drawable/chooser_action_button_bg.xml b/java/res/drawable/chooser_action_button_bg.xml new file mode 100644 index 00000000..b984a067 --- /dev/null +++ b/java/res/drawable/chooser_action_button_bg.xml @@ -0,0 +1,33 @@ +<?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 + --> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?android:attr/colorControlHighlight" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"> + <item> + <inset + android:insetLeft="0dp" + android:insetTop="8dp" + android:insetRight="0dp" + android:insetBottom="8dp"> + <shape android:shape="rectangle"> + <corners android:radius="8dp" /> + <stroke android:width="1dp" + android:color="?androidprv:attr/colorAccentPrimaryVariant"/> + </shape> + </inset> + </item> +</ripple> diff --git a/java/res/drawable/chooser_dialog_background.xml b/java/res/drawable/chooser_dialog_background.xml new file mode 100644 index 00000000..9c38cb37 --- /dev/null +++ b/java/res/drawable/chooser_dialog_background.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android"> + <corners android:radius="?android:attr/dialogCornerRadius" /> + <solid android:color="?android:attr/colorBackgroundFloating" /> +</shape> diff --git a/java/res/drawable/chooser_direct_share_icon_placeholder.xml b/java/res/drawable/chooser_direct_share_icon_placeholder.xml new file mode 100644 index 00000000..9642a0d5 --- /dev/null +++ b/java/res/drawable/chooser_direct_share_icon_placeholder.xml @@ -0,0 +1,84 @@ +<?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. +--> +<animated-vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt"> + <aapt:attr name="android:drawable"> + <vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="36dp" + android:height="36dp" + android:viewportHeight="64" + android:viewportWidth="64"> + + <group android:name="background"> + <path android:pathData="M0,0 L 64,0 64,64 0,64 z" + android:fillColor="@android:color/transparent"/> + </group> + + <!-- Gradient starts offscreen so it is not visible in the first frame before start --> + <group android:name="gradient" android:translateX="-128"> + <path + android:pathData="M0,0 L 128,0 128,128 0,128 z"> + <aapt:attr name="android:fillColor"> + <gradient + android:type="linear" + android:startX="0" + android:endX="128" + android:startY="0" + android:endY="0"> + <item + android:color="@android:color/transparent" + android:offset="0.0" /> + <item + android:color="@android:color/transparent" + android:offset="0.5" /> + <item + android:color="@android:color/transparent" + android:offset="1.0" /> + </gradient> + </aapt:attr> + </path> + </group> + + <!-- Use a foregroud with a cutout shape matching direct share inset for appx applied + shadow. Using clip-path is a more elegant solution but leaves awful jaggies around + the path's shape. --> + <group android:name="cover"> + <path android:fillColor="@android:color/transparent" + android:pathData="M0,0 L64,0 L64,64 L0,64 L0,0 Z M59.0587325,42.453601 C60.3124932,39.2104785 61,35.6855272 61,32 C61,15.9837423 48.0162577,3 32,3 C15.9837423,3 3,15.9837423 3,32 C3,48.0162577 15.9837423,61 32,61 C35.6855272,61 39.2104785,60.3124932 42.453601,59.0587325 C44.3362195,60.2864794 46.5847839,61 49,61 C55.627417,61 61,55.627417 61,49 C61,46.5847839 60.2864794,44.3362195 59.0587325,42.453601 Z"/> + </group> + </vector> + </aapt:attr> + + <!-- This AVD uses special properties so that once started it will loop infinitely with no + need for callbacks to restart. --> + <target android:name="gradient"> + <aapt:attr name="android:animation"> + <objectAnimator + android:duration="1700" + android:pathData="M -128,0 L 192,0" + android:propertyXName="translateX" + android:repeatMode="restart" + android:repeatCount="infinite" + android:startOffset="0"> + <aapt:attr name="android:interpolator"> + <pathInterpolator android:pathData="M 0.0,0.0 c0.167,0.167 0.833,0.833 1.0,1.0" /> + </aapt:attr> + </objectAnimator> + </aapt:attr> + </target> +</animated-vector> diff --git a/java/res/drawable/chooser_direct_share_label_placeholder.xml b/java/res/drawable/chooser_direct_share_label_placeholder.xml new file mode 100644 index 00000000..b21444bf --- /dev/null +++ b/java/res/drawable/chooser_direct_share_label_placeholder.xml @@ -0,0 +1,37 @@ +<?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/chooser_file_generic.xml b/java/res/drawable/chooser_file_generic.xml new file mode 100644 index 00000000..006dfba4 --- /dev/null +++ b/java/res/drawable/chooser_file_generic.xml @@ -0,0 +1,24 @@ +<!-- +Copyright (C) 2015 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <path + android:fillColor="#FF737373" + android:pathData="M6 2c-1.1 0,-1.99.9,-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2,-.9 2,-2V8l-6,-6H6zm7 7V3.5L18.5 9H13z"/> +</vector> diff --git a/java/res/drawable/chooser_group_background.xml b/java/res/drawable/chooser_group_background.xml new file mode 100644 index 00000000..036028de --- /dev/null +++ b/java/res/drawable/chooser_group_background.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/ic_chooser_group_arrow" + android:gravity="end|center_vertical" + android:width="12dp" + android:height="12dp" + android:start="4dp" + android:end="0dp" /> +</layer-list> diff --git a/java/res/drawable/chooser_pinned_background.xml b/java/res/drawable/chooser_pinned_background.xml new file mode 100644 index 00000000..e8c9910e --- /dev/null +++ b/java/res/drawable/chooser_pinned_background.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:drawable="@drawable/ic_chooser_pin" + android:gravity="start|top" + android:top="4dp" + android:width="12dp" + android:height="12dp" + android:start="0dp" + android:end="4dp" /> +</layer-list>
\ No newline at end of file diff --git a/java/res/drawable/chooser_row_layer_list.xml b/java/res/drawable/chooser_row_layer_list.xml new file mode 100644 index 00000000..43f0cf6f --- /dev/null +++ b/java/res/drawable/chooser_row_layer_list.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 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" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"> + <item> + <shape android:shape="rectangle"> + <solid android:color="?androidprv:attr/colorAccentSecondary"/> + <size android:width="128dp" android:height="2dp"/> + <corners android:radius="2dp" /> + </shape> + </item> +</layer-list> diff --git a/java/res/drawable/ic_chooser_group_arrow.xml b/java/res/drawable/ic_chooser_group_arrow.xml new file mode 100644 index 00000000..057c3e76 --- /dev/null +++ b/java/res/drawable/ic_chooser_group_arrow.xml @@ -0,0 +1,26 @@ +<!-- + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="12dp" + android:height="12dp" + android:viewportWidth="12" + android:viewportHeight="12" + android:tint="?android:attr/textColorSecondary"> + <path + android:pathData="M2,4L6,8L10,4L2,4Z" + android:fillColor="#FF000000"/> +</vector> diff --git a/java/res/drawable/ic_chooser_pin.xml b/java/res/drawable/ic_chooser_pin.xml new file mode 100644 index 00000000..c6877963 --- /dev/null +++ b/java/res/drawable/ic_chooser_pin.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="12dp" + android:height="12dp" + android:viewportWidth="12" + android:viewportHeight="12" + android:tint="?android:attr/textColorSecondary"> + <path + android:pathData="M8.5,2C8.5,1.45 8.055,1 7.5,1L4.5,1C3.95,1 3.5,1.45 3.5,2L3.5,5.5L2.5,7L2.5,8L5.5,8L5.5,10.5L6,11L6.5,10.5L6.5,8L9.5,8L9.5,7L8.5,5.5L8.5,2Z" + android:fillColor="#FF000000" /> +</vector> diff --git a/java/res/drawable/ic_chooser_pin_dialog.xml b/java/res/drawable/ic_chooser_pin_dialog.xml new file mode 100644 index 00000000..2ac01c79 --- /dev/null +++ b/java/res/drawable/ic_chooser_pin_dialog.xml @@ -0,0 +1,25 @@ +<!-- + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M14,4v5c0,1.12 0.37,2.16 1,3H9c0.65,-0.86 1,-1.9 1,-3V4H14M17,2H7C6.45,2 6,2.45 6,3c0,0.55 0.45,1 1,1c0,0 0,0 0,0l1,0v5c0,1.66 -1.34,3 -3,3v2h5.97v7l1,1l1,-1v-7H19v-2c0,0 0,0 0,0c-1.66,0 -3,-1.34 -3,-3V4l1,0c0,0 0,0 0,0c0.55,0 1,-0.45 1,-1C18,2.45 17.55,2 17,2L17,2z" + android:fillColor="#FF000000"/> +</vector> diff --git a/java/res/drawable/ic_drag_handle.xml b/java/res/drawable/ic_drag_handle.xml new file mode 100644 index 00000000..9b0e2046 --- /dev/null +++ b/java/res/drawable/ic_drag_handle.xml @@ -0,0 +1,21 @@ +<!-- + 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. +--> +<shape + xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle" > + <solid android:color="#FFFFFFFF" /> + <corners android:radius="2dp" /> +</shape> diff --git a/java/res/drawable/ic_file_copy.xml b/java/res/drawable/ic_file_copy.xml new file mode 100644 index 00000000..d05b55f1 --- /dev/null +++ b/java/res/drawable/ic_file_copy.xml @@ -0,0 +1,25 @@ +<!-- + 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. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:tint="@*android:color/material_grey_600" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM15,5l6,6v10c0,1.1 -0.9,2 -2,2L7.99,23C6.89,23 6,22.1 6,21l0.01,-14c0,-1.1 0.89,-2 1.99,-2h7zM14,12h5.5L14,6.5L14,12z" + android:fillColor="@android:color/white"/> +</vector> diff --git a/java/res/drawable/iconfactory_adaptive_icon_drawable_wrapper.xml b/java/res/drawable/iconfactory_adaptive_icon_drawable_wrapper.xml new file mode 100644 index 00000000..7d6677a9 --- /dev/null +++ b/java/res/drawable/iconfactory_adaptive_icon_drawable_wrapper.xml @@ -0,0 +1,24 @@ +<?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 + --> + +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@android:color/white"/> + <foreground> + <drawable + class="com.android.intentresolver.SimpleIconFactory$FixedScaleDrawable"/> + </foreground> +</adaptive-icon> diff --git a/java/res/drawable/resolver_button_bg.xml b/java/res/drawable/resolver_button_bg.xml new file mode 100644 index 00000000..0ab15e82 --- /dev/null +++ b/java/res/drawable/resolver_button_bg.xml @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ 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. + --> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:color="@androidprv:color/resolver_accent_ripple"> + + <item android:id="@android:id/mask"> + <shape android:shape="rectangle"> + <corners android:radius="12dp" /> + <solid android:color="@androidprv:color/resolver_accent_ripple" /> + </shape> + </item> + + <item> + <inset + android:insetLeft="0dp" + android:insetTop="6dp" + android:insetRight="0dp" + android:insetBottom="6dp"> + <shape android:shape="rectangle"> + <corners android:radius="12dp" /> + <solid android:color="@androidprv:color/resolver_profile_tab_selected_bg" /> + </shape> + </inset> + </item> + +</ripple> diff --git a/java/res/drawable/resolver_icon_placeholder.xml b/java/res/drawable/resolver_icon_placeholder.xml new file mode 100644 index 00000000..7236fbe8 --- /dev/null +++ b/java/res/drawable/resolver_icon_placeholder.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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. +--> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval"> + <solid android:color="@color/chooser_gradient_background"/> + <size android:width="36dp" android:height="36dp"/> +</shape>
\ No newline at end of file diff --git a/java/res/drawable/resolver_outlined_button_bg.xml b/java/res/drawable/resolver_outlined_button_bg.xml new file mode 100644 index 00000000..da569e72 --- /dev/null +++ b/java/res/drawable/resolver_outlined_button_bg.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:color="?android:attr/colorControlHighlight"> + <item> + <inset + android:insetLeft="0dp" + android:insetTop="6dp" + android:insetRight="0dp" + android:insetBottom="6dp"> + <shape android:shape="rectangle"> + <corners android:radius="8dp" /> + <stroke android:width="1dp" + android:color="?androidprv:attr/colorAccentPrimaryVariant"/> + </shape> + </inset> + </item> +</ripple> diff --git a/java/res/drawable/resolver_profile_tab_bg.xml b/java/res/drawable/resolver_profile_tab_bg.xml new file mode 100644 index 00000000..66cd6d88 --- /dev/null +++ b/java/res/drawable/resolver_profile_tab_bg.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ 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. + --> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:color="@androidprv:color/resolver_accent_ripple"> + + <item android:id="@android:id/mask"> + <shape android:shape="rectangle"> + <corners android:radius="12dp" /> + <solid android:color="@androidprv:color/resolver_accent_ripple" /> + </shape> + </item> + + <item> + <selector android:enterFadeDuration="100"> + <item android:state_selected="false"> + <shape android:shape="rectangle"> + <corners android:radius="12dp" /> + <solid android:color="?androidprv:attr/colorSurface" /> + </shape> + </item> + + <item android:state_selected="true"> + <shape android:shape="rectangle"> + <corners android:radius="12dp" /> + <solid android:color="@androidprv:color/resolver_profile_tab_selected_bg" /> + </shape> + </item> + </selector> + </item> + +</ripple> diff --git a/java/res/layout/chooser_action_button.xml b/java/res/layout/chooser_action_button.xml new file mode 100644 index 00000000..2b68ccca --- /dev/null +++ b/java/res/layout/chooser_action_button.xml @@ -0,0 +1,31 @@ +<!-- + ~ 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 + --> + +<Button xmlns:android="http://schemas.android.com/apk/res/android" + android:gravity="center_vertical|start" + android:paddingStart="12dp" + android:paddingEnd="12dp" + android:drawablePadding="8dp" + android:textColor="?android:attr/textColorPrimary" + android:textSize="12sp" + android:maxWidth="192dp" + android:singleLine="true" + android:clickable="true" + android:background="@drawable/chooser_action_button_bg" + android:drawableTint="?android:attr/textColorPrimary" + android:drawableTintMode="src_in" + style="?android:attr/borderlessButtonStyle" + /> diff --git a/java/res/layout/chooser_action_row.xml b/java/res/layout/chooser_action_row.xml new file mode 100644 index 00000000..ea756112 --- /dev/null +++ b/java/res/layout/chooser_action_row.xml @@ -0,0 +1,26 @@ +<!-- + ~ 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 + --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingLeft="@dimen/chooser_edge_margin_normal" + android:paddingRight="@dimen/chooser_edge_margin_normal" + android:gravity="center" + > + +</LinearLayout> diff --git a/java/res/layout/chooser_az_label_row.xml b/java/res/layout/chooser_az_label_row.xml new file mode 100644 index 00000000..baf07cec --- /dev/null +++ b/java/res/layout/chooser_az_label_row.xml @@ -0,0 +1,26 @@ +<?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 + --> + +<ImageView xmlns:android="http://schemas.android.com/apk/res/android" + android:contentDescription="@string/chooser_all_apps_button_label" + android:src="@drawable/chooser_row_layer_list" + android:paddingTop="16dp" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:scaleType="center" + android:gravity="center"/> + diff --git a/java/res/layout/chooser_dialog.xml b/java/res/layout/chooser_dialog.xml new file mode 100644 index 00000000..ff66bbb9 --- /dev/null +++ b/java/res/layout/chooser_dialog.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:background="@drawable/chooser_dialog_background" + android:orientation="vertical" + android:paddingBottom="8dp" + android:paddingTop="8dp" + android:layout_width="240dp" + android:layout_height="wrap_content"> + + <LinearLayout + android:gravity="start|center_vertical" + android:paddingStart="16dp" + android:paddingEnd="16dp" + android:minHeight="56dp" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <ImageView android:id="@android:id/icon" + android:layout_marginEnd="16dp" + android:layout_width="24dp" + android:layout_height="24dp"/> + + <TextView android:id="@android:id/title" + android:textSize="16sp" + android:textColor="?android:attr/textColorPrimary" + android:textAppearance="@android:style/TextAppearance.DeviceDefault.WindowTitle" + android:text="App name" + android:lines="1" + android:ellipsize="end" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + + </LinearLayout> + + <com.android.internal.widget.RecyclerView + xmlns:app="http://schemas.android.com/apk/res-auto" + androidprv:layoutManager="com.android.internal.widget.LinearLayoutManager" + android:id="@androidprv:id/listContainer" + android:overScrollMode="never" + android:layout_width="match_parent" + android:layout_height="wrap_content"/> + +</LinearLayout> diff --git a/java/res/layout/chooser_dialog_item.xml b/java/res/layout/chooser_dialog_item.xml new file mode 100644 index 00000000..58b07441 --- /dev/null +++ b/java/res/layout/chooser_dialog_item.xml @@ -0,0 +1,46 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:background="?android:attr/selectableItemBackground" + android:clickable="true" + android:paddingStart="16dp" + android:paddingEnd="16dp" + android:orientation="horizontal" + android:gravity="start|center_vertical" + android:minHeight="56dp" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <ImageView android:id="@android:id/icon" + android:alpha="0.54" + android:tint="?android:attr/textColorPrimary" + android:layout_marginEnd="16dp" + android:layout_width="24dp" + android:layout_height="24dp"/> + + <TextView android:id="@androidprv:id/text" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textColor="?android:attr/textColorPrimary" + android:textSize="16sp" + android:maxLines="2" + android:ellipsize="end" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + +</LinearLayout> diff --git a/java/res/layout/chooser_grid.xml b/java/res/layout/chooser_grid.xml new file mode 100644 index 00000000..a95b0ebe --- /dev/null +++ b/java/res/layout/chooser_grid.xml @@ -0,0 +1,97 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +* Copyright 2015, The Android Open Source Project +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +--> +<com.android.internal.widget.ResolverDrawerLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + androidprv:maxCollapsedHeight="0dp" + androidprv:maxCollapsedHeightSmall="56dp" + android:maxWidth="@dimen/chooser_width" + android:id="@androidprv:id/contentPanel"> + + <RelativeLayout + android:id="@androidprv:id/chooser_header" + android:layout_width="match_parent" + android:layout_height="wrap_content" + androidprv:layout_alwaysShow="true" + android:elevation="0dp" + android:background="@drawable/bottomsheet_background"> + + <ImageView + android:id="@androidprv:id/drag" + android:layout_width="24dp" + android:layout_height="4dp" + android:src="@drawable/ic_drag_handle" + android:layout_marginTop="@dimen/chooser_edge_margin_thin" + android:layout_marginBottom="@dimen/chooser_edge_margin_thin" + android:tint="?androidprv:attr/colorSurfaceVariant" + android:layout_centerHorizontal="true" + android:layout_alignParentTop="true" /> + + <TextView android:id="@android:id/title" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:textAppearance="@android:style/TextAppearance.DeviceDefault.WindowTitle" + android:gravity="center" + android:paddingBottom="@dimen/chooser_view_spacing" + android:paddingLeft="24dp" + android:paddingRight="24dp" + android:visibility="gone" + android:layout_below="@androidprv:id/drag" + android:layout_centerHorizontal="true"/> + </RelativeLayout> + + <FrameLayout + android:id="@androidprv:id/content_preview_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" /> + + <TabHost + android:id="@androidprv:id/profile_tabhost" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:layout_centerHorizontal="true" + android:background="?android:attr/colorBackground"> + <LinearLayout + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <TabWidget + android:id="@android:id/tabs" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone"> + </TabWidget> + <FrameLayout + android:id="@android:id/tabcontent" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <com.android.intentresolver.ResolverViewPager + android:id="@androidprv:id/profile_pager" + android:layout_width="match_parent" + android:layout_height="wrap_content"/> + </FrameLayout> + </LinearLayout> + </TabHost> + +</com.android.internal.widget.ResolverDrawerLayout> diff --git a/java/res/layout/chooser_grid_preview_file.xml b/java/res/layout/chooser_grid_preview_file.xml new file mode 100644 index 00000000..6d2e76a0 --- /dev/null +++ b/java/res/layout/chooser_grid_preview_file.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +* Copyright 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. +*/ +--> +<!-- Layout Option: File preview, icon, filename, copy--> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:id="@androidprv:id/content_preview_file_area" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:paddingBottom="@dimen/chooser_view_spacing" + android:background="?android:attr/colorBackground"> + + <LinearLayout + android:layout_width="@dimen/chooser_preview_width" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:orientation="horizontal" + android:paddingLeft="@dimen/chooser_edge_margin_normal" + android:paddingRight="@dimen/chooser_edge_margin_normal" + android:layout_marginBottom="@dimen/chooser_view_spacing" + android:id="@androidprv:id/content_preview_file_layout"> + + <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + android:id="@androidprv:id/content_preview_file_thumbnail" + android:layout_width="75dp" + android:layout_height="75dp" + android:layout_marginRight="16dp" + android:adjustViewBounds="true" + android:layout_gravity="center_vertical" + android:gravity="center" + android:scaleType="centerCrop"/> + <ImageView + android:id="@androidprv:id/content_preview_file_icon" + android:layout_width="36dp" + android:layout_height="36dp" + android:layout_marginRight="16dp" + android:adjustViewBounds="true" + android:layout_gravity="center_vertical" + android:gravity="center" + android:scaleType="fitCenter" + android:visibility="gone"/> + <TextView + android:id="@androidprv:id/content_preview_filename" + android:layout_width="0dp" + android:layout_weight="1" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:ellipsize="middle" + android:gravity="start|top" + android:paddingRight="24dp" + android:singleLine="true"/> + </LinearLayout> + + <include + android:id="@androidprv:id/chooser_action_row" + layout="@layout/chooser_action_row" + android:layout_width="@dimen/chooser_preview_width" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/chooser_view_spacing" + android:layout_gravity="center" + /> +</LinearLayout> + diff --git a/java/res/layout/chooser_grid_preview_image.xml b/java/res/layout/chooser_grid_preview_image.xml new file mode 100644 index 00000000..96054eb5 --- /dev/null +++ b/java/res/layout/chooser_grid_preview_image.xml @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +* Copyright 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. +*/ +--> +<!-- Layout Option: Supporting up to 3 images for preview --> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:background="?android:attr/colorBackground"> + <RelativeLayout + android:id="@androidprv:id/content_preview_image_area" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:paddingBottom="@dimen/chooser_view_spacing" + android:background="?android:attr/colorBackground"> + + <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + android:id="@androidprv:id/content_preview_image_1_large" + android:layout_width="120dp" + android:layout_height="104dp" + android:layout_alignParentTop="true" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + + <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + android:id="@androidprv:id/content_preview_image_2_large" + android:visibility="gone" + android:layout_width="120dp" + android:layout_height="104dp" + android:layout_alignParentTop="true" + android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" + android:layout_marginLeft="10dp" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + + <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + android:id="@androidprv:id/content_preview_image_2_small" + android:visibility="gone" + android:layout_width="120dp" + android:layout_height="65dp" + android:layout_alignParentTop="true" + android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" + android:layout_marginLeft="10dp" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + + <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + android:id="@androidprv:id/content_preview_image_3_small" + android:visibility="gone" + android:layout_width="120dp" + android:layout_height="65dp" + android:layout_below="@androidprv:id/content_preview_image_2_small" + android:layout_toRightOf="@androidprv:id/content_preview_image_1_large" + android:layout_marginLeft="10dp" + android:layout_marginTop="10dp" + android:adjustViewBounds="true" + android:gravity="center" + android:scaleType="centerCrop"/> + + </RelativeLayout> + + <include + android:id="@androidprv:id/chooser_action_row" + layout="@layout/chooser_action_row" + android:layout_width="@dimen/chooser_preview_width" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/chooser_view_spacing" + android:layout_gravity="center" + /> + +</LinearLayout> + diff --git a/java/res/layout/chooser_grid_preview_text.xml b/java/res/layout/chooser_grid_preview_text.xml new file mode 100644 index 00000000..a9ed71b7 --- /dev/null +++ b/java/res/layout/chooser_grid_preview_text.xml @@ -0,0 +1,102 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +* Copyright 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. +*/ +--> +<!-- Layout Option: Text preview, with optional title and thumbnail --> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:id="@androidprv:id/content_preview_text_area" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:background="?android:attr/colorBackground"> + + <RelativeLayout + android:layout_width="@dimen/chooser_preview_width" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:orientation="horizontal" + android:paddingLeft="@dimen/chooser_edge_margin_normal" + android:paddingRight="@dimen/chooser_edge_margin_normal" + android:layout_marginBottom="@dimen/chooser_view_spacing" + android:id="@androidprv:id/content_preview_text_layout"> + + <TextView + android:id="@androidprv:id/content_preview_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentStart="true" + android:layout_centerVertical="true" + android:ellipsize="end" + android:fontFamily="@androidprv:string/config_headlineFontFamily" + android:textColor="?android:attr/textColorPrimary" + android:textAlignment="gravity" + android:textDirection="locale" + android:maxLines="2" + android:focusable="true"/> + + </RelativeLayout> + + <include + android:id="@androidprv:id/chooser_action_row" + layout="@layout/chooser_action_row" + android:layout_width="@dimen/chooser_preview_width" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/chooser_view_spacing" + android:layout_gravity="center" + /> + + <!-- Required sub-layout so we can get the nice rounded corners--> + <!-- around this section --> + <LinearLayout + android:layout_width="@dimen/chooser_preview_width" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:orientation="horizontal" + android:layout_marginLeft="@dimen/chooser_edge_margin_normal" + android:layout_marginRight="@dimen/chooser_edge_margin_normal" + android:layout_marginBottom="@dimen/chooser_view_spacing" + android:minHeight="80dp" + android:background="@androidprv:drawable/chooser_content_preview_rounded" + android:id="@androidprv:id/content_preview_title_layout"> + + <view class="com.android.intentresolver.ChooserActivity$RoundedRectImageView" + android:id="@androidprv:id/content_preview_thumbnail" + android:layout_width="75dp" + android:layout_height="75dp" + android:layout_marginRight="16dp" + android:adjustViewBounds="true" + android:layout_gravity="center_vertical" + android:gravity="center" + android:scaleType="centerCrop"/> + + <TextView + android:id="@androidprv:id/content_preview_title" + android:layout_width="0dp" + android:layout_weight="1" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:ellipsize="end" + android:maxLines="2" + android:textAlignment="gravity" + android:textDirection="locale" + android:textAppearance="@android:style/TextAppearance.DeviceDefault.WindowTitle" + android:fontFamily="@androidprv:string/config_headlineFontFamily"/> + </LinearLayout> +</LinearLayout> + diff --git a/java/res/layout/chooser_list_per_profile.xml b/java/res/layout/chooser_list_per_profile.xml new file mode 100644 index 00000000..8d876cdf --- /dev/null +++ b/java/res/layout/chooser_list_per_profile.xml @@ -0,0 +1,33 @@ +<!-- + ~ Copyright (C) 2019 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<RelativeLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <com.android.internal.widget.RecyclerView + android:layout_width="match_parent" + android:layout_height="match_parent" + androidprv:layoutManager="com.android.intentresolver.ChooserGridLayoutManager" + android:id="@androidprv:id/resolver_list" + android:clipToPadding="false" + android:background="?android:attr/colorBackground" + android:scrollbars="none" + android:elevation="1dp" + android:nestedScrollingEnabled="true" /> + + <include layout="@layout/resolver_empty_states" /> +</RelativeLayout> diff --git a/java/res/layout/chooser_profile_row.xml b/java/res/layout/chooser_profile_row.xml new file mode 100644 index 00000000..855ee9ad --- /dev/null +++ b/java/res/layout/chooser_profile_row.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 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. +*/ +--> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center"> + <Button + android:id="@androidprv:id/profile_button" + android:layout_width="wrap_content" + android:layout_height="48dp" + style="?android:attr/borderlessButtonStyle" + android:textAppearance="?android:attr/textAppearanceButton" + android:textColor="?android:attr/colorAccent" + android:singleLine="true"/> +</LinearLayout> + diff --git a/java/res/layout/chooser_row.xml b/java/res/layout/chooser_row.xml new file mode 100644 index 00000000..29914a82 --- /dev/null +++ b/java/res/layout/chooser_row.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 2015, The Android Open Source Project +** +** Licensed under the Apache License, Version 2.0 (the "License"); +** you may not use this file except in compliance with the License. +** You may obtain a copy of the License at +** +** http://www.apache.org/licenses/LICENSE-2.0 +** +** Unless required by applicable law or agreed to in writing, software +** distributed under the License is distributed on an "AS IS" BASIS, +** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +** See the License for the specific language governing permissions and +** limitations under the License. +*/ +--> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:orientation="horizontal" + android:layout_width="match_parent" + android:layout_height="100dp" + android:gravity="start|top"> + <TextView + android:id="@androidprv:id/chooser_row_text_option" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center" + android:layout_gravity="center" + android:visibility="gone" /> +</LinearLayout> + diff --git a/java/res/layout/chooser_row_direct_share.xml b/java/res/layout/chooser_row_direct_share.xml new file mode 100644 index 00000000..d7e36eed --- /dev/null +++ b/java/res/layout/chooser_row_direct_share.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 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. +*/ +--> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="200dp"> + +</LinearLayout> + + diff --git a/java/res/layout/miniresolver.xml b/java/res/layout/miniresolver.xml new file mode 100644 index 00000000..ab65aa9b --- /dev/null +++ b/java/res/layout/miniresolver.xml @@ -0,0 +1,115 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<com.android.internal.widget.ResolverDrawerLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:maxWidth="@dimen/resolver_max_width" + androidprv:maxCollapsedHeight="@dimen/resolver_max_collapsed_height" + androidprv:maxCollapsedHeightSmall="56dp" + android:id="@androidprv:id/contentPanel"> + + <RelativeLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + androidprv:layout_alwaysShow="true" + android:elevation="@dimen/resolver_elevation" + android:paddingTop="24dp" + android:paddingStart="@dimen/resolver_edge_margin" + android:paddingEnd="@dimen/resolver_edge_margin" + android:paddingBottom="@dimen/resolver_title_padding_bottom" + android:background="@drawable/bottomsheet_background"> + + <ImageView + android:id="@android:id/icon" + android:layout_width="48dp" + android:layout_height="48dp" + android:layout_alignParentTop="true" + android:layout_centerHorizontal="true" + android:scaleType="fitCenter" + /> + + <TextView + android:id="@androidprv:id/open_cross_profile" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:paddingTop="16dp" + android:layout_below="@android:id/icon" + android:layout_centerHorizontal="true" + android:textSize="24sp" + android:lineHeight="32sp" + android:gravity="center" + android:textColor="?android:textColorPrimary" + /> + </RelativeLayout> + + <LinearLayout + android:id="@androidprv:id/button_bar_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + androidprv:layout_alwaysShow="true" + android:paddingTop="32dp" + android:paddingBottom="@dimen/resolver_button_bar_spacing" + android:orientation="vertical" + android:background="?android:attr/colorBackground" + androidprv:layout_ignoreOffset="true"> + <RelativeLayout + style="?android:attr/buttonBarStyle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + androidprv:layout_ignoreOffset="true" + androidprv:layout_hasNestedScrollIndicator="true" + android:gravity="end|center_vertical" + android:orientation="horizontal" + android:layoutDirection="locale" + android:measureWithLargestChild="true" + android:paddingHorizontal="16dp" + android:paddingBottom="2dp" + android:elevation="@dimen/resolver_elevation"> + + <Button + android:id="@androidprv:id/use_same_profile_browser" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentStart="true" + android:maxLines="2" + android:background="@drawable/resolver_outlined_button_bg" + style="?android:attr/borderlessButtonStyle" + android:paddingHorizontal="16dp" + android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" + android:textAllCaps="false" + android:text="@string/activity_resolver_use_once" + /> + + <Button + android:id="@androidprv:id/button_open" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentEnd="true" + android:maxLines="2" + android:paddingHorizontal="16dp" + android:background="@drawable/resolver_button_bg" + style="?android:attr/borderlessButtonStyle" + android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" + android:textAllCaps="false" + android:textColor="@androidprv:color/resolver_button_text" + android:text="@string/whichViewApplicationLabel" + /> + </RelativeLayout> + </LinearLayout> +</com.android.internal.widget.ResolverDrawerLayout> diff --git a/java/res/layout/resolve_grid_item.xml b/java/res/layout/resolve_grid_item.xml new file mode 100644 index 00000000..db6c7dd9 --- /dev/null +++ b/java/res/layout/resolve_grid_item.xml @@ -0,0 +1,65 @@ +<?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:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minHeight="100dp" + android:gravity="center" + android:paddingTop="24dp" + android:paddingBottom="12dp" + android:paddingLeft="12dp" + android:paddingRight="12dp" + 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:scaleType="fitCenter" /> + + <!-- Size manually tuned to match specs --> + <Space android:layout_width="1dp" + android:layout_height="7dp"/> + + <!-- App name or Direct Share target name, DS set to 2 lines --> + <TextView android:id="@android:id/text1" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textColor="?android:attr/textColorPrimary" + android:textSize="12sp" + android:gravity="top|center_horizontal" + android:lines="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="?android:attr/textColorSecondary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:lines="1" + android:gravity="top|center_horizontal" + android:ellipsize="end"/> + +</LinearLayout> + diff --git a/java/res/layout/resolve_list_item.xml b/java/res/layout/resolve_list_item.xml new file mode 100644 index 00000000..4d12b775 --- /dev/null +++ b/java/res/layout/resolve_list_item.xml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* //device/apps/common/res/any/layout/resolve_list_item.xml +** +** 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:orientation="horizontal" + android:layout_height="wrap_content" + android:layout_width="match_parent" + android:minHeight="?android:attr/listPreferredItemHeightSmall" + android:background="?android:attr/activatedBackgroundIndicator"> + + <!-- Activity icon when presenting dialog + Size will be filled in by ResolverActivity --> + <ImageView android:id="@android:id/icon" + android:layout_width="@dimen/resolver_icon_size" + android:layout_height="@dimen/resolver_icon_size" + android:layout_gravity="start|center_vertical" + android:layout_marginStart="@dimen/resolver_icon_margin" + android:layout_marginEnd="@dimen/resolver_icon_margin" + android:layout_marginTop="12dp" + android:layout_marginBottom="12dp" + android:scaleType="fitCenter" /> + + <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:gravity="start|center_vertical" + android:orientation="vertical" + android:paddingEnd="@dimen/resolver_edge_margin" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:layout_gravity="start|center_vertical"> + <!-- Activity name --> + <TextView android:id="@android:id/text1" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="start|center_vertical" + android:textColor="?android:attr/textColorPrimary" + android:fontFamily="@androidprv:string/config_bodyFontFamily" + android:textSize="16sp" + android:minLines="1" + android:maxLines="1" + android:ellipsize="marquee" /> + <!-- Extended activity info to distinguish between duplicate activity names + or provide record w/o permission warnings. + --> + <TextView android:id="@android:id/text2" + android:textColor="?android:attr/textColorSecondary" + android:fontFamily="@androidprv:string/config_bodyFontFamily" + android:layout_gravity="start|center_vertical" + android:textSize="14sp" + android:visibility="gone" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:minLines="1" + android:maxLines="2" + android:ellipsize="marquee" /> + </LinearLayout> +</LinearLayout> + diff --git a/java/res/layout/resolver_different_item_header.xml b/java/res/layout/resolver_different_item_header.xml new file mode 100644 index 00000000..4f801597 --- /dev/null +++ b/java/res/layout/resolver_different_item_header.xml @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* + * Copyright 2014, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +--> +<TextView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + androidprv:layout_alwaysShow="true" + android:text="@string/use_a_different_app" + android:textColor="?android:attr/textColorPrimary" + android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" + android:textSize="16sp" + android:gravity="start|center_vertical" + android:paddingStart="@dimen/resolver_edge_margin" + android:paddingEnd="@dimen/resolver_edge_margin" + android:paddingTop="@dimen/resolver_small_margin" + android:paddingBottom="@dimen/resolver_edge_margin" + android:elevation="1dp" /> diff --git a/java/res/layout/resolver_empty_states.xml b/java/res/layout/resolver_empty_states.xml new file mode 100644 index 00000000..d77630ee --- /dev/null +++ b/java/res/layout/resolver_empty_states.xml @@ -0,0 +1,91 @@ +<!-- + ~ 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. + --> + +<RelativeLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:id="@androidprv:id/resolver_empty_state" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:visibility="gone" + android:paddingStart="24dp" + android:paddingEnd="24dp"> + <RelativeLayout + android:id="@androidprv:id/resolver_empty_state_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingTop="@dimen/resolver_empty_state_container_padding_top" + android:paddingBottom="@dimen/resolver_empty_state_container_padding_bottom" + android:gravity="center_horizontal"> + <TextView + android:id="@androidprv:id/resolver_empty_state_title" + android:layout_below="@androidprv:id/resolver_empty_state_icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" + android:textColor="?android:attr/textColorPrimary" + android:textSize="18sp" + android:lineHeight="24sp" + android:gravity="center_horizontal" + android:layout_centerHorizontal="true" /> + <TextView + android:id="@androidprv:id/resolver_empty_state_subtitle" + android:layout_below="@androidprv:id/resolver_empty_state_title" + android:layout_marginTop="16dp" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="?android:attr/textColorSecondary" + android:textSize="14sp" + android:lineHeight="20sp" + android:gravity="center_horizontal" + android:layout_centerHorizontal="true" /> + <Button + android:id="@androidprv:id/resolver_empty_state_button" + android:layout_below="@androidprv:id/resolver_empty_state_subtitle" + android:layout_marginTop="16dp" + android:text="@string/resolver_switch_on_work" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:padding="12dp" + android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" + android:textSize="14sp" + android:textColor="?android:attr/textColorPrimary" + android:layout_centerHorizontal="true" + android:background="@drawable/chooser_action_button_bg" + /> + <ProgressBar + android:id="@androidprv:id/resolver_empty_state_progress" + style="@android:style/Widget.Material.Light.ProgressBar" + android:visibility="gone" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:indeterminate="true" + android:layout_alignTop="@androidprv:id/resolver_empty_state_icon" + android:layout_alignBottom="@androidprv:id/resolver_empty_state_button" + android:layout_centerHorizontal="true" + android:layout_below="@androidprv:id/resolver_empty_state_subtitle" + android:indeterminateTint="?android:attr/colorAccent"/> + </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/layout/resolver_list.xml b/java/res/layout/resolver_list.xml new file mode 100644 index 00000000..179c4073 --- /dev/null +++ b/java/res/layout/resolver_list.xml @@ -0,0 +1,172 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +* Copyright 2012, The Android Open Source Project +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +--> +<com.android.internal.widget.ResolverDrawerLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:maxWidth="@dimen/resolver_max_width" + androidprv:maxCollapsedHeight="@dimen/resolver_max_collapsed_height" + androidprv:maxCollapsedHeightSmall="56dp" + android:id="@androidprv:id/contentPanel"> + + <RelativeLayout + android:id="@androidprv:id/title_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + androidprv:layout_alwaysShow="true" + android:elevation="@dimen/resolver_elevation" + android:paddingTop="@dimen/resolver_small_margin" + android:paddingStart="@dimen/resolver_edge_margin" + android:paddingEnd="@dimen/resolver_edge_margin" + android:paddingBottom="@dimen/resolver_title_padding_bottom" + android:background="@drawable/bottomsheet_background"> + + <TextView + android:id="@androidprv:id/profile_button" + android:layout_width="wrap_content" + android:layout_height="48dp" + android:layout_marginEnd="8dp" + android:visibility="gone" + style="?android:attr/borderlessButtonStyle" + android:textAppearance="?android:attr/textAppearanceButton" + android:textColor="?android:attr/colorAccent" + android:gravity="center_vertical" + android:layout_alignParentTop="true" + android:layout_alignParentEnd="true" + android:singleLine="true" /> + + <TextView + android:id="@android:id/title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_below="@androidprv:id/profile_button" + android:layout_alignParentStart="true" + android:textColor="?android:attr/textColorPrimary" + android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" + android:textSize="16sp" + android:gravity="start|center_vertical" /> + </RelativeLayout> + + <View + android:id="@androidprv:id/divider" + androidprv:layout_alwaysShow="true" + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?android:attr/colorBackground" + android:foreground="?android:attr/dividerVertical" /> + + <FrameLayout + android:id="@androidprv:id/stub" + android:visibility="gone" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?android:attr/colorBackground"/> + + <TabHost + android:id="@androidprv:id/profile_tabhost" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:layout_centerHorizontal="true" + android:accessibilityTraversalAfter="@android:id/title" + android:background="?android:attr/colorBackground"> + <LinearLayout + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <!-- horizontal padding = 8dp content padding - 4dp margin that tab buttons have. --> + <TabWidget + android:id="@android:id/tabs" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:tabStripEnabled="false" + android:paddingHorizontal="4dp" + android:visibility="gone" /> + <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 + android:id="@androidprv:id/button_bar_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + androidprv:layout_alwaysShow="true" + android:orientation="vertical" + android:background="?android:attr/colorBackground" + androidprv:layout_ignoreOffset="true"> + <View + android:id="@androidprv:id/resolver_button_bar_divider" + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?android:attr/colorBackground" + android:foreground="?android:attr/dividerVertical" /> + <LinearLayout + android:id="@androidprv:id/button_bar" + android:visibility="gone" + style="?android:attr/buttonBarStyle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + androidprv:layout_ignoreOffset="true" + androidprv:layout_hasNestedScrollIndicator="true" + android:gravity="end|center_vertical" + android:orientation="horizontal" + android:layoutDirection="locale" + android:measureWithLargestChild="true" + android:paddingTop="@dimen/resolver_button_bar_spacing" + android:paddingBottom="@dimen/resolver_button_bar_spacing" + android:paddingStart="@dimen/resolver_edge_margin" + android:paddingEnd="@dimen/resolver_small_margin" + android:elevation="@dimen/resolver_elevation"> + + <Button + android:id="@androidprv:id/button_once" + android:layout_width="wrap_content" + android:layout_gravity="start" + android:maxLines="2" + style="?android:attr/buttonBarButtonStyle" + android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" + android:layout_height="wrap_content" + android:textAllCaps="false" + android:enabled="false" + android:text="@string/activity_resolver_use_once" + android:onClick="onButtonClick" /> + + <Button + android:id="@androidprv:id/button_always" + android:layout_width="wrap_content" + android:layout_gravity="end" + android:maxLines="2" + style="?android:attr/buttonBarButtonStyle" + android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" + android:textAllCaps="false" + android:layout_height="wrap_content" + android:enabled="false" + android:text="@string/activity_resolver_use_always" + android:onClick="onButtonClick" /> + </LinearLayout> + </LinearLayout> +</com.android.internal.widget.ResolverDrawerLayout> diff --git a/java/res/layout/resolver_list_per_profile.xml b/java/res/layout/resolver_list_per_profile.xml new file mode 100644 index 00000000..5752edcd --- /dev/null +++ b/java/res/layout/resolver_list_per_profile.xml @@ -0,0 +1,38 @@ +<?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. + --> +<RelativeLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <ListView + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:id="@androidprv:id/resolver_list" + android:clipToPadding="false" + android:background="?android:attr/colorBackground" + android:elevation="@dimen/resolver_elevation" + android:nestedScrollingEnabled="true" + android:scrollbarStyle="outsideOverlay" + android:divider="@null" + android:footerDividersEnabled="false" + android:headerDividersEnabled="false" + android:dividerHeight="0dp" /> + + <include layout="@layout/resolver_empty_states" /> +</RelativeLayout> diff --git a/java/res/layout/resolver_list_with_default.xml b/java/res/layout/resolver_list_with_default.xml new file mode 100644 index 00000000..341c58e7 --- /dev/null +++ b/java/res/layout/resolver_list_with_default.xml @@ -0,0 +1,206 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +* Copyright 2014, The Android Open Source Project +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +--> +<com.android.internal.widget.ResolverDrawerLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:maxWidth="@dimen/resolver_max_width" + androidprv:maxCollapsedHeight="@dimen/resolver_max_collapsed_height_with_default" + android:id="@androidprv:id/contentPanel"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + androidprv:layout_alwaysShow="true" + android:orientation="vertical" + android:background="@drawable/bottomsheet_background" + android:paddingTop="@dimen/resolver_small_margin" + android:elevation="@dimen/resolver_elevation"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:paddingBottom="@dimen/resolver_edge_margin" + android:paddingEnd="@dimen/resolver_edge_margin"> + <ImageView + android:id="@android:id/icon" + android:layout_width="@dimen/resolver_icon_size" + android:layout_height="@dimen/resolver_icon_size" + android:layout_gravity="start|top" + android:layout_marginStart="@dimen/resolver_icon_margin" + android:src="@drawable/resolver_icon_placeholder" + android:scaleType="fitCenter" /> + + <TextView + android:id="@android:id/title" + android:layout_width="0dp" + android:layout_weight="1" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/resolver_icon_margin" + android:textColor="?android:attr/textColorPrimary" + android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" + android:textSize="16sp" + android:gravity="start|center_vertical" + android:paddingEnd="16dp" /> + + <LinearLayout + android:id="@androidprv:id/profile_button" + android:layout_width="wrap_content" + android:layout_height="48dp" + android:layout_marginTop="4dp" + android:layout_marginEnd="4dp" + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="4dp" + android:paddingBottom="4dp" + android:focusable="true" + android:visibility="gone" + style="?android:attr/borderlessButtonStyle"> + + <ImageView + android:id="@android:id/icon" + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_gravity="start|center_vertical" + android:layout_marginEnd="?android:attr/listPreferredItemPaddingEnd" + android:layout_marginTop="12dp" + android:layout_marginBottom="12dp" + android:scaleType="fitCenter" /> + + <TextView + android:id="@android:id/text1" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="start|center_vertical" + android:layout_marginEnd="?android:attr/listPreferredItemPaddingEnd" + android:textAppearance="?android:attr/textAppearanceButton" + android:textColor="?android:attr/textColorPrimary" + android:minLines="1" + android:maxLines="1" + android:ellipsize="marquee" /> + </LinearLayout> + </LinearLayout> + + <LinearLayout + android:id="@androidprv:id/button_bar" + android:visibility="gone" + style="?android:attr/buttonBarStyle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + androidprv:layout_alwaysShow="true" + android:gravity="end|center_vertical" + android:orientation="horizontal" + android:layoutDirection="locale" + android:measureWithLargestChild="true" + android:paddingTop="@dimen/resolver_button_bar_spacing" + android:paddingBottom="@dimen/resolver_button_bar_spacing" + android:paddingStart="@dimen/resolver_edge_margin" + android:paddingEnd="@dimen/resolver_small_margin" + android:elevation="@dimen/resolver_elevation"> + + <Button + android:id="@androidprv:id/button_once" + android:layout_width="wrap_content" + android:layout_gravity="start" + android:maxLines="2" + style="?android:attr/buttonBarButtonStyle" + android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" + android:layout_height="wrap_content" + android:enabled="false" + android:textAllCaps="false" + android:text="@string/activity_resolver_use_once" + android:onClick="onButtonClick" /> + + <Button + android:id="@androidprv:id/button_always" + android:layout_width="wrap_content" + android:layout_gravity="end" + android:maxLines="2" + style="?android:attr/buttonBarButtonStyle" + android:fontFamily="@androidprv:string/config_headlineFontFamilyMedium" + android:layout_height="wrap_content" + android:enabled="false" + android:textAllCaps="false" + android:text="@string/activity_resolver_use_always" + android:onClick="onButtonClick" /> + </LinearLayout> + </LinearLayout> + + <View + android:id="@androidprv:id/divider" + androidprv:layout_alwaysShow="true" + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?android:attr/colorBackground" + android:foreground="?android:attr/dividerVertical" /> + + <FrameLayout + android:id="@androidprv:id/stub" + androidprv:layout_alwaysShow="true" + android:visibility="gone" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?android:attr/colorBackground"/> + + <TabHost + androidprv:layout_alwaysShow="true" + android:id="@androidprv:id/profile_tabhost" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:layout_centerHorizontal="true" + android:background="?android:attr/colorBackground"> + <LinearLayout + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <TabWidget + android:id="@android:id/tabs" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone"> + </TabWidget> + <View + android:id="@androidprv:id/resolver_tab_divider" + android:visibility="gone" + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?android:attr/colorBackground" + android:foreground="?android:attr/dividerVertical"/> + <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> + + <View + androidprv:layout_alwaysShow="true" + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?android:attr/colorBackground" + android:foreground="?android:attr/dividerVertical" /> +</com.android.internal.widget.ResolverDrawerLayout> diff --git a/java/res/layout/resolver_profile_tab_button.xml b/java/res/layout/resolver_profile_tab_button.xml new file mode 100644 index 00000000..95e11cdc --- /dev/null +++ b/java/res/layout/resolver_profile_tab_button.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + + <Button + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + android:layout_width="0dp" + android:layout_height="36dp" + android:layout_weight="1" + android:layout_marginVertical="6dp" + android:layout_marginHorizontal="@dimen/resolver_profile_tab_margin" + android:background="@drawable/resolver_profile_tab_bg" + android:textColor="@androidprv:color/resolver_profile_tab_text" + android:textSize="@dimen/resolver_tab_text_size" + android:textAppearance="@android:style/TextAppearance.DeviceDefault.DialogWindowTitle" + style="?android:attr/borderlessButtonStyle" /> diff --git a/java/res/values-h480dp/bools.xml b/java/res/values-h480dp/bools.xml new file mode 100644 index 00000000..7896d9bf --- /dev/null +++ b/java/res/values-h480dp/bools.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<resources> + <bool name="resolver_landscape_phone">false</bool> +</resources>
\ No newline at end of file diff --git a/java/res/values-h480dp/dimens.xml b/java/res/values-h480dp/dimens.xml new file mode 100644 index 00000000..9cdc8899 --- /dev/null +++ b/java/res/values-h480dp/dimens.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8" ?> +<!-- + ~ 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. + --> + +<resources> + <dimen name="resolver_empty_state_container_padding_top">48dp</dimen> + <dimen name="resolver_empty_state_container_padding_bottom">48dp</dimen> + <dimen name="resolver_title_padding_bottom">@dimen/resolver_edge_margin</dimen> + <dimen name="resolver_button_bar_spacing">8dp</dimen> +</resources>
\ No newline at end of file diff --git a/java/res/values-land/dimens.xml b/java/res/values-land/dimens.xml new file mode 100644 index 00000000..7e3fb9cb --- /dev/null +++ b/java/res/values-land/dimens.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** +** Copyright 2010, The Android Open Source Project +** +** Licensed under the Apache License, Version 2.0 (the "License"); +** you may not use this file except in compliance with the License. +** You may obtain a copy of the License at +** +** http://www.apache.org/licenses/LICENSE-2.0 +** +** Unless required by applicable law or agreed to in writing, software +** distributed under the License is distributed on an "AS IS" BASIS, +** WITHOUT 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> + <dimen name="chooser_preview_width">412dp</dimen> +</resources> diff --git a/java/res/values-night/colors.xml b/java/res/values-night/colors.xml new file mode 100644 index 00000000..9a4738bd --- /dev/null +++ b/java/res/values-night/colors.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2018 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + + NOTE: You might also want to edit: packages/SystemUI/res/values-night/colors.xml + --> +<resources> + + <color name="chooser_row_divider">@*android:color/list_divider_color_dark</color> + <color name="chooser_gradient_background">@*android:color/loading_gradient_background_color_dark</color> + +</resources> diff --git a/java/res/values-sw600dp/dimens.xml b/java/res/values-sw600dp/dimens.xml new file mode 100644 index 00000000..b397630e --- /dev/null +++ b/java/res/values-sw600dp/dimens.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* //device/apps/common/assets/res/any/dimens.xml +** +** 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. +*/ +--> +<resources> + + <dimen name="chooser_width">624dp</dimen> + +</resources> diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml new file mode 100644 index 00000000..3ec7c2f3 --- /dev/null +++ b/java/res/values/attrs.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 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. +--> + +<!-- Formatting note: terminate all comments with a period, to avoid breaking + the documentation output. To suppress comment lines from the documentation + output, insert an eat-comment element after the comment lines. +--> + +<resources> + <declare-styleable name="ResolverDrawerLayout"> + <attr name="android:maxWidth" /> + <attr name="maxCollapsedHeight" format="dimension" /> + <attr name="maxCollapsedHeightSmall" format="dimension" /> + <!-- Whether the Drawer should be positioned at the top rather than at the bottom. --> + <attr name="showAtTop" format="boolean" /> + </declare-styleable> + + <declare-styleable name="ResolverDrawerLayout_LayoutParams"> + <attr name="layout_alwaysShow" format="boolean" /> + <attr name="layout_ignoreOffset" format="boolean" /> + <attr name="android:layout_gravity" /> + <attr name="layout_hasNestedScrollIndicator" format="boolean" /> + <attr name="layout_maxHeight" format="dimension"/> + </declare-styleable> +</resources> diff --git a/java/res/values/bools.xml b/java/res/values/bools.xml new file mode 100644 index 00000000..a84081b6 --- /dev/null +++ b/java/res/values/bools.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2011 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT 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> + <bool name="resolver_landscape_phone">@*android:bool/resolver_landscape_phone</bool> +</resources> diff --git a/java/res/values/colors.xml b/java/res/values/colors.xml new file mode 100644 index 00000000..758e403b --- /dev/null +++ b/java/res/values/colors.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* //device/apps/common/assets/res/any/colors.xml +** +** 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. +*/ +--> +<resources> + <color name="chooser_row_divider">@*android:color/list_divider_color_light</color> + <color name="chooser_gradient_background">@*android:color/loading_gradient_background_color_light</color> +</resources> diff --git a/java/res/values/config.xml b/java/res/values/config.xml new file mode 100644 index 00000000..f8addc9f --- /dev/null +++ b/java/res/values/config.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +** Copyright 2009, The Android Open Source Project +** +** Licensed under the Apache License, Version 2.0 (the "License"); +** you may not use this file except in compliance with the License. +** You may obtain a copy of the License at +** +** http://www.apache.org/licenses/LICENSE-2.0 +** +** Unless required by applicable law or agreed to in writing, software +** distributed under the License is distributed on an "AS IS" BASIS, +** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +** See the License for the specific language governing permissions and +** limitations under the License. +*/ +--> + +<!-- These resources are around just to allow their values to be customized + for different hardware and product builds. Do not translate. + + NOTE: The naming convention is "config_camelCaseValue". Some legacy + entries do not follow the convention, but all new entries should. --> + +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <!-- The duration (in milliseconds) of a short animation. --> + <integer name="config_shortAnimTime">@android:integer/config_shortAnimTime</integer> + + <!-- Sharesheet: define a max number of targets per application for new shortcuts-based direct share introduced in Q --> + <integer name="config_maxShortcutTargetsPerApp">@*android:integer/config_maxShortcutTargetsPerApp</integer> + + <!-- Component name that accepts ACTION_SEND intents for nearby (proximity-based) sharing. + Used by ChooserActivity. --> + <string translatable="false" name="config_defaultNearbySharingComponent">@*android:string/config_defaultNearbySharingComponent</string> + + <!-- Chooser image editing activity. Must handle ACTION_EDIT image/png intents. + If omitted, image editing will not be offered via Chooser. + This name is in the ComponentName flattened format (package/class) [DO NOT TRANSLATE] --> + <string name="config_systemImageEditor" translatable="false">@*android:string/config_systemImageEditor</string> + + <integer name="config_chooser_max_targets_per_row">@*android:integer/config_chooser_max_targets_per_row</integer> +</resources> diff --git a/java/res/values/dimens.xml b/java/res/values/dimens.xml index a0ad1c81..2d6fe816 100644 --- a/java/res/values/dimens.xml +++ b/java/res/values/dimens.xml @@ -14,8 +14,40 @@ ~ limitations under the License. --> <resources xmlns:android="http://schemas.android.com/apk/res/android"> + <dimen name="resolver_max_width">480dp</dimen> + + <!-- chooser/resolver (sharesheet) spacing --> + <dimen name="chooser_width">412dp</dimen> + <dimen name="chooser_corner_radius">28dp</dimen> + <dimen name="chooser_row_text_option_translate">25dp</dimen> + <dimen name="chooser_view_spacing">18dp</dimen> + <dimen name="chooser_edge_margin_thin">16dp</dimen> + <dimen name="chooser_edge_margin_normal">24dp</dimen> + <dimen name="chooser_preview_image_font_size">20sp</dimen> + <dimen name="chooser_preview_image_border">1dp</dimen> + <dimen name="chooser_preview_image_max_dimen">200dp</dimen> + <dimen name="chooser_preview_width">-1px</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> + <dimen name="resolver_button_bar_spacing">0dp</dimen> <dimen name="resolver_badge_size">18dp</dimen> -</resources>
\ No newline at end of file + <dimen name="resolver_icon_margin">8dp</dimen> + <dimen name="resolver_small_margin">18dp</dimen> + <dimen name="resolver_edge_margin">24dp</dimen> + <dimen name="resolver_elevation">1dp</dimen> + <dimen name="resolver_max_collapsed_height">192dp</dimen> + <dimen name="resolver_max_collapsed_height_with_tabs">268dp</dimen> + <dimen name="resolver_max_collapsed_height_with_default">144dp</dimen> + <dimen name="resolver_max_collapsed_height_with_default_with_tabs">300dp</dimen> + <dimen name="resolver_tab_text_size">14sp</dimen> + <dimen name="resolver_title_padding_bottom">0dp</dimen> + <dimen name="resolver_empty_state_container_padding_top">48dp</dimen> + <dimen name="resolver_empty_state_container_padding_bottom">8dp</dimen> + <dimen name="resolver_profile_tab_margin">4dp</dimen> + + <dimen name="chooser_action_button_icon_size">18dp</dimen> +</resources> diff --git a/java/res/values/strings.xml b/java/res/values/strings.xml index 2e570d25..a536d3bc 100644 --- a/java/res/values/strings.xml +++ b/java/res/values/strings.xml @@ -19,4 +19,86 @@ <!-- Title of the IntentResolver application. [CHAR LIMIT=50] --> <string name="app_label">IntentResolver</string> + <!-- Label for a link to a intent resolver dialog to view something --> + <string name="whichViewApplicationLabel">Open</string> + <!-- Title of the list of alternate options to complete an action shown when the + last used option is being displayed separately. --> + <string name="use_a_different_app">Use a different app</string> + <!-- Text to display when there are no activities found to display in the + activity chooser. See the "Select an action" title. --> + <string name="noApplications">No apps can perform this action.</string> + + <!-- Title for a button to choose the currently selected activity + as the default in the activity resolver. [CHAR LIMIT=25] --> + <string name="activity_resolver_use_always">Always</string> + + <!-- Title for a button to choose the currently selected activity + from the activity resolver to use just this once. [CHAR LIMIT=25] --> + <string name="activity_resolver_use_once">Just once</string> + + <!-- Resolver target actions strings --> + <!-- Pin this app to the top of the Sharesheet app list. [CHAR LIMIT=60]--> + <string name="pin_specific_target">Pin <xliff:g id="label" example="Tweet">%1$s</xliff:g></string> + <!-- Un-pin this app in the Sharesheet, so that it is sorted normally. [CHAR LIMIT=60]--> + <string name="unpin_specific_target">Unpin <xliff:g id="label" example="Tweet">%1$s</xliff:g></string> + + <string name="file_count">{count, plural, + =1 {{file_name} + # file} + other {{file_name} + # files} + } + </string> + + <!-- ChooserActivity - No direct share targets are available. [CHAR LIMIT=NONE] --> + <string name="chooser_no_direct_share_targets">No recommended people to share with</string> + + <!-- ChooserActivity - Alphabetically sorted apps list label. [CHAR LIMIT=NONE] --> + <string name="chooser_all_apps_button_label">Apps list</string> + + <!-- 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 --> + <!-- 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> + + <!-- 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> + + <!-- 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> + <!-- 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 work profile. [CHAR LIMIT=NONE] --> + <string name="resolver_cant_share_with_work_apps_explanation">This content can\u2019t be shared with work 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 work profile. [CHAR LIMIT=NONE] --> + <string name="resolver_cant_access_work_apps_explanation">This content can\u2019t be opened with work apps</string> + + <!-- Error message. This text is explaining that the user's IT admin doesn't allow them to share this specific content with apps in their personal profile. [CHAR LIMIT=NONE] --> + <string name="resolver_cant_share_with_personal_apps_explanation">This content can\u2019t be shared with personal 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 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 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 profile is paused</string> + <!-- Button text. This button turns on a user's work profile so they can access their work apps and data. [CHAR LIMIT=NONE] --> + <string name="resolver_switch_on_work">Tap to turn on</string> + + <!-- Error message. This text lets the user know that their current work apps don't support the specific content. [CHAR LIMIT=NONE] --> + <string name="resolver_no_work_apps_available">No work apps</string> + + <!-- 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> + + <!-- 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] --> + <string name="miniresolver_open_in_work">Open <xliff:g id="app" example="YouTube">%s</xliff:g> in your work profile?</string> + <!-- Button option. Open the link in the personal browser. [CHAR LIMIT=NONE] --> + <string name="miniresolver_use_personal_browser">Use personal browser</string> + <!-- Button option. Open the link in the work browser. [CHAR LIMIT=NONE] --> + <string name="miniresolver_use_work_browser">Use work browser</string> </resources> diff --git a/java/res/values/styles.xml b/java/res/values/styles.xml index f366cd9a..cbbf406d 100644 --- a/java/res/values/styles.xml +++ b/java/res/values/styles.xml @@ -36,14 +36,14 @@ <!-- <item name="listPreferredItemPaddingStart">?attr/dialogPreferredPadding</item>--> <!-- <item name="listPreferredItemPaddingEnd">?attr/dialogPreferredPadding</item>--> <item name="android:navigationBarColor">@android:color/transparent</item> -<!-- <item name="android:iconfactoryIconSize">@dimen/resolver_icon_size</item>--> -<!-- <item name="android:iconfactoryBadgeSize">@dimen/resolver_badge_size</item>--> + <item name="*android:iconfactoryIconSize">@dimen/resolver_icon_size</item> + <item name="*android:iconfactoryBadgeSize">@dimen/resolver_badge_size</item> </style> <style name="Theme.DeviceDefault.Resolver" parent="Theme.DeviceDefault.ResolverCommon"> <item name="android:windowLightNavigationBar">true</item> </style> <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:iconfactoryIconSize">@dimen/chooser_icon_size</item> + <item name="*android:iconfactoryBadgeSize">@dimen/chooser_badge_size</item> </style> </resources> diff --git a/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java new file mode 100644 index 00000000..4f6c0bf1 --- /dev/null +++ b/java/src/com/android/intentresolver/AbstractMultiProfilePagerAdapter.java @@ -0,0 +1,632 @@ +/* + * 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.Nullable; +import android.app.AppGlobals; +import android.app.admin.DevicePolicyEventLogger; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.IPackageManager; +import android.content.pm.ResolveInfo; +import android.os.AsyncTask; +import android.os.Trace; +import android.os.UserHandle; +import android.os.UserManager; +import android.stats.devicepolicy.DevicePolicyEnums; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.widget.PagerAdapter; +import com.android.internal.widget.ViewPager; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * 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 OnSwitchOnWorkSelectedListener mOnSwitchOnWorkSelectedListener; + private Set<Integer> mLoadedPages; + private final UserHandle mPersonalProfileUserHandle; + private final UserHandle mWorkProfileUserHandle; + private Injector mInjector; + private boolean mIsWaitingToEnableWorkProfile; + + AbstractMultiProfilePagerAdapter(Context context, int currentPage, + UserHandle personalProfileUserHandle, + UserHandle workProfileUserHandle) { + mContext = Objects.requireNonNull(context); + mCurrentPage = currentPage; + mLoadedPages = new HashSet<>(); + mPersonalProfileUserHandle = personalProfileUserHandle; + mWorkProfileUserHandle = workProfileUserHandle; + UserManager userManager = context.getSystemService(UserManager.class); + mInjector = new Injector() { + @Override + public boolean hasCrossProfileIntents(List<Intent> intents, int sourceUserId, + int targetUserId) { + return AbstractMultiProfilePagerAdapter.this + .hasCrossProfileIntents(intents, sourceUserId, targetUserId); + } + + @Override + public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + return userManager.isQuietModeEnabled(workProfileUserHandle); + } + + @Override + public void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle) { + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { + userManager.requestQuietModeEnabled(enabled, workProfileUserHandle); + }); + mIsWaitingToEnableWorkProfile = true; + } + }; + } + + protected void markWorkProfileEnabledBroadcastReceived() { + mIsWaitingToEnableWorkProfile = false; + } + + protected boolean isWaitingToEnableWorkProfile() { + return mIsWaitingToEnableWorkProfile; + } + + /** + * Overrides the default {@link Injector} for testing purposes. + */ + @VisibleForTesting + public void setInjector(Injector injector) { + mInjector = injector; + } + + protected boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + return mInjector.isQuietModeEnabled(workProfileUserHandle); + } + + void setOnProfileSelectedListener(OnProfileSelectedListener listener) { + mOnProfileSelectedListener = listener; + } + + void setOnSwitchOnWorkSelectedListener(OnSwitchOnWorkSelectedListener listener) { + mOnSwitchOnWorkSelectedListener = 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().mResolverListController.getUserHandle(); + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return view == object; + } + + @Override + public CharSequence getPageTitle(int position) { + return null; + } + + /** + * 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); + + /** + * 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(); + + abstract String getMetricsCategory(); + + /** + * 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().mResolverListController.getUserHandle())) { + return PROFILE_PERSONAL; + } else { + return PROFILE_WORK; + } + } + + private boolean rebuildTab(ResolverListAdapter activeListAdapter, boolean doPostProcessing) { + if (shouldShowNoCrossProfileIntentsEmptyState(activeListAdapter)) { + activeListAdapter.postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); + return false; + } + return activeListAdapter.rebuildList(doPostProcessing); + } + + private boolean shouldShowNoCrossProfileIntentsEmptyState( + ResolverListAdapter activeListAdapter) { + UserHandle listUserHandle = activeListAdapter.getUserHandle(); + return UserHandle.myUserId() != listUserHandle.getIdentifier() + && allowShowNoCrossProfileIntentsEmptyState() + && !mInjector.hasCrossProfileIntents(activeListAdapter.getIntents(), + UserHandle.myUserId(), listUserHandle.getIdentifier()); + } + + boolean allowShowNoCrossProfileIntentsEmptyState() { + return true; + } + + protected abstract void showWorkProfileOffEmptyState( + ResolverListAdapter activeListAdapter, View.OnClickListener listener); + + protected abstract void showNoPersonalToWorkIntentsEmptyState( + ResolverListAdapter activeListAdapter); + + protected abstract void showNoPersonalAppsAvailableEmptyState( + ResolverListAdapter activeListAdapter); + + protected abstract void showNoWorkAppsAvailableEmptyState( + ResolverListAdapter activeListAdapter); + + protected abstract void showNoWorkToPersonalIntentsEmptyState( + ResolverListAdapter activeListAdapter); + + /** + * The empty state screens are shown according to their priority: + * <ol> + * <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) { + if (maybeShowNoCrossProfileIntentsEmptyState(listAdapter)) { + return; + } + if (maybeShowWorkProfileOffEmptyState(listAdapter)) { + return; + } + maybeShowNoAppsAvailableEmptyState(listAdapter); + } + + private boolean maybeShowNoCrossProfileIntentsEmptyState(ResolverListAdapter listAdapter) { + if (!shouldShowNoCrossProfileIntentsEmptyState(listAdapter)) { + return false; + } + if (listAdapter.getUserHandle().equals(mPersonalProfileUserHandle)) { + DevicePolicyEventLogger.createEvent( + DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL) + .setStrings(getMetricsCategory()) + .write(); + showNoWorkToPersonalIntentsEmptyState(listAdapter); + } else { + DevicePolicyEventLogger.createEvent( + DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK) + .setStrings(getMetricsCategory()) + .write(); + showNoPersonalToWorkIntentsEmptyState(listAdapter); + } + return true; + } + + /** + * Returns {@code true} if the work profile off empty state screen is shown. + */ + private boolean maybeShowWorkProfileOffEmptyState(ResolverListAdapter listAdapter) { + UserHandle listUserHandle = listAdapter.getUserHandle(); + if (!listUserHandle.equals(mWorkProfileUserHandle) + || !mInjector.isQuietModeEnabled(mWorkProfileUserHandle) + || listAdapter.getCount() == 0) { + return false; + } + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_EMPTY_STATE_WORK_APPS_DISABLED) + .setStrings(getMetricsCategory()) + .write(); + showWorkProfileOffEmptyState(listAdapter, + v -> { + ProfileDescriptor descriptor = getItem( + userHandleToPageIndex(listAdapter.getUserHandle())); + showSpinner(descriptor.getEmptyStateView()); + if (mOnSwitchOnWorkSelectedListener != null) { + mOnSwitchOnWorkSelectedListener.onSwitchOnWorkSelected(); + } + mInjector.requestQuietModeEnabled(false, mWorkProfileUserHandle); + }); + return true; + } + + private void maybeShowNoAppsAvailableEmptyState(ResolverListAdapter listAdapter) { + UserHandle listUserHandle = listAdapter.getUserHandle(); + if (mWorkProfileUserHandle != null + && (UserHandle.myUserId() == listUserHandle.getIdentifier() + || !hasAppsInOtherProfile(listAdapter))) { + DevicePolicyEventLogger.createEvent( + DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_APPS_RESOLVED) + .setStrings(getMetricsCategory()) + .setBoolean(/*isPersonalProfile*/ listUserHandle == mPersonalProfileUserHandle) + .write(); + if (listUserHandle == mPersonalProfileUserHandle) { + showNoPersonalAppsAvailableEmptyState(listAdapter); + } else { + showNoWorkAppsAvailableEmptyState(listAdapter); + } + } else if (mWorkProfileUserHandle == null) { + showConsumerUserNoAppsAvailableEmptyState(listAdapter); + } + } + + protected void showEmptyState(ResolverListAdapter activeListAdapter, String title, + String subtitle) { + showEmptyState(activeListAdapter, title, subtitle, /* buttonOnClick */ null); + } + + protected void showEmptyState(ResolverListAdapter activeListAdapter, + String title, String subtitle, View.OnClickListener buttonOnClick) { + ProfileDescriptor descriptor = getItem( + userHandleToPageIndex(activeListAdapter.getUserHandle())); + descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE); + ViewGroup emptyStateView = descriptor.getEmptyStateView(); + resetViewVisibilitiesForWorkProfileEmptyState(emptyStateView); + emptyStateView.setVisibility(View.VISIBLE); + + View container = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_container); + setupContainerPadding(container); + + TextView titleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title); + titleView.setText(title); + + TextView subtitleView = emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle); + if (subtitle != null) { + subtitleView.setVisibility(View.VISIBLE); + subtitleView.setText(subtitle); + } else { + subtitleView.setVisibility(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 showConsumerUserNoAppsAvailableEmptyState(ResolverListAdapter activeListAdapter) { + ProfileDescriptor descriptor = getItem( + userHandleToPageIndex(activeListAdapter.getUserHandle())); + descriptor.rootView.findViewById(com.android.internal.R.id.resolver_list).setVisibility(View.GONE); + View emptyStateView = descriptor.getEmptyStateView(); + resetViewVisibilitiesForConsumerUserEmptyState(emptyStateView); + emptyStateView.setVisibility(View.VISIBLE); + + activeListAdapter.markTabLoaded(); + } + + private boolean isSpinnerShowing(View emptyStateView) { + return emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).getVisibility() + == View.VISIBLE; + } + + private void showSpinner(View emptyStateView) { + emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.INVISIBLE); + emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.INVISIBLE); + 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 resetViewVisibilitiesForWorkProfileEmptyState(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); + } + + private void resetViewVisibilitiesForConsumerUserEmptyState(View emptyStateView) { + emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_title).setVisibility(View.GONE); + emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_subtitle).setVisibility(View.GONE); + emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_button).setVisibility(View.GONE); + emptyStateView.findViewById(com.android.internal.R.id.resolver_empty_state_progress).setVisibility(View.GONE); + emptyStateView.findViewById(com.android.internal.R.id.empty).setVisibility(View.VISIBLE); + } + + protected void showListView(ResolverListAdapter activeListAdapter) { + ProfileDescriptor descriptor = getItem( + userHandleToPageIndex(activeListAdapter.getUserHandle())); + 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); + } + + private boolean hasCrossProfileIntents(List<Intent> intents, int source, int target) { + IPackageManager packageManager = AppGlobals.getPackageManager(); + ContentResolver contentResolver = mContext.getContentResolver(); + for (Intent intent : intents) { + if (IntentForwarderActivity.canForward(intent, source, target, packageManager, + contentResolver) != null) { + return true; + } + } + return false; + } + + private boolean hasAppsInOtherProfile(ResolverListAdapter adapter) { + if (mWorkProfileUserHandle == null) { + return false; + } + List<ResolverActivity.ResolvedComponentInfo> resolversForIntent = + adapter.getResolversForUser(UserHandle.of(UserHandle.myUserId())); + for (ResolverActivity.ResolvedComponentInfo info : resolversForIntent) { + ResolveInfo resolveInfo = info.getResolveInfoAt(0); + if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { + return true; + } + } + return false; + } + + boolean shouldShowEmptyStateScreen(ResolverListAdapter listAdapter) { + int count = listAdapter.getUnfilteredCount(); + return (count == 0 && listAdapter.getPlaceholderCount() == 0) + || (listAdapter.getUserHandle().equals(mWorkProfileUserHandle) + && isQuietModeEnabled(mWorkProfileUserHandle)); + } + + protected 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); + } + + /** + * 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(); + } + + /** + * Describes an injector to be used for cross profile functionality. Overridable for testing. + */ + @VisibleForTesting + public interface Injector { + /** + * Returns {@code true} if at least one of the provided {@code intents} can be forwarded + * from {@code sourceUserId} to {@code targetUserId}. + */ + boolean hasCrossProfileIntents(List<Intent> intents, int sourceUserId, int targetUserId); + + /** + * Returns whether the given profile is in quiet mode or not. + */ + boolean isQuietModeEnabled(UserHandle workProfileUserHandle); + + /** + * Enables or disables quiet mode for a managed profile. + */ + void requestQuietModeEnabled(boolean enabled, UserHandle workProfileUserHandle); + } +} diff --git a/java/src/com/android/intentresolver/AbstractResolverComparator.java b/java/src/com/android/intentresolver/AbstractResolverComparator.java new file mode 100644 index 00000000..6f802876 --- /dev/null +++ b/java/src/com/android/intentresolver/AbstractResolverComparator.java @@ -0,0 +1,296 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.usage.UsageStatsManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.BadParcelableException; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.UserHandle; +import android.util.Log; + +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Used to sort resolved activities in {@link ResolverListController}. + * + * @hide + */ +public abstract class AbstractResolverComparator implements Comparator<ResolvedComponentInfo> { + + private static final int NUM_OF_TOP_ANNOTATIONS_TO_USE = 3; + private static final boolean DEBUG = true; + private static final String TAG = "AbstractResolverComp"; + + protected AfterCompute mAfterCompute; + protected final PackageManager mPm; + protected final UsageStatsManager mUsm; + protected String[] mAnnotations; + protected String mContentType; + + // True if the current share is a link. + private final boolean mHttp; + + // message types + static final int RANKER_SERVICE_RESULT = 0; + static final int RANKER_RESULT_TIMEOUT = 1; + + // timeout for establishing connections with a ResolverRankerService, collecting features and + // predicting ranking scores. + private static final int WATCHDOG_TIMEOUT_MILLIS = 500; + + private final Comparator<ResolveInfo> mAzComparator; + private ChooserActivityLogger mChooserActivityLogger; + + protected final Handler mHandler = new Handler(Looper.getMainLooper()) { + public void handleMessage(Message msg) { + switch (msg.what) { + case RANKER_SERVICE_RESULT: + if (DEBUG) { + Log.d(TAG, "RANKER_SERVICE_RESULT"); + } + if (mHandler.hasMessages(RANKER_RESULT_TIMEOUT)) { + handleResultMessage(msg); + mHandler.removeMessages(RANKER_RESULT_TIMEOUT); + afterCompute(); + } + break; + + case RANKER_RESULT_TIMEOUT: + if (DEBUG) { + Log.d(TAG, "RANKER_RESULT_TIMEOUT; unbinding services"); + } + mHandler.removeMessages(RANKER_SERVICE_RESULT); + afterCompute(); + if (mChooserActivityLogger != null) { + mChooserActivityLogger.logSharesheetAppShareRankingTimeout(); + } + break; + + default: + super.handleMessage(msg); + } + } + }; + + public AbstractResolverComparator(Context context, Intent intent) { + String scheme = intent.getScheme(); + mHttp = "http".equals(scheme) || "https".equals(scheme); + mContentType = intent.getType(); + getContentAnnotations(intent); + mPm = context.getPackageManager(); + mUsm = (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE); + mAzComparator = new AzInfoComparator(context); + } + + // get annotations of content from intent. + private void getContentAnnotations(Intent intent) { + try { + ArrayList<String> annotations = intent.getStringArrayListExtra( + Intent.EXTRA_CONTENT_ANNOTATIONS); + if (annotations != null) { + int size = annotations.size(); + if (size > NUM_OF_TOP_ANNOTATIONS_TO_USE) { + size = NUM_OF_TOP_ANNOTATIONS_TO_USE; + } + mAnnotations = new String[size]; + for (int i = 0; i < size; i++) { + mAnnotations[i] = annotations.get(i); + } + } + } catch (BadParcelableException e) { + Log.i(TAG, "Couldn't unparcel intent annotations. Ignoring."); + mAnnotations = new String[0]; + } + } + + /** + * Callback to be called when {@link #compute(List)} finishes. This signals to stop waiting. + */ + interface AfterCompute { + + void afterCompute(); + } + + void setCallBack(AfterCompute afterCompute) { + mAfterCompute = afterCompute; + } + + void setChooserActivityLogger(ChooserActivityLogger chooserActivityLogger) { + mChooserActivityLogger = chooserActivityLogger; + } + + ChooserActivityLogger getChooserActivityLogger() { + return mChooserActivityLogger; + } + + protected final void afterCompute() { + final AfterCompute afterCompute = mAfterCompute; + if (afterCompute != null) { + afterCompute.afterCompute(); + } + } + + @Override + public final int compare(ResolvedComponentInfo lhsp, ResolvedComponentInfo rhsp) { + final ResolveInfo lhs = lhsp.getResolveInfoAt(0); + final ResolveInfo rhs = rhsp.getResolveInfoAt(0); + + final boolean lFixedAtTop = lhsp.isFixedAtTop(); + final boolean rFixedAtTop = rhsp.isFixedAtTop(); + if (lFixedAtTop && !rFixedAtTop) return -1; + if (!lFixedAtTop && rFixedAtTop) return 1; + + // We want to put the one targeted to another user at the end of the dialog. + if (lhs.targetUserId != UserHandle.USER_CURRENT) { + return rhs.targetUserId != UserHandle.USER_CURRENT ? 0 : 1; + } + if (rhs.targetUserId != UserHandle.USER_CURRENT) { + return -1; + } + + if (mHttp) { + final boolean lhsSpecific = ResolverActivity.isSpecificUriMatch(lhs.match); + final boolean rhsSpecific = ResolverActivity.isSpecificUriMatch(rhs.match); + if (lhsSpecific != rhsSpecific) { + return lhsSpecific ? -1 : 1; + } + } + + final boolean lPinned = lhsp.isPinned(); + final boolean rPinned = rhsp.isPinned(); + + // Pinned items always receive priority. + if (lPinned && !rPinned) { + return -1; + } else if (!lPinned && rPinned) { + return 1; + } else if (lPinned && rPinned) { + // If both items are pinned, resolve the tie alphabetically. + return mAzComparator.compare(lhsp.getResolveInfoAt(0), rhsp.getResolveInfoAt(0)); + } + + return compare(lhs, rhs); + } + + /** + * 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); + + /** + * Computes features for each target. This will be called before calls to {@link + * #getScore(ComponentName)} or {@link #compare(Object, Object)}, in order to prepare the + * comparator for those calls. Note that {@link #getScore(ComponentName)} uses {@link + * ComponentName}, so the implementation will have to be prepared to identify a {@link + * ResolvedComponentInfo} by {@link ComponentName}. {@link #beforeCompute()} will be called + * before doing any computing. + */ + final void compute(List<ResolvedComponentInfo> targets) { + beforeCompute(); + doCompute(targets); + } + + /** Implementation of compute called after {@link #beforeCompute()}. */ + abstract void doCompute(List<ResolvedComponentInfo> targets); + + /** + * Returns the score that was calculated for the corresponding {@link ResolvedComponentInfo} + * when {@link #compute(List)} was called before this. + */ + abstract float getScore(ComponentName name); + + /** Handles result message sent to mHandler. */ + abstract void handleResultMessage(Message message); + + /** + * Reports to UsageStats what was chosen. + */ + final void updateChooserCounts(String packageName, int userId, String action) { + if (mUsm != null) { + mUsm.reportChooserSelection(packageName, userId, mContentType, mAnnotations, action); + } + } + + /** + * Updates the model used to rank the componentNames. + * + * <p>Default implementation does nothing, as we could have simple model that does not train + * online. + * + * @param componentName the component that the user clicked + */ + void updateModel(ComponentName componentName) { + } + + /** Called before {@link #doCompute(List)}. Sets up 500ms timeout. */ + void beforeCompute() { + if (DEBUG) Log.d(TAG, "Setting watchdog timer for " + WATCHDOG_TIMEOUT_MILLIS + "ms"); + if (mHandler == null) { + Log.d(TAG, "Error: Handler is Null; Needs to be initialized."); + return; + } + mHandler.sendEmptyMessageDelayed(RANKER_RESULT_TIMEOUT, WATCHDOG_TIMEOUT_MILLIS); + } + + /** + * Called when the {@link ResolverActivity} is destroyed. This calls {@link #afterCompute()}. If + * this call needs to happen at a different time during destroy, the method should be + * overridden. + */ + void destroy() { + mHandler.removeMessages(RANKER_SERVICE_RESULT); + mHandler.removeMessages(RANKER_RESULT_TIMEOUT); + afterCompute(); + 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/AppPredictionServiceResolverComparator.java b/java/src/com/android/intentresolver/AppPredictionServiceResolverComparator.java new file mode 100644 index 00000000..9b9fc1c0 --- /dev/null +++ b/java/src/com/android/intentresolver/AppPredictionServiceResolverComparator.java @@ -0,0 +1,276 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.prediction.AppTargetEvent.ACTION_LAUNCH; + +import android.annotation.Nullable; +import android.app.prediction.AppPredictor; +import android.app.prediction.AppTarget; +import android.app.prediction.AppTargetEvent; +import android.app.prediction.AppTargetId; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.os.Message; +import android.os.UserHandle; +import android.util.Log; + +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; + +/** + * Uses an {@link AppPredictor} to sort Resolver targets. If the AppPredictionService appears to be + * disabled by returning an empty sorted target list, {@link AppPredictionServiceResolverComparator} + * will fallback to using a {@link ResolverRankerServiceResolverComparator}. + */ +class AppPredictionServiceResolverComparator extends AbstractResolverComparator { + + private static final String TAG = "APSResolverComparator"; + + private final AppPredictor mAppPredictor; + private final Context mContext; + private final Map<ComponentName, Integer> mTargetRanks = new HashMap<>(); + private final Map<ComponentName, Integer> mTargetScores = new HashMap<>(); + private final UserHandle mUser; + private final Intent mIntent; + private final String mReferrerPackage; + // If this is non-null (and this is not destroyed), it means APS is disabled and we should fall + // back to using the ResolverRankerService. + // TODO: responsibility for this fallback behavior can live outside of the AppPrediction client. + private ResolverRankerServiceResolverComparator mResolverRankerService; + private AppPredictionServiceComparatorModel mComparatorModel; + + AppPredictionServiceResolverComparator( + Context context, + Intent intent, + String referrerPackage, + AppPredictor appPredictor, + UserHandle user, + ChooserActivityLogger chooserActivityLogger) { + super(context, intent); + mContext = context; + mIntent = intent; + mAppPredictor = appPredictor; + mUser = user; + mReferrerPackage = referrerPackage; + setChooserActivityLogger(chooserActivityLogger); + mComparatorModel = buildUpdatedModel(); + } + + @Override + int compare(ResolveInfo lhs, ResolveInfo rhs) { + return mComparatorModel.getComparator().compare(lhs, rhs); + } + + @Override + void doCompute(List<ResolvedComponentInfo> targets) { + if (targets.isEmpty()) { + mHandler.sendEmptyMessage(RANKER_SERVICE_RESULT); + return; + } + List<AppTarget> appTargets = new ArrayList<>(); + for (ResolvedComponentInfo target : targets) { + appTargets.add( + new AppTarget.Builder( + new AppTargetId(target.name.flattenToString()), + target.name.getPackageName(), + mUser) + .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()); + 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) { + // 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; + handleSortedAppTargets(sortedAppTargets); + } else if (msg.obj == null && mResolverRankerService == null) { + Log.e(TAG, "Unexpected null result"); + } + } + + private void handleResult(List<AppTarget> sortedAppTargets) { + if (mHandler.hasMessages(RANKER_RESULT_TIMEOUT)) { + handleSortedAppTargets(sortedAppTargets); + mHandler.removeMessages(RANKER_RESULT_TIMEOUT); + afterCompute(); + } + } + + private void handleSortedAppTargets(List<AppTarget> sortedAppTargets) { + if (checkAppTargetRankValid(sortedAppTargets)) { + sortedAppTargets.forEach(target -> mTargetScores.put( + new ComponentName(target.getPackageName(), target.getClassName()), + target.getRank())); + } + for (int i = 0; i < sortedAppTargets.size(); i++) { + ComponentName componentName = new ComponentName( + sortedAppTargets.get(i).getPackageName(), + sortedAppTargets.get(i).getClassName()); + mTargetRanks.put(componentName, i); + Log.i(TAG, "handleSortedAppTargets, sortedAppTargets #" + i + ": " + componentName); + } + mComparatorModel = buildUpdatedModel(); + } + + private boolean checkAppTargetRankValid(List<AppTarget> sortedAppTargets) { + for (AppTarget target : sortedAppTargets) { + if (target.getRank() != 0) { + return true; + } + } + return false; + } + + @Override + float getScore(ComponentName name) { + return mComparatorModel.getScore(name); + } + + @Override + void updateModel(ComponentName componentName) { + mComparatorModel.notifyOnTargetSelected(componentName); + } + + @Override + void destroy() { + if (mResolverRankerService != null) { + mResolverRankerService.destroy(); + mResolverRankerService = null; + mComparatorModel = buildUpdatedModel(); + } + } + + /** + * Re-construct an {@code AppPredictionServiceComparatorModel} to replace the current model + * instance (if any) using the up-to-date {@code AppPredictionServiceResolverComparator} ivar + * values. + * + * TODO: each time we replace the model instance, we're either updating the model to use + * adjusted data (which is appropriate), or we're providing a (late) value for one of our ivars + * that wasn't available the last time the model was updated. For those latter cases, we should + * just avoid creating the model altogether until we have all the prerequisites we'll need. Then + * we can probably simplify the logic in {@code AppPredictionServiceComparatorModel} since we + * won't need to handle edge cases when the model data isn't fully prepared. + * (In some cases, these kinds of "updates" might interleave -- e.g., we might have finished + * initializing the first time and now want to adjust some data, but still need to wait for + * changes to propagate to the other ivars before rebuilding the model.) + */ + private AppPredictionServiceComparatorModel buildUpdatedModel() { + return new AppPredictionServiceComparatorModel( + mAppPredictor, mResolverRankerService, mUser, mTargetRanks); + } + + // TODO: Finish separating behaviors of AbstractResolverComparator, then (probably) make this a + // standalone class once clients are written in terms of ResolverComparatorModel. + static class AppPredictionServiceComparatorModel implements ResolverComparatorModel { + private final AppPredictor mAppPredictor; + private final ResolverRankerServiceResolverComparator mResolverRankerService; + private final UserHandle mUser; + private final Map<ComponentName, Integer> mTargetRanks; // Treat as immutable. + + AppPredictionServiceComparatorModel( + AppPredictor appPredictor, + @Nullable ResolverRankerServiceResolverComparator resolverRankerService, + UserHandle user, + Map<ComponentName, Integer> targetRanks) { + mAppPredictor = appPredictor; + mResolverRankerService = resolverRankerService; + mUser = user; + mTargetRanks = targetRanks; + } + + @Override + public Comparator<ResolveInfo> getComparator() { + return (lhs, rhs) -> { + if (mResolverRankerService != null) { + return mResolverRankerService.compare(lhs, rhs); + } + Integer lhsRank = mTargetRanks.get(new ComponentName(lhs.activityInfo.packageName, + lhs.activityInfo.name)); + Integer rhsRank = mTargetRanks.get(new ComponentName(rhs.activityInfo.packageName, + rhs.activityInfo.name)); + if (lhsRank == null && rhsRank == null) { + return 0; + } else if (lhsRank == null) { + return -1; + } else if (rhsRank == null) { + return 1; + } + return lhsRank - rhsRank; + }; + } + + @Override + public float getScore(ComponentName name) { + if (mResolverRankerService != null) { + return mResolverRankerService.getScore(name); + } + Integer rank = mTargetRanks.get(name); + if (rank == null) { + Log.w(TAG, "Score requested for unknown component. Did you call compute yet?"); + return 0f; + } + int consecutiveSumOfRanks = (mTargetRanks.size() - 1) * (mTargetRanks.size()) / 2; + return 1.0f - (((float) rank) / consecutiveSumOfRanks); + } + + @Override + public void notifyOnTargetSelected(ComponentName componentName) { + if (mResolverRankerService != null) { + mResolverRankerService.updateModel(componentName); + return; + } + mAppPredictor.notifyAppTargetEvent( + new AppTargetEvent.Builder( + new AppTarget.Builder( + new AppTargetId(componentName.toString()), + componentName.getPackageName(), mUser) + .setClassName(componentName.getClassName()).build(), + ACTION_LAUNCH).build()); + } + } +} diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 4c96f7a7..14d77427 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 The Android Open Source Project + * 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. @@ -16,27 +16,4126 @@ package com.android.intentresolver; +import static com.android.internal.util.LatencyTracker.ACTION_LOAD_SHARE_SHEET; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.ActivityOptions; +import android.app.SharedElementCallback; +import android.app.prediction.AppPredictionContext; +import android.app.prediction.AppPredictionManager; +import android.app.prediction.AppPredictor; +import android.app.prediction.AppTarget; +import android.app.prediction.AppTargetEvent; +import android.app.prediction.AppTargetId; +import android.compat.annotation.UnsupportedAppUsage; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; +import android.content.IntentSender; +import android.content.IntentSender.SendIntentException; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.database.Cursor; +import android.database.DataSetObserver; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Insets; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.drawable.AnimatedVectorDrawable; +import android.graphics.drawable.Drawable; +import android.metrics.LogMaker; +import android.net.Uri; +import android.os.AsyncTask; import android.os.Bundle; -import android.os.StrictMode; +import android.os.Environment; +import android.os.Handler; +import android.os.Message; +import android.os.Parcelable; +import android.os.PatternMatcher; +import android.os.ResultReceiver; +import android.os.UserHandle; +import android.os.UserManager; +import android.os.storage.StorageManager; +import android.provider.DeviceConfig; +import android.provider.DocumentsContract; +import android.provider.Downloads; +import android.provider.OpenableColumns; +import android.provider.Settings; +import android.service.chooser.ChooserTarget; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.HashedStringCache; +import android.util.Log; +import android.util.PluralsMessageFormatter; +import android.util.Size; +import android.util.Slog; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.ViewTreeObserver; +import android.view.WindowInsets; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.LinearInterpolator; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.Space; +import android.widget.TextView; + +import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; +import com.android.intentresolver.ResolverListAdapter.ViewHolder; +import com.android.intentresolver.chooser.ChooserTargetInfo; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.MultiDisplayResolveInfo; +import com.android.intentresolver.chooser.NotSelectableTargetInfo; +import com.android.intentresolver.chooser.SelectableTargetInfo; +import com.android.intentresolver.chooser.SelectableTargetInfo.SelectableTargetInfoCommunicator; +import com.android.intentresolver.chooser.TargetInfo; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; +import com.android.internal.content.PackageMonitor; +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.internal.util.FrameworkStatsLog; +import com.android.internal.widget.GridLayoutManager; +import com.android.internal.widget.RecyclerView; +import com.android.internal.widget.ResolverDrawerLayout; +import com.android.internal.widget.ViewPager; -import androidx.annotation.Nullable; +import com.google.android.collect.Lists; + +import java.io.File; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.net.URISyntaxException; +import java.text.Collator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; /** - * Activity for selecting which application ought to handle an ACTION_SEND intent. + * The Chooser Activity handles intent resolution specifically for sharing intents - + * for example, those generated by @see android.content.Intent#createChooser(Intent, CharSequence). + * */ -public class ChooserActivity extends com.android.internal.app.ChooserActivity { +public class ChooserActivity extends ResolverActivity implements + ChooserListAdapter.ChooserListCommunicator, + SelectableTargetInfoCommunicator { + private static final String TAG = "ChooserActivity"; + + private AppPredictor mPersonalAppPredictor; + private AppPredictor mWorkAppPredictor; + private boolean mShouldDisplayLandscape; + + @UnsupportedAppUsage + public ChooserActivity() { + } + /** + * Boolean extra to change the following behavior: Normally, ChooserActivity finishes itself + * in onStop when launched in a new task. If this extra is set to true, we do not finish + * ourselves when onStop gets called. + */ + public static final String EXTRA_PRIVATE_RETAIN_IN_ON_STOP + = "com.android.internal.app.ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP"; + + + /** + * Transition name for the first image preview. + * To be used for shared element transition into this activity. + * @hide + */ + public static final String FIRST_IMAGE_PREVIEW_TRANSITION_NAME = "screenshot_preview_image"; + + private static final String PREF_NUM_SHEET_EXPANSIONS = "pref_num_sheet_expansions"; + + private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; + private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; + + private static final boolean DEBUG = true; + + private static final boolean USE_PREDICTION_MANAGER_FOR_SHARE_ACTIVITIES = true; + // TODO(b/123088566) Share these in a better way. + private static final String APP_PREDICTION_SHARE_UI_SURFACE = "share"; + public static final String LAUNCH_LOCATION_DIRECT_SHARE = "direct_share"; + public static final String CHOOSER_TARGET = "chooser_target"; + private static final String SHORTCUT_TARGET = "shortcut_target"; + private static final int APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT = 20; + public static final String APP_PREDICTION_INTENT_FILTER_KEY = "intent_filter"; + private static final String SHARED_TEXT_KEY = "shared_text"; + + private static final String PLURALS_COUNT = "count"; + private static final String PLURALS_FILE_NAME = "file_name"; + + private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; + + private boolean mIsAppPredictorComponentAvailable; + private Map<ChooserTarget, AppTarget> mDirectShareAppTargetCache; + private Map<ChooserTarget, ShortcutInfo> mDirectShareShortcutInfoCache; + + public static final int TARGET_TYPE_DEFAULT = 0; + public static final int TARGET_TYPE_CHOOSER_TARGET = 1; + public static final int TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER = 2; + public static final int TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE = 3; + + public static final int SELECTION_TYPE_SERVICE = 1; + public static final int SELECTION_TYPE_APP = 2; + public static final int SELECTION_TYPE_STANDARD = 3; + public static final int SELECTION_TYPE_COPY = 4; + public static final int SELECTION_TYPE_NEARBY = 5; + public static final int SELECTION_TYPE_EDIT = 6; + + private static final int SCROLL_STATUS_IDLE = 0; + private static final int SCROLL_STATUS_SCROLLING_VERTICAL = 1; + private static final int SCROLL_STATUS_SCROLLING_HORIZONTAL = 2; + + // statsd logger wrapper + protected ChooserActivityLogger mChooserActivityLogger; + + @IntDef(flag = false, prefix = { "TARGET_TYPE_" }, value = { + TARGET_TYPE_DEFAULT, + TARGET_TYPE_CHOOSER_TARGET, + TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER, + TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE + }) + @Retention(RetentionPolicy.SOURCE) + public @interface ShareTargetType {} + + /** + * The transition time between placeholders for direct share to a message + * indicating that non are available. + */ + private static final int NO_DIRECT_SHARE_ANIM_IN_MILLIS = 200; + + private static final float DIRECT_SHARE_EXPANSION_RATE = 0.78f; + + private static final int DEFAULT_SALT_EXPIRATION_DAYS = 7; + private int mMaxHashSaltDays = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.HASH_SALT_MAX_DAYS, + DEFAULT_SALT_EXPIRATION_DAYS); + + private static final boolean DEFAULT_IS_NEARBY_SHARE_FIRST_TARGET_IN_RANKED_APP = false; + private boolean mIsNearbyShareFirstTargetInRankedApp = + DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.IS_NEARBY_SHARE_FIRST_TARGET_IN_RANKED_APP, + DEFAULT_IS_NEARBY_SHARE_FIRST_TARGET_IN_RANKED_APP); + + private static final int DEFAULT_LIST_VIEW_UPDATE_DELAY_MS = 0; + + private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; + + @VisibleForTesting + int mListViewUpdateDelayMs = DeviceConfig.getInt(DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.SHARESHEET_LIST_VIEW_UPDATE_DELAY, + DEFAULT_LIST_VIEW_UPDATE_DELAY_MS); + + private Bundle mReplacementExtras; + private IntentSender mChosenComponentSender; + private IntentSender mRefinementIntentSender; + private RefinementResultReceiver mRefinementResultReceiver; + private ChooserTarget[] mCallerChooserTargets; + private ComponentName[] mFilteredComponentNames; + + private Intent mReferrerFillInIntent; + + private long mChooserShownTime; + protected boolean mIsSuccessfullySelected; + + private long mQueriedSharingShortcutsTimeMs; + + private int mCurrAvailableWidth = 0; + private Insets mLastAppliedInsets = null; + private int mLastNumberOfChildren = -1; + private int mMaxTargetsPerRow = 1; + + private static final String TARGET_DETAILS_FRAGMENT_TAG = "targetDetailsFragment"; + + private static final int MAX_LOG_RANK_POSITION = 12; + + private static final int MAX_EXTRA_INITIAL_INTENTS = 2; + private static final int MAX_EXTRA_CHOOSER_TARGETS = 2; + + private SharedPreferences mPinnedSharedPrefs; + private static final String PINNED_SHARED_PREFS_NAME = "chooser_pin_settings"; + + @Retention(SOURCE) + @IntDef({CONTENT_PREVIEW_FILE, CONTENT_PREVIEW_IMAGE, CONTENT_PREVIEW_TEXT}) + private @interface ContentPreviewType { + } + + // Starting at 1 since 0 is considered "undefined" for some of the database transformations + // of tron logs. + protected static final int CONTENT_PREVIEW_IMAGE = 1; + protected static final int CONTENT_PREVIEW_FILE = 2; + protected static final int CONTENT_PREVIEW_TEXT = 3; + protected MetricsLogger mMetricsLogger; + + private ContentPreviewCoordinator mPreviewCoord; + private int mScrollStatus = SCROLL_STATUS_IDLE; + + @VisibleForTesting + protected ChooserMultiProfilePagerAdapter mChooserMultiProfilePagerAdapter; + private final EnterTransitionAnimationDelegate mEnterTransitionAnimationDelegate = + new EnterTransitionAnimationDelegate(); + + private boolean mRemoveSharedElements = false; + + private View mContentView = null; + + private class ContentPreviewCoordinator { + private static final int IMAGE_FADE_IN_MILLIS = 150; + private static final int IMAGE_LOAD_TIMEOUT = 1; + private static final int IMAGE_LOAD_INTO_VIEW = 2; + + private final int mImageLoadTimeoutMillis = + getResources().getInteger(R.integer.config_shortAnimTime); + + private final View mParentView; + private boolean mHideParentOnFail; + private boolean mAtLeastOneLoaded = false; + + class LoadUriTask { + public final Uri mUri; + public final int mImageResourceId; + public final int mExtraCount; + public final Bitmap mBmp; + + LoadUriTask(int imageResourceId, Uri uri, int extraCount, Bitmap bmp) { + this.mImageResourceId = imageResourceId; + this.mUri = uri; + this.mExtraCount = extraCount; + this.mBmp = bmp; + } + } + + // If at least one image loads within the timeout period, allow other + // loads to continue. Otherwise terminate and optionally hide + // the parent area + private final Handler mHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case IMAGE_LOAD_TIMEOUT: + maybeHideContentPreview(); + break; + + case IMAGE_LOAD_INTO_VIEW: + if (isFinishing()) break; + + LoadUriTask task = (LoadUriTask) msg.obj; + RoundedRectImageView imageView = mParentView.findViewById( + task.mImageResourceId); + if (task.mBmp == null) { + imageView.setVisibility(View.GONE); + maybeHideContentPreview(); + return; + } + + mAtLeastOneLoaded = true; + imageView.setVisibility(View.VISIBLE); + imageView.setAlpha(0.0f); + imageView.setImageBitmap(task.mBmp); + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(imageView, "alpha", 0.0f, + 1.0f); + fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + fadeAnim.setDuration(IMAGE_FADE_IN_MILLIS); + fadeAnim.start(); + + if (task.mExtraCount > 0) { + imageView.setExtraImageCount(task.mExtraCount); + } + + setupPreDrawForSharedElementTransition(imageView); + } + } + }; + + private void setupPreDrawForSharedElementTransition(View v) { + v.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + v.getViewTreeObserver().removeOnPreDrawListener(this); + + if (!mRemoveSharedElements && isActivityTransitionRunning()) { + // Disable the window animations as it interferes with the + // transition animation. + getWindow().setWindowAnimations(0); + } + mEnterTransitionAnimationDelegate.markImagePreviewReady(); + return true; + } + }); + } + + ContentPreviewCoordinator(View parentView, boolean hideParentOnFail) { + super(); + + this.mParentView = parentView; + this.mHideParentOnFail = hideParentOnFail; + } + + private void loadUriIntoView(final int imageResourceId, final Uri uri, + final int extraImages) { + mHandler.sendEmptyMessageDelayed(IMAGE_LOAD_TIMEOUT, mImageLoadTimeoutMillis); + + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { + int size = getResources().getDimensionPixelSize( + R.dimen.chooser_preview_image_max_dimen); + final Bitmap bmp = loadThumbnail(uri, new Size(size, size)); + final Message msg = Message.obtain(); + msg.what = IMAGE_LOAD_INTO_VIEW; + msg.obj = new LoadUriTask(imageResourceId, uri, extraImages, bmp); + mHandler.sendMessage(msg); + }); + } + + private void cancelLoads() { + mHandler.removeMessages(IMAGE_LOAD_INTO_VIEW); + mHandler.removeMessages(IMAGE_LOAD_TIMEOUT); + } + + private void maybeHideContentPreview() { + if (!mAtLeastOneLoaded) { + if (mHideParentOnFail) { + Log.i(TAG, "Hiding image preview area. Timed out waiting for preview to load" + + " within " + mImageLoadTimeoutMillis + "ms."); + collapseParentView(); + if (shouldShowTabs()) { + hideStickyContentPreview(); + } else if (mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() != null) { + mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() + .hideContentPreview(); + } + mHideParentOnFail = false; + } + mRemoveSharedElements = true; + mEnterTransitionAnimationDelegate.markImagePreviewReady(); + } + } + + private void collapseParentView() { + // This will effectively hide the content preview row by forcing the height + // to zero. It is faster than forcing a relayout of the listview + final View v = mParentView; + int widthSpec = MeasureSpec.makeMeasureSpec(v.getWidth(), MeasureSpec.EXACTLY); + int heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY); + v.measure(widthSpec, heightSpec); + v.getLayoutParams().height = 0; + v.layout(v.getLeft(), v.getTop(), v.getRight(), v.getTop()); + v.invalidate(); + } + } + + private final ChooserHandler mChooserHandler = new ChooserHandler(); + + private class ChooserHandler extends Handler { + private static final int LIST_VIEW_UPDATE_MESSAGE = 6; + private static final int SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS = 7; + + private void removeAllMessages() { + removeMessages(LIST_VIEW_UPDATE_MESSAGE); + removeMessages(SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS); + } + + @Override + public void handleMessage(Message msg) { + if (mChooserMultiProfilePagerAdapter.getActiveListAdapter() == null || isDestroyed()) { + return; + } + + switch (msg.what) { + case LIST_VIEW_UPDATE_MESSAGE: + if (DEBUG) { + Log.d(TAG, "LIST_VIEW_UPDATE_MESSAGE; "); + } + + UserHandle userHandle = (UserHandle) msg.obj; + mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle(userHandle) + .refreshListView(); + break; + + case SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS: + if (DEBUG) Log.d(TAG, "SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS"); + final ServiceResultInfo[] resultInfos = (ServiceResultInfo[]) msg.obj; + for (ServiceResultInfo resultInfo : resultInfos) { + if (resultInfo.resultTargets != null) { + ChooserListAdapter adapterForUserHandle = + mChooserMultiProfilePagerAdapter.getListAdapterForUserHandle( + resultInfo.userHandle); + if (adapterForUserHandle != null) { + adapterForUserHandle.addServiceResults( + resultInfo.originalTarget, + resultInfo.resultTargets, msg.arg1, + mDirectShareShortcutInfoCache); + } + } + } + + logDirectShareTargetReceived( + MetricsEvent.ACTION_DIRECT_SHARE_TARGETS_LOADED_SHORTCUT_MANAGER); + sendVoiceChoicesIfNeeded(); + getChooserActivityLogger().logSharesheetDirectLoadComplete(); + + mChooserMultiProfilePagerAdapter.getActiveListAdapter() + .completeServiceTargetLoading(); + break; + + default: + super.handleMessage(msg); + } + } + }; @Override - public void startActivityAsCaller(Intent intent, @Nullable Bundle options, - boolean ignoreTargetSecurity, int userId) { - // We're dispatching intents that might be coming from legacy apps, so - // (as in com.android.internal.app.ResolverActivity) exempt ourselves from death. - StrictMode.disableDeathOnFileUriExposure(); + protected void onCreate(Bundle savedInstanceState) { + final long intentReceivedTime = System.currentTimeMillis(); + mLatencyTracker.onActionStart(ACTION_LOAD_SHARE_SHEET); + + getChooserActivityLogger().logSharesheetTriggered(); + // This is the only place this value is being set. Effectively final. + mIsAppPredictorComponentAvailable = isAppPredictionServiceAvailable(); + + mIsSuccessfullySelected = false; + Intent intent = getIntent(); + Parcelable targetParcelable = intent.getParcelableExtra(Intent.EXTRA_INTENT); + if (targetParcelable instanceof Uri) { + try { + targetParcelable = Intent.parseUri(targetParcelable.toString(), + Intent.URI_INTENT_SCHEME); + } catch (URISyntaxException ex) { + // doesn't parse as an intent; let the next test fail and error out + } + } + + if (!(targetParcelable instanceof Intent)) { + Log.w("ChooserActivity", "Target is not an intent: " + targetParcelable); + finish(); + super.onCreate(null); + return; + } + Intent target = (Intent) targetParcelable; + if (target != null) { + modifyTargetIntent(target); + } + Parcelable[] targetsParcelable + = intent.getParcelableArrayExtra(Intent.EXTRA_ALTERNATE_INTENTS); + if (targetsParcelable != null) { + final boolean offset = target == null; + Intent[] additionalTargets = + new Intent[offset ? targetsParcelable.length - 1 : targetsParcelable.length]; + for (int i = 0; i < targetsParcelable.length; i++) { + if (!(targetsParcelable[i] instanceof Intent)) { + Log.w(TAG, "EXTRA_ALTERNATE_INTENTS array entry #" + i + " is not an Intent: " + + targetsParcelable[i]); + finish(); + super.onCreate(null); + return; + } + final Intent additionalTarget = (Intent) targetsParcelable[i]; + if (i == 0 && target == null) { + target = additionalTarget; + modifyTargetIntent(target); + } else { + additionalTargets[offset ? i - 1 : i] = additionalTarget; + modifyTargetIntent(additionalTarget); + } + } + setAdditionalTargets(additionalTargets); + } + + mReplacementExtras = intent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS); + + // Do not allow the title to be changed when sharing content + CharSequence title = null; + if (target != null) { + if (!isSendAction(target)) { + title = intent.getCharSequenceExtra(Intent.EXTRA_TITLE); + } else { + Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a" + + " preview title by using EXTRA_TITLE property of the wrapped" + + " EXTRA_INTENT."); + } + } + + int defaultTitleRes = 0; + if (title == null) { + defaultTitleRes = com.android.internal.R.string.chooseActivity; + } + + Parcelable[] pa = intent.getParcelableArrayExtra(Intent.EXTRA_INITIAL_INTENTS); + Intent[] initialIntents = null; + if (pa != null) { + int count = Math.min(pa.length, MAX_EXTRA_INITIAL_INTENTS); + initialIntents = new Intent[count]; + for (int i = 0; i < count; i++) { + if (!(pa[i] instanceof Intent)) { + Log.w(TAG, "Initial intent #" + i + " not an Intent: " + pa[i]); + finish(); + super.onCreate(null); + return; + } + final Intent in = (Intent) pa[i]; + modifyTargetIntent(in); + initialIntents[i] = in; + } + } + + mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, getReferrer()); + + mChosenComponentSender = intent.getParcelableExtra( + Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER); + mRefinementIntentSender = intent.getParcelableExtra( + Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); + setSafeForwardingMode(true); + + mPinnedSharedPrefs = getPinnedSharedPrefs(this); + + pa = intent.getParcelableArrayExtra(Intent.EXTRA_EXCLUDE_COMPONENTS); + + + // Exclude out Nearby from main list if chip is present, to avoid duplication + ComponentName nearbySharingComponent = getNearbySharingComponent(); + boolean shouldFilterNearby = !shouldNearbyShareBeFirstInRankedRow() + && nearbySharingComponent != null; + + if (pa != null) { + ComponentName[] names = new ComponentName[pa.length + (shouldFilterNearby ? 1 : 0)]; + for (int i = 0; i < pa.length; i++) { + if (!(pa[i] instanceof ComponentName)) { + Log.w(TAG, "Filtered component #" + i + " not a ComponentName: " + pa[i]); + names = null; + break; + } + names[i] = (ComponentName) pa[i]; + } + if (shouldFilterNearby) { + names[names.length - 1] = nearbySharingComponent; + } + + mFilteredComponentNames = names; + } else if (shouldFilterNearby) { + mFilteredComponentNames = new ComponentName[1]; + mFilteredComponentNames[0] = nearbySharingComponent; + } + + pa = intent.getParcelableArrayExtra(Intent.EXTRA_CHOOSER_TARGETS); + if (pa != null) { + int count = Math.min(pa.length, MAX_EXTRA_CHOOSER_TARGETS); + ChooserTarget[] targets = new ChooserTarget[count]; + for (int i = 0; i < count; i++) { + if (!(pa[i] instanceof ChooserTarget)) { + Log.w(TAG, "Chooser target #" + i + " not a ChooserTarget: " + pa[i]); + targets = null; + break; + } + targets[i] = (ChooserTarget) pa[i]; + } + mCallerChooserTargets = targets; + } + + mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + mShouldDisplayLandscape = + shouldDisplayLandscape(getResources().getConfiguration().orientation); + setRetainInOnStop(intent.getBooleanExtra(EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false)); + super.onCreate(savedInstanceState, target, title, defaultTitleRes, initialIntents, + null, false); + + mChooserShownTime = System.currentTimeMillis(); + final long systemCost = mChooserShownTime - intentReceivedTime; + + getMetricsLogger().write(new LogMaker(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN) + .setSubtype(isWorkProfile() ? MetricsEvent.MANAGED_PROFILE : + MetricsEvent.PARENT_PROFILE) + .addTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE, target.getType()) + .addTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS, systemCost)); + + if (mResolverDrawerLayout != null) { + mResolverDrawerLayout.addOnLayoutChangeListener(this::handleLayoutChange); + + // expand/shrink direct share 4 -> 8 viewgroup + if (isSendAction(target)) { + mResolverDrawerLayout.setOnScrollChangeListener(this::handleScroll); + } + + mResolverDrawerLayout.setOnCollapsedChangedListener( + new ResolverDrawerLayout.OnCollapsedChangedListener() { + + // Only consider one expansion per activity creation + private boolean mWrittenOnce = false; + + @Override + public void onCollapsedChanged(boolean isCollapsed) { + if (!isCollapsed && !mWrittenOnce) { + incrementNumSheetExpansions(); + mWrittenOnce = true; + } + getChooserActivityLogger() + .logSharesheetExpansionChanged(isCollapsed); + } + }); + } + + if (DEBUG) { + Log.d(TAG, "System Time Cost is " + systemCost); + } + + getChooserActivityLogger().logShareStarted( + FrameworkStatsLog.SHARESHEET_STARTED, + getReferrerPackageName(), + target.getType(), + mCallerChooserTargets == null ? 0 : mCallerChooserTargets.length, + initialIntents == null ? 0 : initialIntents.length, + isWorkProfile(), + findPreferredContentPreview(getTargetIntent(), getContentResolver()), + target.getAction() + ); + mDirectShareShortcutInfoCache = new HashMap<>(); + + setEnterSharedElementCallback(new SharedElementCallback() { + @Override + public void onMapSharedElements(List<String> names, Map<String, View> sharedElements) { + if (mRemoveSharedElements) { + names.remove(FIRST_IMAGE_PREVIEW_TRANSITION_NAME); + sharedElements.remove(FIRST_IMAGE_PREVIEW_TRANSITION_NAME); + } + super.onMapSharedElements(names, sharedElements); + mRemoveSharedElements = false; + } + }); + mEnterTransitionAnimationDelegate.postponeTransition(); + } + + @Override + protected int appliedThemeResId() { + return R.style.Theme_DeviceDefault_Chooser; + } + + private AppPredictor setupAppPredictorForUser(UserHandle userHandle, + AppPredictor.Callback appPredictorCallback) { + AppPredictor appPredictor = getAppPredictorForDirectShareIfEnabled(userHandle); + if (appPredictor == null) { + return null; + } + mDirectShareAppTargetCache = new HashMap<>(); + appPredictor.registerPredictionUpdates(this.getMainExecutor(), appPredictorCallback); + return appPredictor; + } + + private AppPredictor.Callback createAppPredictorCallback( + ChooserListAdapter chooserListAdapter) { + return resultList -> { + if (isFinishing() || isDestroyed()) { + return; + } + if (chooserListAdapter.getCount() == 0) { + return; + } + if (resultList.isEmpty() + && shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) { + // APS may be disabled, so try querying targets ourselves. + queryDirectShareTargets(chooserListAdapter, true); + return; + } + final List<ShortcutManager.ShareShortcutInfo> shareShortcutInfos = + new ArrayList<>(); + + List<AppTarget> shortcutResults = new ArrayList<>(); + for (AppTarget appTarget : resultList) { + if (appTarget.getShortcutInfo() == null) { + continue; + } + shortcutResults.add(appTarget); + } + resultList = shortcutResults; + for (AppTarget appTarget : resultList) { + shareShortcutInfos.add(new ShortcutManager.ShareShortcutInfo( + appTarget.getShortcutInfo(), + new ComponentName( + appTarget.getPackageName(), appTarget.getClassName()))); + } + sendShareShortcutInfoList(shareShortcutInfos, chooserListAdapter, resultList, + chooserListAdapter.getUserHandle()); + }; + } + + 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); + } + + @Override + protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed) { + if (shouldShowTabs()) { + mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForTwoProfiles( + initialIntents, rList, filterLastUsed); + } else { + mChooserMultiProfilePagerAdapter = createChooserMultiProfilePagerAdapterForOneProfile( + initialIntents, rList, filterLastUsed); + } + return mChooserMultiProfilePagerAdapter; + } + + private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed) { + ChooserGridAdapter adapter = createChooserGridAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + initialIntents, + rList, + filterLastUsed, + /* userHandle */ UserHandle.of(UserHandle.myUserId())); + return new ChooserMultiProfilePagerAdapter( + /* context */ this, + adapter, + getPersonalProfileUserHandle(), + /* workProfileUserHandle= */ null, + isSendAction(getTargetIntent()), mMaxTargetsPerRow); + } + + private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed) { + int selectedProfile = findSelectedProfile(); + ChooserGridAdapter personalAdapter = createChooserGridAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_PERSONAL ? initialIntents : null, + rList, + filterLastUsed, + /* userHandle */ getPersonalProfileUserHandle()); + ChooserGridAdapter workAdapter = createChooserGridAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_WORK ? initialIntents : null, + rList, + filterLastUsed, + /* userHandle */ getWorkProfileUserHandle()); + return new ChooserMultiProfilePagerAdapter( + /* context */ this, + personalAdapter, + workAdapter, + selectedProfile, + getPersonalProfileUserHandle(), + getWorkProfileUserHandle(), + isSendAction(getTargetIntent()), mMaxTargetsPerRow); + } + + private int findSelectedProfile() { + int selectedProfile = getSelectedProfileExtra(); + if (selectedProfile == -1) { + selectedProfile = getProfileForUser(getUser()); + } + return selectedProfile; + } + + @Override + protected boolean postRebuildList(boolean rebuildCompleted) { + updateStickyContentPreview(); + if (shouldShowStickyContentPreview() + || mChooserMultiProfilePagerAdapter + .getCurrentRootAdapter().getSystemRowCount() != 0) { + logActionShareWithPreview(); + } + return postRebuildListInternal(rebuildCompleted); + } + + /** + * Returns true if app prediction service is defined and the component exists on device. + */ + private boolean isAppPredictionServiceAvailable() { + return getPackageManager().getAppPredictionServicePackageName() != null; + } + + /** + * Check if the profile currently used is a work profile. + * @return true if it is work profile, false if it is parent profile (or no work profile is + * set up) + */ + protected boolean isWorkProfile() { + return getSystemService(UserManager.class) + .getUserInfo(UserHandle.myUserId()).isManagedProfile(); + } + + @Override + protected PackageMonitor createPackageMonitor(ResolverListAdapter listAdapter) { + return new PackageMonitor() { + @Override + public void onSomePackagesChanged() { + handlePackagesChanged(listAdapter); + } + }; + } + + /** + * Update UI to reflect changes in data. + */ + public void handlePackagesChanged() { + handlePackagesChanged(/* listAdapter */ null); + } + + /** + * Update UI to reflect changes in data. + * <p>If {@code listAdapter} is {@code null}, both profile list adapters are updated if + * available. + */ + private void handlePackagesChanged(@Nullable ResolverListAdapter listAdapter) { + // Refresh pinned items + mPinnedSharedPrefs = getPinnedSharedPrefs(this); + if (listAdapter == null) { + mChooserMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + if (mChooserMultiProfilePagerAdapter.getCount() > 1) { + mChooserMultiProfilePagerAdapter.getInactiveListAdapter().handlePackagesChanged(); + } + } else { + listAdapter.handlePackagesChanged(); + } + updateProfileViewButton(); + } + + private void onCopyButtonClicked(View v) { + Intent targetIntent = getTargetIntent(); + if (targetIntent == null) { + finish(); + } else { + final String action = targetIntent.getAction(); + + ClipData clipData = null; + if (Intent.ACTION_SEND.equals(action)) { + String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); + Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + + if (extraText != null) { + clipData = ClipData.newPlainText(null, extraText); + } else if (extraStream != null) { + clipData = ClipData.newUri(getContentResolver(), null, extraStream); + } else { + Log.w(TAG, "No data available to copy to clipboard"); + return; + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + final ArrayList<Uri> streams = targetIntent.getParcelableArrayListExtra( + Intent.EXTRA_STREAM); + clipData = ClipData.newUri(getContentResolver(), null, streams.get(0)); + for (int i = 1; i < streams.size(); i++) { + clipData.addItem(getContentResolver(), new ClipData.Item(streams.get(i))); + } + } else { + // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE + // so warn about unexpected action + Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard"); + return; + } + + ClipboardManager clipboardManager = (ClipboardManager) getSystemService( + Context.CLIPBOARD_SERVICE); + clipboardManager.setPrimaryClipAsPackage(clipData, getReferrerPackageName()); + + // Log share completion via copy + LogMaker targetLogMaker = new LogMaker( + MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET).setSubtype(1); + getMetricsLogger().write(targetLogMaker); + getChooserActivityLogger().logShareTargetSelected( + SELECTION_TYPE_COPY, + "", + -1, + false); + + setResult(RESULT_OK); + finish(); + } + } + + @Override + protected void onResume() { + super.onResume(); + Log.d(TAG, "onResume: " + getComponentName().flattenToShortString()); + maybeCancelFinishAnimation(); + } + + @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); + } + + mShouldDisplayLandscape = shouldDisplayLandscape(newConfig.orientation); + mMaxTargetsPerRow = getResources().getInteger(R.integer.config_chooser_max_targets_per_row); + mChooserMultiProfilePagerAdapter.setMaxTargetsPerRow(mMaxTargetsPerRow); + adjustPreviewWidth(newConfig.orientation, null); + updateStickyContentPreview(); + updateTabPadding(); + } + + private boolean shouldDisplayLandscape(int orientation) { + // Sharesheet fixes the # of items per row and therefore can not correctly lay out + // when in the restricted size of multi-window mode. In the future, would be nice + // to use minimum dp size requirements instead + return orientation == Configuration.ORIENTATION_LANDSCAPE && !isInMultiWindowMode(); + } + + private void adjustPreviewWidth(int orientation, View parent) { + int width = -1; + if (mShouldDisplayLandscape) { + width = getResources().getDimensionPixelSize(R.dimen.chooser_preview_width); + } + + parent = parent == null ? getWindow().getDecorView() : parent; + + updateLayoutWidth(com.android.internal.R.id.content_preview_text_layout, width, parent); + updateLayoutWidth(com.android.internal.R.id.content_preview_title_layout, width, parent); + updateLayoutWidth(com.android.internal.R.id.content_preview_file_layout, width, parent); + } + + private void updateTabPadding() { + if (shouldShowTabs()) { + View tabs = findViewById(com.android.internal.R.id.tabs); + float iconSize = getResources().getDimension(R.dimen.chooser_icon_size); + // The entire width consists of icons or padding. Divide the item padding in half to get + // paddingHorizontal. + float padding = (tabs.getWidth() - mMaxTargetsPerRow * iconSize) + / mMaxTargetsPerRow / 2; + // Subtract the margin the buttons already have. + padding -= getResources().getDimension(R.dimen.resolver_profile_tab_margin); + tabs.setPadding((int) padding, 0, (int) padding, 0); + } + } + + private void updateLayoutWidth(int layoutResourceId, int width, View parent) { + View view = parent.findViewById(layoutResourceId); + if (view != null && view.getLayoutParams() != null) { + LayoutParams params = view.getLayoutParams(); + params.width = width; + view.setLayoutParams(params); + } + } + + private ViewGroup createContentPreviewView(ViewGroup parent) { + Intent targetIntent = getTargetIntent(); + int previewType = findPreferredContentPreview(targetIntent, getContentResolver()); + return displayContentPreview(previewType, targetIntent, getLayoutInflater(), parent); + } + + @VisibleForTesting + protected ComponentName getNearbySharingComponent() { + String nearbyComponent = Settings.Secure.getString( + getContentResolver(), + Settings.Secure.NEARBY_SHARING_COMPONENT); + if (TextUtils.isEmpty(nearbyComponent)) { + nearbyComponent = getString(R.string.config_defaultNearbySharingComponent); + } + if (TextUtils.isEmpty(nearbyComponent)) { + return null; + } + return ComponentName.unflattenFromString(nearbyComponent); + } + + @VisibleForTesting + protected @Nullable ComponentName getEditSharingComponent() { + String editorPackage = getApplicationContext().getString(R.string.config_systemImageEditor); + if (editorPackage == null || TextUtils.isEmpty(editorPackage)) { + return null; + } + return ComponentName.unflattenFromString(editorPackage); + } + + @VisibleForTesting + protected TargetInfo getEditSharingTarget(Intent originalIntent) { + final ComponentName cn = getEditSharingComponent(); + + 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(cn); + resolveIntent.setAction(Intent.ACTION_EDIT); + final ResolveInfo ri = getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified image edit component (" + cn + + ") not available"); + return null; + } + + final DisplayResolveInfo dri = new DisplayResolveInfo( + originalIntent, ri, getString(com.android.internal.R.string.screenshot_edit), "", resolveIntent, null); + dri.setDisplayIcon(getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); + return dri; + } + + @VisibleForTesting + protected TargetInfo getNearbySharingTarget(Intent originalIntent) { + final ComponentName cn = getNearbySharingComponent(); + if (cn == null) return null; + + final Intent resolveIntent = new Intent(originalIntent); + resolveIntent.setComponent(cn); + final ResolveInfo ri = getPackageManager().resolveActivity( + resolveIntent, PackageManager.GET_META_DATA); + if (ri == null || ri.activityInfo == null) { + Log.e(TAG, "Device-specified nearby sharing component (" + cn + + ") not available"); + return null; + } + + // Allow the nearby sharing component to provide a more appropriate icon and label + // for the chip. + CharSequence name = null; + Drawable icon = null; + final Bundle metaData = ri.activityInfo.metaData; + if (metaData != null) { + try { + final Resources pkgRes = getPackageManager().getResourcesForActivity(cn); + final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); + name = pkgRes.getString(nameResId); + final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); + icon = pkgRes.getDrawable(resId); + } catch (Resources.NotFoundException ex) { + } catch (NameNotFoundException ex) { + } + } + if (TextUtils.isEmpty(name)) { + name = ri.loadLabel(getPackageManager()); + } + if (icon == null) { + icon = ri.loadIcon(getPackageManager()); + } + + final DisplayResolveInfo dri = new DisplayResolveInfo( + originalIntent, ri, name, "", resolveIntent, null); + dri.setDisplayIcon(icon); + return dri; + } + + private Button createActionButton(Drawable icon, CharSequence title, View.OnClickListener r) { + Button b = (Button) LayoutInflater.from(this).inflate(R.layout.chooser_action_button, null); + if (icon != null) { + final int size = getResources() + .getDimensionPixelSize(R.dimen.chooser_action_button_icon_size); + icon.setBounds(0, 0, size, size); + b.setCompoundDrawablesRelative(icon, null, null, null); + } + b.setText(title); + b.setOnClickListener(r); + return b; + } + + private Button createCopyButton() { + final Button b = createActionButton( + getDrawable(com.android.internal.R.drawable.ic_menu_copy_material), + getString(com.android.internal.R.string.copy), this::onCopyButtonClicked); + b.setId(com.android.internal.R.id.chooser_copy_button); + return b; + } + + private @Nullable Button createNearbyButton(Intent originalIntent) { + final TargetInfo ti = getNearbySharingTarget(originalIntent); + if (ti == null) return null; + + final Button b = createActionButton( + ti.getDisplayIcon(this), + ti.getDisplayLabel(), + (View unused) -> { + // Log share completion via nearby + getChooserActivityLogger().logShareTargetSelected( + SELECTION_TYPE_NEARBY, + "", + -1, + false); + // Action bar is user-independent, always start as primary + safelyStartActivityAsUser(ti, getPersonalProfileUserHandle()); + finish(); + } + ); + b.setId(com.android.internal.R.id.chooser_nearby_button); + return b; + } + + private @Nullable Button createEditButton(Intent originalIntent) { + final TargetInfo ti = getEditSharingTarget(originalIntent); + if (ti == null) return null; + + final Button b = createActionButton( + ti.getDisplayIcon(this), + ti.getDisplayLabel(), + (View unused) -> { + // Log share completion via edit + getChooserActivityLogger().logShareTargetSelected( + SELECTION_TYPE_EDIT, + "", + -1, + false); + View firstImgView = getFirstVisibleImgPreviewView(); + // Action bar is user-independent, always start as primary + if (firstImgView == null) { + safelyStartActivityAsUser(ti, getPersonalProfileUserHandle()); + finish(); + } else { + ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation( + this, firstImgView, IMAGE_EDITOR_SHARED_ELEMENT); + safelyStartActivityAsUser( + ti, getPersonalProfileUserHandle(), options.toBundle()); + startFinishAnimation(); + } + } + ); + b.setId(com.android.internal.R.id.chooser_edit_button); + return b; + } + + @Nullable + private View getFirstVisibleImgPreviewView() { + View firstImage = findViewById(com.android.internal.R.id.content_preview_image_1_large); + return firstImage != null && firstImage.isVisibleToUser() ? firstImage : null; + } + + private void addActionButton(ViewGroup parent, Button b) { + if (b == null) return; + final ViewGroup.MarginLayoutParams lp = new ViewGroup.MarginLayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT + ); + final int gap = getResources().getDimensionPixelSize(R.dimen.resolver_icon_margin) / 2; + lp.setMarginsRelative(gap, 0, gap, 0); + parent.addView(b, lp); + } + + private ViewGroup displayContentPreview(@ContentPreviewType int previewType, + Intent targetIntent, LayoutInflater layoutInflater, ViewGroup parent) { + ViewGroup layout = null; + + switch (previewType) { + case CONTENT_PREVIEW_TEXT: + layout = displayTextContentPreview(targetIntent, layoutInflater, parent); + break; + case CONTENT_PREVIEW_IMAGE: + layout = displayImageContentPreview(targetIntent, layoutInflater, parent); + break; + case CONTENT_PREVIEW_FILE: + layout = displayFileContentPreview(targetIntent, layoutInflater, parent); + break; + default: + Log.e(TAG, "Unexpected content preview type: " + previewType); + } + + if (layout != null) { + adjustPreviewWidth(getResources().getConfiguration().orientation, layout); + } + if (previewType != CONTENT_PREVIEW_IMAGE) { + mEnterTransitionAnimationDelegate.markImagePreviewReady(); + } + + return layout; + } + + private ViewGroup displayTextContentPreview(Intent targetIntent, LayoutInflater layoutInflater, + ViewGroup parent) { + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_text, parent, false); + + final ViewGroup actionRow = + (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + addActionButton(actionRow, createCopyButton()); + if (shouldNearbyShareBeIncludedAsActionButton()) { + addActionButton(actionRow, createNearbyButton(targetIntent)); + } + + CharSequence sharingText = targetIntent.getCharSequenceExtra(Intent.EXTRA_TEXT); + if (sharingText == null) { + contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_text_layout).setVisibility( + View.GONE); + } else { + TextView textView = contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_text); + textView.setText(sharingText); + } + + String previewTitle = targetIntent.getStringExtra(Intent.EXTRA_TITLE); + if (TextUtils.isEmpty(previewTitle)) { + contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_title_layout).setVisibility( + View.GONE); + } else { + TextView previewTitleView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_title); + previewTitleView.setText(previewTitle); + + ClipData previewData = targetIntent.getClipData(); + Uri previewThumbnail = null; + if (previewData != null) { + if (previewData.getItemCount() > 0) { + ClipData.Item previewDataItem = previewData.getItemAt(0); + previewThumbnail = previewDataItem.getUri(); + } + } + + ImageView previewThumbnailView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_thumbnail); + if (previewThumbnail == null) { + previewThumbnailView.setVisibility(View.GONE); + } else { + mPreviewCoord = new ContentPreviewCoordinator(contentPreviewLayout, false); + mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_thumbnail, previewThumbnail, 0); + } + } + + return contentPreviewLayout; + } + + private ViewGroup displayImageContentPreview(Intent targetIntent, LayoutInflater layoutInflater, + ViewGroup parent) { + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_image, parent, false); + ViewGroup imagePreview = contentPreviewLayout.findViewById(com.android.internal.R.id.content_preview_image_area); + + final ViewGroup actionRow = + (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + //TODO: addActionButton(actionRow, createCopyButton()); + if (shouldNearbyShareBeIncludedAsActionButton()) { + addActionButton(actionRow, createNearbyButton(targetIntent)); + } + addActionButton(actionRow, createEditButton(targetIntent)); + + mPreviewCoord = new ContentPreviewCoordinator(contentPreviewLayout, false); + + String action = targetIntent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) + .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); + mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_1_large, uri, 0); + } else { + ContentResolver resolver = getContentResolver(); + + List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + List<Uri> imageUris = new ArrayList<>(); + for (Uri uri : uris) { + if (isImageType(resolver.getType(uri))) { + imageUris.add(uri); + } + } + + if (imageUris.size() == 0) { + Log.i(TAG, "Attempted to display image preview area with zero" + + " available images detected in EXTRA_STREAM list"); + imagePreview.setVisibility(View.GONE); + return contentPreviewLayout; + } + + imagePreview.findViewById(com.android.internal.R.id.content_preview_image_1_large) + .setTransitionName(ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); + mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_1_large, imageUris.get(0), 0); + + if (imageUris.size() == 2) { + mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_2_large, + imageUris.get(1), 0); + } else if (imageUris.size() > 2) { + mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_2_small, + imageUris.get(1), 0); + mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_image_3_small, + imageUris.get(2), imageUris.size() - 3); + } + } + + return contentPreviewLayout; + } + + private static class FileInfo { + public final String name; + public final boolean hasThumbnail; + + FileInfo(String name, boolean hasThumbnail) { + this.name = name; + this.hasThumbnail = hasThumbnail; + } + } + + /** + * Wrapping the ContentResolver call to expose for easier mocking, + * and to avoid mocking Android core classes. + */ + @VisibleForTesting + public Cursor queryResolver(ContentResolver resolver, Uri uri) { + return resolver.query(uri, null, null, null, null); + } + + private FileInfo extractFileInfo(Uri uri, ContentResolver resolver) { + String fileName = null; + boolean hasThumbnail = false; + + try (Cursor cursor = queryResolver(resolver, uri)) { + if (cursor != null && cursor.getCount() > 0) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + int titleIndex = cursor.getColumnIndex(Downloads.Impl.COLUMN_TITLE); + int flagsIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS); + + cursor.moveToFirst(); + if (nameIndex != -1) { + fileName = cursor.getString(nameIndex); + } else if (titleIndex != -1) { + fileName = cursor.getString(titleIndex); + } + + if (flagsIndex != -1) { + hasThumbnail = (cursor.getInt(flagsIndex) + & DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL) != 0; + } + } + } catch (SecurityException | NullPointerException e) { + logContentPreviewWarning(uri); + } + + if (TextUtils.isEmpty(fileName)) { + fileName = uri.getPath(); + int index = fileName.lastIndexOf('/'); + if (index != -1) { + fileName = fileName.substring(index + 1); + } + } + + return new FileInfo(fileName, hasThumbnail); + } + + private void logContentPreviewWarning(Uri uri) { + // The ContentResolver already logs the exception. Log something more informative. + Log.w(TAG, "Could not load (" + uri.toString() + ") thumbnail/name for preview. If " + + "desired, consider using Intent#createChooser to launch the ChooserActivity, " + + "and set your Intent's clipData and flags in accordance with that method's " + + "documentation"); + } + + private ViewGroup displayFileContentPreview(Intent targetIntent, LayoutInflater layoutInflater, + ViewGroup parent) { + + ViewGroup contentPreviewLayout = (ViewGroup) layoutInflater.inflate( + R.layout.chooser_grid_preview_file, parent, false); + + final ViewGroup actionRow = + (ViewGroup) contentPreviewLayout.findViewById(com.android.internal.R.id.chooser_action_row); + //TODO(b/120417119): addActionButton(actionRow, createCopyButton()); + if (shouldNearbyShareBeIncludedAsActionButton()) { + addActionButton(actionRow, createNearbyButton(targetIntent)); + } + + String action = targetIntent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + loadFileUriIntoView(uri, contentPreviewLayout); + } else { + List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + int uriCount = uris.size(); + + if (uriCount == 0) { + contentPreviewLayout.setVisibility(View.GONE); + Log.i(TAG, + "Appears to be no uris available in EXTRA_STREAM, removing " + + "preview area"); + return contentPreviewLayout; + } else if (uriCount == 1) { + loadFileUriIntoView(uris.get(0), contentPreviewLayout); + } else { + FileInfo fileInfo = extractFileInfo(uris.get(0), getContentResolver()); + int remUriCount = uriCount - 1; + Map<String, Object> arguments = new HashMap<>(); + arguments.put(PLURALS_COUNT, remUriCount); + arguments.put(PLURALS_FILE_NAME, fileInfo.name); + String fileName = PluralsMessageFormatter.format( + getResources(), + arguments, + R.string.file_count); + + TextView fileNameView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_filename); + fileNameView.setText(fileName); + + View thumbnailView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_thumbnail); + thumbnailView.setVisibility(View.GONE); + + ImageView fileIconView = contentPreviewLayout.findViewById( + com.android.internal.R.id.content_preview_file_icon); + fileIconView.setVisibility(View.VISIBLE); + fileIconView.setImageResource(R.drawable.ic_file_copy); + } + } + + return contentPreviewLayout; + } + + private void loadFileUriIntoView(final Uri uri, final View parent) { + FileInfo fileInfo = extractFileInfo(uri, getContentResolver()); + + TextView fileNameView = parent.findViewById(com.android.internal.R.id.content_preview_filename); + fileNameView.setText(fileInfo.name); + + if (fileInfo.hasThumbnail) { + mPreviewCoord = new ContentPreviewCoordinator(parent, false); + mPreviewCoord.loadUriIntoView(com.android.internal.R.id.content_preview_file_thumbnail, uri, 0); + } else { + View thumbnailView = parent.findViewById(com.android.internal.R.id.content_preview_file_thumbnail); + thumbnailView.setVisibility(View.GONE); + + ImageView fileIconView = parent.findViewById(com.android.internal.R.id.content_preview_file_icon); + fileIconView.setVisibility(View.VISIBLE); + fileIconView.setImageResource(R.drawable.chooser_file_generic); + } + } + + @VisibleForTesting + protected boolean isImageType(String mimeType) { + return mimeType != null && mimeType.startsWith("image/"); + } + + @ContentPreviewType + private int findPreferredContentPreview(Uri uri, ContentResolver resolver) { + if (uri == null) { + return CONTENT_PREVIEW_TEXT; + } + + String mimeType = resolver.getType(uri); + return isImageType(mimeType) ? CONTENT_PREVIEW_IMAGE : CONTENT_PREVIEW_FILE; + } + + /** + * In {@link android.content.Intent#getType}, the app may specify a very general + * mime-type that broadly covers all data being shared, such as {@literal *}/* + * when sending an image and text. We therefore should inspect each item for the + * the preferred type, in order of IMAGE, FILE, TEXT. + */ + @ContentPreviewType + private int findPreferredContentPreview(Intent targetIntent, ContentResolver resolver) { + String action = targetIntent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + Uri uri = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); + return findPreferredContentPreview(uri, resolver); + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + List<Uri> uris = targetIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (uris == null || uris.isEmpty()) { + return CONTENT_PREVIEW_TEXT; + } + + for (Uri uri : uris) { + // Defaulting to file preview when there are mixed image/file types is + // preferable, as it shows the user the correct number of items being shared + if (findPreferredContentPreview(uri, resolver) == CONTENT_PREVIEW_FILE) { + return CONTENT_PREVIEW_FILE; + } + } + + return CONTENT_PREVIEW_IMAGE; + } + + return CONTENT_PREVIEW_TEXT; + } + + private int getNumSheetExpansions() { + return getPreferences(Context.MODE_PRIVATE).getInt(PREF_NUM_SHEET_EXPANSIONS, 0); + } + + private void incrementNumSheetExpansions() { + getPreferences(Context.MODE_PRIVATE).edit().putInt(PREF_NUM_SHEET_EXPANSIONS, + getNumSheetExpansions() + 1).apply(); + } + + @Override + protected void onStop() { + super.onStop(); + if (maybeCancelFinishAnimation()) { + finish(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + if (isFinishing()) { + mLatencyTracker.onActionCancel(ACTION_LOAD_SHARE_SHEET); + } + + if (mRefinementResultReceiver != null) { + mRefinementResultReceiver.destroy(); + mRefinementResultReceiver = null; + } + mChooserHandler.removeAllMessages(); + + if (mPreviewCoord != null) mPreviewCoord.cancelLoads(); + + mChooserMultiProfilePagerAdapter.getActiveListAdapter().destroyAppPredictor(); + if (mChooserMultiProfilePagerAdapter.getInactiveListAdapter() != null) { + mChooserMultiProfilePagerAdapter.getInactiveListAdapter().destroyAppPredictor(); + } + mPersonalAppPredictor = null; + mWorkAppPredictor = null; + } + + @Override // ResolverListCommunicator + public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { + Intent result = defIntent; + if (mReplacementExtras != null) { + final Bundle replExtras = mReplacementExtras.getBundle(aInfo.packageName); + if (replExtras != null) { + result = new Intent(defIntent); + result.putExtras(replExtras); + } + } + if (aInfo.name.equals(IntentForwarderActivity.FORWARD_INTENT_TO_PARENT) + || aInfo.name.equals(IntentForwarderActivity.FORWARD_INTENT_TO_MANAGED_PROFILE)) { + result = Intent.createChooser(result, + getIntent().getCharSequenceExtra(Intent.EXTRA_TITLE)); + + // Don't auto-launch single intents if the intent is being forwarded. This is done + // because automatically launching a resolving application as a response to the user + // action of switching accounts is pretty unexpected. + result.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false); + } + return result; + } + + @Override + public void onActivityStarted(TargetInfo cti) { + if (mChosenComponentSender != null) { + final ComponentName target = cti.getResolvedComponentName(); + if (target != null) { + final Intent fillIn = new Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, target); + try { + mChosenComponentSender.sendIntent(this, Activity.RESULT_OK, fillIn, null, null); + } catch (IntentSender.SendIntentException e) { + Slog.e(TAG, "Unable to launch supplied IntentSender to report " + + "the chosen component: " + e); + } + } + } + } + + @Override + public void addUseDifferentAppLabelIfNecessary(ResolverListAdapter adapter) { + if (mCallerChooserTargets != null && mCallerChooserTargets.length > 0) { + mChooserMultiProfilePagerAdapter.getActiveListAdapter().addServiceResults( + /* origTarget */ null, + Lists.newArrayList(mCallerChooserTargets), + TARGET_TYPE_DEFAULT, + /* directShareShortcutInfoCache */ null); + } + } + + @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)) { + return false; + } + + return getIntent().getBooleanExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, true); + } + + private void showTargetDetails(TargetInfo targetInfo) { + if (targetInfo == null) return; + + ArrayList<DisplayResolveInfo> targetList; + ChooserTargetActionsDialogFragment fragment = new ChooserTargetActionsDialogFragment(); + Bundle bundle = new Bundle(); + + if (targetInfo instanceof SelectableTargetInfo) { + SelectableTargetInfo selectableTargetInfo = (SelectableTargetInfo) targetInfo; + if (selectableTargetInfo.getDisplayResolveInfo() == null + || selectableTargetInfo.getChooserTarget() == null) { + Log.e(TAG, "displayResolveInfo or chooserTarget in selectableTargetInfo are null"); + return; + } + targetList = new ArrayList<>(); + targetList.add(selectableTargetInfo.getDisplayResolveInfo()); + bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_ID_KEY, + selectableTargetInfo.getChooserTarget().getIntentExtras().getString( + Intent.EXTRA_SHORTCUT_ID)); + bundle.putBoolean(ChooserTargetActionsDialogFragment.IS_SHORTCUT_PINNED_KEY, + selectableTargetInfo.isPinned()); + bundle.putParcelable(ChooserTargetActionsDialogFragment.INTENT_FILTER_KEY, + getTargetIntentFilter()); + if (selectableTargetInfo.getDisplayLabel() != null) { + bundle.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY, + selectableTargetInfo.getDisplayLabel().toString()); + } + } else if (targetInfo instanceof MultiDisplayResolveInfo) { + // For multiple targets, include info on all targets + MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; + targetList = mti.getTargets(); + } else { + targetList = new ArrayList<DisplayResolveInfo>(); + targetList.add((DisplayResolveInfo) targetInfo); + } + bundle.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY, + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + bundle.putParcelableArrayList(ChooserTargetActionsDialogFragment.TARGET_INFOS_KEY, + targetList); + fragment.setArguments(bundle); + + fragment.show(getFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG); + } + + private void modifyTargetIntent(Intent in) { + if (isSendAction(in)) { + in.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT | + Intent.FLAG_ACTIVITY_MULTIPLE_TASK); + } + } + + @Override + protected boolean onTargetSelected(TargetInfo target, boolean alwaysCheck) { + if (mRefinementIntentSender != null) { + final Intent fillIn = new Intent(); + final List<Intent> sourceIntents = target.getAllSourceIntents(); + if (!sourceIntents.isEmpty()) { + fillIn.putExtra(Intent.EXTRA_INTENT, sourceIntents.get(0)); + if (sourceIntents.size() > 1) { + final Intent[] alts = new Intent[sourceIntents.size() - 1]; + for (int i = 1, N = sourceIntents.size(); i < N; i++) { + alts[i - 1] = sourceIntents.get(i); + } + fillIn.putExtra(Intent.EXTRA_ALTERNATE_INTENTS, alts); + } + if (mRefinementResultReceiver != null) { + mRefinementResultReceiver.destroy(); + } + mRefinementResultReceiver = new RefinementResultReceiver(this, target, null); + fillIn.putExtra(Intent.EXTRA_RESULT_RECEIVER, + mRefinementResultReceiver); + try { + mRefinementIntentSender.sendIntent(this, 0, fillIn, null, null); + return false; + } catch (SendIntentException e) { + Log.e(TAG, "Refinement IntentSender failed to send", e); + } + } + } + updateModelAndChooserCounts(target); + return super.onTargetSelected(target, alwaysCheck); + } + + @Override + public void startSelected(int which, boolean always, boolean filtered) { + ChooserListAdapter currentListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + TargetInfo targetInfo = currentListAdapter + .targetInfoForPosition(which, filtered); + if (targetInfo != null && targetInfo instanceof NotSelectableTargetInfo) { + return; + } + + final long selectionCost = System.currentTimeMillis() - mChooserShownTime; + + if (targetInfo instanceof MultiDisplayResolveInfo) { + MultiDisplayResolveInfo mti = (MultiDisplayResolveInfo) targetInfo; + if (!mti.hasSelected()) { + ChooserStackedAppDialogFragment f = new ChooserStackedAppDialogFragment(); + Bundle b = new Bundle(); + b.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY, + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + b.putObject(ChooserStackedAppDialogFragment.MULTI_DRI_KEY, + mti); + b.putInt(ChooserStackedAppDialogFragment.WHICH_KEY, which); + f.setArguments(b); + + f.show(getFragmentManager(), TARGET_DETAILS_FRAGMENT_TAG); + return; + } + } + + super.startSelected(which, always, filtered); + + if (currentListAdapter.getCount() > 0) { + // Log the index of which type of target the user picked. + // Lower values mean the ranking was better. + int cat = 0; + int value = which; + int directTargetAlsoRanked = -1; + int numCallerProvided = 0; + HashedStringCache.HashResult directTargetHashed = null; + switch (currentListAdapter.getPositionTargetType(which)) { + case ChooserListAdapter.TARGET_SERVICE: + cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET; + // Log the package name + target name to answer the question if most users + // share to mostly the same person or to a bunch of different people. + ChooserTarget target = currentListAdapter.getChooserTargetForValue(value); + directTargetHashed = HashedStringCache.getInstance().hashString( + this, + TAG, + target.getComponentName().getPackageName() + + target.getTitle().toString(), + mMaxHashSaltDays); + SelectableTargetInfo selectableTargetInfo = (SelectableTargetInfo) targetInfo; + directTargetAlsoRanked = getRankedPosition(selectableTargetInfo); + + if (mCallerChooserTargets != null) { + numCallerProvided = mCallerChooserTargets.length; + } + getChooserActivityLogger().logShareTargetSelected( + SELECTION_TYPE_SERVICE, + targetInfo.getResolveInfo().activityInfo.processName, + value, + selectableTargetInfo.isPinned() + ); + break; + case ChooserListAdapter.TARGET_CALLER: + case ChooserListAdapter.TARGET_STANDARD: + cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_APP_TARGET; + value -= currentListAdapter.getSurfacedTargetInfo().size(); + numCallerProvided = currentListAdapter.getCallerTargetCount(); + getChooserActivityLogger().logShareTargetSelected( + SELECTION_TYPE_APP, + targetInfo.getResolveInfo().activityInfo.processName, + value, + targetInfo.isPinned() + ); + break; + case ChooserListAdapter.TARGET_STANDARD_AZ: + // A-Z targets are unranked standard targets; we use -1 to mark that they + // are from the alphabetical pool. + value = -1; + cat = MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_STANDARD_TARGET; + getChooserActivityLogger().logShareTargetSelected( + SELECTION_TYPE_STANDARD, + targetInfo.getResolveInfo().activityInfo.processName, + value, + false + ); + break; + } + + if (cat != 0) { + LogMaker targetLogMaker = new LogMaker(cat).setSubtype(value); + if (directTargetHashed != null) { + targetLogMaker.addTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_NAME, directTargetHashed.hashedString); + targetLogMaker.addTaggedData( + MetricsEvent.FIELD_HASHED_TARGET_SALT_GEN, + directTargetHashed.saltGeneration); + targetLogMaker.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION, + directTargetAlsoRanked); + } + targetLogMaker.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED, + numCallerProvided); + getMetricsLogger().write(targetLogMaker); + } + + if (mIsSuccessfullySelected) { + if (DEBUG) { + Log.d(TAG, "User Selection Time Cost is " + selectionCost); + Log.d(TAG, "position of selected app/service/caller is " + + Integer.toString(value)); + } + MetricsLogger.histogram(null, "user_selection_cost_for_smart_sharing", + (int) selectionCost); + MetricsLogger.histogram(null, "app_position_for_smart_sharing", value); + } + } + } + + private int getRankedPosition(SelectableTargetInfo targetInfo) { + String targetPackageName = + targetInfo.getChooserTarget().getComponentName().getPackageName(); + ChooserListAdapter currentListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + int maxRankedResults = Math.min(currentListAdapter.mDisplayList.size(), + MAX_LOG_RANK_POSITION); + + for (int i = 0; i < maxRankedResults; i++) { + if (currentListAdapter.mDisplayList.get(i) + .getResolveInfo().activityInfo.packageName.equals(targetPackageName)) { + return i; + } + } + 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); + } + } + + private IntentFilter getTargetIntentFilter() { + try { + final Intent intent = getTargetIntent(); + String dataString = intent.getDataString(); + if (intent.getType() == null) { + if (!TextUtils.isEmpty(dataString)) { + return new IntentFilter(intent.getAction(), dataString); + } + Log.e(TAG, "Failed to get target intent filter: intent data and type are null"); + return null; + } + IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType()); + List<Uri> contentUris = new ArrayList<>(); + if (Intent.ACTION_SEND.equals(intent.getAction())) { + Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null) { + contentUris.add(uri); + } + } else { + List<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (uris != null) { + contentUris.addAll(uris); + } + } + for (Uri uri : contentUris) { + intentFilter.addDataScheme(uri.getScheme()); + intentFilter.addDataAuthority(uri.getAuthority(), null); + intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL); + } + return intentFilter; + } catch (Exception e) { + Log.e(TAG, "Failed to get target intent filter", e); + return null; + } + } + + @VisibleForTesting + protected void queryDirectShareTargets( + ChooserListAdapter adapter, boolean skipAppPredictionService) { + mQueriedSharingShortcutsTimeMs = System.currentTimeMillis(); + UserHandle userHandle = adapter.getUserHandle(); + if (!skipAppPredictionService) { + AppPredictor appPredictor = getAppPredictorForDirectShareIfEnabled(userHandle); + if (appPredictor != null) { + appPredictor.requestPredictionUpdate(); + return; + } + } + // Default to just querying ShortcutManager if AppPredictor not present. + final IntentFilter filter = getTargetIntentFilter(); + if (filter == null) { + return; + } + + AsyncTask.execute(() -> { + Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */); + ShortcutManager sm = (ShortcutManager) selectedProfileContext + .getSystemService(Context.SHORTCUT_SERVICE); + List<ShortcutManager.ShareShortcutInfo> resultList = sm.getShareTargets(filter); + sendShareShortcutInfoList(resultList, adapter, null, userHandle); + }); + } + + /** + * Returns {@code false} if {@code userHandle} is the work profile and it's either + * in quiet mode or not running. + */ + private boolean shouldQueryShortcutManager(UserHandle userHandle) { + if (!shouldShowTabs()) { + return true; + } + if (!getWorkProfileUserHandle().equals(userHandle)) { + return true; + } + if (!isUserRunning(userHandle)) { + return false; + } + if (!isUserUnlocked(userHandle)) { + return false; + } + if (isQuietModeEnabled(userHandle)) { + return false; + } + return true; + } + + private void sendShareShortcutInfoList( + List<ShortcutManager.ShareShortcutInfo> resultList, + ChooserListAdapter chooserListAdapter, + @Nullable List<AppTarget> appTargets, UserHandle userHandle) { + if (appTargets != null && appTargets.size() != resultList.size()) { + throw new RuntimeException("resultList and appTargets must have the same size." + + " resultList.size()=" + resultList.size() + + " appTargets.size()=" + appTargets.size()); + } + Context selectedProfileContext = createContextAsUser(userHandle, 0 /* flags */); + for (int i = resultList.size() - 1; i >= 0; i--) { + final String packageName = resultList.get(i).getTargetComponent().getPackageName(); + if (!isPackageEnabled(selectedProfileContext, packageName)) { + resultList.remove(i); + if (appTargets != null) { + appTargets.remove(i); + } + } + } + + // If |appTargets| is not null, results are from AppPredictionService and already sorted. + final int shortcutType = (appTargets == null ? TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER : + TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); + + // Match ShareShortcutInfos with DisplayResolveInfos to be able to use the old code path + // for direct share targets. After ShareSheet is refactored we should use the + // ShareShortcutInfos directly. + List<ServiceResultInfo> resultRecords = new ArrayList<>(); + for (int i = 0; i < chooserListAdapter.getDisplayResolveInfoCount(); i++) { + DisplayResolveInfo displayResolveInfo = chooserListAdapter.getDisplayResolveInfo(i); + List<ShortcutManager.ShareShortcutInfo> matchingShortcuts = + filterShortcutsByTargetComponentName( + resultList, displayResolveInfo.getResolvedComponentName()); + if (matchingShortcuts.isEmpty()) { + continue; + } + List<ChooserTarget> chooserTargets = convertToChooserTarget( + matchingShortcuts, resultList, appTargets, shortcutType); + + ServiceResultInfo resultRecord = new ServiceResultInfo( + displayResolveInfo, chooserTargets, userHandle); + resultRecords.add(resultRecord); + } + + sendShortcutManagerShareTargetResults( + shortcutType, resultRecords.toArray(new ServiceResultInfo[0])); + } + + private List<ShortcutManager.ShareShortcutInfo> filterShortcutsByTargetComponentName( + List<ShortcutManager.ShareShortcutInfo> allShortcuts, ComponentName requiredTarget) { + List<ShortcutManager.ShareShortcutInfo> matchingShortcuts = new ArrayList<>(); + for (ShortcutManager.ShareShortcutInfo shortcut : allShortcuts) { + if (requiredTarget.equals(shortcut.getTargetComponent())) { + matchingShortcuts.add(shortcut); + } + } + return matchingShortcuts; + } + + private void sendShortcutManagerShareTargetResults( + int shortcutType, ServiceResultInfo[] results) { + final Message msg = Message.obtain(); + msg.what = ChooserHandler.SHORTCUT_MANAGER_ALL_SHARE_TARGET_RESULTS; + msg.obj = results; + msg.arg1 = shortcutType; + mChooserHandler.sendMessage(msg); + } + + private boolean isPackageEnabled(Context context, String packageName) { + if (TextUtils.isEmpty(packageName)) { + return false; + } + ApplicationInfo appInfo; try { - super.startActivityAsCaller(intent, options, ignoreTargetSecurity, userId); - } finally { - StrictMode.enableDeathOnFileUriExposure(); + appInfo = context.getPackageManager().getApplicationInfo(packageName, 0); + } catch (NameNotFoundException e) { + return false; + } + + if (appInfo != null && appInfo.enabled + && (appInfo.flags & ApplicationInfo.FLAG_SUSPENDED) == 0) { + return true; + } + return false; + } + + /** + * Converts a list of ShareShortcutInfos to ChooserTargets. + * @param matchingShortcuts List of shortcuts, all from the same package, that match the current + * share intent filter. + * @param allShortcuts List of all the shortcuts from all the packages on the device that are + * returned for the current sharing action. + * @param allAppTargets List of AppTargets. Null if the results are not from prediction service. + * @param shortcutType One of the values TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER or + * TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE + * @return A list of ChooserTargets sorted by score in descending order. + */ + @VisibleForTesting + @NonNull + public List<ChooserTarget> convertToChooserTarget( + @NonNull List<ShortcutManager.ShareShortcutInfo> matchingShortcuts, + @NonNull List<ShortcutManager.ShareShortcutInfo> allShortcuts, + @Nullable List<AppTarget> allAppTargets, @ShareTargetType int shortcutType) { + // A set of distinct scores for the matched shortcuts. We use index of a rank in the sorted + // list instead of the actual rank value when converting a rank to a score. + List<Integer> scoreList = new ArrayList<>(); + if (shortcutType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER) { + for (int i = 0; i < matchingShortcuts.size(); i++) { + int shortcutRank = matchingShortcuts.get(i).getShortcutInfo().getRank(); + if (!scoreList.contains(shortcutRank)) { + scoreList.add(shortcutRank); + } + } + Collections.sort(scoreList); + } + + List<ChooserTarget> chooserTargetList = new ArrayList<>(matchingShortcuts.size()); + for (int i = 0; i < matchingShortcuts.size(); i++) { + ShortcutInfo shortcutInfo = matchingShortcuts.get(i).getShortcutInfo(); + int indexInAllShortcuts = allShortcuts.indexOf(matchingShortcuts.get(i)); + + float score; + if (shortcutType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE) { + // Incoming results are ordered. Create a score based on index in the original list. + score = Math.max(1.0f - (0.01f * indexInAllShortcuts), 0.0f); + } else { + // Create a score based on the rank of the shortcut. + int rankIndex = scoreList.indexOf(shortcutInfo.getRank()); + score = Math.max(1.0f - (0.01f * rankIndex), 0.0f); + } + + Bundle extras = new Bundle(); + extras.putString(Intent.EXTRA_SHORTCUT_ID, shortcutInfo.getId()); + + ChooserTarget chooserTarget = new ChooserTarget( + shortcutInfo.getLabel(), + null, // Icon will be loaded later if this target is selected to be shown. + score, matchingShortcuts.get(i).getTargetComponent().clone(), extras); + + chooserTargetList.add(chooserTarget); + if (mDirectShareAppTargetCache != null && allAppTargets != null) { + mDirectShareAppTargetCache.put(chooserTarget, + allAppTargets.get(indexInAllShortcuts)); + } + if (mDirectShareShortcutInfoCache != null) { + mDirectShareShortcutInfoCache.put(chooserTarget, shortcutInfo); + } + } + // Sort ChooserTargets by score in descending order + Comparator<ChooserTarget> byScore = + (ChooserTarget a, ChooserTarget b) -> -Float.compare(a.getScore(), b.getScore()); + Collections.sort(chooserTargetList, byScore); + return chooserTargetList; + } + + private void logDirectShareTargetReceived(int logCategory) { + final int apiLatency = (int) (System.currentTimeMillis() - mQueriedSharingShortcutsTimeMs); + getMetricsLogger().write(new LogMaker(logCategory).setSubtype(apiLatency)); + } + + void updateModelAndChooserCounts(TargetInfo info) { + if (info != null && info instanceof MultiDisplayResolveInfo) { + info = ((MultiDisplayResolveInfo) info).getSelectedTarget(); } + if (info != null) { + sendClickToAppPredictor(info); + final ResolveInfo ri = info.getResolveInfo(); + Intent targetIntent = getTargetIntent(); + if (ri != null && ri.activityInfo != null && targetIntent != null) { + ChooserListAdapter currentListAdapter = + mChooserMultiProfilePagerAdapter.getActiveListAdapter(); + if (currentListAdapter != null) { + sendImpressionToAppPredictor(info, currentListAdapter); + currentListAdapter.updateModel(info.getResolvedComponentName()); + currentListAdapter.updateChooserCounts(ri.activityInfo.packageName, + targetIntent.getAction()); + } + if (DEBUG) { + Log.d(TAG, "ResolveInfo Package is " + ri.activityInfo.packageName); + Log.d(TAG, "Action to be updated is " + targetIntent.getAction()); + } + } else if (DEBUG) { + Log.d(TAG, "Can not log Chooser Counts of null ResovleInfo"); + } + } + mIsSuccessfullySelected = true; + } + + private void sendImpressionToAppPredictor(TargetInfo targetInfo, ChooserListAdapter adapter) { + AppPredictor directShareAppPredictor = getAppPredictorForDirectShareIfEnabled( + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + if (directShareAppPredictor == null) { + return; + } + // Send DS target impression info to AppPredictor, only when user chooses app share. + if (targetInfo instanceof ChooserTargetInfo) { + return; + } + List<ChooserTargetInfo> surfacedTargetInfo = adapter.getSurfacedTargetInfo(); + List<AppTargetId> targetIds = new ArrayList<>(); + for (ChooserTargetInfo chooserTargetInfo : surfacedTargetInfo) { + ChooserTarget chooserTarget = chooserTargetInfo.getChooserTarget(); + ComponentName componentName = chooserTarget.getComponentName(); + if (mDirectShareShortcutInfoCache.containsKey(chooserTarget)) { + String shortcutId = mDirectShareShortcutInfoCache.get(chooserTarget).getId(); + targetIds.add(new AppTargetId( + String.format("%s/%s/%s", shortcutId, componentName.flattenToString(), + SHORTCUT_TARGET))); + } + } + directShareAppPredictor.notifyLaunchLocationShown(LAUNCH_LOCATION_DIRECT_SHARE, targetIds); + } + + private void sendClickToAppPredictor(TargetInfo targetInfo) { + AppPredictor directShareAppPredictor = getAppPredictorForDirectShareIfEnabled( + mChooserMultiProfilePagerAdapter.getCurrentUserHandle()); + if (directShareAppPredictor == null) { + return; + } + if (!(targetInfo instanceof ChooserTargetInfo)) { + return; + } + ChooserTarget chooserTarget = ((ChooserTargetInfo) targetInfo).getChooserTarget(); + AppTarget appTarget = null; + if (mDirectShareAppTargetCache != null) { + appTarget = mDirectShareAppTargetCache.get(chooserTarget); + } + // This is a direct share click that was provided by the APS + if (appTarget != null) { + directShareAppPredictor.notifyAppTargetEvent( + new AppTargetEvent.Builder(appTarget, AppTargetEvent.ACTION_LAUNCH) + .setLaunchLocation(LAUNCH_LOCATION_DIRECT_SHARE) + .build()); + } + } + + @Nullable + private AppPredictor createAppPredictor(UserHandle userHandle) { + if (!mIsAppPredictorComponentAvailable) { + return null; + } + + if (getPersonalProfileUserHandle().equals(userHandle)) { + if (mPersonalAppPredictor != null) { + return mPersonalAppPredictor; + } + } else { + if (mWorkAppPredictor != null) { + return mWorkAppPredictor; + } + } + + // TODO(b/148230574): Currently AppPredictor fetches only the same-profile app targets. + // Make AppPredictor work cross-profile. + Context contextAsUser = createContextAsUser(userHandle, 0 /* flags */); + final IntentFilter filter = getTargetIntentFilter(); + Bundle extras = new Bundle(); + extras.putParcelable(APP_PREDICTION_INTENT_FILTER_KEY, filter); + populateTextContent(extras); + AppPredictionContext appPredictionContext = new AppPredictionContext.Builder(contextAsUser) + .setUiSurface(APP_PREDICTION_SHARE_UI_SURFACE) + .setPredictedTargetCount(APP_PREDICTION_SHARE_TARGET_QUERY_PACKAGE_LIMIT) + .setExtras(extras) + .build(); + AppPredictionManager appPredictionManager = + contextAsUser + .getSystemService(AppPredictionManager.class); + AppPredictor appPredictionSession = appPredictionManager.createAppPredictionSession( + appPredictionContext); + if (getPersonalProfileUserHandle().equals(userHandle)) { + mPersonalAppPredictor = appPredictionSession; + } else { + mWorkAppPredictor = appPredictionSession; + } + return appPredictionSession; + } + + private void populateTextContent(Bundle extras) { + final Intent intent = getTargetIntent(); + String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); + extras.putString(SHARED_TEXT_KEY, sharedText); + } + + /** + * This will return an app predictor if it is enabled for direct share sorting + * and if one exists. Otherwise, it returns null. + * @param userHandle + */ + @Nullable + private AppPredictor getAppPredictorForDirectShareIfEnabled(UserHandle userHandle) { + return ChooserFlags.USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS + && !ActivityManager.isLowRamDeviceStatic() ? createAppPredictor(userHandle) : null; + } + + /** + * This will return an app predictor if it is enabled for share activity sorting + * and if one exists. Otherwise, it returns null. + */ + @Nullable + private AppPredictor getAppPredictorForShareActivitiesIfEnabled(UserHandle userHandle) { + return USE_PREDICTION_MANAGER_FOR_SHARE_ACTIVITIES ? createAppPredictor(userHandle) : null; + } + + void onRefinementResult(TargetInfo selectedTarget, Intent matchingIntent) { + if (mRefinementResultReceiver != null) { + mRefinementResultReceiver.destroy(); + mRefinementResultReceiver = null; + } + if (selectedTarget == null) { + Log.e(TAG, "Refinement result intent did not match any known targets; canceling"); + } else if (!checkTargetSourceIntent(selectedTarget, matchingIntent)) { + Log.e(TAG, "onRefinementResult: Selected target " + selectedTarget + + " cannot match refined source intent " + matchingIntent); + } else { + TargetInfo clonedTarget = selectedTarget.cloneFilledIn(matchingIntent, 0); + if (super.onTargetSelected(clonedTarget, false)) { + updateModelAndChooserCounts(clonedTarget); + finish(); + return; + } + } + onRefinementCanceled(); + } + + void onRefinementCanceled() { + if (mRefinementResultReceiver != null) { + mRefinementResultReceiver.destroy(); + mRefinementResultReceiver = null; + } + finish(); + } + + boolean checkTargetSourceIntent(TargetInfo target, Intent matchingIntent) { + final List<Intent> targetIntents = target.getAllSourceIntents(); + for (int i = 0, N = targetIntents.size(); i < N; i++) { + final Intent targetIntent = targetIntents.get(i); + if (targetIntent.filterEquals(matchingIntent)) { + return true; + } + } + return false; + } + + /** + * Sort intents alphabetically based on display label. + */ + static class AzInfoComparator implements Comparator<DisplayResolveInfo> { + Collator mCollator; + AzInfoComparator(Context context) { + mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); + } + + @Override + public int compare( + DisplayResolveInfo lhsp, DisplayResolveInfo rhsp) { + return mCollator.compare(lhsp.getDisplayLabel(), rhsp.getDisplayLabel()); + } + } + + protected MetricsLogger getMetricsLogger() { + if (mMetricsLogger == null) { + mMetricsLogger = new MetricsLogger(); + } + return mMetricsLogger; + } + + protected ChooserActivityLogger getChooserActivityLogger() { + if (mChooserActivityLogger == null) { + mChooserActivityLogger = new ChooserActivityLoggerImpl(); + } + return mChooserActivityLogger; + } + + public class ChooserListController extends ResolverListController { + public ChooserListController(Context context, + PackageManager pm, + Intent targetIntent, + String referrerPackageName, + int launchedFromUid, + UserHandle userId, + AbstractResolverComparator resolverComparator) { + super(context, pm, targetIntent, referrerPackageName, launchedFromUid, userId, + resolverComparator); + } + + @Override + boolean isComponentFiltered(ComponentName name) { + if (mFilteredComponentNames == null) { + return false; + } + for (ComponentName filteredComponentName : mFilteredComponentNames) { + if (name.equals(filteredComponentName)) { + return true; + } + } + return false; + } + + @Override + public boolean isComponentPinned(ComponentName name) { + return mPinnedSharedPrefs.getBoolean(name.flattenToString(), false); + } + + @Override + public boolean isFixedAtTop(ComponentName name) { + return name != null && name.equals(getNearbySharingComponent()) + && shouldNearbyShareBeFirstInRankedRow(); + } + } + + @VisibleForTesting + public ChooserGridAdapter createChooserGridAdapter(Context context, + List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList, + boolean filterLastUsed, UserHandle userHandle) { + ChooserListAdapter chooserListAdapter = createChooserListAdapter(context, payloadIntents, + initialIntents, rList, filterLastUsed, + createListController(userHandle)); + AppPredictor.Callback appPredictorCallback = createAppPredictorCallback(chooserListAdapter); + AppPredictor appPredictor = setupAppPredictorForUser(userHandle, appPredictorCallback); + chooserListAdapter.setAppPredictor(appPredictor); + chooserListAdapter.setAppPredictorCallback(appPredictorCallback); + return new ChooserGridAdapter(chooserListAdapter); + } + + @VisibleForTesting + public ChooserListAdapter createChooserListAdapter(Context context, + List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList, + boolean filterLastUsed, ResolverListController resolverListController) { + return new ChooserListAdapter(context, payloadIntents, initialIntents, rList, + filterLastUsed, resolverListController, this, + this, context.getPackageManager(), + getChooserActivityLogger()); + } + + @VisibleForTesting + protected ResolverListController createListController(UserHandle userHandle) { + AppPredictor appPredictor = getAppPredictorForShareActivitiesIfEnabled(userHandle); + AbstractResolverComparator resolverComparator; + if (appPredictor != null) { + resolverComparator = new AppPredictionServiceResolverComparator(this, getTargetIntent(), + getReferrerPackageName(), appPredictor, userHandle, getChooserActivityLogger()); + } else { + resolverComparator = + new ResolverRankerServiceResolverComparator(this, getTargetIntent(), + getReferrerPackageName(), null, getChooserActivityLogger()); + } + + return new ChooserListController( + this, + mPm, + getTargetIntent(), + getReferrerPackageName(), + mLaunchedFromUid, + userHandle, + resolverComparator); + } + + @VisibleForTesting + protected Bitmap loadThumbnail(Uri uri, Size size) { + if (uri == null || size == null) { + return null; + } + + try { + return getContentResolver().loadThumbnail(uri, size, null); + } catch (IOException | NullPointerException | SecurityException ex) { + logContentPreviewWarning(uri); + } + return null; + } + + static final class PlaceHolderTargetInfo extends NotSelectableTargetInfo { + public Drawable getDisplayIcon(Context context) { + AnimatedVectorDrawable avd = (AnimatedVectorDrawable) + context.getDrawable(R.drawable.chooser_direct_share_icon_placeholder); + avd.start(); // Start animation after generation + return avd; + } + } + + protected static final class EmptyTargetInfo extends NotSelectableTargetInfo { + public EmptyTargetInfo() {} + + public Drawable getDisplayIcon(Context context) { + return null; + } + } + + private void handleScroll(View view, int x, int y, int oldx, int oldy) { + if (mChooserMultiProfilePagerAdapter.getCurrentRootAdapter() != null) { + mChooserMultiProfilePagerAdapter.getCurrentRootAdapter().handleScroll(view, y, oldy); + } + } + + /* + * Need to dynamically adjust how many icons can fit per row before we add them, + * which also means setting the correct offset to initially show the content + * preview area + 2 rows of targets + */ + private void handleLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, + int oldTop, int oldRight, int oldBottom) { + if (mChooserMultiProfilePagerAdapter == null) { + return; + } + RecyclerView recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); + ChooserGridAdapter gridAdapter = mChooserMultiProfilePagerAdapter.getCurrentRootAdapter(); + // Skip height calculation if recycler view was scrolled to prevent it inaccurately + // calculating the height, as the logic below does not account for the scrolled offset. + if (gridAdapter == null || recyclerView == null + || recyclerView.computeVerticalScrollOffset() != 0) { + return; + } + + final int availableWidth = right - left - v.getPaddingLeft() - v.getPaddingRight(); + boolean isLayoutUpdated = gridAdapter.consumeLayoutRequest() + || gridAdapter.calculateChooserTargetWidth(availableWidth) + || recyclerView.getAdapter() == null + || availableWidth != mCurrAvailableWidth; + + boolean insetsChanged = !Objects.equals(mLastAppliedInsets, mSystemWindowInsets); + + if (isLayoutUpdated + || insetsChanged + || mLastNumberOfChildren != recyclerView.getChildCount()) { + mCurrAvailableWidth = availableWidth; + if (isLayoutUpdated) { + // It is very important we call setAdapter from here. Otherwise in some cases + // the resolver list doesn't get populated, such as b/150922090, b/150918223 + // and b/150936654 + recyclerView.setAdapter(gridAdapter); + ((GridLayoutManager) recyclerView.getLayoutManager()).setSpanCount( + mMaxTargetsPerRow); + + updateTabPadding(); + } + + UserHandle currentUserHandle = mChooserMultiProfilePagerAdapter.getCurrentUserHandle(); + int currentProfile = getProfileForUser(currentUserHandle); + int initialProfile = findSelectedProfile(); + if (currentProfile != initialProfile) { + return; + } + + if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged) { + return; + } + + getMainThreadHandler().post(() -> { + if (mResolverDrawerLayout == null || gridAdapter == null) { + return; + } + int offset = calculateDrawerOffset(top, bottom, recyclerView, gridAdapter); + mResolverDrawerLayout.setCollapsibleHeightReserved(offset); + mEnterTransitionAnimationDelegate.markOffsetCalculated(); + mLastAppliedInsets = mSystemWindowInsets; + }); + } + } + + private int calculateDrawerOffset( + int top, int bottom, RecyclerView recyclerView, ChooserGridAdapter gridAdapter) { + + final int bottomInset = mSystemWindowInsets != null + ? mSystemWindowInsets.bottom : 0; + int offset = bottomInset; + int rowsToShow = gridAdapter.getSystemRowCount() + + gridAdapter.getProfileRowCount() + + gridAdapter.getServiceTargetRowCount() + + gridAdapter.getCallerAndRankedTargetRowCount(); + + // then this is most likely not a SEND_* action, so check + // the app target count + if (rowsToShow == 0) { + rowsToShow = gridAdapter.getRowCount(); + } + + // still zero? then use a default height and leave, which + // can happen when there are no targets to show + if (rowsToShow == 0 && !shouldShowStickyContentPreview()) { + offset += getResources().getDimensionPixelSize( + R.dimen.chooser_max_collapsed_height); + return offset; + } + + View stickyContentPreview = findViewById(com.android.internal.R.id.content_preview_container); + if (shouldShowStickyContentPreview() && isStickyContentPreviewShowing()) { + offset += stickyContentPreview.getHeight(); + } + + if (shouldShowTabs()) { + offset += findViewById(com.android.internal.R.id.tabs).getHeight(); + } + + if (recyclerView.getVisibility() == View.VISIBLE) { + int directShareHeight = 0; + rowsToShow = Math.min(4, rowsToShow); + boolean shouldShowExtraRow = shouldShowExtraRow(rowsToShow); + mLastNumberOfChildren = recyclerView.getChildCount(); + for (int i = 0, childCount = recyclerView.getChildCount(); + i < childCount && rowsToShow > 0; i++) { + View child = recyclerView.getChildAt(i); + if (((GridLayoutManager.LayoutParams) + child.getLayoutParams()).getSpanIndex() != 0) { + continue; + } + int height = child.getHeight(); + offset += height; + if (shouldShowExtraRow) { + offset += height; + } + + if (gridAdapter.getTargetType( + recyclerView.getChildAdapterPosition(child)) + == ChooserListAdapter.TARGET_SERVICE) { + directShareHeight = height; + } + rowsToShow--; + } + + boolean isExpandable = getResources().getConfiguration().orientation + == Configuration.ORIENTATION_PORTRAIT && !isInMultiWindowMode(); + if (directShareHeight != 0 && isSendAction(getTargetIntent()) + && isExpandable) { + // make sure to leave room for direct share 4->8 expansion + int requiredExpansionHeight = + (int) (directShareHeight / DIRECT_SHARE_EXPANSION_RATE); + int topInset = mSystemWindowInsets != null ? mSystemWindowInsets.top : 0; + int minHeight = bottom - top - mResolverDrawerLayout.getAlwaysShowHeight() + - requiredExpansionHeight - topInset - bottomInset; + + offset = Math.min(offset, minHeight); + } + } else { + ViewGroup currentEmptyStateView = getActiveEmptyStateView(); + if (currentEmptyStateView.getVisibility() == View.VISIBLE) { + offset += currentEmptyStateView.getHeight(); + } + } + + return Math.min(offset, bottom - top); + } + + /** + * If we have a tabbed view and are showing 1 row in the current profile and an empty + * state screen in the other 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_PERSONAL}, {@link #PROFILE_WORK}, or -1 if the given user handle + * does not match either the personal or work user handle. + **/ + private int getProfileForUser(UserHandle currentUserHandle) { + if (currentUserHandle.equals(getPersonalProfileUserHandle())) { + return PROFILE_PERSONAL; + } else if (currentUserHandle.equals(getWorkProfileUserHandle())) { + return PROFILE_WORK; + } + Log.e(TAG, "User " + currentUserHandle + " does not belong to a personal or work profile."); + return -1; + } + + private ViewGroup getActiveEmptyStateView() { + int currentPage = mChooserMultiProfilePagerAdapter.getCurrentPage(); + return mChooserMultiProfilePagerAdapter.getItem(currentPage).getEmptyStateView(); + } + + static class BaseChooserTargetComparator implements Comparator<ChooserTarget> { + @Override + public int compare(ChooserTarget lhs, ChooserTarget rhs) { + // Descending order + return (int) Math.signum(rhs.getScore() - lhs.getScore()); + } + } + + @Override // ResolverListCommunicator + public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + mChooserMultiProfilePagerAdapter.getActiveListAdapter().notifyDataSetChanged(); + super.onHandlePackagesChanged(listAdapter); + } + + @Override // SelectableTargetInfoCommunicator + public ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo info) { + return mChooserMultiProfilePagerAdapter.getActiveListAdapter().makePresentationGetter(info); + } + + @Override // SelectableTargetInfoCommunicator + public Intent getReferrerFillInIntent() { + return mReferrerFillInIntent; + } + + @Override // ChooserListCommunicator + public int getMaxRankedTargets() { + return mMaxTargetsPerRow; + } + + @Override // ChooserListCommunicator + public void sendListViewUpdateMessage(UserHandle userHandle) { + Message msg = Message.obtain(); + msg.what = ChooserHandler.LIST_VIEW_UPDATE_MESSAGE; + msg.obj = userHandle; + mChooserHandler.sendMessageDelayed(msg, mListViewUpdateDelayMs); + } + + @Override + public void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildComplete) { + setupScrollListener(); + maybeSetupGlobalLayoutListener(); + + ChooserListAdapter chooserListAdapter = (ChooserListAdapter) listAdapter; + if (chooserListAdapter.getUserHandle() + .equals(mChooserMultiProfilePagerAdapter.getCurrentUserHandle())) { + mChooserMultiProfilePagerAdapter.getActiveAdapterView() + .setAdapter(mChooserMultiProfilePagerAdapter.getCurrentRootAdapter()); + mChooserMultiProfilePagerAdapter + .setupListAdapter(mChooserMultiProfilePagerAdapter.getCurrentPage()); + } + + if (chooserListAdapter.mDisplayList == null + || chooserListAdapter.mDisplayList.isEmpty()) { + chooserListAdapter.notifyDataSetChanged(); + } else { + chooserListAdapter.updateAlphabeticalList(); + } + + if (rebuildComplete) { + getChooserActivityLogger().logSharesheetAppLoadComplete(); + maybeQueryAdditionalPostProcessingTargets(chooserListAdapter); + mLatencyTracker.onActionEnd(ACTION_LOAD_SHARE_SHEET); + } + } + + private void maybeQueryAdditionalPostProcessingTargets(ChooserListAdapter chooserListAdapter) { + // don't support direct share on low ram devices + if (ActivityManager.isLowRamDeviceStatic()) { + return; + } + + // no need to query direct share for work profile when its locked or disabled + if (!shouldQueryShortcutManager(chooserListAdapter.getUserHandle())) { + return; + } + + if (ChooserFlags.USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS) { + if (DEBUG) { + Log.d(TAG, "querying direct share targets from ShortcutManager"); + } + + queryDirectShareTargets(chooserListAdapter, false); + } + } + + @VisibleForTesting + protected boolean isUserRunning(UserHandle userHandle) { + UserManager userManager = getSystemService(UserManager.class); + return userManager.isUserRunning(userHandle); + } + + @VisibleForTesting + protected boolean isUserUnlocked(UserHandle userHandle) { + UserManager userManager = getSystemService(UserManager.class); + return userManager.isUserUnlocked(userHandle); + } + + @VisibleForTesting + protected boolean isQuietModeEnabled(UserHandle userHandle) { + UserManager userManager = getSystemService(UserManager.class); + return userManager.isQuietModeEnabled(userHandle); + } + + private void setupScrollListener() { + if (mResolverDrawerLayout == null) { + return; + } + int elevatedViewResId = shouldShowTabs() ? 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() { + public void onScrollStateChanged(RecyclerView view, int scrollState) { + if (scrollState == RecyclerView.SCROLL_STATE_IDLE) { + if (mScrollStatus == SCROLL_STATUS_SCROLLING_VERTICAL) { + mScrollStatus = SCROLL_STATUS_IDLE; + setHorizontalScrollingEnabled(true); + } + } else if (scrollState == RecyclerView.SCROLL_STATE_DRAGGING) { + if (mScrollStatus == SCROLL_STATUS_IDLE) { + mScrollStatus = SCROLL_STATUS_SCROLLING_VERTICAL; + setHorizontalScrollingEnabled(false); + } + } + } + + public void onScrolled(RecyclerView view, int dx, int dy) { + if (view.getChildCount() > 0) { + View child = view.getLayoutManager().findViewByPosition(0); + if (child == null || child.getTop() < 0) { + elevatedView.setElevation(chooserHeaderScrollElevation); + return; + } + } + + elevatedView.setElevation(defaultElevation); + } + }); + } + + private void maybeSetupGlobalLayoutListener() { + if (shouldShowTabs()) { + return; + } + final View recyclerView = mChooserMultiProfilePagerAdapter.getActiveAdapterView(); + recyclerView.getViewTreeObserver() + .addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + // Fixes an issue were the accessibility border disappears on list creation. + recyclerView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + final TextView titleView = findViewById(com.android.internal.R.id.title); + if (titleView != null) { + titleView.setFocusable(true); + titleView.setFocusableInTouchMode(true); + titleView.requestFocus(); + titleView.requestAccessibilityFocus(); + } + } + }); + } + + @Override // ChooserListCommunicator + public boolean isSendAction(Intent targetIntent) { + if (targetIntent == null) { + return false; + } + + String action = targetIntent.getAction(); + if (action == null) { + return false; + } + + if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) { + return true; + } + + return false; + } + + /** + * The sticky content preview is shown only when we have a tabbed view. It's shown above + * the tabs so it is not part of the scrollable list. If we are not in tabbed view, + * we instead show the content preview as a regular list item. + */ + private boolean shouldShowStickyContentPreview() { + return shouldShowStickyContentPreviewNoOrientationCheck() + && !getResources().getBoolean(R.bool.resolver_landscape_phone); + } + + private boolean shouldShowStickyContentPreviewNoOrientationCheck() { + return shouldShowTabs() + && mMultiProfilePagerAdapter.getListAdapterForUserHandle( + UserHandle.of(UserHandle.myUserId())).getCount() > 0 + && isSendAction(getTargetIntent()); + } + + private void updateStickyContentPreview() { + if (shouldShowStickyContentPreviewNoOrientationCheck()) { + // The sticky content preview is only shown when we show the work and personal tabs. + // We don't show it in landscape as otherwise there is no room for scrolling. + // If the sticky content preview will be shown at some point with orientation change, + // then always preload it to avoid subsequent resizing of the share sheet. + ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); + if (contentPreviewContainer.getChildCount() == 0) { + ViewGroup contentPreviewView = createContentPreviewView(contentPreviewContainer); + contentPreviewContainer.addView(contentPreviewView); + } + } + if (shouldShowStickyContentPreview()) { + showStickyContentPreview(); + } else { + hideStickyContentPreview(); + } + } + + private void showStickyContentPreview() { + if (isStickyContentPreviewShowing()) { + return; + } + ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); + contentPreviewContainer.setVisibility(View.VISIBLE); + } + + private boolean isStickyContentPreviewShowing() { + ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); + return contentPreviewContainer.getVisibility() == View.VISIBLE; + } + + private void hideStickyContentPreview() { + if (!isStickyContentPreviewShowing()) { + return; + } + ViewGroup contentPreviewContainer = findViewById(com.android.internal.R.id.content_preview_container); + contentPreviewContainer.setVisibility(View.GONE); + } + + private void logActionShareWithPreview() { + Intent targetIntent = getTargetIntent(); + int previewType = findPreferredContentPreview(targetIntent, getContentResolver()); + getMetricsLogger().write(new LogMaker(MetricsEvent.ACTION_SHARE_WITH_PREVIEW) + .setSubtype(previewType)); + } + + private void startFinishAnimation() { + View rootView = findRootView(); + rootView.startAnimation(new FinishAnimation(this, rootView)); + } + + private boolean maybeCancelFinishAnimation() { + View rootView = findRootView(); + Animation animation = 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; + } + + abstract static class ViewHolderBase extends RecyclerView.ViewHolder { + private int mViewType; + + ViewHolderBase(View itemView, int viewType) { + super(itemView); + this.mViewType = viewType; + } + + int getViewType() { + return mViewType; + } + } + + /** + * Used to bind types of individual item including + * {@link ChooserGridAdapter#VIEW_TYPE_NORMAL}, + * {@link ChooserGridAdapter#VIEW_TYPE_CONTENT_PREVIEW}, + * {@link ChooserGridAdapter#VIEW_TYPE_PROFILE}, + * and {@link ChooserGridAdapter#VIEW_TYPE_AZ_LABEL}. + */ + final class ItemViewHolder extends ViewHolderBase { + ResolverListAdapter.ViewHolder mWrappedViewHolder; + int mListPosition = ChooserListAdapter.NO_POSITION; + + ItemViewHolder(View itemView, boolean isClickable, int viewType) { + super(itemView, viewType); + mWrappedViewHolder = new ResolverListAdapter.ViewHolder(itemView); + if (isClickable) { + itemView.setOnClickListener(v -> startSelected(mListPosition, + false/* always */, true/* filterd */)); + + itemView.setOnLongClickListener(v -> { + final TargetInfo ti = mChooserMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(mListPosition, /* filtered */ true); + + // This should always be the case for ItemViewHolder, check for validity + if (ti instanceof DisplayResolveInfo && shouldShowTargetDetails(ti)) { + showTargetDetails((DisplayResolveInfo) ti); + } + return true; + }); + } + } + } + + private boolean shouldShowTargetDetails(TargetInfo ti) { + ComponentName nearbyShare = getNearbySharingComponent(); + // Suppress target details for nearby share to hide pin/unpin action + boolean isNearbyShare = nearbyShare != null && nearbyShare.equals( + ti.getResolvedComponentName()) && shouldNearbyShareBeFirstInRankedRow(); + return ti instanceof SelectableTargetInfo + || (ti instanceof DisplayResolveInfo && !isNearbyShare); + } + + /** + * Add a footer to the list, to support scrolling behavior below the navbar. + */ + static final class FooterViewHolder extends ViewHolderBase { + FooterViewHolder(View itemView, int viewType) { + super(itemView, viewType); + } + } + + /** + * Intentionally override the {@link ResolverActivity} implementation as we only need that + * implementation for the intent resolver case. + */ + @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() { + ChooserGridAdapter currentRootAdapter = + mChooserMultiProfilePagerAdapter.getCurrentRootAdapter(); + currentRootAdapter.updateDirectShareExpansion(); + // This fixes an edge case where after performing a variety of gestures, vertical scrolling + // ends up disabled. That's because at some point the old tab's vertical scrolling is + // disabled and the new tab's is enabled. For context, see b/159997845 + setVerticalScrollEnabled(true); + if (mResolverDrawerLayout != null) { + mResolverDrawerLayout.scrollNestedScrollableChildBackToTop(); + } + } + + @Override + protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + if (shouldShowTabs()) { + mChooserMultiProfilePagerAdapter + .setEmptyStateBottomOffset(insets.getSystemWindowInsetBottom()); + mChooserMultiProfilePagerAdapter.setupContainerPadding( + getActiveEmptyStateView().findViewById(com.android.internal.R.id.resolver_empty_state_container)); + } + + WindowInsets result = super.onApplyWindowInsets(v, insets); + if (mResolverDrawerLayout != null) { + mResolverDrawerLayout.requestLayout(); + } + return result; + } + + private void setHorizontalScrollingEnabled(boolean enabled) { + ResolverViewPager viewPager = findViewById(com.android.internal.R.id.profile_pager); + viewPager.setSwipingEnabled(enabled); + } + + private void setVerticalScrollEnabled(boolean enabled) { + ChooserGridLayoutManager layoutManager = + (ChooserGridLayoutManager) mChooserMultiProfilePagerAdapter.getActiveAdapterView() + .getLayoutManager(); + layoutManager.setVerticalScrollEnabled(enabled); + } + + @Override + void onHorizontalSwipeStateChanged(int state) { + if (state == ViewPager.SCROLL_STATE_DRAGGING) { + if (mScrollStatus == SCROLL_STATUS_IDLE) { + mScrollStatus = SCROLL_STATUS_SCROLLING_HORIZONTAL; + setVerticalScrollEnabled(false); + } + } else if (state == ViewPager.SCROLL_STATE_IDLE) { + if (mScrollStatus == SCROLL_STATUS_SCROLLING_HORIZONTAL) { + mScrollStatus = SCROLL_STATUS_IDLE; + setVerticalScrollEnabled(true); + } + } + } + + /** + * Adapter for all types of items and targets in ShareSheet. + * Note that ranked sections like Direct Share - while appearing grid-like - are handled on the + * row level by this adapter but not on the item level. Individual targets within the row are + * handled by {@link ChooserListAdapter} + */ + @VisibleForTesting + public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { + private ChooserListAdapter mChooserListAdapter; + private final LayoutInflater mLayoutInflater; + + private DirectShareViewHolder mDirectShareViewHolder; + private int mChooserTargetWidth = 0; + private boolean mShowAzLabelIfPoss; + private boolean mLayoutRequested = false; + + private int mFooterHeight = 0; + + private static final int VIEW_TYPE_DIRECT_SHARE = 0; + private static final int VIEW_TYPE_NORMAL = 1; + private static final int VIEW_TYPE_CONTENT_PREVIEW = 2; + private static final int VIEW_TYPE_PROFILE = 3; + private static final int VIEW_TYPE_AZ_LABEL = 4; + private static final int VIEW_TYPE_CALLER_AND_RANK = 5; + private static final int VIEW_TYPE_FOOTER = 6; + + private static final int NUM_EXPANSIONS_TO_HIDE_AZ_LABEL = 20; + + ChooserGridAdapter(ChooserListAdapter wrappedAdapter) { + super(); + mChooserListAdapter = wrappedAdapter; + mLayoutInflater = LayoutInflater.from(ChooserActivity.this); + + mShowAzLabelIfPoss = getNumSheetExpansions() < NUM_EXPANSIONS_TO_HIDE_AZ_LABEL; + + wrappedAdapter.registerDataSetObserver(new DataSetObserver() { + @Override + public void onChanged() { + super.onChanged(); + notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + super.onInvalidated(); + notifyDataSetChanged(); + } + }); + } + + public void setFooterHeight(int height) { + mFooterHeight = height; + } + + /** + * Calculate the chooser target width to maximize space per item + * + * @param width The new row width to use for recalculation + * @return true if the view width has changed + */ + public boolean calculateChooserTargetWidth(int width) { + if (width == 0) { + return false; + } + + // Limit width to the maximum width of the chooser activity + int maxWidth = getResources().getDimensionPixelSize(R.dimen.chooser_width); + width = Math.min(maxWidth, width); + + int newWidth = width / mMaxTargetsPerRow; + if (newWidth != mChooserTargetWidth) { + mChooserTargetWidth = newWidth; + return true; + } + + return false; + } + + /** + * Hides the list item content preview. + * <p>Not to be confused with the sticky content preview which is above the + * personal and work tabs. + */ + public void hideContentPreview() { + mLayoutRequested = true; + notifyDataSetChanged(); + } + + public boolean consumeLayoutRequest() { + boolean oldValue = mLayoutRequested; + mLayoutRequested = false; + return oldValue; + } + + public int getRowCount() { + return (int) ( + getSystemRowCount() + + getProfileRowCount() + + getServiceTargetRowCount() + + getCallerAndRankedTargetRowCount() + + getAzLabelRowCount() + + Math.ceil( + (float) mChooserListAdapter.getAlphaTargetCount() + / mMaxTargetsPerRow) + ); + } + + /** + * Whether the "system" row of targets is displayed. + * This area includes the content preview (if present) and action row. + */ + public int getSystemRowCount() { + // For the tabbed case we show the sticky content preview above the tabs, + // please refer to shouldShowStickyContentPreview + if (shouldShowTabs()) { + return 0; + } + + if (!isSendAction(getTargetIntent())) { + return 0; + } + + if (mChooserListAdapter == null || mChooserListAdapter.getCount() == 0) { + return 0; + } + + return 1; + } + + public int getProfileRowCount() { + if (shouldShowTabs()) { + return 0; + } + return mChooserListAdapter.getOtherProfile() == null ? 0 : 1; + } + + public int getFooterRowCount() { + return 1; + } + + public int getCallerAndRankedTargetRowCount() { + return (int) Math.ceil( + ((float) mChooserListAdapter.getCallerTargetCount() + + mChooserListAdapter.getRankedTargetCount()) / mMaxTargetsPerRow); + } + + // There can be at most one row in the listview, that is internally + // a ViewGroup with 2 rows + public int getServiceTargetRowCount() { + if (isSendAction(getTargetIntent()) + && !ActivityManager.isLowRamDeviceStatic()) { + return 1; + } + return 0; + } + + public int getAzLabelRowCount() { + // Only show a label if the a-z list is showing + return (mShowAzLabelIfPoss && mChooserListAdapter.getAlphaTargetCount() > 0) ? 1 : 0; + } + + @Override + public int getItemCount() { + return (int) ( + getSystemRowCount() + + getProfileRowCount() + + getServiceTargetRowCount() + + getCallerAndRankedTargetRowCount() + + getAzLabelRowCount() + + mChooserListAdapter.getAlphaTargetCount() + + getFooterRowCount() + ); + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + switch (viewType) { + case VIEW_TYPE_CONTENT_PREVIEW: + return new ItemViewHolder(createContentPreviewView(parent), false, viewType); + case VIEW_TYPE_PROFILE: + return new ItemViewHolder(createProfileView(parent), false, viewType); + case VIEW_TYPE_AZ_LABEL: + return new ItemViewHolder(createAzLabelView(parent), false, viewType); + case VIEW_TYPE_NORMAL: + return new ItemViewHolder( + mChooserListAdapter.createView(parent), true, viewType); + case VIEW_TYPE_DIRECT_SHARE: + case VIEW_TYPE_CALLER_AND_RANK: + return createItemGroupViewHolder(viewType, parent); + case VIEW_TYPE_FOOTER: + Space sp = new Space(parent.getContext()); + sp.setLayoutParams(new RecyclerView.LayoutParams( + LayoutParams.MATCH_PARENT, mFooterHeight)); + return new FooterViewHolder(sp, viewType); + default: + // Since we catch all possible viewTypes above, no chance this is being called. + return null; + } + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + int viewType = ((ViewHolderBase) holder).getViewType(); + switch (viewType) { + case VIEW_TYPE_DIRECT_SHARE: + case VIEW_TYPE_CALLER_AND_RANK: + bindItemGroupViewHolder(position, (ItemGroupViewHolder) holder); + break; + case VIEW_TYPE_NORMAL: + bindItemViewHolder(position, (ItemViewHolder) holder); + break; + default: + } + } + + @Override + public int getItemViewType(int position) { + int count; + + int countSum = (count = getSystemRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_CONTENT_PREVIEW; + + countSum += (count = getProfileRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_PROFILE; + + countSum += (count = getServiceTargetRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_DIRECT_SHARE; + + countSum += (count = getCallerAndRankedTargetRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_CALLER_AND_RANK; + + countSum += (count = getAzLabelRowCount()); + if (count > 0 && position < countSum) return VIEW_TYPE_AZ_LABEL; + + if (position == getItemCount() - 1) return VIEW_TYPE_FOOTER; + + return VIEW_TYPE_NORMAL; + } + + public int getTargetType(int position) { + return mChooserListAdapter.getPositionTargetType(getListPosition(position)); + } + + private View createProfileView(ViewGroup parent) { + View profileRow = mLayoutInflater.inflate(R.layout.chooser_profile_row, parent, false); + mProfileView = profileRow.findViewById(com.android.internal.R.id.profile_button); + mProfileView.setOnClickListener(ChooserActivity.this::onProfileClick); + updateProfileViewButton(); + return profileRow; + } + + private View createAzLabelView(ViewGroup parent) { + return mLayoutInflater.inflate(R.layout.chooser_az_label_row, parent, false); + } + + private ItemGroupViewHolder loadViewsIntoGroup(ItemGroupViewHolder holder) { + final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + final int exactSpec = MeasureSpec.makeMeasureSpec(mChooserTargetWidth, + MeasureSpec.EXACTLY); + int columnCount = holder.getColumnCount(); + + final boolean isDirectShare = holder instanceof DirectShareViewHolder; + + for (int i = 0; i < columnCount; i++) { + final View v = mChooserListAdapter.createView(holder.getRowByIndex(i)); + final int column = i; + v.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + startSelected(holder.getItemIndex(column), false, true); + } + }); + + // Show menu for both direct share and app share targets after long click. + v.setOnLongClickListener(v1 -> { + TargetInfo ti = mChooserListAdapter.targetInfoForPosition( + holder.getItemIndex(column), true); + if (shouldShowTargetDetails(ti)) { + showTargetDetails(ti); + } + return true; + }); + + holder.addView(i, v); + + // Force Direct Share to be 2 lines and auto-wrap to second line via hoz scroll = + // false. TextView#setHorizontallyScrolling must be reset after #setLines. Must be + // done before measuring. + if (isDirectShare) { + final ViewHolder vh = (ViewHolder) v.getTag(); + vh.text.setLines(2); + vh.text.setHorizontallyScrolling(false); + vh.text2.setVisibility(View.GONE); + } + + // Force height to be a given so we don't have visual disruption during scaling. + v.measure(exactSpec, spec); + setViewBounds(v, v.getMeasuredWidth(), v.getMeasuredHeight()); + } + + final ViewGroup viewGroup = holder.getViewGroup(); + + // Pre-measure and fix height so we can scale later. + holder.measure(); + setViewBounds(viewGroup, LayoutParams.MATCH_PARENT, holder.getMeasuredRowHeight()); + + if (isDirectShare) { + DirectShareViewHolder dsvh = (DirectShareViewHolder) holder; + setViewBounds(dsvh.getRow(0), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight()); + setViewBounds(dsvh.getRow(1), LayoutParams.MATCH_PARENT, dsvh.getMinRowHeight()); + } + + viewGroup.setTag(holder); + return holder; + } + + private void setViewBounds(View view, int widthPx, int heightPx) { + LayoutParams lp = view.getLayoutParams(); + if (lp == null) { + lp = new LayoutParams(widthPx, heightPx); + view.setLayoutParams(lp); + } else { + lp.height = heightPx; + lp.width = widthPx; + } + } + + ItemGroupViewHolder createItemGroupViewHolder(int viewType, ViewGroup parent) { + if (viewType == VIEW_TYPE_DIRECT_SHARE) { + ViewGroup parentGroup = (ViewGroup) mLayoutInflater.inflate( + R.layout.chooser_row_direct_share, parent, false); + ViewGroup row1 = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row, + parentGroup, false); + ViewGroup row2 = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row, + parentGroup, false); + parentGroup.addView(row1); + parentGroup.addView(row2); + + mDirectShareViewHolder = new DirectShareViewHolder(parentGroup, + Lists.newArrayList(row1, row2), mMaxTargetsPerRow, viewType, + mChooserMultiProfilePagerAdapter::getActiveListAdapter); + loadViewsIntoGroup(mDirectShareViewHolder); + + return mDirectShareViewHolder; + } else { + ViewGroup row = (ViewGroup) mLayoutInflater.inflate(R.layout.chooser_row, parent, + false); + ItemGroupViewHolder holder = + new SingleRowViewHolder(row, mMaxTargetsPerRow, viewType); + loadViewsIntoGroup(holder); + + return holder; + } + } + + /** + * Need to merge CALLER + ranked STANDARD into a single row and prevent a separator from + * showing on top of the AZ list if the AZ label is visible. All other types are placed into + * their own row as determined by their target type, and dividers are added in the list to + * separate each type. + */ + int getRowType(int rowPosition) { + // Merge caller and ranked standard into a single row + int positionType = mChooserListAdapter.getPositionTargetType(rowPosition); + if (positionType == ChooserListAdapter.TARGET_CALLER) { + return ChooserListAdapter.TARGET_STANDARD; + } + + // If an the A-Z label is shown, prevent a separator from appearing by making the A-Z + // row type the same as the suggestion row type + if (getAzLabelRowCount() > 0 && positionType == ChooserListAdapter.TARGET_STANDARD_AZ) { + return ChooserListAdapter.TARGET_STANDARD; + } + + return positionType; + } + + void bindItemViewHolder(int position, ItemViewHolder holder) { + View v = holder.itemView; + int listPosition = getListPosition(position); + holder.mListPosition = listPosition; + mChooserListAdapter.bindView(listPosition, v); + } + + void bindItemGroupViewHolder(int position, ItemGroupViewHolder holder) { + final ViewGroup viewGroup = (ViewGroup) holder.itemView; + int start = getListPosition(position); + int startType = getRowType(start); + + int columnCount = holder.getColumnCount(); + int end = start + columnCount - 1; + while (getRowType(end) != startType && end >= start) { + end--; + } + + if (end == start && mChooserListAdapter.getItem(start) instanceof EmptyTargetInfo) { + final TextView textView = viewGroup.findViewById(com.android.internal.R.id.chooser_row_text_option); + + if (textView.getVisibility() != View.VISIBLE) { + textView.setAlpha(0.0f); + textView.setVisibility(View.VISIBLE); + textView.setText(R.string.chooser_no_direct_share_targets); + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(textView, "alpha", 0.0f, 1.0f); + fadeAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + + float translationInPx = getResources().getDimensionPixelSize( + R.dimen.chooser_row_text_option_translate); + textView.setTranslationY(translationInPx); + ValueAnimator translateAnim = ObjectAnimator.ofFloat(textView, "translationY", + 0.0f); + translateAnim.setInterpolator(new DecelerateInterpolator(1.0f)); + + AnimatorSet animSet = new AnimatorSet(); + animSet.setDuration(NO_DIRECT_SHARE_ANIM_IN_MILLIS); + animSet.setStartDelay(NO_DIRECT_SHARE_ANIM_IN_MILLIS); + animSet.playTogether(fadeAnim, translateAnim); + animSet.start(); + } + } + + for (int i = 0; i < columnCount; i++) { + final View v = holder.getView(i); + + if (start + i <= end) { + holder.setViewVisibility(i, View.VISIBLE); + holder.setItemIndex(i, start + i); + mChooserListAdapter.bindView(holder.getItemIndex(i), v); + } else { + holder.setViewVisibility(i, View.INVISIBLE); + } + } + } + + int getListPosition(int position) { + position -= getSystemRowCount() + getProfileRowCount(); + + final int serviceCount = mChooserListAdapter.getServiceTargetCount(); + final int serviceRows = (int) Math.ceil((float) serviceCount / getMaxRankedTargets()); + if (position < serviceRows) { + return position * mMaxTargetsPerRow; + } + + position -= serviceRows; + + final int callerAndRankedCount = mChooserListAdapter.getCallerTargetCount() + + mChooserListAdapter.getRankedTargetCount(); + final int callerAndRankedRows = getCallerAndRankedTargetRowCount(); + if (position < callerAndRankedRows) { + return serviceCount + position * mMaxTargetsPerRow; + } + + position -= getAzLabelRowCount() + callerAndRankedRows; + + return callerAndRankedCount + serviceCount + position; + } + + public void handleScroll(View v, int y, int oldy) { + boolean canExpandDirectShare = canExpandDirectShare(); + if (mDirectShareViewHolder != null && canExpandDirectShare) { + mDirectShareViewHolder.handleScroll( + mChooserMultiProfilePagerAdapter.getActiveAdapterView(), y, oldy, + mMaxTargetsPerRow); + } + } + + /** + * Only expand direct share area if there is a minimum number of targets. + */ + private boolean canExpandDirectShare() { + // Do not enable until we have confirmed more apps are using sharing shortcuts + // Check git history for enablement logic + return false; + } + + public ChooserListAdapter getListAdapter() { + return mChooserListAdapter; + } + + boolean shouldCellSpan(int position) { + return getItemViewType(position) == VIEW_TYPE_NORMAL; + } + + void updateDirectShareExpansion() { + if (mDirectShareViewHolder == null || !canExpandDirectShare()) { + return; + } + RecyclerView activeAdapterView = + mChooserMultiProfilePagerAdapter.getActiveAdapterView(); + if (mResolverDrawerLayout.isCollapsed()) { + mDirectShareViewHolder.collapse(activeAdapterView); + } else { + mDirectShareViewHolder.expand(activeAdapterView); + } + } + } + + /** + * Used to bind types for group of items including: + * {@link ChooserGridAdapter#VIEW_TYPE_DIRECT_SHARE}, + * and {@link ChooserGridAdapter#VIEW_TYPE_CALLER_AND_RANK}. + */ + abstract static class ItemGroupViewHolder extends ViewHolderBase { + protected int mMeasuredRowHeight; + private int[] mItemIndices; + protected final View[] mCells; + private final int mColumnCount; + + ItemGroupViewHolder(int cellCount, View itemView, int viewType) { + super(itemView, viewType); + this.mCells = new View[cellCount]; + this.mItemIndices = new int[cellCount]; + this.mColumnCount = cellCount; + } + + abstract ViewGroup addView(int index, View v); + + abstract ViewGroup getViewGroup(); + + abstract ViewGroup getRowByIndex(int index); + + abstract ViewGroup getRow(int rowNumber); + + abstract void setViewVisibility(int i, int visibility); + + public int getColumnCount() { + return mColumnCount; + } + + public void measure() { + final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + getViewGroup().measure(spec, spec); + mMeasuredRowHeight = getViewGroup().getMeasuredHeight(); + } + + public int getMeasuredRowHeight() { + return mMeasuredRowHeight; + } + + public void setItemIndex(int itemIndex, int listIndex) { + mItemIndices[itemIndex] = listIndex; + } + + public int getItemIndex(int itemIndex) { + return mItemIndices[itemIndex]; + } + + public View getView(int index) { + return mCells[index]; + } + } + + static class SingleRowViewHolder extends ItemGroupViewHolder { + private final ViewGroup mRow; + + SingleRowViewHolder(ViewGroup row, int cellCount, int viewType) { + super(cellCount, row, viewType); + + this.mRow = row; + } + + public ViewGroup getViewGroup() { + return mRow; + } + + public ViewGroup getRowByIndex(int index) { + return mRow; + } + + public ViewGroup getRow(int rowNumber) { + if (rowNumber == 0) return mRow; + return null; + } + + public ViewGroup addView(int index, View v) { + mRow.addView(v); + mCells[index] = v; + + return mRow; + } + + public void setViewVisibility(int i, int visibility) { + getView(i).setVisibility(visibility); + } + } + + static class DirectShareViewHolder extends ItemGroupViewHolder { + private final ViewGroup mParent; + private final List<ViewGroup> mRows; + private int mCellCountPerRow; + + private boolean mHideDirectShareExpansion = false; + private int mDirectShareMinHeight = 0; + private int mDirectShareCurrHeight = 0; + private int mDirectShareMaxHeight = 0; + + private final boolean[] mCellVisibility; + + private final Supplier<ChooserListAdapter> mListAdapterSupplier; + + DirectShareViewHolder(ViewGroup parent, List<ViewGroup> rows, int cellCountPerRow, + int viewType, Supplier<ChooserListAdapter> listAdapterSupplier) { + super(rows.size() * cellCountPerRow, parent, viewType); + + this.mParent = parent; + this.mRows = rows; + this.mCellCountPerRow = cellCountPerRow; + this.mCellVisibility = new boolean[rows.size() * cellCountPerRow]; + this.mListAdapterSupplier = listAdapterSupplier; + } + + public ViewGroup addView(int index, View v) { + ViewGroup row = getRowByIndex(index); + row.addView(v); + mCells[index] = v; + + return row; + } + + public ViewGroup getViewGroup() { + return mParent; + } + + public ViewGroup getRowByIndex(int index) { + return mRows.get(index / mCellCountPerRow); + } + + public ViewGroup getRow(int rowNumber) { + return mRows.get(rowNumber); + } + + public void measure() { + final int spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + getRow(0).measure(spec, spec); + getRow(1).measure(spec, spec); + + mDirectShareMinHeight = getRow(0).getMeasuredHeight(); + mDirectShareCurrHeight = mDirectShareCurrHeight > 0 + ? mDirectShareCurrHeight : mDirectShareMinHeight; + mDirectShareMaxHeight = 2 * mDirectShareMinHeight; + } + + public int getMeasuredRowHeight() { + return mDirectShareCurrHeight; + } + + public int getMinRowHeight() { + return mDirectShareMinHeight; + } + + public void setViewVisibility(int i, int visibility) { + final View v = getView(i); + if (visibility == View.VISIBLE) { + mCellVisibility[i] = true; + v.setVisibility(visibility); + v.setAlpha(1.0f); + } else if (visibility == View.INVISIBLE && mCellVisibility[i]) { + mCellVisibility[i] = false; + + ValueAnimator fadeAnim = ObjectAnimator.ofFloat(v, "alpha", 1.0f, 0f); + fadeAnim.setDuration(NO_DIRECT_SHARE_ANIM_IN_MILLIS); + fadeAnim.setInterpolator(new AccelerateInterpolator(1.0f)); + fadeAnim.addListener(new AnimatorListenerAdapter() { + public void onAnimationEnd(Animator animation) { + v.setVisibility(View.INVISIBLE); + } + }); + fadeAnim.start(); + } + } + + public void handleScroll(RecyclerView view, int y, int oldy, int maxTargetsPerRow) { + // only exit early if fully collapsed, otherwise onListRebuilt() with shifting + // targets can lock us into an expanded mode + boolean notExpanded = mDirectShareCurrHeight == mDirectShareMinHeight; + if (notExpanded) { + if (mHideDirectShareExpansion) { + return; + } + + // only expand if we have more than maxTargetsPerRow, and delay that decision + // until they start to scroll + ChooserListAdapter adapter = mListAdapterSupplier.get(); + int validTargets = adapter.getSelectableServiceTargetCount(); + if (validTargets <= maxTargetsPerRow) { + mHideDirectShareExpansion = true; + return; + } + } + + int yDiff = (int) ((oldy - y) * DIRECT_SHARE_EXPANSION_RATE); + + int prevHeight = mDirectShareCurrHeight; + int newHeight = Math.min(prevHeight + yDiff, mDirectShareMaxHeight); + newHeight = Math.max(newHeight, mDirectShareMinHeight); + yDiff = newHeight - prevHeight; + + updateDirectShareRowHeight(view, yDiff, newHeight); + } + + void expand(RecyclerView view) { + updateDirectShareRowHeight(view, mDirectShareMaxHeight - mDirectShareCurrHeight, + mDirectShareMaxHeight); + } + + void collapse(RecyclerView view) { + updateDirectShareRowHeight(view, mDirectShareMinHeight - mDirectShareCurrHeight, + mDirectShareMinHeight); + } + + private void updateDirectShareRowHeight(RecyclerView view, int yDiff, int newHeight) { + if (view == null || view.getChildCount() == 0 || yDiff == 0) { + return; + } + + // locate the item to expand, and offset the rows below that one + boolean foundExpansion = false; + for (int i = 0; i < view.getChildCount(); i++) { + View child = view.getChildAt(i); + + if (foundExpansion) { + child.offsetTopAndBottom(yDiff); + } else { + if (child.getTag() != null && child.getTag() instanceof DirectShareViewHolder) { + int widthSpec = MeasureSpec.makeMeasureSpec(child.getWidth(), + MeasureSpec.EXACTLY); + int heightSpec = MeasureSpec.makeMeasureSpec(newHeight, + MeasureSpec.EXACTLY); + child.measure(widthSpec, heightSpec); + child.getLayoutParams().height = child.getMeasuredHeight(); + child.layout(child.getLeft(), child.getTop(), child.getRight(), + child.getTop() + child.getMeasuredHeight()); + + foundExpansion = true; + } + } + } + + if (foundExpansion) { + mDirectShareCurrHeight = newHeight; + } + } + } + + static class ServiceResultInfo { + public final DisplayResolveInfo originalTarget; + public final List<ChooserTarget> resultTargets; + public final UserHandle userHandle; + + public ServiceResultInfo(DisplayResolveInfo ot, List<ChooserTarget> rt, + UserHandle userHandle) { + originalTarget = ot; + resultTargets = rt; + this.userHandle = userHandle; + } + } + + static class ChooserTargetRankingInfo { + public final List<AppTarget> scores; + public final UserHandle userHandle; + + ChooserTargetRankingInfo(List<AppTarget> chooserTargetScores, + UserHandle userHandle) { + this.scores = chooserTargetScores; + this.userHandle = userHandle; + } + } + + static class RefinementResultReceiver extends ResultReceiver { + private ChooserActivity mChooserActivity; + private TargetInfo mSelectedTarget; + + public RefinementResultReceiver(ChooserActivity host, TargetInfo target, + Handler handler) { + super(handler); + mChooserActivity = host; + mSelectedTarget = target; + } + + @Override + protected void onReceiveResult(int resultCode, Bundle resultData) { + if (mChooserActivity == null) { + Log.e(TAG, "Destroyed RefinementResultReceiver received a result"); + return; + } + if (resultData == null) { + Log.e(TAG, "RefinementResultReceiver received null resultData"); + return; + } + + switch (resultCode) { + case RESULT_CANCELED: + mChooserActivity.onRefinementCanceled(); + break; + case RESULT_OK: + Parcelable intentParcelable = resultData.getParcelable(Intent.EXTRA_INTENT); + if (intentParcelable instanceof Intent) { + mChooserActivity.onRefinementResult(mSelectedTarget, + (Intent) intentParcelable); + } else { + Log.e(TAG, "RefinementResultReceiver received RESULT_OK but no Intent" + + " in resultData with key Intent.EXTRA_INTENT"); + } + break; + default: + Log.w(TAG, "Unknown result code " + resultCode + + " sent to RefinementResultReceiver"); + break; + } + } + + public void destroy() { + mChooserActivity = null; + mSelectedTarget = null; + } + } + + /** + * Used internally to round image corners while obeying view padding. + */ + public static class RoundedRectImageView extends ImageView { + private int mRadius = 0; + private Path mPath = new Path(); + private Paint mOverlayPaint = new Paint(0); + private Paint mRoundRectPaint = new Paint(0); + private Paint mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private String mExtraImageCount = null; + + public RoundedRectImageView(Context context) { + super(context); + } + + public RoundedRectImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RoundedRectImageView(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public RoundedRectImageView(Context context, AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + mRadius = context.getResources().getDimensionPixelSize(R.dimen.chooser_corner_radius); + + mOverlayPaint.setColor(0x99000000); + mOverlayPaint.setStyle(Paint.Style.FILL); + + mRoundRectPaint.setColor(context.getResources().getColor(R.color.chooser_row_divider)); + mRoundRectPaint.setStyle(Paint.Style.STROKE); + mRoundRectPaint.setStrokeWidth(context.getResources() + .getDimensionPixelSize(R.dimen.chooser_preview_image_border)); + + mTextPaint.setColor(Color.WHITE); + mTextPaint.setTextSize(context.getResources() + .getDimensionPixelSize(R.dimen.chooser_preview_image_font_size)); + mTextPaint.setTextAlign(Paint.Align.CENTER); + } + + private void updatePath(int width, int height) { + mPath.reset(); + + int imageWidth = width - getPaddingRight() - getPaddingLeft(); + int imageHeight = height - getPaddingBottom() - getPaddingTop(); + mPath.addRoundRect(getPaddingLeft(), getPaddingTop(), imageWidth, imageHeight, mRadius, + mRadius, Path.Direction.CW); + } + + /** + * Sets the corner radius on all corners + * + * param radius 0 for no radius, > 0 for a visible corner radius + */ + public void setRadius(int radius) { + mRadius = radius; + updatePath(getWidth(), getHeight()); + } + + /** + * Display an overlay with extra image count on 3rd image + */ + public void setExtraImageCount(int count) { + if (count > 0) { + this.mExtraImageCount = "+" + count; + } else { + this.mExtraImageCount = null; + } + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + updatePath(width, height); + } + + @Override + protected void onDraw(Canvas canvas) { + if (mRadius != 0) { + canvas.clipPath(mPath); + } + + super.onDraw(canvas); + + int x = getPaddingLeft(); + int y = getPaddingRight(); + int width = getWidth() - getPaddingRight() - getPaddingLeft(); + int height = getHeight() - getPaddingBottom() - getPaddingTop(); + if (mExtraImageCount != null) { + canvas.drawRect(x, y, width, height, mOverlayPaint); + + int xPos = canvas.getWidth() / 2; + int yPos = (int) ((canvas.getHeight() / 2.0f) + - ((mTextPaint.descent() + mTextPaint.ascent()) / 2.0f)); + + canvas.drawText(mExtraImageCount, xPos, yPos, mTextPaint); + } + + canvas.drawRoundRect(x, y, width, height, mRadius, mRadius, mRoundRectPaint); + } + } + + /** + * A helper class to track app's readiness for the scene transition animation. + * The app is ready when both the image is laid out and the drawer offset is calculated. + */ + private class EnterTransitionAnimationDelegate implements View.OnLayoutChangeListener { + private boolean mPreviewReady = false; + private boolean mOffsetCalculated = false; + + void postponeTransition() { + postponeEnterTransition(); + } + + void markImagePreviewReady() { + if (!mPreviewReady) { + mPreviewReady = true; + maybeStartListenForLayout(); + } + } + + void markOffsetCalculated() { + if (!mOffsetCalculated) { + mOffsetCalculated = true; + maybeStartListenForLayout(); + } + } + + private void maybeStartListenForLayout() { + if (mPreviewReady && mOffsetCalculated && mResolverDrawerLayout != null) { + if (mResolverDrawerLayout.isInLayout()) { + startPostponedEnterTransition(); + } else { + mResolverDrawerLayout.addOnLayoutChangeListener(this); + mResolverDrawerLayout.requestLayout(); + } + } + } + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, + int oldTop, int oldRight, int oldBottom) { + v.removeOnLayoutChangeListener(this); + startPostponedEnterTransition(); + } + } + + /** + * Used in combination with the scene transition when launching the image editor + */ + private static class FinishAnimation extends AlphaAnimation implements + Animation.AnimationListener { + private Activity mActivity; + private View mRootView; + private final float mFromAlpha; + + FinishAnimation(Activity activity, 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() { + mRootView.setAlpha(mFromAlpha); + cleanup(); + super.cancel(); + } + + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + if (mActivity != null) { + mActivity.finish(); + cleanup(); + } + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + private void cleanup() { + mActivity = null; + mRootView = null; + } + } + + @Override + protected void maybeLogProfileChange() { + getChooserActivityLogger().logShareheetProfileChanged(); + } + + private boolean shouldNearbyShareBeFirstInRankedRow() { + return ActivityManager.isLowRamDeviceStatic() && mIsNearbyShareFirstTargetInRankedApp; + } + + private boolean shouldNearbyShareBeIncludedAsActionButton() { + return !shouldNearbyShareBeFirstInRankedRow(); } } diff --git a/java/src/com/android/intentresolver/ChooserActivityLogger.java b/java/src/com/android/intentresolver/ChooserActivityLogger.java new file mode 100644 index 00000000..1daae01a --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserActivityLogger.java @@ -0,0 +1,246 @@ +/* + * 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 android.content.Intent; +import android.provider.MediaStore; + +import com.android.internal.logging.InstanceId; +import com.android.internal.logging.UiEvent; +import com.android.internal.logging.UiEventLogger; +import com.android.internal.util.FrameworkStatsLog; + +/** + * Interface for writing Sharesheet atoms to statsd log. + * @hide + */ +public interface ChooserActivityLogger { + /** Logs a UiEventReported event for the system sharesheet completing initial start-up. */ + void logShareStarted(int eventId, String packageName, String mimeType, int appProvidedDirect, + int appProvidedApp, boolean isWorkprofile, int previewType, String intent); + + /** Logs a UiEventReported event for the system sharesheet when the user selects a target. */ + void logShareTargetSelected(int targetType, String packageName, int positionPicked, + boolean isPinned); + + /** Logs a UiEventReported event for the system sharesheet being triggered by the user. */ + default void logSharesheetTriggered() { + log(SharesheetStandardEvent.SHARESHEET_TRIGGERED, getInstanceId()); + } + + /** Logs a UiEventReported event for the system sharesheet completing loading app targets. */ + default void logSharesheetAppLoadComplete() { + log(SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE, getInstanceId()); + } + + /** + * Logs a UiEventReported event for the system sharesheet completing loading service targets. + */ + default void logSharesheetDirectLoadComplete() { + log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_COMPLETE, getInstanceId()); + } + + /** + * Logs a UiEventReported event for the system sharesheet timing out loading service targets. + */ + default void logSharesheetDirectLoadTimeout() { + log(SharesheetStandardEvent.SHARESHEET_DIRECT_LOAD_TIMEOUT, getInstanceId()); + } + + /** + * Logs a UiEventReported event for the system sharesheet switching + * between work and main profile. + */ + default void logShareheetProfileChanged() { + log(SharesheetStandardEvent.SHARESHEET_PROFILE_CHANGED, getInstanceId()); + } + + /** Logs a UiEventReported event for the system sharesheet getting expanded or collapsed. */ + default void logSharesheetExpansionChanged(boolean isCollapsed) { + log(isCollapsed ? SharesheetStandardEvent.SHARESHEET_COLLAPSED : + SharesheetStandardEvent.SHARESHEET_EXPANDED, getInstanceId()); + } + + /** + * Logs a UiEventReported event for the system sharesheet app share ranking timing out. + */ + default void logSharesheetAppShareRankingTimeout() { + log(SharesheetStandardEvent.SHARESHEET_APP_SHARE_RANKING_TIMEOUT, getInstanceId()); + } + + /** + * Logs a UiEventReported event for the system sharesheet when direct share row is empty. + */ + default void logSharesheetEmptyDirectShareRow() { + log(SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW, getInstanceId()); + } + + /** + * Logs a UiEventReported event for a given share activity + * @param event + * @param instanceId + */ + void log(UiEventLogger.UiEventEnum event, InstanceId instanceId); + + /** + * + * @return + */ + InstanceId getInstanceId(); + + /** + * The UiEvent enums that this class can log. + */ + enum SharesheetStartedEvent implements UiEventLogger.UiEventEnum { + @UiEvent(doc = "Basic system Sharesheet has started and is visible.") + SHARE_STARTED(228); + + private final int mId; + SharesheetStartedEvent(int id) { + mId = id; + } + @Override + public int getId() { + return mId; + } + } + + /** + * The UiEvent enums that this class can log. + */ + enum SharesheetTargetSelectedEvent implements UiEventLogger.UiEventEnum { + INVALID(0), + @UiEvent(doc = "User selected a service target.") + SHARESHEET_SERVICE_TARGET_SELECTED(232), + @UiEvent(doc = "User selected an app target.") + SHARESHEET_APP_TARGET_SELECTED(233), + @UiEvent(doc = "User selected a standard target.") + SHARESHEET_STANDARD_TARGET_SELECTED(234), + @UiEvent(doc = "User selected the copy target.") + SHARESHEET_COPY_TARGET_SELECTED(235), + @UiEvent(doc = "User selected the nearby target.") + SHARESHEET_NEARBY_TARGET_SELECTED(626), + @UiEvent(doc = "User selected the edit target.") + SHARESHEET_EDIT_TARGET_SELECTED(669); + + private final int mId; + SharesheetTargetSelectedEvent(int id) { + mId = id; + } + @Override public int getId() { + return mId; + } + + public static SharesheetTargetSelectedEvent fromTargetType(int targetType) { + switch(targetType) { + case ChooserActivity.SELECTION_TYPE_SERVICE: + return SHARESHEET_SERVICE_TARGET_SELECTED; + case ChooserActivity.SELECTION_TYPE_APP: + return SHARESHEET_APP_TARGET_SELECTED; + case ChooserActivity.SELECTION_TYPE_STANDARD: + return SHARESHEET_STANDARD_TARGET_SELECTED; + case ChooserActivity.SELECTION_TYPE_COPY: + return SHARESHEET_COPY_TARGET_SELECTED; + case ChooserActivity.SELECTION_TYPE_NEARBY: + return SHARESHEET_NEARBY_TARGET_SELECTED; + case ChooserActivity.SELECTION_TYPE_EDIT: + return SHARESHEET_EDIT_TARGET_SELECTED; + default: + return INVALID; + } + } + } + + /** + * The UiEvent enums that this class can log. + */ + enum SharesheetStandardEvent implements UiEventLogger.UiEventEnum { + INVALID(0), + @UiEvent(doc = "User clicked share.") + SHARESHEET_TRIGGERED(227), + @UiEvent(doc = "User changed from work to personal profile or vice versa.") + SHARESHEET_PROFILE_CHANGED(229), + @UiEvent(doc = "User expanded target list.") + SHARESHEET_EXPANDED(230), + @UiEvent(doc = "User collapsed target list.") + SHARESHEET_COLLAPSED(231), + @UiEvent(doc = "Sharesheet app targets is fully populated.") + SHARESHEET_APP_LOAD_COMPLETE(322), + @UiEvent(doc = "Sharesheet direct targets is fully populated.") + SHARESHEET_DIRECT_LOAD_COMPLETE(323), + @UiEvent(doc = "Sharesheet direct targets timed out.") + SHARESHEET_DIRECT_LOAD_TIMEOUT(324), + @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); + + private final int mId; + SharesheetStandardEvent(int id) { + mId = id; + } + @Override public int getId() { + return mId; + } + } + + /** + * Returns the enum used in sharesheet started atom to indicate what preview type was used. + */ + default int typeFromPreviewInt(int previewType) { + switch(previewType) { + case ChooserActivity.CONTENT_PREVIEW_IMAGE: + return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_IMAGE; + case ChooserActivity.CONTENT_PREVIEW_FILE: + return FrameworkStatsLog.SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_FILE; + case ChooserActivity.CONTENT_PREVIEW_TEXT: + default: + return FrameworkStatsLog + .SHARESHEET_STARTED__PREVIEW_TYPE__CONTENT_PREVIEW_TYPE_UNKNOWN; + } + } + + /** + * Returns the enum used in sharesheet started atom to indicate what intent triggers the + * ChooserActivity. + */ + default int typeFromIntentString(String intent) { + if (intent == null) { + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT; + } + switch (intent) { + case Intent.ACTION_VIEW: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_VIEW; + case Intent.ACTION_EDIT: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_EDIT; + case Intent.ACTION_SEND: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SEND; + case Intent.ACTION_SENDTO: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SENDTO; + case Intent.ACTION_SEND_MULTIPLE: + return FrameworkStatsLog + .SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_SEND_MULTIPLE; + case MediaStore.ACTION_IMAGE_CAPTURE: + return FrameworkStatsLog + .SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_IMAGE_CAPTURE; + case Intent.ACTION_MAIN: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_ACTION_MAIN; + default: + return FrameworkStatsLog.SHARESHEET_STARTED__INTENT_TYPE__INTENT_DEFAULT; + } + } +} diff --git a/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java b/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java new file mode 100644 index 00000000..08a345bc --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserActivityLoggerImpl.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import com.android.internal.logging.InstanceId; +import com.android.internal.logging.InstanceIdSequence; +import com.android.internal.logging.UiEventLogger; +import com.android.internal.logging.UiEventLoggerImpl; +import com.android.internal.util.FrameworkStatsLog; + +/** + * Standard implementation of ChooserActivityLogger interface. + * @hide + */ +public class ChooserActivityLoggerImpl implements ChooserActivityLogger { + private static final int SHARESHEET_INSTANCE_ID_MAX = (1 << 13); + + private UiEventLogger mUiEventLogger = new UiEventLoggerImpl(); + // A small per-notification ID, used for statsd logging. + private InstanceId mInstanceId; + private static InstanceIdSequence sInstanceIdSequence; + + @Override + public void logShareStarted(int eventId, String packageName, String mimeType, + int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, + String intent) { + FrameworkStatsLog.write(FrameworkStatsLog.SHARESHEET_STARTED, + /* event_id = 1 */ SharesheetStartedEvent.SHARE_STARTED.getId(), + /* package_name = 2 */ packageName, + /* instance_id = 3 */ getInstanceId().getId(), + /* mime_type = 4 */ mimeType, + /* num_app_provided_direct_targets = 5 */ appProvidedDirect, + /* num_app_provided_app_targets = 6 */ appProvidedApp, + /* is_workprofile = 7 */ isWorkprofile, + /* previewType = 8 */ typeFromPreviewInt(previewType), + /* intentType = 9 */ typeFromIntentString(intent)); + } + + @Override + public void logShareTargetSelected(int targetType, String packageName, int positionPicked, + boolean isPinned) { + FrameworkStatsLog.write(FrameworkStatsLog.RANKING_SELECTED, + /* event_id = 1 */ SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), + /* package_name = 2 */ packageName, + /* instance_id = 3 */ getInstanceId().getId(), + /* position_picked = 4 */ positionPicked, + /* is_pinned = 5 */ isPinned); + } + + @Override + public void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) { + mUiEventLogger.logWithInstanceId( + event, + 0, + null, + instanceId); + } + + @Override + public InstanceId getInstanceId() { + if (mInstanceId == null) { + if (sInstanceIdSequence == null) { + sInstanceIdSequence = new InstanceIdSequence(SHARESHEET_INSTANCE_ID_MAX); + } + mInstanceId = sInstanceIdSequence.newInstanceId(); + } + return mInstanceId; + } + +} diff --git a/java/src/com/android/intentresolver/ChooserFlags.java b/java/src/com/android/intentresolver/ChooserFlags.java new file mode 100644 index 00000000..67f9046f --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserFlags.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import android.app.prediction.AppPredictionManager; + +/** + * Common flags for {@link ChooserListAdapter} and {@link ChooserActivity}. + */ +public class ChooserFlags { + + /** + * Whether to use {@link AppPredictionManager} to query for direct share targets (as opposed to + * talking directly to {@link android.content.pm.ShortcutManager}. + */ + // TODO(b/123089490): Replace with system flag + static final boolean USE_PREDICTION_MANAGER_FOR_DIRECT_TARGETS = true; +} + diff --git a/java/src/com/android/intentresolver/ChooserGridLayoutManager.java b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java new file mode 100644 index 00000000..7c4b0c1f --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserGridLayoutManager.java @@ -0,0 +1,81 @@ +/* + * 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 android.content.Context; +import android.util.AttributeSet; + +import com.android.internal.widget.GridLayoutManager; +import com.android.internal.widget.RecyclerView; + +/** + * For a11y and per {@link RecyclerView#onInitializeAccessibilityNodeInfo}, override + * methods to ensure proper row counts. + */ +public class ChooserGridLayoutManager extends GridLayoutManager { + + private boolean mVerticalScrollEnabled = true; + + /** + * Constructor used when layout manager is set in XML by RecyclerView attribute + * "layoutManager". If spanCount is not specified in the XML, it defaults to a + * single column. + * + */ + public ChooserGridLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + /** + * Creates a vertical GridLayoutManager + * + * @param context Current context, will be used to access resources. + * @param spanCount The number of columns in the grid + */ + public ChooserGridLayoutManager(Context context, int spanCount) { + super(context, spanCount); + } + + /** + * @param context Current context, will be used to access resources. + * @param spanCount The number of columns or rows in the grid + * @param orientation Layout orientation. Should be {@link #HORIZONTAL} or {@link + * #VERTICAL}. + * @param reverseLayout When set to true, layouts from end to start. + */ + public ChooserGridLayoutManager(Context context, int spanCount, int orientation, + boolean reverseLayout) { + super(context, spanCount, orientation, reverseLayout); + } + + @Override + public int getRowCountForAccessibility(RecyclerView.Recycler recycler, + RecyclerView.State state) { + // Do not count the footer view in the official count + return super.getRowCountForAccessibility(recycler, state) - 1; + } + + void setVerticalScrollEnabled(boolean verticalScrollEnabled) { + mVerticalScrollEnabled = verticalScrollEnabled; + } + + @Override + public boolean canScrollVertically() { + return mVerticalScrollEnabled && super.canScrollVertically(); + } +} diff --git a/java/src/com/android/intentresolver/ChooserListAdapter.java b/java/src/com/android/intentresolver/ChooserListAdapter.java new file mode 100644 index 00000000..6ddaffd7 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserListAdapter.java @@ -0,0 +1,845 @@ +/* + * 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 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.app.ActivityManager; +import android.app.prediction.AppPredictor; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.LabeledIntent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ShortcutInfo; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.os.Trace; +import android.os.UserHandle; +import android.os.UserManager; +import android.provider.DeviceConfig; +import android.service.chooser.ChooserTarget; +import android.text.Layout; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; +import com.android.intentresolver.chooser.ChooserTargetInfo; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.MultiDisplayResolveInfo; +import com.android.intentresolver.chooser.SelectableTargetInfo; +import com.android.intentresolver.chooser.TargetInfo; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ChooserListAdapter extends ResolverListAdapter { + private static final String TAG = "ChooserListAdapter"; + private static final boolean DEBUG = false; + + private boolean mEnableStackedApps = true; + + public static final int NO_POSITION = -1; + public static final int TARGET_BAD = -1; + public static final int TARGET_CALLER = 0; + public static final int TARGET_SERVICE = 1; + public static final int TARGET_STANDARD = 2; + public static final int TARGET_STANDARD_AZ = 3; + + private static final int MAX_SUGGESTED_APP_TARGETS = 4; + private static final int MAX_CHOOSER_TARGETS_PER_APP = 2; + + /** {@link #getBaseScore} */ + public static final float CALLER_TARGET_SCORE_BOOST = 900.f; + /** {@link #getBaseScore} */ + public static final float SHORTCUT_TARGET_SCORE_BOOST = 90.f; + private static final float PINNED_SHORTCUT_TARGET_SCORE_BOOST = 1000.f; + + private final int mMaxShortcutTargetsPerApp; + private final ChooserListCommunicator mChooserListCommunicator; + private final SelectableTargetInfo.SelectableTargetInfoCommunicator + mSelectableTargetInfoCommunicator; + private final ChooserActivityLogger mChooserActivityLogger; + + private int mNumShortcutResults = 0; + private final Map<TargetInfo, AsyncTask> mIconLoaders = new HashMap<>(); + private boolean mApplySharingAppLimits; + + // Reserve spots for incoming direct share targets by adding placeholders + private ChooserTargetInfo + mPlaceHolderTargetInfo = new ChooserActivity.PlaceHolderTargetInfo(); + private final List<ChooserTargetInfo> mServiceTargets = new ArrayList<>(); + private final List<DisplayResolveInfo> mCallerTargets = new ArrayList<>(); + + private final ChooserActivity.BaseChooserTargetComparator mBaseTargetComparator = + new ChooserActivity.BaseChooserTargetComparator(); + private boolean mListViewDataChanged = false; + + // Sorted list of DisplayResolveInfos for the alphabetical app section. + private List<DisplayResolveInfo> mSortedList = new ArrayList<>(); + private AppPredictor mAppPredictor; + private AppPredictor.Callback mAppPredictorCallback; + + private LoadDirectShareIconTaskProvider mTestLoadDirectShareTaskProvider; + + // For pinned direct share labels, if the text spans multiple lines, the TextView will consume + // the full width, even if the characters actually take up less than that. Measure the actual + // line widths and constrain the View's width based upon that so that the pin doesn't end up + // very far from the text. + private final View.OnLayoutChangeListener mPinTextSpacingListener = + new View.OnLayoutChangeListener() { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + TextView textView = (TextView) v; + Layout layout = textView.getLayout(); + if (layout != null) { + int textWidth = 0; + for (int line = 0; line < layout.getLineCount(); line++) { + textWidth = Math.max((int) Math.ceil(layout.getLineMax(line)), + textWidth); + } + int desiredWidth = textWidth + textView.getPaddingLeft() + + textView.getPaddingRight(); + if (textView.getWidth() > desiredWidth) { + ViewGroup.LayoutParams params = textView.getLayoutParams(); + params.width = desiredWidth; + textView.setLayoutParams(params); + // Need to wait until layout pass is over before requesting layout. + textView.post(() -> textView.requestLayout()); + } + textView.removeOnLayoutChangeListener(this); + } + } + }; + + public ChooserListAdapter(Context context, List<Intent> payloadIntents, + Intent[] initialIntents, List<ResolveInfo> rList, + boolean filterLastUsed, ResolverListController resolverListController, + ChooserListCommunicator chooserListCommunicator, + SelectableTargetInfo.SelectableTargetInfoCommunicator selectableTargetInfoCommunicator, + PackageManager packageManager, + ChooserActivityLogger chooserActivityLogger) { + // Don't send the initial intents through the shared ResolverActivity path, + // we want to separate them into a different section. + super(context, payloadIntents, null, rList, filterLastUsed, + resolverListController, chooserListCommunicator, false); + + mMaxShortcutTargetsPerApp = + context.getResources().getInteger(R.integer.config_maxShortcutTargetsPerApp); + mChooserListCommunicator = chooserListCommunicator; + createPlaceHolders(); + mSelectableTargetInfoCommunicator = selectableTargetInfoCommunicator; + mChooserActivityLogger = chooserActivityLogger; + + if (initialIntents != null) { + for (int i = 0; i < initialIntents.length; i++) { + final Intent ii = initialIntents[i]; + if (ii == null) { + continue; + } + + // We reimplement Intent#resolveActivityInfo here because if we have an + // implicit intent, we want the ResolveInfo returned by PackageManager + // instead of one we reconstruct ourselves. The ResolveInfo returned might + // have extra metadata and resolvePackageName set and we want to respect that. + ResolveInfo ri = null; + ActivityInfo ai = null; + final ComponentName cn = ii.getComponent(); + if (cn != null) { + try { + ai = packageManager.getActivityInfo(ii.getComponent(), 0); + ri = new ResolveInfo(); + ri.activityInfo = ai; + } catch (PackageManager.NameNotFoundException ignored) { + // ai will == null below + } + } + if (ai == null) { + // Because of AIDL bug, resolveActivity can't accept subclasses of Intent. + final Intent rii = (ii.getClass() == Intent.class) ? ii : new Intent(ii); + ri = packageManager.resolveActivity(rii, PackageManager.MATCH_DEFAULT_ONLY); + ai = ri != null ? ri.activityInfo : null; + } + if (ai == null) { + Log.w(TAG, "No activity found for " + ii); + continue; + } + UserManager userManager = + (UserManager) context.getSystemService(Context.USER_SERVICE); + if (ii instanceof LabeledIntent) { + LabeledIntent li = (LabeledIntent) ii; + ri.resolvePackageName = li.getSourcePackage(); + ri.labelRes = li.getLabelResource(); + ri.nonLocalizedLabel = li.getNonLocalizedLabel(); + ri.icon = li.getIconResource(); + ri.iconResourceId = ri.icon; + } + if (userManager.isManagedProfile()) { + ri.noResourceId = true; + ri.icon = 0; + } + mCallerTargets.add(new DisplayResolveInfo(ii, ri, ii, makePresentationGetter(ri))); + if (mCallerTargets.size() == MAX_SUGGESTED_APP_TARGETS) break; + } + } + mApplySharingAppLimits = DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + true); + } + + AppPredictor getAppPredictor() { + return mAppPredictor; + } + + @Override + public void handlePackagesChanged() { + if (DEBUG) { + Log.d(TAG, "clearing queryTargets on package change"); + } + createPlaceHolders(); + mChooserListCommunicator.onHandlePackagesChanged(this); + + } + + @Override + public void notifyDataSetChanged() { + if (!mListViewDataChanged) { + mChooserListCommunicator.sendListViewUpdateMessage(getUserHandle()); + mListViewDataChanged = true; + } + } + + void refreshListView() { + if (mListViewDataChanged) { + super.notifyDataSetChanged(); + } + mListViewDataChanged = false; + } + + private void createPlaceHolders() { + mNumShortcutResults = 0; + mServiceTargets.clear(); + for (int i = 0; i < mChooserListCommunicator.getMaxRankedTargets(); i++) { + mServiceTargets.add(mPlaceHolderTargetInfo); + } + } + + @Override + View onCreateView(ViewGroup parent) { + return mInflater.inflate( + R.layout.resolve_grid_item, parent, false); + } + + @Override + protected void onBindView(View view, TargetInfo info, int position) { + final ViewHolder holder = (ViewHolder) view.getTag(); + + if (info == null) { + holder.icon.setImageDrawable( + mContext.getDrawable(R.drawable.resolver_icon_placeholder)); + return; + } + + if (info instanceof DisplayResolveInfo) { + DisplayResolveInfo dri = (DisplayResolveInfo) info; + holder.bindLabel(dri.getDisplayLabel(), dri.getExtendedInfo(), alwaysShowSubLabel()); + startDisplayResolveInfoIconLoading(holder, dri); + } else { + holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel()); + + if (info instanceof SelectableTargetInfo) { + SelectableTargetInfo selectableInfo = (SelectableTargetInfo) info; + // direct share targets should append the application name for a better readout + DisplayResolveInfo rInfo = selectableInfo.getDisplayResolveInfo(); + CharSequence appName = rInfo != null ? rInfo.getDisplayLabel() : ""; + CharSequence extendedInfo = selectableInfo.getExtendedInfo(); + String contentDescription = String.join(" ", selectableInfo.getDisplayLabel(), + extendedInfo != null ? extendedInfo : "", appName); + holder.updateContentDescription(contentDescription); + startSelectableTargetInfoIconLoading(holder, selectableInfo); + } else { + holder.bindIcon(info); + } + } + + // If target is loading, show a special placeholder shape in the label, make unclickable + if (info instanceof ChooserActivity.PlaceHolderTargetInfo) { + 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); + } + + // Always remove the spacing listener, attach as needed to direct share targets below. + holder.text.removeOnLayoutChangeListener(mPinTextSpacingListener); + + if (info instanceof MultiDisplayResolveInfo) { + // 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); + } 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); + holder.text.addOnLayoutChangeListener(mPinTextSpacingListener); + } else { + holder.text.setBackground(null); + holder.text.setPaddingRelative(0, 0, 0, 0); + } + } + + private void startDisplayResolveInfoIconLoading(ViewHolder holder, DisplayResolveInfo info) { + LoadIconTask task = (LoadIconTask) mIconLoaders.get(info); + if (task == null) { + task = new LoadIconTask(info, holder); + mIconLoaders.put(info, task); + task.execute(); + } else { + // The holder was potentially changed as the underlying items were + // reshuffled, so reset the target holder + task.setViewHolder(holder); + } + } + + private void startSelectableTargetInfoIconLoading( + ViewHolder holder, SelectableTargetInfo info) { + LoadDirectShareIconTask task = (LoadDirectShareIconTask) mIconLoaders.get(info); + if (task == null) { + task = mTestLoadDirectShareTaskProvider == null + ? new LoadDirectShareIconTask(info) + : mTestLoadDirectShareTaskProvider.get(); + mIconLoaders.put(info, task); + task.loadIcon(); + } + task.setViewHolder(holder); + } + + void updateAlphabeticalList() { + new AsyncTask<Void, Void, List<DisplayResolveInfo>>() { + @Override + protected List<DisplayResolveInfo> doInBackground(Void... voids) { + List<DisplayResolveInfo> allTargets = new ArrayList<>(); + allTargets.addAll(mDisplayList); + allTargets.addAll(mCallerTargets); + if (!mEnableStackedApps) { + return allTargets; + } + // Consolidate multiple targets from same app. + Map<String, DisplayResolveInfo> consolidated = new HashMap<>(); + for (DisplayResolveInfo info : allTargets) { + String resolvedTarget = info.getResolvedComponentName().getPackageName() + + '#' + info.getDisplayLabel(); + DisplayResolveInfo multiDri = consolidated.get(resolvedTarget); + if (multiDri == null) { + consolidated.put(resolvedTarget, info); + } else if (multiDri instanceof MultiDisplayResolveInfo) { + ((MultiDisplayResolveInfo) multiDri).addTarget(info); + } else { + // create consolidated target from the single DisplayResolveInfo + MultiDisplayResolveInfo multiDisplayResolveInfo = + new MultiDisplayResolveInfo(resolvedTarget, multiDri); + multiDisplayResolveInfo.addTarget(info); + consolidated.put(resolvedTarget, multiDisplayResolveInfo); + } + } + List<DisplayResolveInfo> groupedTargets = new ArrayList<>(); + groupedTargets.addAll(consolidated.values()); + Collections.sort(groupedTargets, new ChooserActivity.AzInfoComparator(mContext)); + return groupedTargets; + } + @Override + protected void onPostExecute(List<DisplayResolveInfo> newList) { + mSortedList = newList; + notifyDataSetChanged(); + } + }.execute(); + } + + @Override + public int getCount() { + return getRankedTargetCount() + getAlphaTargetCount() + + getSelectableServiceTargetCount() + getCallerTargetCount(); + } + + @Override + public int getUnfilteredCount() { + int appTargets = super.getUnfilteredCount(); + if (appTargets > mChooserListCommunicator.getMaxRankedTargets()) { + appTargets = appTargets + mChooserListCommunicator.getMaxRankedTargets(); + } + return appTargets + getSelectableServiceTargetCount() + getCallerTargetCount(); + } + + + public int getCallerTargetCount() { + return mCallerTargets.size(); + } + + /** + * Filter out placeholders and non-selectable service targets + */ + public int getSelectableServiceTargetCount() { + int count = 0; + for (ChooserTargetInfo info : mServiceTargets) { + if (info instanceof SelectableTargetInfo) { + count++; + } + } + return count; + } + + public int getServiceTargetCount() { + if (mChooserListCommunicator.isSendAction(mChooserListCommunicator.getTargetIntent()) + && !ActivityManager.isLowRamDeviceStatic()) { + return Math.min(mServiceTargets.size(), mChooserListCommunicator.getMaxRankedTargets()); + } + + return 0; + } + + int getAlphaTargetCount() { + int groupedCount = mSortedList.size(); + int ungroupedCount = mCallerTargets.size() + mDisplayList.size(); + return ungroupedCount > mChooserListCommunicator.getMaxRankedTargets() ? groupedCount : 0; + } + + /** + * Fetch ranked app target count + */ + public int getRankedTargetCount() { + int spacesAvailable = + mChooserListCommunicator.getMaxRankedTargets() - getCallerTargetCount(); + return Math.min(spacesAvailable, super.getCount()); + } + + public int getPositionTargetType(int position) { + int offset = 0; + + final int serviceTargetCount = getServiceTargetCount(); + if (position < serviceTargetCount) { + return TARGET_SERVICE; + } + offset += serviceTargetCount; + + final int callerTargetCount = getCallerTargetCount(); + if (position - offset < callerTargetCount) { + return TARGET_CALLER; + } + offset += callerTargetCount; + + final int rankedTargetCount = getRankedTargetCount(); + if (position - offset < rankedTargetCount) { + return TARGET_STANDARD; + } + offset += rankedTargetCount; + + final int standardTargetCount = getAlphaTargetCount(); + if (position - offset < standardTargetCount) { + return TARGET_STANDARD_AZ; + } + + return TARGET_BAD; + } + + @Override + public TargetInfo getItem(int position) { + return targetInfoForPosition(position, true); + } + + + /** + * Find target info for a given position. + * Since ChooserActivity displays several sections of content, determine which + * section provides this item. + */ + @Override + public TargetInfo targetInfoForPosition(int position, boolean filtered) { + if (position == NO_POSITION) { + return null; + } + + int offset = 0; + + // Direct share targets + final int serviceTargetCount = filtered ? getServiceTargetCount() : + getSelectableServiceTargetCount(); + if (position < serviceTargetCount) { + return mServiceTargets.get(position); + } + offset += serviceTargetCount; + + // Targets provided by calling app + final int callerTargetCount = getCallerTargetCount(); + if (position - offset < callerTargetCount) { + return mCallerTargets.get(position - offset); + } + offset += callerTargetCount; + + // Ranked standard app targets + final int rankedTargetCount = getRankedTargetCount(); + if (position - offset < rankedTargetCount) { + return filtered ? super.getItem(position - offset) + : getDisplayResolveInfo(position - offset); + } + offset += rankedTargetCount; + + // Alphabetical complete app target list. + if (position - offset < getAlphaTargetCount() && !mSortedList.isEmpty()) { + return mSortedList.get(position - offset); + } + + return null; + } + + // Check whether {@code dri} should be added into mDisplayList. + @Override + protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { + // Checks if this info is already listed in callerTargets. + for (TargetInfo existingInfo : mCallerTargets) { + if (mResolverListCommunicator + .resolveInfoMatch(dri.getResolveInfo(), existingInfo.getResolveInfo())) { + return false; + } + } + return super.shouldAddResolveInfo(dri); + } + + /** + * Fetch surfaced direct share target info + */ + public List<ChooserTargetInfo> getSurfacedTargetInfo() { + int maxSurfacedTargets = mChooserListCommunicator.getMaxRankedTargets(); + return mServiceTargets.subList(0, + Math.min(maxSurfacedTargets, getSelectableServiceTargetCount())); + } + + + /** + * Evaluate targets for inclusion in the direct share area. May not be included + * if score is too low. + */ + public void addServiceResults(DisplayResolveInfo origTarget, List<ChooserTarget> targets, + @ChooserActivity.ShareTargetType int targetType, + Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos) { + if (DEBUG) { + Log.d(TAG, "addServiceResults " + origTarget.getResolvedComponentName() + ", " + + targets.size() + + " targets"); + } + if (targets.size() == 0) { + return; + } + final float baseScore = getBaseScore(origTarget, targetType); + Collections.sort(targets, mBaseTargetComparator); + final boolean isShortcutResult = + (targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER + || targetType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); + final int maxTargets = isShortcutResult ? mMaxShortcutTargetsPerApp + : MAX_CHOOSER_TARGETS_PER_APP; + final int targetsLimit = mApplySharingAppLimits ? Math.min(targets.size(), maxTargets) + : targets.size(); + float lastScore = 0; + boolean shouldNotify = false; + for (int i = 0, count = targetsLimit; i < count; i++) { + final ChooserTarget target = targets.get(i); + float targetScore = target.getScore(); + if (mApplySharingAppLimits) { + targetScore *= baseScore; + if (i > 0 && targetScore >= lastScore) { + // Apply a decay so that the top app can't crowd out everything else. + // This incents ChooserTargetServices to define what's truly better. + targetScore = lastScore * 0.95f; + } + } + ShortcutInfo shortcutInfo = isShortcutResult ? directShareToShortcutInfos.get(target) + : null; + if ((shortcutInfo != null) && shortcutInfo.isPinned()) { + targetScore += PINNED_SHORTCUT_TARGET_SCORE_BOOST; + } + UserHandle userHandle = getUserHandle(); + Context contextAsUser = mContext.createContextAsUser(userHandle, 0 /* flags */); + boolean isInserted = insertServiceTarget(new SelectableTargetInfo(contextAsUser, + origTarget, target, targetScore, mSelectableTargetInfoCommunicator, + shortcutInfo)); + + if (isInserted && isShortcutResult) { + mNumShortcutResults++; + } + + shouldNotify |= isInserted; + + if (DEBUG) { + Log.d(TAG, " => " + target.toString() + " score=" + targetScore + + " base=" + target.getScore() + + " lastScore=" + lastScore + + " baseScore=" + baseScore + + " applyAppLimit=" + mApplySharingAppLimits); + } + + lastScore = targetScore; + } + + if (shouldNotify) { + notifyDataSetChanged(); + } + } + + /** + * The return number have to exceed a minimum limit to make direct share area expandable. When + * append direct share targets is enabled, return count of all available targets parking in the + * memory; otherwise, it is shortcuts count which will help reduce the amount of visible + * shuffling due to older-style direct share targets. + */ + int getNumServiceTargetsForExpand() { + return mNumShortcutResults; + } + + /** + * Use the scoring system along with artificial boosts to create up to 4 distinct buckets: + * <ol> + * <li>App-supplied targets + * <li>Shortcuts ranked via App Prediction Manager + * <li>Shortcuts ranked via legacy heuristics + * <li>Legacy direct share targets + * </ol> + */ + public float getBaseScore( + DisplayResolveInfo target, + @ChooserActivity.ShareTargetType int targetType) { + if (target == null) { + return CALLER_TARGET_SCORE_BOOST; + } + float score = super.getScore(target); + if (targetType == TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER + || targetType == TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE) { + return score * SHORTCUT_TARGET_SCORE_BOOST; + } + return score; + } + + /** + * Calling this marks service target loading complete, and will attempt to no longer + * update the direct share area. + */ + public void completeServiceTargetLoading() { + mServiceTargets.removeIf(o -> o instanceof ChooserActivity.PlaceHolderTargetInfo); + if (mServiceTargets.isEmpty()) { + mServiceTargets.add(new ChooserActivity.EmptyTargetInfo()); + mChooserActivityLogger.logSharesheetEmptyDirectShareRow(); + } + notifyDataSetChanged(); + } + + private boolean insertServiceTarget(ChooserTargetInfo chooserTargetInfo) { + // Avoid inserting any potentially late results + if (mServiceTargets.size() == 1 + && mServiceTargets.get(0) instanceof ChooserActivity.EmptyTargetInfo) { + return false; + } + + // Check for duplicates and abort if found + for (ChooserTargetInfo otherTargetInfo : mServiceTargets) { + if (chooserTargetInfo.isSimilar(otherTargetInfo)) { + return false; + } + } + + int currentSize = mServiceTargets.size(); + final float newScore = chooserTargetInfo.getModifiedScore(); + for (int i = 0; i < Math.min(currentSize, mChooserListCommunicator.getMaxRankedTargets()); + i++) { + final ChooserTargetInfo serviceTarget = mServiceTargets.get(i); + if (serviceTarget == null) { + mServiceTargets.set(i, chooserTargetInfo); + return true; + } else if (newScore > serviceTarget.getModifiedScore()) { + mServiceTargets.add(i, chooserTargetInfo); + return true; + } + } + + if (currentSize < mChooserListCommunicator.getMaxRankedTargets()) { + mServiceTargets.add(chooserTargetInfo); + return true; + } + + return false; + } + + public ChooserTarget getChooserTargetForValue(int value) { + return mServiceTargets.get(value).getChooserTarget(); + } + + protected boolean alwaysShowSubLabel() { + // Always show a subLabel for visual consistency across list items. Show an empty + // subLabel if the subLabel is the same as the label + return true; + } + + /** + * Rather than fully sorting the input list, this sorting task will put the top k elements + * 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], + mChooserListCommunicator.getMaxRankedTargets()); + Trace.endSection(); + return params[0]; + } + @Override + protected void onPostExecute(List<ResolvedComponentInfo> sortedComponents) { + processSortedList(sortedComponents, doPostProcessing); + if (doPostProcessing) { + mChooserListCommunicator.updateProfileViewButton(); + notifyDataSetChanged(); + } + } + }; + } + + public void setAppPredictor(AppPredictor appPredictor) { + mAppPredictor = appPredictor; + } + + public void setAppPredictorCallback(AppPredictor.Callback appPredictorCallback) { + mAppPredictorCallback = appPredictorCallback; + } + + public void destroyAppPredictor() { + if (getAppPredictor() != null) { + getAppPredictor().unregisterPredictionUpdates(mAppPredictorCallback); + getAppPredictor().destroy(); + setAppPredictor(null); + } + } + + /** + * An alias for onBindView to use with unit tests. + */ + @VisibleForTesting + public void testViewBind(View view, TargetInfo info, int position) { + onBindView(view, info, position); + } + + @VisibleForTesting + public void setTestLoadDirectShareTaskProvider(LoadDirectShareIconTaskProvider provider) { + mTestLoadDirectShareTaskProvider = provider; + } + + /** + * Necessary methods to communicate between {@link ChooserListAdapter} + * and {@link ChooserActivity}. + */ + @VisibleForTesting + public interface ChooserListCommunicator extends ResolverListCommunicator { + + int getMaxRankedTargets(); + + void sendListViewUpdateMessage(UserHandle userHandle); + + boolean isSendAction(Intent targetIntent); + } + + /** + * Loads direct share targets icons. + */ + @VisibleForTesting + public class LoadDirectShareIconTask extends AsyncTask<Void, Void, Void> { + private final SelectableTargetInfo mTargetInfo; + private ViewHolder mViewHolder; + + private LoadDirectShareIconTask(SelectableTargetInfo targetInfo) { + mTargetInfo = targetInfo; + } + + @Override + protected Void doInBackground(Void... voids) { + mTargetInfo.loadIcon(); + return null; + } + + @Override + protected void onPostExecute(Void arg) { + if (mViewHolder != null) { + mViewHolder.bindIcon(mTargetInfo); + notifyDataSetChanged(); + } + } + + /** + * Specifies a view holder that will be updated when the task is completed. + */ + public void setViewHolder(ViewHolder viewHolder) { + mViewHolder = viewHolder; + mViewHolder.bindIcon(mTargetInfo); + notifyDataSetChanged(); + } + + /** + * An alias for execute to use with unit tests. + */ + public void loadIcon() { + execute(); + } + } + + /** + * An interface for the unit tests to override icon loading task creation + */ + @VisibleForTesting + public interface LoadDirectShareIconTaskProvider { + /** + * Provides an instance of the task. + * @return + */ + LoadDirectShareIconTask get(); + } +} diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java new file mode 100644 index 00000000..da78fc81 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -0,0 +1,321 @@ +/* + * 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 static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_SHARE_WITH_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; + +import android.annotation.Nullable; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.widget.GridLayoutManager; +import com.android.internal.widget.PagerAdapter; +import com.android.internal.widget.RecyclerView; + +/** + * A {@link PagerAdapter} which describes the work and personal profile share sheet screens. + */ +@VisibleForTesting +public class ChooserMultiProfilePagerAdapter extends AbstractMultiProfilePagerAdapter { + private static final int SINGLE_CELL_SPAN_SIZE = 1; + + private final ChooserProfileDescriptor[] mItems; + private final boolean mIsSendAction; + private int mBottomOffset; + private int mMaxTargetsPerRow; + + ChooserMultiProfilePagerAdapter(Context context, + ChooserActivity.ChooserGridAdapter adapter, + UserHandle personalProfileUserHandle, + UserHandle workProfileUserHandle, + boolean isSendAction, int maxTargetsPerRow) { + super(context, /* currentPage */ 0, personalProfileUserHandle, workProfileUserHandle); + mItems = new ChooserProfileDescriptor[] { + createProfileDescriptor(adapter) + }; + mIsSendAction = isSendAction; + mMaxTargetsPerRow = maxTargetsPerRow; + } + + ChooserMultiProfilePagerAdapter(Context context, + ChooserActivity.ChooserGridAdapter personalAdapter, + ChooserActivity.ChooserGridAdapter workAdapter, + @Profile int defaultProfile, + UserHandle personalProfileUserHandle, + UserHandle workProfileUserHandle, + boolean isSendAction, int maxTargetsPerRow) { + super(context, /* currentPage */ defaultProfile, personalProfileUserHandle, + workProfileUserHandle); + mItems = new ChooserProfileDescriptor[] { + createProfileDescriptor(personalAdapter), + createProfileDescriptor(workAdapter) + }; + mIsSendAction = isSendAction; + mMaxTargetsPerRow = maxTargetsPerRow; + } + + private ChooserProfileDescriptor createProfileDescriptor( + ChooserActivity.ChooserGridAdapter adapter) { + final LayoutInflater inflater = LayoutInflater.from(getContext()); + final ViewGroup rootView = + (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); + ChooserProfileDescriptor profileDescriptor = + new ChooserProfileDescriptor(rootView, adapter); + profileDescriptor.recyclerView.setAccessibilityDelegateCompat( + new ChooserRecyclerViewAccessibilityDelegate(profileDescriptor.recyclerView)); + return profileDescriptor; + } + + public void setMaxTargetsPerRow(int maxTargetsPerRow) { + mMaxTargetsPerRow = maxTargetsPerRow; + } + + RecyclerView getListViewForIndex(int index) { + return getItem(index).recyclerView; + } + + @Override + ChooserProfileDescriptor getItem(int pageIndex) { + return mItems[pageIndex]; + } + + @Override + int getItemCount() { + return mItems.length; + } + + @Override + @VisibleForTesting + public ChooserActivity.ChooserGridAdapter getAdapterForIndex(int pageIndex) { + return mItems[pageIndex].chooserGridAdapter; + } + + @Override + @Nullable + ChooserListAdapter getListAdapterForUserHandle(UserHandle userHandle) { + if (getActiveListAdapter().getUserHandle().equals(userHandle)) { + return getActiveListAdapter(); + } else if (getInactiveListAdapter() != null + && getInactiveListAdapter().getUserHandle().equals(userHandle)) { + return getInactiveListAdapter(); + } + return null; + } + + @Override + void setupListAdapter(int pageIndex) { + final RecyclerView recyclerView = getItem(pageIndex).recyclerView; + ChooserActivity.ChooserGridAdapter chooserGridAdapter = + getItem(pageIndex).chooserGridAdapter; + GridLayoutManager glm = (GridLayoutManager) recyclerView.getLayoutManager(); + glm.setSpanCount(mMaxTargetsPerRow); + glm.setSpanSizeLookup( + new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + return chooserGridAdapter.shouldCellSpan(position) + ? SINGLE_CELL_SPAN_SIZE + : glm.getSpanCount(); + } + }); + } + + @Override + @VisibleForTesting + public ChooserListAdapter getActiveListAdapter() { + return getAdapterForIndex(getCurrentPage()).getListAdapter(); + } + + @Override + @VisibleForTesting + public ChooserListAdapter getInactiveListAdapter() { + if (getCount() == 1) { + return null; + } + return getAdapterForIndex(1 - getCurrentPage()).getListAdapter(); + } + + @Override + public ResolverListAdapter getPersonalListAdapter() { + return getAdapterForIndex(PROFILE_PERSONAL).getListAdapter(); + } + + @Override + @Nullable + public ResolverListAdapter getWorkListAdapter() { + return getAdapterForIndex(PROFILE_WORK).getListAdapter(); + } + + @Override + ChooserActivity.ChooserGridAdapter getCurrentRootAdapter() { + return getAdapterForIndex(getCurrentPage()); + } + + @Override + RecyclerView getActiveAdapterView() { + return getListViewForIndex(getCurrentPage()); + } + + @Override + @Nullable + RecyclerView getInactiveAdapterView() { + if (getCount() == 1) { + return null; + } + return getListViewForIndex(1 - getCurrentPage()); + } + + @Override + String getMetricsCategory() { + return ResolverActivity.METRICS_CATEGORY_CHOOSER; + } + + @Override + protected void showWorkProfileOffEmptyState(ResolverListAdapter activeListAdapter, + View.OnClickListener listener) { + showEmptyState(activeListAdapter, + getWorkAppPausedTitle(), + /* subtitle = */ null, + listener); + } + + @Override + protected void showNoPersonalToWorkIntentsEmptyState(ResolverListAdapter activeListAdapter) { + if (mIsSendAction) { + showEmptyState(activeListAdapter, + getCrossProfileBlockedTitle(), + getCantShareWithWorkMessage()); + } else { + showEmptyState(activeListAdapter, + getCrossProfileBlockedTitle(), + getCantAccessWorkMessage()); + } + } + + @Override + protected void showNoWorkToPersonalIntentsEmptyState(ResolverListAdapter activeListAdapter) { + if (mIsSendAction) { + showEmptyState(activeListAdapter, + getCrossProfileBlockedTitle(), + getCantShareWithPersonalMessage()); + } else { + showEmptyState(activeListAdapter, + getCrossProfileBlockedTitle(), + getCantAccessPersonalMessage()); + } + } + + @Override + protected void showNoPersonalAppsAvailableEmptyState(ResolverListAdapter listAdapter) { + showEmptyState(listAdapter, getNoPersonalAppsAvailableMessage(), /* subtitle= */ null); + + } + + @Override + protected void showNoWorkAppsAvailableEmptyState(ResolverListAdapter listAdapter) { + showEmptyState(listAdapter, getNoWorkAppsAvailableMessage(), /* subtitle = */ null); + } + + private String getWorkAppPausedTitle() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_WORK_PAUSED_TITLE, + () -> getContext().getString(R.string.resolver_turn_on_work_apps)); + } + + private String getCrossProfileBlockedTitle() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + () -> getContext().getString(R.string.resolver_cross_profile_blocked)); + } + + private String getCantShareWithWorkMessage() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_CANT_SHARE_WITH_WORK, + () -> getContext().getString( + R.string.resolver_cant_share_with_work_apps_explanation)); + } + + private String getCantShareWithPersonalMessage() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_CANT_SHARE_WITH_PERSONAL, + () -> getContext().getString( + R.string.resolver_cant_share_with_personal_apps_explanation)); + } + + private String getCantAccessWorkMessage() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_CANT_ACCESS_WORK, + () -> getContext().getString( + R.string.resolver_cant_access_work_apps_explanation)); + } + + private String getCantAccessPersonalMessage() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_CANT_ACCESS_PERSONAL, + () -> getContext().getString( + R.string.resolver_cant_access_personal_apps_explanation)); + } + + private String getNoWorkAppsAvailableMessage() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_WORK_APPS, + () -> getContext().getString( + R.string.resolver_no_work_apps_available)); + } + + private String getNoPersonalAppsAvailableMessage() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_PERSONAL_APPS, + () -> getContext().getString( + R.string.resolver_no_personal_apps_available)); + } + + + void setEmptyStateBottomOffset(int bottomOffset) { + mBottomOffset = bottomOffset; + } + + @Override + protected void setupContainerPadding(View container) { + int initialBottomPadding = getContext().getResources().getDimensionPixelSize( + R.dimen.resolver_empty_state_container_padding_bottom); + container.setPadding(container.getPaddingLeft(), container.getPaddingTop(), + container.getPaddingRight(), initialBottomPadding + mBottomOffset); + } + + class ChooserProfileDescriptor extends ProfileDescriptor { + private ChooserActivity.ChooserGridAdapter chooserGridAdapter; + private RecyclerView recyclerView; + ChooserProfileDescriptor(ViewGroup rootView, ChooserActivity.ChooserGridAdapter adapter) { + super(rootView); + chooserGridAdapter = adapter; + recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); + } + } +} diff --git a/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java new file mode 100644 index 00000000..67571b44 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserRecyclerViewAccessibilityDelegate.java @@ -0,0 +1,92 @@ +/* + * 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.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityEvent; + +import com.android.internal.widget.RecyclerView; +import com.android.internal.widget.RecyclerViewAccessibilityDelegate; + +class ChooserRecyclerViewAccessibilityDelegate extends RecyclerViewAccessibilityDelegate { + private final Rect mTempRect = new Rect(); + private final int[] mConsumed = new int[2]; + + ChooserRecyclerViewAccessibilityDelegate(RecyclerView recyclerView) { + super(recyclerView); + } + + @Override + public boolean onRequestSendAccessibilityEvent( + @NonNull ViewGroup host, + @NonNull View view, + @NonNull AccessibilityEvent event) { + boolean result = super.onRequestSendAccessibilityEvent(host, view, event); + if (result && event.getEventType() == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) { + ensureViewOnScreenVisibility((RecyclerView) host, view); + } + return result; + } + + /** + * Bring the view that received accessibility focus on the screen. + * The method's logic is based on a model where RecyclerView is a child of another scrollable + * component (ResolverDrawerLayout) and can be partially scrolled off the screen. In that case, + * RecyclerView's children that are positioned fully within RecyclerView bounds but scrolled + * out of the screen by the outer component, when selected by the accessibility navigation will + * remain off the screen (as neither components detect such specific case). + * If the view that receiving accessibility focus is scrolled of the screen, perform the nested + * scrolling to make in visible. + */ + private void ensureViewOnScreenVisibility(RecyclerView recyclerView, View view) { + View child = recyclerView.findContainingItemView(view); + if (child == null) { + return; + } + recyclerView.getBoundsOnScreen(mTempRect, true); + int recyclerOnScreenTop = mTempRect.top; + int recyclerOnScreenBottom = mTempRect.bottom; + child.getBoundsOnScreen(mTempRect); + int dy = 0; + // if needed, do the page-length scroll instead of just a row-length scroll as + // ResolverDrawerLayout snaps to the compact view and the row-length scroll can be snapped + // back right away. + if (mTempRect.top < recyclerOnScreenTop) { + // snap to the bottom + dy = mTempRect.bottom - recyclerOnScreenBottom; + } else if (mTempRect.bottom > recyclerOnScreenBottom) { + // snap to the top + dy = mTempRect.top - recyclerOnScreenTop; + } + nestedVerticalScrollBy(recyclerView, dy); + } + + private void nestedVerticalScrollBy(RecyclerView recyclerView, int dy) { + if (dy == 0) { + return; + } + recyclerView.startNestedScroll(View.SCROLL_AXIS_VERTICAL); + if (recyclerView.dispatchNestedPreScroll(0, dy, mConsumed, null)) { + dy -= mConsumed[1]; + } + recyclerView.scrollBy(0, dy); + recyclerView.stopNestedScroll(); + } +} diff --git a/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java new file mode 100644 index 00000000..ae08ace2 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserStackedAppDialogFragment.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.android.intentresolver; + +import android.content.DialogInterface; +import android.content.pm.PackageManager; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.UserHandle; + +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.MultiDisplayResolveInfo; + +/** + * Shows individual actions for a "stacked" app target - such as an app with multiple posting + * streams represented in the Sharesheet. + */ +public class ChooserStackedAppDialogFragment extends ChooserTargetActionsDialogFragment + implements DialogInterface.OnClickListener { + + static final String WHICH_KEY = "which_key"; + static final String MULTI_DRI_KEY = "multi_dri_key"; + + private MultiDisplayResolveInfo mMultiDisplayResolveInfo; + private int mParentWhich; + + public ChooserStackedAppDialogFragment() {} + + void setStateFromBundle(Bundle b) { + mMultiDisplayResolveInfo = (MultiDisplayResolveInfo) b.get(MULTI_DRI_KEY); + mTargetInfos = mMultiDisplayResolveInfo.getTargets(); + mUserHandle = (UserHandle) b.get(USER_HANDLE_KEY); + mParentWhich = b.getInt(WHICH_KEY); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt(WHICH_KEY, mParentWhich); + outState.putParcelable(MULTI_DRI_KEY, mMultiDisplayResolveInfo); + } + + @Override + protected CharSequence getItemLabel(DisplayResolveInfo dri) { + final PackageManager pm = getContext().getPackageManager(); + return dri.getResolveInfo().loadLabel(pm); + } + + @Override + protected Drawable getItemIcon(DisplayResolveInfo dri) { + + // Show no icon for the group disambig dialog, null hides the imageview + return null; + } + + @Override + public void onClick(DialogInterface dialog, int which) { + mMultiDisplayResolveInfo.setSelected(which); + ((ChooserActivity) getActivity()).startSelected(mParentWhich, false, true); + dismiss(); + } +} diff --git a/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java new file mode 100644 index 00000000..ffd173c7 --- /dev/null +++ b/java/src/com/android/intentresolver/ChooserTargetActionsDialogFragment.java @@ -0,0 +1,297 @@ +/* + * 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.content.Context.ACTIVITY_SERVICE; + +import static com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; + +import static java.util.stream.Collectors.toList; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.ActivityManager; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.LauncherApps; +import android.content.pm.PackageManager; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.UserHandle; +import android.util.Pair; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.intentresolver.chooser.DisplayResolveInfo; + +import com.android.internal.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Shows a dialog with actions to take on a chooser target. + */ +public class ChooserTargetActionsDialogFragment extends DialogFragment + implements DialogInterface.OnClickListener { + + protected ArrayList<DisplayResolveInfo> mTargetInfos = new ArrayList<>(); + protected UserHandle mUserHandle; + protected String mShortcutId; + protected String mShortcutTitle; + protected boolean mIsShortcutPinned; + protected IntentFilter mIntentFilter; + + public static final String USER_HANDLE_KEY = "user_handle"; + public static final String TARGET_INFOS_KEY = "target_infos"; + public static final String SHORTCUT_ID_KEY = "shortcut_id"; + public static final String SHORTCUT_TITLE_KEY = "shortcut_title"; + public static final String IS_SHORTCUT_PINNED_KEY = "is_shortcut_pinned"; + public static final String INTENT_FILTER_KEY = "intent_filter"; + + public ChooserTargetActionsDialogFragment() {} + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + setStateFromBundle(savedInstanceState); + } else { + setStateFromBundle(getArguments()); + } + } + + void setStateFromBundle(Bundle b) { + mTargetInfos = (ArrayList<DisplayResolveInfo>) b.get(TARGET_INFOS_KEY); + mUserHandle = (UserHandle) b.get(USER_HANDLE_KEY); + mShortcutId = b.getString(SHORTCUT_ID_KEY); + mShortcutTitle = b.getString(SHORTCUT_TITLE_KEY); + mIsShortcutPinned = b.getBoolean(IS_SHORTCUT_PINNED_KEY); + mIntentFilter = (IntentFilter) b.get(INTENT_FILTER_KEY); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putParcelable(ChooserTargetActionsDialogFragment.USER_HANDLE_KEY, + mUserHandle); + outState.putParcelableArrayList(ChooserTargetActionsDialogFragment.TARGET_INFOS_KEY, + mTargetInfos); + outState.putString(ChooserTargetActionsDialogFragment.SHORTCUT_ID_KEY, mShortcutId); + outState.putBoolean(ChooserTargetActionsDialogFragment.IS_SHORTCUT_PINNED_KEY, + mIsShortcutPinned); + outState.putString(ChooserTargetActionsDialogFragment.SHORTCUT_TITLE_KEY, mShortcutTitle); + outState.putParcelable(ChooserTargetActionsDialogFragment.INTENT_FILTER_KEY, mIntentFilter); + } + + /** + * Recreate the layout from scratch to match new Sharesheet redlines + */ + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + Bundle savedInstanceState) { + if (savedInstanceState != null) { + setStateFromBundle(savedInstanceState); + } else { + setStateFromBundle(getArguments()); + } + // Make the background transparent to show dialog rounding + Optional.of(getDialog()).map(Dialog::getWindow) + .ifPresent(window -> { + window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + }); + + // Fetch UI details from target info + List<Pair<Drawable, CharSequence>> items = mTargetInfos.stream().map(dri -> { + return new Pair<>(getItemIcon(dri), getItemLabel(dri)); + }).collect(toList()); + + View v = inflater.inflate(R.layout.chooser_dialog, container, false); + + TextView title = v.findViewById(com.android.internal.R.id.title); + ImageView icon = v.findViewById(com.android.internal.R.id.icon); + RecyclerView rv = v.findViewById(com.android.internal.R.id.listContainer); + + final ResolveInfoPresentationGetter pg = getProvidingAppPresentationGetter(); + title.setText(isShortcutTarget() ? mShortcutTitle : pg.getLabel()); + icon.setImageDrawable(pg.getIcon(mUserHandle)); + rv.setAdapter(new VHAdapter(items)); + + return v; + } + + class VHAdapter extends RecyclerView.Adapter<VH> { + + List<Pair<Drawable, CharSequence>> mItems; + + VHAdapter(List<Pair<Drawable, CharSequence>> items) { + mItems = items; + } + + @NonNull + @Override + public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new VH(LayoutInflater.from(parent.getContext()).inflate( + R.layout.chooser_dialog_item, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull VH holder, int position) { + holder.bind(mItems.get(position), position); + } + + @Override + public int getItemCount() { + return mItems.size(); + } + } + + class VH extends RecyclerView.ViewHolder { + TextView mLabel; + ImageView mIcon; + + VH(@NonNull View itemView) { + super(itemView); + mLabel = itemView.findViewById(com.android.internal.R.id.text); + mIcon = itemView.findViewById(com.android.internal.R.id.icon); + } + + public void bind(Pair<Drawable, CharSequence> item, int position) { + mLabel.setText(item.second); + + if (item.first == null) { + mIcon.setVisibility(View.GONE); + } else { + mIcon.setVisibility(View.VISIBLE); + mIcon.setImageDrawable(item.first); + } + + itemView.setOnClickListener(v -> onClick(getDialog(), position)); + } + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (isShortcutTarget()) { + toggleShortcutPinned(mTargetInfos.get(which).getResolvedComponentName()); + } else { + pinComponent(mTargetInfos.get(which).getResolvedComponentName()); + } + ((ChooserActivity) getActivity()).handlePackagesChanged(); + dismiss(); + } + + private void toggleShortcutPinned(ComponentName name) { + if (mIntentFilter == null) { + return; + } + // Fetch existing pinned shortcuts of the given package. + List<String> pinnedShortcuts = getPinnedShortcutsFromPackageAsUser(getContext(), + mUserHandle, mIntentFilter, name.getPackageName()); + // If the shortcut has already been pinned, unpin it; otherwise, pin it. + if (mIsShortcutPinned) { + pinnedShortcuts.remove(mShortcutId); + } else { + pinnedShortcuts.add(mShortcutId); + } + // Update pinned shortcut list in ShortcutService via LauncherApps + getContext().getSystemService(LauncherApps.class).pinShortcuts( + name.getPackageName(), pinnedShortcuts, mUserHandle); + } + + private static List<String> getPinnedShortcutsFromPackageAsUser(Context context, + UserHandle user, IntentFilter filter, String packageName) { + Context contextAsUser = context.createContextAsUser(user, 0 /* flags */); + List<ShortcutManager.ShareShortcutInfo> targets = contextAsUser.getSystemService( + ShortcutManager.class).getShareTargets(filter); + return targets.stream() + .map(ShortcutManager.ShareShortcutInfo::getShortcutInfo) + .filter(s -> s.isPinned() && s.getPackage().equals(packageName)) + .map(ShortcutInfo::getId) + .collect(Collectors.toList()); + } + + private void pinComponent(ComponentName name) { + SharedPreferences sp = ChooserActivity.getPinnedSharedPrefs(getContext()); + final String key = name.flattenToString(); + boolean currentVal = sp.getBoolean(name.flattenToString(), false); + if (currentVal) { + sp.edit().remove(key).apply(); + } else { + sp.edit().putBoolean(key, true).apply(); + } + } + + private Drawable getPinIcon(boolean isPinned) { + return isPinned + ? getContext().getDrawable(com.android.internal.R.drawable.ic_close) + : getContext().getDrawable(R.drawable.ic_chooser_pin_dialog); + } + + private CharSequence getPinLabel(boolean isPinned, CharSequence targetLabel) { + return isPinned + ? getResources().getString(R.string.unpin_specific_target, targetLabel) + : getResources().getString(R.string.pin_specific_target, targetLabel); + } + + @NonNull + protected CharSequence getItemLabel(DisplayResolveInfo dri) { + final PackageManager pm = getContext().getPackageManager(); + return getPinLabel(isPinned(dri), + isShortcutTarget() ? mShortcutTitle : dri.getResolveInfo().loadLabel(pm)); + } + + @Nullable + protected Drawable getItemIcon(DisplayResolveInfo dri) { + return getPinIcon(isPinned(dri)); + } + + private ResolveInfoPresentationGetter getProvidingAppPresentationGetter() { + final ActivityManager am = (ActivityManager) getContext() + .getSystemService(ACTIVITY_SERVICE); + final int iconDpi = am.getLauncherLargeIconDensity(); + + // Use the matching application icon and label for the title, any TargetInfo will do + return new ResolveInfoPresentationGetter(getContext(), iconDpi, + mTargetInfos.get(0).getResolveInfo()); + } + + private boolean isPinned(DisplayResolveInfo dri) { + return isShortcutTarget() ? mIsShortcutPinned : dri.isPinned(); + } + + private boolean isShortcutTarget() { + return mShortcutId != null; + } +} diff --git a/java/src/com/android/intentresolver/IntentForwarderActivity.java b/java/src/com/android/intentresolver/IntentForwarderActivity.java new file mode 100644 index 00000000..9b853c95 --- /dev/null +++ b/java/src/com/android/intentresolver/IntentForwarderActivity.java @@ -0,0 +1,436 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +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.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 android.annotation.Nullable; +import android.app.Activity; +import android.app.ActivityThread; +import android.app.AppGlobals; +import android.app.admin.DevicePolicyManager; +import android.compat.annotation.UnsupportedAppUsage; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.IPackageManager; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.UserInfo; +import android.metrics.LogMaker; +import android.os.Build; +import android.os.Bundle; +import android.os.RemoteException; +import android.os.UserHandle; +import android.os.UserManager; +import android.provider.Settings; +import android.util.Slog; +import android.widget.Toast; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * This is used in conjunction with + * {@link DevicePolicyManager#addCrossProfileIntentFilter} to enable intents to + * be passed in and out of a managed profile. + */ +public class IntentForwarderActivity extends Activity { + @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553) + public static String TAG = "IntentForwarderActivity"; + + public static String FORWARD_INTENT_TO_PARENT + = "com.android.internal.app.ForwardIntentToParent"; + + public static String FORWARD_INTENT_TO_MANAGED_PROFILE + = "com.android.internal.app.ForwardIntentToManagedProfile"; + + private static final Set<String> ALLOWED_TEXT_MESSAGE_SCHEMES + = new HashSet<>(Arrays.asList("sms", "smsto", "mms", "mmsto")); + + private static final String TEL_SCHEME = "tel"; + + private static final ComponentName RESOLVER_COMPONENT_NAME = + new ComponentName("android", ResolverActivity.class.getName()); + + private Injector mInjector; + + private MetricsLogger mMetricsLogger; + protected ExecutorService mExecutorService; + + @Override + protected void onDestroy() { + super.onDestroy(); + mExecutorService.shutdown(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mInjector = createInjector(); + mExecutorService = Executors.newSingleThreadExecutor(); + + Intent intentReceived = getIntent(); + String className = intentReceived.getComponent().getClassName(); + final int targetUserId; + final String userMessage; + if (className.equals(FORWARD_INTENT_TO_PARENT)) { + userMessage = getForwardToPersonalMessage(); + targetUserId = getProfileParent(); + + getMetricsLogger().write( + new LogMaker(MetricsEvent.ACTION_SWITCH_SHARE_PROFILE) + .setSubtype(MetricsEvent.PARENT_PROFILE)); + } else if (className.equals(FORWARD_INTENT_TO_MANAGED_PROFILE)) { + userMessage = getForwardToWorkMessage(); + targetUserId = getManagedProfile(); + + getMetricsLogger().write( + new LogMaker(MetricsEvent.ACTION_SWITCH_SHARE_PROFILE) + .setSubtype(MetricsEvent.MANAGED_PROFILE)); + } else { + Slog.wtf(TAG, IntentForwarderActivity.class.getName() + " cannot be called directly"); + userMessage = null; + targetUserId = UserHandle.USER_NULL; + } + if (targetUserId == UserHandle.USER_NULL) { + // This covers the case where there is no parent / managed profile. + finish(); + return; + } + if (Intent.ACTION_CHOOSER.equals(intentReceived.getAction())) { + launchChooserActivityWithCorrectTab(intentReceived, className); + return; + } + + final int callingUserId = getUserId(); + final Intent newIntent = canForward(intentReceived, getUserId(), targetUserId, + mInjector.getIPackageManager(), getContentResolver()); + + if (newIntent == null) { + Slog.wtf(TAG, "the intent: " + intentReceived + " cannot be forwarded from user " + + callingUserId + " to user " + targetUserId); + finish(); + return; + } + + newIntent.prepareToLeaveUser(callingUserId); + final CompletableFuture<ResolveInfo> targetResolveInfoFuture = + mInjector.resolveActivityAsUser(newIntent, MATCH_DEFAULT_ONLY, targetUserId); + targetResolveInfoFuture + .thenApplyAsync(targetResolveInfo -> { + if (isResolverActivityResolveInfo(targetResolveInfo)) { + launchResolverActivityWithCorrectTab(intentReceived, className, newIntent, + callingUserId, targetUserId); + return targetResolveInfo; + } + startActivityAsCaller(newIntent, targetUserId); + return targetResolveInfo; + }, mExecutorService) + .thenAcceptAsync(result -> { + maybeShowDisclosure(intentReceived, result, userMessage); + finish(); + }, getApplicationContext().getMainExecutor()); + } + + private String getForwardToPersonalMessage() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + FORWARD_INTENT_TO_PERSONAL, + () -> getString(com.android.internal.R.string.forward_intent_to_owner)); + } + + private String getForwardToWorkMessage() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + FORWARD_INTENT_TO_WORK, + () -> getString(com.android.internal.R.string.forward_intent_to_work)); + } + + private boolean isIntentForwarderResolveInfo(ResolveInfo resolveInfo) { + if (resolveInfo == null) { + return false; + } + ActivityInfo activityInfo = resolveInfo.activityInfo; + if (activityInfo == null) { + return false; + } + if (!"android".equals(activityInfo.packageName)) { + return false; + } + return activityInfo.name.equals(FORWARD_INTENT_TO_PARENT) + || activityInfo.name.equals(FORWARD_INTENT_TO_MANAGED_PROFILE); + } + + private boolean isResolverActivityResolveInfo(@Nullable ResolveInfo resolveInfo) { + return resolveInfo != null + && resolveInfo.activityInfo != null + && RESOLVER_COMPONENT_NAME.equals(resolveInfo.activityInfo.getComponentName()); + } + + private void maybeShowDisclosure( + Intent intentReceived, ResolveInfo resolveInfo, @Nullable String message) { + if (shouldShowDisclosure(resolveInfo, intentReceived) && message != null) { + mInjector.showToast(message, Toast.LENGTH_LONG); + } + } + + private void startActivityAsCaller(Intent newIntent, int userId) { + try { + startActivityAsCaller( + newIntent, + /* options= */ null, + /* ignoreTargetSecurity= */ false, + userId); + } catch (RuntimeException e) { + Slog.wtf(TAG, "Unable to launch as UID " + getLaunchedFromUid() + " package " + + getLaunchedFromPackage() + ", while running in " + + ActivityThread.currentProcessName(), e); + } + } + + private void launchChooserActivityWithCorrectTab(Intent intentReceived, String className) { + // When showing the sharesheet, instead of forwarding to the other profile, + // we launch the sharesheet in the current user and select the other tab. + // This fixes b/152866292 where the user can not go back to the original profile + // when cross-profile intents are disabled. + int selectedProfile = findSelectedProfile(className); + sanitizeIntent(intentReceived); + intentReceived.putExtra(EXTRA_SELECTED_PROFILE, selectedProfile); + Intent innerIntent = intentReceived.getParcelableExtra(Intent.EXTRA_INTENT); + if (innerIntent == null) { + Slog.wtf(TAG, "Cannot start a chooser intent with no extra " + Intent.EXTRA_INTENT); + return; + } + sanitizeIntent(innerIntent); + startActivityAsCaller(intentReceived, null, false, getUserId()); + finish(); + } + + private void launchResolverActivityWithCorrectTab(Intent intentReceived, String className, + Intent newIntent, int callingUserId, int targetUserId) { + // When showing the intent resolver, instead of forwarding to the other profile, + // we launch it in the current user and select the other tab. This fixes b/155874820. + // + // In the case when there are 0 targets in the current profile and >1 apps in the other + // profile, the package manager launches the intent resolver in the other profile. + // If that's the case, we launch the resolver in the target user instead (other profile). + ResolveInfo callingResolveInfo = mInjector.resolveActivityAsUser( + newIntent, MATCH_DEFAULT_ONLY, callingUserId).join(); + int userId = isIntentForwarderResolveInfo(callingResolveInfo) + ? targetUserId : callingUserId; + int selectedProfile = findSelectedProfile(className); + sanitizeIntent(intentReceived); + intentReceived.putExtra(EXTRA_SELECTED_PROFILE, selectedProfile); + intentReceived.putExtra(EXTRA_CALLING_USER, UserHandle.of(callingUserId)); + startActivityAsCaller(intentReceived, null, false, userId); + finish(); + } + + private int findSelectedProfile(String className) { + if (className.equals(FORWARD_INTENT_TO_PARENT)) { + return ChooserActivity.PROFILE_PERSONAL; + } else if (className.equals(FORWARD_INTENT_TO_MANAGED_PROFILE)) { + return ChooserActivity.PROFILE_WORK; + } + return -1; + } + + private boolean shouldShowDisclosure(@Nullable ResolveInfo ri, Intent intent) { + if (!isDeviceProvisioned()) { + return false; + } + if (ri == null || ri.activityInfo == null) { + return true; + } + if (ri.activityInfo.applicationInfo.isSystemApp() + && (isDialerIntent(intent) || isTextMessageIntent(intent))) { + return false; + } + return !isTargetResolverOrChooserActivity(ri.activityInfo); + } + + private boolean isDeviceProvisioned() { + return Settings.Global.getInt(getContentResolver(), + Settings.Global.DEVICE_PROVISIONED, /* def= */ 0) != 0; + } + + private boolean isTextMessageIntent(Intent intent) { + return (Intent.ACTION_SENDTO.equals(intent.getAction()) || isViewActionIntent(intent)) + && ALLOWED_TEXT_MESSAGE_SCHEMES.contains(intent.getScheme()); + } + + private boolean isDialerIntent(Intent intent) { + return Intent.ACTION_DIAL.equals(intent.getAction()) + || Intent.ACTION_CALL.equals(intent.getAction()) + || Intent.ACTION_CALL_PRIVILEGED.equals(intent.getAction()) + || Intent.ACTION_CALL_EMERGENCY.equals(intent.getAction()) + || (isViewActionIntent(intent) && TEL_SCHEME.equals(intent.getScheme())); + } + + private boolean isViewActionIntent(Intent intent) { + return Intent.ACTION_VIEW.equals(intent.getAction()) + && intent.hasCategory(Intent.CATEGORY_BROWSABLE); + } + + private boolean isTargetResolverOrChooserActivity(ActivityInfo activityInfo) { + if (!"android".equals(activityInfo.packageName)) { + return false; + } + return ResolverActivity.class.getName().equals(activityInfo.name) + || ChooserActivity.class.getName().equals(activityInfo.name); + } + + /** + * 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, + IPackageManager packageManager, ContentResolver contentResolver) { + Intent forwardIntent = new Intent(incomingIntent); + forwardIntent.addFlags( + Intent.FLAG_ACTIVITY_FORWARD_RESULT | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); + sanitizeIntent(forwardIntent); + + Intent intentToCheck = forwardIntent; + if (Intent.ACTION_CHOOSER.equals(forwardIntent.getAction())) { + return null; + } + if (forwardIntent.getSelector() != null) { + intentToCheck = forwardIntent.getSelector(); + } + String resolvedType = intentToCheck.resolveTypeIfNeeded(contentResolver); + sanitizeIntent(intentToCheck); + try { + if (packageManager.canForwardTo( + intentToCheck, resolvedType, sourceUserId, targetUserId)) { + return forwardIntent; + } + } catch (RemoteException e) { + Slog.e(TAG, "PackageManagerService is dead?"); + } + return null; + } + + /** + * Returns the userId of the managed profile for this device or UserHandle.USER_NULL if there is + * no managed profile. + * + * TODO: Remove the assumption that there is only one managed profile + * on the device. + */ + private int getManagedProfile() { + List<UserInfo> relatedUsers = mInjector.getUserManager().getProfiles(UserHandle.myUserId()); + for (UserInfo userInfo : relatedUsers) { + if (userInfo.isManagedProfile()) return userInfo.id; + } + Slog.wtf(TAG, FORWARD_INTENT_TO_MANAGED_PROFILE + + " has been called, but there is no managed profile"); + return UserHandle.USER_NULL; + } + + /** + * Returns the userId of the profile parent or UserHandle.USER_NULL if there is + * no parent. + */ + private int getProfileParent() { + UserInfo parent = mInjector.getUserManager().getProfileParent(UserHandle.myUserId()); + if (parent == null) { + Slog.wtf(TAG, FORWARD_INTENT_TO_PARENT + + " has been called, but there is no parent"); + return UserHandle.USER_NULL; + } + return parent.id; + } + + /** + * Sanitize the intent in place. + */ + private static void sanitizeIntent(Intent intent) { + // Apps should not be allowed to target a specific package/ component in the target user. + intent.setPackage(null); + intent.setComponent(null); + } + + protected MetricsLogger getMetricsLogger() { + if (mMetricsLogger == null) { + mMetricsLogger = new MetricsLogger(); + } + return mMetricsLogger; + } + + @VisibleForTesting + protected Injector createInjector() { + return new InjectorImpl(); + } + + private class InjectorImpl implements Injector { + + @Override + public IPackageManager getIPackageManager() { + return AppGlobals.getPackageManager(); + } + + @Override + public UserManager getUserManager() { + return getSystemService(UserManager.class); + } + + @Override + public PackageManager getPackageManager() { + return IntentForwarderActivity.this.getPackageManager(); + } + + @Override + @Nullable + public CompletableFuture<ResolveInfo> resolveActivityAsUser( + Intent intent, int flags, int userId) { + return CompletableFuture.supplyAsync( + () -> getPackageManager().resolveActivityAsUser(intent, flags, userId)); + } + + @Override + public void showToast(String message, int duration) { + Toast.makeText(IntentForwarderActivity.this, message, duration).show(); + } + } + + public interface Injector { + IPackageManager getIPackageManager(); + + UserManager getUserManager(); + + PackageManager getPackageManager(); + + CompletableFuture<ResolveInfo> resolveActivityAsUser(Intent intent, int flags, int userId); + + void showToast(String message, int duration); + } +} diff --git a/java/src/com/android/intentresolver/ResolverActivity.java b/java/src/com/android/intentresolver/ResolverActivity.java new file mode 100644 index 00000000..453a6e84 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverActivity.java @@ -0,0 +1,2301 @@ +/* + * 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 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_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.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS; + +import android.annotation.Nullable; +import android.annotation.StringRes; +import android.annotation.UiThread; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.ActivityThread; +import android.app.VoiceInteractor.PickOptionRequest; +import android.app.VoiceInteractor.PickOptionRequest.Option; +import android.app.VoiceInteractor.Prompt; +import android.app.admin.DevicePolicyEventLogger; +import android.app.admin.DevicePolicyManager; +import android.compat.annotation.UnsupportedAppUsage; +import android.content.BroadcastReceiver; +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; +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; +import android.os.Bundle; +import android.os.PatternMatcher; +import android.os.RemoteException; +import android.os.StrictMode; +import android.os.Trace; +import android.os.UserHandle; +import android.os.UserManager; +import android.provider.MediaStore; +import android.provider.Settings; +import android.stats.devicepolicy.DevicePolicyEnums; +import android.text.TextUtils; +import android.util.Log; +import android.util.Slog; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.Space; +import android.widget.TabHost; +import android.widget.TabWidget; +import android.widget.TextView; +import android.widget.Toast; + +import com.android.intentresolver.AbstractMultiProfilePagerAdapter.Profile; +import com.android.intentresolver.chooser.ChooserTargetInfo; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.content.PackageMonitor; +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.nano.MetricsProto; +import com.android.internal.util.LatencyTracker; +import com.android.internal.widget.ResolverDrawerLayout; +import com.android.internal.widget.ViewPager; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * This activity is displayed when the system attempts to start an Intent for + * which there is more than one matching activity, allowing the user to decide + * which to go to. It is not normally used directly by application developers. + */ +@UiThread +public class ResolverActivity extends Activity implements + ResolverListAdapter.ResolverListCommunicator { + + @UnsupportedAppUsage + public ResolverActivity() { + mIsIntentPicker = getClass().equals(ResolverActivity.class); + } + + protected ResolverActivity(boolean isIntentPicker) { + mIsIntentPicker = isIntentPicker; + } + + 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; + @UnsupportedAppUsage + protected PackageManager mPm; + protected int mLaunchedFromUid; + + private static final String TAG = "ResolverActivity"; + private static final boolean DEBUG = false; + private static final String LAST_SHOWN_TAB_KEY = "last_shown_tab_key"; + + private boolean mRegistered; + + protected Insets mSystemWindowInsets = null; + private Space mFooterSpacer = null; + + /** See {@link #setRetainInOnStop}. */ + private boolean mRetainInOnStop; + + private static final String EXTRA_SHOW_FRAGMENT_ARGS = ":settings:show_fragment_args"; + private static final String EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key"; + private static final String OPEN_LINKS_COMPONENT_KEY = "app_link_state"; + protected static final String METRICS_CATEGORY_RESOLVER = "intent_resolver"; + protected static final String METRICS_CATEGORY_CHOOSER = "intent_chooser"; + + /** Tracks if we should ignore future broadcasts telling us the work profile is enabled */ + private boolean mWorkProfileHasBeenEnabled = false; + + @VisibleForTesting + public static boolean ENABLE_TABBED_VIEW = true; + private static final String TAB_TAG_PERSONAL = "personal"; + private static final String TAB_TAG_WORK = "work"; + + private PackageMonitor mPersonalPackageMonitor; + private PackageMonitor mWorkPackageMonitor; + + @VisibleForTesting + protected AbstractMultiProfilePagerAdapter 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}. + */ + 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"; + + static final int PROFILE_PERSONAL = AbstractMultiProfilePagerAdapter.PROFILE_PERSONAL; + static final int PROFILE_WORK = AbstractMultiProfilePagerAdapter.PROFILE_WORK; + + private BroadcastReceiver mWorkProfileStateReceiver; + private UserHandle mHeaderCreatorUser; + + private UserHandle mWorkProfileUserHandle; + + protected final LatencyTracker mLatencyTracker = getLatencyTracker(); + + 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; + } + + private enum ActionTitle { + VIEW(Intent.ACTION_VIEW, + com.android.internal.R.string.whichViewApplication, + com.android.internal.R.string.whichViewApplicationNamed, + com.android.internal.R.string.whichViewApplicationLabel), + EDIT(Intent.ACTION_EDIT, + com.android.internal.R.string.whichEditApplication, + com.android.internal.R.string.whichEditApplicationNamed, + com.android.internal.R.string.whichEditApplicationLabel), + SEND(Intent.ACTION_SEND, + com.android.internal.R.string.whichSendApplication, + com.android.internal.R.string.whichSendApplicationNamed, + com.android.internal.R.string.whichSendApplicationLabel), + SENDTO(Intent.ACTION_SENDTO, + com.android.internal.R.string.whichSendToApplication, + com.android.internal.R.string.whichSendToApplicationNamed, + com.android.internal.R.string.whichSendToApplicationLabel), + SEND_MULTIPLE(Intent.ACTION_SEND_MULTIPLE, + com.android.internal.R.string.whichSendApplication, + com.android.internal.R.string.whichSendApplicationNamed, + com.android.internal.R.string.whichSendApplicationLabel), + CAPTURE_IMAGE(MediaStore.ACTION_IMAGE_CAPTURE, + com.android.internal.R.string.whichImageCaptureApplication, + com.android.internal.R.string.whichImageCaptureApplicationNamed, + com.android.internal.R.string.whichImageCaptureApplicationLabel), + DEFAULT(null, + com.android.internal.R.string.whichApplication, + com.android.internal.R.string.whichApplicationNamed, + com.android.internal.R.string.whichApplicationLabel), + HOME(Intent.ACTION_MAIN, + com.android.internal.R.string.whichHomeApplication, + com.android.internal.R.string.whichHomeApplicationNamed, + com.android.internal.R.string.whichHomeApplicationLabel); + + // titles for layout that deals with http(s) intents + public static final int BROWSABLE_TITLE_RES = + com.android.internal.R.string.whichOpenLinksWith; + public static final int BROWSABLE_HOST_TITLE_RES = + com.android.internal.R.string.whichOpenHostLinksWith; + public static final int BROWSABLE_HOST_APP_TITLE_RES = + com.android.internal.R.string.whichOpenHostLinksWithApp; + public static final int BROWSABLE_APP_TITLE_RES = + com.android.internal.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 + public boolean onPackageChanged(String packageName, int uid, String[] components) { + // We care about all package changes, not just the whole package itself which is + // default behavior. + return true; + } + }; + } + + 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 void super_onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @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; + } + + setSafeForwardingMode(true); + + onCreate(savedInstanceState, intent, null, 0, null, null, true); + } + + /** + * Compatibility version for other bundled services that use this overload without + * a default title resource + */ + @UnsupportedAppUsage + protected void onCreate(Bundle savedInstanceState, Intent intent, + CharSequence title, Intent[] initialIntents, + List<ResolveInfo> rList, boolean supportsAlwaysUseOption) { + onCreate(savedInstanceState, intent, title, 0, initialIntents, rList, + supportsAlwaysUseOption); + } + + protected void onCreate(Bundle savedInstanceState, Intent intent, + CharSequence title, int defaultTitleRes, Intent[] initialIntents, + List<ResolveInfo> rList, boolean supportsAlwaysUseOption) { + setTheme(appliedThemeResId()); + super.onCreate(savedInstanceState); + + // Determine whether we should show that intent is forwarded + // from managed profile to owner or other way around. + setProfileSwitchMessage(intent.getContentUserHint()); + + mLaunchedFromUid = getLaunchedFromUid(); + if (mLaunchedFromUid < 0 || UserHandle.isIsolated(mLaunchedFromUid)) { + // Gulp! + finish(); + return; + } + + mPm = getPackageManager(); + + mReferrerPackage = getReferrerPackageName(); + + // 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; + + mSupportsAlwaysUseOption = supportsAlwaysUseOption; + mWorkProfileUserHandle = fetchWorkProfileUserProfile(); + + // The last argument of createResolverListAdapter is whether to do special handling + // of the last used choice to highlight it in the list. We need to always + // 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. + boolean filterLastUsed = mSupportsAlwaysUseOption && !isVoiceInteraction() + && !shouldShowTabs(); + mMultiProfilePagerAdapter = createMultiProfilePagerAdapter(initialIntents, rList, filterLastUsed); + if (configureContentView()) { + return; + } + + mPersonalPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getPersonalListAdapter()); + mPersonalPackageMonitor.register( + this, getMainLooper(), getPersonalProfileUserHandle(), false); + if (shouldShowTabs()) { + mWorkPackageMonitor = createPackageMonitor( + mMultiProfilePagerAdapter.getWorkListAdapter()); + mWorkPackageMonitor.register(this, getMainLooper(), getWorkProfileUserHandle(), 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 = getPackageManager() + .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; + } + + mProfileView = findViewById(com.android.internal.R.id.profile_button); + if (mProfileView != null) { + mProfileView.setOnClickListener(this::onProfileClick); + updateProfileViewButton(); + } + + final Set<String> categories = intent.getCategories(); + MetricsLogger.action(this, mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_APP_FEATURED + : MetricsProto.MetricsEvent.ACTION_SHOW_APP_DISAMBIG_NONE_FEATURED, + intent.getAction() + ":" + intent.getType() + ":" + + (categories != null ? Arrays.toString(categories.toArray()) : "")); + } + + protected AbstractMultiProfilePagerAdapter createMultiProfilePagerAdapter( + Intent[] initialIntents, + List<ResolveInfo> rList, + boolean filterLastUsed) { + AbstractMultiProfilePagerAdapter resolverMultiProfilePagerAdapter = null; + if (shouldShowTabs()) { + resolverMultiProfilePagerAdapter = + createResolverMultiProfilePagerAdapterForTwoProfiles( + initialIntents, rList, filterLastUsed); + } else { + resolverMultiProfilePagerAdapter = createResolverMultiProfilePagerAdapterForOneProfile( + initialIntents, rList, filterLastUsed); + } + return resolverMultiProfilePagerAdapter; + } + + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForOneProfile( + Intent[] initialIntents, + List<ResolveInfo> rList, boolean filterLastUsed) { + ResolverListAdapter adapter = createResolverListAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + initialIntents, + rList, + filterLastUsed, + /* userHandle */ UserHandle.of(UserHandle.myUserId())); + return new ResolverMultiProfilePagerAdapter( + /* context */ this, + adapter, + getPersonalProfileUserHandle(), + /* workProfileUserHandle= */ null); + } + + private ResolverMultiProfilePagerAdapter createResolverMultiProfilePagerAdapterForTwoProfiles( + Intent[] initialIntents, + List<ResolveInfo> rList, + 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 = getIntent().hasExtra(EXTRA_CALLING_USER) + ? getIntent().getParcelableExtra(EXTRA_CALLING_USER) + : getUser(); + if (!getUser().equals(intentUser)) { + if (getPersonalProfileUserHandle().equals(intentUser)) { + selectedProfile = PROFILE_PERSONAL; + } else if (getWorkProfileUserHandle().equals(intentUser)) { + selectedProfile = PROFILE_WORK; + } + } else { + int selectedProfileExtra = getSelectedProfileExtra(); + if (selectedProfileExtra != -1) { + selectedProfile = selectedProfileExtra; + } + } + // We only show the default app for the profile of the current user. The filterLastUsed + // flag determines whether to show a default app and that app is not shown in the + // resolver list. So filterLastUsed should be false for the other profile. + ResolverListAdapter personalAdapter = createResolverListAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_PERSONAL ? initialIntents : null, + rList, + (filterLastUsed && UserHandle.myUserId() + == getPersonalProfileUserHandle().getIdentifier()), + /* userHandle */ getPersonalProfileUserHandle()); + UserHandle workProfileUserHandle = getWorkProfileUserHandle(); + ResolverListAdapter workAdapter = createResolverListAdapter( + /* context */ this, + /* payloadIntents */ mIntents, + selectedProfile == PROFILE_WORK ? initialIntents : null, + rList, + (filterLastUsed && UserHandle.myUserId() + == workProfileUserHandle.getIdentifier()), + /* userHandle */ workProfileUserHandle); + return new ResolverMultiProfilePagerAdapter( + /* context */ this, + personalAdapter, + workAdapter, + selectedProfile, + getPersonalProfileUserHandle(), + getWorkProfileUserHandle(), + /* shouldShowNoCrossProfileIntentsEmptyState= */ getUser().equals(intentUser)); + } + + protected int appliedThemeResId() { + return R.style.Theme_DeviceDefault_Resolver; + } + + /** + * 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} + */ + 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."); + } + } + return selectedProfile; + } + + protected @Profile int getCurrentProfile() { + return (UserHandle.myUserId() == UserHandle.USER_SYSTEM ? PROFILE_PERSONAL : PROFILE_WORK); + } + + protected UserHandle getPersonalProfileUserHandle() { + return UserHandle.of(ActivityManager.getCurrentUser()); + } + protected @Nullable UserHandle getWorkProfileUserHandle() { + return mWorkProfileUserHandle; + } + + protected @Nullable UserHandle fetchWorkProfileUserProfile() { + mWorkProfileUserHandle = null; + UserManager userManager = getSystemService(UserManager.class); + for (final UserInfo userInfo : userManager.getProfiles(ActivityManager.getCurrentUser())) { + if (userInfo.isManagedProfile()) { + mWorkProfileUserHandle = userInfo.getUserHandle(); + } + } + return mWorkProfileUserHandle; + } + + private boolean hasWorkProfile() { + return getWorkProfileUserHandle() != null; + } + + protected boolean shouldShowTabs() { + return hasWorkProfile() && ENABLE_TABBED_VIEW; + } + + protected 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(); + } + + /** + * Numerous layouts are supported, each with optional ViewGroups. + * Make sure the inset gets added to the correct View, using + * a footer for Lists so it can properly scroll under the navbar. + */ + protected boolean shouldAddFooterView() { + if (useLayoutWithDefault()) return true; + + View buttonBar = findViewById(com.android.internal.R.id.button_bar); + if (buttonBar == null || buttonBar.getVisibility() == View.GONE) return true; + + return false; + } + + protected void applyFooterView(int height) { + if (mFooterSpacer == null) { + mFooterSpacer = new Space(getApplicationContext()); + } else { + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .getActiveAdapterView().removeFooterView(mFooterSpacer); + } + mFooterSpacer.setLayoutParams(new AbsListView.LayoutParams(LayoutParams.MATCH_PARENT, + mSystemWindowInsets.bottom)); + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .getActiveAdapterView().addFooterView(mFooterSpacer); + } + + protected WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + mSystemWindowInsets = insets.getSystemWindowInsets(); + + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + + resetButtonBar(); + + if (shouldUseMiniResolver()) { + View buttonContainer = findViewById(com.android.internal.R.id.button_bar_container); + buttonContainer.setPadding(0, 0, 0, mSystemWindowInsets.bottom + + getResources().getDimensionPixelOffset(R.dimen.resolver_button_bar_spacing)); + } + + // Need extra padding so the list can fully scroll up + if (shouldAddFooterView()) { + applyFooterView(mSystemWindowInsets.bottom); + } + + return insets.consumeSystemWindowInsets(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + if (mIsIntentPicker && shouldShowTabs() && !useLayoutWithDefault() + && !shouldUseMiniResolver()) { + updateIntentPickerPaddings(); + } + + if (mSystemWindowInsets != null) { + mResolverDrawerLayout.setPadding(mSystemWindowInsets.left, mSystemWindowInsets.top, + mSystemWindowInsets.right, 0); + } + } + + private void updateIntentPickerPaddings() { + View titleCont = findViewById(com.android.internal.R.id.title_container); + titleCont.setPadding( + titleCont.getPaddingLeft(), + titleCont.getPaddingTop(), + titleCont.getPaddingRight(), + getResources().getDimensionPixelSize(R.dimen.resolver_title_padding_bottom)); + View buttonBar = findViewById(com.android.internal.R.id.button_bar); + buttonBar.setPadding( + buttonBar.getPaddingLeft(), + getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing), + buttonBar.getPaddingRight(), + getResources().getDimensionPixelSize(R.dimen.resolver_button_bar_spacing)); + } + + @Override // ResolverListCommunicator + public void sendVoiceChoicesIfNeeded() { + if (!isVoiceInteraction()) { + // Clearly not needed. + return; + } + + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getCount(); + final Option[] options = new Option[count]; + for (int i = 0, N = options.length; i < N; i++) { + TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter().getItem(i); + if (target == null) { + // If this occurs, a new set of targets is being loaded. Let that complete, + // and have the next call to send voice choices proceed instead. + return; + } + options[i] = optionForChooserTarget(target, i); + } + + mPickOptionRequest = new PickTargetOptionRequest( + new Prompt(getTitle()), options, null); + getVoiceInteractor().submitRequest(mPickOptionRequest); + } + + 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); + } + } + } + + @Override // SelectableTargetInfoCommunicator ResolverListCommunicator + public Intent getTargetIntent() { + return mIntents.isEmpty() ? null : mIntents.get(0); + } + + protected String getReferrerPackageName() { + final Uri referrer = getReferrer(); + if (referrer != null && "android-app".equals(referrer.getScheme())) { + return referrer.getHost(); + } + return null; + } + + public int getLayoutResource() { + return R.layout.resolver_list; + } + + @Override // ResolverListCommunicator + public 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(com.android.internal.R.string.forward_intent_to_owner)); + } + + private String getForwardToWorkMsg() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + FORWARD_INTENT_TO_WORK, + () -> getString(com.android.internal.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 void setSafeForwardingMode(boolean safeForwarding) { + mSafeForwardingMode = safeForwarding; + } + + protected CharSequence getTitleForAction(Intent intent, int defaultTitleRes) { + final ActionTitle title = mResolvingHome + ? ActionTitle.HOME + : ActionTitle.forAction(intent.getAction()); + + // While there may already be a filtered item, we can only use it in the title if the list + // is already sorted and all information relevant to it is already in the list. + final boolean named = + mMultiProfilePagerAdapter.getActiveListAdapter().getFilteredPosition() >= 0; + if (title == ActionTitle.DEFAULT && defaultTitleRes != 0) { + return getString(defaultTitleRes); + } else { + return named + ? getString(title.namedTitleRes, mMultiProfilePagerAdapter + .getActiveListAdapter().getFilteredItem().getDisplayLabel()) + : getString(title.titleRes); + } + } + + void dismiss() { + if (!isFinishing()) { + finish(); + } + } + + @Override + protected 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() && mMultiProfilePagerAdapter.isWaitingToEnableWorkProfile()) { + if (mMultiProfilePagerAdapter.isQuietModeEnabled(getWorkProfileUserHandle())) { + mMultiProfilePagerAdapter.markWorkProfileEnabledBroadcastReceived(); + } + } + mMultiProfilePagerAdapter.getActiveListAdapter().handlePackagesChanged(); + updateProfileViewButton(); + } + + @Override + protected void onStart() { + super.onStart(); + + this.getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); + if (shouldShowTabs()) { + mWorkProfileStateReceiver = createWorkProfileStateReceiver(); + registerWorkProfileStateReceiver(); + + mWorkProfileHasBeenEnabled = isWorkProfileEnabled(); + } + } + + private boolean isWorkProfileEnabled() { + UserHandle workUserHandle = getWorkProfileUserHandle(); + UserManager userManager = getSystemService(UserManager.class); + + return !userManager.isQuietModeEnabled(workUserHandle) + && userManager.isUserUnlocked(workUserHandle); + } + + private void registerWorkProfileStateReceiver() { + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_USER_UNLOCKED); + filter.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE); + filter.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE); + registerReceiverAsUser(mWorkProfileStateReceiver, UserHandle.ALL, filter, null, null); + } + + @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(); + } + } + if (mWorkPackageMonitor != null) { + unregisterReceiver(mWorkProfileStateReceiver); + mWorkPackageMonitor = null; + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations() && mPickOptionRequest != null) { + mPickOptionRequest.cancel(); + } + if (mMultiProfilePagerAdapter != null + && mMultiProfilePagerAdapter.getActiveListAdapter() != null) { + mMultiProfilePagerAdapter.getActiveListAdapter().onDestroy(); + } + } + + @Override + protected 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 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) { + return false; + } + + try { + List<UserInfo> profiles = userManager.getProfiles(getUserId()); + for (UserInfo userInfo : profiles) { + if (userInfo != null && userInfo.isManagedProfile()) { + return true; + } + } + } catch (SecurityException e) { + return false; + } + return false; + } + + private boolean supportsManagedProfiles(ResolveInfo resolveInfo) { + try { + ApplicationInfo appInfo = getPackageManager().getApplicationInfo( + resolveInfo.activityInfo.packageName, 0 /* default flags */); + return appInfo.targetSdkVersion >= Build.VERSION_CODES.LOLLIPOP; + } catch (NameNotFoundException e) { + return false; + } + } + + private void setAlwaysButtonEnabled(boolean hasValidSelection, int checkedPos, + boolean filtered) { + if (!mMultiProfilePagerAdapter.getCurrentUserHandle().equals(getUser())) { + // Never allow the inactive profile to always open an app. + mAlwaysButton.setEnabled(false); + return; + } + boolean enabled = false; + ResolveInfo ri = null; + if (hasValidSelection) { + ri = mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(checkedPos, filtered); + if (ri == null) { + Log.e(TAG, "Invalid position supplied to setAlwaysButtonEnabled"); + return; + } else if (ri.targetUserId != UserHandle.USER_CURRENT) { + Log.e(TAG, "Attempted to set selection to resolve info for another user"); + return; + } else { + enabled = true; + } + + mAlwaysButton.setText(getResources() + .getString(R.string.activity_resolver_use_always)); + } + + if (ri != null) { + ActivityInfo activityInfo = ri.activityInfo; + + boolean hasRecordPermission = + mPm.checkPermission(android.Manifest.permission.RECORD_AUDIO, + activityInfo.packageName) + == android.content.pm.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); + enabled = !hasAudioCapture; + } + } + mAlwaysButton.setEnabled(enabled); + } + + public void onButtonClick(View v) { + final int id = v.getId(); + ListView listView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); + ResolverListAdapter currentListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); + int which = currentListAdapter.hasFilteredItem() + ? currentListAdapter.getFilteredPosition() + : listView.getCheckedItemPosition(); + boolean hasIndexBeenFiltered = !currentListAdapter.hasFilteredItem(); + startSelected(which, id == com.android.internal.R.id.button_always, hasIndexBeenFiltered); + } + + public void startSelected(int which, boolean always, boolean hasIndexBeenFiltered) { + if (isFinishing()) { + return; + } + ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(which, hasIndexBeenFiltered); + if (mResolvingHome && hasManagedProfile() && !supportsManagedProfiles(ri)) { + Toast.makeText(this, + getWorkProfileNotSupportedMsg( + ri.activityInfo.loadLabel(getPackageManager()).toString()), + Toast.LENGTH_LONG).show(); + return; + } + + TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(which, hasIndexBeenFiltered); + if (target == null) { + return; + } + if (onTargetSelected(target, always)) { + if (always && mSupportsAlwaysUseOption) { + 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); + } + MetricsLogger.action(this, + mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem() + ? MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_APP_FEATURED + : MetricsProto.MetricsEvent.ACTION_HIDE_APP_DISAMBIG_NONE_FEATURED); + finish(); + } + } + + private String getWorkProfileNotSupportedMsg(String launcherName) { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_WORK_PROFILE_NOT_SUPPORTED, + () -> getString( + com.android.internal.R.string.activity_resolver_work_profiles_support, + launcherName), + launcherName); + } + + /** + * Replace me in subclasses! + */ + @Override // ResolverListCommunicator + public Intent getReplacementIntent(ActivityInfo aInfo, Intent defIntent) { + return defIntent; + } + + @Override // ResolverListCommunicator + public final void onPostListReady(ResolverListAdapter listAdapter, boolean doPostProcessing, + boolean rebuildCompleted) { + if (isAutolaunching()) { + return; + } + if (mIsIntentPicker) { + ((ResolverMultiProfilePagerAdapter) mMultiProfilePagerAdapter) + .setUseLayoutWithDefault(useLayoutWithDefault()); + } + if (mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(listAdapter)) { + mMultiProfilePagerAdapter.showEmptyResolverListEmptyState(listAdapter); + } else { + mMultiProfilePagerAdapter.showListView(listAdapter); + } + // showEmptyResolverListEmptyState can mark the tab as loaded, + // which is a precondition for auto launching + if (rebuildCompleted && maybeAutolaunchActivity()) { + return; + } + if (doPostProcessing) { + maybeCreateHeader(listAdapter); + resetButtonBar(); + onListRebuilt(listAdapter, rebuildCompleted); + } + } + + protected void onListRebuilt(ResolverListAdapter listAdapter, boolean rebuildCompleted) { + final ItemClickListener listener = new ItemClickListener(); + setupAdapterListView((ListView) mMultiProfilePagerAdapter.getActiveAdapterView(), listener); + if (shouldShowTabs() && mIsIntentPicker) { + final ResolverDrawerLayout rdl = findViewById(com.android.internal.R.id.contentPanel); + if (rdl != null) { + rdl.setMaxCollapsedHeight(getResources() + .getDimensionPixelSize(useLayoutWithDefault() + ? R.dimen.resolver_max_collapsed_height_with_default_with_tabs + : R.dimen.resolver_max_collapsed_height_with_tabs)); + } + } + } + + protected boolean onTargetSelected(TargetInfo target, boolean always) { + final ResolveInfo ri = target.getResolveInfo(); + final Intent intent = target != null ? target.getResolvedIntent() : null; + + if (intent != null && (mSupportsAlwaysUseOption + || mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem()) + && mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredResolveList() != null) { + // Build a reasonable intent filter, based on what matched. + IntentFilter filter = new IntentFilter(); + Intent filterIntent; + + if (intent.getSelector() != null) { + filterIntent = intent.getSelector(); + } else { + filterIntent = intent; + } + + String action = filterIntent.getAction(); + if (action != null) { + filter.addAction(action); + } + Set<String> categories = filterIntent.getCategories(); + if (categories != null) { + for (String cat : categories) { + filter.addCategory(cat); + } + } + filter.addCategory(Intent.CATEGORY_DEFAULT); + + int cat = ri.match & IntentFilter.MATCH_CATEGORY_MASK; + Uri data = filterIntent.getData(); + if (cat == IntentFilter.MATCH_CATEGORY_TYPE) { + String mimeType = filterIntent.resolveType(this); + if (mimeType != null) { + try { + filter.addDataType(mimeType); + } catch (IntentFilter.MalformedMimeTypeException e) { + Log.w("ResolverActivity", e); + filter = null; + } + } + } + if (data != null && data.getScheme() != null) { + // We need the data specification if there was no type, + // OR if the scheme is not one of our magical "file:" + // or "content:" schemes (see IntentFilter for the reason). + if (cat != IntentFilter.MATCH_CATEGORY_TYPE + || (!"file".equals(data.getScheme()) + && !"content".equals(data.getScheme()))) { + filter.addDataScheme(data.getScheme()); + + // Look through the resolved filter to determine which part + // of it matched the original Intent. + Iterator<PatternMatcher> pIt = ri.filter.schemeSpecificPartsIterator(); + if (pIt != null) { + String ssp = data.getSchemeSpecificPart(); + while (ssp != null && pIt.hasNext()) { + PatternMatcher p = pIt.next(); + if (p.match(ssp)) { + filter.addDataSchemeSpecificPart(p.getPath(), p.getType()); + break; + } + } + } + Iterator<IntentFilter.AuthorityEntry> aIt = ri.filter.authoritiesIterator(); + if (aIt != null) { + while (aIt.hasNext()) { + IntentFilter.AuthorityEntry a = aIt.next(); + if (a.match(data) >= 0) { + int port = a.getPort(); + filter.addDataAuthority(a.getHost(), + port >= 0 ? Integer.toString(port) : null); + break; + } + } + } + pIt = ri.filter.pathsIterator(); + if (pIt != null) { + String path = data.getPath(); + while (path != null && pIt.hasNext()) { + PatternMatcher p = pIt.next(); + if (p.match(path)) { + filter.addDataPath(p.getPath(), p.getType()); + break; + } + } + } + } + } + + if (filter != null) { + final int N = mMultiProfilePagerAdapter.getActiveListAdapter() + .getUnfilteredResolveList().size(); + ComponentName[] set; + // If we don't add back in the component for forwarding the intent to a managed + // profile, the preferred activity may not be updated correctly (as the set of + // components we tell it we knew about will have changed). + final boolean needToAddBackProfileForwardingComponent = + mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null; + if (!needToAddBackProfileForwardingComponent) { + set = new ComponentName[N]; + } else { + set = new ComponentName[N + 1]; + } + + int bestMatch = 0; + for (int i=0; i<N; i++) { + ResolveInfo r = mMultiProfilePagerAdapter.getActiveListAdapter() + .getUnfilteredResolveList().get(i).getResolveInfoAt(0); + set[i] = new ComponentName(r.activityInfo.packageName, + r.activityInfo.name); + if (r.match > bestMatch) bestMatch = r.match; + } + + if (needToAddBackProfileForwardingComponent) { + set[N] = mMultiProfilePagerAdapter.getActiveListAdapter() + .getOtherProfile().getResolvedComponentName(); + final int otherProfileMatch = mMultiProfilePagerAdapter.getActiveListAdapter() + .getOtherProfile().getResolveInfo().match; + if (otherProfileMatch > bestMatch) bestMatch = otherProfileMatch; + } + + if (always) { + final int userId = getUserId(); + final PackageManager pm = getPackageManager(); + + // Set the preferred Activity + pm.addUniquePreferredActivity(filter, bestMatch, set, intent.getComponent()); + + if (ri.handleAllWebDataURI) { + // Set default Browser if needed + final String packageName = pm.getDefaultBrowserPackageNameAsUser(userId); + if (TextUtils.isEmpty(packageName)) { + pm.setDefaultBrowserPackageNameAsUser(ri.activityInfo.packageName, userId); + } + } + } else { + try { + mMultiProfilePagerAdapter.getActiveListAdapter() + .mResolverListController.setLastChosen(intent, filter, bestMatch); + } catch (RemoteException re) { + Log.d(TAG, "Error calling setLastChosenActivity\n" + re); + } + } + } + } + + if (target != null) { + safelyStartActivity(target); + + // Rely on the ActivityManager to pop up a dialog regarding app suspension + // and return false + if (target.isSuspended()) { + return false; + } + } + + return true; + } + + @VisibleForTesting + public void safelyStartActivity(TargetInfo cti) { + // We're dispatching intents that might be coming from legacy apps, so + // don't kill ourselves. + StrictMode.disableDeathOnFileUriExposure(); + try { + UserHandle currentUserHandle = mMultiProfilePagerAdapter.getCurrentUserHandle(); + safelyStartActivityInternal(cti, currentUserHandle, null); + } finally { + StrictMode.enableDeathOnFileUriExposure(); + } + } + + /** + * Start activity as a fixed user handle. + * @param cti TargetInfo to be launched. + * @param user User to launch this activity as. + */ + @VisibleForTesting + public void safelyStartActivityAsUser(TargetInfo cti, UserHandle user) { + safelyStartActivityAsUser(cti, user, null); + } + + protected 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(); + } + } + + private 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 " + mLaunchedFromUid + + " package " + getLaunchedFromPackage() + ", while running in " + + ActivityThread.currentProcessName(), e); + } + } + + private void maybeLogCrossProfileTargetLaunch(TargetInfo cti, UserHandle currentUserHandle) { + if (!hasWorkProfile() || currentUserHandle.equals(getUser())) { + return; + } + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_CROSS_PROFILE_TARGET_OPENED) + .setBoolean(currentUserHandle.equals(getPersonalProfileUserHandle())) + .setStrings(getMetricsCategory(), + cti instanceof ChooserTargetInfo ? "direct_share" : "other_target") + .write(); + } + + + public void onActivityStarted(TargetInfo cti) { + // Do nothing + } + + @Override // ResolverListCommunicator + public boolean shouldGetActivityMetadata() { + return false; + } + + public boolean shouldAutoLaunchSingleChoice(TargetInfo target) { + return !target.isSuspended(); + } + + void showTargetDetails(ResolveInfo ri) { + Intent in = new Intent().setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", ri.activityInfo.packageName, null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); + startActivityAsUser(in, mMultiProfilePagerAdapter.getCurrentUserHandle()); + } + + @VisibleForTesting + protected ResolverListAdapter createResolverListAdapter(Context context, + List<Intent> payloadIntents, Intent[] initialIntents, List<ResolveInfo> rList, + boolean filterLastUsed, UserHandle userHandle) { + Intent startIntent = getIntent(); + boolean isAudioCaptureDevice = + startIntent.getBooleanExtra(EXTRA_IS_AUDIO_CAPTURE_DEVICE, false); + return new ResolverListAdapter(context, payloadIntents, initialIntents, rList, + filterLastUsed, createListController(userHandle), this, + isAudioCaptureDevice); + } + + @VisibleForTesting + protected ResolverListController createListController(UserHandle userHandle) { + return new ResolverListController( + this, + mPm, + getTargetIntent(), + getReferrerPackageName(), + mLaunchedFromUid, + userHandle); + } + + /** + * Sets up the content view. + * @return <code>true</code> if the activity is finishing and creation should halt. + */ + private boolean configureContentView() { + if (mMultiProfilePagerAdapter.getActiveListAdapter() == null) { + throw new IllegalStateException("mMultiProfilePagerAdapter.getCurrentListAdapter() " + + "cannot be null."); + } + Trace.beginSection("configureContentView"); + // We partially rebuild the inactive adapter to determine if we should auto launch + // isTabLoaded will be true here if the empty state screen is shown instead of the list. + boolean rebuildCompleted = mMultiProfilePagerAdapter.rebuildActiveTab(true) + || mMultiProfilePagerAdapter.getActiveListAdapter().isTabLoaded(); + if (shouldShowTabs()) { + boolean rebuildInactiveCompleted = mMultiProfilePagerAdapter.rebuildInactiveTab(false) + || mMultiProfilePagerAdapter.getInactiveListAdapter().isTabLoaded(); + rebuildCompleted = rebuildCompleted && rebuildInactiveCompleted; + } + + if (shouldUseMiniResolver()) { + configureMiniResolverContent(); + Trace.endSection(); + return false; + } + + if (useLayoutWithDefault()) { + mLayoutId = R.layout.resolver_list_with_default; + } else { + mLayoutId = getLayoutResource(); + } + setContentView(mLayoutId); + mMultiProfilePagerAdapter.setupViewPager(findViewById(com.android.internal.R.id.profile_pager)); + boolean result = postRebuildList(rebuildCompleted); + Trace.endSection(); + return result; + } + + /** + * Mini resolver is shown when the user is choosing between browser[s] in this profile and a + * single app in the other profile (see shouldUseMiniResolver()). It shows the single app icon + * and asks the user if they'd like to open that cross-profile app or use the in-profile + * browser. + */ + private void configureMiniResolverContent() { + mLayoutId = R.layout.miniresolver; + setContentView(mLayoutId); + + DisplayResolveInfo sameProfileResolveInfo = + mMultiProfilePagerAdapter.getActiveListAdapter().mDisplayList.get(0); + boolean inWorkProfile = getCurrentProfile() == PROFILE_WORK; + + ResolverListAdapter inactiveAdapter = mMultiProfilePagerAdapter.getInactiveListAdapter(); + DisplayResolveInfo otherProfileResolveInfo = inactiveAdapter.mDisplayList.get(0); + + // Load the icon asynchronously + ImageView icon = findViewById(com.android.internal.R.id.icon); + ResolverListAdapter.LoadIconTask iconTask = inactiveAdapter.new LoadIconTask( + otherProfileResolveInfo, new ResolverListAdapter.ViewHolder(icon)); + iconTask.execute(); + + ((TextView) findViewById(com.android.internal.R.id.open_cross_profile)).setText( + getResources().getString( + inWorkProfile ? R.string.miniresolver_open_in_personal + : R.string.miniresolver_open_in_work, + otherProfileResolveInfo.getDisplayLabel())); + ((Button) findViewById(com.android.internal.R.id.use_same_profile_browser)).setText( + inWorkProfile ? R.string.miniresolver_use_work_browser + : R.string.miniresolver_use_personal_browser); + + findViewById(com.android.internal.R.id.use_same_profile_browser).setOnClickListener( + v -> { + safelyStartActivity(sameProfileResolveInfo); + finish(); + }); + + findViewById(com.android.internal.R.id.button_open).setOnClickListener(v -> { + Intent intent = otherProfileResolveInfo.getResolvedIntent(); + safelyStartActivityAsUser(otherProfileResolveInfo, + inactiveAdapter.mResolverListController.getUserHandle()); + finish(); + }); + } + + /** + * Mini resolver should be used when all of the following are true: + * 1. This is the intent picker (ResolverActivity). + * 2. This profile only has web browser matches. + * 3. The other profile has a single non-browser match. + */ + private boolean shouldUseMiniResolver() { + if (!mIsIntentPicker) { + return false; + } + if (mMultiProfilePagerAdapter.getActiveListAdapter() == null + || mMultiProfilePagerAdapter.getInactiveListAdapter() == null) { + return false; + } + List<DisplayResolveInfo> sameProfileList = + mMultiProfilePagerAdapter.getActiveListAdapter().mDisplayList; + List<DisplayResolveInfo> otherProfileList = + mMultiProfilePagerAdapter.getInactiveListAdapter().mDisplayList; + + if (sameProfileList.isEmpty()) { + Log.d(TAG, "No targets in the current profile"); + return false; + } + + if (otherProfileList.size() != 1) { + Log.d(TAG, "Found " + otherProfileList.size() + " resolvers in the other profile"); + return false; + } + + if (otherProfileList.get(0).getResolveInfo().handleAllWebDataURI) { + Log.d(TAG, "Other profile is a web browser"); + return false; + } + + for (DisplayResolveInfo info : sameProfileList) { + if (!info.getResolveInfo().handleAllWebDataURI) { + Log.d(TAG, "Non-browser found in this profile"); + return false; + } + } + + return true; + } + + /** + * 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); + } + + /** + * 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() + && (maybeAutolaunchIfNoAppsOnInactiveTab() + || maybeAutolaunchIfCrossProfileSupported())) { + return true; + } + return false; + } + + private boolean maybeAutolaunchIfSingleTarget() { + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + if (count != 1) { + return false; + } + + if (mMultiProfilePagerAdapter.getActiveListAdapter().getOtherProfile() != null) { + return false; + } + + // Only one target, so we're a candidate to auto-launch! + final TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(0, false); + if (shouldAutoLaunchSingleChoice(target)) { + safelyStartActivity(target); + finish(); + return true; + } + return false; + } + + private boolean maybeAutolaunchIfNoAppsOnInactiveTab() { + int count = mMultiProfilePagerAdapter.getActiveListAdapter().getUnfilteredCount(); + if (count != 1) { + return false; + } + ResolverListAdapter inactiveListAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); + if (inactiveListAdapter.getUnfilteredCount() != 0) { + return false; + } + TargetInfo target = mMultiProfilePagerAdapter.getActiveListAdapter() + .targetInfoForPosition(0, false); + safelyStartActivity(target); + finish(); + return true; + } + + /** + * 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() { + ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); + int count = activeListAdapter.getUnfilteredCount(); + if (count != 1) { + return false; + } + ResolverListAdapter inactiveListAdapter = + mMultiProfilePagerAdapter.getInactiveListAdapter(); + if (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 (!canAppInteractCrossProfiles(packageName)) { + return false; + } + + DevicePolicyEventLogger + .createEvent(DevicePolicyEnums.RESOLVER_AUTOLAUNCH_CROSS_PROFILE_TARGET) + .setBoolean(activeListAdapter.getUserHandle() + .equals(getPersonalProfileUserHandle())) + .setStrings(getMetricsCategory()) + .write(); + safelyStartActivity(activeProfileTarget); + finish(); + return true; + } + + /** + * 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> + * + */ + 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) { + return false; + } + + int packageUid = applicationInfo.uid; + + 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; + } + + private boolean isAutolaunching() { + return !mRegistered && isFinishing(); + } + + 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(); + }); + + viewPager.setVisibility(View.VISIBLE); + tabHost.setCurrentTab(mMultiProfilePagerAdapter.getCurrentPage()); + mMultiProfilePagerAdapter.setOnProfileSelectedListener( + new AbstractMultiProfilePagerAdapter.OnProfileSelectedListener() { + @Override + public void onProfileSelected(int index) { + tabHost.setCurrentTab(index); + resetButtonBar(); + resetCheckedItem(); + } + + @Override + public void onProfilePageStateChanged(int state) { + onHorizontalSwipeStateChanged(state); + } + }); + mMultiProfilePagerAdapter.setOnSwitchOnWorkSelectedListener( + () -> { + final View workTab = tabHost.getTabWidget().getChildAt(1); + workTab.setFocusable(true); + workTab.setFocusableInTouchMode(true); + workTab.requestFocus(); + }); + } + + private String getPersonalTabLabel() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_PERSONAL_TAB, () -> getString(R.string.resolver_personal_tab)); + } + + private String getWorkTabLabel() { + return getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_WORK_TAB, () -> getString(R.string.resolver_work_tab)); + } + + void onHorizontalSwipeStateChanged(int state) {} + + private void maybeHideDivider() { + if (!mIsIntentPicker) { + return; + } + final View divider = findViewById(com.android.internal.R.id.divider); + if (divider == null) { + return; + } + divider.setVisibility(View.GONE); + } + + /** + * Callback called when user changes the profile tab. + * <p>This method is intended to be overridden by subclasses. + */ + protected void onProfileTabSelected() { } + + 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); + } + + private void setupViewVisibilities() { + ResolverListAdapter activeListAdapter = mMultiProfilePagerAdapter.getActiveListAdapter(); + if (!mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter)) { + addUseDifferentAppLabelIfNecessary(activeListAdapter); + } + } + + /** + * 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 (shouldShowTabs()) { + textView.setGravity(Gravity.CENTER); + } + stub.addView(textView); + } + } + + private void setupAdapterListView(ListView listView, ItemClickListener listener) { + listView.setOnItemClickListener(listener); + listView.setOnItemLongClickListener(listener); + + if (mSupportsAlwaysUseOption) { + listView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE); + } + } + + /** + * Configure the area above the app selection list (title, content preview, etc). + */ + private void maybeCreateHeader(ResolverListAdapter listAdapter) { + if (mHeaderCreatorUser != null + && !listAdapter.getUserHandle().equals(mHeaderCreatorUser)) { + return; + } + if (!shouldShowTabs() + && 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); + + 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(); + } + + 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"); + return; + } + ResolverListAdapter activeListAdapter = + mMultiProfilePagerAdapter.getActiveListAdapter(); + View buttonBarDivider = findViewById(com.android.internal.R.id.resolver_button_bar_divider); + if (!useLayoutWithDefault()) { + int inset = mSystemWindowInsets != null ? mSystemWindowInsets.bottom : 0; + buttonLayout.setPadding(buttonLayout.getPaddingLeft(), buttonLayout.getPaddingTop(), + buttonLayout.getPaddingRight(), getResources().getDimensionPixelSize( + R.dimen.resolver_button_bar_spacing) + inset); + } + if (activeListAdapter.isTabLoaded() + && mMultiProfilePagerAdapter.shouldShowEmptyStateScreen(activeListAdapter) + && !useLayoutWithDefault()) { + buttonLayout.setVisibility(View.INVISIBLE); + if (buttonBarDivider != null) { + buttonBarDivider.setVisibility(View.INVISIBLE); + } + setButtonBarIgnoreOffset(/* ignoreOffset */ false); + return; + } + if (buttonBarDivider != null) { + buttonBarDivider.setVisibility(View.VISIBLE); + } + buttonLayout.setVisibility(View.VISIBLE); + setButtonBarIgnoreOffset(/* ignoreOffset */ true); + + mOnceButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_once); + mAlwaysButton = (Button) buttonLayout.findViewById(com.android.internal.R.id.button_always); + + resetAlwaysOrOnceButtonBar(); + } + + /** + * Updates the button bar container {@code ignoreOffset} layout param. + * <p>Setting this to {@code true} means that the button bar will be glued to the bottom of + * the screen. + */ + private void setButtonBarIgnoreOffset(boolean ignoreOffset) { + View buttonBarContainer = findViewById(com.android.internal.R.id.button_bar_container); + if (buttonBarContainer != null) { + ResolverDrawerLayout.LayoutParams layoutParams = + (ResolverDrawerLayout.LayoutParams) buttonBarContainer.getLayoutParams(); + layoutParams.ignoreOffset = ignoreOffset; + buttonBarContainer.setLayoutParams(layoutParams); + } + } + + private void resetAlwaysOrOnceButtonBar() { + // Disable both buttons initially + setAlwaysButtonEnabled(false, ListView.INVALID_POSITION, false); + mOnceButton.setEnabled(false); + + int filteredPosition = mMultiProfilePagerAdapter.getActiveListAdapter() + .getFilteredPosition(); + if (useLayoutWithDefault() && filteredPosition != ListView.INVALID_POSITION) { + setAlwaysButtonEnabled(true, filteredPosition, false); + mOnceButton.setEnabled(true); + // Focus the button if we already have the default option + mOnceButton.requestFocus(); + return; + } + + // When the items load in, if an item was already selected, enable the buttons + ListView currentAdapterView = (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); + if (currentAdapterView != null + && currentAdapterView.getCheckedItemPosition() != ListView.INVALID_POSITION) { + setAlwaysButtonEnabled(true, currentAdapterView.getCheckedItemPosition(), true); + mOnceButton.setEnabled(true); + } + } + + @Override // ResolverListCommunicator + public 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 currentUserAdapterHasFilteredItem; + if (mMultiProfilePagerAdapter.getCurrentUserHandle().getIdentifier() + == UserHandle.myUserId()) { + currentUserAdapterHasFilteredItem = + mMultiProfilePagerAdapter.getActiveListAdapter().hasFilteredItem(); + } else { + currentUserAdapterHasFilteredItem = + mMultiProfilePagerAdapter.getInactiveListAdapter().hasFilteredItem(); + } + return mSupportsAlwaysUseOption && currentUserAdapterHasFilteredItem; + } + + /** + * 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 void setRetainInOnStop(boolean retainInOnStop) { + mRetainInOnStop = retainInOnStop; + } + + /** + * Check a simple match for the component of two ResolveInfos. + */ + @Override // ResolverListCommunicator + public 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); + } + + protected String getMetricsCategory() { + return METRICS_CATEGORY_RESOLVER; + } + + @Override // ResolverListCommunicator + public void onHandlePackagesChanged(ResolverListAdapter listAdapter) { + if (listAdapter == mMultiProfilePagerAdapter.getActiveListAdapter()) { + if (listAdapter.getUserHandle().equals(getWorkProfileUserHandle()) + && mMultiProfilePagerAdapter.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(); + } + } + + private boolean inactiveListAdapterHasItems() { + if (!shouldShowTabs()) { + return false; + } + return mMultiProfilePagerAdapter.getInactiveListAdapter().getCount() > 0; + } + + private BroadcastReceiver createWorkProfileStateReceiver() { + return new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (!TextUtils.equals(action, Intent.ACTION_USER_UNLOCKED) + && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE) + && !TextUtils.equals(action, Intent.ACTION_MANAGED_PROFILE_AVAILABLE)) { + return; + } + + int userId = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, -1); + + if (userId != getWorkProfileUserHandle().getIdentifier()) { + return; + } + + if (isWorkProfileEnabled()) { + if (mWorkProfileHasBeenEnabled) { + return; + } + + mWorkProfileHasBeenEnabled = true; + mMultiProfilePagerAdapter.markWorkProfileEnabledBroadcastReceived(); + } else { + // Must be an UNAVAILABLE broadcast, so we watch for the next availability + mWorkProfileHasBeenEnabled = false; + } + + if (mMultiProfilePagerAdapter.getCurrentUserHandle() + .equals(getWorkProfileUserHandle())) { + mMultiProfilePagerAdapter.rebuildActiveTab(true); + } else { + mMultiProfilePagerAdapter.clearInactiveProfileCache(); + } + } + }; + } + + @VisibleForTesting + public static final class ResolvedComponentInfo { + public final ComponentName name; + private final List<Intent> mIntents = new ArrayList<>(); + private final List<ResolveInfo> mResolveInfos = new ArrayList<>(); + private boolean mPinned; + private boolean mFixedAtTop; + + public ResolvedComponentInfo(ComponentName name, Intent intent, ResolveInfo info) { + this.name = name; + add(intent, info); + } + + public void add(Intent intent, ResolveInfo info) { + mIntents.add(intent); + mResolveInfos.add(info); + } + + public int getCount() { + return mIntents.size(); + } + + public Intent getIntentAt(int index) { + return index >= 0 ? mIntents.get(index) : null; + } + + public ResolveInfo getResolveInfoAt(int index) { + return index >= 0 ? mResolveInfos.get(index) : null; + } + + public int findIntent(Intent intent) { + for (int i = 0, N = mIntents.size(); i < N; i++) { + if (intent.equals(mIntents.get(i))) { + return i; + } + } + return -1; + } + + public int findResolveInfo(ResolveInfo info) { + for (int i = 0, N = mResolveInfos.size(); i < N; i++) { + if (info.equals(mResolveInfos.get(i))) { + return i; + } + } + return -1; + } + + public boolean isPinned() { + return mPinned; + } + + public void setPinned(boolean pinned) { + mPinned = pinned; + } + + public boolean isFixedAtTop() { + return mFixedAtTop; + } + + public void setFixedAtTop(boolean isFixedAtTop) { + mFixedAtTop = isFixedAtTop; + } + } + + class ItemClickListener implements AdapterView.OnItemClickListener, + AdapterView.OnItemLongClickListener { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + final ListView listView = parent instanceof ListView ? (ListView) parent : null; + if (listView != null) { + position -= listView.getHeaderViewsCount(); + } + if (position < 0) { + // Header views don't count. + return; + } + // If we're still loading, we can't yet enable the buttons. + if (mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(position, true) == null) { + return; + } + ListView currentAdapterView = + (ListView) mMultiProfilePagerAdapter.getActiveAdapterView(); + final int checkedPos = currentAdapterView.getCheckedItemPosition(); + final boolean hasValidSelection = checkedPos != ListView.INVALID_POSITION; + if (!useLayoutWithDefault() + && (!hasValidSelection || mLastSelected != checkedPos) + && mAlwaysButton != null) { + setAlwaysButtonEnabled(hasValidSelection, checkedPos, true); + mOnceButton.setEnabled(hasValidSelection); + if (hasValidSelection) { + currentAdapterView.smoothScrollToPosition(checkedPos); + mOnceButton.requestFocus(); + } + mLastSelected = checkedPos; + } else { + startSelected(position, false, true); + } + } + + @Override + public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { + final ListView listView = parent instanceof ListView ? (ListView) parent : null; + if (listView != null) { + position -= listView.getHeaderViewsCount(); + } + if (position < 0) { + // Header views don't count. + return false; + } + ResolveInfo ri = mMultiProfilePagerAdapter.getActiveListAdapter() + .resolveInfoForPosition(position, true); + showTargetDetails(ri); + return true; + } + + } + + static final boolean isSpecificUriMatch(int match) { + match = match&IntentFilter.MATCH_CATEGORY_MASK; + return match >= IntentFilter.MATCH_CATEGORY_HOST + && match <= IntentFilter.MATCH_CATEGORY_PATH; + } + + static class PickTargetOptionRequest extends PickOptionRequest { + public PickTargetOptionRequest(@Nullable Prompt prompt, Option[] options, + @Nullable Bundle extras) { + super(prompt, options, extras); + } + + @Override + public void onCancel() { + super.onCancel(); + final ResolverActivity ra = (ResolverActivity) getActivity(); + if (ra != null) { + ra.mPickOptionRequest = null; + ra.finish(); + } + } + + @Override + public void onPickOptionResult(boolean finished, Option[] selections, Bundle result) { + super.onPickOptionResult(finished, selections, result); + if (selections.length != 1) { + // TODO In a better world we would filter the UI presented here and let the + // user refine. Maybe later. + return; + } + + final ResolverActivity ra = (ResolverActivity) getActivity(); + if (ra != null) { + final TargetInfo ti = ra.mMultiProfilePagerAdapter.getActiveListAdapter() + .getItem(selections[0].getIndex()); + if (ra.onTargetSelected(ti, false)) { + ra.mPickOptionRequest = null; + ra.finish(); + } + } + } + } + + protected void maybeLogProfileChange() {} +} diff --git a/java/src/com/android/intentresolver/ResolverComparatorModel.java b/java/src/com/android/intentresolver/ResolverComparatorModel.java new file mode 100644 index 00000000..79160c84 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverComparatorModel.java @@ -0,0 +1,57 @@ +/* + * Copyright 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.pm.ResolveInfo; + +import java.util.Comparator; +import java.util.List; + +/** + * A ranking model for resolver targets, providing ordering and (optionally) numerical scoring. + * + * As required by the {@link Comparator} contract, objects returned by {@code getComparator()} must + * apply a total ordering on its inputs consistent across all calls to {@code Comparator#compare()}. + * Other query methods and ranking feedback should refer to that same ordering, so implementors are + * generally advised to "lock in" an immutable snapshot of their model data when this object is + * initialized (preferring to replace the entire {@code ResolverComparatorModel} instance if the + * backing data needs to be updated in the future). + */ +interface ResolverComparatorModel { + /** + * Get a {@code Comparator} that can be used to sort {@code ResolveInfo} targets according to + * the model ranking. + */ + Comparator<ResolveInfo> getComparator(); + + /** + * Get the numerical score, if any, that the model assigns to the component with the specified + * {@code name}. Scores range from zero to one, with one representing the highest possible + * likelihood that the user will select that component as the target. Implementations that don't + * assign numerical scores are <em>recommended</em> to return a value of 0 for all components. + */ + float getScore(ComponentName name); + + /** + * Notify the model that the user selected a target. (Models may log this information, use it as + * a feedback signal for their ranking, etc.) Because the data in this + * {@code ResolverComparatorModel} instance is immutable, clients will need to get an up-to-date + * instance in order to see any changes in the ranking that might result from this feedback. + */ + void notifyOnTargetSelected(ComponentName componentName); +} diff --git a/java/src/com/android/intentresolver/ResolverListAdapter.java b/java/src/com/android/intentresolver/ResolverListAdapter.java new file mode 100644 index 00000000..898d8c8e --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverListAdapter.java @@ -0,0 +1,1163 @@ +/* + * 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 static android.content.Context.ACTIVITY_SERVICE; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.ActivityManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.PermissionChecker; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.LabeledIntent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.ColorMatrix; +import android.graphics.ColorMatrixColorFilter; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.os.RemoteException; +import android.os.Trace; +import android.os.UserHandle; +import android.os.UserManager; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; + +import com.android.internal.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; + +public class ResolverListAdapter extends BaseAdapter { + private static final String TAG = "ResolverListAdapter"; + + private final List<Intent> mIntents; + private final Intent[] mInitialIntents; + private final List<ResolveInfo> mBaseResolveList; + private final PackageManager mPm; + protected final Context mContext; + private static ColorMatrixColorFilter sSuspendedMatrixColorFilter; + private final int mIconDpi; + protected ResolveInfo mLastChosen; + private DisplayResolveInfo mOtherProfile; + ResolverListController mResolverListController; + private int mPlaceholderCount; + + protected final LayoutInflater mInflater; + + // This one is the list that the Adapter will actually present. + List<DisplayResolveInfo> mDisplayList; + private List<ResolvedComponentInfo> mUnfilteredResolveList; + + private int mLastChosenPosition = -1; + private boolean mFilterLastUsed; + final ResolverListCommunicator mResolverListCommunicator; + private Runnable mPostListReadyRunnable; + private final boolean mIsAudioCaptureDevice; + private boolean mIsTabLoaded; + + public ResolverListAdapter(Context context, List<Intent> payloadIntents, + Intent[] initialIntents, List<ResolveInfo> rList, + boolean filterLastUsed, + ResolverListController resolverListController, + ResolverListCommunicator resolverListCommunicator, + boolean isAudioCaptureDevice) { + mContext = context; + mIntents = payloadIntents; + mInitialIntents = initialIntents; + mBaseResolveList = rList; + mInflater = LayoutInflater.from(context); + mPm = context.getPackageManager(); + mDisplayList = new ArrayList<>(); + mFilterLastUsed = filterLastUsed; + mResolverListController = resolverListController; + mResolverListCommunicator = resolverListCommunicator; + mIsAudioCaptureDevice = isAudioCaptureDevice; + final ActivityManager am = (ActivityManager) mContext.getSystemService(ACTIVITY_SERVICE); + mIconDpi = am.getLauncherLargeIconDensity(); + } + + public void handlePackagesChanged() { + mResolverListCommunicator.onHandlePackagesChanged(this); + } + + public void setPlaceholderCount(int count) { + mPlaceholderCount = count; + } + + public int getPlaceholderCount() { + return mPlaceholderCount; + } + + @Nullable + public DisplayResolveInfo getFilteredItem() { + if (mFilterLastUsed && mLastChosenPosition >= 0) { + // Not using getItem since it offsets to dodge this position for the list + return mDisplayList.get(mLastChosenPosition); + } + return null; + } + + public DisplayResolveInfo getOtherProfile() { + return mOtherProfile; + } + + public int getFilteredPosition() { + if (mFilterLastUsed && mLastChosenPosition >= 0) { + return mLastChosenPosition; + } + return AbsListView.INVALID_POSITION; + } + + public boolean hasFilteredItem() { + return mFilterLastUsed && mLastChosen != null; + } + + public float getScore(DisplayResolveInfo target) { + return mResolverListController.getScore(target); + } + + /** + * Returns the app share score of the given {@code componentName}. + */ + public float getScore(ComponentName componentName) { + return mResolverListController.getScore(componentName); + } + + public void updateModel(ComponentName componentName) { + mResolverListController.updateModel(componentName); + } + + public void updateChooserCounts(String packageName, String action) { + mResolverListController.updateChooserCounts( + packageName, getUserHandle().getIdentifier(), action); + } + + 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. + * + * 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. + * + * 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 + * to skip that step if we're only loading the inactive profile's resolved apps to know the + * number of targets. + * + * @return Whether the list building was completed synchronously. If not, we'll queue the + * {@code onPostListReady} callback first with {@code rebuildCompleted} false, and then again + * 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) { + Trace.beginSection("ResolverListAdapter#rebuildList"); + mDisplayList.clear(); + mIsTabLoaded = false; + mLastChosenPosition = -1; + + List<ResolvedComponentInfo> currentResolveList = getInitialRebuiltResolveList(); + + /* TODO: this seems like unnecessary extra complexity; why do we need to do this "primary" + * (i.e. "eligibility") filtering before evaluating the "other profile" special-treatment, + * but the "secondary" (i.e. "priority") filtering after? Are there in fact cases where the + * eligibility conditions will filter out a result that would've otherwise gotten the "other + * profile" treatment? Or, are there cases where the priority conditions *would* filter out + * a result, but we *want* that result to get the "other profile" treatment, so we only + * filter *after* evaluating the special-treatment conditions? If the answer to either is + * "no," then the filtering steps can be consolidated. (And that also makes the "unfiltered + * list" bookkeeping a little cleaner.) + */ + mUnfilteredResolveList = performPrimaryResolveListFiltering(currentResolveList); + + // So far we only support a single other profile at a time. + // The first one we see gets special treatment. + ResolvedComponentInfo otherProfileInfo = + getFirstNonCurrentUserResolvedComponentInfo(currentResolveList); + updateOtherProfileTreatment(otherProfileInfo); + if (otherProfileInfo != null) { + currentResolveList.remove(otherProfileInfo); + /* TODO: the previous line removed the "other profile info" item from + * mUnfilteredResolveList *ONLY IF* that variable is an alias for the same List instance + * as currentResolveList (i.e., if no items were filtered out as the result of the + * earlier "primary" filtering). It seems wrong for our behavior to depend on that. + * Should we: + * A. replicate the above removal to mUnfilteredResolveList (which is idempotent, so we + * don't even have to check whether they're aliases); or + * B. break the alias relationship by copying currentResolveList to a new + * mUnfilteredResolveList instance if necessary before removing otherProfileInfo? + * In other words: do we *want* otherProfileInfo in the "unfiltered" results? Either + * way, we'll need one of the changes suggested above. + */ + } + + // If no results have yet been filtered, mUnfilteredResolveList is an alias for the same + // List instance as currentResolveList. Then we need to make a copy to store as the + // mUnfilteredResolveList if we go on to filter any more items. Otherwise we've already + // copied the original unfiltered items to a separate List instance and can now filter + // the remainder in-place without any further bookkeeping. + boolean needsCopyOfUnfiltered = (mUnfilteredResolveList == currentResolveList); + List<ResolvedComponentInfo> originalList = performSecondaryResolveListFiltering( + currentResolveList, needsCopyOfUnfiltered); + if (originalList != null) { + // Only need the originalList value if there was a modification (otherwise it's null + // and shouldn't overwrite mUnfilteredResolveList). + mUnfilteredResolveList = originalList; + } + + boolean result = + finishRebuildingListWithFilteredResults(currentResolveList, doPostProcessing); + Trace.endSection(); + return result; + } + + /** + * Get the full (unfiltered) set of {@code ResolvedComponentInfo} records for all resolvers + * to be considered in a newly-rebuilt list. This list will be filtered and ranked before the + * rebuild is complete. + */ + List<ResolvedComponentInfo> getInitialRebuiltResolveList() { + if (mBaseResolveList != null) { + List<ResolvedComponentInfo> currentResolveList = new ArrayList<>(); + mResolverListController.addResolveListDedupe(currentResolveList, + mResolverListCommunicator.getTargetIntent(), + mBaseResolveList); + return currentResolveList; + } else { + return mResolverListController.getResolversForIntent( + /* shouldGetResolvedFilter= */ true, + mResolverListCommunicator.shouldGetActivityMetadata(), + mResolverListCommunicator.shouldGetOnlyDefaultActivities(), + mIntents); + } + } + + /** + * Remove ineligible activities from {@code currentResolveList} (if non-null), in-place. More + * broadly, filtering logic should apply in the "primary" stage if it should preclude items from + * receiving the "other profile" special-treatment described in {@code rebuildList()}. + * + * @return A copy of the original {@code currentResolveList}, if any items were removed, or a + * (possibly null) reference to the original list otherwise. (That is, this always returns a + * list of all the unfiltered items, but if no items were filtered, it's just an alias for the + * same list that was passed in). + */ + @Nullable + List<ResolvedComponentInfo> performPrimaryResolveListFiltering( + @Nullable List<ResolvedComponentInfo> currentResolveList) { + /* TODO: mBaseResolveList appears to be(?) some kind of configured mode. Why is it not + * subject to filterIneligibleActivities, even though all the other logic still applies + * (including "secondary" filtering)? (This also relates to the earlier question; do we + * believe there's an item that would be eligible for "other profile" special treatment, + * except we want to filter it out as ineligible... but only if we're not in + * "mBaseResolveList mode"? */ + if ((mBaseResolveList != null) || (currentResolveList == null)) { + return currentResolveList; + } + + List<ResolvedComponentInfo> originalList = + mResolverListController.filterIneligibleActivities(currentResolveList, true); + return (originalList == null) ? currentResolveList : originalList; + } + + /** + * Remove low-priority activities from {@code currentResolveList} (if non-null), in place. More + * broadly, filtering logic should apply in the "secondary" stage to prevent items from + * appearing in the rebuilt-list results, while still considering those items for the "other + * profile" special-treatment described in {@code rebuildList()}. + * + * @return the same (possibly null) List reference as {@code currentResolveList} if the list is + * unmodified as a result of filtering; or, if some item(s) were removed, then either a copy of + * the original {@code currentResolveList} (if {@code returnCopyOfOriginalListIfModified} is + * true), or null (otherwise). + */ + @Nullable + List<ResolvedComponentInfo> performSecondaryResolveListFiltering( + @Nullable List<ResolvedComponentInfo> currentResolveList, + boolean returnCopyOfOriginalListIfModified) { + if ((currentResolveList == null) || currentResolveList.isEmpty()) { + return currentResolveList; + } + return mResolverListController.filterLowPriority( + currentResolveList, returnCopyOfOriginalListIfModified); + } + + /** + * Update the special "other profile" UI treatment based on the components resolved for a + * newly-built list. + * + * @param otherProfileInfo the first {@code ResolvedComponentInfo} specifying a + * {@code targetUserId} other than {@code USER_CURRENT}, or null if no such component info was + * found in the process of rebuilding the list (or if any such candidates were already removed + * due to "primary filtering"). + */ + void updateOtherProfileTreatment(@Nullable ResolvedComponentInfo otherProfileInfo) { + mLastChosen = null; + + if (otherProfileInfo != null) { + mOtherProfile = makeOtherProfileDisplayResolveInfo( + mContext, otherProfileInfo, mPm, mResolverListCommunicator, mIconDpi); + } else { + mOtherProfile = null; + try { + mLastChosen = mResolverListController.getLastChosen(); + // TODO: does this also somehow need to update mLastChosenPosition? If so, maybe + // the current method should also take responsibility for re-initializing + // mLastChosenPosition, where it's currently done at the start of rebuildList()? + // (Why is this related to the presence of mOtherProfile in fhe first place?) + } catch (RemoteException re) { + Log.d(TAG, "Error calling getLastChosenActivity\n" + re); + } + } + } + + /** + * Prepare the appropriate placeholders to eventually display the final set of resolved + * components in a newly-rebuilt list, and spawn an asynchronous sorting task if necessary. + * This eventually results in a {@code onPostListReady} callback with {@code rebuildCompleted} + * true; if any asynchronous work is required, that will first be preceded by a separate + * occurrence of the callback with {@code rebuildCompleted} false (once there are placeholders + * set up to represent the pending asynchronous results). + * @return Whether we were able to do all the work to prepare the list for display + * synchronously; if false, there will eventually be two separate {@code onPostListReady} + * callbacks, first with placeholders to represent pending asynchronous results, then later when + * the results are ready for presentation. + */ + boolean finishRebuildingListWithFilteredResults( + @Nullable List<ResolvedComponentInfo> filteredResolveList, boolean doPostProcessing) { + if (filteredResolveList == null || filteredResolveList.size() < 2) { + // No asynchronous work to do. + setPlaceholderCount(0); + processSortedList(filteredResolveList, doPostProcessing); + return true; + } + + int placeholderCount = filteredResolveList.size(); + if (mResolverListCommunicator.useLayoutWithDefault()) { + --placeholderCount; + } + setPlaceholderCount(placeholderCount); + + // Send an "incomplete" list-ready while the async task is running. + postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ false); + createSortingTask(doPostProcessing).execute(filteredResolveList); + 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(); + } + } + }; + } + + protected void processSortedList(List<ResolvedComponentInfo> sortedComponents, + boolean doPostProcessing) { + final int n = sortedComponents != null ? sortedComponents.size() : 0; + Trace.beginSection("ResolverListAdapter#processSortedList:" + n); + if (n != 0) { + // First put the initial items at the top. + if (mInitialIntents != null) { + for (int i = 0; i < mInitialIntents.length; i++) { + Intent ii = mInitialIntents[i]; + if (ii == null) { + continue; + } + // Because of AIDL bug, resolveActivityInfo can't accept subclasses of Intent. + final Intent rii = (ii.getClass() == Intent.class) ? ii : new Intent(ii); + ActivityInfo ai = rii.resolveActivityInfo(mPm, 0); + if (ai == null) { + Log.w(TAG, "No activity found for " + ii); + continue; + } + ResolveInfo ri = new ResolveInfo(); + ri.activityInfo = ai; + UserManager userManager = + (UserManager) mContext.getSystemService(Context.USER_SERVICE); + if (ii instanceof LabeledIntent) { + LabeledIntent li = (LabeledIntent) ii; + ri.resolvePackageName = li.getSourcePackage(); + ri.labelRes = li.getLabelResource(); + ri.nonLocalizedLabel = li.getNonLocalizedLabel(); + ri.icon = li.getIconResource(); + ri.iconResourceId = ri.icon; + } + if (userManager.isManagedProfile()) { + ri.noResourceId = true; + ri.icon = 0; + } + + addResolveInfo(new DisplayResolveInfo(ii, ri, + ri.loadLabel(mPm), null, ii, makePresentationGetter(ri))); + } + } + + + for (ResolvedComponentInfo rci : sortedComponents) { + final ResolveInfo ri = rci.getResolveInfoAt(0); + if (ri != null) { + addResolveInfoWithAlternates(rci); + } + } + } + + mResolverListCommunicator.sendVoiceChoicesIfNeeded(); + postListReadyRunnable(doPostProcessing, /* rebuildCompleted */ true); + mIsTabLoaded = true; + Trace.endSection(); + } + + /** + * 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. + * @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; + } + }; + mContext.getMainThreadHandler().post(mPostListReadyRunnable); + } + } + + private void addResolveInfoWithAlternates(ResolvedComponentInfo rci) { + final int count = rci.getCount(); + final Intent intent = rci.getIntentAt(0); + final ResolveInfo add = rci.getResolveInfoAt(0); + final Intent replaceIntent = + mResolverListCommunicator.getReplacementIntent(add.activityInfo, intent); + final Intent defaultIntent = mResolverListCommunicator.getReplacementIntent( + add.activityInfo, mResolverListCommunicator.getTargetIntent()); + final DisplayResolveInfo + dri = new DisplayResolveInfo(intent, add, + replaceIntent != null ? replaceIntent : defaultIntent, makePresentationGetter(add)); + dri.setPinned(rci.isPinned()); + if (rci.isPinned()) { + Log.i(TAG, "Pinned item: " + rci.name); + } + addResolveInfo(dri); + if (replaceIntent == intent) { + // Only add alternates if we didn't get a specific replacement from + // the caller. If we have one it trumps potential alternates. + for (int i = 1, n = count; i < n; i++) { + final Intent altIntent = rci.getIntentAt(i); + dri.addAlternateSourceIntent(altIntent); + } + } + updateLastChosenPosition(add); + } + + private void updateLastChosenPosition(ResolveInfo info) { + // If another profile is present, ignore the last chosen entry. + if (mOtherProfile != null) { + mLastChosenPosition = -1; + return; + } + if (mLastChosen != null + && mLastChosen.activityInfo.packageName.equals(info.activityInfo.packageName) + && mLastChosen.activityInfo.name.equals(info.activityInfo.name)) { + mLastChosenPosition = mDisplayList.size() - 1; + } + } + + // We assume that at this point we've already filtered out the only intent for a different + // targetUserId which we're going to use. + private void addResolveInfo(DisplayResolveInfo dri) { + if (dri != null && dri.getResolveInfo() != null + && dri.getResolveInfo().targetUserId == UserHandle.USER_CURRENT) { + if (shouldAddResolveInfo(dri)) { + mDisplayList.add(dri); + Log.i(TAG, "Add DisplayResolveInfo component: " + dri.getResolvedComponentName() + + ", intent component: " + dri.getResolvedIntent().getComponent()); + } + } + } + + // Check whether {@code dri} should be added into mDisplayList. + protected boolean shouldAddResolveInfo(DisplayResolveInfo dri) { + // Checks if this info is already listed in display. + for (DisplayResolveInfo existingInfo : mDisplayList) { + if (mResolverListCommunicator + .resolveInfoMatch(dri.getResolveInfo(), existingInfo.getResolveInfo())) { + return false; + } + } + return true; + } + + @Nullable + public ResolveInfo resolveInfoForPosition(int position, boolean filtered) { + TargetInfo target = targetInfoForPosition(position, filtered); + if (target != null) { + return target.getResolveInfo(); + } + return null; + } + + @Nullable + public TargetInfo targetInfoForPosition(int position, boolean filtered) { + if (filtered) { + return getItem(position); + } + if (mDisplayList.size() > position) { + return mDisplayList.get(position); + } + return null; + } + + public int getCount() { + int totalSize = mDisplayList == null || mDisplayList.isEmpty() ? mPlaceholderCount : + mDisplayList.size(); + if (mFilterLastUsed && mLastChosenPosition >= 0) { + totalSize--; + } + return totalSize; + } + + public int getUnfilteredCount() { + return mDisplayList.size(); + } + + @Nullable + public TargetInfo getItem(int position) { + if (mFilterLastUsed && mLastChosenPosition >= 0 && position >= mLastChosenPosition) { + position++; + } + if (mDisplayList.size() > position) { + return mDisplayList.get(position); + } else { + return null; + } + } + + public long getItemId(int position) { + return position; + } + + public int getDisplayResolveInfoCount() { + return mDisplayList.size(); + } + + public DisplayResolveInfo getDisplayResolveInfo(int index) { + // Used to query services. We only query services for primary targets, not alternates. + return mDisplayList.get(index); + } + + public final View getView(int position, View convertView, ViewGroup parent) { + View view = convertView; + if (view == null) { + view = createView(parent); + } + onBindView(view, getItem(position), position); + return view; + } + + public final View createView(ViewGroup parent) { + final View view = onCreateView(parent); + final ViewHolder holder = new ViewHolder(view); + view.setTag(holder); + return view; + } + + View onCreateView(ViewGroup parent) { + return mInflater.inflate( + R.layout.resolve_list_item, parent, false); + } + + public final void bindView(int position, View view) { + onBindView(view, getItem(position), position); + } + + protected void onBindView(View view, TargetInfo info, int position) { + final ViewHolder holder = (ViewHolder) view.getTag(); + if (info == null) { + holder.icon.setImageDrawable( + mContext.getDrawable(R.drawable.resolver_icon_placeholder)); + return; + } + + if (info instanceof DisplayResolveInfo + && !((DisplayResolveInfo) info).hasDisplayLabel()) { + getLoadLabelTask((DisplayResolveInfo) info, holder).execute(); + } else { + holder.bindLabel(info.getDisplayLabel(), info.getExtendedInfo(), alwaysShowSubLabel()); + } + + if (info instanceof DisplayResolveInfo + && !((DisplayResolveInfo) info).hasDisplayIcon()) { + new LoadIconTask((DisplayResolveInfo) info, holder).execute(); + } else { + holder.bindIcon(info); + } + } + + protected LoadLabelTask getLoadLabelTask(DisplayResolveInfo info, ViewHolder holder) { + return new LoadLabelTask(info, holder); + } + + public void onDestroy() { + if (mPostListReadyRunnable != null) { + mContext.getMainThreadHandler().removeCallbacks(mPostListReadyRunnable); + mPostListReadyRunnable = null; + } + if (mResolverListController != null) { + mResolverListController.destroy(); + } + } + + private static ColorMatrixColorFilter getSuspendedColorMatrix() { + if (sSuspendedMatrixColorFilter == null) { + + int grayValue = 127; + float scale = 0.5f; // half bright + + ColorMatrix tempBrightnessMatrix = new ColorMatrix(); + float[] mat = tempBrightnessMatrix.getArray(); + mat[0] = scale; + mat[6] = scale; + mat[12] = scale; + mat[4] = grayValue; + mat[9] = grayValue; + mat[14] = grayValue; + + ColorMatrix matrix = new ColorMatrix(); + matrix.setSaturation(0.0f); + matrix.preConcat(tempBrightnessMatrix); + sSuspendedMatrixColorFilter = new ColorMatrixColorFilter(matrix); + } + return sSuspendedMatrixColorFilter; + } + + ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo ai) { + return new ActivityInfoPresentationGetter(mContext, mIconDpi, ai); + } + + ResolveInfoPresentationGetter makePresentationGetter(ResolveInfo ri) { + return new ResolveInfoPresentationGetter(mContext, mIconDpi, ri); + } + + Drawable loadIconForResolveInfo(ResolveInfo ri) { + // Load icons based on the current process. If in work profile icons should be badged. + return makePresentationGetter(ri).getIcon(getUserHandle()); + } + + void loadFilteredItemIconTaskAsync(@NonNull ImageView iconView) { + final DisplayResolveInfo iconInfo = getFilteredItem(); + if (iconView != null && iconInfo != null) { + new AsyncTask<Void, Void, Drawable>() { + @Override + protected Drawable doInBackground(Void... params) { + return loadIconForResolveInfo(iconInfo.getResolveInfo()); + } + + @Override + protected void onPostExecute(Drawable d) { + iconView.setImageDrawable(d); + } + }.execute(); + } + } + + @VisibleForTesting + public UserHandle getUserHandle() { + return mResolverListController.getUserHandle(); + } + + protected List<ResolvedComponentInfo> getResolversForUser(UserHandle userHandle) { + return mResolverListController.getResolversForIntentAsUser(true, + mResolverListCommunicator.shouldGetActivityMetadata(), + mResolverListCommunicator.shouldGetOnlyDefaultActivities(), + mIntents, userHandle); + } + + protected List<Intent> getIntents() { + return mIntents; + } + + protected boolean isTabLoaded() { + return mIsTabLoaded; + } + + protected void markTabLoaded() { + mIsTabLoaded = true; + } + + protected boolean alwaysShowSubLabel() { + return false; + } + + /** + * Find the first element in a list of {@code ResolvedComponentInfo} objects whose + * {@code ResolveInfo} specifies a {@code targetUserId} other than the current user. + * @return the first ResolvedComponentInfo targeting a non-current user, or null if there are + * none (or if the list itself is null). + */ + private static ResolvedComponentInfo getFirstNonCurrentUserResolvedComponentInfo( + @Nullable List<ResolvedComponentInfo> resolveList) { + if (resolveList == null) { + return null; + } + + for (ResolvedComponentInfo info : resolveList) { + ResolveInfo resolveInfo = info.getResolveInfoAt(0); + if (resolveInfo.targetUserId != UserHandle.USER_CURRENT) { + return info; + } + } + return null; + } + + /** + * Set up a {@code DisplayResolveInfo} to provide "special treatment" for the first "other" + * profile in the resolve list (i.e., the first non-current profile to appear as the target user + * of an element in the resolve list). + */ + private static DisplayResolveInfo makeOtherProfileDisplayResolveInfo( + Context context, + ResolvedComponentInfo resolvedComponentInfo, + PackageManager pm, + ResolverListCommunicator resolverListCommunicator, + int iconDpi) { + ResolveInfo resolveInfo = resolvedComponentInfo.getResolveInfoAt(0); + + Intent pOrigIntent = resolverListCommunicator.getReplacementIntent( + resolveInfo.activityInfo, + resolvedComponentInfo.getIntentAt(0)); + Intent replacementIntent = resolverListCommunicator.getReplacementIntent( + resolveInfo.activityInfo, + resolverListCommunicator.getTargetIntent()); + + ResolveInfoPresentationGetter presentationGetter = + new ResolveInfoPresentationGetter(context, iconDpi, resolveInfo); + + return new DisplayResolveInfo( + resolvedComponentInfo.getIntentAt(0), + resolveInfo, + resolveInfo.loadLabel(pm), + resolveInfo.loadLabel(pm), + pOrigIntent != null ? pOrigIntent : replacementIntent, + presentationGetter); + } + + /** + * Necessary methods to communicate between {@link ResolverListAdapter} + * and {@link ResolverActivity}. + */ + interface ResolverListCommunicator { + + boolean resolveInfoMatch(ResolveInfo lhs, ResolveInfo rhs); + + Intent getReplacementIntent(ActivityInfo activityInfo, Intent defIntent); + + void onPostListReady(ResolverListAdapter listAdapter, boolean updateUi, + boolean rebuildCompleted); + + void sendVoiceChoicesIfNeeded(); + + void updateProfileViewButton(); + + boolean useLayoutWithDefault(); + + boolean shouldGetActivityMetadata(); + + /** + * @return true to filter only apps that can handle + * {@link android.content.Intent#CATEGORY_DEFAULT} intents + */ + default boolean shouldGetOnlyDefaultActivities() { return true; }; + + Intent getTargetIntent(); + + void onHandlePackagesChanged(ResolverListAdapter listAdapter); + } + + /** + * A view holder. + */ + @VisibleForTesting + public static class ViewHolder { + public View itemView; + public Drawable defaultItemViewBackground; + + public TextView text; + public TextView text2; + public ImageView icon; + + @VisibleForTesting + public ViewHolder(View view) { + itemView = view; + defaultItemViewBackground = view.getBackground(); + text = (TextView) view.findViewById(com.android.internal.R.id.text1); + text2 = (TextView) view.findViewById(com.android.internal.R.id.text2); + icon = (ImageView) view.findViewById(com.android.internal.R.id.icon); + } + + public void bindLabel(CharSequence label, CharSequence subLabel, boolean showSubLabel) { + text.setText(label); + + if (TextUtils.equals(label, subLabel)) { + subLabel = null; + } + + text2.setText(subLabel); + if (showSubLabel || subLabel != null) { + text2.setVisibility(View.VISIBLE); + } else { + text2.setVisibility(View.GONE); + } + + itemView.setContentDescription(null); + } + + public void updateContentDescription(String description) { + itemView.setContentDescription(description); + } + + public void bindIcon(TargetInfo info) { + icon.setImageDrawable(info.getDisplayIcon(itemView.getContext())); + if (info.isSuspended()) { + icon.setColorFilter(getSuspendedColorMatrix()); + } else { + icon.setColorFilter(null); + } + } + } + + protected class LoadLabelTask extends AsyncTask<Void, Void, CharSequence[]> { + private final DisplayResolveInfo mDisplayResolveInfo; + private final ViewHolder mHolder; + + protected LoadLabelTask(DisplayResolveInfo dri, ViewHolder holder) { + mDisplayResolveInfo = dri; + mHolder = holder; + } + + @Override + protected CharSequence[] doInBackground(Void... voids) { + ResolveInfoPresentationGetter pg = + makePresentationGetter(mDisplayResolveInfo.getResolveInfo()); + + if (mIsAudioCaptureDevice) { + // This is an audio capture device, so check record permissions + ActivityInfo activityInfo = mDisplayResolveInfo.getResolveInfo().activityInfo; + String packageName = activityInfo.packageName; + + int uid = activityInfo.applicationInfo.uid; + boolean hasRecordPermission = + PermissionChecker.checkPermissionForPreflight( + mContext, + 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[] { + pg.getLabel(), + mContext.getString(R.string.usb_device_resolve_prompt_warn) + }; + } + } + + return new CharSequence[] { + pg.getLabel(), + pg.getSubLabel() + }; + } + + @Override + protected void onPostExecute(CharSequence[] result) { + mDisplayResolveInfo.setDisplayLabel(result[0]); + mDisplayResolveInfo.setExtendedInfo(result[1]); + mHolder.bindLabel(result[0], result[1], alwaysShowSubLabel()); + } + } + + class LoadIconTask extends AsyncTask<Void, Void, Drawable> { + protected final DisplayResolveInfo mDisplayResolveInfo; + private final ResolveInfo mResolveInfo; + private ViewHolder mHolder; + + LoadIconTask(DisplayResolveInfo dri, ViewHolder holder) { + mDisplayResolveInfo = dri; + mResolveInfo = dri.getResolveInfo(); + mHolder = holder; + } + + @Override + protected Drawable doInBackground(Void... params) { + return loadIconForResolveInfo(mResolveInfo); + } + + @Override + protected void onPostExecute(Drawable d) { + if (getOtherProfile() == mDisplayResolveInfo) { + mResolverListCommunicator.updateProfileViewButton(); + } else if (!mDisplayResolveInfo.hasDisplayIcon()) { + mDisplayResolveInfo.setDisplayIcon(d); + mHolder.bindIcon(mDisplayResolveInfo); + // Notify in case view is already bound to resolve the race conditions on + // low end devices + notifyDataSetChanged(); + } + } + + public void setViewHolder(ViewHolder holder) { + mHolder = holder; + mHolder.bindIcon(mDisplayResolveInfo); + } + } + + /** + * Loads the icon and label for the provided ResolveInfo. + */ + @VisibleForTesting + public static class ResolveInfoPresentationGetter extends ActivityInfoPresentationGetter { + private final ResolveInfo mRi; + public ResolveInfoPresentationGetter(Context ctx, int iconDpi, ResolveInfo ri) { + super(ctx, iconDpi, ri.activityInfo); + mRi = ri; + } + + @Override + Drawable getIconSubstituteInternal() { + Drawable dr = null; + try { + // Do not use ResolveInfo#getIconResource() as it defaults to the app + if (mRi.resolvePackageName != null && mRi.icon != 0) { + dr = loadIconFromResource( + mPm.getResourcesForApplication(mRi.resolvePackageName), mRi.icon); + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but " + + "couldn't find resources for package", e); + } + + // Fall back to ActivityInfo if no icon is found via ResolveInfo + if (dr == null) dr = super.getIconSubstituteInternal(); + + return dr; + } + + @Override + String getAppSubLabelInternal() { + // Will default to app name if no intent filter or activity label set, make sure to + // check if subLabel matches label before final display + return mRi.loadLabel(mPm).toString(); + } + + @Override + String getAppLabelForSubstitutePermission() { + // Will default to app name if no activity label set + return mRi.getComponentInfo().loadLabel(mPm).toString(); + } + } + + /** + * Loads the icon and label for the provided ActivityInfo. + */ + @VisibleForTesting + public static class ActivityInfoPresentationGetter extends + TargetPresentationGetter { + private final ActivityInfo mActivityInfo; + public ActivityInfoPresentationGetter(Context ctx, int iconDpi, + ActivityInfo activityInfo) { + super(ctx, iconDpi, activityInfo.applicationInfo); + mActivityInfo = activityInfo; + } + + @Override + Drawable getIconSubstituteInternal() { + Drawable dr = null; + try { + // Do not use ActivityInfo#getIconResource() as it defaults to the app + if (mActivityInfo.icon != 0) { + dr = loadIconFromResource( + mPm.getResourcesForApplication(mActivityInfo.applicationInfo), + mActivityInfo.icon); + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON permission granted but " + + "couldn't find resources for package", e); + } + + return dr; + } + + @Override + String getAppSubLabelInternal() { + // Will default to app name if no activity label set, make sure to check if subLabel + // matches label before final display + return (String) mActivityInfo.loadLabel(mPm); + } + + @Override + String getAppLabelForSubstitutePermission() { + return getAppSubLabelInternal(); + } + } + + /** + * Loads the icon and label for the provided ApplicationInfo. Defaults to using the application + * icon and label over any IntentFilter or Activity icon to increase user understanding, with an + * exception for applications that hold the right permission. Always attempts to use available + * resources over PackageManager loading mechanisms so badging can be done by iconloader. Uses + * Strings to strip creative formatting. + */ + private abstract static class TargetPresentationGetter { + @Nullable abstract Drawable getIconSubstituteInternal(); + @Nullable abstract String getAppSubLabelInternal(); + @Nullable abstract String getAppLabelForSubstitutePermission(); + + private Context mCtx; + private final int mIconDpi; + private final boolean mHasSubstitutePermission; + private final ApplicationInfo mAi; + + protected PackageManager mPm; + + TargetPresentationGetter(Context ctx, int iconDpi, ApplicationInfo ai) { + mCtx = ctx; + mPm = ctx.getPackageManager(); + mAi = ai; + mIconDpi = iconDpi; + mHasSubstitutePermission = PackageManager.PERMISSION_GRANTED == mPm.checkPermission( + android.Manifest.permission.SUBSTITUTE_SHARE_TARGET_APP_NAME_AND_ICON, + mAi.packageName); + } + + public Drawable getIcon(UserHandle userHandle) { + return new BitmapDrawable(mCtx.getResources(), getIconBitmap(userHandle)); + } + + public Bitmap getIconBitmap(@Nullable UserHandle userHandle) { + Drawable dr = null; + if (mHasSubstitutePermission) { + dr = getIconSubstituteInternal(); + } + + if (dr == null) { + try { + if (mAi.icon != 0) { + dr = loadIconFromResource(mPm.getResourcesForApplication(mAi), mAi.icon); + } + } catch (PackageManager.NameNotFoundException ignore) { + } + } + + // Fall back to ApplicationInfo#loadIcon if nothing has been loaded + if (dr == null) { + dr = mAi.loadIcon(mPm); + } + + SimpleIconFactory sif = SimpleIconFactory.obtain(mCtx); + Bitmap icon = sif.createUserBadgedIconBitmap(dr, userHandle); + sif.recycle(); + + return icon; + } + + public String getLabel() { + String label = null; + // Apps with the substitute permission will always show the activity label as the + // app label if provided + if (mHasSubstitutePermission) { + label = getAppLabelForSubstitutePermission(); + } + + if (label == null) { + label = (String) mAi.loadLabel(mPm); + } + + return label; + } + + public String getSubLabel() { + // Apps with the substitute permission will always show the resolve info label as the + // sublabel if provided + if (mHasSubstitutePermission){ + String appSubLabel = getAppSubLabelInternal(); + // Use the resolve info label as sublabel if it is set + if(!TextUtils.isEmpty(appSubLabel) + && !TextUtils.equals(appSubLabel, getLabel())){ + return appSubLabel; + } + return null; + } + return getAppSubLabelInternal(); + } + + protected String loadLabelFromResource(Resources res, int resId) { + return res.getString(resId); + } + + @Nullable + protected Drawable loadIconFromResource(Resources res, int resId) { + return res.getDrawableForDensity(resId, mIconDpi); + } + + } +} diff --git a/java/src/com/android/intentresolver/ResolverListController.java b/java/src/com/android/intentresolver/ResolverListController.java new file mode 100644 index 00000000..ff616ce0 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverListController.java @@ -0,0 +1,413 @@ +/* + * 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 android.annotation.WorkerThread; +import android.app.ActivityManager; +import android.app.AppGlobals; +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.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.RemoteException; +import android.os.UserHandle; +import android.util.Log; + +import com.android.intentresolver.chooser.DisplayResolveInfo; + +import com.android.internal.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.PriorityQueue; +import java.util.concurrent.CountDownLatch; + +/** + * A helper for the ResolverActivity that exposes methods to retrieve, filter and sort its list of + * resolvers. + */ +public class ResolverListController { + + private final Context mContext; + private final PackageManager mpm; + private final int mLaunchedFromUid; + + // Needed for sorting resolvers. + private final Intent mTargetIntent; + private final String mReferrerPackage; + + private static final String TAG = "ResolverListController"; + private static final boolean DEBUG = false; + private final UserHandle mUserHandle; + + private AbstractResolverComparator mResolverComparator; + private boolean isComputed = false; + + public ResolverListController( + Context context, + PackageManager pm, + Intent targetIntent, + String referrerPackage, + int launchedFromUid, + UserHandle userHandle) { + this(context, pm, targetIntent, referrerPackage, launchedFromUid, userHandle, + new ResolverRankerServiceResolverComparator( + context, targetIntent, referrerPackage, null, null)); + } + + public ResolverListController( + Context context, + PackageManager pm, + Intent targetIntent, + String referrerPackage, + int launchedFromUid, + UserHandle userHandle, + AbstractResolverComparator resolverComparator) { + mContext = context; + mpm = pm; + mLaunchedFromUid = launchedFromUid; + mTargetIntent = targetIntent; + mReferrerPackage = referrerPackage; + mUserHandle = userHandle; + mResolverComparator = resolverComparator; + } + + @VisibleForTesting + public ResolveInfo getLastChosen() throws RemoteException { + return AppGlobals.getPackageManager().getLastChosenActivity( + mTargetIntent, mTargetIntent.resolveTypeIfNeeded(mContext.getContentResolver()), + PackageManager.MATCH_DEFAULT_ONLY); + } + + @VisibleForTesting + public void setLastChosen(Intent intent, IntentFilter filter, int match) + throws RemoteException { + AppGlobals.getPackageManager().setLastChosenActivity(intent, + intent.resolveType(mContext.getContentResolver()), + PackageManager.MATCH_DEFAULT_ONLY, + filter, match, intent.getComponent()); + } + + @VisibleForTesting + public List<ResolverActivity.ResolvedComponentInfo> getResolversForIntent( + boolean shouldGetResolvedFilter, + boolean shouldGetActivityMetadata, + boolean shouldGetOnlyDefaultActivities, + List<Intent> intents) { + return getResolversForIntentAsUser(shouldGetResolvedFilter, shouldGetActivityMetadata, + shouldGetOnlyDefaultActivities, intents, mUserHandle); + } + + public List<ResolverActivity.ResolvedComponentInfo> getResolversForIntentAsUser( + boolean shouldGetResolvedFilter, + boolean shouldGetActivityMetadata, + boolean shouldGetOnlyDefaultActivities, + List<Intent> intents, + UserHandle userHandle) { + int baseFlags = (shouldGetOnlyDefaultActivities ? PackageManager.MATCH_DEFAULT_ONLY : 0) + | PackageManager.MATCH_DIRECT_BOOT_AWARE + | PackageManager.MATCH_DIRECT_BOOT_UNAWARE + | (shouldGetResolvedFilter ? PackageManager.GET_RESOLVED_FILTER : 0) + | (shouldGetActivityMetadata ? PackageManager.GET_META_DATA : 0); + return getResolversForIntentAsUserInternal(intents, userHandle, baseFlags); + } + + private List<ResolverActivity.ResolvedComponentInfo> getResolversForIntentAsUserInternal( + List<Intent> intents, + UserHandle userHandle, + int baseFlags) { + List<ResolverActivity.ResolvedComponentInfo> resolvedComponents = null; + for (int i = 0, N = intents.size(); i < N; i++) { + Intent intent = intents.get(i); + int flags = baseFlags; + if (intent.isWebIntent() + || (intent.getFlags() & Intent.FLAG_ACTIVITY_MATCH_EXTERNAL) != 0) { + flags |= PackageManager.MATCH_INSTANT; + } + // Because of AIDL bug, queryIntentActivitiesAsUser can't accept subclasses of Intent. + intent = (intent.getClass() == Intent.class) ? intent : new Intent( + intent); + final List<ResolveInfo> infos = mpm.queryIntentActivitiesAsUser(intent, flags, + userHandle); + if (infos != null) { + if (resolvedComponents == null) { + resolvedComponents = new ArrayList<>(); + } + addResolveListDedupe(resolvedComponents, intent, infos); + } + } + return resolvedComponents; + } + + @VisibleForTesting + public UserHandle getUserHandle() { + return mUserHandle; + } + + @VisibleForTesting + public void addResolveListDedupe(List<ResolverActivity.ResolvedComponentInfo> into, + Intent intent, + List<ResolveInfo> from) { + final int fromCount = from.size(); + final int intoCount = into.size(); + for (int i = 0; i < fromCount; i++) { + final ResolveInfo newInfo = from.get(i); + boolean found = false; + // Only loop to the end of into as it was before we started; no dupes in from. + for (int j = 0; j < intoCount; j++) { + final ResolverActivity.ResolvedComponentInfo rci = into.get(j); + if (isSameResolvedComponent(newInfo, rci)) { + found = true; + rci.add(intent, newInfo); + break; + } + } + if (!found) { + final ComponentName name = new ComponentName( + newInfo.activityInfo.packageName, newInfo.activityInfo.name); + final ResolverActivity.ResolvedComponentInfo rci = + new ResolverActivity.ResolvedComponentInfo(name, intent, newInfo); + rci.setPinned(isComponentPinned(name)); + rci.setFixedAtTop(isFixedAtTop(name)); + into.add(rci); + } + } + } + + + /** + * Whether this component is pinned by the user. Always false for resolver; overridden in + * Chooser. + */ + public boolean isComponentPinned(ComponentName name) { + return false; + } + + /** + * Whether this component is fixed at top in the ranked apps list. Always false for Resolver; + * overridden in Chooser. + */ + public boolean isFixedAtTop(ComponentName name) { + return false; + } + + // Filter out any activities that the launched uid does not have permission for. + // To preserve the inputList, optionally will return the original list if any modification has + // been made. + @VisibleForTesting + public ArrayList<ResolverActivity.ResolvedComponentInfo> filterIneligibleActivities( + List<ResolverActivity.ResolvedComponentInfo> inputList, + boolean returnCopyOfOriginalListIfModified) { + ArrayList<ResolverActivity.ResolvedComponentInfo> listToReturn = null; + for (int i = inputList.size()-1; i >= 0; i--) { + ActivityInfo ai = inputList.get(i) + .getResolveInfoAt(0).activityInfo; + int granted = ActivityManager.checkComponentPermission( + ai.permission, mLaunchedFromUid, + ai.applicationInfo.uid, ai.exported); + + if (granted != PackageManager.PERMISSION_GRANTED + || isComponentFiltered(ai.getComponentName())) { + // Access not allowed! We're about to filter an item, + // so modify the unfiltered version if it hasn't already been modified. + if (returnCopyOfOriginalListIfModified && listToReturn == null) { + listToReturn = new ArrayList<>(inputList); + } + inputList.remove(i); + } + } + return listToReturn; + } + + // Filter out any low priority items. + // + // To preserve the inputList, optionally will return the original list if any modification has + // been made. + @VisibleForTesting + public ArrayList<ResolverActivity.ResolvedComponentInfo> filterLowPriority( + List<ResolverActivity.ResolvedComponentInfo> inputList, + boolean returnCopyOfOriginalListIfModified) { + ArrayList<ResolverActivity.ResolvedComponentInfo> listToReturn = null; + // Only display the first matches that are either of equal + // priority or have asked to be default options. + ResolverActivity.ResolvedComponentInfo rci0 = inputList.get(0); + ResolveInfo r0 = rci0.getResolveInfoAt(0); + int N = inputList.size(); + for (int i = 1; i < N; i++) { + ResolveInfo ri = inputList.get(i).getResolveInfoAt(0); + if (DEBUG) Log.v( + TAG, + r0.activityInfo.name + "=" + + r0.priority + "/" + r0.isDefault + " vs " + + ri.activityInfo.name + "=" + + ri.priority + "/" + ri.isDefault); + if (r0.priority != ri.priority || + r0.isDefault != ri.isDefault) { + while (i < N) { + if (returnCopyOfOriginalListIfModified && listToReturn == null) { + listToReturn = new ArrayList<>(inputList); + } + inputList.remove(i); + N--; + } + } + } + return listToReturn; + } + + private class ComputeCallback implements AbstractResolverComparator.AfterCompute { + + private CountDownLatch mFinishComputeSignal; + + public ComputeCallback(CountDownLatch finishComputeSignal) { + mFinishComputeSignal = finishComputeSignal; + } + + public void afterCompute () { + mFinishComputeSignal.countDown(); + } + } + + private void compute(List<ResolverActivity.ResolvedComponentInfo> inputList) + throws InterruptedException { + if (mResolverComparator == null) { + Log.d(TAG, "Comparator has already been destroyed; skipped."); + return; + } + final CountDownLatch finishComputeSignal = new CountDownLatch(1); + ComputeCallback callback = new ComputeCallback(finishComputeSignal); + mResolverComparator.setCallBack(callback); + mResolverComparator.compute(inputList); + finishComputeSignal.await(); + isComputed = true; + } + + @VisibleForTesting + @WorkerThread + public void sort(List<ResolverActivity.ResolvedComponentInfo> inputList) { + try { + long beforeRank = System.currentTimeMillis(); + if (!isComputed) { + compute(inputList); + } + Collections.sort(inputList, mResolverComparator); + + long afterRank = System.currentTimeMillis(); + if (DEBUG) { + Log.d(TAG, "Time Cost: " + Long.toString(afterRank - beforeRank)); + } + } catch (InterruptedException e) { + Log.e(TAG, "Compute & Sort was interrupted: " + e); + } + } + + @VisibleForTesting + @WorkerThread + public void topK(List<ResolverActivity.ResolvedComponentInfo> inputList, int k) { + if (inputList == null || inputList.isEmpty() || k <= 0) { + return; + } + if (inputList.size() <= k) { + // Fall into normal sort when number of ranked elements + // needed is not smaller than size of input list. + sort(inputList); + return; + } + try { + long beforeRank = System.currentTimeMillis(); + if (!isComputed) { + compute(inputList); + } + + // Top of this heap has lowest rank. + PriorityQueue<ResolverActivity.ResolvedComponentInfo> minHeap = new PriorityQueue<>(k, + (o1, o2) -> -mResolverComparator.compare(o1, o2)); + final int size = inputList.size(); + // Use this pointer to keep track of the position of next element + // to update in input list, starting from the last position. + int pointer = size - 1; + minHeap.addAll(inputList.subList(size - k, size)); + for (int i = size - k - 1; i >= 0; --i) { + ResolverActivity.ResolvedComponentInfo ci = inputList.get(i); + if (-mResolverComparator.compare(ci, minHeap.peek()) > 0) { + // When ranked higher than top of heap, remove top of heap, + // update input list with it, add this new element to heap. + inputList.set(pointer--, minHeap.poll()); + minHeap.add(ci); + } else { + // When ranked no higher than top of heap, update input list + // with this new element. + inputList.set(pointer--, ci); + } + } + + // Now we have top k elements in heap, update first + // k positions of input list with them. + while (!minHeap.isEmpty()) { + inputList.set(pointer--, minHeap.poll()); + } + + long afterRank = System.currentTimeMillis(); + if (DEBUG) { + Log.d(TAG, "Time Cost for top " + k + " targets: " + + Long.toString(afterRank - beforeRank)); + } + } catch (InterruptedException e) { + Log.e(TAG, "Compute & greatestOf was interrupted: " + e); + } + } + + private static boolean isSameResolvedComponent(ResolveInfo a, + ResolverActivity.ResolvedComponentInfo b) { + final ActivityInfo ai = a.activityInfo; + return ai.packageName.equals(b.name.getPackageName()) + && ai.name.equals(b.name.getClassName()); + } + + boolean isComponentFiltered(ComponentName componentName) { + return false; + } + + @VisibleForTesting + public float getScore(DisplayResolveInfo target) { + return mResolverComparator.getScore(target.getResolvedComponentName()); + } + + /** + * Returns the app share score of the given {@code componentName}. + */ + public float getScore(ComponentName componentName) { + return mResolverComparator.getScore(componentName); + } + + public void updateModel(ComponentName componentName) { + mResolverComparator.updateModel(componentName); + } + + public void updateChooserCounts(String packageName, int userId, String action) { + mResolverComparator.updateChooserCounts(packageName, userId, action); + } + + public void destroy() { + mResolverComparator.destroy(); + } +} diff --git a/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java new file mode 100644 index 00000000..56d326c1 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverMultiProfilePagerAdapter.java @@ -0,0 +1,279 @@ +/* + * 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 static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_PERSONAL; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT_ACCESS_WORK; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_PERSONAL_APPS; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_NO_WORK_APPS; +import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_WORK_PAUSED_TITLE; + +import android.annotation.Nullable; +import android.app.admin.DevicePolicyManager; +import android.content.Context; +import android.os.UserHandle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.widget.PagerAdapter; + +/** + * A {@link PagerAdapter} which describes the work and personal profile intent resolver screens. + */ +@VisibleForTesting +public class ResolverMultiProfilePagerAdapter extends AbstractMultiProfilePagerAdapter { + + private final ResolverProfileDescriptor[] mItems; + private final boolean mShouldShowNoCrossProfileIntentsEmptyState; + private boolean mUseLayoutWithDefault; + + ResolverMultiProfilePagerAdapter(Context context, + ResolverListAdapter adapter, + UserHandle personalProfileUserHandle, + UserHandle workProfileUserHandle) { + super(context, /* currentPage */ 0, personalProfileUserHandle, workProfileUserHandle); + mItems = new ResolverProfileDescriptor[] { + createProfileDescriptor(adapter) + }; + mShouldShowNoCrossProfileIntentsEmptyState = true; + } + + ResolverMultiProfilePagerAdapter(Context context, + ResolverListAdapter personalAdapter, + ResolverListAdapter workAdapter, + @Profile int defaultProfile, + UserHandle personalProfileUserHandle, + UserHandle workProfileUserHandle, + boolean shouldShowNoCrossProfileIntentsEmptyState) { + super(context, /* currentPage */ defaultProfile, personalProfileUserHandle, + workProfileUserHandle); + mItems = new ResolverProfileDescriptor[] { + createProfileDescriptor(personalAdapter), + createProfileDescriptor(workAdapter) + }; + mShouldShowNoCrossProfileIntentsEmptyState = shouldShowNoCrossProfileIntentsEmptyState; + } + + private ResolverProfileDescriptor createProfileDescriptor( + ResolverListAdapter adapter) { + final LayoutInflater inflater = LayoutInflater.from(getContext()); + final ViewGroup rootView = + (ViewGroup) inflater.inflate(R.layout.resolver_list_per_profile, null, false); + return new ResolverProfileDescriptor(rootView, adapter); + } + + ListView getListViewForIndex(int index) { + return getItem(index).listView; + } + + @Override + ResolverProfileDescriptor getItem(int pageIndex) { + return mItems[pageIndex]; + } + + @Override + int getItemCount() { + return mItems.length; + } + + @Override + void setupListAdapter(int pageIndex) { + final ListView listView = getItem(pageIndex).listView; + listView.setAdapter(getItem(pageIndex).resolverListAdapter); + } + + @Override + @VisibleForTesting + public ResolverListAdapter getAdapterForIndex(int pageIndex) { + return mItems[pageIndex].resolverListAdapter; + } + + @Override + public ViewGroup instantiateItem(ViewGroup container, int position) { + setupListAdapter(position); + return super.instantiateItem(container, position); + } + + @Override + @Nullable + ResolverListAdapter getListAdapterForUserHandle(UserHandle userHandle) { + if (getActiveListAdapter().getUserHandle().equals(userHandle)) { + return getActiveListAdapter(); + } else if (getInactiveListAdapter() != null + && getInactiveListAdapter().getUserHandle().equals(userHandle)) { + return getInactiveListAdapter(); + } + return null; + } + + @Override + @VisibleForTesting + public ResolverListAdapter getActiveListAdapter() { + return getAdapterForIndex(getCurrentPage()); + } + + @Override + @VisibleForTesting + public ResolverListAdapter getInactiveListAdapter() { + if (getCount() == 1) { + return null; + } + return getAdapterForIndex(1 - getCurrentPage()); + } + + @Override + public ResolverListAdapter getPersonalListAdapter() { + return getAdapterForIndex(PROFILE_PERSONAL); + } + + @Override + @Nullable + public ResolverListAdapter getWorkListAdapter() { + return getAdapterForIndex(PROFILE_WORK); + } + + @Override + ResolverListAdapter getCurrentRootAdapter() { + return getActiveListAdapter(); + } + + @Override + ListView getActiveAdapterView() { + return getListViewForIndex(getCurrentPage()); + } + + @Override + @Nullable + ViewGroup getInactiveAdapterView() { + if (getCount() == 1) { + return null; + } + return getListViewForIndex(1 - getCurrentPage()); + } + + @Override + String getMetricsCategory() { + return ResolverActivity.METRICS_CATEGORY_RESOLVER; + } + + @Override + boolean allowShowNoCrossProfileIntentsEmptyState() { + return mShouldShowNoCrossProfileIntentsEmptyState; + } + + @Override + protected void showWorkProfileOffEmptyState(ResolverListAdapter activeListAdapter, + View.OnClickListener listener) { + showEmptyState(activeListAdapter, + getWorkAppPausedTitle(), + /* subtitle = */ null, + listener); + } + + @Override + protected void showNoPersonalToWorkIntentsEmptyState(ResolverListAdapter activeListAdapter) { + showEmptyState(activeListAdapter, + getCrossProfileBlockedTitle(), + getCantAccessWorkMessage()); + } + + @Override + protected void showNoWorkToPersonalIntentsEmptyState(ResolverListAdapter activeListAdapter) { + showEmptyState(activeListAdapter, + getCrossProfileBlockedTitle(), + getCantAccessPersonalMessage()); + } + + @Override + protected void showNoPersonalAppsAvailableEmptyState(ResolverListAdapter listAdapter) { + showEmptyState(listAdapter, + getNoPersonalAppsAvailableMessage(), + /* subtitle = */ null); + } + + @Override + protected void showNoWorkAppsAvailableEmptyState(ResolverListAdapter listAdapter) { + showEmptyState(listAdapter, + getNoWorkAppsAvailableMessage(), + /* subtitle= */ null); + } + + private String getWorkAppPausedTitle() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_WORK_PAUSED_TITLE, + () -> getContext().getString(R.string.resolver_turn_on_work_apps)); + } + + private String getCrossProfileBlockedTitle() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_CROSS_PROFILE_BLOCKED_TITLE, + () -> getContext().getString(R.string.resolver_cross_profile_blocked)); + } + + private String getCantAccessWorkMessage() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_CANT_ACCESS_WORK, + () -> getContext().getString( + R.string.resolver_cant_access_work_apps_explanation)); + } + + private String getCantAccessPersonalMessage() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_CANT_ACCESS_PERSONAL, + () -> getContext().getString( + R.string.resolver_cant_access_personal_apps_explanation)); + } + + private String getNoWorkAppsAvailableMessage() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_WORK_APPS, + () -> getContext().getString( + R.string.resolver_no_work_apps_available)); + } + + private String getNoPersonalAppsAvailableMessage() { + return getContext().getSystemService(DevicePolicyManager.class).getResources().getString( + RESOLVER_NO_PERSONAL_APPS, + () -> getContext().getString( + R.string.resolver_no_personal_apps_available)); + } + + void setUseLayoutWithDefault(boolean useLayoutWithDefault) { + mUseLayoutWithDefault = useLayoutWithDefault; + } + + @Override + protected void setupContainerPadding(View container) { + int bottom = mUseLayoutWithDefault ? container.getPaddingBottom() : 0; + container.setPadding(container.getPaddingLeft(), container.getPaddingTop(), + container.getPaddingRight(), bottom); + } + + class ResolverProfileDescriptor extends ProfileDescriptor { + private ResolverListAdapter resolverListAdapter; + final ListView listView; + ResolverProfileDescriptor(ViewGroup rootView, ResolverListAdapter adapter) { + super(rootView); + resolverListAdapter = adapter; + listView = rootView.findViewById(com.android.internal.R.id.resolver_list); + } + } +} diff --git a/java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java b/java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java new file mode 100644 index 00000000..be3e6f18 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverRankerServiceResolverComparator.java @@ -0,0 +1,599 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.android.intentresolver; + +import android.app.usage.UsageStats; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.metrics.LogMaker; +import android.os.IBinder; +import android.os.Message; +import android.os.RemoteException; +import android.os.UserHandle; +import android.service.resolver.IResolverRankerResult; +import android.service.resolver.IResolverRankerService; +import android.service.resolver.ResolverRankerService; +import android.service.resolver.ResolverTarget; +import android.util.Log; + +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; + +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Ranks and compares packages based on usage stats and uses the {@link ResolverRankerService}. + */ +class ResolverRankerServiceResolverComparator extends AbstractResolverComparator { + private static final String TAG = "RRSResolverComparator"; + + private static final boolean DEBUG = false; + + // One week + private static final long USAGE_STATS_PERIOD = 1000 * 60 * 60 * 24 * 7; + + private static final long RECENCY_TIME_PERIOD = 1000 * 60 * 60 * 12; + + private static final float RECENCY_MULTIPLIER = 2.f; + + // timeout for establishing connections with a ResolverRankerService. + private static final int CONNECTION_COST_TIMEOUT_MILLIS = 200; + + private final Collator mCollator; + private final Map<String, UsageStats> mStats; + private final long mCurrentTime; + private final long mSinceTime; + private final LinkedHashMap<ComponentName, ResolverTarget> mTargetsDict = new LinkedHashMap<>(); + private final String mReferrerPackage; + private final Object mLock = new Object(); + private ArrayList<ResolverTarget> mTargets; + private String mAction; + private ComponentName mResolvedRankerName; + private ComponentName mRankerServiceName; + private IResolverRankerService mRanker; + private ResolverRankerServiceConnection mConnection; + private Context mContext; + private CountDownLatch mConnectSignal; + private ResolverRankerServiceComparatorModel mComparatorModel; + + public ResolverRankerServiceResolverComparator(Context context, Intent intent, + String referrerPackage, AfterCompute afterCompute, + ChooserActivityLogger chooserActivityLogger) { + super(context, intent); + mCollator = Collator.getInstance(context.getResources().getConfiguration().locale); + mReferrerPackage = referrerPackage; + mContext = context; + + mCurrentTime = System.currentTimeMillis(); + mSinceTime = mCurrentTime - USAGE_STATS_PERIOD; + mStats = mUsm.queryAndAggregateUsageStats(mSinceTime, mCurrentTime); + mAction = intent.getAction(); + mRankerServiceName = new ComponentName(mContext, this.getClass()); + setCallBack(afterCompute); + setChooserActivityLogger(chooserActivityLogger); + + mComparatorModel = buildUpdatedModel(); + } + + @Override + public void handleResultMessage(Message msg) { + if (msg.what != RANKER_SERVICE_RESULT) { + return; + } + if (msg.obj == null) { + Log.e(TAG, "Receiving null prediction results."); + return; + } + final List<ResolverTarget> receivedTargets = (List<ResolverTarget>) msg.obj; + if (receivedTargets != null && mTargets != null + && receivedTargets.size() == mTargets.size()) { + final int size = mTargets.size(); + boolean isUpdated = false; + for (int i = 0; i < size; ++i) { + final float predictedProb = + receivedTargets.get(i).getSelectProbability(); + if (predictedProb != mTargets.get(i).getSelectProbability()) { + mTargets.get(i).setSelectProbability(predictedProb); + isUpdated = true; + } + } + if (isUpdated) { + mRankerServiceName = mResolvedRankerName; + mComparatorModel = buildUpdatedModel(); + } + } else { + Log.e(TAG, "Sizes of sent and received ResolverTargets diff."); + } + } + + // compute features for each target according to usage stats of targets. + @Override + public void doCompute(List<ResolvedComponentInfo> targets) { + final long recentSinceTime = mCurrentTime - RECENCY_TIME_PERIOD; + + float mostRecencyScore = 1.0f; + float mostTimeSpentScore = 1.0f; + float mostLaunchScore = 1.0f; + float mostChooserScore = 1.0f; + + for (ResolvedComponentInfo target : targets) { + final ResolverTarget resolverTarget = new ResolverTarget(); + mTargetsDict.put(target.name, resolverTarget); + final UsageStats pkStats = mStats.get(target.name.getPackageName()); + if (pkStats != null) { + // Only count recency for apps that weren't the caller + // since the caller is always the most recent. + // Persistent processes muck this up, so omit them too. + if (!target.name.getPackageName().equals(mReferrerPackage) + && !isPersistentProcess(target)) { + final float recencyScore = + (float) Math.max(pkStats.getLastTimeUsed() - recentSinceTime, 0); + resolverTarget.setRecencyScore(recencyScore); + if (recencyScore > mostRecencyScore) { + mostRecencyScore = recencyScore; + } + } + final float timeSpentScore = (float) pkStats.getTotalTimeInForeground(); + resolverTarget.setTimeSpentScore(timeSpentScore); + if (timeSpentScore > mostTimeSpentScore) { + mostTimeSpentScore = timeSpentScore; + } + final float launchScore = (float) pkStats.mLaunchCount; + resolverTarget.setLaunchScore(launchScore); + if (launchScore > mostLaunchScore) { + mostLaunchScore = launchScore; + } + + float chooserScore = 0.0f; + if (pkStats.mChooserCounts != null && mAction != null + && pkStats.mChooserCounts.get(mAction) != null) { + chooserScore = (float) pkStats.mChooserCounts.get(mAction) + .getOrDefault(mContentType, 0); + if (mAnnotations != null) { + final int size = mAnnotations.length; + for (int i = 0; i < size; i++) { + chooserScore += (float) pkStats.mChooserCounts.get(mAction) + .getOrDefault(mAnnotations[i], 0); + } + } + } + if (DEBUG) { + if (mAction == null) { + Log.d(TAG, "Action type is null"); + } else { + Log.d(TAG, "Chooser Count of " + mAction + ":" + + target.name.getPackageName() + " is " + + Float.toString(chooserScore)); + } + } + resolverTarget.setChooserScore(chooserScore); + if (chooserScore > mostChooserScore) { + mostChooserScore = chooserScore; + } + } + } + + if (DEBUG) { + Log.d(TAG, "compute - mostRecencyScore: " + mostRecencyScore + + " mostTimeSpentScore: " + mostTimeSpentScore + + " mostLaunchScore: " + mostLaunchScore + + " mostChooserScore: " + mostChooserScore); + } + + mTargets = new ArrayList<>(mTargetsDict.values()); + for (ResolverTarget target : mTargets) { + final float recency = target.getRecencyScore() / mostRecencyScore; + setFeatures(target, recency * recency * RECENCY_MULTIPLIER, + target.getLaunchScore() / mostLaunchScore, + target.getTimeSpentScore() / mostTimeSpentScore, + target.getChooserScore() / mostChooserScore); + addDefaultSelectProbability(target); + if (DEBUG) { + Log.d(TAG, "Scores: " + target); + } + } + predictSelectProbabilities(mTargets); + + mComparatorModel = buildUpdatedModel(); + } + + @Override + public int compare(ResolveInfo lhs, ResolveInfo rhs) { + return mComparatorModel.getComparator().compare(lhs, rhs); + } + + @Override + public float getScore(ComponentName name) { + return mComparatorModel.getScore(name); + } + + // update ranking model when the connection to it is valid. + @Override + public void updateModel(ComponentName componentName) { + synchronized (mLock) { + mComparatorModel.notifyOnTargetSelected(componentName); + } + } + + // unbind the service and clear unhandled messges. + @Override + public void destroy() { + mHandler.removeMessages(RANKER_SERVICE_RESULT); + mHandler.removeMessages(RANKER_RESULT_TIMEOUT); + if (mConnection != null) { + mContext.unbindService(mConnection); + mConnection.destroy(); + } + afterCompute(); + if (DEBUG) { + Log.d(TAG, "Unbinded Resolver Ranker."); + } + } + + // connect to a ranking service. + private void initRanker(Context context) { + synchronized (mLock) { + if (mConnection != null && mRanker != null) { + if (DEBUG) { + Log.d(TAG, "Ranker still exists; reusing the existing one."); + } + return; + } + } + Intent intent = resolveRankerService(); + if (intent == null) { + return; + } + mConnectSignal = new CountDownLatch(1); + mConnection = new ResolverRankerServiceConnection(mConnectSignal); + context.bindServiceAsUser(intent, mConnection, Context.BIND_AUTO_CREATE, UserHandle.SYSTEM); + } + + // resolve the service for ranking. + private Intent resolveRankerService() { + Intent intent = new Intent(ResolverRankerService.SERVICE_INTERFACE); + final List<ResolveInfo> resolveInfos = mPm.queryIntentServices(intent, 0); + for (ResolveInfo resolveInfo : resolveInfos) { + if (resolveInfo == null || resolveInfo.serviceInfo == null + || resolveInfo.serviceInfo.applicationInfo == null) { + if (DEBUG) { + Log.d(TAG, "Failed to retrieve a ranker: " + resolveInfo); + } + continue; + } + ComponentName componentName = new ComponentName( + resolveInfo.serviceInfo.applicationInfo.packageName, + resolveInfo.serviceInfo.name); + try { + final String perm = mPm.getServiceInfo(componentName, 0).permission; + if (!ResolverRankerService.BIND_PERMISSION.equals(perm)) { + Log.w(TAG, "ResolverRankerService " + componentName + " does not require" + + " permission " + ResolverRankerService.BIND_PERMISSION + + " - this service will not be queried for " + + "ResolverRankerServiceResolverComparator. add android:permission=\"" + + ResolverRankerService.BIND_PERMISSION + "\"" + + " to the <service> tag for " + componentName + + " in the manifest."); + continue; + } + if (PackageManager.PERMISSION_GRANTED != mPm.checkPermission( + ResolverRankerService.HOLD_PERMISSION, + resolveInfo.serviceInfo.packageName)) { + Log.w(TAG, "ResolverRankerService " + componentName + " does not hold" + + " permission " + ResolverRankerService.HOLD_PERMISSION + + " - this service will not be queried for " + + "ResolverRankerServiceResolverComparator."); + continue; + } + } catch (NameNotFoundException e) { + Log.e(TAG, "Could not look up service " + componentName + + "; component name not found"); + continue; + } + if (DEBUG) { + Log.d(TAG, "Succeeded to retrieve a ranker: " + componentName); + } + mResolvedRankerName = componentName; + intent.setComponent(componentName); + return intent; + } + return null; + } + + private class ResolverRankerServiceConnection implements ServiceConnection { + private final CountDownLatch mConnectSignal; + + public ResolverRankerServiceConnection(CountDownLatch connectSignal) { + mConnectSignal = connectSignal; + } + + 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); + } + } + }; + + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + if (DEBUG) { + Log.d(TAG, "onServiceConnected: " + name); + } + synchronized (mLock) { + mRanker = IResolverRankerService.Stub.asInterface(service); + mComparatorModel = buildUpdatedModel(); + mConnectSignal.countDown(); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + if (DEBUG) { + Log.d(TAG, "onServiceDisconnected: " + name); + } + synchronized (mLock) { + destroy(); + } + } + + public void destroy() { + synchronized (mLock) { + mRanker = null; + mComparatorModel = buildUpdatedModel(); + } + } + } + + @Override + void beforeCompute() { + super.beforeCompute(); + mTargetsDict.clear(); + mTargets = null; + mRankerServiceName = new ComponentName(mContext, this.getClass()); + mComparatorModel = buildUpdatedModel(); + mResolvedRankerName = null; + initRanker(mContext); + } + + // predict select probabilities if ranking service is valid. + private void predictSelectProbabilities(List<ResolverTarget> targets) { + if (mConnection == null) { + if (DEBUG) { + Log.d(TAG, "Has not found valid ResolverRankerService; Skip Prediction"); + } + } else { + try { + mConnectSignal.await(CONNECTION_COST_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + synchronized (mLock) { + if (mRanker != null) { + mRanker.predict(targets, mConnection.resolverRankerResult); + return; + } else { + if (DEBUG) { + Log.d(TAG, "Ranker has not been initialized; skip predict."); + } + } + } + } catch (InterruptedException e) { + Log.e(TAG, "Error in Wait for Service Connection."); + } catch (RemoteException e) { + Log.e(TAG, "Error in Predict: " + e); + } + } + afterCompute(); + } + + // adds select prob as the default values, according to a pre-trained Logistic Regression model. + private void addDefaultSelectProbability(ResolverTarget target) { + float sum = 2.5543f * target.getLaunchScore() + 2.8412f * target.getTimeSpentScore() + + 0.269f * target.getRecencyScore() + 4.2222f * target.getChooserScore(); + target.setSelectProbability((float) (1.0 / (1.0 + Math.exp(1.6568f - sum)))); + } + + // sets features for each target + private void setFeatures(ResolverTarget target, float recencyScore, float launchScore, + float timeSpentScore, float chooserScore) { + target.setRecencyScore(recencyScore); + target.setLaunchScore(launchScore); + target.setTimeSpentScore(timeSpentScore); + target.setChooserScore(chooserScore); + } + + static boolean isPersistentProcess(ResolvedComponentInfo rci) { + if (rci != null && rci.getCount() > 0) { + return (rci.getResolveInfoAt(0).activityInfo.applicationInfo.flags & + ApplicationInfo.FLAG_PERSISTENT) != 0; + } + return false; + } + + /** + * Re-construct a {@code ResolverRankerServiceComparatorModel} to replace the current model + * instance (if any) using the up-to-date {@code ResolverRankerServiceResolverComparator} ivar + * values. + * + * TODO: each time we replace the model instance, we're either updating the model to use + * adjusted data (which is appropriate), or we're providing a (late) value for one of our ivars + * that wasn't available the last time the model was updated. For those latter cases, we should + * just avoid creating the model altogether until we have all the prerequisites we'll need. Then + * we can probably simplify the logic in {@code ResolverRankerServiceComparatorModel} since we + * won't need to handle edge cases when the model data isn't fully prepared. + * (In some cases, these kinds of "updates" might interleave -- e.g., we might have finished + * initializing the first time and now want to adjust some data, but still need to wait for + * changes to propagate to the other ivars before rebuilding the model.) + */ + private ResolverRankerServiceComparatorModel buildUpdatedModel() { + // TODO: we don't currently guarantee that the underlying target list/map won't be mutated, + // so the ResolverComparatorModel may provide inconsistent results. We should make immutable + // copies of the data (waiting for any necessary remaining data before creating the model). + return new ResolverRankerServiceComparatorModel( + mStats, + mTargetsDict, + mTargets, + mCollator, + mRanker, + mRankerServiceName, + (mAnnotations != null), + mPm); + } + + /** + * Implementation of a {@code ResolverComparatorModel} that provides the same ranking logic as + * the legacy {@code ResolverRankerServiceResolverComparator}, as a refactoring step toward + * removing the complex legacy API. + */ + static class ResolverRankerServiceComparatorModel implements ResolverComparatorModel { + private final Map<String, UsageStats> mStats; // Treat as immutable. + private final Map<ComponentName, ResolverTarget> mTargetsDict; // Treat as immutable. + private final List<ResolverTarget> mTargets; // Treat as immutable. + private final Collator mCollator; + private final IResolverRankerService mRanker; + private final ComponentName mRankerServiceName; + private final boolean mAnnotationsUsed; + private final PackageManager mPm; + + // TODO: it doesn't look like we should have to pass both targets and targetsDict, but it's + // not written in a way that makes it clear whether we can derive one from the other (at + // least in this constructor). + ResolverRankerServiceComparatorModel( + Map<String, UsageStats> stats, + Map<ComponentName, ResolverTarget> targetsDict, + List<ResolverTarget> targets, + Collator collator, + IResolverRankerService ranker, + ComponentName rankerServiceName, + boolean annotationsUsed, + PackageManager pm) { + mStats = stats; + mTargetsDict = targetsDict; + mTargets = targets; + mCollator = collator; + mRanker = ranker; + mRankerServiceName = rankerServiceName; + mAnnotationsUsed = annotationsUsed; + mPm = pm; + } + + @Override + public Comparator<ResolveInfo> getComparator() { + // TODO: doCompute() doesn't seem to be concerned about null-checking mStats. Is that + // a bug there, or do we have a way of knowing it will be non-null under certain + // conditions? + return (lhs, rhs) -> { + if (mStats != null) { + final ResolverTarget lhsTarget = mTargetsDict.get(new ComponentName( + lhs.activityInfo.packageName, lhs.activityInfo.name)); + final ResolverTarget rhsTarget = mTargetsDict.get(new ComponentName( + rhs.activityInfo.packageName, rhs.activityInfo.name)); + + if (lhsTarget != null && rhsTarget != null) { + final int selectProbabilityDiff = Float.compare( + rhsTarget.getSelectProbability(), lhsTarget.getSelectProbability()); + + if (selectProbabilityDiff != 0) { + return selectProbabilityDiff > 0 ? 1 : -1; + } + } + } + + CharSequence sa = lhs.loadLabel(mPm); + if (sa == null) sa = lhs.activityInfo.name; + CharSequence sb = rhs.loadLabel(mPm); + if (sb == null) sb = rhs.activityInfo.name; + + return mCollator.compare(sa.toString().trim(), sb.toString().trim()); + }; + } + + @Override + public float getScore(ComponentName name) { + final ResolverTarget target = mTargetsDict.get(name); + if (target != null) { + return target.getSelectProbability(); + } + return 0; + } + + @Override + public void notifyOnTargetSelected(ComponentName componentName) { + if (mRanker != null) { + try { + int selectedPos = new ArrayList<ComponentName>(mTargetsDict.keySet()) + .indexOf(componentName); + if (selectedPos >= 0 && mTargets != null) { + final float selectedProbability = getScore(componentName); + int order = 0; + for (ResolverTarget target : mTargets) { + if (target.getSelectProbability() > selectedProbability) { + order++; + } + } + logMetrics(order); + mRanker.train(mTargets, selectedPos); + } else { + if (DEBUG) { + Log.d(TAG, "Selected a unknown component: " + componentName); + } + } + } catch (RemoteException e) { + Log.e(TAG, "Error in Train: " + e); + } + } else { + if (DEBUG) { + Log.d(TAG, "Ranker is null; skip updateModel."); + } + } + } + + /** Records metrics for evaluation. */ + private void logMetrics(int selectedPos) { + if (mRankerServiceName != null) { + MetricsLogger metricsLogger = new MetricsLogger(); + LogMaker log = new LogMaker(MetricsEvent.ACTION_TARGET_SELECTED); + log.setComponentName(mRankerServiceName); + log.addTaggedData(MetricsEvent.FIELD_IS_CATEGORY_USED, mAnnotationsUsed ? 1 : 0); + log.addTaggedData(MetricsEvent.FIELD_RANKED_POSITION, selectedPos); + metricsLogger.write(log); + } + } + } +} diff --git a/java/src/com/android/intentresolver/ResolverViewPager.java b/java/src/com/android/intentresolver/ResolverViewPager.java new file mode 100644 index 00000000..1c234526 --- /dev/null +++ b/java/src/com/android/intentresolver/ResolverViewPager.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import com.android.internal.widget.ViewPager; + +/** + * A {@link ViewPager} which wraps around its tallest child's height. + * <p>Normally {@link ViewPager} instances expand their height to cover all remaining space in + * the layout. + * <p>This class is used for the intent resolver and share sheet's tabbed view. + */ +public class ResolverViewPager extends ViewPager { + + private boolean mSwipingEnabled = true; + + public ResolverViewPager(Context context) { + super(context); + } + + public ResolverViewPager(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ResolverViewPager(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public ResolverViewPager(Context context, AttributeSet attrs, + int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.AT_MOST) { + return; + } + widthMeasureSpec = MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY); + int height = getMeasuredHeight(); + int maxHeight = 0; + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + child.measure(widthMeasureSpec, + MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST)); + if (maxHeight < child.getMeasuredHeight()) { + maxHeight = child.getMeasuredHeight(); + } + } + if (maxHeight > 0) { + height = maxHeight; + } + heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + /** + * Sets whether swiping sideways should happen. + * <p>Note that swiping is always disabled for RTL layouts (b/159110029 for context). + */ + void setSwipingEnabled(boolean swipingEnabled) { + mSwipingEnabled = swipingEnabled; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + return !isLayoutRtl() && mSwipingEnabled && super.onInterceptTouchEvent(ev); + } +} diff --git a/java/src/com/android/intentresolver/SimpleIconFactory.java b/java/src/com/android/intentresolver/SimpleIconFactory.java new file mode 100644 index 00000000..b05b4f68 --- /dev/null +++ b/java/src/com/android/intentresolver/SimpleIconFactory.java @@ -0,0 +1,752 @@ +/* + * 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 static android.content.Context.ACTIVITY_SERVICE; +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; +import android.content.res.Resources; +import android.content.res.Resources.Theme; +import android.graphics.Bitmap; +import android.graphics.BlurMaskFilter; +import android.graphics.BlurMaskFilter.Blur; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PaintFlagsDrawFilter; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.AdaptiveIconDrawable; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.DrawableWrapper; +import android.os.UserHandle; +import android.util.AttributeSet; +import android.util.Pools.SynchronizedPool; +import android.util.TypedValue; + +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 + * possibly badged. It is intended to be used only by Sharesheet for the Q release with custom code. + */ +@Deprecated +public class SimpleIconFactory { + + + private static final SynchronizedPool<SimpleIconFactory> sPool = + new SynchronizedPool<>(Runtime.getRuntime().availableProcessors()); + + private static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; + private static final float BLUR_FACTOR = 1.5f / 48; + + private Context mContext; + private Canvas mCanvas; + private PackageManager mPm; + + private int mFillResIconDpi; + private int mIconBitmapSize; + private int mBadgeBitmapSize; + private int mWrapperBackgroundColor; + + private Drawable mWrapperIcon; + private final Rect mOldBounds = new Rect(); + + /** + * Obtain a SimpleIconFactory from a pool objects. + * + * @deprecated Do not use, functionality will be replaced by iconloader lib eventually. + */ + @Deprecated + public static SimpleIconFactory obtain(Context ctx) { + SimpleIconFactory instance = sPool.acquire(); + if (instance == null) { + final ActivityManager am = (ActivityManager) ctx.getSystemService(ACTIVITY_SERVICE); + final int iconDpi = (am == null) ? 0 : am.getLauncherLargeIconDensity(); + + final int iconSize = getIconSizeFromContext(ctx); + final int badgeSize = getBadgeSizeFromContext(ctx); + instance = new SimpleIconFactory(ctx, iconDpi, iconSize, badgeSize); + instance.setWrapperBackgroundColor(Color.WHITE); + } + + return instance; + } + + private static int getAttrDimFromContext(Context ctx, @AttrRes int attrId, String errorMsg) { + final Resources res = ctx.getResources(); + TypedValue outVal = new TypedValue(); + if (!ctx.getTheme().resolveAttribute(attrId, outVal, true)) { + throw new IllegalStateException(errorMsg); + } + return res.getDimensionPixelSize(outVal.resourceId); + } + + private static int getIconSizeFromContext(Context ctx) { + return getAttrDimFromContext(ctx, + com.android.internal.R.attr.iconfactoryIconSize, + "Expected theme to define iconfactoryIconSize."); + } + + private static int getBadgeSizeFromContext(Context ctx) { + return getAttrDimFromContext(ctx, + com.android.internal.R.attr.iconfactoryBadgeSize, + "Expected theme to define iconfactoryBadgeSize."); + } + + /** + * Recycles the SimpleIconFactory so others may use it. + * + * @deprecated Do not use, functionality will be replaced by iconloader lib eventually. + */ + @Deprecated + public void recycle() { + // Return to default background color + setWrapperBackgroundColor(Color.WHITE); + sPool.release(this); + } + + /** + * @deprecated Do not use, functionality will be replaced by iconloader lib eventually. + */ + @Deprecated + private SimpleIconFactory(Context context, int fillResIconDpi, int iconBitmapSize, + int badgeBitmapSize) { + mContext = context.getApplicationContext(); + mPm = mContext.getPackageManager(); + mIconBitmapSize = iconBitmapSize; + mBadgeBitmapSize = badgeBitmapSize; + mFillResIconDpi = fillResIconDpi; + + mCanvas = new Canvas(); + mCanvas.setDrawFilter(new PaintFlagsDrawFilter(DITHER_FLAG, FILTER_BITMAP_FLAG)); + + // Normalizer init + // Use twice the icon size as maximum size to avoid scaling down twice. + mMaxSize = iconBitmapSize * 2; + mBitmap = Bitmap.createBitmap(mMaxSize, mMaxSize, Bitmap.Config.ALPHA_8); + mScaleCheckCanvas = new Canvas(mBitmap); + mPixels = new byte[mMaxSize * mMaxSize]; + mLeftBorder = new float[mMaxSize]; + mRightBorder = new float[mMaxSize]; + mBounds = new Rect(); + mAdaptiveIconBounds = new Rect(); + mAdaptiveIconScale = SCALE_NOT_INITIALIZED; + + // Shadow generator init + mDefaultBlurMaskFilter = new BlurMaskFilter(iconBitmapSize * BLUR_FACTOR, + Blur.NORMAL); + } + + /** + * Sets the background color used for wrapped adaptive icon + * + * @deprecated Do not use, functionality will be replaced by iconloader lib eventually. + */ + @Deprecated + void setWrapperBackgroundColor(int color) { + mWrapperBackgroundColor = (Color.alpha(color) < 255) ? DEFAULT_WRAPPER_BACKGROUND : color; + } + + /** + * Creates bitmap using the source drawable and various parameters. + * The bitmap is visually normalized with other icons and has enough spacing to add shadow. + * Note: this method has been modified from iconloaderlib to remove a profile diff check. + * + * @param icon source of the icon associated with a user that has no badge, + * likely user 0 + * @param user info can be used for a badge + * @return a bitmap suitable for disaplaying as an icon at various system UIs. + * + * @deprecated Do not use, functionality will be replaced by iconloader lib eventually. + */ + @Deprecated + Bitmap createUserBadgedIconBitmap(@Nullable Drawable icon, @Nullable UserHandle user) { + float [] scale = new float[1]; + + // If no icon is provided use the system default + if (icon == null) { + icon = getFullResDefaultActivityIcon(mFillResIconDpi); + } + icon = normalizeAndWrapToAdaptiveIcon(icon, null, scale); + Bitmap bitmap = createIconBitmap(icon, scale[0]); + if (icon instanceof AdaptiveIconDrawable) { + mCanvas.setBitmap(bitmap); + recreateIcon(Bitmap.createBitmap(bitmap), mCanvas); + mCanvas.setBitmap(null); + } + + final Bitmap result; + if (user != null /* if modification from iconloaderlib */) { + BitmapDrawable drawable = new FixedSizeBitmapDrawable(bitmap); + Drawable badged = mPm.getUserBadgedIcon(drawable, user); + if (badged instanceof BitmapDrawable) { + result = ((BitmapDrawable) badged).getBitmap(); + } else { + result = createIconBitmap(badged, 1f); + } + } else { + result = bitmap; + } + + return result; + } + + /** + * Creates bitmap using the source drawable and flattened pre-rendered app icon. + * The bitmap is visually normalized with other icons and has enough spacing to add shadow. + * This is custom functionality added to Iconloaderlib that will need to be ported. + * + * @param icon source of the icon associated with a user that has no badge + * @param renderedAppIcon pre-rendered app icon to use as a badge, likely the output + * of createUserBadgedIconBitmap for user 0 + * @return a bitmap suitable for disaplaying as an icon at various system UIs. + * + * @deprecated Do not use, functionality will be replaced by iconloader lib eventually. + */ + @Deprecated + public Bitmap createAppBadgedIconBitmap(@Nullable Drawable icon, Bitmap renderedAppIcon) { + // If no icon is provided use the system default + if (icon == null) { + icon = getFullResDefaultActivityIcon(mFillResIconDpi); + } + + // Direct share icons cannot be adaptive, most will arrive as bitmaps. To get reliable + // presentation, force all DS icons to be circular. Scale DS image so it completely fills. + int w = icon.getIntrinsicWidth(); + int h = icon.getIntrinsicHeight(); + float scale = 1; + if (h > w && w > 0) { + scale = (float) h / w; + } else if (w > h && h > 0) { + scale = (float) w / h; + } + Bitmap bitmap = createIconBitmapNoInsetOrMask(icon, scale); + bitmap = maskBitmapToCircle(bitmap); + icon = new BitmapDrawable(mContext.getResources(), bitmap); + + // We now have a circular masked and scaled icon, inset and apply shadow + scale = getScale(icon, null); + bitmap = createIconBitmap(icon, scale); + + mCanvas.setBitmap(bitmap); + recreateIcon(Bitmap.createBitmap(bitmap), mCanvas); + + if (renderedAppIcon != null) { + // Now scale down and apply the badge to the bottom right corner of the flattened icon + renderedAppIcon = Bitmap.createScaledBitmap(renderedAppIcon, mBadgeBitmapSize, + mBadgeBitmapSize, false); + + // Paint the provided badge on top of the flattened icon + mCanvas.drawBitmap(renderedAppIcon, mIconBitmapSize - mBadgeBitmapSize, + mIconBitmapSize - mBadgeBitmapSize, null); + } + + mCanvas.setBitmap(null); + + return bitmap; + } + + private Bitmap maskBitmapToCircle(Bitmap bitmap) { + final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), + bitmap.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(output); + final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG + | Paint.FILTER_BITMAP_FLAG); + + // Apply an offset to enable shadow to be drawn + final int size = bitmap.getWidth(); + int offset = Math.max((int) Math.ceil(BLUR_FACTOR * size), 1); + + // Draw mask + paint.setColor(0xffffffff); + canvas.drawARGB(0, 0, 0, 0); + canvas.drawCircle(bitmap.getWidth() / 2f, + bitmap.getHeight() / 2f, + bitmap.getWidth() / 2f - offset, + paint); + + // Draw masked bitmap + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + canvas.drawBitmap(bitmap, rect, rect, paint); + + return output; + } + + private static Drawable getFullResDefaultActivityIcon(int iconDpi) { + return Resources.getSystem().getDrawableForDensity(android.R.mipmap.sym_def_app_icon, + iconDpi); + } + + private Bitmap createIconBitmap(Drawable icon, float scale) { + return createIconBitmap(icon, scale, mIconBitmapSize, true, false); + } + + private Bitmap createIconBitmapNoInsetOrMask(Drawable icon, float scale) { + return createIconBitmap(icon, scale, mIconBitmapSize, false, true); + } + + /** + * @param icon drawable that should be flattened to a bitmap + * @param scale the scale to apply before drawing {@param icon} on the canvas + * @param insetAdiForShadow when rendering AdaptiveIconDrawables inset to make room for a shadow + * @param ignoreAdiMask when rendering AdaptiveIconDrawables ignore the current system mask + */ + private Bitmap createIconBitmap(Drawable icon, float scale, int size, boolean insetAdiForShadow, + boolean ignoreAdiMask) { + Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + + mCanvas.setBitmap(bitmap); + mOldBounds.set(icon.getBounds()); + + if (icon instanceof AdaptiveIconDrawable) { + final AdaptiveIconDrawable adi = (AdaptiveIconDrawable) icon; + + // By default assumes the output bitmap will have a shadow directly applied and makes + // room for it by insetting. If there are intermediate steps before applying the shadow + // insetting is disableable. + int offset = Math.round(size * (1 - scale) / 2); + if (insetAdiForShadow) { + offset = Math.max((int) Math.ceil(BLUR_FACTOR * size), offset); + } + Rect bounds = new Rect(offset, offset, size - offset, size - offset); + + // AdaptiveIconDrawables are by default masked by the user's icon shape selection. + // If further masking is to be done, directly render to avoid the system masking. + if (ignoreAdiMask) { + final int cX = bounds.width() / 2; + final int cY = bounds.height() / 2; + final float portScale = 1f / (1 + 2 * getExtraInsetFraction()); + final int insetWidth = (int) (bounds.width() / (portScale * 2)); + final int insetHeight = (int) (bounds.height() / (portScale * 2)); + + Rect childRect = new Rect(cX - insetWidth, cY - insetHeight, cX + insetWidth, + cY + insetHeight); + Optional.ofNullable(adi.getBackground()).ifPresent(drawable -> { + drawable.setBounds(childRect); + drawable.draw(mCanvas); + }); + Optional.ofNullable(adi.getForeground()).ifPresent(drawable -> { + drawable.setBounds(childRect); + drawable.draw(mCanvas); + }); + } else { + adi.setBounds(bounds); + adi.draw(mCanvas); + } + } else { + if (icon instanceof BitmapDrawable) { + BitmapDrawable bitmapDrawable = (BitmapDrawable) icon; + Bitmap b = bitmapDrawable.getBitmap(); + if (bitmap != null && b.getDensity() == Bitmap.DENSITY_NONE) { + bitmapDrawable.setTargetDensity(mContext.getResources().getDisplayMetrics()); + } + } + int width = size; + int height = size; + + int intrinsicWidth = icon.getIntrinsicWidth(); + int intrinsicHeight = icon.getIntrinsicHeight(); + if (intrinsicWidth > 0 && intrinsicHeight > 0) { + // Scale the icon proportionally to the icon dimensions + final float ratio = (float) intrinsicWidth / intrinsicHeight; + if (intrinsicWidth > intrinsicHeight) { + height = (int) (width / ratio); + } else if (intrinsicHeight > intrinsicWidth) { + width = (int) (height * ratio); + } + } + final int left = (size - width) / 2; + final int top = (size - height) / 2; + icon.setBounds(left, top, left + width, top + height); + mCanvas.save(); + mCanvas.scale(scale, scale, size / 2, size / 2); + icon.draw(mCanvas); + mCanvas.restore(); + + } + + icon.setBounds(mOldBounds); + mCanvas.setBitmap(null); + return bitmap; + } + + private Drawable normalizeAndWrapToAdaptiveIcon(Drawable icon, RectF outIconBounds, + float[] outScale) { + float scale = 1f; + + if (mWrapperIcon == null) { + mWrapperIcon = mContext.getDrawable( + R.drawable.iconfactory_adaptive_icon_drawable_wrapper).mutate(); + } + + AdaptiveIconDrawable dr = (AdaptiveIconDrawable) mWrapperIcon; + dr.setBounds(0, 0, 1, 1); + scale = getScale(icon, outIconBounds); + if (!(icon instanceof AdaptiveIconDrawable)) { + FixedScaleDrawable fsd = ((FixedScaleDrawable) dr.getForeground()); + fsd.setDrawable(icon); + fsd.setScale(scale); + icon = dr; + scale = getScale(icon, outIconBounds); + + ((ColorDrawable) dr.getBackground()).setColor(mWrapperBackgroundColor); + } + + outScale[0] = scale; + return icon; + } + + + /* Normalization block */ + + private static final float SCALE_NOT_INITIALIZED = 0; + // Ratio of icon visible area to full icon size for a square shaped icon + private static final float MAX_SQUARE_AREA_FACTOR = 375.0f / 576; + // Ratio of icon visible area to full icon size for a circular shaped icon + private static final float MAX_CIRCLE_AREA_FACTOR = 380.0f / 576; + + private static final float CIRCLE_AREA_BY_RECT = (float) Math.PI / 4; + + // Slope used to calculate icon visible area to full icon size for any generic shaped icon. + private static final float LINEAR_SCALE_SLOPE = + (MAX_CIRCLE_AREA_FACTOR - MAX_SQUARE_AREA_FACTOR) / (1 - CIRCLE_AREA_BY_RECT); + + private static final int MIN_VISIBLE_ALPHA = 40; + + private float mAdaptiveIconScale; + private final Rect mAdaptiveIconBounds; + private final Rect mBounds; + private final int mMaxSize; + private final byte[] mPixels; + private final float[] mLeftBorder; + private final float[] mRightBorder; + private final Bitmap mBitmap; + private final Canvas mScaleCheckCanvas; + + /** + * Returns the amount by which the {@param d} should be scaled (in both dimensions) so that it + * matches the design guidelines for a launcher icon. + * + * We first calculate the convex hull of the visible portion of the icon. + * This hull then compared with the bounding rectangle of the hull to find how closely it + * resembles a circle and a square, by comparing the ratio of the areas. Note that this is not + * an ideal solution but it gives satisfactory result without affecting the performance. + * + * This closeness is used to determine the ratio of hull area to the full icon size. + * Refer {@link #MAX_CIRCLE_AREA_FACTOR} and {@link #MAX_SQUARE_AREA_FACTOR} + * + * @param outBounds optional rect to receive the fraction distance from each edge. + */ + private synchronized float getScale(@NonNull Drawable d, @Nullable RectF outBounds) { + if (d instanceof AdaptiveIconDrawable) { + if (mAdaptiveIconScale != SCALE_NOT_INITIALIZED) { + if (outBounds != null) { + outBounds.set(mAdaptiveIconBounds); + } + return mAdaptiveIconScale; + } + } + int width = d.getIntrinsicWidth(); + int height = d.getIntrinsicHeight(); + if (width <= 0 || height <= 0) { + width = width <= 0 || width > mMaxSize ? mMaxSize : width; + height = height <= 0 || height > mMaxSize ? mMaxSize : height; + } else if (width > mMaxSize || height > mMaxSize) { + int max = Math.max(width, height); + width = mMaxSize * width / max; + height = mMaxSize * height / max; + } + + mBitmap.eraseColor(Color.TRANSPARENT); + d.setBounds(0, 0, width, height); + d.draw(mScaleCheckCanvas); + + ByteBuffer buffer = ByteBuffer.wrap(mPixels); + buffer.rewind(); + mBitmap.copyPixelsToBuffer(buffer); + + // Overall bounds of the visible icon. + int topY = -1; + int bottomY = -1; + int leftX = mMaxSize + 1; + int rightX = -1; + + // Create border by going through all pixels one row at a time and for each row find + // the first and the last non-transparent pixel. Set those values to mLeftBorder and + // mRightBorder and use -1 if there are no visible pixel in the row. + + // buffer position + int index = 0; + // buffer shift after every row, width of buffer = mMaxSize + int rowSizeDiff = mMaxSize - width; + // first and last position for any row. + int firstX, lastX; + + for (int y = 0; y < height; y++) { + firstX = lastX = -1; + for (int x = 0; x < width; x++) { + if ((mPixels[index] & 0xFF) > MIN_VISIBLE_ALPHA) { + if (firstX == -1) { + firstX = x; + } + lastX = x; + } + index++; + } + index += rowSizeDiff; + + mLeftBorder[y] = firstX; + mRightBorder[y] = lastX; + + // If there is at least one visible pixel, update the overall bounds. + if (firstX != -1) { + bottomY = y; + if (topY == -1) { + topY = y; + } + + leftX = Math.min(leftX, firstX); + rightX = Math.max(rightX, lastX); + } + } + + if (topY == -1 || rightX == -1) { + // No valid pixels found. Do not scale. + return 1; + } + + convertToConvexArray(mLeftBorder, 1, topY, bottomY); + convertToConvexArray(mRightBorder, -1, topY, bottomY); + + // Area of the convex hull + float area = 0; + for (int y = 0; y < height; y++) { + if (mLeftBorder[y] <= -1) { + continue; + } + area += mRightBorder[y] - mLeftBorder[y] + 1; + } + + // Area of the rectangle required to fit the convex hull + float rectArea = (bottomY + 1 - topY) * (rightX + 1 - leftX); + float hullByRect = area / rectArea; + + float scaleRequired; + if (hullByRect < CIRCLE_AREA_BY_RECT) { + scaleRequired = MAX_CIRCLE_AREA_FACTOR; + } else { + scaleRequired = MAX_SQUARE_AREA_FACTOR + LINEAR_SCALE_SLOPE * (1 - hullByRect); + } + mBounds.left = leftX; + mBounds.right = rightX; + + mBounds.top = topY; + mBounds.bottom = bottomY; + + if (outBounds != null) { + outBounds.set(((float) mBounds.left) / width, ((float) mBounds.top) / height, + 1 - ((float) mBounds.right) / width, + 1 - ((float) mBounds.bottom) / height); + } + float areaScale = area / (width * height); + // Use sqrt of the final ratio as the images is scaled across both width and height. + float scale = areaScale > scaleRequired ? (float) Math.sqrt(scaleRequired / areaScale) : 1; + if (d instanceof AdaptiveIconDrawable && mAdaptiveIconScale == SCALE_NOT_INITIALIZED) { + mAdaptiveIconScale = scale; + mAdaptiveIconBounds.set(mBounds); + } + return scale; + } + + /** + * Modifies {@param xCoordinates} to represent a convex border. Fills in all missing values + * (except on either ends) with appropriate values. + * @param xCoordinates map of x coordinate per y. + * @param direction 1 for left border and -1 for right border. + * @param topY the first Y position (inclusive) with a valid value. + * @param bottomY the last Y position (inclusive) with a valid value. + */ + private static void convertToConvexArray( + float[] xCoordinates, int direction, int topY, int bottomY) { + int total = xCoordinates.length; + // The tangent at each pixel. + float[] angles = new float[total - 1]; + + int first = topY; // First valid y coordinate + int last = -1; // Last valid y coordinate which didn't have a missing value + + float lastAngle = Float.MAX_VALUE; + + for (int i = topY + 1; i <= bottomY; i++) { + if (xCoordinates[i] <= -1) { + continue; + } + int start; + + if (lastAngle == Float.MAX_VALUE) { + start = first; + } else { + float currentAngle = (xCoordinates[i] - xCoordinates[last]) / (i - last); + start = last; + // If this position creates a concave angle, keep moving up until we find a + // position which creates a convex angle. + if ((currentAngle - lastAngle) * direction < 0) { + while (start > first) { + start--; + currentAngle = (xCoordinates[i] - xCoordinates[start]) / (i - start); + if ((currentAngle - angles[start]) * direction >= 0) { + break; + } + } + } + } + + // Reset from last check + lastAngle = (xCoordinates[i] - xCoordinates[start]) / (i - start); + // Update all the points from start. + for (int j = start; j < i; j++) { + angles[j] = lastAngle; + xCoordinates[j] = xCoordinates[start] + lastAngle * (j - start); + } + last = i; + } + } + + /* Shadow generator block */ + + private static final float KEY_SHADOW_DISTANCE = 1f / 48; + private static final int KEY_SHADOW_ALPHA = 10; + private static final int AMBIENT_SHADOW_ALPHA = 7; + + private Paint mBlurPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); + private Paint mDrawPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); + private BlurMaskFilter mDefaultBlurMaskFilter; + + private synchronized void recreateIcon(Bitmap icon, Canvas out) { + recreateIcon(icon, mDefaultBlurMaskFilter, AMBIENT_SHADOW_ALPHA, KEY_SHADOW_ALPHA, out); + } + + private synchronized void recreateIcon(Bitmap icon, BlurMaskFilter blurMaskFilter, + int ambientAlpha, int keyAlpha, Canvas out) { + int[] offset = new int[2]; + mBlurPaint.setMaskFilter(blurMaskFilter); + Bitmap shadow = icon.extractAlpha(mBlurPaint, offset); + + // Draw ambient shadow + mDrawPaint.setAlpha(ambientAlpha); + out.drawBitmap(shadow, offset[0], offset[1], mDrawPaint); + + // Draw key shadow + mDrawPaint.setAlpha(keyAlpha); + out.drawBitmap(shadow, offset[0], offset[1] + KEY_SHADOW_DISTANCE * mIconBitmapSize, + mDrawPaint); + + // Draw the icon + mDrawPaint.setAlpha(255); // TODO if b/128609682 not fixed by launch use .setAlpha(254) + out.drawBitmap(icon, 0, 0, mDrawPaint); + } + + /* Classes */ + + /** + * Extension of {@link DrawableWrapper} which scales the child drawables by a fixed amount. + */ + public static class FixedScaleDrawable extends DrawableWrapper { + + private static final float LEGACY_ICON_SCALE = .7f * .6667f; + private float mScaleX, mScaleY; + + public FixedScaleDrawable() { + super(new ColorDrawable()); + mScaleX = LEGACY_ICON_SCALE; + mScaleY = LEGACY_ICON_SCALE; + } + + @Override + public void draw(@NonNull Canvas canvas) { + int saveCount = canvas.save(); + canvas.scale(mScaleX, mScaleY, + getBounds().exactCenterX(), getBounds().exactCenterY()); + super.draw(canvas); + canvas.restoreToCount(saveCount); + } + + @Override + public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs) { } + + @Override + public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs, Theme theme) { } + + /** + * Sets the scale associated with this drawable + * @param scale + */ + public void setScale(float scale) { + float h = getIntrinsicHeight(); + float w = getIntrinsicWidth(); + mScaleX = scale * LEGACY_ICON_SCALE; + mScaleY = scale * LEGACY_ICON_SCALE; + if (h > w && w > 0) { + mScaleX *= w / h; + } else if (w > h && h > 0) { + mScaleY *= h / w; + } + } + } + + /** + * An extension of {@link BitmapDrawable} which returns the bitmap pixel size as intrinsic size. + * This allows the badging to be done based on the action bitmap size rather than + * the scaled bitmap size. + */ + private static class FixedSizeBitmapDrawable extends BitmapDrawable { + + FixedSizeBitmapDrawable(Bitmap bitmap) { + super(null, bitmap); + } + + @Override + public int getIntrinsicHeight() { + return getBitmap().getWidth(); + } + + @Override + public int getIntrinsicWidth() { + return getBitmap().getWidth(); + } + } + +} diff --git a/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java new file mode 100644 index 00000000..1c763071 --- /dev/null +++ b/java/src/com/android/intentresolver/chooser/ChooserTargetInfo.java @@ -0,0 +1,53 @@ +/* + * 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.chooser; + +import android.service.chooser.ChooserTarget; +import android.text.TextUtils; + +/** + * A TargetInfo for Direct Share. Includes a {@link ChooserTarget} representing the + * Direct Share deep link into an application. + */ +public interface ChooserTargetInfo extends TargetInfo { + float getModifiedScore(); + + ChooserTarget getChooserTarget(); + + /** + * Do not label as 'equals', since this doesn't quite work + * as intended with java 8. + */ + default boolean isSimilar(ChooserTargetInfo other) { + if (other == null) return false; + + ChooserTarget ct1 = getChooserTarget(); + ChooserTarget ct2 = other.getChooserTarget(); + + // If either is null, there is not enough info to make an informed decision + // about equality, so just exit + if (ct1 == null || ct2 == null) return false; + + if (ct1.getComponentName().equals(ct2.getComponentName()) + && TextUtils.equals(getDisplayLabel(), other.getDisplayLabel()) + && TextUtils.equals(getExtendedInfo(), other.getExtendedInfo())) { + return true; + } + + return false; + } +} diff --git a/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java new file mode 100644 index 00000000..e7ffe3c6 --- /dev/null +++ b/java/src/com/android/intentresolver/chooser/DisplayResolveInfo.java @@ -0,0 +1,243 @@ +/* + * 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.chooser; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.ResolveInfo; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.UserHandle; + +import com.android.intentresolver.ResolverActivity; +import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; + +import java.util.ArrayList; +import java.util.List; + +/** + * A TargetInfo plus additional information needed to render it (such as icon and label) and + * resolve it to an activity. + */ +public class DisplayResolveInfo implements TargetInfo, Parcelable { + private final ResolveInfo mResolveInfo; + private CharSequence mDisplayLabel; + private Drawable mDisplayIcon; + private CharSequence mExtendedInfo; + private final Intent mResolvedIntent; + private final List<Intent> mSourceIntents = new ArrayList<>(); + private boolean mIsSuspended; + private ResolveInfoPresentationGetter mResolveInfoPresentationGetter; + private boolean mPinned = false; + + public DisplayResolveInfo(Intent originalIntent, ResolveInfo pri, Intent pOrigIntent, + ResolveInfoPresentationGetter resolveInfoPresentationGetter) { + this(originalIntent, pri, null /*mDisplayLabel*/, null /*mExtendedInfo*/, pOrigIntent, + resolveInfoPresentationGetter); + } + + public DisplayResolveInfo(Intent originalIntent, ResolveInfo pri, CharSequence pLabel, + CharSequence pInfo, @NonNull Intent resolvedIntent, + @Nullable ResolveInfoPresentationGetter resolveInfoPresentationGetter) { + mSourceIntents.add(originalIntent); + mResolveInfo = pri; + mDisplayLabel = pLabel; + mExtendedInfo = pInfo; + mResolveInfoPresentationGetter = resolveInfoPresentationGetter; + + final Intent intent = new Intent(resolvedIntent); + intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT + | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); + final ActivityInfo ai = mResolveInfo.activityInfo; + intent.setComponent(new ComponentName(ai.applicationInfo.packageName, ai.name)); + + mIsSuspended = (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; + + mResolvedIntent = intent; + } + + private DisplayResolveInfo(DisplayResolveInfo other, Intent fillInIntent, int flags, + ResolveInfoPresentationGetter resolveInfoPresentationGetter) { + mSourceIntents.addAll(other.getAllSourceIntents()); + mResolveInfo = other.mResolveInfo; + mDisplayLabel = other.mDisplayLabel; + mDisplayIcon = other.mDisplayIcon; + mExtendedInfo = other.mExtendedInfo; + mResolvedIntent = new Intent(other.mResolvedIntent); + mResolvedIntent.fillIn(fillInIntent, flags); + mResolveInfoPresentationGetter = resolveInfoPresentationGetter; + } + + DisplayResolveInfo(DisplayResolveInfo other) { + mSourceIntents.addAll(other.getAllSourceIntents()); + mResolveInfo = other.mResolveInfo; + mDisplayLabel = other.mDisplayLabel; + mDisplayIcon = other.mDisplayIcon; + mExtendedInfo = other.mExtendedInfo; + mResolvedIntent = other.mResolvedIntent; + mResolveInfoPresentationGetter = other.mResolveInfoPresentationGetter; + } + + public ResolveInfo getResolveInfo() { + return mResolveInfo; + } + + public CharSequence getDisplayLabel() { + if (mDisplayLabel == null && mResolveInfoPresentationGetter != null) { + mDisplayLabel = mResolveInfoPresentationGetter.getLabel(); + mExtendedInfo = mResolveInfoPresentationGetter.getSubLabel(); + } + return mDisplayLabel; + } + + public boolean hasDisplayLabel() { + return mDisplayLabel != null; + } + + public void setDisplayLabel(CharSequence displayLabel) { + mDisplayLabel = displayLabel; + } + + public void setExtendedInfo(CharSequence extendedInfo) { + mExtendedInfo = extendedInfo; + } + + public Drawable getDisplayIcon(Context context) { + return mDisplayIcon; + } + + @Override + public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { + return new DisplayResolveInfo(this, fillInIntent, flags, mResolveInfoPresentationGetter); + } + + @Override + public List<Intent> getAllSourceIntents() { + return mSourceIntents; + } + + public void addAlternateSourceIntent(Intent alt) { + mSourceIntents.add(alt); + } + + public void setDisplayIcon(Drawable icon) { + mDisplayIcon = icon; + } + + public boolean hasDisplayIcon() { + return mDisplayIcon != null; + } + + public CharSequence getExtendedInfo() { + return mExtendedInfo; + } + + public Intent getResolvedIntent() { + return mResolvedIntent; + } + + @Override + public ComponentName getResolvedComponentName() { + return new ComponentName(mResolveInfo.activityInfo.packageName, + mResolveInfo.activityInfo.name); + } + + @Override + public boolean start(Activity activity, Bundle options) { + activity.startActivity(mResolvedIntent, options); + return true; + } + + @Override + public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + prepareIntentForCrossProfileLaunch(mResolvedIntent, userId); + activity.startActivityAsCaller(mResolvedIntent, options, false, userId); + return true; + } + + @Override + public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { + prepareIntentForCrossProfileLaunch(mResolvedIntent, user.getIdentifier()); + activity.startActivityAsUser(mResolvedIntent, options, user); + return false; + } + + public boolean isSuspended() { + return mIsSuspended; + } + + @Override + public boolean isPinned() { + return mPinned; + } + + public void setPinned(boolean pinned) { + mPinned = pinned; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeCharSequence(mDisplayLabel); + dest.writeCharSequence(mExtendedInfo); + dest.writeParcelable(mResolvedIntent, 0); + dest.writeTypedList(mSourceIntents); + dest.writeBoolean(mIsSuspended); + dest.writeBoolean(mPinned); + dest.writeParcelable(mResolveInfo, 0); + } + + public static final Parcelable.Creator<DisplayResolveInfo> CREATOR = + new Parcelable.Creator<DisplayResolveInfo>() { + public DisplayResolveInfo createFromParcel(Parcel in) { + return new DisplayResolveInfo(in); + } + + public DisplayResolveInfo[] newArray(int size) { + return new DisplayResolveInfo[size]; + } + }; + + private static void prepareIntentForCrossProfileLaunch(Intent intent, int targetUserId) { + final int currentUserId = UserHandle.myUserId(); + if (targetUserId != currentUserId) { + intent.fixUris(currentUserId); + } + } + + private DisplayResolveInfo(Parcel in) { + mDisplayLabel = in.readCharSequence(); + mExtendedInfo = in.readCharSequence(); + mResolvedIntent = in.readParcelable(null /* ClassLoader */, android.content.Intent.class); + in.readTypedList(mSourceIntents, Intent.CREATOR); + mIsSuspended = in.readBoolean(); + mPinned = in.readBoolean(); + mResolveInfo = in.readParcelable(null /* ClassLoader */, android.content.pm.ResolveInfo.class); + } +} diff --git a/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java new file mode 100644 index 00000000..5133d997 --- /dev/null +++ b/java/src/com/android/intentresolver/chooser/MultiDisplayResolveInfo.java @@ -0,0 +1,100 @@ +/* + * 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.chooser; + +import android.app.Activity; +import android.os.Bundle; +import android.os.UserHandle; + +import com.android.intentresolver.ResolverActivity; + +import java.util.ArrayList; + +/** + * Represents a "stack" of chooser targets for various activities within the same component. + */ +public class MultiDisplayResolveInfo extends DisplayResolveInfo { + + ArrayList<DisplayResolveInfo> mTargetInfos = new ArrayList<>(); + // We'll use this DRI for basic presentation info - eg icon, name. + final DisplayResolveInfo mBaseInfo; + // Index of selected target + private int mSelected = -1; + + /** + * @param firstInfo A representative DRI to use for the main icon, title, etc for this Info. + */ + public MultiDisplayResolveInfo(String packageName, DisplayResolveInfo firstInfo) { + super(firstInfo); + mBaseInfo = firstInfo; + mTargetInfos.add(firstInfo); + } + + @Override + public CharSequence getExtendedInfo() { + // Never show subtitle for stacked apps + return null; + } + + /** + * Add another DisplayResolveInfo to the list included for this target. + */ + public void addTarget(DisplayResolveInfo target) { + mTargetInfos.add(target); + } + + /** + * List of all DisplayResolveInfos included in this target. + */ + public ArrayList<DisplayResolveInfo> getTargets() { + return mTargetInfos; + } + + public void setSelected(int selected) { + mSelected = selected; + } + + /** + * Return selected target. + */ + public DisplayResolveInfo getSelectedTarget() { + return hasSelected() ? mTargetInfos.get(mSelected) : null; + } + + /** + * Whether or not the user has selected a specific target for this MultiInfo. + */ + public boolean hasSelected() { + return mSelected >= 0; + } + + @Override + public boolean start(Activity activity, Bundle options) { + return mTargetInfos.get(mSelected).start(activity, options); + } + + @Override + public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + return mTargetInfos.get(mSelected).startAsCaller(activity, options, userId); + } + + @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 new file mode 100644 index 00000000..220870f2 --- /dev/null +++ b/java/src/com/android/intentresolver/chooser/NotSelectableTargetInfo.java @@ -0,0 +1,92 @@ +/* + * 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.chooser; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import android.os.UserHandle; +import android.service.chooser.ChooserTarget; + +import com.android.intentresolver.ResolverActivity; + +import java.util.List; + +/** + * Distinguish between targets that selectable by the user, vs those that are + * placeholders for the system while information is loading in an async manner. + */ +public abstract class NotSelectableTargetInfo implements ChooserTargetInfo { + + public Intent getResolvedIntent() { + return null; + } + + public ComponentName getResolvedComponentName() { + return null; + } + + public boolean start(Activity activity, Bundle options) { + return false; + } + + public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + return false; + } + + public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { + return false; + } + + public ResolveInfo getResolveInfo() { + return null; + } + + public CharSequence getDisplayLabel() { + return null; + } + + public CharSequence getExtendedInfo() { + return null; + } + + public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { + return null; + } + + public List<Intent> getAllSourceIntents() { + return null; + } + + public float getModifiedScore() { + return -0.1f; + } + + public ChooserTarget getChooserTarget() { + return null; + } + + public boolean isSuspended() { + return false; + } + + public boolean isPinned() { + return false; + } +} diff --git a/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java new file mode 100644 index 00000000..1610d0fd --- /dev/null +++ b/java/src/com/android/intentresolver/chooser/SelectableTargetInfo.java @@ -0,0 +1,339 @@ +/* + * 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.chooser; + +import android.annotation.Nullable; +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.LauncherApps; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ShortcutInfo; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.os.Bundle; +import android.os.UserHandle; +import android.service.chooser.ChooserTarget; +import android.text.SpannableStringBuilder; +import android.util.Log; + +import com.android.intentresolver.ChooserActivity; +import com.android.intentresolver.ResolverActivity; +import com.android.intentresolver.ResolverListAdapter.ActivityInfoPresentationGetter; +import com.android.intentresolver.SimpleIconFactory; + +import com.android.internal.annotations.GuardedBy; + +import java.util.ArrayList; +import java.util.List; + +/** + * Live target, currently selectable by the user. + * @see NotSelectableTargetInfo + */ +public final class SelectableTargetInfo implements ChooserTargetInfo { + private static final String TAG = "SelectableTargetInfo"; + + private final Context mContext; + private final DisplayResolveInfo mSourceInfo; + private final ResolveInfo mBackupResolveInfo; + private final ChooserTarget mChooserTarget; + private final String mDisplayLabel; + private final PackageManager mPm; + private final SelectableTargetInfoCommunicator mSelectableTargetInfoCommunicator; + @GuardedBy("this") + private ShortcutInfo mShortcutInfo; + private Drawable mBadgeIcon = null; + private CharSequence mBadgeContentDescription; + @GuardedBy("this") + private Drawable mDisplayIcon; + private final Intent mFillInIntent; + private final int mFillInFlags; + private final boolean mIsPinned; + private final float mModifiedScore; + private boolean mIsSuspended = false; + + public SelectableTargetInfo(Context context, DisplayResolveInfo sourceInfo, + ChooserTarget chooserTarget, + float modifiedScore, SelectableTargetInfoCommunicator selectableTargetInfoComunicator, + @Nullable ShortcutInfo shortcutInfo) { + mContext = context; + mSourceInfo = sourceInfo; + mChooserTarget = chooserTarget; + mModifiedScore = modifiedScore; + mPm = mContext.getPackageManager(); + mSelectableTargetInfoCommunicator = selectableTargetInfoComunicator; + mShortcutInfo = shortcutInfo; + mIsPinned = shortcutInfo != null && shortcutInfo.isPinned(); + if (sourceInfo != null) { + final ResolveInfo ri = sourceInfo.getResolveInfo(); + if (ri != null) { + final ActivityInfo ai = ri.activityInfo; + if (ai != null && ai.applicationInfo != null) { + final PackageManager pm = mContext.getPackageManager(); + mBadgeIcon = pm.getApplicationIcon(ai.applicationInfo); + mBadgeContentDescription = pm.getApplicationLabel(ai.applicationInfo); + mIsSuspended = + (ai.applicationInfo.flags & ApplicationInfo.FLAG_SUSPENDED) != 0; + } + } + } + + if (sourceInfo != null) { + mBackupResolveInfo = null; + } else { + mBackupResolveInfo = + mContext.getPackageManager().resolveActivity(getResolvedIntent(), 0); + } + + mFillInIntent = null; + mFillInFlags = 0; + + mDisplayLabel = sanitizeDisplayLabel(chooserTarget.getTitle()); + } + + private SelectableTargetInfo(SelectableTargetInfo other, + Intent fillInIntent, int flags) { + mContext = other.mContext; + mPm = other.mPm; + mSelectableTargetInfoCommunicator = other.mSelectableTargetInfoCommunicator; + mSourceInfo = other.mSourceInfo; + mBackupResolveInfo = other.mBackupResolveInfo; + mChooserTarget = other.mChooserTarget; + mBadgeIcon = other.mBadgeIcon; + mBadgeContentDescription = other.mBadgeContentDescription; + synchronized (other) { + mShortcutInfo = other.mShortcutInfo; + mDisplayIcon = other.mDisplayIcon; + } + mFillInIntent = fillInIntent; + mFillInFlags = flags; + mModifiedScore = other.mModifiedScore; + mIsPinned = other.mIsPinned; + + mDisplayLabel = sanitizeDisplayLabel(mChooserTarget.getTitle()); + } + + private String sanitizeDisplayLabel(CharSequence label) { + SpannableStringBuilder sb = new SpannableStringBuilder(label); + sb.clearSpans(); + return sb.toString(); + } + + public boolean isSuspended() { + return mIsSuspended; + } + + public DisplayResolveInfo getDisplayResolveInfo() { + return mSourceInfo; + } + + /** + * Load display icon, if needed. + */ + public void loadIcon() { + ShortcutInfo shortcutInfo; + Drawable icon; + synchronized (this) { + shortcutInfo = mShortcutInfo; + icon = mDisplayIcon; + } + if (icon == null && shortcutInfo != null) { + icon = getChooserTargetIconDrawable(mChooserTarget, shortcutInfo); + synchronized (this) { + mDisplayIcon = icon; + mShortcutInfo = null; + } + } + } + + private Drawable getChooserTargetIconDrawable(ChooserTarget target, + @Nullable ShortcutInfo shortcutInfo) { + Drawable directShareIcon = null; + + // First get the target drawable and associated activity info + final Icon icon = target.getIcon(); + if (icon != null) { + directShareIcon = icon.loadDrawable(mContext); + } else if (shortcutInfo != null) { + LauncherApps launcherApps = (LauncherApps) mContext.getSystemService( + Context.LAUNCHER_APPS_SERVICE); + directShareIcon = launcherApps.getShortcutIconDrawable(shortcutInfo, 0); + } + + if (directShareIcon == null) return null; + + ActivityInfo info = null; + try { + info = mPm.getActivityInfo(target.getComponentName(), 0); + } catch (PackageManager.NameNotFoundException error) { + Log.e(TAG, "Could not find activity associated with ChooserTarget"); + } + + if (info == null) return null; + + // Now fetch app icon and raster with no badging even in work profile + Bitmap appIcon = mSelectableTargetInfoCommunicator.makePresentationGetter(info) + .getIconBitmap(null); + + // Raster target drawable with appIcon as a badge + SimpleIconFactory sif = SimpleIconFactory.obtain(mContext); + Bitmap directShareBadgedIcon = sif.createAppBadgedIconBitmap(directShareIcon, appIcon); + sif.recycle(); + + return new BitmapDrawable(mContext.getResources(), directShareBadgedIcon); + } + + public float getModifiedScore() { + return mModifiedScore; + } + + @Override + public Intent getResolvedIntent() { + if (mSourceInfo != null) { + return mSourceInfo.getResolvedIntent(); + } + + final Intent targetIntent = new Intent(mSelectableTargetInfoCommunicator.getTargetIntent()); + targetIntent.setComponent(mChooserTarget.getComponentName()); + targetIntent.putExtras(mChooserTarget.getIntentExtras()); + return targetIntent; + } + + @Override + public ComponentName getResolvedComponentName() { + if (mSourceInfo != null) { + return mSourceInfo.getResolvedComponentName(); + } else if (mBackupResolveInfo != null) { + return new ComponentName(mBackupResolveInfo.activityInfo.packageName, + mBackupResolveInfo.activityInfo.name); + } + return null; + } + + private Intent getBaseIntentToSend() { + Intent result = getResolvedIntent(); + if (result == null) { + Log.e(TAG, "ChooserTargetInfo: no base intent available to send"); + } else { + result = new Intent(result); + if (mFillInIntent != null) { + result.fillIn(mFillInIntent, mFillInFlags); + } + result.fillIn(mSelectableTargetInfoCommunicator.getReferrerFillInIntent(), 0); + } + return result; + } + + @Override + public boolean start(Activity activity, Bundle options) { + throw new RuntimeException("ChooserTargets should be started as caller."); + } + + @Override + public boolean startAsCaller(ResolverActivity activity, Bundle options, int userId) { + final Intent intent = getBaseIntentToSend(); + if (intent == null) { + return false; + } + intent.setComponent(mChooserTarget.getComponentName()); + intent.putExtras(mChooserTarget.getIntentExtras()); + + // Important: we will ignore the target security checks in ActivityManager + // if and only if the ChooserTarget's target package is the same package + // where we got the ChooserTargetService that provided it. This lets a + // ChooserTargetService provide a non-exported or permission-guarded target + // to the chooser for the user to pick. + // + // If mSourceInfo is null, we got this ChooserTarget from the caller or elsewhere + // so we'll obey the caller's normal security checks. + final boolean ignoreTargetSecurity = mSourceInfo != null + && mSourceInfo.getResolvedComponentName().getPackageName() + .equals(mChooserTarget.getComponentName().getPackageName()); + activity.startActivityAsCaller(intent, options, ignoreTargetSecurity, userId); + return true; + } + + @Override + public boolean startAsUser(Activity activity, Bundle options, UserHandle user) { + throw new RuntimeException("ChooserTargets should be started as caller."); + } + + @Override + public ResolveInfo getResolveInfo() { + return mSourceInfo != null ? mSourceInfo.getResolveInfo() : mBackupResolveInfo; + } + + @Override + public CharSequence getDisplayLabel() { + return mDisplayLabel; + } + + @Override + public CharSequence getExtendedInfo() { + // ChooserTargets have badge icons, so we won't show the extended info to disambiguate. + return null; + } + + @Override + public synchronized Drawable getDisplayIcon(Context context) { + return mDisplayIcon; + } + + public ChooserTarget getChooserTarget() { + return mChooserTarget; + } + + @Override + public TargetInfo cloneFilledIn(Intent fillInIntent, int flags) { + return new SelectableTargetInfo(this, fillInIntent, flags); + } + + @Override + public List<Intent> getAllSourceIntents() { + final List<Intent> results = new ArrayList<>(); + if (mSourceInfo != null) { + // We only queried the service for the first one in our sourceinfo. + results.add(mSourceInfo.getAllSourceIntents().get(0)); + } + return results; + } + + @Override + public boolean isPinned() { + return mIsPinned; + } + + /** + * Necessary methods to communicate between {@link SelectableTargetInfo} + * and {@link ResolverActivity} or {@link ChooserActivity}. + */ + public interface SelectableTargetInfoCommunicator { + + ActivityInfoPresentationGetter makePresentationGetter(ActivityInfo info); + + Intent getTargetIntent(); + + Intent getReferrerFillInIntent(); + } +} diff --git a/java/src/com/android/intentresolver/chooser/TargetInfo.java b/java/src/com/android/intentresolver/chooser/TargetInfo.java new file mode 100644 index 00000000..fabb26c2 --- /dev/null +++ b/java/src/com/android/intentresolver/chooser/TargetInfo.java @@ -0,0 +1,133 @@ +/* + * 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.chooser; + + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.UserHandle; + +import com.android.intentresolver.ResolverActivity; + +import java.util.List; + +/** + * A single target as represented in the chooser. + */ +public interface TargetInfo { + /** + * Get the resolved intent that represents this target. Note that this may not be the + * intent that will be launched by calling one of the <code>start</code> methods provided; + * this is the intent that will be credited with the launch. + * + * @return the resolved intent for this target + */ + Intent getResolvedIntent(); + + /** + * Get the resolved component name that represents this target. Note that this may not + * be the component that will be directly launched by calling one of the <code>start</code> + * methods provided; this is the component that will be credited with the launch. + * + * @return the resolved ComponentName for this target + */ + ComponentName getResolvedComponentName(); + + /** + * Start the activity referenced by this target. + * + * @param activity calling Activity performing the launch + * @param options ActivityOptions bundle + * @return true if the start completed successfully + */ + boolean start(Activity activity, Bundle options); + + /** + * Start the activity referenced by this target as if the ResolverActivity's caller + * was performing the start operation. + * + * @param activity calling Activity (actually) performing the launch + * @param options ActivityOptions bundle + * @param userId userId to start as or {@link UserHandle#USER_NULL} for activity's caller + * @return true if the start completed successfully + */ + boolean startAsCaller(ResolverActivity activity, Bundle options, int userId); + + /** + * Start the activity referenced by this target as a given user. + * + * @param activity calling activity performing the launch + * @param options ActivityOptions bundle + * @param user handle for the user to start the activity as + * @return true if the start completed successfully + */ + boolean startAsUser(Activity activity, Bundle options, UserHandle user); + + /** + * Return the ResolveInfo about how and why this target matched the original query + * for available targets. + * + * @return ResolveInfo representing this target's match + */ + ResolveInfo getResolveInfo(); + + /** + * Return the human-readable text label for this target. + * + * @return user-visible target label + */ + CharSequence getDisplayLabel(); + + /** + * Return any extended info for this target. This may be used to disambiguate + * otherwise identical targets. + * + * @return human-readable disambig string or null if none present + */ + CharSequence getExtendedInfo(); + + /** + * @return The drawable that should be used to represent this target including badge + * @param context + */ + Drawable getDisplayIcon(Context context); + + /** + * Clone this target with the given fill-in information. + */ + TargetInfo cloneFilledIn(Intent fillInIntent, int flags); + + /** + * @return the list of supported source intents deduped against this single target + */ + List<Intent> getAllSourceIntents(); + + /** + * @return true if this target cannot be selected by the user + */ + boolean isSuspended(); + + /** + * @return true if this target should be pinned to the front by the request of the user + */ + boolean isPinned(); +} diff --git a/java/tests/AndroidManifest.xml b/java/tests/AndroidManifest.xml index 1c7506b6..bfe3a39f 100644 --- a/java/tests/AndroidManifest.xml +++ b/java/tests/AndroidManifest.xml @@ -27,7 +27,6 @@ <application> <uses-library android:name="android.test.runner" /> <activity android:name="com.android.intentresolver.ChooserWrapperActivity" /> - <activity android:name="com.android.internal.app.ChooserWrapperActivity" /> </application> <instrumentation android:name="android.testing.TestableInstrumentation" diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java new file mode 100644 index 00000000..e4146cc5 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserActivityLoggerFake.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.intentresolver; + +import com.android.internal.logging.InstanceId; +import com.android.internal.logging.UiEventLogger; +import com.android.internal.util.FrameworkStatsLog; + +import java.util.ArrayList; +import java.util.List; + +public class ChooserActivityLoggerFake implements ChooserActivityLogger { + static class CallRecord { + // shared fields between all logs + public int atomId; + public String packageName; + public InstanceId instanceId; + + // generic log field + public UiEventLogger.UiEventEnum event; + + // share started fields + public String mimeType; + public int appProvidedDirect; + public int appProvidedApp; + public boolean isWorkprofile; + public int previewType; + public String intent; + + // share completed fields + public int targetType; + public int positionPicked; + public boolean isPinned; + + CallRecord(int atomId, UiEventLogger.UiEventEnum eventId, + String packageName, InstanceId instanceId) { + this.atomId = atomId; + this.packageName = packageName; + this.instanceId = instanceId; + this.event = eventId; + } + + CallRecord(int atomId, String packageName, InstanceId instanceId, String mimeType, + int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, + String intent) { + this.atomId = atomId; + this.packageName = packageName; + this.instanceId = instanceId; + this.mimeType = mimeType; + this.appProvidedDirect = appProvidedDirect; + this.appProvidedApp = appProvidedApp; + this.isWorkprofile = isWorkprofile; + this.previewType = previewType; + this.intent = intent; + } + + CallRecord(int atomId, String packageName, InstanceId instanceId, int targetType, + int positionPicked, boolean isPinned) { + this.atomId = atomId; + this.packageName = packageName; + this.instanceId = instanceId; + this.targetType = targetType; + this.positionPicked = positionPicked; + this.isPinned = isPinned; + } + + } + private List<CallRecord> mCalls = new ArrayList<>(); + + public int numCalls() { + return mCalls.size(); + } + + List<CallRecord> getCalls() { + return mCalls; + } + + CallRecord get(int index) { + return mCalls.get(index); + } + + UiEventLogger.UiEventEnum event(int index) { + return mCalls.get(index).event; + } + + public void removeCallsForUiEventsOfType(int uiEventType) { + mCalls.removeIf( + call -> + (call.atomId == FrameworkStatsLog.UI_EVENT_REPORTED) + && (call.event.getId() == uiEventType)); + } + + @Override + public void logShareStarted(int eventId, String packageName, String mimeType, + int appProvidedDirect, int appProvidedApp, boolean isWorkprofile, int previewType, + String intent) { + mCalls.add(new CallRecord(FrameworkStatsLog.SHARESHEET_STARTED, packageName, + getInstanceId(), mimeType, appProvidedDirect, appProvidedApp, isWorkprofile, + previewType, intent)); + } + + @Override + public void logShareTargetSelected(int targetType, String packageName, int positionPicked, + boolean isPinned) { + mCalls.add(new CallRecord(FrameworkStatsLog.RANKING_SELECTED, packageName, getInstanceId(), + SharesheetTargetSelectedEvent.fromTargetType(targetType).getId(), positionPicked, + isPinned)); + } + + @Override + public void log(UiEventLogger.UiEventEnum event, InstanceId instanceId) { + mCalls.add(new CallRecord(FrameworkStatsLog.UI_EVENT_REPORTED, + event, "", instanceId)); + } + + @Override + public InstanceId getInstanceId() { + return InstanceId.fakeInstanceId(-1); + } +} diff --git a/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java new file mode 100644 index 00000000..080f1e41 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ChooserActivityOverrideData.java @@ -0,0 +1,115 @@ +/* + * 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.Mockito.mock; + +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.os.UserHandle; + +import com.android.intentresolver.chooser.TargetInfo; +import com.android.internal.logging.MetricsLogger; + +import java.util.List; +import java.util.function.Function; + +/** + * Singleton providing overrides to be applied by any {@code IChooserWrapper} used in testing. + * We cannot directly mock the activity created since instrumentation creates it, so instead we use + * this singleton to modify behavior. + */ +public class ChooserActivityOverrideData { + private static ChooserActivityOverrideData sInstance = null; + + public static ChooserActivityOverrideData getInstance() { + if (sInstance == null) { + sInstance = new ChooserActivityOverrideData(); + } + return sInstance; + } + + @SuppressWarnings("Since15") + public Function<PackageManager, PackageManager> createPackageManager; + public Function<TargetInfo, Boolean> onSafelyStartCallback; + public Function<ChooserListAdapter, Void> onQueryDirectShareTargets; + public ResolverListController resolverListController; + public ResolverListController workResolverListController; + public Boolean isVoiceInteraction; + public boolean isImageType; + public Cursor resolverCursor; + public boolean resolverForceException; + public Bitmap previewThumbnail; + public MetricsLogger metricsLogger; + public ChooserActivityLogger chooserActivityLogger; + public int alternateProfileSetting; + public Resources resources; + public UserHandle workProfileUserHandle; + public boolean hasCrossProfileIntents; + public boolean isQuietModeEnabled; + public boolean isWorkProfileUserRunning; + public boolean isWorkProfileUserUnlocked; + public AbstractMultiProfilePagerAdapter.Injector multiPagerAdapterInjector; + public PackageManager packageManager; + + public void reset() { + onSafelyStartCallback = null; + onQueryDirectShareTargets = null; + isVoiceInteraction = null; + createPackageManager = null; + previewThumbnail = null; + isImageType = false; + resolverCursor = null; + resolverForceException = false; + resolverListController = mock(ResolverListController.class); + workResolverListController = mock(ResolverListController.class); + metricsLogger = mock(MetricsLogger.class); + chooserActivityLogger = new ChooserActivityLoggerFake(); + alternateProfileSetting = 0; + resources = null; + workProfileUserHandle = null; + hasCrossProfileIntents = true; + isQuietModeEnabled = false; + isWorkProfileUserRunning = true; + isWorkProfileUserUnlocked = true; + packageManager = null; + multiPagerAdapterInjector = new AbstractMultiProfilePagerAdapter.Injector() { + @Override + public boolean hasCrossProfileIntents(List<Intent> intents, int sourceUserId, + int targetUserId) { + return hasCrossProfileIntents; + } + + @Override + public boolean isQuietModeEnabled(UserHandle workProfileUserHandle) { + return isQuietModeEnabled; + } + + @Override + public void requestQuietModeEnabled(boolean enabled, + UserHandle workProfileUserHandle) { + isQuietModeEnabled = enabled; + } + }; + } + + private ChooserActivityOverrideData() {} +} + diff --git a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java index 552b4e0d..0e9f010e 100644 --- a/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java +++ b/java/tests/src/com/android/intentresolver/ChooserWrapperActivity.java @@ -33,15 +33,15 @@ import android.net.Uri; import android.os.UserHandle; import android.util.Size; -import com.android.internal.app.AbstractMultiProfilePagerAdapter; -import com.android.internal.app.ChooserActivityLogger; -import com.android.internal.app.ChooserActivityOverrideData; -import com.android.internal.app.ChooserListAdapter; -import com.android.internal.app.IChooserWrapper; -import com.android.internal.app.ResolverListAdapter.ResolveInfoPresentationGetter; -import com.android.internal.app.ResolverListController; -import com.android.internal.app.chooser.DisplayResolveInfo; -import com.android.internal.app.chooser.TargetInfo; +import com.android.intentresolver.AbstractMultiProfilePagerAdapter; +import com.android.intentresolver.ChooserActivityLogger; +import com.android.intentresolver.ChooserActivityOverrideData; +import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.IChooserWrapper; +import com.android.intentresolver.ResolverListAdapter.ResolveInfoPresentationGetter; +import com.android.intentresolver.ResolverListController; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.intentresolver.chooser.TargetInfo; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.nano.MetricsProto.MetricsEvent; @@ -139,7 +139,7 @@ public class ChooserWrapperActivity } @Override - public void safelyStartActivity(com.android.internal.app.chooser.TargetInfo cti) { + public void safelyStartActivity(com.android.intentresolver.chooser.TargetInfo cti) { if (sOverrides.onSafelyStartCallback != null && sOverrides.onSafelyStartCallback.apply(cti)) { return; diff --git a/java/tests/src/com/android/intentresolver/IChooserWrapper.java b/java/tests/src/com/android/intentresolver/IChooserWrapper.java new file mode 100644 index 00000000..f81cd023 --- /dev/null +++ b/java/tests/src/com/android/intentresolver/IChooserWrapper.java @@ -0,0 +1,44 @@ +/* + * 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.ResolverListAdapter.ResolveInfoPresentationGetter; +import com.android.intentresolver.chooser.DisplayResolveInfo; + +/** + * 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 ResolveInfoPresentationGetter resolveInfoPresentationGetter); + UserHandle getCurrentUserHandle(); + ChooserActivityLogger getChooserActivityLogger(); +} diff --git a/java/tests/src/com/android/intentresolver/MatcherUtils.java b/java/tests/src/com/android/intentresolver/MatcherUtils.java new file mode 100644 index 00000000..6168968b --- /dev/null +++ b/java/tests/src/com/android/intentresolver/MatcherUtils.java @@ -0,0 +1,51 @@ +/* + * 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/ResolverDataProvider.java b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java new file mode 100644 index 00000000..33e7123f --- /dev/null +++ b/java/tests/src/com/android/intentresolver/ResolverDataProvider.java @@ -0,0 +1,169 @@ +/* + * 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 + */ +class ResolverDataProvider { + + static private int USER_SOMEONE_ELSE = 10; + + static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfo(int i) { + return new ResolverActivity.ResolvedComponentInfo(createComponentName(i), + createResolverIntent(i), createResolveInfo(i, UserHandle.USER_CURRENT)); + } + + static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i) { + return new ResolverActivity.ResolvedComponentInfo(createComponentName(i), + createResolverIntent(i), createResolveInfo(i, USER_SOMEONE_ELSE)); + } + + static ResolverActivity.ResolvedComponentInfo createResolvedComponentInfoWithOtherId(int i, + int userId) { + return new ResolverActivity.ResolvedComponentInfo(createComponentName(i), + createResolverIntent(i), createResolveInfo(i, userId)); + } + + static ComponentName createComponentName(int i) { + final String name = "component" + i; + return new ComponentName("foo.bar." + name, name); + } + + static ResolveInfo createResolveInfo(int i, int userId) { + final ResolveInfo resolveInfo = new ResolveInfo(); + resolveInfo.activityInfo = createActivityInfo(i); + resolveInfo.targetUserId = userId; + 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 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; + } + + static PackageManagerMockedInfo createPackageManagerMockedInfo(boolean hasOverridePermission) { + final String appLabel = "app_label"; + final String activityLabel = "activity_label"; + final String resolveInfoLabel = "resolve_info_label"; + + 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/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index f89b4586..b901fc1e 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 The Android Open Source Project + * 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. @@ -16,32 +16,132 @@ 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.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.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.assertFalse; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThan; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.app.usage.UsageStatsManager; +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.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.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.drawable.Icon; +import android.metrics.LogMaker; +import android.net.Uri; +import android.os.UserHandle; +import android.provider.DeviceConfig; +import android.service.chooser.ChooserTarget; +import android.view.View; +import androidx.annotation.CallSuper; +import androidx.test.espresso.matcher.BoundedDiagnosingMatcher; import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; -import com.android.internal.app.ChooserActivity; -import com.android.internal.app.ChooserActivityTest; +import com.android.intentresolver.ResolverActivity.ResolvedComponentInfo; +import com.android.intentresolver.chooser.DisplayResolveInfo; +import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.internal.util.FrameworkStatsLog; +import com.android.internal.widget.GridLayoutManager; +import com.android.internal.widget.RecyclerView; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.junit.Before; import org.junit.Ignore; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; 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). + */ @Ignore("investigate b/241944046 and re-enabled") @RunWith(Parameterized.class) -public class UnbundledChooserActivityTest extends ChooserActivityTest { +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 Function<PackageManager, PackageManager> DEFAULT_PM = pm -> pm; private static final Function<PackageManager, PackageManager> NO_APP_PREDICTION_SERVICE_PM = pm -> { @@ -58,21 +158,16 @@ public class UnbundledChooserActivityTest extends ChooserActivityTest { }); } - @Override - protected Intent getConcreteIntentForLaunch(Intent clientIntent) { - Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); - clientIntent.setClass(context, com.android.intentresolver.ChooserWrapperActivity.class); - return clientIntent; - } - - @Override - protected boolean shouldTestTogglingAppPredictionServiceAvailabilityAtRuntime() { - // Unbundled chooser takes in app prediction availability as a parameter from the system, so - // changing the availability conditions after the fact won't make a difference. - return false; - } + /* -------- + * Subclasses can override the following methods to customize test behavior. + * -------- + */ - @Override + /** + * 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). @@ -81,14 +176,2727 @@ public class UnbundledChooserActivityTest extends ChooserActivityTest { .getUiAutomation() .adoptShellPermissionIdentity(); - super.setup(); + cleanOverrideData(); + } + + /** + * 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. + * -------- + */ + + // 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. + @Rule + public 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 Function<PackageManager, PackageManager> mPackageManagerOverride; + private int mTestNum; + + public UnbundledChooserActivityTest( int testNum, String testName, Function<PackageManager, PackageManager> packageManagerOverride) { - super(testNum, testName, packageManagerOverride); + mPackageManagerOverride = packageManagerOverride; + mTestNum = testNum; + } + + public void cleanOverrideData() { + ChooserActivityOverrideData.getInstance().reset(); + ChooserActivityOverrideData.getInstance().createPackageManager = mPackageManagerOverride; + DeviceConfig.setProperty(DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(true), + true /* makeDefault*/); + } + + @Test + public void customTitle() throws InterruptedException { + Intent viewIntent = createViewTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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(withIdFromRuntimeResource("title")).check(matches(withText("chooser test"))); + } + + @Test + public void customTitleIgnoredForSendIntents() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "chooser test")); + waitForIdle(); + onView(withIdFromRuntimeResource("title")) + .check(matches(withTextFromRuntimeResource("whichSendApplication"))); + } + + @Test + public void emptyTitle() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withIdFromRuntimeResource("title")) + .check(matches(withTextFromRuntimeResource("whichSendApplication"))); + } + + @Test + public void emptyPreviewTitleAndThumbnail() throws InterruptedException { + Intent sendIntent = createSendTextIntentWithPreview(null, null); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withIdFromRuntimeResource("content_preview_title")) + .check(matches(not(isDisplayed()))); + onView(withIdFromRuntimeResource("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); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withIdFromRuntimeResource("content_preview_title")) + .check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_title")) + .check(matches(withText(previewTitle))); + onView(withIdFromRuntimeResource("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); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withIdFromRuntimeResource("content_preview_title")).check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_thumbnail")) + .check(matches(not(isDisplayed()))); + } + + @Test + public void visiblePreviewTitleAndThumbnail() throws InterruptedException { + String previewTitle = "My Content Preview Title"; + Intent sendIntent = createSendTextIntentWithPreview(previewTitle, + Uri.parse("android.resource://com.android.frameworks.coretests/" + + com.android.frameworks.coretests.R.drawable.test320x240)); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withIdFromRuntimeResource("content_preview_title")).check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_thumbnail")) + .check(matches(isDisplayed())); + } + + @Test @Ignore + public void twoOptionsAndUserSelectsOne() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + assertThat(activity.getAdapter().getCount(), is(2)); + onView(withIdFromRuntimeResource("profile_button")).check(doesNotExist()); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = 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); + 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); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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().onSafelyStartCallback = 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); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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().onSafelyStartCallback = targetInfo -> { + return true; + }; + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + waitForIdle(); + verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) + .updateChooserCounts(Mockito.anyString(), anyInt(), Mockito.anyString()); + verify(ChooserActivityOverrideData.getInstance().resolverListController, times(1)) + .updateModel(toChoose.activityInfo.getComponentName()); + assertThat(activity.getIsSelected(), is(true)); + } + + @Ignore // b/148158199 + @Test + public void noResultsFromPackageManager() { + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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(withIdFromRuntimeResource("empty")).check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("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().onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(1); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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() throws Exception { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + 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().onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + // Make a stable copy of the components as the original list may be modified + List<ResolvedComponentInfo> stableCopy = + createResolvedComponentsForTestWithOtherProfile(2, /* userId= */ 10); + waitForIdle(); + Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + + 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 { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(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().onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + // Make a stable copy of the components as the original list may be modified + List<ResolvedComponentInfo> stableCopy = + createResolvedComponentsForTestWithOtherProfile(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 { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3); + ResolveInfo toChoose = resolvedComponentInfos.get(1).getResolveInfoAt(0); + + when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(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().onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + // Make a stable copy of the components as the original list may be modified + List<ResolvedComponentInfo> stableCopy = + createResolvedComponentsForTestWithOtherProfile(3); + onView(withText(stableCopy.get(1).getResolveInfoAt(0).activityInfo.name)) + .perform(click()); + waitForIdle(); + assertThat(chosen[0], is(toChoose)); + } + + @Test + public void copyTextToClipboard() throws Exception { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withIdFromRuntimeResource("chooser_copy_button")).check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("chooser_copy_button")).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() throws Exception { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + + MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; + ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withIdFromRuntimeResource("chooser_copy_button")).check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("chooser_copy_button")).perform(click()); + + verify(mockLogger, atLeastOnce()).write(logMakerCaptor.capture()); + + // The last captured event is the selection of the target. + assertThat(logMakerCaptor.getValue().getCategory(), + is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SYSTEM_TARGET)); + assertThat(logMakerCaptor.getValue().getSubtype(), is(1)); + } + + + @Test + @Ignore + public void testNearbyShareLogging() throws Exception { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withIdFromRuntimeResource("chooser_nearby_button")).check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("chooser_nearby_button")).perform(click()); + + ChooserActivityLoggerFake logger = + (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + logger.removeCallsForUiEventsOfType( + ChooserActivityLogger.SharesheetStandardEvent + .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); + + // SHARESHEET_TRIGGERED: + assertThat(logger.event(0).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); + + // SHARESHEET_STARTED: + assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); + assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); + assertThat(logger.get(1).mimeType, is("text/plain")); + assertThat(logger.get(1).packageName, is( + InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); + assertThat(logger.get(1).appProvidedApp, is(0)); + assertThat(logger.get(1).appProvidedDirect, is(0)); + assertThat(logger.get(1).isWorkprofile, is(false)); + assertThat(logger.get(1).previewType, is(3)); + + // SHARESHEET_APP_LOAD_COMPLETE: + assertThat(logger.event(2).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); + + // Next are just artifacts of test set-up: + assertThat(logger.event(3).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); + assertThat(logger.event(4).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); + + // SHARESHEET_NEARBY_TARGET_SELECTED: + assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); + assertThat(logger.get(5).targetType, + is(ChooserActivityLogger + .SharesheetTargetSelectedEvent.SHARESHEET_NEARBY_TARGET_SELECTED.getId())); + + // No more events. + assertThat(logger.numCalls(), is(6)); + } + + + + @Test @Ignore + public void testEditImageLogs() throws Exception { + Intent sendIntent = createSendImageIntent( + Uri.parse("android.resource://com.android.frameworks.coretests/" + + com.android.frameworks.coretests.R.drawable.test320x240)); + + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withIdFromRuntimeResource("chooser_edit_button")).check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("chooser_edit_button")).perform(click()); + + ChooserActivityLoggerFake logger = + (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + logger.removeCallsForUiEventsOfType( + ChooserActivityLogger.SharesheetStandardEvent + .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); + + // SHARESHEET_TRIGGERED: + assertThat(logger.event(0).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); + + // SHARESHEET_STARTED: + assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); + assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); + assertThat(logger.get(1).mimeType, is("image/png")); + assertThat(logger.get(1).packageName, is( + InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); + assertThat(logger.get(1).appProvidedApp, is(0)); + assertThat(logger.get(1).appProvidedDirect, is(0)); + assertThat(logger.get(1).isWorkprofile, is(false)); + assertThat(logger.get(1).previewType, is(1)); + + // SHARESHEET_APP_LOAD_COMPLETE: + assertThat(logger.event(2).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); + + // Next are just artifacts of test set-up: + assertThat(logger.event(3).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); + assertThat(logger.event(4).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); + + // SHARESHEET_EDIT_TARGET_SELECTED: + assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); + assertThat(logger.get(5).targetType, + is(ChooserActivityLogger + .SharesheetTargetSelectedEvent.SHARESHEET_EDIT_TARGET_SELECTED.getId())); + + // No more events. + assertThat(logger.numCalls(), is(6)); + } + + + @Test + public void oneVisibleImagePreview() throws InterruptedException { + Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + + com.android.frameworks.coretests.R.drawable.test320x240); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withIdFromRuntimeResource("content_preview_image_1_large")) + .check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_image_2_large")) + .check(matches(not(isDisplayed()))); + onView(withIdFromRuntimeResource("content_preview_image_2_small")) + .check(matches(not(isDisplayed()))); + onView(withIdFromRuntimeResource("content_preview_image_3_small")) + .check(matches(not(isDisplayed()))); + } + + @Test + public void twoVisibleImagePreview() throws InterruptedException { + Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + + com.android.frameworks.coretests.R.drawable.test320x240); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withIdFromRuntimeResource("content_preview_image_1_large")) + .check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_image_2_large")) + .check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_image_2_small")) + .check(matches(not(isDisplayed()))); + onView(withIdFromRuntimeResource("content_preview_image_3_small")) + .check(matches(not(isDisplayed()))); + } + + @Test + public void threeOrMoreVisibleImagePreview() throws InterruptedException { + Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + + com.android.frameworks.coretests.R.drawable.test320x240); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withIdFromRuntimeResource("content_preview_image_1_large")) + .check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_image_2_large")) + .check(matches(not(isDisplayed()))); + onView(withIdFromRuntimeResource("content_preview_image_2_small")) + .check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_image_3_small")) + .check(matches(isDisplayed())); + } + + @Test + public void testOnCreateLogging() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; + ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); + waitForIdle(); + verify(mockLogger, atLeastOnce()).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(0).getCategory(), + is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN)); + assertThat(logMakerCaptor + .getAllValues().get(0) + .getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS), + is(notNullValue())); + assertThat(logMakerCaptor + .getAllValues().get(0) + .getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE), + is(TEST_MIME_TYPE)); + assertThat(logMakerCaptor + .getAllValues().get(0) + .getSubtype(), + is(MetricsEvent.PARENT_PROFILE)); + } + + @Test + public void testOnCreateLoggingFromWorkProfile() { + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ChooserActivityOverrideData.getInstance().alternateProfileSetting = + MetricsEvent.MANAGED_PROFILE; + MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; + ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "logger test")); + waitForIdle(); + verify(mockLogger, atLeastOnce()).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(0).getCategory(), + is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN)); + assertThat(logMakerCaptor + .getAllValues().get(0) + .getTaggedData(MetricsEvent.FIELD_TIME_TO_APP_TARGETS), + is(notNullValue())); + assertThat(logMakerCaptor + .getAllValues().get(0) + .getTaggedData(MetricsEvent.FIELD_SHARESHEET_MIMETYPE), + is(TEST_MIME_TYPE)); + assertThat(logMakerCaptor + .getAllValues().get(0) + .getSubtype(), + is(MetricsEvent.MANAGED_PROFILE)); + } + + @Test + public void testEmptyPreviewLogging() { + Intent sendIntent = createSendTextIntentWithPreview(null, null); + + MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; + ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "empty preview logger test")); + waitForIdle(); + + verify(mockLogger, Mockito.times(1)).write(logMakerCaptor.capture()); + // First invocation is from onCreate + assertThat(logMakerCaptor.getAllValues().get(0).getCategory(), + is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_SHOWN)); + } + + @Test + public void testTitlePreviewLogging() { + Intent sendIntent = createSendTextIntentWithPreview("TestTitle", null); + + MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; + ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))).thenReturn(resolvedComponentInfos); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + // Second invocation is from onCreate + verify(mockLogger, Mockito.times(2)).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(0).getSubtype(), + is(CONTENT_PREVIEW_TEXT)); + assertThat(logMakerCaptor.getAllValues().get(0).getCategory(), + is(MetricsEvent.ACTION_SHARE_WITH_PREVIEW)); + } + + @Test + public void testImagePreviewLogging() { + Uri uri = Uri.parse("android.resource://com.android.frameworks.coretests/" + + com.android.frameworks.coretests.R.drawable.test320x240); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().previewThumbnail = createBitmap(); + ChooserActivityOverrideData.getInstance().isImageType = true; + + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; + ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + verify(mockLogger, Mockito.times(2)).write(logMakerCaptor.capture()); + // First invocation is from onCreate + assertThat(logMakerCaptor.getAllValues().get(0).getSubtype(), + is(CONTENT_PREVIEW_IMAGE)); + assertThat(logMakerCaptor.getAllValues().get(0).getCategory(), + is(MetricsEvent.ACTION_SHARE_WITH_PREVIEW)); + } + + @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); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withIdFromRuntimeResource("content_preview_filename")).check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_filename")) + .check(matches(withText("app.pdf"))); + onView(withIdFromRuntimeResource("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); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withIdFromRuntimeResource("content_preview_filename")) + .check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_filename")) + .check(matches(withText("app.pdf + 2 files"))); + onView(withIdFromRuntimeResource("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); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + ChooserActivityOverrideData.getInstance().resolverForceException = true; + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + onView(withIdFromRuntimeResource("content_preview_filename")).check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_filename")) + .check(matches(withText("app.pdf"))); + onView(withIdFromRuntimeResource("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); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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(withIdFromRuntimeResource("content_preview_filename")).check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("content_preview_filename")) + .check(matches(withText("app.pdf + 1 file"))); + onView(withIdFromRuntimeResource("content_preview_file_icon")) + .check(matches(isDisplayed())); + } + + @Test + public void testGetBaseScore() { + final float testBaseScore = 0.89f; + + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + 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), "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)); + } + + @Test + public void testConvertToChooserTarget_predictionService() { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + List<ShareShortcutInfo> shortcuts = createShortcuts(activity); + + int[] expectedOrderAllShortcuts = {0, 1, 2, 3}; + float[] expectedScoreAllShortcuts = {1.0f, 0.99f, 0.98f, 0.97f}; + + List<ChooserTarget> chooserTargets = activity.convertToChooserTarget(shortcuts, shortcuts, + null, TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); + assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, + expectedOrderAllShortcuts, expectedScoreAllShortcuts); + + List<ShareShortcutInfo> subset = new ArrayList<>(); + subset.add(shortcuts.get(1)); + subset.add(shortcuts.get(2)); + subset.add(shortcuts.get(3)); + + int[] expectedOrderSubset = {1, 2, 3}; + float[] expectedScoreSubset = {0.99f, 0.98f, 0.97f}; + + chooserTargets = activity.convertToChooserTarget(subset, shortcuts, null, + TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE); + assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, + expectedOrderSubset, expectedScoreSubset); + } + + @Test + public void testConvertToChooserTarget_shortcutManager() { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + List<ShareShortcutInfo> shortcuts = createShortcuts(activity); + + int[] expectedOrderAllShortcuts = {2, 0, 3, 1}; + float[] expectedScoreAllShortcuts = {1.0f, 0.99f, 0.99f, 0.98f}; + + List<ChooserTarget> chooserTargets = activity.convertToChooserTarget(shortcuts, shortcuts, + null, TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER); + assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, + expectedOrderAllShortcuts, expectedScoreAllShortcuts); + + List<ShareShortcutInfo> subset = new ArrayList<>(); + subset.add(shortcuts.get(1)); + subset.add(shortcuts.get(2)); + subset.add(shortcuts.get(3)); + + int[] expectedOrderSubset = {2, 3, 1}; + float[] expectedScoreSubset = {1.0f, 0.99f, 0.98f}; + + chooserTargets = activity.convertToChooserTarget(subset, shortcuts, null, + TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER); + assertCorrectShortcutToChooserTargetConversion(shortcuts, chooserTargets, + expectedOrderSubset, expectedScoreSubset); + } + + // This test is too long and too slow and should not be taken as an example for future tests. + @Test @Ignore + public void testDirectTargetSelectionLogging() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + // Set up resources + MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; + ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); + // Create direct share target + List<ChooserTarget> serviceTargets = createDirectShareTargets(1, ""); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + + // Insert the direct share target + Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); + directShareToShortcutInfos.put(serviceTargets.get(0), null); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> activity.getAdapter().addServiceResults( + activity.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null), + serviceTargets, + TARGET_TYPE_CHOOSER_TARGET, + directShareToShortcutInfos) + ); + + // Thread.sleep shouldn't be a thing in an integration test but it's + // necessary here because of the way the code is structured + // TODO: restructure the tests b/129870719 + Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + + assertThat("Chooser should have 3 targets (2 apps, 1 direct)", + activity.getAdapter().getCount(), is(3)); + assertThat("Chooser should have exactly one selectable direct target", + activity.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat("The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + // Currently we're seeing 3 invocations + // 1. ChooserActivity.onCreate() + // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() + // 3. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), + is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); + String hashedName = (String) logMakerCaptor + .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_HASHED_TARGET_NAME); + assertThat("Hash is not predictable but must be obfuscated", + hashedName, is(not(name))); + assertThat("The packages shouldn't match for app target and direct target", logMakerCaptor + .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(-1)); + } + + // This test is too long and too slow and should not be taken as an example for future tests. + @Test @Ignore + public void testDirectTargetLoggingWithRankedAppTarget() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + // Set up resources + MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; + ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); + // Create direct share target + List<ChooserTarget> serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + + // Insert the direct share target + Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); + directShareToShortcutInfos.put(serviceTargets.get(0), null); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> activity.getAdapter().addServiceResults( + activity.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null), + serviceTargets, + TARGET_TYPE_CHOOSER_TARGET, + directShareToShortcutInfos) + ); + // Thread.sleep shouldn't be a thing in an integration test but it's + // necessary here because of the way the code is structured + // TODO: restructure the tests b/129870719 + Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + + assertThat("Chooser should have 3 targets (2 apps, 1 direct)", + activity.getAdapter().getCount(), is(3)); + assertThat("Chooser should have exactly one selectable direct target", + activity.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat("The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + // Currently we're seeing 3 invocations + // 1. ChooserActivity.onCreate() + // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() + // 3. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), + is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); + assertThat("The packages should match for app target and direct target", logMakerCaptor + .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(0)); + } + + @Test @Ignore + public void testShortcutTargetWithApplyAppLimits() throws InterruptedException { + // Set up resources + ChooserActivityOverrideData.getInstance().resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + when( + ChooserActivityOverrideData + .getInstance() + .resources + .getInteger( + getRuntimeResourceId("config_maxShortcutTargetsPerApp", "integer"))) + .thenReturn(1); + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + // Create direct share target + List<ChooserTarget> serviceTargets = createDirectShareTargets(2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // Start activity + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + final IChooserWrapper wrapper = (IChooserWrapper) activity; + + // Insert the direct share target + Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); + List<ShareShortcutInfo> shortcutInfos = createShortcuts(activity); + directShareToShortcutInfos.put(serviceTargets.get(0), + shortcutInfos.get(0).getShortcutInfo()); + directShareToShortcutInfos.put(serviceTargets.get(1), + shortcutInfos.get(1).getShortcutInfo()); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> wrapper.getAdapter().addServiceResults( + wrapper.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null), + serviceTargets, + TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, + directShareToShortcutInfos) + ); + // Thread.sleep shouldn't be a thing in an integration test but it's + // necessary here because of the way the code is structured + // TODO: restructure the tests b/129870719 + Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + + assertThat("Chooser should have 3 targets (2 apps, 1 direct)", + wrapper.getAdapter().getCount(), is(3)); + assertThat("Chooser should have exactly one selectable direct target", + wrapper.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat("The resolver info must match the resolver info used to create the target", + wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); + assertThat("The display label must match", + wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0")); + } + + @Test @Ignore + public void testShortcutTargetWithoutApplyAppLimits() throws InterruptedException { + DeviceConfig.setProperty(DeviceConfig.NAMESPACE_SYSTEMUI, + SystemUiDeviceConfigFlags.APPLY_SHARING_APP_LIMITS_IN_SYSUI, + Boolean.toString(false), + true /* makeDefault*/); + // Set up resources + ChooserActivityOverrideData.getInstance().resources = Mockito.spy( + InstrumentationRegistry.getInstrumentation().getContext().getResources()); + when( + ChooserActivityOverrideData + .getInstance() + .resources + .getInteger( + getRuntimeResourceId("config_maxShortcutTargetsPerApp", "integer"))) + .thenReturn(1); + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + // Create direct share target + List<ChooserTarget> serviceTargets = createDirectShareTargets(2, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // Start activity + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + final IChooserWrapper wrapper = (IChooserWrapper) activity; + + // Insert the direct share target + Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); + List<ShareShortcutInfo> shortcutInfos = createShortcuts(activity); + directShareToShortcutInfos.put(serviceTargets.get(0), + shortcutInfos.get(0).getShortcutInfo()); + directShareToShortcutInfos.put(serviceTargets.get(1), + shortcutInfos.get(1).getShortcutInfo()); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> wrapper.getAdapter().addServiceResults( + wrapper.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null), + serviceTargets, + TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE, + directShareToShortcutInfos) + ); + // Thread.sleep shouldn't be a thing in an integration test but it's + // necessary here because of the way the code is structured + // TODO: restructure the tests b/129870719 + Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + + assertThat("Chooser should have 4 targets (2 apps, 2 direct)", + wrapper.getAdapter().getCount(), is(4)); + assertThat("Chooser should have exactly two selectable direct target", + wrapper.getAdapter().getSelectableServiceTargetCount(), is(2)); + assertThat("The resolver info must match the resolver info used to create the target", + wrapper.getAdapter().getItem(0).getResolveInfo(), is(ri)); + assertThat("The display label must match", + wrapper.getAdapter().getItem(0).getDisplayLabel(), is("testTitle0")); + assertThat("The display label must match", + wrapper.getAdapter().getItem(1).getDisplayLabel(), is("testTitle1")); + } + + @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(withIdFromRuntimeResource("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 + ) throws InterruptedException { + 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); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + // Set up resources + MetricsLogger mockLogger = ChooserActivityOverrideData.getInstance().metricsLogger; + ArgumentCaptor<LogMaker> logMakerCaptor = ArgumentCaptor.forClass(LogMaker.class); + // Create direct share target + List<ChooserTarget> serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(14).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(16, 0); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + final IChooserWrapper wrapper = (IChooserWrapper) activity; + // Insert the direct share target + Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); + directShareToShortcutInfos.put(serviceTargets.get(0), null); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> wrapper.getAdapter().addServiceResults( + wrapper.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null), + serviceTargets, + TARGET_TYPE_CHOOSER_TARGET, + directShareToShortcutInfos) + ); + // Thread.sleep shouldn't be a thing in an integration test but it's + // necessary here because of the way the code is structured + // TODO: restructure the tests b/129870719 + Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + + assertThat( + String.format("Chooser should have %d targets (%d apps, 1 direct, 15 A-Z)", + 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(); + + // Currently we're seeing 3 invocations + // 1. ChooserActivity.onCreate() + // 2. ChooserActivity$ChooserRowAdapter.createContentPreviewView() + // 3. ChooserActivity.startSelected -- which is the one we're after + verify(mockLogger, Mockito.times(3)).write(logMakerCaptor.capture()); + assertThat(logMakerCaptor.getAllValues().get(2).getCategory(), + is(MetricsEvent.ACTION_ACTIVITY_CHOOSER_PICKED_SERVICE_TARGET)); + assertThat("The packages shouldn't match for app target and direct target", logMakerCaptor + .getAllValues().get(2).getTaggedData(MetricsEvent.FIELD_RANKED_POSITION), is(-1)); + } + + @Test + public void testWorkTab_displayedWhenWorkProfileUserAvailable() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + markWorkProfileUserAvailable(); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + onView(withIdFromRuntimeResource("tabs")).check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_hiddenWhenWorkProfileUserNotAvailable() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + onView(withIdFromRuntimeResource("tabs")).check(matches(not(isDisplayed()))); + } + + @Test + public void testWorkTab_eachTabUsesExpectedAdapter() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + int personalProfileTargets = 3; + int otherProfileTargets = 1; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + 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(withTextFromRuntimeResource("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 { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + 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(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + + assertThat(activity.getWorkListAdapter().getCount(), is(workProfileTargets)); + } + + @Test @Ignore + public void testWorkTab_selectingWorkTabAppOpensAppInWorkProfile() throws InterruptedException { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + 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().onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + // wait for the share sheet to expand + Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + + onView(first(allOf( + withText(workResolvedComponentInfos.get(0) + .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() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + 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(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + onView(withIdFromRuntimeResource("contentPanel")) + .perform(swipeUp()); + + onView(withTextFromRuntimeResource("resolver_cross_profile_blocked")) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_workProfileDisabled_emptyStateShown() { + // enable the work tab feature flag + markWorkProfileUserAvailable(); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(workProfileTargets); + ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + ResolverActivity.ENABLE_TABBED_VIEW = true; + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withIdFromRuntimeResource("contentPanel")) + .perform(swipeUp()); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + + onView(withTextFromRuntimeResource("resolver_turn_on_work_apps")) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noWorkAppsAvailable_emptyStateShown() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + 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(withIdFromRuntimeResource("contentPanel")) + .perform(swipeUp()); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + + onView(withTextFromRuntimeResource("resolver_no_work_apps_available")) + .check(matches(isDisplayed())); + } + + @Ignore // b/220067877 + @Test + public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + 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(withIdFromRuntimeResource("contentPanel")) + .perform(swipeUp()); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + + onView(withTextFromRuntimeResource("resolver_cross_profile_blocked")) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noAppsAvailable_workOff_noAppsAvailableEmptyStateShown() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + 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(withIdFromRuntimeResource("contentPanel")) + .perform(swipeUp()); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + + onView(withTextFromRuntimeResource("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); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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(withIdFromRuntimeResource("profile_button")).check(doesNotExist()); + + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + ResolveInfo toChoose = resolvedComponentInfos.get(0).getResolveInfoAt(0); + onView(withText(toChoose.activityInfo.name)) + .perform(click()); + waitForIdle(); + + ChooserActivityLoggerFake logger = + (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + logger.removeCallsForUiEventsOfType( + ChooserActivityLogger.SharesheetStandardEvent + .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); + + // SHARESHEET_TRIGGERED: + assertThat(logger.event(0).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); + + // SHARESHEET_STARTED: + assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); + assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); + assertThat(logger.get(1).mimeType, is("text/plain")); + assertThat(logger.get(1).packageName, is( + InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); + assertThat(logger.get(1).appProvidedApp, is(0)); + assertThat(logger.get(1).appProvidedDirect, is(0)); + assertThat(logger.get(1).isWorkprofile, is(false)); + assertThat(logger.get(1).previewType, is(3)); + + // SHARESHEET_APP_LOAD_COMPLETE: + assertThat(logger.event(2).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); + + // Next are just artifacts of test set-up: + assertThat(logger.event(3).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); + assertThat(logger.event(4).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); + + // SHARESHEET_APP_TARGET_SELECTED: + assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); + assertThat(logger.get(5).targetType, + is(ChooserActivityLogger + .SharesheetTargetSelectedEvent.SHARESHEET_APP_TARGET_SELECTED.getId())); + + // No more events. + assertThat(logger.numCalls(), is(6)); + } + + @Test @Ignore + public void testDirectTargetLogging() throws InterruptedException { + Intent sendIntent = createSendTextIntent(); + // We need app targets for direct targets to get displayed + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + // Create direct share target + List<ChooserTarget> serviceTargets = createDirectShareTargets(1, + resolvedComponentInfos.get(0).getResolveInfoAt(0).activityInfo.packageName); + ResolveInfo ri = ResolverDataProvider.createResolveInfo(3, 0); + + // Start activity + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + + // Insert the direct share target + Map<ChooserTarget, ShortcutInfo> directShareToShortcutInfos = new HashMap<>(); + directShareToShortcutInfos.put(serviceTargets.get(0), null); + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> activity.getAdapter().addServiceResults( + activity.createTestDisplayResolveInfo(sendIntent, + ri, + "testLabel", + "testInfo", + sendIntent, + /* resolveInfoPresentationGetter */ null), + serviceTargets, + TARGET_TYPE_CHOOSER_TARGET, + directShareToShortcutInfos) + ); + // Thread.sleep shouldn't be a thing in an integration test but it's + // necessary here because of the way the code is structured + // TODO: restructure the tests b/129870719 + Thread.sleep(((ChooserActivity) activity).mListViewUpdateDelayMs); + + assertThat("Chooser should have 3 targets (2 apps, 1 direct)", + activity.getAdapter().getCount(), is(3)); + assertThat("Chooser should have exactly one selectable direct target", + activity.getAdapter().getSelectableServiceTargetCount(), is(1)); + assertThat("The resolver info must match the resolver info used to create the target", + activity.getAdapter().getItem(0).getResolveInfo(), is(ri)); + + // Click on the direct target + String name = serviceTargets.get(0).getTitle().toString(); + onView(withText(name)) + .perform(click()); + waitForIdle(); + + ChooserActivityLoggerFake logger = + (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); + assertThat(logger.numCalls(), is(6)); + // first one should be SHARESHEET_TRIGGERED uievent + assertThat(logger.get(0).atomId, is(FrameworkStatsLog.UI_EVENT_REPORTED)); + assertThat(logger.get(0).event.getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); + // second one should be SHARESHEET_STARTED event + assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); + assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); + assertThat(logger.get(1).mimeType, is("text/plain")); + assertThat(logger.get(1).packageName, is( + InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); + assertThat(logger.get(1).appProvidedApp, is(0)); + assertThat(logger.get(1).appProvidedDirect, is(0)); + assertThat(logger.get(1).isWorkprofile, is(false)); + assertThat(logger.get(1).previewType, is(3)); + // third one should be SHARESHEET_APP_LOAD_COMPLETE uievent + assertThat(logger.get(2).atomId, is(FrameworkStatsLog.UI_EVENT_REPORTED)); + assertThat(logger.get(2).event.getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); + // fourth and fifth are just artifacts of test set-up + // sixth one should be ranking atom with SHARESHEET_COPY_TARGET_SELECTED event + assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); + assertThat(logger.get(5).targetType, + is(ChooserActivityLogger + .SharesheetTargetSelectedEvent.SHARESHEET_SERVICE_TARGET_SELECTED.getId())); + } + + @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); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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)); + + ChooserActivityLoggerFake logger = + (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + logger.removeCallsForUiEventsOfType( + ChooserActivityLogger.SharesheetStandardEvent + .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); + + // SHARESHEET_TRIGGERED: + assertThat(logger.event(0).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); + + // SHARESHEET_STARTED: + assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); + assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); + assertThat(logger.get(1).mimeType, is("text/plain")); + assertThat(logger.get(1).packageName, is( + InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); + assertThat(logger.get(1).appProvidedApp, is(0)); + assertThat(logger.get(1).appProvidedDirect, is(0)); + assertThat(logger.get(1).isWorkprofile, is(false)); + assertThat(logger.get(1).previewType, is(3)); + + // SHARESHEET_APP_LOAD_COMPLETE: + assertThat(logger.event(2).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); + + // SHARESHEET_EMPTY_DIRECT_SHARE_ROW: + assertThat(logger.event(3).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); + + // Next is just an artifact of test set-up: + assertThat(logger.event(4).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); + + assertThat(logger.numCalls(), is(5)); + } + + @Ignore // b/220067877 + @Test + public void testCopyTextToClipboardLogging() throws Exception { + Intent sendIntent = createSendTextIntent(); + List<ResolvedComponentInfo> resolvedComponentInfos = createResolvedComponentsForTest(2); + + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(resolvedComponentInfos); + + final IChooserWrapper activity = (IChooserWrapper) + mActivityRule.launchActivity(Intent.createChooser(sendIntent, null)); + waitForIdle(); + + onView(withIdFromRuntimeResource("chooser_copy_button")).check(matches(isDisplayed())); + onView(withIdFromRuntimeResource("chooser_copy_button")).perform(click()); + + ChooserActivityLoggerFake logger = + (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + logger.removeCallsForUiEventsOfType( + ChooserActivityLogger.SharesheetStandardEvent + .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); + + // SHARESHEET_TRIGGERED: + assertThat(logger.event(0).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); + + // SHARESHEET_STARTED: + assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); + assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); + assertThat(logger.get(1).mimeType, is("text/plain")); + assertThat(logger.get(1).packageName, is( + InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); + assertThat(logger.get(1).appProvidedApp, is(0)); + assertThat(logger.get(1).appProvidedDirect, is(0)); + assertThat(logger.get(1).isWorkprofile, is(false)); + assertThat(logger.get(1).previewType, is(3)); + + // SHARESHEET_APP_LOAD_COMPLETE: + assertThat(logger.event(2).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); + + // Next are just artifacts of test set-up: + assertThat(logger.event(3).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); + assertThat(logger.event(4).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_EXPANDED.getId())); + + // SHARESHEET_COPY_TARGET_SELECTED: + assertThat(logger.get(5).atomId, is(FrameworkStatsLog.RANKING_SELECTED)); + assertThat(logger.get(5).targetType, + is(ChooserActivityLogger + .SharesheetTargetSelectedEvent.SHARESHEET_COPY_TARGET_SELECTED.getId())); + + // No more events. + assertThat(logger.numCalls(), is(6)); + } + + @Test @Ignore("b/222124533") + public void testSwitchProfileLogging() throws InterruptedException { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + markWorkProfileUserAvailable(); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + 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(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + onView(withTextFromRuntimeResource("resolver_personal_tab")).perform(click()); + waitForIdle(); + + ChooserActivityLoggerFake logger = + (ChooserActivityLoggerFake) activity.getChooserActivityLogger(); + + // TODO(b/211669337): Determine the expected SHARESHEET_DIRECT_LOAD_COMPLETE events. + logger.removeCallsForUiEventsOfType( + ChooserActivityLogger.SharesheetStandardEvent + .SHARESHEET_DIRECT_LOAD_COMPLETE.getId()); + + // SHARESHEET_TRIGGERED: + assertThat(logger.event(0).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent.SHARESHEET_TRIGGERED.getId())); + + // SHARESHEET_STARTED: + assertThat(logger.get(1).atomId, is(FrameworkStatsLog.SHARESHEET_STARTED)); + assertThat(logger.get(1).intent, is(Intent.ACTION_SEND)); + assertThat(logger.get(1).mimeType, is(TEST_MIME_TYPE)); + assertThat(logger.get(1).packageName, is( + InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName())); + assertThat(logger.get(1).appProvidedApp, is(0)); + assertThat(logger.get(1).appProvidedDirect, is(0)); + assertThat(logger.get(1).isWorkprofile, is(false)); + assertThat(logger.get(1).previewType, is(3)); + + // SHARESHEET_APP_LOAD_COMPLETE: + assertThat(logger.event(2).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); + + // Next is just an artifact of test set-up: + assertThat(logger.event(3).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); + + // SHARESHEET_PROFILE_CHANGED: + assertThat(logger.event(4).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent + .SHARESHEET_PROFILE_CHANGED.getId())); + + // Repeat the loading steps in the new profile: + + // SHARESHEET_APP_LOAD_COMPLETE: + assertThat(logger.event(5).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_APP_LOAD_COMPLETE.getId())); + + // Next is again an artifact of test set-up: + assertThat(logger.event(6).getId(), + is(ChooserActivityLogger + .SharesheetStandardEvent.SHARESHEET_EMPTY_DIRECT_SHARE_ROW.getId())); + + // SHARESHEET_PROFILE_CHANGED: + assertThat(logger.event(7).getId(), + is(ChooserActivityLogger.SharesheetStandardEvent + .SHARESHEET_PROFILE_CHANGED.getId())); + + // No more events (this profile was already loaded). + assertThat(logger.numCalls(), is(8)); + } + + @Test + public void testAutolaunch_singleTarget_wifthWorkProfileAndTabbedViewOff_noAutolaunch() { + ResolverActivity.ENABLE_TABBED_VIEW = false; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(2, /* userId */ 10); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + waitForIdle(); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + assertTrue(chosen[0] == null); + } + + @Test + public void testAutolaunch_singleTarget_noWorkProfile_autolaunch() { + ResolverActivity.ENABLE_TABBED_VIEW = false; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(1); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + waitForIdle(); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + + assertThat(chosen[0], is(personalResolvedComponentInfos.get(0).getResolveInfoAt(0))); + } + + @Test + public void testWorkTab_onePersonalTarget_emptyStateOnWorkTarget_autolaunch() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + markWorkProfileUserAvailable(); + int workProfileTargets = 4; + List<ResolvedComponentInfo> personalResolvedComponentInfos = + 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().onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + + mActivityRule.launchActivity(sendIntent); + waitForIdle(); + + assertThat(chosen[0], is(personalResolvedComponentInfos.get(1).getResolveInfoAt(0))); + } + + @Test + public void testOneInitialIntent_noAutolaunch() { + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(1); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + Intent chooserIntent = createChooserIntent(createSendTextIntent(), + new Intent[] {new Intent("action.fake")}); + ResolveInfo[] chosen = new ResolveInfo[1]; + ChooserActivityOverrideData.getInstance().onSafelyStartCallback = targetInfo -> { + chosen[0] = targetInfo.getResolveInfo(); + return true; + }; + ChooserActivityOverrideData.getInstance().packageManager = mock(PackageManager.class); + ResolveInfo ri = createFakeResolveInfo(); + when( + ChooserActivityOverrideData + .getInstance().packageManager + .resolveActivity(any(Intent.class), anyInt())) + .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() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + 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), anyInt())) + .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() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + 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), anyInt())) + .thenReturn(createFakeResolveInfo()); + + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + onView(withIdFromRuntimeResource("contentPanel")) + .perform(swipeUp()); + + onView(withTextFromRuntimeResource("resolver_cross_profile_blocked")) + .check(matches(isDisplayed())); + } + + @Test + public void testWorkTab_noWorkAppsAvailable_nonSendIntent_emptyStateShown() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(3); + 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), anyInt())) + .thenReturn(createFakeResolveInfo()); + + mActivityRule.launchActivity(chooserIntent); + waitForIdle(); + onView(withIdFromRuntimeResource("contentPanel")) + .perform(swipeUp()); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + + onView(withTextFromRuntimeResource("resolver_no_work_apps_available")) + .check(matches(isDisplayed())); + } + + @Test + public void testDeduplicateCallerTargetRankedTarget() { + // Create 4 ranked app targets. + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(4); + when(ChooserActivityOverrideData.getInstance().resolverListController.getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(new ArrayList<>(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); + when( + ChooserActivityOverrideData + .getInstance() + .packageManager + .resolveActivity(any(Intent.class), anyInt())) + .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 testWorkTab_selectingWorkTabWithPausedWorkProfile_directShareTargetsNotQueried() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + ChooserActivityOverrideData.getInstance().isQuietModeEnabled = true; + boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; + ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = + chooserListAdapter -> { + isQueryDirectShareCalledOnWorkProfile[0] = + (chooserListAdapter.getUserHandle().getIdentifier() == 10); + return null; + }; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withIdFromRuntimeResource("contentPanel")) + .perform(swipeUp()); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + + assertFalse("Direct share targets were queried on a paused work profile", + isQueryDirectShareCalledOnWorkProfile[0]); + } + + @Test + public void testWorkTab_selectingWorkTabWithNotRunningWorkUser_directShareTargetsNotQueried() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false; + boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; + ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = + chooserListAdapter -> { + isQueryDirectShareCalledOnWorkProfile[0] = + (chooserListAdapter.getUserHandle().getIdentifier() == 10); + return null; + }; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withIdFromRuntimeResource("contentPanel")) + .perform(swipeUp()); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + + assertFalse("Direct share targets were queried on a locked work profile user", + isQueryDirectShareCalledOnWorkProfile[0]); + } + + @Test + public void testWorkTab_workUserNotRunning_workTargetsShown() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ChooserActivityOverrideData.getInstance().isWorkProfileUserRunning = false; + + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + final IChooserWrapper wrapper = (IChooserWrapper) activity; + waitForIdle(); + onView(withIdFromRuntimeResource("contentPanel")).perform(swipeUp()); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + + assertEquals(3, wrapper.getWorkListAdapter().getCount()); + } + + @Test + public void testWorkTab_selectingWorkTabWithLockedWorkUser_directShareTargetsNotQueried() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false; + boolean[] isQueryDirectShareCalledOnWorkProfile = new boolean[] { false }; + ChooserActivityOverrideData.getInstance().onQueryDirectShareTargets = + chooserListAdapter -> { + isQueryDirectShareCalledOnWorkProfile[0] = + (chooserListAdapter.getUserHandle().getIdentifier() == 10); + return null; + }; + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + waitForIdle(); + onView(withIdFromRuntimeResource("contentPanel")) + .perform(swipeUp()); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + + assertFalse("Direct share targets were queried on a locked work profile user", + isQueryDirectShareCalledOnWorkProfile[0]); + } + + @Test + public void testWorkTab_workUserLocked_workTargetsShown() { + // enable the work tab feature flag + ResolverActivity.ENABLE_TABBED_VIEW = true; + markWorkProfileUserAvailable(); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTestWithOtherProfile(3, /* userId */ 10); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + Intent sendIntent = createSendTextIntent(); + sendIntent.setType(TEST_MIME_TYPE); + ChooserActivityOverrideData.getInstance().isWorkProfileUserUnlocked = false; + + final ChooserActivity activity = + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "work tab test")); + final IChooserWrapper wrapper = (IChooserWrapper) activity; + waitForIdle(); + onView(withIdFromRuntimeResource("contentPanel")) + .perform(swipeUp()); + onView(withTextFromRuntimeResource("resolver_work_tab")).perform(click()); + waitForIdle(); + + assertEquals(3, wrapper.getWorkListAdapter().getCount()); + } + + 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 @@ -105,7 +2913,326 @@ public class UnbundledChooserActivityTest extends ChooserActivityTest { 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"; + 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 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)); + } + return infoList; + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( + int numberOfResults) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add(ResolverDataProvider.createResolvedComponentInfoWithOtherId(i)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + } + } + return infoList; + } + + private List<ResolvedComponentInfo> createResolvedComponentsForTestWithOtherProfile( + int numberOfResults, int userId) { + List<ResolvedComponentInfo> infoList = new ArrayList<>(numberOfResults); + for (int i = 0; i < numberOfResults; i++) { + if (i == 0) { + infoList.add( + ResolverDataProvider.createResolvedComponentInfoWithOtherId(i, userId)); + } else { + infoList.add(ResolverDataProvider.createResolvedComponentInfo(i)); + } + } + return infoList; + } + + private 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)); + } + 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() { + int width = 200; + int height = 200; + 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 assertCorrectShortcutToChooserTargetConversion(List<ShareShortcutInfo> shortcuts, + List<ChooserTarget> chooserTargets, int[] expectedOrder, float[] expectedScores) { + assertEquals(expectedOrder.length, chooserTargets.size()); + for (int i = 0; i < chooserTargets.size(); i++) { + ChooserTarget ct = chooserTargets.get(i); + ShortcutInfo si = shortcuts.get(expectedOrder[i]).getShortcutInfo(); + ComponentName cn = shortcuts.get(expectedOrder[i]).getTargetComponent(); + + assertEquals(si.getId(), ct.getIntentExtras().getString(Intent.EXTRA_SHORTCUT_ID)); + assertEquals(si.getShortLabel(), ct.getTitle()); + assertThat(Math.abs(expectedScores[i] - ct.getScore()) < 0.000001, is(true)); + assertEquals(cn.flattenToString(), ct.getComponentName().flattenToString()); + } + } + + private void markWorkProfileUserAvailable() { + ChooserActivityOverrideData.getInstance().workProfileUserHandle = UserHandle.of(10); + } + + private void setupResolverControllers( + List<ResolvedComponentInfo> personalResolvedComponentInfos, + List<ResolvedComponentInfo> workResolvedComponentInfos) { + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + when( + ChooserActivityOverrideData + .getInstance() + .workResolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(new ArrayList<>(workResolvedComponentInfos)); + when( + ChooserActivityOverrideData + .getInstance() + .workResolverListController + .getResolversForIntentAsUser( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class), + eq(UserHandle.SYSTEM))) + .thenReturn(new ArrayList<>(personalResolvedComponentInfos)); + } + + private Matcher<View> withIdFromRuntimeResource(String id) { + return withId(getRuntimeResourceId(id, "id")); + } + + private Matcher<View> withTextFromRuntimeResource(String id) { + return withText(getRuntimeResourceId(id, "string")); + } + + private static GridRecyclerSpanCountMatcher withGridColumnCount(int columnCount) { + return new GridRecyclerSpanCountMatcher(Matchers.is(columnCount)); + } + + 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); + when( + ChooserActivityOverrideData + .getInstance() + .resolverListController + .getResolversForIntent( + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.anyBoolean(), + Mockito.isA(List.class))) + .thenReturn(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); + } + + // ChooserWrapperActivity inherits from the framework ChooserActivity, so if the framework + // resources have been updated since the framework was last built/pushed, the inherited behavior + // (which is the focus of our testing) will still be implemented in terms of the old resource + // IDs; then when we try to assert those IDs in tests (e.g. `onView(withText(R.string.foo))`), + // the expected values won't match. The tests can instead call this method (with the same + // general semantics as Resources#getIdentifier() e.g. `getRuntimeResourceId("foo", "string")`) + // to refer to the resource by that name in the runtime chooser, regardless of whether the + // framework code on the device is up-to-date. + // TODO: is there a better way to do this? (Other than abandoning inheritance-based DI wrapper?) + private int getRuntimeResourceId(String name, String defType) { + int id = -1; + if (ChooserActivityOverrideData.getInstance().resources != null) { + id = ChooserActivityOverrideData.getInstance().resources.getIdentifier( + name, defType, "android"); + } else { + id = mActivityRule.getActivity().getResources().getIdentifier(name, defType, "android"); + } + assertThat(id, greaterThan(0)); + + return id; + } } |