diff options
| author | Xin Li <delphij@google.com> | 2020-08-27 10:16:46 -0700 |
|---|---|---|
| committer | Xin Li <delphij@google.com> | 2020-08-27 10:16:46 -0700 |
| commit | d07994ac9db451db4a047e2178987ffaab6fdf13 (patch) | |
| tree | cf021df6043c34c183ad40ccf0db0033d7002d83 | |
| parent | 68538c52302c4670060cf68120da97c17f925158 (diff) | |
| parent | 99b73806aceeadd04edb8ada16e19150a6c05f18 (diff) | |
| download | platform_packages_apps_Car_CompanionDeviceSupport-d07994ac9db451db4a047e2178987ffaab6fdf13.tar.gz platform_packages_apps_Car_CompanionDeviceSupport-d07994ac9db451db4a047e2178987ffaab6fdf13.tar.bz2 platform_packages_apps_Car_CompanionDeviceSupport-d07994ac9db451db4a047e2178987ffaab6fdf13.zip | |
Merge Android R (rvc-dev-plus-aosp-without-vendor@6692709)
Bug: 166295507
Merged-In: I0573e1cc0e0a32ca9eaf0ba3504a6bf446afabb2
Change-Id: Idba575ac3010c6f441e7037a02876d46c917d995
29 files changed, 1453 insertions, 283 deletions
@@ -64,7 +64,6 @@ android_library { static_libs: [ "CompanionDeviceSupport-aidl", "CompanionDeviceSupport-proto", - "EncryptionRunner-lib", "androidx-constraintlayout_constraintlayout", "androidx-constraintlayout_constraintlayout-solver", "androidx.legacy_legacy-support-v4", @@ -75,10 +74,11 @@ android_library { "com.google.android.material_material", "companion-feature-calendarsync-protos", "connected-device-lib", + "encryption-runner", ], plugins: [ - "car-androidx-room-compiler", + "androidx.room_room-compiler-plugin", ], } diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 4d4ddb3..ab9da21 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -29,11 +29,13 @@ <!-- Needed for the calendar sync feature --> <uses-permission android:name="android.permission.WRITE_CALENDAR"/> + <!-- Needed to post messaging notifications on behalf of other apps --> + <uses-permission android:name="android.permission.SUBSTITUTE_NOTIFICATION_APP_NAME"/> + <application android:label="@string/app_name" android:theme="@style/CompanionDeviceSupportTheme" - android:directBootAware="true" - xmlns:tools="http://schemas.android.com/tools"> + android:directBootAware="true"> <service android:name=".service.CompanionDeviceSupportService" android:singleUser="true" @@ -48,7 +50,8 @@ </intent-filter> </service> <activity android:name=".activity.AssociationActivity" - android:exported="true"> + android:exported="true" + android:launchMode="singleInstance"> <intent-filter> <action android:name="com.android.car.companiondevicesupport.ASSOCIATION_ACTIVITY" /> <action android:name="com.android.settings.action.EXTRA_SETTINGS" /> @@ -116,13 +119,5 @@ <meta-data android:name="com.android.settings.category" android:value="com.android.settings.category.ia.security" /> </activity> - - <!-- Workaround for b/113294940 --> - <provider - android:name="androidx.lifecycle.ProcessLifecycleOwnerInitializer" - tools:replace="android:authorities" - android:authorities="${applicationId}.lifecycle" - android:exported="false" - android:multiprocess="true" /> </application> </manifest> diff --git a/res/drawable/ic_connection_indicator.xml b/res/drawable/ic_connection_indicator.xml new file mode 100644 index 0000000..35e3ab3 --- /dev/null +++ b/res/drawable/ic_connection_indicator.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. + --> + +<shape + xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="oval"> + <solid android:color="?android:attr/textColorPrimary" /> + <size + android:width="@dimen/connection_indicator_size" + android:height="@dimen/connection_indicator_size" /> +</shape>
\ No newline at end of file diff --git a/res/layout/add_associated_device_fragment.xml b/res/layout/add_associated_device_fragment.xml index 044fd66..d068a41 100644 --- a/res/layout/add_associated_device_fragment.xml +++ b/res/layout/add_associated_device_fragment.xml @@ -71,26 +71,35 @@ app:layout_constraintTop_toBottomOf="@+id/add_associated_device_instruction" app:layout_constraintStart_toEndOf="@+id/start_guideline" app:layout_constraintEnd_toStartOf="@+id/end_guideline" - style="@style/InstructionStepItem"> + android:minHeight="@dimen/list_item_height"> <ImageView android:id="@+id/install_app_icon" - android:layout_width="@dimen/icon_size" - android:layout_height="@dimen/icon_size" - android:layout_marginStart="@dimen/icon_margin_start" + android:layout_width="@dimen/list_item_icon_size" + android:layout_height="@dimen/list_item_icon_size" + android:layout_gravity="center" + android:scaleType="fitXY" android:src="@drawable/ic_smartphone" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@+id/install_app_icon_end_guideline" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - style="@style/AssociationDetailIcon"/> + app:layout_constraintBottom_toBottomOf="parent"/> + <androidx.constraintlayout.widget.Guideline + android:id="@+id/install_app_icon_end_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_begin="@dimen/car_ui_list_item_icon_container_width" /> <TextView android:id="@+id/install_app_text" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/text_margin_start" - android:layout_marginEnd="@dimen/text_margin_end" + android:layout_marginStart="@dimen/list_item_text_margin_start" + android:layout_marginEnd="@dimen/list_item_text_margin_end" + android:layout_marginBottom="@dimen/list_item_content_margin_bottom" + android:layout_marginTop="@dimen/list_item_content_margin_top" android:text="@string/associated_device_install_app" android:textAppearance="@style/AssociationInstruction" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintStart_toEndOf="@+id/install_app_icon_end_guideline" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> @@ -103,26 +112,35 @@ app:layout_constraintStart_toEndOf="@+id/start_guideline" app:layout_constraintEnd_toStartOf="@+id/end_guideline" app:layout_constraintTop_toBottomOf="@+id/install_app_instruction" - style="@style/InstructionStepItem"> + android:minHeight="@dimen/list_item_height"> <ImageView android:id="@+id/select_device_icon" - android:layout_width="@dimen/icon_size" - android:layout_height="@dimen/icon_size" - android:layout_marginStart="@dimen/icon_margin_start" + android:layout_width="@dimen/list_item_icon_size" + android:layout_height="@dimen/list_item_icon_size" + android:layout_gravity="center" + android:scaleType="fitXY" android:src="@drawable/ic_directions_car" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@+id/select_device_icon_end_guideline" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - style="@style/AssociationDetailIcon"/> + app:layout_constraintBottom_toBottomOf="parent"/> + <androidx.constraintlayout.widget.Guideline + android:id="@+id/select_device_icon_end_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_begin="@dimen/car_ui_list_item_icon_container_width" /> <TextView android:id="@+id/associated_device_select_device" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/text_margin_start" - android:layout_marginEnd="@dimen/text_margin_end" + android:layout_marginStart="@dimen/list_item_text_margin_start" + android:layout_marginEnd="@dimen/list_item_text_margin_end" + android:layout_marginBottom="@dimen/list_item_content_margin_bottom" + android:layout_marginTop="@dimen/list_item_content_margin_top" android:text="@string/associated_device_select_device" android:textAppearance="@style/AssociationInstruction" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintStart_toEndOf="@+id/select_device_icon_end_guideline" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/res/layout/associated_device_detail_fragment.xml b/res/layout/associated_device_detail_fragment.xml index f5f6a53..b0738be 100644 --- a/res/layout/associated_device_detail_fragment.xml +++ b/res/layout/associated_device_detail_fragment.xml @@ -43,16 +43,29 @@ app:layout_constraintTop_toBottomOf="@+id/device_icon" app:layout_constraintBottom_toTopOf="@+id/connection_status"/> - <TextView + <LinearLayout android:id="@+id/connection_status" android:layout_height="wrap_content" android:layout_width="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" android:layout_marginTop="@dimen/device_detail_margin" - android:textAppearance="@style/AssociationMessage" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/device_name" - app:layout_constraintBottom_toTopOf="@+id/connection_button"/> + app:layout_constraintBottom_toTopOf="@+id/connection_button"> + <ImageView + android:id="@+id/connection_status_indicator" + android:layout_height="@dimen/connection_indicator_size" + android:layout_width="@dimen/connection_indicator_size" + android:layout_marginEnd="@dimen/connection_indicator_margin_end" + android:src="@drawable/ic_connection_indicator"/> + <TextView + android:id="@+id/connection_status_text" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:textAppearance="@style/AssociationMessage"/> + </LinearLayout> <androidx.constraintlayout.widget.Guideline android:id="@+id/start_guideline" @@ -66,30 +79,40 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="@dimen/device_detail_button_margin_top" + android:background="@drawable/car_ui_list_item_background" + android:clickable="true" + android:minHeight="@dimen/list_item_height" app:layout_constraintStart_toEndOf="@+id/start_guideline" app:layout_constraintEnd_toStartOf="@+id/end_guideline" app:layout_constraintTop_toBottomOf="@+id/connection_status" - app:layout_constraintBottom_toTopOf="@+id/remove_button" - style="@style/DeviceDetailButton"> + app:layout_constraintBottom_toTopOf="@+id/remove_button"> <ImageView android:id="@+id/connection_icon" - android:layout_width="@dimen/icon_size" - android:layout_height="@dimen/icon_size" - android:layout_marginStart="@dimen/icon_margin_start" + android:layout_width="@dimen/list_item_icon_size" + android:layout_height="@dimen/list_item_icon_size" + android:scaleType="fitXY" android:src="@drawable/ic_phonelink_erase" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@+id/connection_icon_end_guideline" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - style="@style/AssociationDetailIcon"/> + app:layout_constraintBottom_toBottomOf="parent"/> + <androidx.constraintlayout.widget.Guideline + android:id="@+id/connection_icon_end_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_begin="@dimen/car_ui_list_item_icon_container_width" /> <TextView android:id="@+id/connection_text" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/text_margin_start" - android:layout_marginEnd="@dimen/text_margin_end" + android:layout_marginStart="@dimen/list_item_text_margin_start" + android:layout_marginEnd="@dimen/list_item_text_margin_end" + android:layout_marginBottom="@dimen/list_item_content_margin_bottom" + android:layout_marginTop="@dimen/list_item_content_margin_top" android:text="@string/connection" android:textAppearance="@style/AssociationTitle" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintStart_toEndOf="@+id/connection_icon_end_guideline" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> @@ -99,30 +122,41 @@ android:id="@+id/remove_button" android:layout_width="0dp" android:layout_height="wrap_content" + android:background="@drawable/car_ui_list_item_background" + android:clickable="true" + android:minHeight="@dimen/list_item_height" app:layout_constraintStart_toEndOf="@+id/start_guideline" app:layout_constraintEnd_toStartOf="@+id/end_guideline" app:layout_constraintTop_toBottomOf="@+id/connection_button" - app:layout_constraintBottom_toTopOf="@+id/divider" - style="@style/DeviceDetailButton"> + app:layout_constraintBottom_toTopOf="@+id/divider"> <ImageView android:id="@+id/delete_icon" - android:layout_width="@dimen/icon_size" - android:layout_height="@dimen/icon_size" - android:layout_marginStart="@dimen/icon_margin_start" + android:layout_width="@dimen/list_item_icon_size" + android:layout_height="@dimen/list_item_icon_size" + android:layout_gravity="center" + android:scaleType="fitXY" android:src="@drawable/ic_delete" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@+id/delete_icon_end_guideline" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - style="@style/AssociationDetailIcon"/> + app:layout_constraintBottom_toBottomOf="parent"/> + <androidx.constraintlayout.widget.Guideline + android:id="@+id/delete_icon_end_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_begin="@dimen/car_ui_list_item_icon_container_width" /> <TextView android:id="@+id/remove_text" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/text_margin_start" - android:layout_marginEnd="@dimen/text_margin_end" + android:layout_marginStart="@dimen/list_item_text_margin_start" + android:layout_marginEnd="@dimen/list_item_text_margin_end" + android:layout_marginBottom="@dimen/list_item_content_margin_bottom" + android:layout_marginTop="@dimen/list_item_content_margin_top" android:text="@string/forget_title" android:textAppearance="@style/AssociationTitle" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintStart_toEndOf="@+id/delete_icon_end_guideline" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> @@ -131,7 +165,7 @@ <View android:id="@+id/divider" android:layout_width="0dp" - android:layout_height="1dp" + android:layout_height="@dimen/list_item_divider_width" android:layout_marginTop="@dimen/device_detail_divider_margin" app:layout_constraintStart_toEndOf="@+id/start_guideline" app:layout_constraintEnd_toStartOf="@+id/end_guideline" @@ -144,29 +178,40 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="@dimen/device_detail_divider_margin" + android:background="@drawable/car_ui_list_item_background" + android:clickable="true" + android:minHeight="@dimen/list_item_height" app:layout_constraintTop_toBottomOf="@+id/divider" app:layout_constraintStart_toEndOf="@+id/start_guideline" - app:layout_constraintEnd_toStartOf="@+id/end_guideline" - style="@style/DeviceDetailButton"> + app:layout_constraintEnd_toStartOf="@+id/end_guideline"> <ImageView android:id="@+id/trusted_device_icon" - android:layout_width="@dimen/icon_size" - android:layout_height="@dimen/icon_size" - android:layout_marginStart="@dimen/icon_margin_start" + android:layout_width="@dimen/list_item_icon_size" + android:layout_height="@dimen/list_item_icon_size" + android:layout_gravity="center" + android:scaleType="fitXY" android:src="@drawable/ic_lock" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@+id/trusted_device_icon_end_guideline" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - style="@style/AssociationDetailIcon"/> + app:layout_constraintBottom_toBottomOf="parent"/> + <androidx.constraintlayout.widget.Guideline + android:id="@+id/trusted_device_icon_end_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_begin="@dimen/car_ui_list_item_icon_container_width" /> <TextView android:id="@+id/trusted_device_title" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/text_margin_start" - android:layout_marginEnd="@dimen/text_margin_end" + android:layout_marginStart="@dimen/list_item_text_margin_start" + android:layout_marginEnd="@dimen/list_item_text_margin_end" + android:layout_marginBottom="@dimen/list_item_content_margin_bottom" + android:layout_marginTop="@dimen/list_item_content_margin_top" android:text="@string/trusted_device_feature_title" android:textAppearance="@style/AssociationTitle" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintStart_toEndOf="@+id/trusted_device_icon_end_guideline" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/res/layout/base_activity.xml b/res/layout/base_activity.xml index a654563..830ccd0 100644 --- a/res/layout/base_activity.xml +++ b/res/layout/base_activity.xml @@ -20,19 +20,12 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> - <com.android.car.ui.toolbar.Toolbar - android:id="@+id/toolbar" - android:layout_width="match_parent" - android:layout_height="wrap_content" - app:layout_constraintTop_toTopOf="parent" - app:state="subpage"/> - <FrameLayout android:id="@+id/fragment_container" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/toolbar" + app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/res/layout/confirm_pairing_code_fragment.xml b/res/layout/confirm_pairing_code_fragment.xml index 7af0612..263b187 100644 --- a/res/layout/confirm_pairing_code_fragment.xml +++ b/res/layout/confirm_pairing_code_fragment.xml @@ -50,7 +50,7 @@ android:id="@+id/pairing_code" android:layout_height="@dimen/list_item_height" android:layout_width="wrap_content" - android:layout_marginTop="@dimen/pairing_code_top_margin" + android:layout_marginTop="@dimen/pairing_code_margin_top" android:layout_marginStart="@dimen/car_ui_margin" android:layout_marginEnd="@dimen/car_ui_margin" android:gravity="center" diff --git a/res/layout/trusted_device_detail_fragment.xml b/res/layout/trusted_device_detail_fragment.xml index 6371aff..f2b1274 100644 --- a/res/layout/trusted_device_detail_fragment.xml +++ b/res/layout/trusted_device_detail_fragment.xml @@ -20,22 +20,23 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - <FrameLayout + <TextView android:id="@+id/trusted_device_instruction" - android:layout_width="match_parent" + android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="@dimen/car_ui_margin" android:layout_marginEnd="@dimen/car_ui_margin" - android:gravity="center_horizontal" - style="@style/DeviceListItem"> - <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/no_icon_text_margin_start" - android:layout_marginEnd="@dimen/text_margin_end" - android:text="@string/trusted_device_feature_instruction" - android:textAppearance="@style/TrustedDeviceMessage"/> - </FrameLayout> + android:minHeight="@dimen/list_item_height" + android:gravity="center_vertical" + android:paddingStart="@dimen/list_item_text_no_icon_margin_start" + android:paddingEnd="@dimen/list_item_text_margin_end" + android:paddingBottom="@dimen/list_item_content_margin_bottom" + android:paddingTop="@dimen/list_item_content_margin_top" + android:text="@string/trusted_device_feature_instruction" + android:textAppearance="@style/TrustedDeviceMessage" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent"/> <androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/connection_button" @@ -43,27 +44,33 @@ android:layout_height="wrap_content" android:layout_marginStart="@dimen/car_ui_margin" android:layout_marginEnd="@dimen/car_ui_margin" + android:minHeight="@dimen/list_item_height" app:layout_constraintTop_toBottomOf="@+id/trusted_device_instruction" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" - style="@style/DeviceListItem"> + app:layout_constraintEnd_toEndOf="parent"> <TextView android:id="@+id/trusted_device_item_title" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/no_icon_text_margin_start" - android:layout_marginEnd="@dimen/text_margin_end" + android:layout_marginStart="@dimen/list_item_text_no_icon_margin_start" + android:layout_marginEnd="@dimen/list_item_text_margin_end" android:textAppearance="@style/TrustedDeviceTitle" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toStartOf="@+id/trusted_device_switch" + app:layout_constraintEnd_toStartOf="@+id/switch_start_guideline" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> + <androidx.constraintlayout.widget.Guideline + android:id="@+id/switch_start_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_end="@dimen/car_ui_list_item_icon_container_width" /> <Switch android:id="@+id/trusted_device_switch" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginEnd="@dimen/text_margin_end" - app:layout_constraintStart_toEndOf="@+id/trusted_device_item_title" + android:layout_gravity="center" + app:layout_constraintStart_toEndOf="@+id/switch_start_guideline" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> diff --git a/res/values-land/dimens.xml b/res/values-land/dimens.xml index 5e25502..29e29fb 100644 --- a/res/values-land/dimens.xml +++ b/res/values-land/dimens.xml @@ -21,5 +21,5 @@ <dimen name="device_detail_margin_top">@dimen/car_ui_padding_1</dimen> <dimen name="device_detail_margin">@dimen/car_ui_padding_1</dimen> <dimen name="device_detail_button_margin_top">@dimen/car_ui_padding_1</dimen> - <dimen name="device_detail_divider_margin">@dimen/car_ui_padding_1</dimen> + <dimen name="device_detail_divider_margin">0dp</dimen> </resources> diff --git a/res/values/colors.xml b/res/values/colors.xml index ba7d0f4..c1a7a7a 100644 --- a/res/values/colors.xml +++ b/res/values/colors.xml @@ -15,5 +15,9 @@ --> <resources> + <color name="connection_color_connected">@*android:color/car_green_500</color> + <color name="connection_color_disconnected">@*android:color/car_red_500</color> + <color name="connection_color_not_detected">#b7ffffff</color> + <color name="car_red_300">@*android:color/car_red_300</color> </resources> diff --git a/res/values/dimens.xml b/res/values/dimens.xml index d2c9db7..150ce7b 100644 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -16,36 +16,33 @@ --> <resources> - <dimen name="car_button_height">@*android:dimen/car_button_height</dimen> - <dimen name="icon_size">@*android:dimen/car_primary_icon_size</dimen> - <dimen name="icon_margin_end">44dp</dimen> - <dimen name="icon_margin_start">@dimen/car_ui_keyline_1</dimen> - <dimen name="text_margin_start">@dimen/car_ui_keyline_3</dimen> - <dimen name="text_margin_end">@dimen/car_ui_keyline_1</dimen> - <dimen name="no_icon_text_margin_start">@dimen/car_ui_keyline_1</dimen> - <dimen name="list_item_margin_top">@dimen/car_ui_preference_content_margin_top</dimen> - <dimen name="list_item_margin_bottom">@dimen/car_ui_preference_content_margin_bottom</dimen> - <dimen name="list_item_margin_end">@dimen/car_ui_preference_icon_margin_end</dimen> - <dimen name="list_item_height">116dp</dimen> - <dimen name="list_item_padding">36dp</dimen> - + <dimen name="icon_size">@dimen/car_ui_primary_icon_size</dimen> <dimen name="title_margin_top">116dp</dimen> <dimen name="message_margin_top">@dimen/car_ui_padding_3</dimen> <dimen name="button_margin_top">48dp</dimen> + <dimen name="list_item_height">@dimen/car_ui_list_item_height</dimen> + <dimen name="list_item_icon_size">@dimen/car_ui_list_item_icon_size</dimen> + <dimen name="list_item_icon_container_size">@dimen/car_ui_list_item_icon_container_width</dimen> + <dimen name="list_item_text_margin_start">@dimen/car_ui_list_item_text_start_margin</dimen> + <dimen name="list_item_text_margin_end">@dimen/car_ui_list_item_text_start_margin</dimen> + <dimen name="list_item_text_no_icon_margin_start">@dimen/car_ui_list_item_text_no_icon_start_margin</dimen> + <dimen name="list_item_content_margin_top">@dimen/car_ui_preference_content_margin_top</dimen> + <dimen name="list_item_content_margin_bottom">@dimen/car_ui_preference_content_margin_bottom</dimen> + <dimen name="list_item_divider_width">@dimen/car_ui_divider_width</dimen> + <dimen name="instruction_margin_top">48dp</dimen> <dimen name="instruction_detail_margin_top">@dimen/car_ui_padding_3</dimen> <dimen name="instruction_steps_margin_top">72dp</dimen> - <dimen name="pairing_code_top_margin">@dimen/car_ui_padding_5</dimen> - <dimen name="device_detail_margin_top">48dp</dimen> <dimen name="device_detail_margin">@dimen/car_ui_padding_3</dimen> - <dimen name="device_detail_text_margin">44dp</dimen> <dimen name="device_detail_button_margin_top">72dp</dimen> <dimen name="device_detail_divider_margin">@dimen/car_ui_padding_2</dimen> - <dimen name="car_ui_display1_size">44sp</dimen> - + <dimen name="pairing_code_margin_top">@dimen/car_ui_padding_5</dimen> <item name="pairing_code_text_spacing" format="float" type="dimen">0.6</item> + + <dimen name="connection_indicator_size">12dp</dimen> + <dimen name="connection_indicator_margin_end">12dp</dimen> </resources> diff --git a/res/values/strings.xml b/res/values/strings.xml index 51d6951..4b22e7a 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -99,15 +99,11 @@ <string name="unknown">Unknown</string> <!-- NotificationMsg Feature strings--> - <!-- Notification Channel name for the Companion Device Text Message notifications service [CHAR LIMIT=80]--> - <string name="notification_msg_channel_name">Companion Device Text Message Notifications Channel</string> - <!-- Notification Channel description for the Companion Device Text Message notifications service [CHAR LIMIT=50]--> - <string name="notification_msg_channel_description">Phone Text Message Receiver Service</string> <!--Notification Channel name for the silent "message notification service running" notification [CHAR LIMIT=100]--> - <string name="app_running_msg_channel_name">Phone Text Message service running</string> + <string name="app_running_msg_channel_name">Phone text message service running</string> <!--Notification Channel description for the silent "message notification service running" notification [CHAR LIMIT=50]--> - <string name="app_running_msg_notification_title">Phone Text Messaging service is active</string> - <string name="app_running_msg_notification_content">Receiving Messages via Companion Device</string> + <string name="app_running_msg_notification_title">Phone text messaging service is active</string> + <string name="app_running_msg_notification_content">Receiving text messages via companion device</string> <!-- Title for trusted device feature [CHAR LIMIT=40]--> <string name="trusted_device_feature_title">Unlock profile with phone</string> diff --git a/res/values/styles.xml b/res/values/styles.xml index 45ac7ea..02835fc 100644 --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -19,43 +19,27 @@ <item name="android:tint">@color/car_ui_color_accent</item> <item name="android:scaleType">fitCenter</item> </style> - <style name="AssociationDetailIcon"> - <item name="android:tint">?android:attr/textColorPrimary</item> - <item name="android:scaleType">fitCenter</item> - </style> <style name="AssociationTitle" parent="TextAppearance.Body1"/> + <style name="AssociationInstruction" parent="TextAppearance.Body3"/> + <style name="AssociationMessage" parent="TextAppearance.Body3"> <item name="android:textColor">?android:attr/textColorTertiary</item> </style> + <style name="AssociationPairingCode" parent="TextAppearance.Display1"> <item name="android:letterSpacing">@dimen/pairing_code_text_spacing</item> <item name="android:textColor">@color/car_ui_color_accent</item> </style> + <style name="TrustedDeviceTitle" parent="TextAppearance.Body1"/> - <style name="TrustedDeviceMessage" parent="TextAppearance.Body3"> - <item name="android:textColor">?android:attr/textColorTertiary</item> - </style> - <style name="ConnectionDisabled"> - <item name="android:textColor">@*android:color/car_red_300</item> - </style> - <style name="ConnectionEnabled"> + <style name="TrustedDeviceMessage" parent="TextAppearance.Body3"> <item name="android:textColor">?android:attr/textColorTertiary</item> </style> <style name="DeviceDetailDivider"> <item name="android:background">?android:attr/textColorPrimary</item> </style> - - <style name="InstructionStepItem" parent="DeviceListItem"/> - <style name="DeviceDetailButton" parent="DeviceListItem"> - <item name="android:background">?android:attr/selectableItemBackground</item> - </style> - <style name="DeviceListItem"> - <item name="android:minHeight">@dimen/list_item_height</item> - <item name="android:paddingTop">@dimen/list_item_padding</item> - <item name="android:paddingBottom">@dimen/list_item_padding</item> - </style> -</resources>
\ No newline at end of file +</resources> diff --git a/res/values/themes.xml b/res/values/themes.xml index ef4ea68..d7c3ebf 100644 --- a/res/values/themes.xml +++ b/res/values/themes.xml @@ -16,5 +16,5 @@ --> <resources> - <style name="CompanionDeviceSupportTheme" parent="Theme.CarUi"/> + <style name="CompanionDeviceSupportTheme" parent="Theme.CarUi.WithToolbar"/> </resources> diff --git a/src/com/android/car/companiondevicesupport/activity/AssociatedDeviceDetailFragment.java b/src/com/android/car/companiondevicesupport/activity/AssociatedDeviceDetailFragment.java index 0438636..ab3224c 100644 --- a/src/com/android/car/companiondevicesupport/activity/AssociatedDeviceDetailFragment.java +++ b/src/com/android/car/companiondevicesupport/activity/AssociatedDeviceDetailFragment.java @@ -18,6 +18,7 @@ package com.android.car.companiondevicesupport.activity; import static com.android.car.connecteddevice.util.SafeLog.loge; +import android.graphics.drawable.Drawable; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -30,7 +31,6 @@ import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProviders; import com.android.car.companiondevicesupport.R; -import com.android.car.companiondevicesupport.api.external.AssociatedDevice; import com.android.car.companiondevicesupport.feature.trust.TrustedDeviceConstants; @@ -38,7 +38,8 @@ import com.android.car.companiondevicesupport.feature.trust.TrustedDeviceConstan public class AssociatedDeviceDetailFragment extends Fragment { private final static String TAG = "AssociatedDeviceDetailFragment"; private TextView mDeviceName; - private TextView mConnectionStatus; + private TextView mConnectionStatusText; + private ImageView mConnectionStatusIndicator; private TextView mConnectionText; private ImageView mConnectionIcon; private AssociatedDeviceViewModel mModel; @@ -54,7 +55,8 @@ public class AssociatedDeviceDetailFragment extends Fragment { mDeviceName = view.findViewById(R.id.device_name); mConnectionIcon = view.findViewById(R.id.connection_icon); mConnectionText = view.findViewById(R.id.connection_text); - mConnectionStatus = view.findViewById(R.id.connection_status); + mConnectionStatusText = view.findViewById(R.id.connection_status_text); + mConnectionStatusIndicator = view.findViewById(R.id.connection_status_indicator); mModel = ViewModelProviders.of(getActivity()).get(AssociatedDeviceViewModel.class); mModel.getDeviceDetails().observe(this, this::setDeviceDetails); @@ -74,30 +76,33 @@ public class AssociatedDeviceDetailFragment extends Fragment { return; } mDeviceName.setText(deviceDetails.getDeviceName()); + if (!deviceDetails.isConnectionEnabled()) { - setConnectionDisabledStyle(); - return; - } - setConnectionEnabledStyle(); - if (deviceDetails.isConnected()) { - mConnectionStatus.setText(getString(R.string.connected)); + setConnectionStatus( + ContextCompat.getColor(getContext(), R.color.connection_color_disconnected), + getString(R.string.disconnected), + ContextCompat.getDrawable(getContext(), R.drawable.ic_phonelink_ring), + getString(R.string.enable_device_connection_text)); + } else if (deviceDetails.isConnected()) { + setConnectionStatus( + ContextCompat.getColor(getContext(), R.color.connection_color_connected), + getString(R.string.connected), + ContextCompat.getDrawable(getContext(), R.drawable.ic_phonelink_erase), + getString(R.string.disable_device_connection_text)); } else { - mConnectionStatus.setText(getString(R.string.notDetected)); + setConnectionStatus( + ContextCompat.getColor(getContext(), R.color.connection_color_not_detected), + getString(R.string.notDetected), + ContextCompat.getDrawable(getContext(), R.drawable.ic_phonelink_erase), + getString(R.string.disable_device_connection_text)); } } - private void setConnectionEnabledStyle() { - mConnectionText.setText(getString(R.string.disable_device_connection_text)); - mConnectionIcon.setImageDrawable(ContextCompat.getDrawable(getContext(), - R.drawable.ic_phonelink_erase)); - mConnectionStatus.setTextAppearance(R.style.ConnectionEnabled); - } - - private void setConnectionDisabledStyle() { - mConnectionStatus.setText(getString(R.string.disconnected)); - mConnectionStatus.setTextAppearance(R.style.ConnectionDisabled); - mConnectionText.setText(getString(R.string.enable_device_connection_text)); - mConnectionIcon.setImageDrawable(ContextCompat.getDrawable(getContext(), - R.drawable.ic_phonelink_ring)); + private void setConnectionStatus(int connectionStatusColor, String connectionStatusText, + Drawable connectionIcon, String connectionText) { + mConnectionStatusText.setText(connectionStatusText); + mConnectionStatusIndicator.setColorFilter(connectionStatusColor); + mConnectionText.setText(connectionText); + mConnectionIcon.setImageDrawable(connectionIcon); } } diff --git a/src/com/android/car/companiondevicesupport/activity/AssociationActivity.java b/src/com/android/car/companiondevicesupport/activity/AssociationActivity.java index bb522c4..aae0a9c 100644 --- a/src/com/android/car/companiondevicesupport/activity/AssociationActivity.java +++ b/src/com/android/car/companiondevicesupport/activity/AssociationActivity.java @@ -18,6 +18,8 @@ package com.android.car.companiondevicesupport.activity; import static com.android.car.connecteddevice.util.SafeLog.logd; import static com.android.car.connecteddevice.util.SafeLog.loge; +import static com.android.car.ui.core.CarUi.requireToolbar; +import static com.android.car.ui.toolbar.Toolbar.State.SUBPAGE; import android.annotation.NonNull; import android.app.AlertDialog; @@ -38,7 +40,7 @@ import androidx.lifecycle.ViewModelProviders; import com.android.car.companiondevicesupport.R; import com.android.car.companiondevicesupport.api.external.AssociatedDevice; import com.android.car.ui.toolbar.MenuItem; -import com.android.car.ui.toolbar.Toolbar; +import com.android.car.ui.toolbar.ToolbarController; import java.util.Arrays; @@ -62,19 +64,20 @@ public class AssociationActivity extends FragmentActivity { private static final String REMOVE_DEVICE_DIALOG_TAG = "RemoveDeviceDialog"; private static final String TURN_ON_BLUETOOTH_DIALOG_TAG = "TurnOnBluetoothDialog"; - private Toolbar mToolbar; + private ToolbarController mToolbar; private AssociatedDeviceViewModel mModel; @Override public void onCreate(Bundle saveInstanceState) { super.onCreate(saveInstanceState); setContentView(R.layout.base_activity); - mToolbar = findViewById(R.id.toolbar); + mToolbar = requireToolbar(this); + mToolbar.setState(SUBPAGE); observeViewModel(); if (saveInstanceState != null) { resumePreviousState(); } - mToolbar.showProgressBar(); + mToolbar.getProgressBar().setVisible(true); } @Override @@ -82,7 +85,7 @@ public class AssociationActivity extends FragmentActivity { super.onBackPressed(); mModel.stopAssociation(); dismissConfirmButtons(); - mToolbar.hideProgressBar(); + mToolbar.getProgressBar().setVisible(false); } private void observeViewModel() { @@ -167,26 +170,26 @@ public class AssociationActivity extends FragmentActivity { private void showTurnOnBluetoothFragment() { TurnOnBluetoothFragment fragment = new TurnOnBluetoothFragment(); - mToolbar.showProgressBar(); + mToolbar.getProgressBar().setVisible(true); launchFragment(fragment, TURN_ON_BLUETOOTH_FRAGMENT_TAG); } private void showAddAssociatedDeviceFragment(String deviceName) { AddAssociatedDeviceFragment fragment = AddAssociatedDeviceFragment.newInstance(deviceName); launchFragment(fragment, ADD_DEVICE_FRAGMENT_TAG); - mToolbar.showProgressBar(); + mToolbar.getProgressBar().setVisible(true); } private void showConfirmPairingCodeFragment(String pairingCode) { ConfirmPairingCodeFragment fragment = ConfirmPairingCodeFragment.newInstance(pairingCode); launchFragment(fragment, PAIRING_CODE_FRAGMENT_TAG); showConfirmButtons(); - mToolbar.hideProgressBar(); + mToolbar.getProgressBar().setVisible(false); } private void showAssociationErrorFragment() { dismissConfirmButtons(); - mToolbar.showProgressBar(); + mToolbar.getProgressBar().setVisible(true); AssociationErrorFragment fragment = new AssociationErrorFragment(); launchFragment(fragment, ASSOCIATION_ERROR_FRAGMENT_TAG); } @@ -194,7 +197,7 @@ public class AssociationActivity extends FragmentActivity { private void showAssociatedDeviceDetailFragment() { AssociatedDeviceDetailFragment fragment = new AssociatedDeviceDetailFragment(); launchFragment(fragment, DEVICE_DETAIL_FRAGMENT_TAG); - mToolbar.hideProgressBar(); + mToolbar.getProgressBar().setVisible(false); showTurnOnBluetoothDialog(); } @@ -254,7 +257,7 @@ public class AssociationActivity extends FragmentActivity { private void retryAssociation() { dismissConfirmButtons(); - mToolbar.showProgressBar(); + mToolbar.getProgressBar().setVisible(true); Fragment fragment = getSupportFragmentManager() .findFragmentByTag(PAIRING_CODE_FRAGMENT_TAG); if (fragment != null) { diff --git a/src/com/android/car/companiondevicesupport/feature/RemoteFeature.java b/src/com/android/car/companiondevicesupport/feature/RemoteFeature.java index 6fa3256..d4551e5 100644 --- a/src/com/android/car/companiondevicesupport/feature/RemoteFeature.java +++ b/src/com/android/car/companiondevicesupport/feature/RemoteFeature.java @@ -18,6 +18,7 @@ package com.android.car.companiondevicesupport.feature; import static com.android.car.connecteddevice.util.SafeLog.logd; import static com.android.car.connecteddevice.util.SafeLog.loge; +import static com.android.car.connecteddevice.util.SafeLog.logw; import android.annotation.CallSuper; import android.annotation.NonNull; @@ -26,7 +27,9 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.os.Handler; import android.os.IBinder; +import android.os.Looper; import android.os.ParcelUuid; import android.os.RemoteException; import android.os.UserHandle; @@ -39,6 +42,7 @@ import com.android.car.companiondevicesupport.api.external.IDeviceAssociationCal import com.android.car.companiondevicesupport.api.external.IDeviceCallback; import com.android.car.companiondevicesupport.service.CompanionDeviceSupportService; +import java.time.Duration; import java.util.List; /** @@ -50,12 +54,23 @@ public abstract class RemoteFeature { private static final String TAG = "RemoteFeature"; + private static final String SERVICE_PACKAGE_NAME = "com.android.car.companiondevicesupport"; + + private static final String FULLY_QUALIFIED_SERVICE_NAME = SERVICE_PACKAGE_NAME + + ".service.CompanionDeviceSupportService"; + + private static final Duration BIND_RETRY_DURATION = Duration.ofSeconds(1); + + private static final int MAX_BIND_ATTEMPTS = 3; + private final Context mContext; private final ParcelUuid mFeatureId; private IConnectedDeviceManager mConnectedDeviceManager; + private int mBindAttempts; + public RemoteFeature(@NonNull Context context, @NonNull ParcelUuid featureId) { mContext = context; mFeatureId = featureId; @@ -64,10 +79,8 @@ public abstract class RemoteFeature { /** Start setup process and begin binding to {@link CompanionDeviceSupportService}. */ @CallSuper public void start() { - Intent intent = new Intent(mContext, CompanionDeviceSupportService.class); - intent.setAction(CompanionDeviceSupportService.ACTION_BIND_CONNECTED_DEVICE_MANAGER); - mContext.bindServiceAsUser(intent, mServiceConnection, Context.BIND_AUTO_CREATE, - UserHandle.SYSTEM); + mBindAttempts = 0; + bindToService(); } /** Called when the hosting service is being destroyed. Cleans up internal feature logic. */ @@ -211,6 +224,25 @@ public abstract class RemoteFeature { /** Called when an {@link AssociatedDevice} is updated for the given user. */ protected void onAssociatedDeviceUpdated(@NonNull AssociatedDevice device) { } + private void bindToService() { + Intent intent = new Intent(); + intent.setComponent(new ComponentName(SERVICE_PACKAGE_NAME, FULLY_QUALIFIED_SERVICE_NAME)); + intent.setAction(CompanionDeviceSupportService.ACTION_BIND_CONNECTED_DEVICE_MANAGER); + boolean success = mContext.bindServiceAsUser(intent, mServiceConnection, + Context.BIND_AUTO_CREATE, UserHandle.SYSTEM); + if (!success) { + mBindAttempts++; + if (mBindAttempts > MAX_BIND_ATTEMPTS) { + loge(TAG, "Failed to bind to CompanionDeviceSupportService after " + mBindAttempts + + " attempts. Aborting."); + return; + } + logw(TAG, "Unable to bind to CompanionDeviceSupportService. Trying again."); + new Handler(Looper.getMainLooper()).postDelayed(this::bindToService, + BIND_RETRY_DURATION.toMillis()); + } + } + private final ServiceConnection mServiceConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { diff --git a/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarCleaner.java b/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarCleaner.java index 9bedfab..9b2885c 100644 --- a/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarCleaner.java +++ b/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarCleaner.java @@ -60,6 +60,12 @@ class CalendarCleaner { CalendarContract.ACCOUNT_TYPE_LOCAL }, null); + if (cursor == null) { + // This means the content provider crashed and the Activity Manager will kill this + // process shortly afterwards. + return; + } + ArrayList<ContentProviderOperation> deleteOps = new ArrayList<>(); while (cursor.moveToNext()) { buildCalendarDeletionOps(cursor.getString(0), deleteOps); @@ -89,6 +95,11 @@ class CalendarCleaner { EVENTS_CALENDAR_ID_SELECTION, calendarIdArgs, null); + if (cursor == null) { + // This means the content provider crashed and the Activity Manager will kill this + // process shortly afterwards. + return; + } while (cursor.moveToNext()) { operations.add( diff --git a/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarImporter.java b/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarImporter.java index c05fc4d..5416a15 100644 --- a/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarImporter.java +++ b/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarImporter.java @@ -75,11 +75,8 @@ class CalendarImporter { */ void importCalendars(@NonNull Calendars calendars) { for (Calendar calendar : calendars.getCalendarList()) { - logd( - TAG, - String.format( - "Import Calendar[title=%s, uuid=%s] with %d events", - calendar.getTitle(), calendar.getUuid(), calendar.getEventCount())); + logd(TAG, String.format("Import Calendar[title=%s, uuid=%s] with %d events", + calendar.getTitle(), calendar.getUuid(), calendar.getEventCount())); if (calendar.getEventCount() == 0) { logd(TAG, "Ignore calendar- has no events"); continue; @@ -102,21 +99,23 @@ class CalendarImporter { /** * Provides the calendar identifier used by the system. - * - * <p>This identifier is system-specific and is used to know to which calendar an events - * belongs. + * <p> + * This identifier is system-specific and is used to know to which calendar an events belongs. * * @param uuid The UUID of the calendar to find. * @return The identifier of the calendar or {@link #INVALID_CALENDAR_ID} if nothing was found. */ int findCalendar(@NonNull final String uuid) { - Cursor cursor = - mContentResolver.query( - CalendarContract.Calendars.CONTENT_URI, - new String[]{CalendarContract.Calendars._ID}, - CalendarContract.Calendars._SYNC_ID + " = ?", - new String[]{uuid}, - null); + Cursor cursor = mContentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + new String[]{ + CalendarContract.Calendars._ID + }, + CalendarContract.Calendars._SYNC_ID + " = ?", + new String[]{ + uuid + }, + null); if (cursor.getCount() == 0) { return INVALID_CALENDAR_ID; @@ -150,8 +149,7 @@ class CalendarImporter { Uri uri = insertContent(CalendarContract.Calendars.CONTENT_URI, values); Matcher matcher = CALENDAR_ID_PATTERN.matcher(uri.toString()); - return matcher.matches() - ? Integer.valueOf(matcher.group(CALENDAR_ID_GROUP)) + return matcher.matches() ? Integer.valueOf(matcher.group(CALENDAR_ID_GROUP)) : findCalendar(calendar.getUuid()); } @@ -167,11 +165,10 @@ class CalendarImporter { SECONDS.toMillis(event.getStartDate().getSeconds())); values.put(CalendarContract.Events.DTEND, SECONDS.toMillis(event.getEndDate().getSeconds())); - values.put(CalendarContract.Events.ALL_DAY, event.getIsAllDay() ? 1 : 0); values.put(CalendarContract.Events.EVENT_LOCATION, event.getLocation()); values.put(CalendarContract.Events.ORGANIZER, event.getOrganizer()); + values.put(CalendarContract.Events.ALL_DAY, event.getIsAllDay() ? 1 : 0); - // TODO(b/150335852) Store CreationDate and LastModifiedDate if (event.hasColor()) { values.put(CalendarContract.Events.EVENT_COLOR, event.getColor().getArgb()); } @@ -210,16 +207,14 @@ class CalendarImporter { values.put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.getEmail()); values.put(CalendarContract.Attendees.ATTENDEE_TYPE, convertAttendeeType(attendee.getType())); - values.put( - CalendarContract.Attendees.ATTENDEE_STATUS, + values.put(CalendarContract.Attendees.ATTENDEE_STATUS, convertAttendeeStatus(attendee.getStatus())); values.put(CalendarContract.Attendees.EVENT_ID, eventId); - operations.add( - ContentProviderOperation.newInsert( - appendQueryParameters(CalendarContract.Attendees.CONTENT_URI)) - .withValues(values) - .build()); + operations.add(ContentProviderOperation.newInsert( + appendQueryParameters(CalendarContract.Attendees.CONTENT_URI)) + .withValues(values) + .build()); } try { @@ -236,8 +231,8 @@ class CalendarImporter { private Uri appendQueryParameters(@NonNull Uri contentUri) { Uri.Builder builder = contentUri.buildUpon(); builder.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, DEFAULT_ACCOUNT_NAME); - builder.appendQueryParameter( - CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL); + builder.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, + CalendarContract.ACCOUNT_TYPE_LOCAL); builder.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true"); return builder.build(); } diff --git a/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgDelegate.java b/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgDelegate.java index 6e82fb4..9c00b93 100644 --- a/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgDelegate.java +++ b/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgDelegate.java @@ -22,25 +22,30 @@ import static com.android.car.connecteddevice.util.SafeLog.logw; import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; +import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.media.AudioAttributes; import android.provider.Settings; +import androidx.annotation.Nullable; + import com.android.car.companiondevicesupport.api.external.CompanionDevice; import com.android.car.messenger.NotificationMsgProto.NotificationMsg.Action; -import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MapEntry; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.AvatarIconSync; import com.android.car.messenger.NotificationMsgProto.NotificationMsg.CarToPhoneMessage; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.ClearAppDataRequest; import com.android.car.messenger.NotificationMsgProto.NotificationMsg.ConversationNotification; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MapEntry; import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyleMessage; import com.android.car.messenger.NotificationMsgProto.NotificationMsg.PhoneToCarMessage; import com.android.car.messenger.common.BaseNotificationDelegate; import com.android.car.messenger.common.ConversationKey; import com.android.car.messenger.common.ConversationNotificationInfo; import com.android.car.messenger.common.Message; -import com.android.car.messenger.common.MessageKey; import com.android.car.messenger.common.ProjectionStateListener; import com.android.car.messenger.common.SenderKey; import com.android.car.messenger.common.Utils; +import com.android.internal.annotations.VisibleForTesting; import java.util.HashMap; import java.util.List; @@ -53,28 +58,36 @@ import java.util.Map; public class NotificationMsgDelegate extends BaseNotificationDelegate { private static final String TAG = "NotificationMsgDelegate"; - /** The different {@link PhoneToCarMessage} Message Types. **/ - private static final String NEW_CONVERSATION_MESSAGE_TYPE = "NEW_CONVERSATION"; - private static final String NEW_MESSAGE_MESSAGE_TYPE = "NEW_MESSAGE"; - private static final String ACTION_STATUS_UPDATE_MESSAGE_TYPE = "ACTION_STATUS_UPDATE"; - private static final String OTHER_MESSAGE_TYPE = "OTHER"; /** Key for the Reply string in a {@link MapEntry}. **/ - private static final String REPLY_KEY = "REPLY"; + protected static final String REPLY_KEY = "REPLY"; + /** + * Value for {@link ClearAppDataRequest#getMessagingAppPackageName()}, representing + * when all messaging applications' data should be removed. + */ + protected static final String REMOVE_ALL_APP_DATA = "ALL"; private static final AudioAttributes AUDIO_ATTRIBUTES = new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION) .build(); private Map<String, NotificationChannelWrapper> mAppNameToChannel = new HashMap<>(); + + /** + * The Bluetooth Device address of the connected device. NOTE: this is NOT the same as + * {@link CompanionDevice#getDeviceId()}. + */ private String mConnectedDeviceBluetoothAddress; + /** + * Maps a Bitmap of a sender's Large Icon to the sender's unique key for 1-1 conversations. + **/ + protected final Map<SenderKey, Bitmap> mOneOnOneConversationAvatarMap = new HashMap<>(); /** Tracks whether a projection application is active in the foreground. **/ private ProjectionStateListener mProjectionStateListener; - public NotificationMsgDelegate(Context context, String className) { - super(context, className, /* useLetterTile */ false); + public NotificationMsgDelegate(Context context) { + super(context, /* useLetterTile */ false); mProjectionStateListener = new ProjectionStateListener(context); - mProjectionStateListener.start(); } public void onMessageReceived(CompanionDevice device, PhoneToCarMessage message) { @@ -83,19 +96,28 @@ public class NotificationMsgDelegate extends BaseNotificationDelegate { switch (message.getMessageDataCase()) { case CONVERSATION: initializeNewConversation(device, message.getConversation(), notificationKey); + return; case MESSAGE: initializeNewMessage(device.getDeviceId(), message.getMessage(), notificationKey); + return; case STATUS_UPDATE: // TODO (b/144924164): implement Action Request tracking logic. + return; case AVATAR_ICON_SYNC: - // TODO(b/148412881): implement avatar icon sync. + storeIcon(new ConversationKey(device.getDeviceId(), notificationKey), + message.getAvatarIconSync()); + return; case PHONE_METADATA: mConnectedDeviceBluetoothAddress = message.getPhoneMetadata().getBluetoothDeviceAddress(); + return; case CLEAR_APP_DATA_REQUEST: - // TODO(b/150326327): implement removal behavior. + clearAppData(device.getDeviceId(), + message.getClearAppDataRequest().getMessagingAppPackageName()); + return; case FEATURE_ENABLED_STATE_CHANGE: // TODO(b/150326327): implement enabled state change behavior. + return; case MESSAGEDATA_NOT_SET: default: logw(TAG, "PhoneToCarMessage: message data not set!"); @@ -103,8 +125,7 @@ public class NotificationMsgDelegate extends BaseNotificationDelegate { } protected CarToPhoneMessage dismiss(ConversationKey convoKey) { - clearNotifications(key -> key.equals(convoKey)); - excludeFromNotification(convoKey); + super.dismissInternal(convoKey); // TODO(b/144924164): add a request id to the action. Action action = Action.newBuilder() .setActionName(Action.ActionName.DISMISS) @@ -129,18 +150,6 @@ public class NotificationMsgDelegate extends BaseNotificationDelegate { .build(); } - /** - * Excludes messages from a notification so that the messages are not shown to the user once - * the notification gets updated with newer messages. - */ - private void excludeFromNotification(ConversationKey convoKey) { - ConversationNotificationInfo info = mNotificationInfos.get(convoKey); - for (MessageKey key : info.mMessageKeys) { - Message message = mMessages.get(key); - message.excludeFromNotification(); - } - } - protected CarToPhoneMessage reply(ConversationKey convoKey, String message) { // TODO(b/144924164): add a request id to the action. MapEntry entry = MapEntry.newBuilder() @@ -162,7 +171,8 @@ public class NotificationMsgDelegate extends BaseNotificationDelegate { // Erase all the notifications and local data, so that no user data stays on the device // after the feature is stopped. cleanupMessagesAndNotifications(key -> true); - mProjectionStateListener.stop(); + mProjectionStateListener.destroy(); + mOneOnOneConversationAvatarMap.clear(); mAppNameToChannel.clear(); mConnectedDeviceBluetoothAddress = null; } @@ -170,34 +180,45 @@ public class NotificationMsgDelegate extends BaseNotificationDelegate { protected void onDeviceDisconnected(String deviceId) { mConnectedDeviceBluetoothAddress = null; cleanupMessagesAndNotifications(key -> key.matches(deviceId)); + mOneOnOneConversationAvatarMap.entrySet().removeIf( + conversationKey -> conversationKey.getKey().matches(deviceId)); } private void initializeNewConversation(CompanionDevice device, ConversationNotification notification, String notificationKey) { String deviceAddress = device.getDeviceId(); ConversationKey convoKey = new ConversationKey(deviceAddress, notificationKey); - if (mNotificationInfos.containsKey(convoKey)) { - logw(TAG, "Conversation already exists! " + notificationKey); - } if (!Utils.isValidConversationNotification(notification, /* isShallowCheck= */ false)) { logd(TAG, "Failed to initialize new Conversation, object missing required fields"); return; } - ConversationNotificationInfo convoInfo = ConversationNotificationInfo. - createConversationNotificationInfo(device.getDeviceName(), device.getDeviceId(), - notification, notificationKey); - mNotificationInfos.put(convoKey, convoInfo); + ConversationNotificationInfo convoInfo; + if (mNotificationInfos.containsKey(convoKey)) { + logw(TAG, "Conversation already exists! " + notificationKey); + convoInfo = mNotificationInfos.get(convoKey); + } else { + convoInfo = ConversationNotificationInfo. + createConversationNotificationInfo(device.getDeviceName(), device.getDeviceId(), + notification, notificationKey); + mNotificationInfos.put(convoKey, convoInfo); + } + String appDisplayName = convoInfo.getAppDisplayName(); List<MessagingStyleMessage> messages = notification.getMessagingStyle().getMessagingStyleMsgList(); + MessagingStyleMessage latestMessage = messages.get(0); for (MessagingStyleMessage messagingStyleMessage : messages) { createNewMessage(deviceAddress, messagingStyleMessage, convoKey); + if (messagingStyleMessage.getTimestamp() > latestMessage.getTimestamp()) { + latestMessage = messagingStyleMessage; + } } - postNotification(convoKey, convoInfo, getChannelId(appDisplayName)); + postNotification(convoKey, convoInfo, getChannelId(appDisplayName), + getAvatarIcon(convoKey, latestMessage)); } private void initializeNewMessage(String deviceAddress, @@ -216,7 +237,41 @@ public class NotificationMsgDelegate extends BaseNotificationDelegate { createNewMessage(deviceAddress, messagingStyleMessage, convoKey); ConversationNotificationInfo convoInfo = mNotificationInfos.get(convoKey); - postNotification(convoKey, convoInfo, getChannelId(convoInfo.getAppDisplayName())); + postNotification(convoKey, convoInfo, getChannelId(convoInfo.getAppDisplayName()), + getAvatarIcon(convoKey, messagingStyleMessage)); + } + + @Nullable + private Bitmap getAvatarIcon(ConversationKey convoKey, MessagingStyleMessage message) { + ConversationNotificationInfo notificationInfo = mNotificationInfos.get(convoKey); + if (!notificationInfo.isGroupConvo()) { + return mOneOnOneConversationAvatarMap.get( + SenderKey.createSenderKey(convoKey, message.getSender())); + } else if (message.getSender().getAvatar() != null + || !message.getSender().getAvatar().isEmpty()) { + byte[] iconArray = message.getSender().getAvatar().toByteArray(); + return BitmapFactory.decodeByteArray(iconArray, 0, iconArray.length); + } + return null; + } + + private void storeIcon(ConversationKey convoKey, AvatarIconSync iconSync) { + if (!Utils.isValidAvatarIconSync(iconSync) || !mNotificationInfos.containsKey(convoKey)) { + logw(TAG, "storeIcon: invalid AvatarIconSync obj or no conversation found."); + return; + } + if (mNotificationInfos.get(convoKey).isGroupConvo()) { + return; + } + byte[] iconArray = iconSync.getPerson().getAvatar().toByteArray(); + Bitmap bitmap = BitmapFactory.decodeByteArray(iconArray, /* offset= */ 0, iconArray.length); + if (bitmap != null) { + mOneOnOneConversationAvatarMap.put( + SenderKey.createSenderKey(convoKey, iconSync.getPerson()), + bitmap); + } else { + logw(TAG, "storeIcon: Bitmap could not be created from byteArray"); + } } private String getChannelId(String appDisplayName) { @@ -224,24 +279,34 @@ public class NotificationMsgDelegate extends BaseNotificationDelegate { mAppNameToChannel.put(appDisplayName, new NotificationChannelWrapper(appDisplayName)); } - boolean isProjectionActive = mProjectionStateListener.isProjectionInActiveForeground( - mConnectedDeviceBluetoothAddress); - return mAppNameToChannel.get(appDisplayName).getChannelId(isProjectionActive); + return mAppNameToChannel.get(appDisplayName).getChannelId( + mProjectionStateListener.isProjectionInActiveForeground( + mConnectedDeviceBluetoothAddress)); } private void createNewMessage(String deviceAddress, MessagingStyleMessage messagingStyleMessage, ConversationKey convoKey) { - String appDisplayName = mNotificationInfos.get(convoKey).getAppDisplayName(); + String appPackageName = mNotificationInfos.get(convoKey).getAppPackageName(); Message message = Message.parseFromMessage(deviceAddress, messagingStyleMessage, - appDisplayName); + SenderKey.createSenderKey(convoKey, messagingStyleMessage.getSender())); addMessageToNotificationInfo(message, convoKey); - SenderKey senderKey = message.getSenderKey(); - if (!mSenderLargeIcons.containsKey(senderKey) - && messagingStyleMessage.getSender().getAvatar() != null) { - byte[] iconArray = messagingStyleMessage.getSender().getAvatar().toByteArray(); - mSenderLargeIcons.put(senderKey, - BitmapFactory.decodeByteArray(iconArray, 0, iconArray.length)); + AvatarIconSync iconSync = AvatarIconSync.newBuilder() + .setPerson(messagingStyleMessage.getSender()) + .setMessagingAppPackageName(appPackageName) + .build(); + storeIcon(convoKey, iconSync); + } + + private void clearAppData(String deviceId, String packageName) { + if (!packageName.equals(REMOVE_ALL_APP_DATA)) { + // Clearing data for specific package names is not supported since this use case + // is not needed right now. + logw(TAG, "clearAppData not supported for arg: " + packageName); + return; } + cleanupMessagesAndNotifications(key -> key.matches(deviceId)); + mOneOnOneConversationAvatarMap.entrySet().removeIf( + conversationKey -> conversationKey.getKey().matches(deviceId)); } /** Creates notification channels per unique messaging application. **/ @@ -297,4 +362,14 @@ public class NotificationMsgDelegate extends BaseNotificationDelegate { return ++NEXT_NOTIFICATION_CHANNEL_ID; } } + + @VisibleForTesting + void setNotificationManager(NotificationManager manager) { + mNotificationManager = manager; + } + + @VisibleForTesting + void setProjectionStateListener(ProjectionStateListener listener) { + mProjectionStateListener = listener; + } } diff --git a/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgFeature.java b/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgFeature.java index 925aa0c..9e8d4a3 100644 --- a/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgFeature.java +++ b/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgFeature.java @@ -63,7 +63,7 @@ public class NotificationMsgFeature extends RemoteFeature { public void stop() { // Erase all the notifications and local data, so that no user data stays on the device // after the feature is stopped. - mNotificationMsgDelegate.cleanupMessagesAndNotifications(key -> true); + mNotificationMsgDelegate.onDestroy(); super.stop(); } diff --git a/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgService.java b/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgService.java index 8d7e694..c75d6af 100644 --- a/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgService.java +++ b/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgService.java @@ -75,7 +75,7 @@ public class NotificationMsgService extends Service { super.onCreate(); mNotificationManager = getSystemService(NotificationManager.class); - mNotificationMsgDelegate = new NotificationMsgDelegate(this, this.getClass().getName()); + mNotificationMsgDelegate = new NotificationMsgDelegate(this); mNotificationMsgFeature = new NotificationMsgFeature(this, mNotificationMsgDelegate); mNotificationMsgFeature.start(); sendServiceRunningNotification(); @@ -85,7 +85,6 @@ public class NotificationMsgService extends Service { public void onDestroy() { super.onDestroy(); mNotificationMsgFeature.stop(); - mNotificationMsgDelegate.onDestroy(); } @Override diff --git a/src/com/android/car/companiondevicesupport/feature/trust/ui/TrustedDeviceActivity.java b/src/com/android/car/companiondevicesupport/feature/trust/ui/TrustedDeviceActivity.java index fc9b227..e53e821 100644 --- a/src/com/android/car/companiondevicesupport/feature/trust/ui/TrustedDeviceActivity.java +++ b/src/com/android/car/companiondevicesupport/feature/trust/ui/TrustedDeviceActivity.java @@ -21,6 +21,8 @@ import static com.android.car.companiondevicesupport.activity.AssociationActivit import static com.android.car.connecteddevice.util.SafeLog.logd; import static com.android.car.connecteddevice.util.SafeLog.loge; import static com.android.car.connecteddevice.util.SafeLog.logw; +import static com.android.car.ui.core.CarUi.requireToolbar; +import static com.android.car.ui.toolbar.Toolbar.State.SUBPAGE; import android.annotation.Nullable; import android.app.AlertDialog; @@ -53,7 +55,7 @@ import com.android.car.companiondevicesupport.api.internal.trust.ITrustedDeviceM import com.android.car.companiondevicesupport.api.internal.trust.TrustedDevice; import com.android.car.companiondevicesupport.feature.trust.TrustedDeviceConstants; import com.android.car.companiondevicesupport.feature.trust.TrustedDeviceManagerService; -import com.android.car.ui.toolbar.Toolbar; +import com.android.car.ui.toolbar.ToolbarController; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @@ -104,7 +106,7 @@ public class TrustedDeviceActivity extends FragmentActivity { private ITrustedDeviceManager mTrustedDeviceManager; - private Toolbar mToolbar; + private ToolbarController mToolbar; private TrustedDeviceViewModel mModel; @@ -114,9 +116,10 @@ public class TrustedDeviceActivity extends FragmentActivity { setContentView(R.layout.base_activity); observeViewModel(); resumePreviousState(savedInstanceState); - mToolbar = findViewById(R.id.toolbar); + mToolbar = requireToolbar(this); + mToolbar.setState(SUBPAGE); mToolbar.setTitle(R.string.trusted_device_feature_title); - mToolbar.showProgressBar(); + mToolbar.getProgressBar().setVisible(true); mIsScreenLockNewlyCreated.set(false); mIsStartedForEnrollment.set(false); mHasPendingCredential.set(false); @@ -375,7 +378,7 @@ public class TrustedDeviceActivity extends FragmentActivity { } private void showTrustedDeviceDetailFragment(AssociatedDevice device) { - mToolbar.hideProgressBar(); + mToolbar.getProgressBar().setVisible(false); TrustedDeviceDetailFragment fragment = TrustedDeviceDetailFragment.newInstance(device); getSupportFragmentManager().beginTransaction() .replace(R.id.fragment_container, fragment, DEVICE_DETAIL_FRAGMENT_TAG) diff --git a/tests/unit/AndroidManifest.xml b/tests/unit/AndroidManifest.xml index b121cf9..7447fe0 100644 --- a/tests/unit/AndroidManifest.xml +++ b/tests/unit/AndroidManifest.xml @@ -26,16 +26,8 @@ <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"/> <application android:testOnly="true" - android:debuggable="true" - xmlns:tools="http://schemas.android.com/tools"> + android:debuggable="true"> <uses-library android:name="android.test.runner"/> - <!-- Workaround for b/113294940 --> - <provider - android:name="androidx.lifecycle.ProcessLifecycleOwnerInitializer" - tools:replace="android:authorities" - android:authorities="${applicationId}.lifecycle" - android:exported="false" - android:multiprocess="true" /> </application> <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner" diff --git a/tests/unit/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarCleanerTest.java b/tests/unit/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarCleanerTest.java index 536f098..8f65b81 100644 --- a/tests/unit/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarCleanerTest.java +++ b/tests/unit/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarCleanerTest.java @@ -20,6 +20,7 @@ import static android.provider.CalendarContract.AUTHORITY; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; @@ -165,6 +166,28 @@ public class CalendarCleanerTest { verifyNoMoreInteractions(mContentProvider); } + @Test + public void eraseCalendar_failingQuery() { + when(mContentResolver.query(eq(Events.CONTENT_URI), any(), any(), any(), + eq(null))).thenReturn(null); + try { + mCalendarCleaner.eraseCalendar(CALENDAR_IDENTIFIER); + } catch (NullPointerException e) { + fail(); + } + } + + @Test + public void eraseCalendars_failingQuery() { + when(mContentResolver.query(eq(Events.CONTENT_URI), any(), any(), any(), + eq(null))).thenReturn(null); + try { + mCalendarCleaner.eraseCalendars(); + } catch (NullPointerException e) { + fail(); + } + } + // --- Helpers --- private ArgumentMatcher<String[]> createStringArrayMatcher(String expectedArg) { @@ -181,7 +204,7 @@ public class CalendarCleanerTest { private void verifyDelete(Uri uri, String selection, String selectionArg) { verify(mContentProvider).delete( eq(uri), - eq(String.format("%s = ?", selection)), - argThat(createStringArrayMatcher(selectionArg))); + argThat(selectionBundleMatcher( + String.format("%s = ?", selection), new String[]{selectionArg}))); } } diff --git a/tests/unit/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarImporterTest.java b/tests/unit/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarImporterTest.java index a2c2cad..97dfeb0 100644 --- a/tests/unit/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarImporterTest.java +++ b/tests/unit/src/com/android/car/companiondevicesupport/feature/calendarsync/CalendarImporterTest.java @@ -113,15 +113,16 @@ public class CalendarImporterTest { when(mContentProvider.insert( argThat(startsWithUriMatcher(CalendarContract.Attendees.CONTENT_URI)), + any(), any())) .thenReturn(CalendarContract.Attendees.CONTENT_URI); - } - @Test - public void findCalendar() { when(mCursor.getCount()).thenReturn(1); when(mCursor.getString(eq(0))).thenReturn(CALENDAR_ID); + } + @Test + public void findCalendar() { assertEquals(CALENDAR_ID, String.valueOf(mCalendarImporter.findCalendar(CALENDAR_UNIQUE_ID))); @@ -141,9 +142,6 @@ public class CalendarImporterTest { @Test public void importCalendarsWithExistingCalendar() throws Exception { - when(mCursor.getCount()).thenReturn(1); - when(mCursor.getString(eq(0))).thenReturn(CALENDAR_ID); - ArgumentCaptor<ArrayList<ContentProviderOperation>> batchOpsCaptor = ArgumentCaptor.forClass(ArrayList.class); @@ -176,6 +174,27 @@ public class CalendarImporterTest { } @Test + public void importCalendarAndVerifyAllDayEvent() throws Exception { + Event.Builder allDayEventBuilder = newEvent("UID_1", "Event A", "", "here", 1, 2, + "Europe/Berlin", null); + allDayEventBuilder.setIsAllDay(true); + + mCalendarsProto = Calendars.newBuilder() + .addCalendar(Calendar.newBuilder() + .setUuid(CALENDAR_UNIQUE_ID) + .setTitle("calendar one") + .addEvent(allDayEventBuilder)) + .build(); + + mCalendarImporter.importCalendars(mCalendarsProto); + + // Verify event insertion. + verify(mContentResolver).insert( + argThat(startsWithUriMatcher(CalendarContract.Events.CONTENT_URI)), + argThat(argument -> argument.getAsInteger(Events.ALL_DAY) == 1)); + } + + @Test public void convertAttendeeStatus() { assertEquals(Attendees.ATTENDEE_STATUS_NONE, CalendarImporter.convertAttendeeStatus(Attendee.Status.NONE_STATUS)); @@ -230,7 +249,8 @@ public class CalendarImporterTest { argument.getAsLong(Events.DTEND).equals( SECONDS.toMillis(event.getEndDate().getSeconds())) && argument.getAsString(Events.EVENT_LOCATION).equals(event.getLocation()) && - argument.getAsString(Events.ORGANIZER).equals(event.getOrganizer()); + argument.getAsString(Events.ORGANIZER).equals(event.getOrganizer()) && + argument.getAsInteger(Events.ALL_DAY) == (event.getIsAllDay() ? 1 : 0); }; } @@ -277,6 +297,7 @@ public class CalendarImporterTest { private void verifyAttendeeInsert(Attendee attendee) { verify(mContentProvider).insert( argThat(startsWithUriMatcher(CalendarContract.Attendees.CONTENT_URI)), - argThat(attendeeMatcher(attendee))); + argThat(attendeeMatcher(attendee)), + any()); } } diff --git a/tests/unit/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgDelegateTest.java b/tests/unit/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgDelegateTest.java new file mode 100644 index 0000000..e41df2c --- /dev/null +++ b/tests/unit/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgDelegateTest.java @@ -0,0 +1,785 @@ +/* + * 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.car.companiondevicesupport.feature.notificationmsg; + + +import static android.app.NotificationManager.IMPORTANCE_HIGH; +import static android.app.NotificationManager.IMPORTANCE_LOW; + +import static androidx.core.app.NotificationCompat.CATEGORY_MESSAGE; + +import static com.android.car.messenger.NotificationMsgProto.NotificationMsg.Action.ActionName.DISMISS; +import static com.android.car.messenger.NotificationMsgProto.NotificationMsg.Action.ActionName.MARK_AS_READ; +import static com.android.car.messenger.NotificationMsgProto.NotificationMsg.Action.ActionName.REPLY; + +import static com.google.common.truth.Truth.assertThat; + +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.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.Icon; + +import androidx.core.app.NotificationCompat; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.car.companiondevicesupport.api.external.CompanionDevice; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.Action; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.AvatarIconSync; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.CarToPhoneMessage; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.ClearAppDataRequest; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.ConversationNotification; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyle; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyleMessage; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.Person; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.PhoneMetadata; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg.PhoneToCarMessage; +import com.android.car.messenger.common.ConversationKey; +import com.android.car.messenger.common.ProjectionStateListener; +import com.android.car.messenger.common.SenderKey; +import com.android.car.protobuf.ByteString; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@RunWith(AndroidJUnit4.class) +public class NotificationMsgDelegateTest { + private static final String NOTIFICATION_KEY_1 = "notification_key_1"; + private static final String NOTIFICATION_KEY_2 = "notification_key_2"; + + private static final String COMPANION_DEVICE_ID = "sampleId"; + private static final String COMPANION_DEVICE_NAME = "sampleName"; + private static final String DEVICE_ADDRESS = UUID.randomUUID().toString(); + private static final String BT_DEVICE_ADDRESS = UUID.randomUUID().toString(); + + private static final String MESSAGING_APP_NAME = "Messaging App"; + private static final String MESSAGING_PACKAGE_NAME = "com.android.messaging.app"; + private static final String CONVERSATION_TITLE = "Conversation"; + private static final String USER_DISPLAY_NAME = "User"; + private static final String SENDER_1 = "Sender"; + private static final String MESSAGE_TEXT_1 = "Message 1"; + private static final String MESSAGE_TEXT_2 = "Message 2"; + + /** ConversationKey for {@link NotificationMsgDelegateTest#VALID_CONVERSATION_MSG}. **/ + private static final ConversationKey CONVERSATION_KEY_1 + = new ConversationKey(COMPANION_DEVICE_ID, NOTIFICATION_KEY_1); + + private static final MessagingStyleMessage MESSAGE_2 = MessagingStyleMessage.newBuilder() + .setTextMessage(MESSAGE_TEXT_2) + .setSender(Person.newBuilder() + .setName(SENDER_1)) + .setTimestamp((long) 1577909718950f) + .build(); + + private static final MessagingStyle VALID_STYLE = MessagingStyle.newBuilder() + .setConvoTitle(CONVERSATION_TITLE) + .setUserDisplayName(USER_DISPLAY_NAME) + .setIsGroupConvo(false) + .addMessagingStyleMsg(MessagingStyleMessage.newBuilder() + .setTextMessage(MESSAGE_TEXT_1) + .setSender(Person.newBuilder() + .setName(SENDER_1)) + .setTimestamp((long) 1577909718050f) + .build()) + .build(); + + private static final ConversationNotification VALID_CONVERSATION = + ConversationNotification.newBuilder() + .setMessagingAppDisplayName(MESSAGING_APP_NAME) + .setMessagingAppPackageName(MESSAGING_PACKAGE_NAME) + .setTimeMs((long) 1577909716000f) + .setMessagingStyle(VALID_STYLE) + .build(); + + private static final PhoneToCarMessage VALID_CONVERSATION_MSG = PhoneToCarMessage.newBuilder() + .setNotificationKey(NOTIFICATION_KEY_1) + .setConversation(VALID_CONVERSATION) + .build(); + + private Bitmap mIconBitmap; + private byte[] mIconByteArray; + + @Mock + CompanionDevice mCompanionDevice; + @Mock + NotificationManager mMockNotificationManager; + @Mock + ProjectionStateListener mMockProjectionStateListener; + + @Captor + ArgumentCaptor<Notification> mNotificationCaptor; + @Captor + ArgumentCaptor<Integer> mNotificationIdCaptor; + + Context mContext = ApplicationProvider.getApplicationContext(); + NotificationMsgDelegate mNotificationMsgDelegate; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + when(mCompanionDevice.getDeviceId()).thenReturn(COMPANION_DEVICE_ID); + when(mCompanionDevice.getDeviceName()).thenReturn(COMPANION_DEVICE_NAME); + + mNotificationMsgDelegate = new NotificationMsgDelegate(mContext); + mNotificationMsgDelegate.setNotificationManager(mMockNotificationManager); + mNotificationMsgDelegate.setProjectionStateListener(mMockProjectionStateListener); + + mIconBitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888); + + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + mIconBitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + mIconByteArray = stream.toByteArray(); + stream.reset(); + + } + + @After + public void tearDown() { + mIconBitmap.recycle(); + } + + @Test + public void newConversationShouldPostNewNotification() { + // Test that a new conversation notification is posted with the correct fields. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + verify(mMockNotificationManager).notify(anyInt(), mNotificationCaptor.capture()); + + Notification postedNotification = mNotificationCaptor.getValue(); + verifyNotification(VALID_CONVERSATION, postedNotification); + } + + @Test + public void multipleNewConversationShouldPostMultipleNewNotifications() { + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + verify(mMockNotificationManager).notify(mNotificationIdCaptor.capture(), + any(Notification.class)); + int firstNotificationId = mNotificationIdCaptor.getValue(); + + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, + createSecondConversation()); + verify(mMockNotificationManager, times(2)).notify(mNotificationIdCaptor.capture(), + any(Notification.class)); + + // Verify the notification id is different than the first. + assertThat((long) mNotificationIdCaptor.getValue()).isNotEqualTo(firstNotificationId); + } + + @Test + public void invalidConversationShouldDoNothing() { + // Test that a conversation without all the required fields is dropped. + PhoneToCarMessage newConvo = PhoneToCarMessage.newBuilder() + .setNotificationKey(NOTIFICATION_KEY_1) + .setConversation(VALID_CONVERSATION.toBuilder().clearMessagingStyle()) + .build(); + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, newConvo); + + verify(mMockNotificationManager, never()).notify(anyInt(), any(Notification.class)); + } + + @Test + public void newMessageShouldUpdateConversationNotification() { + // Check whether a new message updates the notification of the conversation it belongs to. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + verify(mMockNotificationManager).notify(mNotificationIdCaptor.capture(), + any(Notification.class)); + int notificationId = mNotificationIdCaptor.getValue(); + int messageCount = VALID_CONVERSATION_MSG.getConversation().getMessagingStyle() + .getMessagingStyleMsgCount(); + + // Post a new message in this conversation. + updateConversationWithMessage2(); + + // Verify same notification id is posted twice. + verify(mMockNotificationManager, times(2)).notify(eq(notificationId), + mNotificationCaptor.capture()); + + // Verify the notification contains one more message. + NotificationCompat.MessagingStyle messagingStyle = getMessagingStyle( + mNotificationCaptor.getValue()); + assertThat(messagingStyle.getMessages().size()).isEqualTo(messageCount + 1); + + // Verify notification's latest message matches the new message. + verifyMessage(MESSAGE_2, messagingStyle.getMessages().get(messageCount)); + } + + @Test + public void existingConversationShouldUpdateNotification() { + // Test that a conversation that already exists, but gets a new conversation message + // is updated with the new conversation metadata. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + verify(mMockNotificationManager).notify(mNotificationIdCaptor.capture(), + any(Notification.class)); + int notificationId = mNotificationIdCaptor.getValue(); + + ConversationNotification updatedConversation = addSecondMessageToConversation().toBuilder() + .setMessagingAppDisplayName("New Messaging App") + .build(); + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, PhoneToCarMessage.newBuilder() + .setNotificationKey(NOTIFICATION_KEY_1) + .setConversation(updatedConversation) + .build()); + + verify(mMockNotificationManager, times(2)).notify(eq(notificationId), + mNotificationCaptor.capture()); + Notification postedNotification = mNotificationCaptor.getValue(); + + // Verify Conversation level metadata does NOT change + verifyConversationLevelMetadata(VALID_CONVERSATION, postedNotification); + // Verify the MessagingStyle metadata does update with the new message. + verifyMessagingStyle(updatedConversation.getMessagingStyle(), postedNotification); + } + + @Test + public void messageForUnknownConversationShouldDoNothing() { + // A message for an unknown conversation should be dropped. + updateConversationWithMessage2(); + + verify(mMockNotificationManager, never()).notify(anyInt(), any(Notification.class)); + } + + @Test + public void invalidMessageShouldDoNothing() { + // Message without all the required fields is dropped. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + // Create a MessagingStyleMessage without a required field (Sender information). + MessagingStyleMessage invalidMsgStyleMessage = MessagingStyleMessage.newBuilder() + .setTextMessage("Message 2") + .setTimestamp((long) 1577909718950f) + .build(); + PhoneToCarMessage invalidMessage = PhoneToCarMessage.newBuilder() + .setNotificationKey(NOTIFICATION_KEY_1) + .setMessage(invalidMsgStyleMessage) + .build(); + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, invalidMessage); + + // Verify only one notification is posted, and never updated. + verify(mMockNotificationManager).notify(anyInt(), any(Notification.class)); + } + + @Test + public void invalidAvatarIconSyncShouldDoNothing() { + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + // Create AvatarIconSync message without required field (Icon), ensure it's treated as an + // invalid message. + PhoneToCarMessage invalidMessage = PhoneToCarMessage.newBuilder() + .setNotificationKey(NOTIFICATION_KEY_1) + .setAvatarIconSync(AvatarIconSync.newBuilder() + .setPerson(Person.newBuilder() + .setName(SENDER_1)) + .build()) + .build(); + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, invalidMessage); + assertThat(mNotificationMsgDelegate.mOneOnOneConversationAvatarMap.isEmpty()).isTrue(); + } + + @Test + public void avatarIconSyncForGroupConversationShouldDoNothing() { + // We only sync avatars for 1-1 conversations. + sendGroupConversationMessage(); + + sendValidAvatarIconSyncMessage(); + + assertThat(mNotificationMsgDelegate.mOneOnOneConversationAvatarMap.isEmpty()).isTrue(); + } + + @Test + public void avatarIconSyncForUnknownConversationShouldDoNothing() { + // Drop avatar if it's for a conversation that is unknown. + sendValidAvatarIconSyncMessage(); + + assertThat(mNotificationMsgDelegate.mOneOnOneConversationAvatarMap.isEmpty()).isTrue(); + } + + @Test + public void avatarIconSyncSetsAvatarInNotification() { + // Check that a conversation that didn't have an avatar, but gets this message posts + // a notification with the avatar. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + verify(mMockNotificationManager).notify(anyInt(), mNotificationCaptor.capture()); + Icon notSetIcon = mNotificationCaptor.getValue().getLargeIcon(); + + sendValidAvatarIconSyncMessage(); + + // Post an update so we update the notification and can see the new icon. + updateConversationWithMessage2(); + + verify(mMockNotificationManager, times(2)).notify(anyInt(), mNotificationCaptor.capture()); + Icon newIcon = mNotificationCaptor.getValue().getLargeIcon(); + assertThat(newIcon).isNotEqualTo(notSetIcon); + } + + + @Test + public void avatarIconSyncStoresBitmapCorrectly() { + // Post a conversation notification first, so we don't drop the avatar message. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + sendValidAvatarIconSyncMessage(); + + AvatarIconSync iconSync = createValidAvatarIconSync(); + SenderKey senderKey = SenderKey.createSenderKey(CONVERSATION_KEY_1, iconSync.getPerson()); + byte[] iconArray = iconSync.getPerson().getAvatar().toByteArray(); + Bitmap bitmap = BitmapFactory.decodeByteArray(iconArray, /* offset= */ 0, iconArray.length); + + assertThat(mNotificationMsgDelegate.mOneOnOneConversationAvatarMap).hasSize(1); + Bitmap actualBitmap = mNotificationMsgDelegate.mOneOnOneConversationAvatarMap.get( + senderKey); + assertThat(actualBitmap).isNotNull(); + assertThat(actualBitmap.sameAs(bitmap)).isTrue(); + } + + @Test + public void phoneMetadataUsedToCheckProjectionStatus_projectionActive() { + // Assert projectionListener gets called with phone metadata address. + when(mMockProjectionStateListener.isProjectionInActiveForeground( + BT_DEVICE_ADDRESS)).thenReturn(true); + sendValidPhoneMetadataMessage(); + + // Send a new conversation to trigger Projection State check. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + verify(mMockProjectionStateListener).isProjectionInActiveForeground(BT_DEVICE_ADDRESS); + verify(mMockNotificationManager).notify(anyInt(), mNotificationCaptor.capture()); + checkChannelImportanceLevel( + mNotificationCaptor.getValue().getChannelId(), /* isLowImportance= */ true); + } + + @Test + public void phoneMetadataUsedCorrectlyToCheckProjectionStatus_projectionInactive() { + // Assert projectionListener gets called with phone metadata address. + when(mMockProjectionStateListener.isProjectionInActiveForeground( + BT_DEVICE_ADDRESS)).thenReturn(false); + sendValidPhoneMetadataMessage(); + + // Send a new conversation to trigger Projection State check. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + verify(mMockProjectionStateListener).isProjectionInActiveForeground(BT_DEVICE_ADDRESS); + verify(mMockNotificationManager).notify(anyInt(), mNotificationCaptor.capture()); + checkChannelImportanceLevel( + mNotificationCaptor.getValue().getChannelId(), /* isLowImportance= */ false); + } + + @Test + public void delegateChecksProjectionStatus_projectionActive() { + // Assert projectionListener gets called with phone metadata address. + when(mMockProjectionStateListener.isProjectionInActiveForeground(null)).thenReturn(true); + + // Send a new conversation to trigger Projection State check. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + verify(mMockProjectionStateListener).isProjectionInActiveForeground(null); + verify(mMockNotificationManager).notify(anyInt(), mNotificationCaptor.capture()); + checkChannelImportanceLevel( + mNotificationCaptor.getValue().getChannelId(), /* isLowImportance= */ true); + } + + @Test + public void delegateChecksProjectionStatus_projectionInactive() { + // Assert projectionListener gets called with phone metadata address. + when(mMockProjectionStateListener.isProjectionInActiveForeground(null)).thenReturn(false); + + // Send a new conversation to trigger Projection State check. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + verify(mMockProjectionStateListener).isProjectionInActiveForeground(null); + verify(mMockNotificationManager).notify(anyInt(), mNotificationCaptor.capture()); + checkChannelImportanceLevel( + mNotificationCaptor.getValue().getChannelId(), /* isLowImportance= */ false); + } + + @Test + public void clearAllAppDataShouldClearInternalDataAndNotifications() { + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + verify(mMockNotificationManager).notify(mNotificationIdCaptor.capture(), + any(Notification.class)); + int notificationId = mNotificationIdCaptor.getValue(); + + sendClearAppDataRequest(NotificationMsgDelegate.REMOVE_ALL_APP_DATA); + + verify(mMockNotificationManager).cancel(eq(notificationId)); + } + + @Test + public void clearSpecificAppDataShouldDoNothing() { + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + verify(mMockNotificationManager).notify(anyInt(), any(Notification.class)); + + sendClearAppDataRequest(MESSAGING_PACKAGE_NAME); + + verify(mMockNotificationManager, never()).cancel(anyInt()); + } + + @Test + public void conversationsFromSameApplicationPostedOnSameChannel() { + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + verify(mMockNotificationManager).notify(anyInt(), mNotificationCaptor.capture()); + String firstChannelId = mNotificationCaptor.getValue().getChannelId(); + + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, + VALID_CONVERSATION_MSG.toBuilder() + .setNotificationKey(NOTIFICATION_KEY_2) + .setConversation(VALID_CONVERSATION.toBuilder() + .setMessagingStyle( + VALID_STYLE.toBuilder().addMessagingStyleMsg(MESSAGE_2)) + .build()) + .build()); + verify(mMockNotificationManager, times(2)).notify(anyInt(), mNotificationCaptor.capture()); + + assertThat(mNotificationCaptor.getValue().getChannelId()).isEqualTo(firstChannelId); + } + + @Test + public void messageDataNotSetShouldDoNothing() { + // For a PhoneToCarMessage w/ no MessageData + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, PhoneToCarMessage.newBuilder() + .setNotificationKey(NOTIFICATION_KEY_1) + .build()); + + verifyZeroInteractions(mMockNotificationManager); + } + + @Test + public void dismissShouldCreateCarToPhoneMessage() { + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + CarToPhoneMessage dismissMessage = mNotificationMsgDelegate.dismiss(CONVERSATION_KEY_1); + + verifyCarToPhoneActionMessage(dismissMessage, NOTIFICATION_KEY_1, DISMISS); + } + + @Test + public void dismissShouldDismissNotification() { + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + verify(mMockNotificationManager).notify(mNotificationIdCaptor.capture(), + any(Notification.class)); + int notificationId = mNotificationIdCaptor.getValue(); + + mNotificationMsgDelegate.dismiss(CONVERSATION_KEY_1); + + verify(mMockNotificationManager).cancel(eq(notificationId)); + } + + @Test + public void markAsReadShouldCreateCarToPhoneMessage() { + // Mark message as read, verify message sent to phone. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + CarToPhoneMessage markAsRead = mNotificationMsgDelegate.markAsRead(CONVERSATION_KEY_1); + + verifyCarToPhoneActionMessage(markAsRead, NOTIFICATION_KEY_1, MARK_AS_READ); + } + + @Test + public void markAsReadShouldExcludeMessageFromNotification() { + // Mark message as read, verify when new message comes in, read + // messages are not in notification. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + verify(mMockNotificationManager).notify(anyInt(), any(Notification.class)); + + mNotificationMsgDelegate.markAsRead(CONVERSATION_KEY_1); + // Post an update to this conversation to ensure the now read message is not in + // notification. + updateConversationWithMessage2(); + verify(mMockNotificationManager, times(2)).notify(anyInt(), + mNotificationCaptor.capture()); + + // Verify the notification contains only the latest message. + NotificationCompat.MessagingStyle messagingStyle = + NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification( + mNotificationCaptor.getValue()); + assertThat(messagingStyle.getMessages().size()).isEqualTo(1); + verifyMessage(MESSAGE_2, messagingStyle.getMessages().get(0)); + + } + + @Test + public void replyShouldCreateCarToPhoneMessage() { + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + CarToPhoneMessage reply = mNotificationMsgDelegate.reply(CONVERSATION_KEY_1, + MESSAGE_TEXT_2); + Action replyAction = reply.getActionRequest(); + NotificationMsg.MapEntry replyEntry = replyAction.getMapEntry(0); + + verifyCarToPhoneActionMessage(reply, NOTIFICATION_KEY_1, REPLY); + assertThat(replyAction.getMapEntryCount()).isEqualTo(1); + assertThat(replyEntry.getKey()).isEqualTo(NotificationMsgDelegate.REPLY_KEY); + assertThat(replyEntry.getValue()).isEqualTo(MESSAGE_TEXT_2); + } + + @Test + public void onDestroyShouldClearInternalDataAndNotifications() { + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + verify(mMockNotificationManager).notify(mNotificationIdCaptor.capture(), + any(Notification.class)); + int notificationId = mNotificationIdCaptor.getValue(); + sendValidAvatarIconSyncMessage(); + + mNotificationMsgDelegate.onDestroy(); + + assertThat(mNotificationMsgDelegate.mOneOnOneConversationAvatarMap.isEmpty()).isTrue(); + verify(mMockNotificationManager).cancel(eq(notificationId)); + } + + @Test + public void deviceDisconnectedShouldClearDeviceNotificationsAndMetadata() { + // Test that after a device disconnects, all the avatars, notifications for the device + // is removed. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + verify(mMockNotificationManager).notify(mNotificationIdCaptor.capture(), + any(Notification.class)); + int notificationId = mNotificationIdCaptor.getValue(); + sendValidAvatarIconSyncMessage(); + + mNotificationMsgDelegate.onDeviceDisconnected(COMPANION_DEVICE_ID); + + assertThat(mNotificationMsgDelegate.mOneOnOneConversationAvatarMap.isEmpty()).isTrue(); + verify(mMockNotificationManager).cancel(eq(notificationId)); + } + + @Test + public void deviceDisconnectedShouldResetProjectionDeviceAddress() { + // Test that after a device disconnects, then reconnects, the projection device address + // is reset. + when(mMockProjectionStateListener.isProjectionInActiveForeground( + BT_DEVICE_ADDRESS)).thenReturn(true); + sendValidPhoneMetadataMessage(); + + mNotificationMsgDelegate.onDeviceDisconnected(COMPANION_DEVICE_ID); + + // Now post a new notification for this device and ensure it is not posted silently. + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, VALID_CONVERSATION_MSG); + + verify(mMockProjectionStateListener).isProjectionInActiveForeground(null); + verify(mMockNotificationManager).notify(anyInt(), mNotificationCaptor.capture()); + checkChannelImportanceLevel( + mNotificationCaptor.getValue().getChannelId(), /* isLowImportance= */ false); + } + + private void verifyNotification(ConversationNotification expected, Notification notification) { + verifyConversationLevelMetadata(expected, notification); + verifyMessagingStyle(expected.getMessagingStyle(), notification); + } + + /** + * Verifies the conversation level metadata and other aspects of a notification that do not + * change when a new message is added to it (such as the actions, intents). + */ + private void verifyConversationLevelMetadata(ConversationNotification expected, + Notification notification) { + assertThat(notification.category).isEqualTo(CATEGORY_MESSAGE); + + assertThat(notification.getSmallIcon()).isNotNull(); + if (!expected.getAppIcon().isEmpty()) { + byte[] iconBytes = expected.getAppIcon().toByteArray(); + Icon appIcon = Icon.createWithData(iconBytes, 0, iconBytes.length); + assertThat(notification.getSmallIcon()).isEqualTo(appIcon); + } + + assertThat(notification.deleteIntent).isNotNull(); + + if (expected.getMessagingAppPackageName() != null) { + CharSequence appName = notification.extras.getCharSequence( + Notification.EXTRA_SUBSTITUTE_APP_NAME); + assertThat(appName).isEqualTo(expected.getMessagingAppDisplayName()); + } + + assertThat(notification.actions.length).isEqualTo(2); + for (NotificationCompat.Action action : getAllActions(notification)) { + if (action.getSemanticAction() == NotificationCompat.Action.SEMANTIC_ACTION_REPLY) { + assertThat(action.getRemoteInputs().length).isEqualTo(1); + } + assertThat(action.getShowsUserInterface()).isFalse(); + } + } + + private void verifyMessagingStyle(MessagingStyle expected, Notification notification) { + final NotificationCompat.MessagingStyle messagingStyle = getMessagingStyle(notification); + + assertThat(messagingStyle.getUser().getName()).isEqualTo(expected.getUserDisplayName()); + assertThat(messagingStyle.isGroupConversation()).isEqualTo(expected.getIsGroupConvo()); + assertThat(messagingStyle.getMessages().size()).isEqualTo(expected.getMessagingStyleMsgCount()); + + for (int i = 0; i < expected.getMessagingStyleMsgCount(); i++) { + MessagingStyleMessage expectedMsg = expected.getMessagingStyleMsg(i); + NotificationCompat.MessagingStyle.Message actualMsg = messagingStyle.getMessages().get( + i); + verifyMessage(expectedMsg, actualMsg); + + } + } + + private void verifyMessage(MessagingStyleMessage expectedMsg, + NotificationCompat.MessagingStyle.Message actualMsg) { + assertThat(actualMsg.getTimestamp()).isEqualTo(expectedMsg.getTimestamp()); + assertThat(actualMsg.getText()).isEqualTo(expectedMsg.getTextMessage()); + + Person expectedSender = expectedMsg.getSender(); + androidx.core.app.Person actualSender = actualMsg.getPerson(); + assertThat(actualSender.getName()).isEqualTo(expectedSender.getName()); + if (!expectedSender.getAvatar().isEmpty()) { + assertThat(actualSender.getIcon()).isNotNull(); + } else { + assertThat(actualSender.getIcon()).isNull(); + } + } + + private void verifyCarToPhoneActionMessage(CarToPhoneMessage message, String notificationKey, + Action.ActionName actionName) { + assertThat(message.getNotificationKey()).isEqualTo(notificationKey); + assertThat(message.getActionRequest()).isNotNull(); + assertThat(message.getActionRequest().getNotificationKey()).isEqualTo(notificationKey); + assertThat(message.getActionRequest().getActionName()).isEqualTo(actionName); + } + + private void checkChannelImportanceLevel(String channelId, boolean isLowImportance) { + ArgumentCaptor<NotificationChannel> channelCaptor = ArgumentCaptor.forClass( + NotificationChannel.class); + verify(mMockNotificationManager, atLeastOnce()).createNotificationChannel( + channelCaptor.capture()); + + int desiredImportance = isLowImportance ? IMPORTANCE_LOW : IMPORTANCE_HIGH; + List<String> desiredImportanceChannelIds = new ArrayList<>(); + // Each messaging app has 2 channels, one high and one low importance. + for (NotificationChannel notificationChannel : channelCaptor.getAllValues()) { + if (notificationChannel.getImportance() == desiredImportance) { + desiredImportanceChannelIds.add(notificationChannel.getId()); + } + } + assertThat(desiredImportanceChannelIds.contains(channelId)).isTrue(); + } + + private NotificationCompat.MessagingStyle getMessagingStyle(Notification notification) { + return NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification( + notification); + } + + private List<NotificationCompat.Action> getAllActions(Notification notification) { + List<NotificationCompat.Action> actions = new ArrayList<>(); + actions.addAll(NotificationCompat.getInvisibleActions(notification)); + for (int i = 0; i < NotificationCompat.getActionCount(notification); i++) { + actions.add(NotificationCompat.getAction(notification, i)); + } + return actions; + } + + private PhoneToCarMessage createSecondConversation() { + return VALID_CONVERSATION_MSG.toBuilder() + .setNotificationKey(NOTIFICATION_KEY_2) + .setConversation(addSecondMessageToConversation()) + .build(); + } + + private ConversationNotification addSecondMessageToConversation() { + return VALID_CONVERSATION.toBuilder() + .setMessagingStyle( + VALID_STYLE.toBuilder().addMessagingStyleMsg(MESSAGE_2)).build(); + } + + private AvatarIconSync createValidAvatarIconSync() { + return AvatarIconSync.newBuilder() + .setMessagingAppPackageName(MESSAGING_PACKAGE_NAME) + .setMessagingAppDisplayName(MESSAGING_APP_NAME) + .setPerson(Person.newBuilder() + .setName(SENDER_1) + .setAvatar(ByteString.copyFrom(mIconByteArray)) + .build()) + .build(); + } + + /** + * Small helper method that updates {@link NotificationMsgDelegateTest#VALID_CONVERSATION} with + * a new message. + */ + private void updateConversationWithMessage2() { + PhoneToCarMessage updateConvo = PhoneToCarMessage.newBuilder() + .setNotificationKey(NOTIFICATION_KEY_1) + .setMessage(MESSAGE_2) + .build(); + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, updateConvo); + } + + private void sendValidAvatarIconSyncMessage() { + PhoneToCarMessage validMessage = PhoneToCarMessage.newBuilder() + .setNotificationKey(NOTIFICATION_KEY_1) + .setAvatarIconSync(createValidAvatarIconSync()) + .build(); + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, validMessage); + } + + private void sendValidPhoneMetadataMessage() { + PhoneToCarMessage metadataMessage = PhoneToCarMessage.newBuilder() + .setPhoneMetadata(PhoneMetadata.newBuilder() + .setBluetoothDeviceAddress(BT_DEVICE_ADDRESS) + .build()) + .build(); + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, metadataMessage); + } + + private void sendGroupConversationMessage() { + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, + VALID_CONVERSATION_MSG.toBuilder() + .setConversation(VALID_CONVERSATION.toBuilder() + .setMessagingStyle(VALID_STYLE.toBuilder() + .setIsGroupConvo(true) + .build()) + .build()) + .build()); + } + + + private void sendClearAppDataRequest(String messagingAppPackageName) { + mNotificationMsgDelegate.onMessageReceived(mCompanionDevice, + PhoneToCarMessage.newBuilder() + .setClearAppDataRequest(ClearAppDataRequest.newBuilder() + .setMessagingAppPackageName(messagingAppPackageName) + .build()) + .build()); + } +} diff --git a/tests/unit/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgFeatureTest.java b/tests/unit/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgFeatureTest.java new file mode 100644 index 0000000..e195610 --- /dev/null +++ b/tests/unit/src/com/android/car/companiondevicesupport/feature/notificationmsg/NotificationMsgFeatureTest.java @@ -0,0 +1,159 @@ +/* + * 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.car.companiondevicesupport.feature.notificationmsg; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.content.ServiceConnection; +import android.os.IBinder; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.car.companiondevicesupport.R; +import com.android.car.companiondevicesupport.api.external.CompanionDevice; +import com.android.car.messenger.NotificationMsgProto.NotificationMsg; +import com.android.car.messenger.common.CompositeKey; +import com.android.car.messenger.common.ConversationKey; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.UUID; +import java.util.function.Predicate; + +@RunWith(AndroidJUnit4.class) +public class NotificationMsgFeatureTest { + + private static final String DEVICE_ID = UUID.randomUUID().toString(); + private static final NotificationMsg.PhoneToCarMessage PHONE_TO_CAR_MESSAGE = + NotificationMsg.PhoneToCarMessage.newBuilder() + .build(); + + @Mock + private Context mContext; + @Mock + private IBinder mIBinder; + @Mock + private NotificationMsgDelegate mNotificationMsgDelegate; + @Mock + private CompanionDevice mCompanionDevice; + + + private NotificationMsgFeature mNotificationMsgFeature; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + when(mContext.getString(eq(R.string.notification_msg_feature_id))).thenReturn( + UUID.randomUUID().toString()); + when(mContext.bindServiceAsUser(any(), any(), anyInt(), any())).thenReturn(true); + when(mCompanionDevice.getDeviceId()).thenReturn(DEVICE_ID); + + mNotificationMsgFeature = new NotificationMsgFeature(mContext, mNotificationMsgDelegate); + } + + @Test + public void startShouldClearInternalMemory() { + mNotificationMsgFeature.start(); + + ArgumentCaptor<Predicate<CompositeKey>> predicateArgumentCaptor = ArgumentCaptor.forClass( + Predicate.class); + verify(mNotificationMsgDelegate).cleanupMessagesAndNotifications( + predicateArgumentCaptor.capture()); + + // There's no way to test if two predicates have the same logic, so test the logic itself. + // The expected predicate should return true for any CompositeKey. Since CompositeKey is + // an abstract class, test with a class that extends it. + ConversationKey device_key = new ConversationKey(DEVICE_ID, "subKey"); + ConversationKey device_key_1 = new ConversationKey("NOT_DEVICE_ID", "subKey"); + assertThat(predicateArgumentCaptor.getValue().test(device_key)).isTrue(); + assertThat(predicateArgumentCaptor.getValue().test(device_key_1)).isTrue(); + assertThat(predicateArgumentCaptor.getValue().test(null)).isTrue(); + } + + @Test + public void stopShouldDestroyDelegate() { + mNotificationMsgFeature.start(); + + ArgumentCaptor<ServiceConnection> serviceConnectionCaptor = + ArgumentCaptor.forClass(ServiceConnection.class); + verify(mContext).bindServiceAsUser(any(), serviceConnectionCaptor.capture(), anyInt(), + any()); + serviceConnectionCaptor.getValue().onServiceConnected(null, mIBinder); + mNotificationMsgFeature.stop(); + verify(mNotificationMsgDelegate).onDestroy(); + } + + @Test + public void onMessageReceivedShouldPassMessageToDelegate() { + startWithSecureDevice(); + + mNotificationMsgFeature.onMessageReceived(mCompanionDevice, + PHONE_TO_CAR_MESSAGE.toByteArray()); + verify(mNotificationMsgDelegate).onMessageReceived(mCompanionDevice, PHONE_TO_CAR_MESSAGE); + } + + @Test + public void onMessageReceivedShouldCheckDeviceConnection() { + when(mCompanionDevice.hasSecureChannel()).thenReturn(false); + when(mCompanionDevice.isActiveUser()).thenReturn(true); + mNotificationMsgFeature.start(); + + mNotificationMsgFeature.onMessageReceived(mCompanionDevice, + PHONE_TO_CAR_MESSAGE.toByteArray()); + verify(mNotificationMsgDelegate, never()).onMessageReceived(mCompanionDevice, + PHONE_TO_CAR_MESSAGE); + } + + @Test + public void unknownDeviceDisconnectedShouldDoNothing() { + when(mCompanionDevice.hasSecureChannel()).thenReturn(true); + when(mCompanionDevice.isActiveUser()).thenReturn(true); + mNotificationMsgFeature.start(); + + mNotificationMsgFeature.onDeviceDisconnected(mCompanionDevice); + verify(mNotificationMsgDelegate, never()).onDeviceDisconnected(DEVICE_ID); + } + + @Test + public void secureDeviceDisconnectedShouldAlertDelegate() { + startWithSecureDevice(); + + mNotificationMsgFeature.onDeviceDisconnected(mCompanionDevice); + verify(mNotificationMsgDelegate).onDeviceDisconnected(DEVICE_ID); + } + + private void startWithSecureDevice() { + when(mCompanionDevice.hasSecureChannel()).thenReturn(true); + when(mCompanionDevice.isActiveUser()).thenReturn(true); + mNotificationMsgFeature.start(); + mNotificationMsgFeature.onSecureChannelEstablished(mCompanionDevice); + } +} diff --git a/tests/unit/src/com/android/car/companiondevicesupport/feature/notificationmsg/OWNERS b/tests/unit/src/com/android/car/companiondevicesupport/feature/notificationmsg/OWNERS new file mode 100644 index 0000000..0c113c4 --- /dev/null +++ b/tests/unit/src/com/android/car/companiondevicesupport/feature/notificationmsg/OWNERS @@ -0,0 +1,3 @@ +# People who can approve changes for submission. +igorr@google.com +jiayuzhou@google.com |
