diff options
author | Uchenna Okoye <uokoye@google.com> | 2021-03-18 21:52:05 +0000 |
---|---|---|
committer | Uchenna Okoye <uokoye@google.com> | 2021-03-18 21:52:05 +0000 |
commit | 010c6e0449a5a171e76e30a7593ca9acbcbd4ade (patch) | |
tree | 4426f3813862c26564d0ea826b84fce222c8dad8 | |
parent | c3c585e07c255d3af5779b5daff7161d9cf1f6fc (diff) | |
download | Messenger-010c6e0449a5a171e76e30a7593ca9acbcbd4ade.tar.gz |
Revert "Project import generated by Copybara."
This reverts commit c3c585e07c255d3af5779b5daff7161d9cf1f6fc.
Reason for revert: Need to fix authoring
Change-Id: I513c3785dd0d3719fe1836167a92e2121b2c166b
81 files changed, 2259 insertions, 6440 deletions
diff --git a/AndroidManifest.xml b/AndroidManifest.xml index b24137a..5438926 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,136 +1,91 @@ <?xml version="1.0" encoding="utf-8"?> -<!-- - Copyright (C) 2020 The Android Open Source Project +<!-- Copyright (C) 2017 The Android Open Source Project - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - --> + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> <manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.android.car.messenger"> - - <application - android:name="com.android.car.messenger.impl.CarMessengerApp" - android:icon="@drawable/ic_launcher_icon" - android:label="@string/app_name" - android:screenOrientation="landscape" - android:supportsRtl="true" - android:theme="@style/Theme.CarUi.WithToolbar"> - - <activity - android:name=".core.ui.launcher.MessageLauncherActivity" - android:exported="true" - android:screenOrientation="landscape"> - <intent-filter> - <action android:name="android.intent.action.MAIN" /> - <category android:name="android.intent.category.LAUNCHER" /> - </intent-filter> - - <intent-filter> - <action android:name="android.intent.action.MAIN" /> - <category android:name="android.intent.category.APP_MESSAGING" /> - </intent-filter> + package="com.android.car.messenger"> + + <uses-sdk android:minSdkVersion="26" android:targetSdkVersion="29"/> + + <uses-permission android:name="android.permission.BLUETOOTH"/> + <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> + <uses-permission android:name="android.permission.READ_CONTACTS"/> + <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> + <uses-permission android:name="android.permission.RECEIVE_SMS"/> + <uses-permission android:name="android.permission.SEND_SMS"/> + <uses-permission android:name="android.permission.READ_SMS"/> + <uses-permission android:name="android.permission.WRITE_SMS"/> + <uses-permission android:name="android.car.permission.ACCESS_CAR_PROJECTION_STATUS"/> + + <application android:label="@string/app_name"> + <service android:name=".MessengerService" + android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE" + android:exported="true" > <intent-filter> - <action android:name="android.intent.action.VIEW" /> - <action android:name="android.intent.action.SENDTO" /> - + <action android:name="android.intent.action.RESPOND_VIA_MESSAGE" /> <category android:name="android.intent.category.DEFAULT" /> - <category android:name="android.intent.category.BROWSABLE" /> - <data android:scheme="sms" /> <data android:scheme="smsto" /> - </intent-filter> - <intent-filter> - <action android:name="android.intent.action.VIEW" /> - <action android:name="android.intent.action.SENDTO" /> - - <category android:name="android.intent.category.DEFAULT" /> - <category android:name="android.intent.category.BROWSABLE" /> - <data android:scheme="mms" /> <data android:scheme="mmsto" /> </intent-filter> - <meta-data - android:name="distractionOptimized" - android:value="true" /> - </activity> + </service> <!-- BroadcastReceiver that listens for incoming SMS messages --> - <receiver - android:name=".impl.receivers.MmsReceiver" - android:exported="false" - android:permission="android.permission.BROADCAST_WAP_PUSH"> + <receiver android:name=".SmsReceiver" + android:exported="true" + android:permission="android.permission.BROADCAST_SMS"> <intent-filter> - <action android:name="android.provider.Telephony.WAP_PUSH_DELIVER" /> - <data android:mimeType="application/vnd.wap.mms-message" /> + <action android:name="android.provider.Telephony.SMS_DELIVER" /> + <action android:name="android.provider.Telephony.SMS_RECEIVED" /> </intent-filter> </receiver> <!-- BroadcastReceiver that listens for incoming MMS messages --> - <receiver - android:name=".core.service.OnBootReceiver" - android:enabled="true" - android:exported="false" - android:permission="android.permission.RECEIVE_BOOT_COMPLETED"> + <receiver android:name=".MmsReceiver" + android:exported="true" + android:permission="android.permission.BROADCAST_WAP_PUSH"> <intent-filter> - <action android:name="android.intent.action.BOOT_COMPLETED" /> + <action android:name="android.provider.Telephony.WAP_PUSH_DELIVER" /> + <data android:mimeType="application/vnd.wap.mms-message" /> </intent-filter> </receiver> - <!-- BroadcastReceiver for car booting --> - <receiver - android:name=".impl.receivers.SmsReceiver" - android:exported="false" - android:permission="android.permission.BROADCAST_SMS"> + <activity android:name=".MessengerActivity" android:exported="true"> + <meta-data android:name="distractionOptimized" android:value="true"/> <intent-filter> - <action android:name="android.provider.Telephony.SMS_DELIVER" /> - <action android:name="android.provider.Telephony.SMS_RECEIVED" /> + <action android:name="android.intent.action.MAIN"/> + <category android:name="android.intent.category.APP_MESSAGING"/> </intent-filter> - </receiver> - - <service - android:name=".core.service.MessengerService" - android:exported="false" - android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE"> <intent-filter> - <action android:name="android.intent.action.RESPOND_VIA_MESSAGE" /> + <action android:name="android.intent.action.VIEW"/> + <action android:name="android.intent.action.SENDTO" /> <category android:name="android.intent.category.DEFAULT" /> - + <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="sms" /> <data android:scheme="smsto" /> + </intent-filter> + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + <action android:name="android.intent.action.SENDTO" /> + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="mms" /> <data android:scheme="mmsto" /> </intent-filter> - </service> - + </activity> </application> - - <uses-permission android:name="android.permission.SEND_SMS" /> - <uses-permission android:name="android.permission.RECEIVE_SMS" /> - <uses-permission android:name="android.permission.READ_CONTACTS" /> - <uses-permission android:name="android.permission.READ_PHONE_STATE" /> - <uses-permission android:name="android.permission.READ_SMS" /> - <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> - <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> - <!-- Permissions required to know the current projection app status. --> - <uses-permission android:name="android.car.permission.ACCESS_CAR_PROJECTION_STATUS" /> - <!-- Permissions required to retrieve the SubscriptionInfo#getIccId. - This maps to the bluetooth address and is necessary - for various functions such as Assistant device disambiguation, - checking the projection state and more etc. - --> - <uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" /> - - <uses-sdk - android:minSdkVersion="30" - android:targetSdkVersion="30" /> </manifest> diff --git a/res/anim/trans_bottom_in.xml b/res/anim/trans_bottom_in.xml new file mode 100644 index 0000000..0635de3 --- /dev/null +++ b/res/anim/trans_bottom_in.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2017 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <objectAnimator + android:interpolator="@android:interpolator/decelerate_quint" + android:valueFrom="100dp" + android:valueTo="0dp" + android:valueType="floatType" + android:propertyName="translationY" + android:duration="@integer/anim_time" /> + <objectAnimator + android:interpolator="@android:interpolator/decelerate_quint" + android:valueFrom="0.0" + android:valueTo="1.0" + android:valueType="floatType" + android:propertyName="alpha" + android:duration="@integer/anim_time" /> +</set>
\ No newline at end of file diff --git a/res/anim/trans_bottom_out.xml b/res/anim/trans_bottom_out.xml new file mode 100644 index 0000000..81295e8 --- /dev/null +++ b/res/anim/trans_bottom_out.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2017 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <objectAnimator + android:interpolator="@android:interpolator/decelerate_quint" + android:valueFrom="0dp" + android:valueTo="-100dp" + android:valueType="floatType" + android:propertyName="translationY" + android:duration="@integer/anim_time" /> + <objectAnimator + android:interpolator="@android:interpolator/decelerate_quint" + android:valueFrom="1.0" + android:valueTo="0.0" + android:valueType="floatType" + android:propertyName="alpha" + android:duration="@integer/anim_time" /> +</set>
\ No newline at end of file diff --git a/res/color/uxr_button_text_color_selector.xml b/res/color/uxr_button_text_color_selector.xml deleted file mode 100644 index 8c0f6c8..0000000 --- a/res/color/uxr_button_text_color_selector.xml +++ /dev/null @@ -1,22 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - Copyright (C) 2019 The Android Open Source Project - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - --> - -<selector xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto"> - <item app:state_ux_restricted="true" android:color="@color/uxr_button_text_disabled_color"/> - <item app:state_ux_restricted="false" android:color="@color/uxr_button_text_color"/> -</selector> diff --git a/res/drawable/car_ui_icon_reply.xml b/res/drawable/car_ui_icon_reply.xml deleted file mode 100644 index 3cdb44a..0000000 --- a/res/drawable/car_ui_icon_reply.xml +++ /dev/null @@ -1,27 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- - ~ Copyright (C) 2020 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ http://www.apache.org/licenses/LICENSE-2.0 - ~ - ~ Unless required by applicable law or agreed to in writing, software - ~ distributed under the License is distributed on an "AS IS" BASIS, - ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - ~ See the License for the specific language governing permissions and - ~ limitations under the License. - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="48dp" - android:height="37.333332dp" - android:viewportHeight="28" - android:viewportWidth="36"> - <path - android:fillColor="@color/secondary_text_color" - android:pathData="M26,10H7.66L12,5.66L14.82,2.84L12,0L0,12L12,24L14.82,21.18L12,18.34L7.66, - 14H26C29.3,14 32,16.7 32,20V28H36V20C36,14.48 31.52,10 26,10Z" /> -</vector> - diff --git a/res/drawable/car_ui_icon_toggle_mute.xml b/res/drawable/car_ui_icon_toggle_mute.xml deleted file mode 100644 index faa8cd5..0000000 --- a/res/drawable/car_ui_icon_toggle_mute.xml +++ /dev/null @@ -1,33 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ Copyright (C) 2020 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ http://www.apache.org/licenses/LICENSE-2.0 - ~ - ~ Unless required by applicable law or agreed to in writing, software - ~ distributed under the License is distributed on an "AS IS" BASIS, - ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - ~ See the License for the specific language governing permissions and - ~ limitations under the License. - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="48dp" - android:height="48dp" - android:viewportHeight="32" - android:viewportWidth="32"> - <path - android:fillColor="@color/car_ui_toolbar_menu_item_icon_color" - android:pathData="M2.295,0L0,2.2788L7.7252,10.004H2.602V19.701H9.0667L17.1475, - 27.7818V19.4263L22.5131,24.7919C21.8343,25.1636 21.1394,25.4707 - 20.3798,25.697V29.0263C21.996,28.6545 23.499,27.9919 24.8566,27.1354L29.7212,32L32, - 29.7212L2.295,0ZM13.9151,19.9758L10.4081,16.4687H5.8343V13.2364H10.9576L13.9151, - 16.1939V19.9758ZM17.1475,1.9232L12.9616,6.1091L17.1475,10.2788V1.9232ZM24.4202, - 14.8525C24.4202,11.9919 22.7717,9.5353 20.3798,8.3394V13.5111L24.0323,17.1636C24.2747, - 16.4364 24.4202,15.6606 24.4202,14.8525ZM28.4606,14.8525C28.4606,16.7919 27.9596,18.6343 - 27.103,20.2343L29.4626,22.5939C30.8687,20.3475 31.6929,17.697 31.6929,14.8525C31.6929,7.9353 - 26.8606,2.1495 20.3798,0.6788V4.0081C25.0505,5.398 28.4606,9.7293 28.4606,14.8525Z" /> -</vector> diff --git a/res/drawable/hero_button_background.xml b/res/drawable/hero_button_background.xml deleted file mode 100644 index 496ecfa..0000000 --- a/res/drawable/hero_button_background.xml +++ /dev/null @@ -1,24 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2019 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. ---> -<ripple xmlns:android="http://schemas.android.com/apk/res/android" - android:color="@color/car_card_ripple_background"> - <item> - <shape android:shape="rectangle"> - <corners android:radius="@dimen/hero_button_corner_radius" /> - <solid android:color="@color/hero_button_background_color" /> - </shape> - </item> -</ripple> diff --git a/res/drawable/ic_launcher_icon.xml b/res/drawable/ic_launcher_icon.xml deleted file mode 100644 index bf04507..0000000 --- a/res/drawable/ic_launcher_icon.xml +++ /dev/null @@ -1,41 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2021 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. ---> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="44dp" - android:height="44dp" - android:viewportHeight="44" - android:viewportWidth="44"> - <path - android:fillColor="#1A73E8" - android:pathData="M22,44C34.1503,44 44,34.1503 44,22C44,9.8497 34.1503,0 22,0C9.8497, - 0 0,9.8497 0,22C0,34.1503 9.8497,44 22,44Z" /> - <path - android:fillColor="#ffffff" - android:pathData="M30,13H8.995C8.145,13 7.685,13.73 8.245,14.5L11,19.25V27.75C11,29.9225 - 12.59,31.75 14.75,31.75H30C32.16,31.75 34,29.9225 34,27.75V17C34,14.8275 32.16,13 30,13Z" /> - <path - android:fillColor="#8AB4F8" - android:pathData="M29.75,19H15.25C14.615,19 14,18.6225 14,18C14,17.3775 14.615,17 - 15.25,17H29.75C30.385,17 31,17.3775 31,18C31,18.6225 30.385,19 29.75,19Z" /> - <path - android:fillColor="#8AB4F8" - android:pathData="M29.75,23H15.25C14.615,23 14,22.6225 14,22C14,21.3775 14.615,21 - 15.25,21H29.75C30.385,21 31,21.3775 31,22C31,22.6225 30.385,23 29.75,23Z" /> - <path - android:fillColor="#8AB4F8" - android:pathData="M25.75,27H15.25C14.62,27 14,26.6225 14,26C14,25.3775 14.62,25 - 15.25,25H25.75C26.38,25 27,25.3775 27,26C27,26.6225 26.38,27 25.75,27Z" /> -</vector> diff --git a/res/drawable/ic_message.xml b/res/drawable/ic_message.xml index 503f790..3f9dbd7 100644 --- a/res/drawable/ic_message.xml +++ b/res/drawable/ic_message.xml @@ -15,13 +15,12 @@ limitations under the License. --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="48dp" - android:height="48dp" +<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" + android:viewportWidth="48" android:viewportHeight="48" - android:viewportWidth="48"> - <path - android:fillColor="#FFFFFF" - android:pathData="M40 4H8C5.79 4 4.02 5.79 4.02 8L4 44l8 -8h28c2.21 0 4 -1.79 4 -4V8c0 -2.21 - -1.79 -4 -4 -4zM12 18h24v4H12v-4zm16 10H12v-4h16v4zm8 -12H12v-4h24v4z" /> -</vector> + android:width="48dp" + android:height="48dp"> + <path + android:pathData="M40 4H8C5.79 4 4.02 5.79 4.02 8L4 44l8 -8h28c2.21 0 4 -1.79 4 -4V8c0 -2.21 -1.79 -4 -4 -4zM12 18h24v4H12v-4zm16 10H12v-4h16v4zm8 -12H12v-4h24v4z" + android:fillColor="#FFFFFF" /> +</vector>
\ No newline at end of file diff --git a/res/drawable/ic_person.xml b/res/drawable/ic_person.xml deleted file mode 100644 index 258d5a0..0000000 --- a/res/drawable/ic_person.xml +++ /dev/null @@ -1,26 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ Copyright (C) 2020 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ http://www.apache.org/licenses/LICENSE-2.0 - ~ - ~ Unless required by applicable law or agreed to in writing, software - ~ distributed under the License is distributed on an "AS IS" BASIS, - ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - ~ See the License for the specific language governing permissions and - ~ limitations under the License. - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24.0" - android:viewportHeight="24.0"> - <path - android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 - 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z" - android:fillColor="#000000"/> -</vector> diff --git a/res/drawable/ic_voice_out.xml b/res/drawable/ic_voice_out.xml new file mode 100644 index 0000000..7672029 --- /dev/null +++ b/res/drawable/ic_voice_out.xml @@ -0,0 +1,32 @@ +<!-- + ~ Copyright (C) 2017 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> + +<vector android:height="24dp" + android:viewportHeight="48.0" + android:viewportWidth="50.0" + android:width="24dp" + xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="#00796B" android:pathData="M22,0h6v48h-6z" + android:strokeColor="#00000000" android:strokeWidth="1"/> + <path android:fillColor="#00796B" android:pathData="M33,10h6v28h-6z" + android:strokeColor="#00000000" android:strokeWidth="1"/> + <path android:fillColor="#00796B" android:pathData="M11,10h6v28h-6z" + android:strokeColor="#00000000" android:strokeWidth="1"/> + <path android:fillColor="#00796B" android:pathData="M0,19h6v10h-6z" + android:strokeColor="#00000000" android:strokeWidth="1"/> + <path android:fillColor="#00796B" android:pathData="M44,19h6v10h-6z" + android:strokeColor="#00000000" android:strokeWidth="1"/> +</vector> diff --git a/res/drawable/list_divider.xml b/res/drawable/list_divider.xml deleted file mode 100644 index 647392b..0000000 --- a/res/drawable/list_divider.xml +++ /dev/null @@ -1,24 +0,0 @@ -<?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. - --> -<inset xmlns:android="http://schemas.android.com/apk/res/android" - android:insetLeft="@dimen/list_divider_inset" - android:insetRight="@dimen/list_divider_inset"> - <shape android:shape="rectangle"> - <size android:height="@dimen/list_divider_height" /> - <solid android:color="@color/divider_color" /> - </shape> -</inset> diff --git a/res/layout/conversation_list_item.xml b/res/layout/conversation_list_item.xml deleted file mode 100644 index 6ada6fc..0000000 --- a/res/layout/conversation_list_item.xml +++ /dev/null @@ -1,169 +0,0 @@ -<?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. ---> -<androidx.constraintlayout.widget.ConstraintLayout - xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="@dimen/message_history_item_height" - tools:background="@color/background_image_30p_black"> - - <ImageView - android:id="@+id/unread_indicator" - android:layout_width="@dimen/unread_icon_size" - android:layout_height="@dimen/unread_icon_size" - android:layout_marginEnd="@dimen/unread_icon_marginEnd" - android:contentDescription="@string/cd_unread" - android:scaleType="centerCrop" - app:layout_constraintBottom_toBottomOf="@id/icon" - app:layout_constraintEnd_toStartOf="@id/icon" - app:layout_constraintTop_toTopOf="@id/icon" - tools:src="#66B5FF" /> - - <ImageView - android:id="@+id/icon" - android:layout_width="@dimen/avatar_icon_size" - android:layout_height="@dimen/avatar_icon_size" - android:layout_marginStart="@dimen/message_history_item_padding" - android:contentDescription="@string/cd_conversation_icon" - android:scaleType="centerCrop" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:src="@color/car_red_500a" /> - - <ImageView - android:id="@+id/last_action_icon_view" - android:layout_width="@dimen/subtitle_icon_width" - android:layout_height="0dp" - android:contentDescription="@string/cd_icon_indicating_the_last_action" - android:scaleType="centerInside" - android:src="@drawable/car_ui_icon_reply" - app:layout_constraintBottom_toBottomOf="@id/time_text" - app:layout_constraintStart_toStartOf="@id/guideline_begin" - app:layout_constraintTop_toBottomOf="@id/title" - app:layout_constraintTop_toTopOf="@id/time_text" /> - - <ImageView - android:id="@+id/reply_action_button" - android:layout_width="0dp" - android:layout_height="match_parent" - android:background="?android:attr/selectableItemBackground" - android:contentDescription="@string/cd_reply_action_button" - android:scaleType="center" - android:src="@drawable/car_ui_icon_reply" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/mute_action_button" - app:layout_constraintStart_toEndOf="@id/guideline_end" - app:layout_constraintTop_toTopOf="parent" /> - - <ImageView - android:id="@+id/mute_action_button" - android:layout_width="0dp" - android:layout_height="match_parent" - android:background="?android:attr/selectableItemBackground" - android:contentDescription="@string/cd_mute_button" - android:scaleType="center" - android:src="@drawable/car_ui_icon_toggle_mute" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/reply_action_button" - app:layout_constraintTop_toTopOf="parent" /> - - <TextView - android:id="@+id/title" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginEnd="@dimen/message_history_text_margin_end" - android:singleLine="true" - android:theme="@style/Theme.Messaging.BidiText" - app:layout_constraintBottom_toTopOf="@+id/text" - app:layout_constraintEnd_toEndOf="@id/guideline_end" - app:layout_constraintStart_toStartOf="@id/guideline_begin" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_chainStyle="packed" /> - - <TextView - android:id="@+id/time_text" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/message_history_icons_margin" - android:singleLine="true" - app:layout_constraintStart_toEndOf="@id/last_action_icon_view" - app:layout_constraintTop_toBottomOf="@id/title" - tools:text="14:02 PM" /> - - <TextView - android:id="@+id/dot" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/message_history_icons_margin" - android:singleLine="true" - android:text="@string/dot" - android:visibility="gone" - app:layout_constraintBottom_toBottomOf="@id/text" - app:layout_constraintStart_toEndOf="@id/time_text" - app:layout_constraintTop_toTopOf="@id/text" - tools:visibility="visible" /> - - <TextView - android:id="@id/text" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginEnd="@dimen/message_history_text_margin_end" - android:layout_marginStart="@dimen/message_history_icons_margin" - android:singleLine="true" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="@id/guideline_end" - app:layout_constraintStart_toEndOf="@id/dot" - app:layout_constraintTop_toBottomOf="@id/title" - tools:text="Replied" /> - - <View - android:id="@+id/play_action_touch_view" - android:layout_width="0dp" - android:layout_height="match_parent" - android:background="?android:attr/selectableItemBackground" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/guideline_end" - app:layout_constraintHorizontal_bias="0.0" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_bias="0.0" /> - - <View - android:id="@+id/divider" - android:layout_width="@dimen/vertical_divider_width" - android:layout_height="match_parent" - android:layout_marginBottom="@dimen/vertical_divider_inset" - android:layout_marginTop="@dimen/vertical_divider_inset" - android:background="@color/divider_color" - app:layout_constraintStart_toStartOf="@id/guideline_end" /> - - <androidx.constraintlayout.widget.Guideline - android:id="@+id/guideline_begin" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_begin="@dimen/message_history_guideline_begin" /> - - <androidx.constraintlayout.widget.Guideline - android:id="@+id/guideline_end" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="vertical" - app:layout_constraintGuide_end="200dp" /> -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/res/layout/loading_info_view.xml b/res/layout/loading_info_view.xml deleted file mode 100644 index 07ffb73..0000000 --- a/res/layout/loading_info_view.xml +++ /dev/null @@ -1,60 +0,0 @@ -<?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. - --> -<androidx.constraintlayout.widget.ConstraintLayout - xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <Button - android:id="@+id/loading_info_action_button" - style="@style/LoadingInfoActionButtonStyle" - android:layout_marginTop="@dimen/loading_info_button_margin_top" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/loading_info_secondary_message" /> - - <ImageView - android:id="@+id/loading_info_icon" - android:layout_width="@dimen/loading_info_icon_size" - android:layout_height="@dimen/loading_info_icon_size" - android:layout_marginBottom="@dimen/loading_info_icon_margin_bottom" - android:contentDescription="@string/cd_loading_info_icon" - app:layout_constraintBottom_toTopOf="@id/loading_info_message" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_chainStyle="packed" - app:tint="@color/primary_icon_color" /> - - <TextView - android:id="@+id/loading_info_secondary_message" - style="@style/LoadingInfoSecondaryMessageStyle" - app:layout_constraintBottom_toTopOf="@id/loading_info_action_button" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/loading_info_message" /> - - <TextView - android:id="@+id/loading_info_message" - style="@style/LoadingInfoMessageStyle" - app:layout_constraintBottom_toTopOf="@id/loading_info_secondary_message" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/loading_info_icon" /> -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/res/layout/loading_list_fragment.xml b/res/layout/loading_list_fragment.xml deleted file mode 100644 index b6c4765..0000000 --- a/res/layout/loading_list_fragment.xml +++ /dev/null @@ -1,24 +0,0 @@ -<?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. - --> -<com.android.car.messenger.core.ui.shared.LoadingFrameLayout - xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/loading_frame_layout" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <include layout="@layout/list_fragment" /> -</com.android.car.messenger.core.ui.shared.LoadingFrameLayout> diff --git a/res/layout/loading_progress_view.xml b/res/layout/loading_progress_view.xml deleted file mode 100644 index 79eaf37..0000000 --- a/res/layout/loading_progress_view.xml +++ /dev/null @@ -1,31 +0,0 @@ -<?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. - --> -<androidx.constraintlayout.widget.ConstraintLayout - xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <ProgressBar - style="?android:attr/progressBarStyleLarge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/res/values/attrs.xml b/res/values/attrs.xml deleted file mode 100644 index 983562b..0000000 --- a/res/values/attrs.xml +++ /dev/null @@ -1,22 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2020 The Android Open Source Project - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. ---> -<resources> - <declare-styleable name="LoadingFrameLayout"> - <attr name="progressViewLayout" format="reference" /> - <attr name="emptyViewLayout" format="reference" /> - <attr name="errorViewLayout" format="reference" /> - </declare-styleable> -</resources> diff --git a/res/values/colors.xml b/res/values/colors.xml deleted file mode 100644 index b4a189e..0000000 --- a/res/values/colors.xml +++ /dev/null @@ -1,67 +0,0 @@ -<?xml version='1.0' encoding='UTF-8'?> -<!-- Copyright (C) 2020 The Android Open Source Project - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. ---> -<resources> - <array name="letter_tile_colors"> - <item>#db4437</item> - <item>#e91e63</item> - <item>#9c27b0</item> - <item>#673ab7</item> - <item>#3f51b5</item> - <item>#4285f4</item> - <item>#039be5</item> - <item>#0097a7</item> - <item>#008577</item> - <item>#0f9d58</item> - <item>#689f38</item> - <item>#ef6c00</item> - <item>#ff5722</item> - <item>#757575</item> - </array> - <color name="divider_color">@color/divider_color_light</color> - <color name="divider_color_light">#38FFFFFF</color> - <color name="primary_icon_color">@color/icon_tint</color> - <color name="letter_tile_default_color">#cccccc</color> - - <color name="letter_tile_font_color">#ffffff</color> - - <color name="icon_tint">@color/car_grey_50</color> - - <color name="primary_text_color">#FFFFFFFF</color> - <color name="secondary_text_color">#B8FFFFFF</color> - - <color name="car_card_ripple_background">#17000000</color> - - <color name="background_image_30p_black">#4D000000</color> - - <color name="uxr_button_text_color">@color/primary_text_color</color> - <color name="uxr_button_text_disabled_color">#80FFFFFF</color> - - <color name="hero_button_background_color">@color/car_grey_868</color> - <color name="hero_button_text_color">@color/uxr_button_text_color_selector</color> - - <!-- - Color palette for cars. - Those values are NOT part of the car-ui-lib "resource API", they are just constants used in - various places to give a default value to the attributes of the "api". - --> - <color name="car_grey_868">#ff282a2d</color> - <color name="car_grey_50">#fff8f9fa</color> - - <color name="car_red_500a">#ffd50000</color> - - <color name="group_avatar_stroke_color">@android:color/transparent</color> - <color name="group_avatar_background_color">@android:color/transparent</color> -</resources> diff --git a/res/values/config.xml b/res/values/config.xml index 0ed08d4..56cb667 100644 --- a/res/values/config.xml +++ b/res/values/config.xml @@ -1,34 +1,21 @@ <?xml version="1.0" encoding="utf-8"?> <!-- - ~ Copyright (C) 2020 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ http://www.apache.org/licenses/LICENSE-2.0 - ~ - ~ Unless required by applicable law or agreed to in writing, software - ~ distributed under the License is distributed on an "AS IS" BASIS, - ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - ~ See the License for the specific language governing permissions and - ~ limitations under the License - --> -<resources> - <bool name="group_avatar_fill_background">false</bool> - - <!-- - The maximum number of individual avatars used for group avatar. - A number between 1 and 4 is required. - When the value is 1, the first avatar is used for the group avatar. - When the value is 2-4, the first nth avatars make up the group avatar, - where n is the value. - --> - <integer name="group_avatar_max_group_size">4</integer> + Copyright (C) 2019 The Android Open Source Project - <integer name="config_letter_tile_text_style">0</integer> + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at - <!-- Typeface.NORMAL=0; Typeface.BOLD=1; Typeface.ITALIC=2; Typeface.BOLD_ITALIC=3--> - <string name="config_letter_tile_font_family" translatable="false">sans-serif-light</string> + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<resources> + <!-- Whether existing messages should be loaded. Recommended to turn off if head-unit's and + BT-paired phone's clocks are not synced.--> + <bool name="config_loadExistingMessages">false</bool> </resources> diff --git a/res/values/dimens.xml b/res/values/dimens.xml index 42d721b..1f9b6af 100644 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -1,70 +1,20 @@ <?xml version='1.0' encoding='UTF-8'?> -<!-- Copyright (C) 2020 The Android Open Source Project - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. ---> +<!-- + ~ Copyright (C) 2017 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> <resources> - <!-- Message list dimensions --> - <dimen name="message_history_item_height">@dimen/list_item_height</dimen> - <dimen name="message_history_item_padding">@dimen/list_item_padding</dimen> - <dimen name="message_history_guideline_begin"> - @dimen/list_item_guideline_begin - </dimen> - <dimen name="message_history_text_margin_end"> - @dimen/list_item_text_margin_end - </dimen> - <dimen name="message_history_icons_margin">@dimen/car_ui_padding_1</dimen> - - <dimen name="list_top_padding">@dimen/car_ui_padding_2</dimen> - <!-- Components --> - <dimen name="list_item_height">116dp</dimen> - <dimen name="list_item_guideline_begin">@dimen/car_keyline_4</dimen> - <dimen name="list_item_text_margin_end">@dimen/car_ui_padding_2</dimen> - <dimen name="list_item_padding">@dimen/car_keyline_1</dimen> - <dimen name="list_divider_inset">@dimen/car_keyline_1</dimen> - <dimen name="list_divider_height"> - @dimen/car_ui_list_item_action_divider_height - </dimen> - <dimen name="vertical_divider_inset">@dimen/car_ui_padding_2</dimen> - <dimen name="vertical_divider_width">2dp</dimen> - <dimen name="avatar_icon_size">76dp</dimen> + <dimen name="notification_contact_photo_size">300dp</dimen> <dimen name="contact_avatar_corner_radius_percent" format="float">0.5</dimen> - <dimen name="car_keyline_1">60dp</dimen> - <dimen name="car_keyline_4">168dp</dimen> - - <!-- Loading status view dimensions --> - <dimen name="loading_info_icon_size">56dp</dimen> - <dimen name="loading_info_icon_margin_bottom">@dimen/car_ui_padding_3</dimen> - <dimen name="loading_info_button_margin_top">@dimen/car_ui_padding_2</dimen> - - <!-- Dialog and button --> - <dimen name="dialog_max_width">706dp</dimen> - <dimen name="hero_button_max_width">@dimen/dialog_max_width</dimen> - <dimen name="hero_button_min_width">@dimen/car_ui_touch_target_size</dimen> - <dimen name="hero_button_height">@dimen/car_ui_touch_target_size</dimen> - <dimen name="hero_button_corner_radius">38dp</dimen> - - <dimen name="conversation_avatar_width">50dp</dimen> - - <!-- Letter spacing --> - <dimen name="unread_icon_size">16dp</dimen> - <dimen name="unread_icon_marginEnd">24dp</dimen> - <dimen name="subtitle_icon_width">20dp</dimen> - <item name="letter_to_tile_ratio" format="fraction" type="fraction">67%</item> - - <!-- Contact Avatar --> - <item name="letter_spacing_display3" format="float" type="dimen">0.0</item> - <item name="letter_spacing_body1" format="float" type="dimen">0.0</item> - <item name="letter_spacing_body2" format="float" type="dimen">0.0</item> - <item name="letter_spacing_body3" format="float" type="dimen">0.0</item> </resources> diff --git a/res/drawable/ic_play.xml b/res/values/integers.xml index 5e47e70..22ff0de 100644 --- a/res/drawable/ic_play.xml +++ b/res/values/integers.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <!-- - ~ Copyright (C) 2020 The Android Open Source Project + ~ Copyright (C) 2017 The Android Open Source Project ~ ~ Licensed under the Apache License, Version 2.0 (the "License"); ~ you may not use this file except in compliance with the License. @@ -12,14 +12,10 @@ ~ distributed under the License is distributed on an "AS IS" BASIS, ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ~ See the License for the specific language governing permissions and - ~ limitations under the License. + ~ limitations under the License --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="11dp" - android:height="14dp" - android:viewportHeight="14" - android:viewportWidth="11"> - <path - android:fillColor="@color/secondary_text_color" - android:pathData="M2,3.64L7.27,7L2,10.36V3.64ZM0,0V14L11,7L0,0Z" /> -</vector> + +<resources> + <integer name="anim_time">1000</integer> + <integer name="notification_conversation_title_length">30</integer> +</resources> diff --git a/res/values/strings.xml b/res/values/strings.xml index 837ad3e..1bd3c74 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1,70 +1,52 @@ <?xml version="1.0" encoding="utf-8"?> -<!-- - ~ Copyright 2020 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ http://www.apache.org/licenses/LICENSE-2.0 - ~ - ~ Unless required by applicable law or agreed to in writing, software - ~ distributed under the License is distributed on an "AS IS" BASIS, - ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - ~ See the License for the specific language governing permissions and - ~ limitations under the License. - --> -<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> - <!-- Application name [CHAR LIMIT=30] --> - <plurals name="new_message"> - <item quantity="one" translatable="false">New message</item> - <item quantity="other" translatable="false"> - <xliff:g example="2" id="count">%d</xliff:g> messages</item> - </plurals> +<!-- Copyright (C) 2007 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at - <!-- Button text for when disconnected from Bluetooth [CHAR LIMIT=40] --> - <string name="app_name" translatable="false">Car Messenger</string> + http://www.apache.org/licenses/LICENSE-2.0 - <!-- Button text for connecting to Bluetooth [CHAR LIMIT=40] --> - <string name="bluetooth_disconnected" translatable="false">Bluetooth disconnected</string> + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> - <!-- Status when no new messages[CHAR LIMIT=40] --> - <string name="connect_bluetooth_button_text" translatable="false">Connect to Bluetooth</string> +<resources> + <string name="app_name">Messenger</string> - <!-- Status when replied [CHAR LIMIT=40] --> - <string name="no_new_messages" translatable="false">No new messages</string> + <plurals name="notification_new_message"> + <item quantity="one">New message</item> + <item quantity="other">%d new messages</item> + </plurals> - <!-- Dot separator [CHAR LIMIT=1] --> - <string name="replied" translatable="false">Replied</string> - <string name="dot" translatable="false">·</string> - <string name="action_reply" translatable="false">Reply</string> - <string name="action_mute" translatable="false">Mute</string> + <string name="action_play">Play</string> + <string name="action_mark_as_read">Mark As Read</string> + <string name="action_repeat">Repeat</string> + <string name="action_reply">Reply</string> + <string name="action_stop">Stop</string> + <string name="action_close_messages">Close</string> + <string name="auto_reply_failed_message">Unable to send reply. Please try again.</string> + <string name="auto_reply_device_disconnected">Unable to send reply. Device is not connected. + </string> - <!-- The message service channel name [CHAR LIMIT=40] --> - <string name="action_mark_as_read" translatable="false">Mark As Read</string> - <!-- The message service channel description [CHAR LIMIT=40] --> - <string name="app_running_msg_notification_title">Messaging service is active</string> - <!-- The message channel name [CHAR LIMIT=40] --> - <string name="app_running_msg_notification_content">Receiving Messages on car</string> - <!-- The message channel description [CHAR LIMIT=40] --> - <string name="message_channel_name">Message Channel</string> - <!-- New Message String [CHAR LIMIT=40] --> - <string name="message_channel_description">Phone Message Receiver Service</string> + <string name="tts_sender_says">%s says</string> - <!-- Status when there is a new message [CHAR LIMIT=40] --> - <string name="new_message">New Message</string> + <string name="tts_failed_toast">Text playout failed!</string> + <string name="reply_message_display_template">\"%s\"</string> + <string name="message_sent_notice">Reply sent to %s</string> - <!-- Content Descriptions--> - <!-- An icon indicating that this is unread [CHAR LIMIT=40] --> - <string name="cd_unread">Unread</string> - <!-- Conversation Icon [CHAR LIMIT=40] --> - <string name="cd_conversation_icon">Conversation Icon</string> - <!-- Subtitle Text Icon [CHAR LIMIT=40] --> - <string name="cd_icon_indicating_the_last_action">Subtitle Text Icon</string> - <!-- Mute button [CHAR LIMIT=40] --> - <string name="cd_mute_button">Mute Button</string> - <!-- Mute button [CHAR LIMIT=40] --> - <string name="cd_loading_info_icon">Loading Info Icon</string> - <!-- Mute button [CHAR LIMIT=40] --> - <string name="cd_reply_action_button">Reply Action Button</string> + <string name="sms_channel_name">SMS Channel</string> + <string name="sms_channel_description">Phone SMS Receiver Service</string> + + <!-- Default Sender name that appears in message notification if sender name is not available. [CHAR_LIMIT=NONE] --> + <string name="name_not_available">Name not available</string> + <!-- Separator between names in a list (i.e. ", " for "Harry, Ron"). [CHAR_LIMIT=NONE] --> + <string name="name_separator">, </string> + + <string name="app_running_msg_channel_name">Uncategorized</string> + <string name="app_running_msg_notification_title">Messaging service is active</string> + <string name="app_running_msg_notification_content">Receiving SMS through Bluetooth</string> </resources> diff --git a/res/values/styles.xml b/res/values/styles.xml deleted file mode 100644 index 2568a05..0000000 --- a/res/values/styles.xml +++ /dev/null @@ -1,105 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2020 The Android Open Source Project - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. ---> -<resources xmlns:android="http://schemas.android.com/apk/res/android"> - <!-- Message history --> - <style name="TextAppearance.MessageHistoryTitle" parent="TextAppearance.Body1" /> - - <style name="TextAppearance.MessageHistorySubtitle" parent="TextAppearance.Body3"> - <item name="android:textColor">@color/secondary_text_color</item> - </style> - <!-- Customized text color for unread messages can be added here --> - <style name="TextAppearance.MessageHistoryUnreadTitle" - parent="TextAppearance.MessageHistoryTitle"> - <item name="android:textStyle">bold</item> - </style> - - <style name="TextAppearance.MessageHistoryUnreadSubtitle" - parent="TextAppearance.MessageHistorySubtitle"> - <item name="android:textStyle">bold</item> - </style> - - <style name="Widget.Button" parent="android:Widget.DeviceDefault.Button"> - <item name="android:ellipsize">none</item> - <item name="android:requiresFadingEdge">horizontal</item> - </style> - - <style name="LoadingInfoMessageStyle" parent="FullScreenErrorMessageStyle"> - <item name="android:textAppearance">@style/TextAppearance.Display3</item> - <item name="android:textFontWeight">500</item> - <item name="android:textStyle">normal</item> - </style> - - <style name="LoadingInfoSecondaryMessageStyle" parent="FullScreenErrorMessageStyle"> - <item name="android:textAppearance">@style/TextAppearance.Body2</item> - </style> - - <style name="LoadingInfoActionButtonStyle" parent="FullScreenErrorButtonStyle"> - <item name="android:textAppearance">@style/TextAppearance.Body3</item> - <item name="android:textFontWeight">500</item> - <item name="android:textStyle">normal</item> - </style> - - <!-- Styles for text. Sub1-3 are not included here as their use should be exceptional --> - <style name="TextAppearance"> - <item name="android:fontFamily">roboto-regular</item> - <item name="android:textColor">@color/primary_text_color</item> - <item name="android:textAlignment">viewStart</item> - </style> - - <style name="TextAppearance.Display3" parent="TextAppearance"> - <item name="android:textSize">36sp</item> - <item name="android:letterSpacing">@dimen/letter_spacing_display3</item> - </style> - - <style name="TextAppearance.Body1" parent="TextAppearance"> - <item name="android:textSize">32sp</item> - <item name="android:letterSpacing">@dimen/letter_spacing_body1</item> - </style> - - <style name="TextAppearance.Body2" parent="TextAppearance"> - <item name="android:textSize">28sp</item> - <item name="android:letterSpacing">@dimen/letter_spacing_body2</item> - </style> - - <style name="TextAppearance.Body3" parent="TextAppearance"> - <item name="android:textSize">24sp</item> - <item name="android:letterSpacing">@dimen/letter_spacing_body3</item> - </style> - - <!-- Styles for ControlBar --> - <style name="FullScreenErrorMessageStyle"> - <item name="android:layout_width">wrap_content</item> - <item name="android:layout_height">wrap_content</item> - <item name="android:textAppearance">@style/TextAppearance.Body1</item> - <item name="android:gravity">center</item> - <item name="android:maxWidth">@dimen/dialog_max_width</item> - </style> - - <style name="FullScreenErrorButtonStyle"> - <item name="android:layout_width">wrap_content</item> - <item name="android:layout_height">@dimen/hero_button_height</item> - <item name="android:textAppearance">@style/TextAppearance.Body1</item> - <item name="android:textStyle">bold</item> - <item name="android:maxWidth">@dimen/hero_button_max_width</item> - <item name="android:minWidth">@dimen/hero_button_min_width</item> - <item name="android:textAllCaps">false</item> - <item name="android:singleLine">true</item> - <item name="android:background">@drawable/hero_button_background</item> - <item name="android:textColor">@color/hero_button_text_color</item> - <item name="android:gravity">center</item> - <item name="android:paddingHorizontal">@dimen/hero_button_corner_radius</item> - </style> -</resources> diff --git a/res/values/themes.xml b/res/values/themes.xml deleted file mode 100644 index f2ddf7a..0000000 --- a/res/values/themes.xml +++ /dev/null @@ -1,28 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2020 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. ---> -<resources> - <!-- The base theme for the Message UI Library--> - <style name="Theme.Messaging" parent="Theme.CarUi.WithToolbar"> - <item name="android:listDivider">@drawable/list_divider</item> - <item name="android:buttonStyle">@style/Widget.Button</item> - <item name="android:textDirection">locale</item> - </style> - - <style name="Theme.Messaging.BidiText" parent="Theme.Messaging"> - <item name="android:textDirection">ltr</item> - <item name="android:textAlignment">viewStart</item> - </style> -</resources> diff --git a/src/com/android/car/messenger/MessageNotificationDelegate.java b/src/com/android/car/messenger/MessageNotificationDelegate.java new file mode 100644 index 0000000..5fa6db0 --- /dev/null +++ b/src/com/android/car/messenger/MessageNotificationDelegate.java @@ -0,0 +1,487 @@ +/* + * 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.messenger; + + +import static com.android.car.apps.common.util.SafeLog.logd; +import static com.android.car.apps.common.util.SafeLog.loge; +import static com.android.car.apps.common.util.SafeLog.logw; + +import android.annotation.Nullable; +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothMapClient; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.net.Uri; +import android.widget.Toast; + +import androidx.core.graphics.drawable.RoundedBitmapDrawable; +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; + +import com.android.car.messenger.bluetooth.BluetoothHelper; +import com.android.car.messenger.bluetooth.BluetoothMonitor; +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.ProjectionStateListener; +import com.android.car.messenger.common.SenderKey; +import com.android.car.messenger.common.Utils; +import com.android.car.telephony.common.TelecomUtils; +import com.android.internal.annotations.GuardedBy; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.RequestOptions; +import com.bumptech.glide.request.target.SimpleTarget; +import com.bumptech.glide.request.transition.Transition; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +/** Delegate class responsible for handling messaging service actions */ +public class MessageNotificationDelegate extends BaseNotificationDelegate implements + BluetoothMonitor.OnBluetoothEventListener { + private static final String TAG = "MsgNotiDelegate"; + private static final Object mMapClientLock = new Object(); + + @GuardedBy("mMapClientLock") + private BluetoothMapClient mBluetoothMapClient; + /** Tracks whether a projection application is active in the foreground. **/ + private ProjectionStateListener mProjectionStateListener; + private CompletableFuture<Void> mPhoneNumberInfoFuture; + private Locale mGeneratedGroupConversationTitlesLocale; + private static int mBitmapSize; + private static float mCornerRadiusPercent; + private static boolean mShouldLoadExistingMessages; + private static int mNotificationConversationTitleLength; + + final Map<String, Long> mBtDeviceAddressToConnectionTimestamp = new HashMap<>(); + final Map<SenderKey, Bitmap> mSenderToLargeIconBitmap = new HashMap<>(); + final Map<String, String> mUriToSenderNameMap = new HashMap<>(); + final Set<ConversationKey> mGeneratedGroupConversationTitles = new HashSet<>(); + + public MessageNotificationDelegate(Context context) { + super(context, /* useLetterTile */ true); + mProjectionStateListener = new ProjectionStateListener(context); + loadConfigValues(context); + } + + /** Loads all necessary values from the config.xml at creation or when values are changed. **/ + protected static void loadConfigValues(Context context) { + mBitmapSize = 300; + mCornerRadiusPercent = (float) 0.5; + mShouldLoadExistingMessages = false; + mNotificationConversationTitleLength = 30; + try { + mBitmapSize = + context.getResources() + .getDimensionPixelSize(R.dimen.notification_contact_photo_size); + mCornerRadiusPercent = context.getResources() + .getFloat(R.dimen.contact_avatar_corner_radius_percent); + mShouldLoadExistingMessages = + context.getResources().getBoolean(R.bool.config_loadExistingMessages); + mNotificationConversationTitleLength = context.getResources().getInteger( + R.integer.notification_conversation_title_length); + } catch (Resources.NotFoundException e) { + // Should only happen for robolectric unit tests; + loge(TAG, "Disabling loading of existing messages: " + e.getMessage()); + } + } + + @Override + public void onMessageReceived(Intent intent) { + addNamesToSenderMap(intent); + if (Utils.isGroupConversation(intent)) { + // Group Conversations have URIs of senders whose names we need to load from the DB. + loadNamesFromDatabase(intent); + } + loadAvatarIconAndProcessMessage(intent); + } + + @Override + public void onMessageSent(Intent intent) { + logd(TAG, "onMessageSent"); + } + + @Override + public void onDeviceConnected(BluetoothDevice device) { + logd(TAG, "Device connected: " + device.getAddress()); + mBtDeviceAddressToConnectionTimestamp.put(device.getAddress(), System.currentTimeMillis()); + synchronized (mMapClientLock) { + if (mBluetoothMapClient != null) { + if (mShouldLoadExistingMessages) { + mBluetoothMapClient.getUnreadMessages(device); + } + } else { + // onDeviceConnected should be sent by BluetoothMapClient, so log if we run into + // this strange case. + loge(TAG, "BluetoothMapClient is null after connecting to device."); + } + } + } + + @Override + public void onDeviceDisconnected(BluetoothDevice device) { + String deviceAddress = device.getAddress(); + logd(TAG, "Device disconnected: " + deviceAddress); + cleanupMessagesAndNotifications(key -> key.matches(deviceAddress)); + mBtDeviceAddressToConnectionTimestamp.remove(deviceAddress); + mSenderToLargeIconBitmap.entrySet().removeIf(entry -> + entry.getKey().getDeviceId().equals(deviceAddress)); + mGeneratedGroupConversationTitles.removeIf( + convoKey -> convoKey.getDeviceId().equals(deviceAddress)); + } + + @Override + public void onMapConnected(BluetoothMapClient client) { + logd(TAG, "Connected to BluetoothMapClient"); + List<BluetoothDevice> connectedDevices; + synchronized (mMapClientLock) { + if (mBluetoothMapClient == client) { + return; + } + + mBluetoothMapClient = client; + connectedDevices = mBluetoothMapClient.getConnectedDevices(); + } + if (connectedDevices != null) { + for (BluetoothDevice device : connectedDevices) { + onDeviceConnected(device); + } + } + } + + @Override + public void onMapDisconnected() { + logd(TAG, "Disconnected from BluetoothMapClient"); + resetInternalData(); + synchronized (mMapClientLock) { + mBluetoothMapClient = null; + } + } + + @Override + public void onSdpRecord(BluetoothDevice device, boolean supportsReply) { + /* NO_OP */ + } + + protected void markAsRead(ConversationKey convoKey) { + excludeFromNotification(convoKey); + } + + protected void dismiss(ConversationKey convoKey) { + super.dismissInternal(convoKey); + } + + @Override + protected boolean shouldAddReplyAction(String deviceAddress) { + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + if (adapter == null) { + return false; + } + BluetoothDevice device = adapter.getRemoteDevice(deviceAddress); + + synchronized (mMapClientLock) { + return (mBluetoothMapClient != null) && mBluetoothMapClient.isUploadingSupported( + device); + } + } + + protected void sendMessage(ConversationKey conversationKey, String messageText) { + final boolean deviceConnected = mBtDeviceAddressToConnectionTimestamp.containsKey( + conversationKey.getDeviceId()); + if (!deviceConnected) { + logw(TAG, "sendMessage: device disconnected, can't send message"); + return; + } + boolean success = false; + synchronized (mMapClientLock) { + if (mBluetoothMapClient != null) { + ConversationNotificationInfo notificationInfo = mNotificationInfos.get( + conversationKey); + if (notificationInfo == null) { + logw(TAG, "No notificationInfo found for senderKey " + + conversationKey.toString()); + } else if (notificationInfo.getCcRecipientsUris().isEmpty()) { + logw(TAG, "No contact URI for sender!"); + } else { + success = sendMessageInternal(conversationKey, messageText); + } + } + } + + if (!success) { + Toast.makeText(mContext, R.string.auto_reply_failed_message, Toast.LENGTH_SHORT).show(); + } + } + + protected void onDestroy() { + resetInternalData(); + if (mPhoneNumberInfoFuture != null) { + mPhoneNumberInfoFuture.cancel(true); + } + mProjectionStateListener.destroy(); + } + + private void resetInternalData() { + cleanupMessagesAndNotifications(key -> true); + mUriToSenderNameMap.clear(); + mSenderToLargeIconBitmap.clear(); + mBtDeviceAddressToConnectionTimestamp.clear(); + mGeneratedGroupConversationTitles.clear(); + } + + /** + * Creates a new message and links it to the conversation identified by the convoKey. Then + * posts the message notification after all loading queries from the database have finished. + */ + private void initializeNewMessage(ConversationKey convoKey, Message message) { + addMessageToNotificationInfo(message, convoKey); + ConversationNotificationInfo notificationInfo = mNotificationInfos.get(convoKey); + // Only show notifications for messages received AFTER phone was connected. + mPhoneNumberInfoFuture.thenRun(() -> { + setGroupConversationTitle(convoKey); + // Only show notifications for messages received AFTER phone was connected. + if (message.getReceivedTime() + >= mBtDeviceAddressToConnectionTimestamp.get(convoKey.getDeviceId())) { + postNotification(convoKey, notificationInfo, getChannelId(convoKey.getDeviceId()), + mSenderToLargeIconBitmap.get(message.getSenderKey())); + } + }); + } + + /** + * Creates a new conversation with all of the conversation metadata, and adds the first + * message to the conversation. + */ + private void initializeNewConversation(ConversationKey convoKey, Intent intent) { + if (mNotificationInfos.containsKey(convoKey)) { + logw(TAG, "Conversation already exists! " + convoKey.toString()); + } + Message message = Message.parseFromIntent(intent); + ConversationNotificationInfo notiInfo; + try { + // Pass in null icon, since the fallback icon represents the system app's icon. + notiInfo = + ConversationNotificationInfo.createConversationNotificationInfo(intent, + message.getSenderName(), mContext.getClass().getName(), + /* appIcon */ null); + } catch (IllegalArgumentException e) { + logw(TAG, "initNewConvo: Message could not be created from the intent."); + return; + } + mNotificationInfos.put(convoKey, notiInfo); + initializeNewMessage(convoKey, message); + } + + /** Loads the avatar icon, and processes the message after avatar is loaded. **/ + private void loadAvatarIconAndProcessMessage(Intent intent) { + SenderKey senderKey = SenderKey.createSenderKey(intent); + String phoneNumber = Utils.getPhoneNumberFromMapClient(Utils.getSenderUri(intent)); + if (mSenderToLargeIconBitmap.containsKey(senderKey) || phoneNumber == null) { + addMessageFromIntent(intent); + return; + } + loadPhoneNumberInfo(phoneNumber, phoneNumberInfo -> { + if (phoneNumberInfo == null) { + return; + } + Glide.with(mContext) + .asBitmap() + .load(phoneNumberInfo.getAvatarUri()) + .apply(new RequestOptions().override(mBitmapSize)) + .into(new SimpleTarget<Bitmap>() { + @Override + public void onResourceReady(Bitmap bitmap, + Transition<? super Bitmap> transition) { + RoundedBitmapDrawable roundedBitmapDrawable = + RoundedBitmapDrawableFactory + .create(mContext.getResources(), bitmap); + Icon avatarIcon = TelecomUtils + .createFromRoundedBitmapDrawable(roundedBitmapDrawable, + mBitmapSize, + mCornerRadiusPercent); + mSenderToLargeIconBitmap.put(senderKey, avatarIcon.getBitmap()); + addMessageFromIntent(intent); + return; + } + + @Override + public void onLoadFailed(@Nullable Drawable fallback) { + addMessageFromIntent(intent); + return; + } + }); + }); + } + + /** + * Extracts the message from the intent and creates a new conversation or message + * appropriately. + */ + private void addMessageFromIntent(Intent intent) { + ConversationKey convoKey = ConversationKey.createConversationKey(intent); + + if (convoKey == null) return; + logd(TAG, "Received message from " + convoKey.getDeviceId()); + if (mNotificationInfos.containsKey(convoKey)) { + try { + initializeNewMessage(convoKey, Message.parseFromIntent(intent)); + } catch (IllegalArgumentException e) { + logw(TAG, "addMessage: Message could not be created from the intent."); + return; + } + } else { + initializeNewConversation(convoKey, intent); + } + } + + private void addNamesToSenderMap(Intent intent) { + String senderUri = Utils.getSenderUri(intent); + String senderName = Utils.getSenderName(intent); + if (senderUri != null) { + mUriToSenderNameMap.put(senderUri, senderName); + } + } + + /** + * Loads the name of a sender based on the sender's contact URI. + * + * This is needed to load the participants' names of a group conversation since + * {@link BluetoothMapClient} only sends the URIs of these participants. + */ + private void loadNamesFromDatabase(Intent intent) { + for (String uri : Utils.getInclusiveRecipientsUrisList(intent)) { + String phoneNumber = Utils.getPhoneNumberFromMapClient(uri); + if (phoneNumber != null && !mUriToSenderNameMap.containsKey(uri)) { + loadPhoneNumberInfo(phoneNumber, (phoneNumberInfo) -> { + mUriToSenderNameMap.put(uri, phoneNumberInfo.getDisplayName()); + }); + } + } + } + + /** + * Sets the group conversation title using the names of all the participants in the group. + * If all the participants' names have been loaded from the database, then we don't need + * to generate the title again. + * + * A group conversation's title should be an alphabetically sorted list of the participant's + * names, separated by commas. + */ + private void setGroupConversationTitle(ConversationKey conversationKey) { + ConversationNotificationInfo notificationInfo = mNotificationInfos.get(conversationKey); + Locale locale = Locale.getDefault(); + + // Do not reuse the old titles if locale has changed. The new locale might need different + // formatting or text direction. + if (locale != mGeneratedGroupConversationTitlesLocale) { + mGeneratedGroupConversationTitles.clear(); + } + if (!notificationInfo.isGroupConvo() + || mGeneratedGroupConversationTitles.contains(conversationKey)) { + return; + } + + List<String> names = new ArrayList<>(); + + boolean allNamesLoaded = true; + for (String uri : notificationInfo.getCcRecipientsUris()) { + if (mUriToSenderNameMap.containsKey(uri)) { + names.add(mUriToSenderNameMap.get(uri)); + } else { + names.add(Utils.getPhoneNumberFromMapClient(uri)); + // This URI has not been loaded from the database, set allNamesLoaded to false. + allNamesLoaded = false; + } + } + + notificationInfo.setConvoTitle(Utils.constructGroupConversationTitle(names, + mContext.getString(R.string.name_separator), mNotificationConversationTitleLength)); + if (allNamesLoaded) { + mGeneratedGroupConversationTitlesLocale = locale; + mGeneratedGroupConversationTitles.add(conversationKey); + } + } + + private void loadPhoneNumberInfo(@Nullable String phoneNumber, + Consumer<? super TelecomUtils.PhoneNumberInfo> action) { + if (phoneNumber == null) { + logw(TAG, " Could not load PhoneNumberInfo due to null phone number"); + return; + } + + mPhoneNumberInfoFuture = TelecomUtils.getPhoneNumberInfo(mContext, phoneNumber) + .thenAcceptAsync(action, mContext.getMainExecutor()); + } + + private String getChannelId(String deviceAddress) { + if (mProjectionStateListener.isProjectionInActiveForeground(deviceAddress)) { + return MessengerService.SILENT_SMS_CHANNEL_ID; + } + return MessengerService.SMS_CHANNEL_ID; + } + + /** Sends reply message to the BluetoothMapClient to send to the connected phone. **/ + private boolean sendMessageInternal(ConversationKey conversationKey, String messageText) { + ConversationNotificationInfo notificationInfo = mNotificationInfos.get(conversationKey); + Uri[] recipientUrisArray = generateRecipientUriArray(notificationInfo); + + final int requestCode = conversationKey.hashCode(); + + Intent intent = new Intent(BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY); + PendingIntent sentIntent = PendingIntent.getBroadcast(mContext, requestCode, + intent, + PendingIntent.FLAG_ONE_SHOT); + + try { + return BluetoothHelper.sendMessage(mBluetoothMapClient, + conversationKey.getDeviceId(), recipientUrisArray, messageText, + sentIntent, null); + } catch (IllegalArgumentException e) { + logw(TAG, "Invalid device address: " + conversationKey.getDeviceId()); + } + return false; + } + + /** + * Generate an array containing all the recipients' URIs that should receive the user's + * message for the given notificationInfo. + */ + private Uri[] generateRecipientUriArray(ConversationNotificationInfo notificationInfo) { + List<String> ccRecipientsUris = notificationInfo.getCcRecipientsUris(); + Uri[] recipientUris = new Uri[ccRecipientsUris.size()]; + + for (int i = 0; i < ccRecipientsUris.size(); i++) { + recipientUris[i] = Uri.parse(ccRecipientsUris.get(i)); + } + return recipientUris; + } +} diff --git a/src/com/android/car/messenger/MessengerActivity.java b/src/com/android/car/messenger/MessengerActivity.java new file mode 100644 index 0000000..e350dce --- /dev/null +++ b/src/com/android/car/messenger/MessengerActivity.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.messenger; + + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.Nullable; + +/** + * No-op Activity that only exists in order to have an entry in the manifest with SMS specific + * intent-filter. + * <p> + * We need the manifest entry so that PackageManager will grant this pre-installed app SMS related + * permissions. See DefaultPermissionGrantPolicy.grantDefaultSystemHandlerPermissions(). + */ +public class MessengerActivity extends Activity { + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + finish(); + } + + @Override + public void startActivity(Intent intent) { + super.startActivity(intent); + finish(); + } + +} diff --git a/src/com/android/car/messenger/MessengerService.java b/src/com/android/car/messenger/MessengerService.java new file mode 100644 index 0000000..905109f --- /dev/null +++ b/src/com/android/car/messenger/MessengerService.java @@ -0,0 +1,263 @@ +package com.android.car.messenger; + + +import static com.android.car.messenger.common.BaseNotificationDelegate.ACTION_DISMISS_NOTIFICATION; +import static com.android.car.messenger.common.BaseNotificationDelegate.ACTION_MARK_AS_READ; +import static com.android.car.messenger.common.BaseNotificationDelegate.ACTION_REPLY; +import static com.android.car.messenger.common.BaseNotificationDelegate.EXTRA_CONVERSATION_KEY; +import static com.android.car.messenger.common.BaseNotificationDelegate.EXTRA_REMOTE_INPUT_KEY; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Intent; +import android.media.AudioAttributes; +import android.os.Binder; +import android.os.Bundle; +import android.os.IBinder; +import android.provider.Settings; +import android.telephony.TelephonyManager; +import android.text.TextUtils; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.RemoteInput; + +import com.android.car.messenger.bluetooth.BluetoothMonitor; +import com.android.car.messenger.common.BaseNotificationDelegate; +import com.android.car.messenger.common.ConversationKey; +import com.android.car.messenger.log.L; + +/** Service responsible for handling SMS messaging events from paired Bluetooth devices. */ +public class MessengerService extends Service { + private final static String TAG = "CM.MessengerService"; + + /* ACTIONS */ + /** Used to start this service at boot-complete. Takes no arguments. */ + public static final String ACTION_START = "com.android.car.messenger.ACTION_START"; + + /** Used to notify when a sms is received. Takes no arguments. */ + public static final String ACTION_RECEIVED_SMS = + "com.android.car.messenger.ACTION_RECEIVED_SMS"; + + /** Used to notify when a mms is received. Takes no arguments. */ + public static final String ACTION_RECEIVED_MMS = + "com.android.car.messenger.ACTION_RECEIVED_MMS"; + + /* EXTRAS */ + + /* NOTIFICATIONS */ + static final String SMS_CHANNEL_ID = "SMS_CHANNEL_ID"; + static final String SILENT_SMS_CHANNEL_ID = "SILENT_SMS_CHANNEL_ID"; + private static final String APP_RUNNING_CHANNEL_ID = "APP_RUNNING_CHANNEL_ID"; + private static final int SERVICE_STARTED_NOTIFICATION_ID = Integer.MAX_VALUE; + + /** Delegate class used to handle this services' actions */ + private MessageNotificationDelegate mMessengerDelegate; + + /** Notifies this service of new bluetooth actions */ + private BluetoothMonitor mBluetoothMonitor; + + /* Binding boilerplate */ + private final IBinder mBinder = new LocalBinder(); + + public class LocalBinder extends Binder { + MessengerService getService() { + return MessengerService.this; + } + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public void onCreate() { + super.onCreate(); + L.d(TAG, "onCreate"); + + mMessengerDelegate = new MessageNotificationDelegate(this); + mBluetoothMonitor = new BluetoothMonitor(this); + mBluetoothMonitor.registerListener(mMessengerDelegate); + sendServiceRunningNotification(); + } + + + private void sendServiceRunningNotification() { + NotificationManager notificationManager = getSystemService(NotificationManager.class); + + if (notificationManager == null) { + L.e(TAG, "Failed to get NotificationManager instance"); + return; + } + + // Create notification channel for app running notification + { + NotificationChannel appRunningNotificationChannel = + new NotificationChannel(APP_RUNNING_CHANNEL_ID, + getString(R.string.app_running_msg_channel_name), + NotificationManager.IMPORTANCE_MIN); + notificationManager.createNotificationChannel(appRunningNotificationChannel); + } + + // Create notification channel for notifications that should be posted silently in the + // notification center, without a heads up notification. + { + NotificationChannel silentNotificationChannel = + new NotificationChannel(SILENT_SMS_CHANNEL_ID, + getString(R.string.sms_channel_description), + NotificationManager.IMPORTANCE_LOW); + notificationManager.createNotificationChannel(silentNotificationChannel); + } + + { + AudioAttributes attributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .build(); + NotificationChannel smsChannel = new NotificationChannel(SMS_CHANNEL_ID, + getString(R.string.sms_channel_name), + NotificationManager.IMPORTANCE_HIGH); + smsChannel.setDescription(getString(R.string.sms_channel_description)); + smsChannel.setSound(Settings.System.DEFAULT_NOTIFICATION_URI, attributes); + notificationManager.createNotificationChannel(smsChannel); + } + + final Notification notification = + new NotificationCompat.Builder(this, APP_RUNNING_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_message) + .setContentTitle(getString(R.string.app_running_msg_notification_title)) + .setContentText(getString(R.string.app_running_msg_notification_content)) + .build(); + startForeground(SERVICE_STARTED_NOTIFICATION_ID, notification); + } + + @Override + public void onDestroy() { + super.onDestroy(); + L.d(TAG, "onDestroy"); + mMessengerDelegate.onDestroy(); + mBluetoothMonitor.onDestroy(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + final int result = START_STICKY; + + if (intent == null || intent.getAction() == null) return result; + + final String action = intent.getAction(); + if (!hasRequiredArgs(intent)) { + L.e(TAG, "Dropping command: %s. Reason: Missing required argument.", action); + return result; + } + + switch (action) { + case ACTION_START: + // NO-OP + break; + case ACTION_REPLY: + voiceReply(intent); + break; + case ACTION_DISMISS_NOTIFICATION: + clearNotificationState(intent); + break; + case ACTION_MARK_AS_READ: + markAsRead(intent); + break; + case ACTION_RECEIVED_SMS: + // NO-OP + break; + case ACTION_RECEIVED_MMS: + // NO-OP + break; + case TelephonyManager.ACTION_RESPOND_VIA_MESSAGE: + respondViaMessage(intent); + break; + default: + L.w(TAG, "Unsupported action: %s", action); + } + + return result; + } + + /** + * Checks that the intent has all of the required arguments for its requested action. + * + * @param intent the intent to check + * @return true if the intent has all of the required {@link Bundle} args for its action + */ + private static boolean hasRequiredArgs(Intent intent) { + switch (intent.getAction()) { + case ACTION_REPLY: + case ACTION_DISMISS_NOTIFICATION: + case ACTION_MARK_AS_READ: + if (!intent.hasExtra(EXTRA_CONVERSATION_KEY)) { + L.w(TAG, "Intent %s missing conversation-key extra.", intent.getAction()); + return false; + } + return true; + default: + // For unknown actions, default to true. We'll report an error for these later. + return true; + } + } + + /** + * Sends a reply, meant to be used from a caller originating from voice input. + * + * @param intent intent containing {@link BaseNotificationDelegate#EXTRA_CONVERSATION_KEY} and + * a {@link RemoteInput} with + * {@link BaseNotificationDelegate#EXTRA_REMOTE_INPUT_KEY} resultKey + */ + public void voiceReply(Intent intent) { + final ConversationKey conversationKey = intent.getParcelableExtra(EXTRA_CONVERSATION_KEY); + final Bundle bundle = RemoteInput.getResultsFromIntent(intent); + if (bundle == null) { + L.e(TAG, "Dropping voice reply. Received null RemoteInput result!"); + return; + } + final CharSequence message = bundle.getCharSequence(EXTRA_REMOTE_INPUT_KEY); + L.d(TAG, "voiceReply"); + if (!TextUtils.isEmpty(message)) { + mMessengerDelegate.sendMessage(conversationKey, message.toString()); + } + } + + /** + * Clears notification(s) associated with a given sender key. + * + * @param intent intent containing {@link BaseNotificationDelegate#EXTRA_CONVERSATION_KEY} bundle argument + */ + public void clearNotificationState(Intent intent) { + final ConversationKey conversationKey = intent.getParcelableExtra(EXTRA_CONVERSATION_KEY); + L.d(TAG, "clearNotificationState"); + mMessengerDelegate.clearNotifications(key -> key.equals(conversationKey)); + } + + /** + * Mark a conversation associated with a given sender key as read. + * + * @param intent intent containing {@link BaseNotificationDelegate#EXTRA_CONVERSATION_KEY} bundle argument + */ + public void markAsRead(Intent intent) { + final ConversationKey conversationKey = intent.getParcelableExtra(EXTRA_CONVERSATION_KEY); + L.d(TAG, "markAsRead"); + mMessengerDelegate.markAsRead(conversationKey); + } + + /** + * Respond to a call via text message. + * + * @param intent intent containing a URI describing the recipient and the URI schema + */ + public void respondViaMessage(Intent intent) { + Bundle extras = intent.getExtras(); + if (extras == null) { + L.v(TAG, "Called to send SMS but no extras"); + return; + } + + // TODO: get conversationKey from the recipient's address, and sendMessage() to it. + } +} diff --git a/src/com/android/car/messenger/impl/receivers/MmsReceiver.java b/src/com/android/car/messenger/MmsReceiver.java index 6811d23..37cc5ef 100644 --- a/src/com/android/car/messenger/impl/receivers/MmsReceiver.java +++ b/src/com/android/car/messenger/MmsReceiver.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 The Android Open Source Project + * Copyright (C) 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,16 +14,20 @@ * limitations under the License. */ -package com.android.car.messenger.impl.receivers; +package com.android.car.messenger; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import androidx.annotation.NonNull; - -/** No-op Receiver that only exists in order to be eligible to be the default SMS app. */ +/** + * No-op Receiver that only exists in order to be eligible to be the default SMS app. + */ public class MmsReceiver extends BroadcastReceiver { @Override - public void onReceive(@NonNull Context context, @NonNull Intent intent) {} + public void onReceive(Context context, Intent intent) { + Intent startIntent = new Intent(context, MessengerService.class) + .setAction(MessengerService.ACTION_RECEIVED_MMS); + context.startForegroundService(startIntent); + } } diff --git a/src/com/android/car/messenger/SmsDatabaseHandler.java b/src/com/android/car/messenger/SmsDatabaseHandler.java new file mode 100644 index 0000000..a5d0472 --- /dev/null +++ b/src/com/android/car/messenger/SmsDatabaseHandler.java @@ -0,0 +1,220 @@ +package com.android.car.messenger; + + +import android.Manifest; +import android.app.AppOpsManager; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.net.Uri; +import android.provider.BaseColumns; +import android.provider.ContactsContract; +import android.provider.Telephony; +import android.text.TextUtils; +import android.util.Log; + +import androidx.core.content.ContextCompat; + +import com.android.car.messenger.common.Message; +import com.android.car.messenger.log.L; + +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * Reads and writes SMS Messages into the Telephony.SMS Database. + */ +class SmsDatabaseHandler { + private static final String TAG = "CM.SmsDatabaseHandler"; + private static final int MESSAGE_NOT_FOUND = -1; + private static final int DUPLICATE_MESSAGES_FOUND = -2; + private static final int DATABASE_ERROR = -3; + private static final Uri SMS_URI = Telephony.Sms.CONTENT_URI; + private static final String SMS_SELECTION = Telephony.Sms.ADDRESS + "=? AND " + + Telephony.Sms.BODY + "=? AND (" + Telephony.Sms.DATE + ">=? OR " + Telephony.Sms.DATE + + "<=?)"; + private static final SimpleDateFormat DATE_FORMATTER = new SimpleDateFormat( + "MMM dd,yyyy HH:mm"); + + private final ContentResolver mContentResolver; + private final boolean mCanWriteToDatabase; + + protected SmsDatabaseHandler(Context context) { + mCanWriteToDatabase = canWriteToDatabase(context); + mContentResolver = context.getContentResolver(); + readDatabase(context); + } + + protected void addOrUpdate(String deviceAddress, Message message) { + if (!mCanWriteToDatabase) { + return; + } + + int messageIndex = findMessageIndex(deviceAddress, message); + switch(messageIndex) { + case DUPLICATE_MESSAGES_FOUND: + removePreviousAndInsert(deviceAddress, message); + L.d(TAG, "Message has more than one duplicate in Telephony Database: %s", + message.toString()); + return; + case MESSAGE_NOT_FOUND: + mContentResolver.insert(SMS_URI, buildMessageContentValues(deviceAddress, message)); + return; + case DATABASE_ERROR: + return; + default: + update(messageIndex, buildMessageContentValues(deviceAddress, message)); + } + } + + protected void removeMessagesForDevice(String address) { + if (!mCanWriteToDatabase) { + return; + } + + String smsSelection = Telephony.Sms.ADDRESS + "=?"; + String[] smsSelectionArgs = {address}; + mContentResolver.delete(SMS_URI, smsSelection, smsSelectionArgs); + } + + /** + * Reads the Telephony SMS Database, and logs all of the SMS messages that have been received + * in the last five minutes. + * @param context + */ + protected static void readDatabase(Context context) { + if (!Log.isLoggable(TAG, Log.DEBUG)) { + return; + } + + Long beginningTimeStamp = System.currentTimeMillis() - 300000; + String timeStamp = DATE_FORMATTER.format(new Date(beginningTimeStamp)); + Log.d(TAG, + " ------ printing SMSs received after " + timeStamp + "-------- "); + + String smsSelection = Telephony.Sms.DATE + ">=?"; + String[] smsSelectionArgs = {Long.toString(beginningTimeStamp)}; + Cursor cursor = context.getContentResolver().query(SMS_URI, null, + smsSelection, + smsSelectionArgs, null /* sortOrder */); + if (cursor != null) { + while (cursor.moveToNext()) { + String body = cursor.getString(12); + + Date date = new Date(cursor.getLong(4)); + Log.d(TAG, + "_id " + cursor.getInt(0) + " person: " + cursor.getInt(3) + " body: " + + body.substring(0, Math.min(body.length(), 17)) + " address: " + + cursor.getString(2) + " date: " + DATE_FORMATTER.format( + date) + " longDate " + cursor.getLong(4) + " read: " + + cursor.getInt(7)); + } + } + Log.d(TAG, " ------ end read table --------"); + } + + /** Removes multiple previous copies, and inserts the new message. **/ + private void removePreviousAndInsert(String deviceAddress, Message message) { + String[] smsSelectionArgs = createSmsSelectionArgs(deviceAddress, message); + + mContentResolver.delete(SMS_URI, SMS_SELECTION, smsSelectionArgs); + mContentResolver.insert(SMS_URI, buildMessageContentValues(deviceAddress, message)); + } + + private int findMessageIndex(String deviceAddress, Message message) { + String[] smsSelectionArgs = createSmsSelectionArgs(deviceAddress, message); + + String[] projection = {BaseColumns._ID}; + Cursor cursor = mContentResolver.query(SMS_URI, projection, SMS_SELECTION, + smsSelectionArgs, null /* sortOrder */); + + if (cursor != null && cursor.getCount() != 0) { + if (cursor.moveToFirst() && cursor.isLast()) { + return getIdOrThrow(cursor); + } else { + return DUPLICATE_MESSAGES_FOUND; + } + } else { + return MESSAGE_NOT_FOUND; + } + } + + private int getIdOrThrow(Cursor cursor) { + try { + int columnIndex = cursor.getColumnIndexOrThrow(BaseColumns._ID); + return cursor.getInt(columnIndex); + } catch (IllegalArgumentException e) { + L.d(TAG, "Could not find _id column: " + e.getMessage()); + return DATABASE_ERROR; + } + } + + private void update(int messageIndex, ContentValues value) { + final String smsSelection = BaseColumns._ID + "=?"; + String[] smsSelectionArgs = {Integer.toString(messageIndex)}; + + mContentResolver.update(SMS_URI, value, smsSelection, smsSelectionArgs); + } + + /** Create the ContentValues object using message info, following SMS columns **/ + private ContentValues buildMessageContentValues(String deviceAddress, Message message) { + ContentValues newMessage = new ContentValues(); + newMessage.put(Telephony.Sms.BODY, DatabaseUtils.sqlEscapeString(message.getMessageText())); + newMessage.put(Telephony.Sms.DATE, message.getReceivedTime()); + newMessage.put(Telephony.Sms.ADDRESS, deviceAddress); + // TODO: if contactId is null, add it. + newMessage.put(Telephony.Sms.PERSON, + getContactId(mContentResolver, + message.getSenderContactUri())); + newMessage.put(Telephony.Sms.READ, (message.isReadOnPhone() + || message.shouldExcludeFromNotification())); + return newMessage; + } + + private String[] createSmsSelectionArgs(String deviceAddress, Message message) { + String sqlFriendlyMessageText = DatabaseUtils.sqlEscapeString(message.getMessageText()); + String[] smsSelectionArgs = {deviceAddress, sqlFriendlyMessageText, + Long.toString(message.getReceivedTime() - 5000), Long.toString( + message.getReceivedTime() + 5000)}; + return smsSelectionArgs; + } + + /** Checks if the application has the needed AppOps permission to write to the Telephony DB. **/ + private boolean canWriteToDatabase(Context context) { + boolean granted = ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_SMS) + == PackageManager.PERMISSION_GRANTED; + + AppOpsManager appOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE); + int mode = appOps.checkOpNoThrow(AppOpsManager.OP_WRITE_SMS, android.os.Process.myUid(), + context.getPackageName()); + if (mode != AppOpsManager.MODE_DEFAULT) { + granted = (mode == AppOpsManager.MODE_ALLOWED); + } + + return granted; + } + + // TODO: move out to a shared library. + private static int getContactId(ContentResolver cr, String contactUri) { + if (TextUtils.isEmpty(contactUri)) { + return 0; + } + + Uri lookupUri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, + Uri.encode(contactUri)); + String[] projection = new String[]{ContactsContract.PhoneLookup._ID}; + + try (Cursor cursor = cr.query(lookupUri, projection, null, null, null)) { + if (cursor != null && cursor.moveToFirst() && cursor.isLast()) { + return cursor.getInt(cursor.getColumnIndex(ContactsContract.PhoneLookup._ID)); + } else { + L.w(TAG, "Unable to find contact id from phone number."); + } + } + + return 0; + } +} diff --git a/src/com/android/car/messenger/impl/receivers/SmsReceiver.java b/src/com/android/car/messenger/SmsReceiver.java index 9150e30..25dbf89 100644 --- a/src/com/android/car/messenger/impl/receivers/SmsReceiver.java +++ b/src/com/android/car/messenger/SmsReceiver.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 The Android Open Source Project + * Copyright (C) 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,17 +14,21 @@ * limitations under the License. */ -package com.android.car.messenger.impl.receivers; +package com.android.car.messenger; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import androidx.annotation.NonNull; - -/** No-op Receiver that only exists in order to be eligible to be the default SMS app. */ +/** + * No-op Receiver that only exists in order to be eligible to be the default SMS app. + */ public class SmsReceiver extends BroadcastReceiver { @Override - public void onReceive(@NonNull Context context, @NonNull Intent intent) {} + public void onReceive(Context context, Intent intent) { + Intent startIntent = new Intent(context, MessengerService.class) + .setAction(MessengerService.ACTION_RECEIVED_SMS); + context.startForegroundService(startIntent); + } } diff --git a/src/com/android/car/messenger/bluetooth/BluetoothHelper.java b/src/com/android/car/messenger/bluetooth/BluetoothHelper.java new file mode 100644 index 0000000..e95b386 --- /dev/null +++ b/src/com/android/car/messenger/bluetooth/BluetoothHelper.java @@ -0,0 +1,64 @@ +package com.android.car.messenger.bluetooth; + +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothMapClient; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collections; +import java.util.Set; + +/** + * Provides helper methods for performing bluetooth actions. + */ +public class BluetoothHelper { + + /** + * Returns a (potentially empty) immutable set of bonded (paired) devices. + */ + public static Set<BluetoothDevice> getBondedDevices() { + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + + if (adapter != null) { + Set<BluetoothDevice> devices = adapter.getBondedDevices(); + if (devices != null) { + return devices; + } + } + + return Collections.emptySet(); + } + + /** + * Helper method to send an SMS message through bluetooth. + * + * @param client the MAP Client used to send the message + * @param deviceAddress the device used to send the SMS + * @param contacts contacts to send the message to + * @param message message to send + * @param sentIntent callback issued once the message was sent + * @param deliveredIntent callback issued once the message was delivered + * @return true if the message was enqueued, false on error + * @throws IllegalArgumentException if deviceAddress is invalid + */ + public static boolean sendMessage(@NonNull BluetoothMapClient client, + String deviceAddress, + Uri[] contacts, + String message, + @Nullable PendingIntent sentIntent, + @Nullable PendingIntent deliveredIntent) + throws IllegalArgumentException { + + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + if (adapter == null) { + return false; + } + BluetoothDevice device = adapter.getRemoteDevice(deviceAddress); + + return client.sendMessage(device, contacts, message, sentIntent, deliveredIntent); + } +} diff --git a/src/com/android/car/messenger/bluetooth/BluetoothMonitor.java b/src/com/android/car/messenger/bluetooth/BluetoothMonitor.java new file mode 100644 index 0000000..095474f --- /dev/null +++ b/src/com/android/car/messenger/bluetooth/BluetoothMonitor.java @@ -0,0 +1,329 @@ +package com.android.car.messenger.bluetooth; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothMapClient; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.SdpMasRecord; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import com.android.car.messenger.log.L; +import java.util.HashSet; +import java.util.Set; + + +/** + * Provides a callback interface for subscribers to be notified of bluetooth MAP/SDP changes. + */ +public class BluetoothMonitor { + private static final String TAG = "CM.BluetoothMonitor"; + + private final Context mContext; + private final BluetoothMapReceiver mBluetoothMapReceiver; + private final BluetoothSdpReceiver mBluetoothSdpReceiver; + private final MapDeviceMonitor mMapDeviceMonitor; + private final BluetoothProfile.ServiceListener mMapServiceListener; + private BluetoothMapClient mBluetoothMapClient; + + private final Set<OnBluetoothEventListener> mListeners; + + public BluetoothMonitor(@NonNull Context context) { + mContext = context; + mBluetoothMapReceiver = new BluetoothMapReceiver(); + mBluetoothSdpReceiver = new BluetoothSdpReceiver(); + mMapDeviceMonitor = new MapDeviceMonitor(); + mMapServiceListener = new BluetoothProfile.ServiceListener() { + @Override + public void onServiceConnected(int profile, BluetoothProfile proxy) { + L.d(TAG, "Connected to MAP service!"); + onMapConnected((BluetoothMapClient) proxy); + } + + @Override + public void onServiceDisconnected(int profile) { + L.d(TAG, "Disconnected from MAP service!"); + onMapDisconnected(); + } + }; + mListeners = new HashSet<>(); + connectToMap(); + } + + /** + * Registers a listener to receive Bluetooth MAP events. + * If this listener is already registered, calling this method has no effect. + * + * @param listener the listener to register + * @return true if this listener was not already registered + */ + public boolean registerListener(@NonNull OnBluetoothEventListener listener) { + return mListeners.add(listener); + } + + /** + * Unregisters a listener from receiving Bluetooth MAP events. + * If this listener is not registered, calling this method has no effect. + * + * @param listener the listener to unregister + * @return true if the set of registered listeners contained this listener + */ + public boolean unregisterListener(OnBluetoothEventListener listener) { + return mListeners.remove(listener); + } + + public interface OnBluetoothEventListener { + /** + * Callback issued when a new message was received. + * + * @param intent intent containing the message details + */ + void onMessageReceived(Intent intent); + + /** + * Callback issued when a new message was sent successfully. + * + * @param intent intent containing the message details + */ + void onMessageSent(Intent intent); + + /** + * Callback issued when a new device has connected to bluetooth. + * + * @param device the connected device + */ + void onDeviceConnected(BluetoothDevice device); + + /** + * Callback issued when a previously connected device has disconnected from bluetooth. + * + * @param device the disconnected device + */ + void onDeviceDisconnected(BluetoothDevice device); + + /** + * Callback issued when a new MAP client has been connected. + * + * @param client the MAP client + */ + void onMapConnected(BluetoothMapClient client); + + /** + * Callback issued when a MAP client has been disconnected. + */ + void onMapDisconnected(); + + /** + * Callback issued when a new SDP record has been detected. + * + * @param device the device detected + * @param supportsReply true if the device supports SMS replies through bluetooth + */ + void onSdpRecord(BluetoothDevice device, boolean supportsReply); + } + + private void onMessageReceived(Intent intent) { + mListeners.forEach(listener -> listener.onMessageReceived(intent)); + } + + private void onMessageSent(Intent intent) { + mListeners.forEach(listener -> listener.onMessageSent(intent)); + } + + private void onDeviceConnected(BluetoothDevice device) { + mListeners.forEach(listener -> listener.onDeviceConnected(device)); + } + + private void onDeviceDisconnected(BluetoothDevice device) { + mListeners.forEach(listener -> listener.onDeviceDisconnected(device)); + } + + private void onMapConnected(BluetoothMapClient client) { + mBluetoothMapClient = client; + mListeners.forEach(listener -> listener.onMapConnected(client)); + } + + private void onMapDisconnected() { + mBluetoothMapClient = null; + mListeners.forEach(listener -> listener.onMapDisconnected()); + } + + private void onSdpRecord(BluetoothDevice device, boolean supportsReply) { + mListeners.forEach(listener -> listener.onSdpRecord(device, supportsReply)); + } + + /** Connects to the MAP client. */ + private void connectToMap() { + L.d(TAG, "Connecting to MAP service"); + + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + if (adapter == null) { + // This can happen on devices that don't support Bluetooth. + L.e(TAG, "BluetoothAdapter is null! Unable to connect to MAP client."); + return; + } + + if (!adapter.getProfileProxy(mContext, mMapServiceListener, BluetoothProfile.MAP_CLIENT)) { + // This *should* never happen. Unless arguments passed are incorrect somehow... + L.wtf(TAG, "Unable to get MAP profile!"); + return; + } + } + + /** + * Performs {@link Context} related cleanup (such as unregistering from receivers). + */ + public void onDestroy() { + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + if (adapter != null) { + adapter.closeProfileProxy(BluetoothProfile.MAP_CLIENT, mBluetoothMapClient); + } + onMapDisconnected(); + mListeners.clear(); + mBluetoothMapReceiver.unregisterReceivers(); + mBluetoothSdpReceiver.unregisterReceivers(); + mMapDeviceMonitor.unregisterReceivers(); + } + + @VisibleForTesting + BluetoothProfile.ServiceListener getServiceListener() { + return mMapServiceListener; + } + + /** Monitors for new device connections and disconnections */ + private class MapDeviceMonitor extends BroadcastReceiver { + MapDeviceMonitor() { + L.d(TAG, "Registering Map device monitor"); + + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(BluetoothMapClient.ACTION_CONNECTION_STATE_CHANGED); + mContext.registerReceiver(this, intentFilter, + android.Manifest.permission.BLUETOOTH, null); + } + + void unregisterReceivers() { + mContext.unregisterReceiver(this); + } + + @Override + public void onReceive(Context context, Intent intent) { + final int STATE_NOT_FOUND = -1; + int state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, STATE_NOT_FOUND); + int previousState = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, + STATE_NOT_FOUND); + + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + + if (state == STATE_NOT_FOUND || previousState == STATE_NOT_FOUND || device == null) { + L.w(TAG, "Skipping broadcast, missing required extra"); + return; + } + + if (previousState == BluetoothProfile.STATE_CONNECTED + && state != BluetoothProfile.STATE_CONNECTED) { + L.d(TAG, "Device losing MAP connection: %s", device); + + onDeviceDisconnected(device); + } + + if (previousState == BluetoothProfile.STATE_CONNECTING + && state == BluetoothProfile.STATE_CONNECTED) { + L.d(TAG, "Device connected: %s", device); + + onDeviceConnected(device); + } + } + } + + /** Monitors for new incoming messages and sent-message broadcast. */ + private class BluetoothMapReceiver extends BroadcastReceiver { + BluetoothMapReceiver() { + L.d(TAG, "Registering receiver for bluetooth MAP"); + + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY); + intentFilter.addAction(BluetoothMapClient.ACTION_MESSAGE_RECEIVED); + mContext.registerReceiver(this, intentFilter); + } + + void unregisterReceivers() { + mContext.unregisterReceiver(this); + } + + @Override + public void onReceive(Context context, Intent intent) { + + if (intent == null || intent.getAction() == null) return; + + switch (intent.getAction()) { + case BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY: + L.d(TAG, "SMS sent successfully."); + onMessageSent(intent); + break; + case BluetoothMapClient.ACTION_MESSAGE_RECEIVED: + L.d(TAG, "SMS message received."); + onMessageReceived(intent); + break; + default: + L.w(TAG, "Ignoring unknown broadcast %s", intent.getAction()); + break; + } + } + } + + /** Monitors for new SDP records */ + private class BluetoothSdpReceiver extends BroadcastReceiver { + + // reply or "upload" feature is indicated by the 3rd bit + private static final int REPLY_FEATURE_FLAG_POSITION = 3; + private static final int REPLY_FEATURE_MIN_VERSION = 0x102; + + BluetoothSdpReceiver() { + L.d(TAG, "Registering receiver for sdp"); + + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(BluetoothDevice.ACTION_SDP_RECORD); + mContext.registerReceiver(this, intentFilter); + } + + void unregisterReceivers() { + mContext.unregisterReceiver(this); + } + + @Override + public void onReceive(Context context, Intent intent) { + if (BluetoothDevice.ACTION_SDP_RECORD.equals(intent.getAction())) { + L.d(TAG, "get SDP record: %s", intent.getExtras()); + + Parcelable parcelable = intent.getParcelableExtra(BluetoothDevice.EXTRA_SDP_RECORD); + if (!(parcelable instanceof SdpMasRecord)) { + L.d(TAG, "not SdpMasRecord: %s", parcelable); + return; + } + + SdpMasRecord masRecord = (SdpMasRecord) parcelable; + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + onSdpRecord(device, supportsReply(masRecord)); + } else { + L.w(TAG, "Ignoring unknown broadcast %s", intent.getAction()); + } + } + + private boolean isOn(int input, int position) { + return ((input >> position) & 1) == 1; + } + + private boolean supportsReply(@NonNull SdpMasRecord masRecord) { + final int version = masRecord.getProfileVersion(); + final int features = masRecord.getSupportedFeatures(); + // We only consider the device as supporting the reply feature if the version + // is 1.02 at minimum and the feature flag is turned on. + return version >= REPLY_FEATURE_MIN_VERSION + && isOn(features, REPLY_FEATURE_FLAG_POSITION); + } + } +} diff --git a/src/com/android/car/messenger/core/interfaces/AppFactory.java b/src/com/android/car/messenger/core/interfaces/AppFactory.java deleted file mode 100644 index a88078d..0000000 --- a/src/com/android/car/messenger/core/interfaces/AppFactory.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.interfaces; - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.annotation.NonNull; - -/** - * The AppFactory provides singleton instances to be used throughout the app. - * - * <p>The Factory implementation points to the UI core library interfaces. - * - * <p>Once the Factory implementation is initialized with {@link #setInstance(AppFactory)}, the - * library interface implementations can be accessed anywhere throughout the application. - */ -public abstract class AppFactory { - @NonNull private static AppFactory sInstance; - protected static boolean sRegistered; - protected static boolean sInitialized; - - /** Returns the Factory instance for the Application. */ - @NonNull - public static AppFactory get() { - return sInstance; - } - - /** - * Sets the Factory instance. - * - * <p>This is called when the application starts, in onCreate of the custom Application class - */ - protected static void setInstance(@NonNull final AppFactory factory) { - // Not allowed to call this after real application initialization is complete - if (sRegistered && sInitialized) { - return; - } - sInstance = factory; - } - - /** Returns context most appropriate for UI context-requiring tasks. */ - @NonNull - public abstract Context getContext(); - - /** - * Perhaps the single most important methods to implement, this provides the data source for the - * app. - */ - @NonNull - public abstract DataModel getDataModel(); - - /** Returns the shared preference instance for the app */ - @NonNull - public abstract SharedPreferences getSharedPreferences(); -} diff --git a/src/com/android/car/messenger/core/interfaces/DataModel.java b/src/com/android/car/messenger/core/interfaces/DataModel.java deleted file mode 100644 index 3b4ad75..0000000 --- a/src/com/android/car/messenger/core/interfaces/DataModel.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.interfaces; - -import androidx.lifecycle.LiveData; - -import androidx.annotation.NonNull; - -import com.android.car.messenger.common.Conversation; -import com.android.car.messenger.core.models.UserAccount; - -import java.util.Collection; - -/** - * This interface allows the UI to communicate with the host app. The methods provides the data and - * actions needed by the UI library. Message Interface Channel should be implemented by the host - * app. Method calls are done on the main thread. Extensive data gathering work should be delegated - * to a background thread and the UI library can be notified once the data is ready via the change - * listener. - */ -public interface DataModel { - - /** - * Get list of accounts. Here an account can refer to actual accounts or separate user accounts. - * Data will be separated in the UI by user accounts. - */ - @NonNull - LiveData<Collection<UserAccount>> getAccounts(); - - /** - * Get collection of conversations for the given account. - * - * @param userAccount The account to which data is being queried. This could be the subscription - * id matching a sim in multi-account setting or account id with multi-user account - */ - @NonNull - LiveData<Collection<Conversation>> getConversations(@NonNull UserAccount userAccount); - - /** - * Returns an observable conversation item, holding only unread messages. since the last known - * {@link UserAccount#getConnectionTime}. - * - * <p>If no unread messages are found for the conversation id, the live data emits no data. - */ - LiveData<Conversation> getUnreadMessages(); - - /** - * Called by UI to mute all notifications for this conversation - * - * @param conversationId The unique id for the conversation - * @param mute The requested mute action, false is to unmute, true is to mute - */ - void muteConversation(@NonNull String conversationId, boolean mute); - - /** - * Called by UI to mark conversation as read - * - * @param conversationId The unique id for the conversation - */ - void markAsRead(@NonNull String conversationId); - - /** - * Called by UI to send a message - * - * @param conversationId The phone number to send message - * @param message The desired message to send to conversation thread - */ - void sendMessage(@NonNull String conversationId, @NonNull String message); - - /** - * Called by UI to send a message to a phone number on a device - * - * @param accountId The user account/device id to send the message from - * @param phoneNumber The desired phone number to send message to - * @param message The desired message to send to conversation thread - */ - void sendMessage(int accountId, @NonNull String phoneNumber, @NonNull String message); - - /** - * Called by UI to send a message to a phone number on a device - * - * @param iccId The {@link UserAccount#getIccId()} belonging to the device/user account to send - * the message from - * @param phoneNumber The desired phone number to send message to - * @param message The desired message to send to conversation thread - */ - void sendMessage(@NonNull String iccId, @NonNull String phoneNumber, @NonNull String message); -} diff --git a/src/com/android/car/messenger/core/models/ConnectionStatus.java b/src/com/android/car/messenger/core/models/ConnectionStatus.java deleted file mode 100644 index 375dfde..0000000 --- a/src/com/android/car/messenger/core/models/ConnectionStatus.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.models; - -/** Connection status for a user account. */ -public enum ConnectionStatus { - /** - * Represents a state when the user account is fully setup to retrieve data. This could be a - * bluetooth connection or logged in account. - */ - CONNECTED, - /** - * Represents a state when the user account is disconnected or logged out. This could occur when - * a phone is disconnected or unpaired or a user is logged out from a messaging account - */ - DISCONNECTED, -} diff --git a/src/com/android/car/messenger/core/models/UserAccount.java b/src/com/android/car/messenger/core/models/UserAccount.java deleted file mode 100644 index 406d60a..0000000 --- a/src/com/android/car/messenger/core/models/UserAccount.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.models; - -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.time.Instant; - -/** - * A user account can represent a bluetooth connection to a phone sim or a user account. There can - * be more than one user accounts during a drive. The user id can be used to retrieve data for that - * specific account. The user name can be used to display the user the name of the account/account. - */ -public class UserAccount implements Parcelable { - private final int mId; - @Nullable private final String mIccId; - @NonNull private final String mName; - @NonNull private final Instant mConnectionTime; - - public UserAccount( - int id, @NonNull String name, @Nullable String iccId, @NonNull Instant connectionTime) { - mId = id; - mIccId = iccId; - mName = name; - mConnectionTime = connectionTime; - } - - protected UserAccount(Parcel in) { - this.mId = in.readInt(); - this.mIccId = in.readString(); - this.mName = in.readString(); - this.mConnectionTime = (Instant) in.readSerializable(); - } - - @NonNull - public static final Creator<UserAccount> CREATOR = - new Creator<UserAccount>() { - @Override - public UserAccount createFromParcel(@NonNull Parcel source) { - return new UserAccount(source); - } - - @Override - public UserAccount[] newArray(int size) { - return new UserAccount[size]; - } - }; - - /** - * The user id can be used to retrieve data for that specific account. - * - * @return the unique identifier for the user account - */ - public int getId() { - return mId; - } - - /** - * The IccId is a globally unique serial number—a one-of-a-kind signature that identifies the - * SIM card itself or bluetooth address of the account. - * - * <p>For device/account disambiguation for Contact db queries, this field maps to {@link - * android.provider.ContactsContract.RawContacts#ACCOUNT_NAME} in the Contacts database. - * - * @return The id or null, if not set - */ - @Nullable - public String getIccId() { - return mIccId; - } - - /** The display name for the account or account. */ - @NonNull - public String getName() { - return mName; - } - - /** Returns the {@link Instant} the car was connected to this {@link UserAccount} */ - @NonNull - public Instant getConnectionTime() { - return mConnectionTime; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeInt(this.mId); - dest.writeString(mIccId); - dest.writeString(this.mName); - dest.writeSerializable(this.mConnectionTime); - } -} diff --git a/src/com/android/car/messenger/core/service/MessengerService.java b/src/com/android/car/messenger/core/service/MessengerService.java deleted file mode 100644 index 60cbfe5..0000000 --- a/src/com/android/car/messenger/core/service/MessengerService.java +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.service; - -import static com.android.car.messenger.core.shared.MessageConstants.ACTION_DIRECT_SEND; -import static com.android.car.messenger.core.shared.MessageConstants.ACTION_MARK_AS_READ; -import static com.android.car.messenger.core.shared.MessageConstants.ACTION_MUTE; -import static com.android.car.messenger.core.shared.MessageConstants.ACTION_REPLY; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.Service; -import android.content.Intent; -import android.media.AudioAttributes; -import android.os.Binder; -import android.os.IBinder; -import android.provider.Settings; -import androidx.core.app.NotificationCompat; -import android.telephony.TelephonyManager; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.R; -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.shared.NotificationHandler; -import com.android.car.messenger.core.util.L; -import com.android.car.messenger.core.util.VoiceUtil; - -/** Service responsible for handling messaging events. */ -public class MessengerService extends Service { - /* ACTIONS */ - /** Used to start this service at boot-complete. Takes no arguments. */ - @NonNull public static final String ACTION_START = "com.android.car.messenger.ACTION_START"; - - /* EXTRAS */ - /* NOTIFICATIONS */ - @NonNull public static final String MESSAGE_CHANNEL_ID = "MESSAGE_CHANNEL_ID"; - @NonNull public static final String SILENT_MESSAGE_CHANNEL_ID = "SILENT_MESSAGE_CHANNEL_ID"; - @NonNull public static final String APP_RUNNING_CHANNEL_ID = "APP_RUNNING_CHANNEL_ID"; - private static final int SERVICE_STARTED_NOTIFICATION_ID = Integer.MAX_VALUE; - - /* Binding boilerplate */ - @NonNull private final IBinder mBinder = new LocalBinder(); - - /** Local Binder For {@link MessengerService} */ - public class LocalBinder extends Binder { - /** Returns {@link MessengerService} */ - @NonNull - public MessengerService getService() { - return MessengerService.this; - } - } - - @Override - @NonNull - public IBinder onBind(@NonNull Intent intent) { - return mBinder; - } - - @Override - public void onCreate() { - super.onCreate(); - L.d("MessengerService - onCreate"); - AppFactory.get() - .getDataModel() - .getUnreadMessages() - .observeForever(NotificationHandler::postOrRemoveNotification); - sendServiceRunningNotification(); - } - - private void sendServiceRunningNotification() { - NotificationManager notificationManager = getSystemService(NotificationManager.class); - - if (notificationManager == null) { - L.e("Failed to get NotificationManager instance"); - return; - } - - // Create notification channel for app running notification - { - NotificationChannel appRunningNotificationChannel = - new NotificationChannel( - APP_RUNNING_CHANNEL_ID, - getString(R.string.app_running_msg_notification_title), - NotificationManager.IMPORTANCE_LOW); - notificationManager.createNotificationChannel(appRunningNotificationChannel); - } - - // Create notification channel for notifications that should be posted silently in the - // notification center, without a heads up notification. - { - NotificationChannel silentNotificationChannel = - new NotificationChannel( - SILENT_MESSAGE_CHANNEL_ID, - getString(R.string.message_channel_description), - NotificationManager.IMPORTANCE_LOW); - notificationManager.createNotificationChannel(silentNotificationChannel); - } - - { - AudioAttributes attributes = - new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .build(); - NotificationChannel channel = - new NotificationChannel( - MESSAGE_CHANNEL_ID, - getString(R.string.message_channel_name), - NotificationManager.IMPORTANCE_HIGH); - channel.setDescription(getString(R.string.message_channel_description)); - channel.setSound(Settings.System.DEFAULT_NOTIFICATION_URI, attributes); - notificationManager.createNotificationChannel(channel); - } - - final Notification notification = - new NotificationCompat.Builder(this, APP_RUNNING_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_message) - .setContentTitle(getString(R.string.app_running_msg_notification_title)) - .setContentText(getString(R.string.app_running_msg_notification_content)) - .build(); - - startForeground(SERVICE_STARTED_NOTIFICATION_ID, notification); - } - - @Override - public void onDestroy() { - super.onDestroy(); - L.d("onDestroy"); - } - - @Override - public int onStartCommand(@Nullable Intent intent, int flags, int startId) { - final int result = START_STICKY; - - if (intent == null || intent.getAction() == null) { - return result; - } - - final String action = intent.getAction(); - switch (action) { - case ACTION_START: - // NO-OP - break; - case ACTION_REPLY: - VoiceUtil.voiceReply(intent); - break; - case ACTION_MUTE: - VoiceUtil.mute(intent); - break; - case ACTION_MARK_AS_READ: - VoiceUtil.markAsRead(intent); - break; - case ACTION_DIRECT_SEND: - VoiceUtil.directSend(intent); - break; - case TelephonyManager.ACTION_RESPOND_VIA_MESSAGE: - // Not currently supported. This was added to allow CarMessenger become the default - // SMS app. - break; - default: - L.w("Unsupported action: " + action); - } - return result; - } -} diff --git a/src/com/android/car/messenger/core/service/OnBootReceiver.java b/src/com/android/car/messenger/core/service/OnBootReceiver.java deleted file mode 100644 index fdadfe0..0000000 --- a/src/com/android/car/messenger/core/service/OnBootReceiver.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.service; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -import androidx.annotation.NonNull; - -import com.android.car.messenger.core.util.L; - -/** - * Receiver that listens for on boot completed broadcast intent and starts {@link MessengerService}. - */ -public class OnBootReceiver extends BroadcastReceiver { - - @Override - public void onReceive(@NonNull Context context, @NonNull Intent intent) { - L.d("BootReceiver received!"); - if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { - context.startService(new Intent(context, MessengerService.class)); - } - } -} diff --git a/src/com/android/car/messenger/core/shared/MessageConstants.java b/src/com/android/car/messenger/core/shared/MessageConstants.java deleted file mode 100644 index 97f6f88..0000000 --- a/src/com/android/car/messenger/core/shared/MessageConstants.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.shared; - -import androidx.annotation.NonNull; - -/** Constants. */ -public final class MessageConstants { - - private MessageConstants() {} - - /** See {@link android.content.res.Resources#getIdentifier(String, String, String)} */ - public static final int INVALID_RES_ID = 0; - - /** - * The key in Default Shared Preferences that maps to a list of conversation ids that are muted - */ - @NonNull public static final String KEY_MUTED_CONVERSATIONS = "KEY_MUTED_CONVERSATIONS"; - - /** - * This is added as an extra in the {@link com.android.car.messenger.common.Conversation} to - * indicate what the last reply timestamp is, if any - */ - @NonNull public static final String LAST_REPLY_TIMESTAMP_EXTRA = "LAST_REPLY_TIMESTAMP_EXTRA"; - - /** Used to reply to message. */ - @NonNull public static final String ACTION_REPLY = "ACTION_REPLY"; - - /** Used to mark a conversation as read */ - @NonNull public static final String ACTION_MARK_AS_READ = "ACTION_MARK_AS_READ"; - - /** Used to direct send to a specified phone number */ - @NonNull public static final String ACTION_DIRECT_SEND = "ACTION_DIRECT_SEND"; - - /** Used to mute a conversation */ - @NonNull public static final String ACTION_MUTE = "ACTION_MUTE"; - - /* EXTRAS */ - /** Key under which the {@link String} is provided. */ - @NonNull public static final String EXTRA_CONVERSATION_KEY = "EXTRA_CONVERSATION_KEY"; -} diff --git a/src/com/android/car/messenger/core/shared/NotificationHandler.java b/src/com/android/car/messenger/core/shared/NotificationHandler.java deleted file mode 100644 index c326c1f..0000000 --- a/src/com/android/car/messenger/core/shared/NotificationHandler.java +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.shared; - -import android.app.Notification; -import android.app.NotificationManager; -import android.content.Context; -import android.service.notification.StatusBarNotification; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.assist.payloadhandlers.ConversationPayloadHandler; -import com.android.car.messenger.R; -import com.android.car.messenger.common.Conversation; -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.service.MessengerService; -import com.android.car.messenger.core.util.VoiceUtil; - -/** Useful notification handler for posting messages */ -public class NotificationHandler { - @NonNull - private static final String GROUP_TAP_TO_READ_NOTIFICATION = - "com.android.car.messenger.TAP_TO_READ"; - - private static final int TAP_TO_READ_SBN_ATTEMPT_LIMIT = 3; - - private NotificationHandler() {} - - /** Posts, removes or updates a notification based on a conversation */ - public static void postOrRemoveNotification(Conversation conversation) { - if (conversation.isMuted()) { - removeNotification(conversation); - } else { - postNotification(conversation); - } - } - - /* Posts or updates a notification based on a conversation */ - private static void postNotification(@NonNull Conversation conversation) { - Conversation tapToReadConversation = VoiceUtil.createTapToReadConversation(conversation); - Context context = AppFactory.get().getContext(); - NotificationManager notificationManager = - context.getSystemService(NotificationManager.class); - String channelId = MessengerService.MESSAGE_CHANNEL_ID; - Notification notification = - ConversationPayloadHandler.createNotificationFromConversation( - context, channelId, tapToReadConversation, R.drawable.ic_message, null); - - notificationManager.notify(tapToReadConversation.getId().hashCode(), notification); - } - - /** - * Posts a notification in the foreground for Tap To Read - * - * <p>This is useful as legacy digital assistant implementations of Tap To Read require a {@link - * StatusBarNotification} in order to fulfill a tap to read request. - * - * <p>This notification is invisible to the user but accessible by digital assistants. - * - * @return the StatusBarNotification posted by the system for this notification, or null if not - * found after a limited attempt at retrieval - */ - @Nullable - public static StatusBarNotification postNotificationForLegacyTapToRead( - @NonNull Conversation tapToReadConversation) { - Context context = AppFactory.get().getContext(); - // cancel any other notifications within group. - // There should be only notification in group at a time. - cancelAllTapToReadNotifications(context); - // Post as a foreground service: - // Foreground notifications by system apps with low priority - // are hidden from user view, which is desired - Notification notification = - ConversationPayloadHandler.createNotificationFromConversation( - context, - MessengerService.APP_RUNNING_CHANNEL_ID, - tapToReadConversation, - context.getApplicationInfo().icon, - GROUP_TAP_TO_READ_NOTIFICATION); - int id = (GROUP_TAP_TO_READ_NOTIFICATION + tapToReadConversation.getId()).hashCode(); - NotificationManager notificationManager = - context.getSystemService(NotificationManager.class); - notificationManager.notify(id, notification); - - // attempt to retrieve the status bar notification based on the notification - // limit attempts - int tries = 0; - StatusBarNotification sbn; - do { - sbn = findSBN(notificationManager, id); - tries++; - } while (sbn == null && tries < TAP_TO_READ_SBN_ATTEMPT_LIMIT); - return sbn; - } - - /** Cancels all Tap To Read Notifications */ - public static void cancelAllTapToReadNotifications(@NonNull Context context) { - NotificationManager notificationManager = - context.getSystemService(NotificationManager.class); - for (StatusBarNotification sbn : notificationManager.getActiveNotifications()) { - if (GROUP_TAP_TO_READ_NOTIFICATION.equals(sbn.getNotification().getGroup())) { - notificationManager.cancel(sbn.getId()); - } - } - } - - /** Returns the {@link StatusBarNotification} with desired id, or null if none found */ - private static StatusBarNotification findSBN( - @NonNull NotificationManager notificationManager, int id) { - for (StatusBarNotification sbn : notificationManager.getActiveNotifications()) { - if (sbn.getId() == id) { - return sbn; - } - } - return null; - } - - /* Removes a notification based on a conversation */ - private static void removeNotification(@NonNull Conversation conversation) { - Context context = AppFactory.get().getContext(); - NotificationManager notificationManager = - context.getSystemService(NotificationManager.class); - notificationManager.cancel(conversation.getId().hashCode()); - } -} diff --git a/src/com/android/car/messenger/core/ui/base/MessageListBaseFragment.java b/src/com/android/car/messenger/core/ui/base/MessageListBaseFragment.java deleted file mode 100644 index e585dd1..0000000 --- a/src/com/android/car/messenger/core/ui/base/MessageListBaseFragment.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.ui.base; - -import android.content.Context; -import android.os.Bundle; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.CallSuper; -import androidx.annotation.LayoutRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; - -import com.android.car.messenger.R; -import com.android.car.messenger.core.ui.shared.LoadingFrameLayout; -import com.android.car.ui.baselayout.Insets; -import com.android.car.ui.baselayout.InsetsChangedListener; -import com.android.car.ui.core.CarUi; -import com.android.car.ui.recyclerview.CarUiRecyclerView; -import com.android.car.ui.toolbar.Toolbar.State; -import com.android.car.ui.toolbar.ToolbarController; - -/** - * Base fragment that inflates a {@link RecyclerView}. It handles the top offset for first row item - * so the list can scroll underneath the top bar. - */ -public class MessageListBaseFragment extends Fragment implements InsetsChangedListener { - - @NonNull protected LoadingFrameLayout mLoadingFrameLayout; - @NonNull private CarUiRecyclerView mRecyclerView; - @Nullable protected ToolbarController mToolbar; - - @Override - public View onCreateView( - LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(getLayoutResource(), container, false); - mLoadingFrameLayout = view.findViewById(R.id.loading_frame_layout); - mRecyclerView = view.requireViewById(R.id.list_view); - mRecyclerView.setLayoutManager(createLayoutManager()); - return view; - } - - /** Layout resource for this fragment. It must contains a RecyclerView with id list_view. */ - @LayoutRes - protected int getLayoutResource() { - return R.layout.loading_list_fragment; - } - - /** - * Creates the layout manager for the recycler view. Default is a {@link LinearLayoutManager}. - * Child inheriting from this fragment can override to create a different layout manager. - */ - @NonNull - protected RecyclerView.LayoutManager createLayoutManager() { - return new LinearLayoutManager(getContext()); - } - - /** Returns the {@link RecyclerView} instance. */ - @NonNull - protected CarUiRecyclerView getRecyclerView() { - return mRecyclerView; - } - - @CallSuper - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - mToolbar = CarUi.getToolbar(requireActivity()); - // Null check for unit tests to pass - if (mToolbar != null) { - setupToolbar(mToolbar); - } - Insets insets = CarUi.getInsets(requireActivity()); - // Null check for unit tests to pass - if (insets != null) { - onCarUiInsetsChanged(insets); - } - } - - /** Customizes the tool bar. Can be overridden in subclasses. */ - protected void setupToolbar(@NonNull ToolbarController toolbar) { - Context context = getContext(); - if (context == null) { - return; - } - toolbar.setTitle(R.string.app_name); - toolbar.setState(getToolbarState()); - toolbar.setLogo( - getToolbarState() == State.HOME - ? ContextCompat.getDrawable(context, context.getApplicationInfo().icon) - : null); - } - - @NonNull - protected State getToolbarState() { - return State.HOME; - } - - @Override - public void onCarUiInsetsChanged(Insets insets) { - int listTopPadding = - requireContext().getResources().getDimensionPixelSize(R.dimen.list_top_padding); - mRecyclerView.setPadding(0, insets.getTop() + listTopPadding, 0, insets.getBottom()); - } -} diff --git a/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemAdapter.java b/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemAdapter.java deleted file mode 100644 index b54c82c..0000000 --- a/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemAdapter.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.ui.conversationlist; - -import android.content.Context; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; - -import com.android.car.messenger.R; -import com.android.car.messenger.common.Conversation; - -import java.util.ArrayList; -import java.util.List; - -/** Adapter for conversation log list. */ -public class ConversationItemAdapter extends RecyclerView.Adapter<ConversationItemViewHolder> { - /** Item Click listener for when an item on the UI is tapped */ - public interface OnConversationItemClickListener { - /** Callback to start tap to read voice interaction for conversation item */ - void onConversationItemClicked(@NonNull Conversation conversation); - /** Callback to start tap to reply voice interaction for conversation item */ - void onReplyIconClicked(@NonNull Conversation conversation); - } - - @NonNull private final List<UIConversationItem> mUIConversationItems = new ArrayList<>(); - @NonNull private final OnConversationItemClickListener mOnConversationItemClickListener; - - public ConversationItemAdapter( - @NonNull OnConversationItemClickListener onConversationItemClickListener) { - mOnConversationItemClickListener = onConversationItemClickListener; - } - - /** Sets conversation logs. */ - public void setConversationLogItems(@NonNull List<UIConversationItem> uIConversationItems) { - mUIConversationItems.clear(); - mUIConversationItems.addAll(uIConversationItems); - notifyDataSetChanged(); - } - - @NonNull - @Override - public ConversationItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - Context context = parent.getContext(); - View rootView = - LayoutInflater.from(context) - .inflate(R.layout.conversation_list_item, parent, false); - return new ConversationItemViewHolder(rootView, mOnConversationItemClickListener); - } - - @Override - public void onBindViewHolder(@NonNull ConversationItemViewHolder holder, int position) { - holder.bind(mUIConversationItems.get(position)); - } - - @Override - public void onViewRecycled(@NonNull ConversationItemViewHolder holder) { - holder.recycle(); - } - - @Override - public int getItemCount() { - return mUIConversationItems.size(); - } -} diff --git a/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemViewHolder.java b/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemViewHolder.java deleted file mode 100644 index 09f071f..0000000 --- a/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemViewHolder.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.ui.conversationlist; - -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffColorFilter; -import androidx.recyclerview.widget.RecyclerView; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; - -import com.android.car.messenger.R; -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.interfaces.DataModel; -import com.android.car.messenger.core.ui.conversationlist.ConversationItemAdapter.OnConversationItemClickListener; -import com.android.car.messenger.core.ui.shared.CircularOutputlineProvider; -import com.android.car.messenger.core.ui.shared.ViewUtils; - -/** - * {@link RecyclerView.ViewHolder} for Conversation Log item, responsible for presenting and - * resetting the UI on recycle. - */ -public class ConversationItemViewHolder extends RecyclerView.ViewHolder { - @NonNull private final DataModel mDataModel; - - @NonNull - private final ConversationItemAdapter.OnConversationItemClickListener - mOnConversationItemClickListener; - - @NonNull private final View mPlayMessageTouchView; - @NonNull private final ImageView mAvatarView; - @NonNull private final TextView mTitleView; - @NonNull private final TextView mTimeTextView; - @NonNull private final TextView mTextView; - @NonNull private final TextView mDotSeparatorView; - @NonNull private final ImageView mSubtitleIconView; - @NonNull private final ImageView mMuteActionButton; - @NonNull private final View mReplyActionButton; - @NonNull private final View mUnreadIconIndicator; - @NonNull private final View mDivider; - - /** Conversation Item View Holder constructor */ - public ConversationItemViewHolder( - @NonNull View itemView, - @NonNull OnConversationItemClickListener onConversationItemClickListener) { - super(itemView); - mOnConversationItemClickListener = onConversationItemClickListener; - mPlayMessageTouchView = itemView.findViewById(R.id.play_action_touch_view); - mAvatarView = itemView.findViewById(R.id.icon); - mTitleView = itemView.findViewById(R.id.title); - mTimeTextView = itemView.findViewById(R.id.time_text); - mTextView = itemView.findViewById(R.id.text); - mDotSeparatorView = itemView.findViewById(R.id.dot); - mUnreadIconIndicator = itemView.findViewById(R.id.unread_indicator); - mSubtitleIconView = itemView.findViewById(R.id.last_action_icon_view); - mMuteActionButton = itemView.findViewById(R.id.mute_action_button); - mReplyActionButton = itemView.findViewById(R.id.reply_action_button); - mDivider = itemView.findViewById(R.id.divider); - mAvatarView.setOutlineProvider(CircularOutputlineProvider.get()); - mUnreadIconIndicator.setOutlineProvider(CircularOutputlineProvider.get()); - mDataModel = AppFactory.get().getDataModel(); - } - - /** Binds the view holder with relevant data. */ - public void bind(@NonNull UIConversationItem uiData) { - mTitleView.setText(uiData.getTitle()); - mTimeTextView.setText(uiData.getReadableTime()); - mTextView.setText(uiData.getSubtitle()); - mAvatarView.setImageDrawable(uiData.getAvatar()); - mPlayMessageTouchView.setOnClickListener(null); - mSubtitleIconView.setImageDrawable(uiData.getSubtitleIcon()); - boolean showDotSeparatorSubtitle = - !uiData.getReadableTime().isEmpty() && !uiData.getSubtitle().isEmpty(); - ViewUtils.setVisible(mDotSeparatorView, showDotSeparatorSubtitle); - ViewUtils.setVisible(mSubtitleIconView, uiData.getSubtitleIcon() != null); - setUpActionButton(uiData); - setUpTextAppearance(uiData); - updateMuteButton(uiData.isMuted()); - itemView.setVisibility(View.VISIBLE); - } - - private void setUpTextAppearance(@NonNull UIConversationItem uiData) { - if (uiData.shouldUseUnreadTheme()) { - mTitleView.setTextAppearance(R.style.TextAppearance_MessageHistoryUnreadTitle); - mTimeTextView.setTextAppearance(R.style.TextAppearance_MessageHistoryUnreadSubtitle); - mTextView.setTextAppearance(R.style.TextAppearance_MessageHistoryUnreadSubtitle); - mDotSeparatorView.setTextAppearance( - R.style.TextAppearance_MessageHistoryUnreadSubtitle); - ViewUtils.setVisible(mUnreadIconIndicator, /* visible= */ true); - } else { - mTitleView.setTextAppearance(R.style.TextAppearance_MessageHistoryTitle); - mTimeTextView.setTextAppearance(R.style.TextAppearance_MessageHistorySubtitle); - mTextView.setTextAppearance(R.style.TextAppearance_MessageHistorySubtitle); - mDotSeparatorView.setTextAppearance(R.style.TextAppearance_MessageHistorySubtitle); - ViewUtils.setVisible(mUnreadIconIndicator, /* visible= */ false); - } - } - - private void updateMuteButton(boolean isMuted) { - @ColorInt int color = isMuted ? Color.RED : Color.WHITE; - PorterDuffColorFilter porterDuffColorFilter = - new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP); - mMuteActionButton.getDrawable().setColorFilter(porterDuffColorFilter); - } - - /** Recycles views. */ - public void recycle() { - mPlayMessageTouchView.setOnClickListener(null); - } - - private void setUpActionButton(@NonNull UIConversationItem uiData) { - ViewUtils.setVisible(mDivider, uiData.shouldShowReplyIcon() || uiData.shouldShowMuteIcon()); - ViewUtils.setVisible(mMuteActionButton, uiData.shouldShowMuteIcon()); - ViewUtils.setVisible(mReplyActionButton, uiData.shouldShowReplyIcon()); - if (uiData.shouldShowReplyIcon()) { - mReplyActionButton.setEnabled(true); - } - if (uiData.shouldShowReplyIcon()) { - mMuteActionButton.setEnabled(true); - } - - mPlayMessageTouchView.setOnClickListener( - view -> - mOnConversationItemClickListener.onConversationItemClicked( - uiData.getConversation())); - - mReplyActionButton.setOnClickListener( - view -> - mOnConversationItemClickListener.onReplyIconClicked( - uiData.getConversation())); - mMuteActionButton.setOnClickListener( - view -> { - boolean mute = !uiData.isMuted(); - mDataModel.muteConversation(uiData.getConversationId(), mute); - }); - } -} diff --git a/src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java b/src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java deleted file mode 100644 index 4bbbace..0000000 --- a/src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.car.messenger.core.ui.conversationlist; - -import android.app.Activity; -import androidx.lifecycle.ViewModelProvider; -import android.content.Intent; -import android.os.Bundle; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.R; -import com.android.car.messenger.common.Conversation; -import com.android.car.messenger.core.models.ConnectionStatus; -import com.android.car.messenger.core.models.UserAccount; -import com.android.car.messenger.core.shared.MessageConstants; -import com.android.car.messenger.core.ui.base.MessageListBaseFragment; -import com.android.car.messenger.core.util.L; -import com.android.car.messenger.core.util.VoiceUtil; -import com.android.car.ui.toolbar.MenuItem; - -import java.util.ArrayList; - -/** Fragment for Message History/Conversation Metadata List */ -public class ConversationListFragment extends MessageListBaseFragment - implements ConversationItemAdapter.OnConversationItemClickListener { - @NonNull - private static final String BLUETOOTH_SETTING_ACTION = "android.settings.BLUETOOTH_SETTINGS"; - - @NonNull - private static final String BLUETOOTH_SETTING_CATEGORY = "android.intent.category.DEFAULT"; - - @NonNull private static final String KEY_USER_ACCOUNT = "KEY_USER_ACCOUNT"; - @Nullable private ConversationItemAdapter mConversationItemAdapter; - @Nullable private UserAccount mUserAccount; - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - if (getArguments() != null) { - mUserAccount = getArguments().getParcelable(KEY_USER_ACCOUNT); - } - - // Don't recreate the adapter if we already have one, so that the list items - // will display immediately upon the view being recreated. - L.d("In View Created, about to load message data"); - if (mConversationItemAdapter == null) { - mConversationItemAdapter = - new ConversationItemAdapter(/* onConversationItemClickListener= */ this); - } - getRecyclerView().setAdapter(mConversationItemAdapter); - ConversationListViewModel viewModel = - new ViewModelProvider(this).get(ConversationListViewModel.class); - - viewModel - .getConversations(mUserAccount) - .observe( - this, - conversationLog -> { - if (conversationLog.getConnectionStatus() - != ConnectionStatus.CONNECTED) { - ConnectionStatus connectionStatus = - conversationLog.getConnectionStatus(); - if (connectionStatus == ConnectionStatus.DISCONNECTED) { - // we currently only support bluetooth disconnect. - // Future work can add other types of disconnect such as login - handleBluetoothDisconnected(); - } - } else if (conversationLog.isLoading()) { - mLoadingFrameLayout.showLoading(); - } else if (conversationLog.getData() == null - || conversationLog.getData().isEmpty()) { - mLoadingFrameLayout.showEmpty( - MessageConstants.INVALID_RES_ID, - R.string.no_new_messages, - MessageConstants.INVALID_RES_ID); - setMenuItems(); - } else { - mConversationItemAdapter.setConversationLogItems( - conversationLog.getData()); - mLoadingFrameLayout.showContent(); - setMenuItems(); - } - }); - } - - private void handleBluetoothDisconnected() { - Intent launchIntent = new Intent(); - launchIntent.setAction(BLUETOOTH_SETTING_ACTION); - launchIntent.addCategory(BLUETOOTH_SETTING_CATEGORY); - mLoadingFrameLayout.showError( - MessageConstants.INVALID_RES_ID, - R.string.bluetooth_disconnected, - MessageConstants.INVALID_RES_ID, - R.string.connect_bluetooth_button_text, - v -> startActivity(launchIntent), - true); - } - - private void setMenuItems() { - Activity activity = getActivity(); - if (activity == null || mUserAccount == null || mToolbar == null) { - return; - } - MenuItem newMessageButton = - new MenuItem.Builder(activity) - .setIcon(R.drawable.car_ui_icon_edit) - .setTinted(true) - .setShowIconAndTitle(true) - .setTitle(R.string.new_message) - .setOnClickListener( - item -> - VoiceUtil.voiceRequestGenericCompose( - activity, mUserAccount)) - .build(); - - ArrayList<MenuItem> menuItems = new ArrayList<>(); - menuItems.add(newMessageButton); - mToolbar.setMenuItems(menuItems); - } - - @Override - public void onConversationItemClicked(@NonNull Conversation conversation) { - VoiceUtil.voiceRequestReadConversation(requireActivity(), conversation); - } - - @Override - public void onReplyIconClicked(@NonNull Conversation conversation) { - VoiceUtil.voiceRequestReplyConversation(requireActivity(), conversation); - } - - /** - * Get instance of Conversation Log fragment - * - * @param userAccount the user device info data will be retrieved for. If null, this fragment - * shows a disconnect page - * @return ConversationLogFragment instance - */ - public static ConversationListFragment newInstance(@Nullable UserAccount userAccount) { - Bundle args = new Bundle(); - args.putParcelable(KEY_USER_ACCOUNT, userAccount); - ConversationListFragment fragment = new ConversationListFragment(); - fragment.setArguments(args); - return fragment; - } - - /** - * Get unique fragment tag for fragment loading data for user device - * - * @param userAccount the user device info data will be retrieved for. - * @return unique fragment tag - */ - public static String getFragmentTag(@Nullable UserAccount userAccount) { - int id = userAccount == null ? -1 : userAccount.getId(); - return ConversationListFragment.class.getName() + id; - } -} diff --git a/src/com/android/car/messenger/core/ui/conversationlist/ConversationListViewModel.java b/src/com/android/car/messenger/core/ui/conversationlist/ConversationListViewModel.java deleted file mode 100644 index 8ac0851..0000000 --- a/src/com/android/car/messenger/core/ui/conversationlist/ConversationListViewModel.java +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.ui.conversationlist; - -import android.annotation.SuppressLint; -import android.app.Application; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MediatorLiveData; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.interfaces.DataModel; -import com.android.car.messenger.core.models.UserAccount; - -import java.util.List; -import java.util.stream.Collectors; - -/** View model for ConversationLogFragment which provides message history live data. */ -public class ConversationListViewModel extends AndroidViewModel { - @SuppressLint("StaticFieldLeak") - @NonNull - private final DataModel mDataModel; - - @Nullable private UserAccount mUserAccount; - @Nullable private LiveData<UIConversationLog> mUIConversationLogLiveData; - - public ConversationListViewModel(@NonNull Application application) { - super(application); - mDataModel = AppFactory.get().getDataModel(); - } - - /** - * Gets an observable {@link UIConversationLog} for the connected account - * - * <p>The observable emits the following: - {@link UIConversationLog#isLoading()} returns true - * when loading - {@link UIConversationLog#getConnectionStatus()} returns appropriate connection - * status, such as connected or disconnected - {@link UIConversationLog#getData()} returns a - * non-null list of {@link UIConversationItem}, or empty if no items found - */ - @NonNull - public LiveData<UIConversationLog> getConversations(@Nullable UserAccount userAccount) { - if (userAccount == null) { - MediatorLiveData<UIConversationLog> mutableLiveData = new MediatorLiveData<>(); - mutableLiveData.postValue(UIConversationLog.getDisconnectedState()); - return mutableLiveData; - } - if (mUserAccount != null - && mUserAccount.getId() == userAccount.getId() - && mUIConversationLogLiveData != null) { - return mUIConversationLogLiveData; - } - mUserAccount = userAccount; - mUIConversationLogLiveData = createUIConversationLog(mUserAccount); - return mUIConversationLogLiveData; - } - - private LiveData<UIConversationLog> createUIConversationLog(@NonNull UserAccount userAccount) { - MediatorLiveData<UIConversationLog> mutableLiveData = new MediatorLiveData<>(); - mutableLiveData.postValue(UIConversationLog.getLoadingState()); - mutableLiveData.addSource( - mDataModel.getConversations(userAccount), - list -> { - List<UIConversationItem> data = - list.stream() - .map(UIConversationItemConverter::convertToUIConversationItem) - .collect(Collectors.toList()); - UIConversationLog log = UIConversationLog.getLoadedState(data); - mutableLiveData.postValue(log); - }); - return mutableLiveData; - } -} diff --git a/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItem.java b/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItem.java deleted file mode 100644 index bdd71e7..0000000 --- a/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItem.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.ui.conversationlist; - -import android.graphics.drawable.Drawable; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.common.Conversation; - -/** UI Conversation Item represents the UI layer for a Conversation Item row */ -public class UIConversationItem { - - @NonNull String mConversationId; - @NonNull String mTitle; - @NonNull String mSubtitle; - @Nullable Drawable mSubtitleIcon; - @NonNull String mReadableTime; - @Nullable Drawable mAvatar; - boolean mIsMuted; - boolean mShowMuteIcon; - boolean mShowReplyIcon; - boolean mUseUnreadTheme; - @NonNull Conversation mConversation; - - public UIConversationItem( - @NonNull String conversationId, - @NonNull String title, - @NonNull String subtitle, - @Nullable Drawable subtitleIcon, - @NonNull String readableTime, - @Nullable Drawable avatar, - boolean showMuteIcon, - boolean showReplyIcon, - boolean useUnreadTheme, - boolean isMuted, - @NonNull Conversation conversation) { - this.mConversationId = conversationId; - this.mTitle = title; - this.mSubtitle = subtitle; - this.mSubtitleIcon = subtitleIcon; - this.mReadableTime = readableTime; - this.mAvatar = avatar; - this.mShowMuteIcon = showMuteIcon; - this.mShowReplyIcon = showReplyIcon; - this.mUseUnreadTheme = useUnreadTheme; - this.mIsMuted = isMuted; - this.mConversation = conversation; - } - - /** Returns conversation id */ - @NonNull - public String getConversationId() { - return mConversationId; - } - - /** Returns human readable title for conversation */ - @NonNull - public String getTitle() { - return mTitle; - } - - /** Returns subtitle for the conversation */ - @NonNull - public String getSubtitle() { - return mSubtitle; - } - - /** - * Returns icon to show by the corner of the subtitle. This can be null if nothing should be - * shown. - */ - @Nullable - public Drawable getSubtitleIcon() { - return mSubtitleIcon; - } - - /** Gets the human readable time in hh::mm */ - @NonNull - public String getReadableTime() { - return mReadableTime; - } - - /** Returns the avatar for the conversation */ - @Nullable - public Drawable getAvatar() { - return mAvatar; - } - - /** Returns true, if mute icon should be shown, false otherwise */ - public boolean shouldShowMuteIcon() { - return mShowMuteIcon; - } - - /** Returns true, if conversation is muted or false otherwise */ - public boolean isMuted() { - return mIsMuted; - } - - /** Returns true, if reply icon should be shown, false otherwise */ - public boolean shouldShowReplyIcon() { - return mShowReplyIcon; - } - - /** Returns true, if unread theme should be used, false otherwise */ - public boolean shouldUseUnreadTheme() { - return mUseUnreadTheme; - } - - /** Returns the conversation object */ - @NonNull - public Conversation getConversation() { - return mConversation; - } -} diff --git a/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItemConverter.java b/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItemConverter.java deleted file mode 100644 index 94f597d..0000000 --- a/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItemConverter.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.ui.conversationlist; - -import android.content.Context; -import android.graphics.drawable.Drawable; -import android.text.format.DateFormat; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.R; -import com.android.car.messenger.common.Conversation; -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.util.ConversationUtil; - -import java.util.Objects; - -/** Util class that converts Conversation Item to UIConversationItem */ -public class UIConversationItemConverter { - - private UIConversationItemConverter() {} - - /** Converts Conversation Item to UIConversationItem */ - public static UIConversationItem convertToUIConversationItem(Conversation conversation) { - Context context = AppFactory.get().getContext(); - boolean isUnread = conversation.getUnreadCount() > 0; - long timestamp = ConversationUtil.getConversationTimestamp(conversation); - boolean isReplied = ConversationUtil.isReplied(conversation); - - String subtitle = ""; - Drawable subtitleIcon = null; - if (isReplied) { - subtitle = context.getString(R.string.replied); - subtitleIcon = context.getDrawable(R.drawable.car_ui_icon_reply); - } else if (isUnread) { - subtitle = getNumberOfUnreadMessages(context, conversation.getUnreadCount()); - subtitleIcon = context.getDrawable(R.drawable.ic_play); - } - - return new UIConversationItem( - conversation.getId(), - Objects.requireNonNull(conversation.getConversationTitle()), - subtitle, - subtitleIcon, - toHumanDisplay(timestamp), - getConversationAvatar(context, conversation), - /* showMuteIcon= */ true, - /* showReplyIcon= */ true, - isUnread, - conversation.isMuted(), - conversation); - } - - @NonNull - private static String getNumberOfUnreadMessages( - @NonNull Context context, int noOfUnreadMessages) { - if (noOfUnreadMessages == 1) { - return context.getResources().getQuantityString(R.plurals.new_message, 1); - } else { - return context.getResources() - .getQuantityString( - R.plurals.new_message, noOfUnreadMessages, noOfUnreadMessages); - } - } - - @NonNull - private static String toHumanDisplay(long timeInMillis) { - String delegate = "hh:mm aaa"; - return (String) DateFormat.format(delegate, timeInMillis); - } - - @Nullable - private static Drawable getConversationAvatar( - @NonNull Context context, @NonNull Conversation conversation) { - return (conversation.getConversationIcon() != null) - ? conversation.getConversationIcon().loadDrawable(context) - : null; - } -} diff --git a/src/com/android/car/messenger/core/ui/conversationlist/UIConversationLog.java b/src/com/android/car/messenger/core/ui/conversationlist/UIConversationLog.java deleted file mode 100644 index 15f315f..0000000 --- a/src/com/android/car/messenger/core/ui/conversationlist/UIConversationLog.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.ui.conversationlist; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.core.models.ConnectionStatus; - -import java.util.ArrayList; -import java.util.List; - -/** UI Data Model for presenting a log of conversation items */ -public class UIConversationLog { - - @NonNull private ConnectionStatus mConnectionStatus = ConnectionStatus.DISCONNECTED; - private boolean mIsLoading = false; - @Nullable private List<UIConversationItem> mData = new ArrayList<>(); - - public static UIConversationLog getDefault() { - return new UIConversationLog(); - } - - public static UIConversationLog getDisconnectedState() { - return new UIConversationLog( - ConnectionStatus.DISCONNECTED, /* isLoading= */ false, /* list= */ null); - } - - public static UIConversationLog getLoadingState() { - return new UIConversationLog( - ConnectionStatus.CONNECTED, /* isLoading= */ true, new ArrayList<>()); - } - - /** Get Loaded State */ - public static UIConversationLog getLoadedState(@NonNull List<UIConversationItem> data) { - return new UIConversationLog(ConnectionStatus.CONNECTED, /* isLoading= */ false, data); - } - - private UIConversationLog() {} - - public UIConversationLog( - @NonNull ConnectionStatus connectionStatus, - boolean isLoading, - @Nullable List<UIConversationItem> list) { - mConnectionStatus = connectionStatus; - mIsLoading = isLoading; - mData = list; - } - - @NonNull - public ConnectionStatus getConnectionStatus() { - return mConnectionStatus; - } - - public boolean isLoading() { - return mIsLoading; - } - - @Nullable - public List<UIConversationItem> getData() { - return mData; - } -} diff --git a/src/com/android/car/messenger/core/ui/launcher/MessageLauncherActivity.java b/src/com/android/car/messenger/core/ui/launcher/MessageLauncherActivity.java deleted file mode 100644 index 248ac29..0000000 --- a/src/com/android/car/messenger/core/ui/launcher/MessageLauncherActivity.java +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.ui.launcher; - -import androidx.lifecycle.ViewModelProvider; -import android.os.Bundle; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; - -import androidx.annotation.NonNull; - -import com.android.car.messenger.core.models.UserAccount; -import com.android.car.messenger.core.ui.conversationlist.ConversationListFragment; -import com.android.car.messenger.core.util.L; -import com.android.car.ui.baselayout.Insets; -import com.android.car.ui.baselayout.InsetsChangedListener; - -/** - * This is the launcher activity for the messaging app. This first routes to{@link - * ConversationListFragment} or displays an error when no {@link UserAccount} are found. - */ -public class MessageLauncherActivity extends FragmentActivity implements InsetsChangedListener { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - MessageLauncherViewModel viewModel = - new ViewModelProvider(this).get(MessageLauncherViewModel.class); - - L.d("In onCreate: MessageLauncher"); - viewModel - .getAccounts() - .observe( - this, - accounts -> { - L.d("Total number of accounts: " + accounts.size()); - // First version only takes one device until multi-account support is - // added - UserAccount primaryAccount = - !accounts.isEmpty() ? accounts.get(0) : null; - String fragmentTag = - ConversationListFragment.getFragmentTag(primaryAccount); - Fragment fragment = - getSupportFragmentManager().findFragmentByTag(fragmentTag); - if (fragment == null) { - fragment = ConversationListFragment.newInstance(primaryAccount); - } - setContentFragment(fragment, fragmentTag); - }); - } - - private void setContentFragment(Fragment fragment, String fragmentTag) { - getSupportFragmentManager().executePendingTransactions(); - while (getSupportFragmentManager().getBackStackEntryCount() > 0) { - getSupportFragmentManager().popBackStackImmediate(); - } - pushContentFragment(fragment, fragmentTag); - } - - private void pushContentFragment( - @NonNull Fragment topContentFragment, @NonNull String fragmentTag) { - getSupportFragmentManager() - .beginTransaction() - .replace(android.R.id.content, topContentFragment, fragmentTag) - .addToBackStack(fragmentTag) - .commit(); - } - - @Override - public void onCarUiInsetsChanged(Insets insets) { - // Do nothing, this is just a marker that we will handle the insets in fragments. - // This is only necessary because the fragments are not immediately added to the - // activity when calling .commit() - } -} diff --git a/src/com/android/car/messenger/core/ui/launcher/MessageLauncherViewModel.java b/src/com/android/car/messenger/core/ui/launcher/MessageLauncherViewModel.java deleted file mode 100644 index 4b4d96a..0000000 --- a/src/com/android/car/messenger/core/ui/launcher/MessageLauncherViewModel.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.ui.launcher; - -import android.app.Application; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.Transformations; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.interfaces.DataModel; -import com.android.car.messenger.core.models.UserAccount; - -import java.util.List; -import java.util.stream.Collectors; - -/** View model for MessageLauncherActivity */ -public class MessageLauncherViewModel extends AndroidViewModel { - @NonNull private final DataModel mDataSource; - @Nullable private LiveData<List<UserAccount>> mAccountsLiveData; - // We currently only support the primary account until multi-account support is added - private static final int DEVICE_LIMIT = 1; - - public MessageLauncherViewModel(@NonNull Application application) { - super(application); - mDataSource = AppFactory.get().getDataModel(); - } - - /** Get observable data with list of accounts/user accounts */ - @NonNull - public LiveData<List<UserAccount>> getAccounts() { - if (mAccountsLiveData == null) { - mAccountsLiveData = getAccountList(); - } - return mAccountsLiveData; - } - - private LiveData<List<UserAccount>> getAccountList() { - return Transformations.map( - mDataSource.getAccounts(), - accountList -> - accountList.stream().limit(DEVICE_LIMIT).collect(Collectors.toList())); - } -} diff --git a/src/com/android/car/messenger/core/ui/shared/CircularOutputlineProvider.java b/src/com/android/car/messenger/core/ui/shared/CircularOutputlineProvider.java deleted file mode 100644 index 49577a9..0000000 --- a/src/com/android/car/messenger/core/ui/shared/CircularOutputlineProvider.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.ui.shared; - -import android.graphics.Outline; -import android.view.View; -import android.view.ViewOutlineProvider; - -import androidx.annotation.NonNull; - -import com.android.car.messenger.R; - -/** OutlineProvider that changes the shape of ImageViews */ -public final class CircularOutputlineProvider extends ViewOutlineProvider { - - private CircularOutputlineProvider() {} - - @NonNull - private static final CircularOutputlineProvider INSTANCE = new CircularOutputlineProvider(); - - /** Gets the singleton instance */ - @NonNull - public static CircularOutputlineProvider get() { - return INSTANCE; - } - - @Override - public void getOutline(View view, Outline outline) { - float radiusPercent = - view.getContext() - .getResources() - .getFloat(R.dimen.contact_avatar_corner_radius_percent); - float radius = Math.min(view.getWidth(), view.getHeight()) * radiusPercent; - outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), radius); - view.setClipToOutline(true); - } -} diff --git a/src/com/android/car/messenger/core/ui/shared/LetterTileDrawable.java b/src/com/android/car/messenger/core/ui/shared/LetterTileDrawable.java deleted file mode 100644 index c50c349..0000000 --- a/src/com/android/car/messenger/core/ui/shared/LetterTileDrawable.java +++ /dev/null @@ -1,300 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.car.messenger.core.ui.shared; - -import android.content.res.Resources; -import android.content.res.TypedArray; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.ColorFilter; -import android.graphics.Paint; -import android.graphics.Paint.Align; -import android.graphics.PixelFormat; -import android.graphics.PorterDuff; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.R; - -/** - * A drawable that encapsulates all the functionality needed to display a letter tile to represent a - * contact image. - */ -@SuppressWarnings("StaticAssignmentInConstructor") -public class LetterTileDrawable extends Drawable { - /** Letter tile */ - @NonNull private static int[] sColors; - - private static int sDefaultColor; - private static int sTileFontColor; - private static float sLetterToTileRatio; - @NonNull private static Drawable sDefaultPersonAvatar; - @NonNull private static Drawable sDefaultBusinessAvatar; - @NonNull private static Drawable sDefaultVoicemailAvatar; - - /** Reusable components to avoid new allocations */ - @NonNull private static final Paint sPaint = new Paint(); - - @NonNull private static final Rect sRect = new Rect(); - - /** Contact type constants */ - public static final int TYPE_PERSON = 1; - - public static final int TYPE_BUSINESS = 2; - public static final int TYPE_VOICEMAIL = 3; - public static final int TYPE_DEFAULT = TYPE_PERSON; - - @NonNull private final Paint mPaint; - - @Nullable private String mLetters; - private int mColor; - private int mContactType = TYPE_DEFAULT; - private float mScale = 1.0f; - private float mOffset = 0.0f; - private boolean mIsCircle = false; - - /** A custom Drawable that draws letters on a colored background. */ - // The use pattern for this constructor is: - // create LTD, setContactDetails(), and setIsCircular(true) if needed. - public LetterTileDrawable(@NonNull final Resources res) { - this(res, null, null); - } - - /** A custom Drawable that draws letters on a colored background. */ - // This constructor allows passing the letters and identifier directly. There is no need to - // call setContactDetails() again. setIsCircular(true) needs to be called separately if needed. - public LetterTileDrawable( - @NonNull final Resources res, @Nullable String letters, @Nullable String identifier) { - mPaint = new Paint(); - mPaint.setFilterBitmap(true); - mPaint.setDither(true); - setScale(0.7f); - - if (sColors == null) { - sDefaultColor = res.getColor(R.color.letter_tile_default_color, null /* theme */); - TypedArray ta = res.obtainTypedArray(R.array.letter_tile_colors); - if (ta.length() == 0) { - // TODO(b/26518438). Looks like robolectric shadow doesn't currently support - // obtainTypedArray and always returns length 0 array, which will make some code - // below that does a division by length of sColors choke. Workaround by creating - // an array of length 1. - sColors = new int[] {sDefaultColor}; - - } else { - sColors = new int[ta.length()]; - for (int i = ta.length() - 1; i >= 0; i--) { - sColors[i] = ta.getColor(i, sDefaultColor); - } - ta.recycle(); - } - - sTileFontColor = res.getColor(R.color.letter_tile_font_color, null /* theme */); - sLetterToTileRatio = res.getFraction(R.fraction.letter_to_tile_ratio, 1, 1); - // TODO: get images for business and voicemail - sDefaultPersonAvatar = res.getDrawable(R.drawable.ic_person, null /* theme */); - sDefaultBusinessAvatar = res.getDrawable(R.drawable.ic_person, null /* theme */); - sDefaultVoicemailAvatar = res.getDrawable(R.drawable.ic_person, null /* theme */); - sPaint.setTypeface( - Typeface.create( - res.getString(R.string.config_letter_tile_font_family), - res.getInteger(R.integer.config_letter_tile_text_style))); - sPaint.setTextAlign(Align.CENTER); - sPaint.setAntiAlias(true); - } - - setContactDetails(letters, identifier); - } - - @Override - public void draw(@NonNull final Canvas canvas) { - final Rect bounds = getBounds(); - if (!isVisible() || bounds.isEmpty()) { - return; - } - // Draw letter tile. - drawLetterTile(canvas); - } - - /** - * Draw the drawable onto the canvas at the current bounds taking into account the current - * scale. - */ - private void drawDrawableOnCanvas(final Drawable drawable, @NonNull final Canvas canvas) { - // The drawable should be drawn in the middle of the canvas without changing its width to - // height ratio. - final Rect destRect = copyBounds(); - - // Crop the destination bounds into a square, scaled and offset as appropriate - final int halfLength = (int) (mScale * Math.min(destRect.width(), destRect.height()) / 2); - - destRect.set( - destRect.centerX() - halfLength, - (int) (destRect.centerY() - halfLength + mOffset * destRect.height()), - destRect.centerX() + halfLength, - (int) (destRect.centerY() + halfLength + mOffset * destRect.height())); - - drawable.setAlpha(mPaint.getAlpha()); - drawable.setColorFilter(sTileFontColor, PorterDuff.Mode.SRC_IN); - drawable.setBounds(destRect); - drawable.draw(canvas); - } - - private void drawLetterTile(@NonNull final Canvas canvas) { - // Draw background color. - sPaint.setColor(mColor); - - sPaint.setAlpha(mPaint.getAlpha()); - final Rect bounds = getBounds(); - final int minDimension = Math.min(bounds.width(), bounds.height()); - - if (mIsCircle) { - canvas.drawCircle(bounds.centerX(), bounds.centerY(), minDimension / 2, sPaint); - } else { - canvas.drawRect(bounds, sPaint); - } - - if (!TextUtils.isEmpty(mLetters)) { - // Scale text by canvas bounds and user selected scaling factor - sPaint.setTextSize(mScale * sLetterToTileRatio * minDimension); - // sPaint.setTextSize(sTileLetterFontSize); - sPaint.getTextBounds(mLetters, 0, mLetters.length(), sRect); - sPaint.setColor(sTileFontColor); - - // Draw the letter in the canvas, vertically shifted up or down by the user-defined - // offset - canvas.drawText( - mLetters, - 0, - mLetters.length(), - bounds.centerX(), - bounds.centerY() + mOffset * bounds.height() + sRect.height() / 2, - sPaint); - } else { - // Draw the default image if there is no letter/digit to be drawn - final Drawable drawable = getDrawablepForContactType(mContactType); - drawDrawableOnCanvas(drawable, canvas); - } - } - - public int getColor() { - return mColor; - } - - /** Returns a deterministic color based on the provided contact identifier string. */ - private int pickColor(@Nullable final String identifier) { - if (TextUtils.isEmpty(identifier) || mContactType == TYPE_VOICEMAIL) { - return sDefaultColor; - } - // String.hashCode() implementation is not supposed to change across java versions, so - // this should guarantee the same email address always maps to the same color. - // The email should already have been normalized by the ContactRequest. - final int color = Math.abs(identifier.hashCode()) % sColors.length; - return sColors[color]; - } - - @NonNull - private static Drawable getDrawablepForContactType(int contactType) { - switch (contactType) { - case TYPE_BUSINESS: - return sDefaultBusinessAvatar; - case TYPE_VOICEMAIL: - return sDefaultVoicemailAvatar; - case TYPE_PERSON: - default: - return sDefaultPersonAvatar; - } - } - - @Override - public void setAlpha(final int alpha) { - mPaint.setAlpha(alpha); - } - - @Override - public void setColorFilter(@NonNull final ColorFilter cf) { - mPaint.setColorFilter(cf); - } - - @Override - public int getOpacity() { - return PixelFormat.OPAQUE; - } - - /** - * Scale the drawn letter tile to a ratio of its default size - * - * @param scale The ratio the letter tile should be scaled to as a percentage of its default - * size, from a scale of 0 to 2.0f. The default is 1.0f. - */ - public void setScale(float scale) { - mScale = scale; - } - - /** - * Assigns the vertical offset of the position of the letter tile to the ContactDrawable - * - * @param offset The provided offset must be within the range of -0.5f to 0.5f. If set to -0.5f, - * the letter will be shifted upwards by 0.5 times the height of the canvas it is being - * drawn on, which means it will be drawn with the center of the letter starting at the top - * edge of the canvas. If set to 0.5f, the letter will be shifted downwards by 0.5 times the - * height of the canvas it is being drawn on, which means it will be drawn with the center - * of the letter starting at the bottom edge of the canvas. The default is 0.0f. - */ - public void setOffset(float offset) { - mOffset = offset; - } - - /** - * Sets the details. - * - * @param letters The letters need to be drawn - * @param identifier decides the color for the drawable. - */ - public void setContactDetails(@Nullable String letters, @Nullable String identifier) { - mLetters = letters; - mColor = pickColor(identifier); - } - - public void setContactType(int contactType) { - mContactType = contactType; - } - - public void setIsCircular(boolean isCircle) { - mIsCircle = isCircle; - } - - /** - * Convert the drawable to a bitmap. - * - * @param size The target size of the bitmap. - * @return A bitmap representation of the drawable. - */ - @NonNull - public Bitmap toBitmap(int size) { - Bitmap largeIcon = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(largeIcon); - Rect bounds = getBounds(); - setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - draw(canvas); - setBounds(bounds); - return largeIcon; - } -} diff --git a/src/com/android/car/messenger/core/ui/shared/LoadingFrameLayout.java b/src/com/android/car/messenger/core/ui/shared/LoadingFrameLayout.java deleted file mode 100644 index a6f0bfa..0000000 --- a/src/com/android/car/messenger/core/ui/shared/LoadingFrameLayout.java +++ /dev/null @@ -1,321 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.ui.shared; - -import android.content.Context; -import android.content.res.TypedArray; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.DrawableRes; -import androidx.annotation.IntDef; -import androidx.annotation.LayoutRes; -import androidx.annotation.MainThread; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; - -import com.android.car.messenger.R; - -/** A widget that supports different {@link State}s: NEW, LOADING, CONTENT, EMPTY OR ERROR. */ -public class LoadingFrameLayout extends FrameLayout { - private static final int INVALID_RES_ID = 0; - - /** Possible states of a service request display. */ - @IntDef({State.NEW, State.LOADING, State.CONTENT, State.ERROR, State.EMPTY}) - public @interface State { - int NEW = 0; - int LOADING = 1; - int CONTENT = 2; - int ERROR = 3; - int EMPTY = 4; - } - - @NonNull private final Context mContext; - @NonNull private ViewContainer mEmptyView; - @NonNull private ViewContainer mLoadingView; - @NonNull private ViewContainer mErrorView; - - @State private int mState = State.NEW; - - public LoadingFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) { - this(context, attrs, 0); - } - - public LoadingFrameLayout( - @NonNull Context context, @Nullable AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - mContext = context; - TypedArray values = - context.obtainStyledAttributes(attrs, R.styleable.LoadingFrameLayout, defStyle, 0); - setLoadingView( - values.getResourceId( - R.styleable.LoadingFrameLayout_progressViewLayout, - R.layout.loading_progress_view)); - setEmptyView(); - setErrorView(); - values.recycle(); - } - - @Override - public void onFinishInflate() { - super.onFinishInflate(); - // Start with a loading view when inflated from XML. - showLoading(); - } - - private void setLoadingView(int loadingLayoutId) { - mLoadingView = new ViewContainer(State.LOADING, loadingLayoutId); - } - - private void setEmptyView() { - mEmptyView = new ViewContainer(State.EMPTY); - } - - private void setErrorView() { - mErrorView = new ViewContainer(State.ERROR); - } - - /** Shows the loading view, hides other views. */ - @MainThread - public void showLoading() { - switchTo(State.LOADING); - } - - /** - * Shows the error view where the action button is not available and hides other views. - * - * @param iconResId drawable resource id used for the top icon. When it is invalid, hide the - * icon view. - * @param messageResId string resource id used for the error message. When it is invalid, hide - * the message view. - * @param secondaryMessageResId string resource id for the secondary error message. When it is - * invalid, hide the secondary message view. - */ - public void showError( - @DrawableRes int iconResId, - @StringRes int messageResId, - @StringRes int secondaryMessageResId) { - showError(iconResId, messageResId, secondaryMessageResId, INVALID_RES_ID, null, false); - } - - /** - * Shows the error view, hides other views. - * - * @param iconResId drawable resource id used for the top icon.When it is invalid, hide the icon - * view. - * @param messageResId string resource id used for the error message. When it is invalid, hide - * the message view. - * @param secondaryMessageResId string resource id for the secondary error message. When it is - * invalid, hide the secondary message view. - * @param actionButtonTextResId string resource id for the action button. - * @param actionButtonOnClickListener click listener set on the action button. - * @param showActionButton boolean flag if the action button will show. - */ - public void showError( - @DrawableRes int iconResId, - @StringRes int messageResId, - @StringRes int secondaryMessageResId, - @StringRes int actionButtonTextResId, - @Nullable OnClickListener actionButtonOnClickListener, - boolean showActionButton) { - switchTo(State.ERROR); - mErrorView.setIcon(iconResId); - mErrorView.setMessage(messageResId); - mErrorView.setSecondaryMessage(secondaryMessageResId); - mErrorView.setActionButtonText(actionButtonTextResId); - mErrorView.setActionButtonClickListener(actionButtonOnClickListener); - mErrorView.setActionButtonVisible(showActionButton); - } - - /** - * Shows the empty view where the action button is not available and hides other views. - * - * @param iconResId drawable resource id used for the top icon. When it is invalid, hide the - * icon view. - * @param messageResId string resource id used for the empty message. When it is invalid, hide - * the message view. - * @param secondaryMessageResId string resource id for the secondary empty message. When it is - * invalid, hide the secondary message view. - */ - public void showEmpty( - @DrawableRes int iconResId, - @StringRes int messageResId, - @StringRes int secondaryMessageResId) { - showEmpty(iconResId, messageResId, secondaryMessageResId, INVALID_RES_ID, null, false); - } - - /** - * Shows the empty view and hides other views. - * - * @param iconResId drawable resource id used for the top icon.When it is invalid, hide the icon - * view. - * @param messageResId string resource id used for the empty message. When it is invalid, hide - * the message view. - * @param secondaryMessageResId string resource id for the secondary empty message. When it is - * invalid, hide the secondary message view. - * @param actionButtonTextResId string resource id for the action button. - * @param actionButtonOnClickListener click listener set on the action button. - * @param showActionButton boolean flag if the action button will show. - */ - public void showEmpty( - @DrawableRes int iconResId, - @StringRes int messageResId, - @StringRes int secondaryMessageResId, - @StringRes int actionButtonTextResId, - @Nullable OnClickListener actionButtonOnClickListener, - boolean showActionButton) { - mEmptyView.setIcon(iconResId); - mEmptyView.setMessage(messageResId); - mEmptyView.setSecondaryMessage(secondaryMessageResId); - mEmptyView.setActionButtonText(actionButtonTextResId); - mEmptyView.setActionButtonClickListener(actionButtonOnClickListener); - mEmptyView.setActionButtonVisible(showActionButton); - switchTo(State.EMPTY); - } - - /** Shows the content view, hides other views. */ - public void showContent() { - switchTo(State.CONTENT); - } - - /** Hide all views. */ - public void reset() { - switchTo(State.NEW); - } - - private void switchTo(@State int state) { - if (mState != state) { - ViewUtils.setVisible((View) findViewById(R.id.list_view), state == State.CONTENT); - mLoadingView.setVisibilityFromState(state); - mErrorView.setVisibilityFromState(state); - mEmptyView.setVisibilityFromState(state); - mState = state; - } - } - - /** - * Container for views held by this LoadingFrameLayout. Used for deferring view inflation until - * the view is about to be shown. - */ - private class ViewContainer { - @State private final int mViewState; - private View mView; - private ImageView mIconView; - private TextView mActionButton; - private TextView mMessageView; - private TextView mSecondaryMessageView; - - private ViewContainer(@State int state) { - mViewState = state; - mView = inflateView(); - LoadingFrameLayout.this.addView(mView); - } - - private ViewContainer(@State int state, @LayoutRes int layout) { - mViewState = state; - mView = LayoutInflater.from(mContext).inflate(layout, LoadingFrameLayout.this, false); - LoadingFrameLayout.this.addView(mView); - } - - private View inflateView() { - View view = - LayoutInflater.from(mContext) - .inflate(R.layout.loading_info_view, LoadingFrameLayout.this, false); - mMessageView = view.findViewById(R.id.loading_info_message); - mSecondaryMessageView = view.findViewById(R.id.loading_info_secondary_message); - mIconView = view.findViewById(R.id.loading_info_icon); - mActionButton = view.findViewById(R.id.loading_info_action_button); - return view; - } - - public void setVisibilityFromState(@State int newState) { - if (mViewState == newState) { - show(); - } else { - hide(); - } - } - - private void show() { - mView.setVisibility(View.VISIBLE); - } - - private void hide() { - if (mView != null) { - mView.setVisibility(View.GONE); - mView.clearFocus(); - } - } - - private void setMessage(@StringRes int messageResId) { - if (mMessageView == null) { - return; - } - if (messageResId != INVALID_RES_ID) { - mMessageView.setText(messageResId); - } else { - ViewUtils.setVisible(mMessageView, false); - } - } - - private void setSecondaryMessage(@StringRes int secondaryMessageResId) { - if (mSecondaryMessageView == null) { - return; - } - if (secondaryMessageResId != INVALID_RES_ID) { - mSecondaryMessageView.setText(secondaryMessageResId); - } else { - ViewUtils.setVisible(mSecondaryMessageView, false); - } - } - - private void setActionButtonClickListener(OnClickListener actionButtonOnClickListener) { - if (mActionButton == null) { - return; - } - mActionButton.setOnClickListener(actionButtonOnClickListener); - } - - private void setActionButtonText(@StringRes int actionButtonTextResId) { - if (mActionButton == null) { - return; - } - if (actionButtonTextResId != INVALID_RES_ID) { - mActionButton.setText(actionButtonTextResId); - } - } - - private void setActionButtonVisible(boolean visible) { - ViewUtils.setVisible(mActionButton, visible); - } - - private void setIcon(@DrawableRes int iconResId) { - if (iconResId != INVALID_RES_ID) { - if (mIconView != null) { - mIconView.setImageResource(iconResId); - } - } else { - ViewUtils.setVisible(mIconView, false); - } - } - } -} diff --git a/src/com/android/car/messenger/core/ui/shared/ViewUtils.java b/src/com/android/car/messenger/core/ui/shared/ViewUtils.java deleted file mode 100644 index 0e77b00..0000000 --- a/src/com/android/car/messenger/core/ui/shared/ViewUtils.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 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.messenger.core.ui.shared; - -import android.view.View; - -import androidx.annotation.Nullable; - -/** Utility methods to operate over views. */ -public class ViewUtils { - - private ViewUtils() {} - - /** Sets the visibility of the (optional) view to {@link View#VISIBLE} or {@link View#GONE}. */ - public static void setVisible(@Nullable View view, boolean visible) { - if (view != null) { - view.setVisibility(visible ? View.VISIBLE : View.GONE); - } - } -} diff --git a/src/com/android/car/messenger/core/util/ConversationUtil.java b/src/com/android/car/messenger/core/util/ConversationUtil.java deleted file mode 100644 index b56cbce..0000000 --- a/src/com/android/car/messenger/core/util/ConversationUtil.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.util; - -import static com.android.car.messenger.core.shared.MessageConstants.LAST_REPLY_TIMESTAMP_EXTRA; - -import static java.lang.Math.max; - -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.common.Conversation; -import com.android.car.messenger.common.Conversation.Message; -import com.android.car.messenger.common.Conversation.Message.MessageStatus; - -/** Conversation Util class for the {@link Conversation} DAO */ -public class ConversationUtil { - private ConversationUtil() {} - - /** - * Get the last timestamp for the conversation. This could be a reply timestamp or last received - * message timestamp, whichever is last. - */ - public static long getConversationTimestamp(@Nullable Conversation conversation) { - if (conversation == null) { - return 0L; - } - long replyTimestamp = conversation.getExtras().getLong(LAST_REPLY_TIMESTAMP_EXTRA, 0L); - Message lastMessage = getLastMessage(conversation); - long lastMessageTimestamp = lastMessage == null ? 0L : lastMessage.getTimestamp(); - return max(replyTimestamp, lastMessageTimestamp); - } - - /** Returns if the {@link Conversation} has been last responded to. */ - public static boolean isReplied(@Nullable Conversation conversation) { - if (conversation == null) { - return false; - } - long lastReplyTimestamp = getReplyTimestamp(conversation); - long lastMessageTimestamp = 0L; - Message lastMessageGroup = ConversationUtil.getLastMessage(conversation); - if (lastMessageGroup != null) { - lastMessageTimestamp = lastMessageGroup.getTimestamp(); - } - return lastReplyTimestamp > lastMessageTimestamp; - } - - /** - * Returns the last message in the conversation, or null if {@link Conversation#getMessages} is - * empty - */ - @Nullable - public static Message getLastMessage(@Nullable Conversation conversation) { - if (conversation == null || conversation.getMessages().isEmpty()) { - return null; - } - int size = conversation.getMessages().size(); - return conversation.getMessages().get(size - 1); - } - - /** - * Gets the conversation status of the last messages Returns {@link - * MessageStatus#MESSAGE_STATUS_NONE} when no known message status or last message is a reply - */ - @MessageStatus - public static int getConversationStatus(@Nullable Conversation conversation) { - Message lastMessage = getLastMessage(conversation); - return isReplied(conversation) || lastMessage == null - ? MessageStatus.MESSAGE_STATUS_NONE - : lastMessage.getMessageStatus(); - } - - /** - * Sets the Reply timestamp to a {@link Conversation.Builder} - * - * @param extras optional, pass an existing bundle to which the reply timestamp will be added - * to. If no extra is passed, a new one will be created. The final extras will be added to - * the {@link Conversation#getExtras()} - */ - public static void setReplyTimestampAsAnExtra( - @NonNull Conversation.Builder conversationBuilder, - @Nullable Bundle extras, - long lastReplyTimestamp) { - if (lastReplyTimestamp > 0L) { - if (extras == null) { - extras = new Bundle(); - } - extras.putLong(LAST_REPLY_TIMESTAMP_EXTRA, lastReplyTimestamp); - conversationBuilder.setExtras(extras); - } - } - - /** Gets reply timestamp */ - private static long getReplyTimestamp(@Nullable Conversation conversation) { - if (conversation == null) { - return 0L; - } - return conversation.getExtras().getLong(LAST_REPLY_TIMESTAMP_EXTRA, 0L); - } -} diff --git a/src/com/android/car/messenger/core/util/L.java b/src/com/android/car/messenger/core/util/L.java deleted file mode 100644 index 4e2fcb1..0000000 --- a/src/com/android/car/messenger/core/util/L.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.core.util; - -import android.util.Log; - -import androidx.annotation.NonNull; - -/** Util class for logging. */ -public class L { - @NonNull private static final String TAG = "CarMessenger"; - - private L() {} - - /** - * Logs verbose level logs if loggable. - * - * @param msg the message to log, as a format string - */ - public static void v(@NonNull String msg) { - if (Log.isLoggable(TAG, Log.VERBOSE)) { - Log.v(TAG, msg); - } - } - - /** - * Logs debug level logs if loggable. - * - * @param msg the message to log, as a format string - */ - public static void d(@NonNull String msg) { - if (Log.isLoggable(TAG, Log.DEBUG)) { - Log.d(TAG, msg); - } - } - - /** - * Logs info level logs if loggable. - * - * @param msg the message to log, as a format string - */ - public static void i(@NonNull String msg) { - if (Log.isLoggable(TAG, Log.INFO)) { - Log.i(TAG, msg); - } - } - - /** - * Logs warning level logs if loggable. - * - * @param msg the message to log, as a format string - */ - public static void w(@NonNull String msg) { - if (Log.isLoggable(TAG, Log.WARN)) { - Log.w(TAG, msg); - } - } - - /** - * Logs error level logs. - * - * @param msg the message to log, as a format string - */ - public static void e(@NonNull String msg) { - Log.e(TAG, msg); - } - - /** - * Logs warning level logs. - * - * @param msg the message to log, as a format string - * @param e a throwable to log - */ - public static void e(@NonNull String msg, Throwable e) { - Log.e(TAG, msg, e); - } - - /** - * Logs conditions that should never happen. - * - * @param msg the message to log, as a format string - */ - public static void wtf(@NonNull String msg) { - Log.wtf(TAG, msg); - } - - /** - * Logs conditions that should never happen. - * - * @param e an exception to log - * @param msg the message to log, as a format string - */ - public static void wtf(Exception e, @NonNull String msg) { - Log.wtf(TAG, msg, e); - } -} diff --git a/src/com/android/car/messenger/core/util/VoiceUtil.java b/src/com/android/car/messenger/core/util/VoiceUtil.java deleted file mode 100644 index 16be671..0000000 --- a/src/com/android/car/messenger/core/util/VoiceUtil.java +++ /dev/null @@ -1,257 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.car.messenger.core.util; - -import static com.android.car.assist.CarVoiceInteractionSession.KEY_ACTION; -import static com.android.car.assist.CarVoiceInteractionSession.KEY_CONVERSATION; -import static com.android.car.assist.CarVoiceInteractionSession.KEY_DEVICE_ADDRESS; -import static com.android.car.assist.CarVoiceInteractionSession.KEY_DEVICE_NAME; -import static com.android.car.assist.CarVoiceInteractionSession.KEY_NOTIFICATION; -import static com.android.car.assist.CarVoiceInteractionSession.KEY_PHONE_NUMBER; -import static com.android.car.assist.CarVoiceInteractionSession.KEY_SEND_PENDING_INTENT; -import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_READ_CONVERSATION; -import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_READ_NOTIFICATION; -import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_REPLY_CONVERSATION; -import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_REPLY_NOTIFICATION; -import static com.android.car.assist.CarVoiceInteractionSession.VOICE_ACTION_SEND_SMS; -import static com.android.car.messenger.core.shared.MessageConstants.ACTION_DIRECT_SEND; -import static com.android.car.messenger.core.shared.MessageConstants.ACTION_MARK_AS_READ; -import static com.android.car.messenger.core.shared.MessageConstants.ACTION_MUTE; -import static com.android.car.messenger.core.shared.MessageConstants.ACTION_REPLY; -import static com.android.car.messenger.core.shared.MessageConstants.EXTRA_CONVERSATION_KEY; - -import android.app.Activity; -import android.app.PendingIntent; -import android.app.RemoteAction; -import android.app.RemoteInput; -import android.content.Context; -import android.content.Intent; -import android.graphics.drawable.Icon; -import android.os.Bundle; -import android.service.notification.StatusBarNotification; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.R; -import com.android.car.messenger.common.Conversation; -import com.android.car.messenger.common.Conversation.ConversationAction; -import com.android.car.messenger.common.Conversation.ConversationAction.ActionType; -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.models.UserAccount; -import com.android.car.messenger.core.service.MessengerService; -import com.android.car.messenger.core.shared.NotificationHandler; - -import java.util.ArrayList; -import java.util.List; - -/** Voice Util classes for requesting voice interactions and responding to voice actions */ -public class VoiceUtil { - - private VoiceUtil() {} - - /** Requests Voice request to read a conversation */ - public static void voiceRequestReadConversation( - @NonNull Activity activity, @NonNull Conversation conversation) { - voiceRequestHelper( - activity, - conversation, - VOICE_ACTION_READ_CONVERSATION, - VOICE_ACTION_READ_NOTIFICATION); - } - - /** Requests Voice request to reply to a conversation */ - public static void voiceRequestReplyConversation( - @NonNull Activity activity, @NonNull Conversation conversation) { - voiceRequestHelper( - activity, - conversation, - VOICE_ACTION_REPLY_CONVERSATION, - VOICE_ACTION_REPLY_NOTIFICATION); - } - - private static void voiceRequestHelper( - @NonNull Activity activity, - @NonNull Conversation conversation, - @NonNull String conversationAction, - @NonNull String notificationAction) { - Bundle args = new Bundle(); - Conversation tapToReadConversation = createTapToReadConversation(conversation); - // use legacy tap to read by default as support for - // new api using Conversation class is still very limited and very nascent - StatusBarNotification sbn = - NotificationHandler.postNotificationForLegacyTapToRead(tapToReadConversation); - if (sbn != null) { - args.putString(KEY_ACTION, notificationAction); - args.putParcelable(KEY_NOTIFICATION, sbn); - } else { - // New API using generic Conversation class - // is currently limited in support by partner assistants and is currently being phase - // in. - args.putString(KEY_ACTION, conversationAction); - args.putBundle(KEY_CONVERSATION, tapToReadConversation.toBundle()); - } - - activity.startLocalVoiceInteraction(args); - } - - /** Requests Voice request to start a generic compose voice interaction */ - public static void voiceRequestGenericCompose(Activity activity, UserAccount userAccount) { - Bundle bundle = new Bundle(); - bundle.putString(KEY_ACTION, VOICE_ACTION_SEND_SMS); - bundle.putString(KEY_DEVICE_ADDRESS, userAccount.getIccId()); - bundle.putString(KEY_DEVICE_NAME, userAccount.getName()); - PendingIntent sendIntent = - createServiceIntent(ACTION_DIRECT_SEND, /* conversationKey= */ null); - bundle.putParcelable(KEY_SEND_PENDING_INTENT, sendIntent); - activity.startLocalVoiceInteraction(bundle); - } - - /** - * Returns a new conversation containing the tap to read pending intents to be transferred over - * to the Voice Assistant. - * - * <p>The conversation object returned remained unmodified. - * - * <p>This is important to allow the Assistant have a different instance than the one that - * powers our UI. We can create new pending intents without modifying the instance the Assistant - * holds. - * - * @return new conversation instance with the same data and pending intents for tap to read. - */ - public static Conversation createTapToReadConversation(Conversation conversation) { - Context context = AppFactory.get().getContext(); - String conversationKey = conversation.getId(); - Conversation.Builder builder = conversation.toBuilder(); - - final int replyIcon = R.drawable.car_ui_icon_reply; - final String replyString = context.getString(R.string.action_reply); - PendingIntent replyIntent = createServiceIntent(ACTION_REPLY, conversationKey); - ConversationAction replyAction = - new ConversationAction( - ActionType.ACTION_TYPE_REPLY, - new RemoteAction( - Icon.createWithResource(context, replyIcon), - replyString, - replyString, - replyIntent), - new RemoteInput.Builder(Intent.EXTRA_TEXT).build()); - - final int markAsReadIcon = android.R.drawable.ic_media_play; - final String markAsReadString = context.getString(R.string.action_mark_as_read); - PendingIntent markAsReadIntent = createServiceIntent(ACTION_MARK_AS_READ, conversationKey); - ConversationAction markAsReadAction = - new ConversationAction( - ActionType.ACTION_TYPE_MARK_AS_READ, - new RemoteAction( - Icon.createWithResource(context, markAsReadIcon), - markAsReadString, - markAsReadString, - markAsReadIntent), - null); - - final int muteIcon = R.drawable.car_ui_icon_toggle_mute; - final String muteString = context.getString(R.string.action_mute); - PendingIntent muteIntent = createServiceIntent(ACTION_MUTE, conversationKey); - ConversationAction muteAction = - new ConversationAction( - ActionType.ACTION_TYPE_MUTE, - new RemoteAction( - Icon.createWithResource(context, muteIcon), - muteString, - muteString, - muteIntent), - null); - - List<ConversationAction> actions = new ArrayList<>(); - actions.add(replyAction); - actions.add(markAsReadAction); - actions.add(muteAction); - builder.setActions(actions); - return builder.build(); - } - - private static PendingIntent createServiceIntent( - @NonNull String action, @Nullable String conversationKey) { - Context context = AppFactory.get().getContext(); - Bundle bundle = new Bundle(); - if (conversationKey != null) { - bundle.putString(EXTRA_CONVERSATION_KEY, conversationKey); - } - Intent intent = - new Intent(context, MessengerService.class) - .setAction(action) - .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP) - .setClass(context, MessengerService.class) - .putExtras(bundle); - - int requestCode = - (conversationKey == null) ? action.hashCode() : conversationKey.hashCode(); - return PendingIntent.getForegroundService( - context, requestCode, intent, PendingIntent.FLAG_ONE_SHOT); - } - - /** Sends a reply, meant to be used from a caller originating from voice input. */ - public static void directSend(Intent intent) { - final CharSequence phoneNumber = intent.getCharSequenceExtra(KEY_PHONE_NUMBER); - final String iccId = intent.getStringExtra(KEY_DEVICE_ADDRESS); - final CharSequence message = intent.getCharSequenceExtra(Intent.EXTRA_TEXT); - if (iccId == null || phoneNumber == null || TextUtils.isEmpty(message)) { - L.e("Dropping voice reply. Received no icc id, phone Number and/or empty message!"); - return; - } - L.d("Sending a message to specified phone number"); - AppFactory.get() - .getDataModel() - .sendMessage(iccId, phoneNumber.toString(), message.toString()); - } - - /** Sends a reply, meant to be used from a caller originating from voice input. */ - public static void voiceReply(Intent intent) { - final String conversationKey = intent.getStringExtra(EXTRA_CONVERSATION_KEY); - final Bundle bundle = RemoteInput.getResultsFromIntent(intent); - if (bundle == null) { - L.e("Dropping voice reply. Received null bundle!"); - return; - } - final CharSequence message = bundle.getCharSequence(Intent.EXTRA_TEXT); - L.d("voiceReply: " + message); - if (!TextUtils.isEmpty(message)) { - AppFactory.get().getDataModel().sendMessage(conversationKey, message.toString()); - } - } - - /** Mark a conversation associated with a given sender key as read. */ - public static void mute(Intent intent) { - Bundle extras = intent.getExtras(); - if (extras != null) { - final String conversationKey = extras.getString(EXTRA_CONVERSATION_KEY); - L.d("mute"); - AppFactory.get().getDataModel().muteConversation(conversationKey, true); - } - } - - /** Mark a conversation associated with a given sender key as read. */ - public static void markAsRead(Intent intent) { - Bundle extras = intent.getExtras(); - if (extras != null) { - final String conversationKey = extras.getString(EXTRA_CONVERSATION_KEY); - L.d("marking as read"); - AppFactory.get().getDataModel().markAsRead(conversationKey); - } - } -} diff --git a/src/com/android/car/messenger/impl/AppFactoryImpl.java b/src/com/android/car/messenger/impl/AppFactoryImpl.java deleted file mode 100644 index cceb716..0000000 --- a/src/com/android/car/messenger/impl/AppFactoryImpl.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.SharedPreferences; -import android.os.IBinder; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.preference.PreferenceManager; - -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.interfaces.DataModel; -import com.android.car.messenger.core.service.MessengerService; -import com.android.car.messenger.impl.datamodels.TelephonyDataModel; - -/* App Factory Implementation */ -class AppFactoryImpl extends AppFactory { - @NonNull private Context mApplicationContext; - @NonNull private DataModel mDataModel; - @NonNull private SharedPreferences mSharedPreferences; - @Nullable private MessengerService mMessengerService; - - @NonNull - private final ServiceConnection mServiceConnection = - new ServiceConnection() { - @Override - public void onServiceConnected( - @NonNull ComponentName className, @NonNull IBinder service) { - MessengerService.LocalBinder binder = (MessengerService.LocalBinder) service; - mMessengerService = binder.getService(); - } - - @Override - public void onServiceDisconnected(@NonNull ComponentName arg0) { - mMessengerService = null; - } - }; - - private AppFactoryImpl() {} - - public static void register(@NonNull final CarMessengerApp application) { - if (sRegistered && sInitialized) { - return; - } - - final AppFactoryImpl factory = new AppFactoryImpl(); - AppFactory.setInstance(factory); - sRegistered = true; - - // At this point Factory is published. Services can now get initialized and depend on - // Factory.get(). - factory.mApplicationContext = application.getApplicationContext(); - factory.mDataModel = new TelephonyDataModel(); - factory.mSharedPreferences = - PreferenceManager.getDefaultSharedPreferences(factory.mApplicationContext); - - // Create Messenger Service - Intent intent = new Intent(factory.mApplicationContext, MessengerService.class); - factory.mApplicationContext.bindService( - intent, factory.mServiceConnection, Context.BIND_AUTO_CREATE); - } - - @Override - @NonNull - public Context getContext() { - // prefer the messenger service context - // to avoid warnings on using app context for UI constants - if (mMessengerService != null) { - return mMessengerService; - } else { - return mApplicationContext; - } - } - - @Override - @NonNull - public DataModel getDataModel() { - return mDataModel; - } - - @Override - @NonNull - public SharedPreferences getSharedPreferences() { - return mSharedPreferences; - } -} diff --git a/src/com/android/car/messenger/impl/CarMessengerApp.java b/src/com/android/car/messenger/impl/CarMessengerApp.java deleted file mode 100644 index 4ed9440..0000000 --- a/src/com/android/car/messenger/impl/CarMessengerApp.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl; - -import android.app.Application; -import android.os.Handler; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.core.util.L; - -import java.lang.Thread.UncaughtExceptionHandler; - -/** The application object */ -public class CarMessengerApp extends Application implements UncaughtExceptionHandler { - @Nullable private static UncaughtExceptionHandler sSystemUncaughtExceptionHandler; - - @Override - public void onCreate() { - super.onCreate(); - AppFactoryImpl.register(this); - sSystemUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler(); - Thread.setDefaultUncaughtExceptionHandler(this); - } - - @Override - public void onLowMemory() { - super.onLowMemory(); - L.d("onLowMemory"); - } - - @Override - public void uncaughtException(final Thread thread, final Throwable ex) { - final boolean background = getMainLooper().getThread() != thread; - if (background) { - L.e("Uncaught exception in background thread " + thread, ex); - final Handler handler = new Handler(getMainLooper()); - handler.post( - () -> nullSafeUncaughtException(thread, ex)); - } else { - nullSafeUncaughtException(thread, ex); - } - } - - private static void nullSafeUncaughtException(@NonNull Thread thread, @NonNull Throwable ex) { - if (sSystemUncaughtExceptionHandler != null) { - sSystemUncaughtExceptionHandler.uncaughtException(thread, ex); - } - } -} diff --git a/src/com/android/car/messenger/impl/common/ProjectionStateListener.java b/src/com/android/car/messenger/impl/common/ProjectionStateListener.java deleted file mode 100644 index cb3b26d..0000000 --- a/src/com/android/car/messenger/impl/common/ProjectionStateListener.java +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl.common; - -import android.bluetooth.BluetoothDevice; -import android.car.Car; -import android.car.CarProjectionManager; -import android.car.projection.ProjectionStatus; -import android.content.Context; -import android.os.Bundle; -import android.os.Parcelable; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.core.util.L; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** - * {@link ProjectionStatus} listener that exposes APIs to detect whether a projection application is - * active. - */ -public class ProjectionStateListener { - @NonNull - static final String PROJECTION_STATUS_EXTRA_DEVICE_STATE = - "android.car.projection.DEVICE_STATE"; - - @Nullable private CarProjectionManager mCarProjectionManager = null; - - @NonNull - private final CarProjectionManager.ProjectionStatusListener mListener = - (state, packageName, details) -> { - mProjectionState = state; - mProjectionDetails = details; - }; - - @Nullable private Car mCar; - - private int mProjectionState = ProjectionStatus.PROJECTION_STATE_INACTIVE; - @NonNull private List<ProjectionStatus> mProjectionDetails = new ArrayList<>(); - - public ProjectionStateListener(@NonNull Context context) { - Car.createCar( - context, - /* handler= */ null, - Car.CAR_WAIT_TIMEOUT_DO_NOT_WAIT, - (car, ready) -> { - mCar = car; - mCarProjectionManager = - (CarProjectionManager) mCar.getCarManager(Car.PROJECTION_SERVICE); - if (mCarProjectionManager != null) { - mCarProjectionManager.registerProjectionStatusListener(mListener); - } - }); - } - - /** Unregisters the listener. Should be called when the caller's lifecycle is ending. */ - public void destroy() { - if (mCarProjectionManager != null) { - mCarProjectionManager.unregisterProjectionStatusListener(mListener); - } - if (mCar != null) { - mCar.disconnect(); - mCar = null; - } - mProjectionState = ProjectionStatus.PROJECTION_STATE_INACTIVE; - mProjectionDetails = Collections.emptyList(); - } - - /** - * Returns {@code true} if the input device currently has a projection app running in the - * foreground. - * - * @param bluetoothAddress of the device that should be checked. If null, return whether any - * device is currently running a projection app in the foreground. - */ - public boolean isProjectionInActiveForeground(@Nullable String bluetoothAddress) { - if (bluetoothAddress == null) { - L.i("returning non-device-specific projection status"); - return isProjectionInActiveForeground(); - } - - if (!isProjectionInActiveForeground()) { - return false; - } - - for (ProjectionStatus status : mProjectionDetails) { - if (!status.isActive()) { - // Don't suppress UI for packages that aren't actively projecting. - L.d("skip non-projecting package " + status.getPackageName()); - continue; - } - - for (ProjectionStatus.MobileDevice device : status.getConnectedMobileDevices()) { - if (!device.isProjecting()) { - // Don't suppress UI for devices that aren't foreground. - L.d("skip non-projecting device " + device.getName()); - continue; - } - - Bundle extras = device.getExtras(); - if (extras.getInt( - PROJECTION_STATUS_EXTRA_DEVICE_STATE, - ProjectionStatus.PROJECTION_STATE_ACTIVE_FOREGROUND) - != ProjectionStatus.PROJECTION_STATE_ACTIVE_FOREGROUND) { - L.d("skip device " + device.getName() + " - not foreground"); - continue; - } - - Parcelable projectingBluetoothDevice = - extras.getParcelable(BluetoothDevice.EXTRA_DEVICE); - L.d("Device " + device.getName() + " has BT device " + projectingBluetoothDevice); - - if (projectingBluetoothDevice == null) { - L.i( - "Suppressing message notification - device " - + device - + " is projection, and does not specify a Bluetooth address"); - return true; - } else if (!(projectingBluetoothDevice instanceof BluetoothDevice)) { - L.e( - "Device " - + device - + " has bad EXTRA_DEVICE value " - + projectingBluetoothDevice - + " - treating as unspecified"); - return true; - } else if (bluetoothAddress.equals( - ((BluetoothDevice) projectingBluetoothDevice).getAddress())) { - L.i( - "Suppressing message notification - device " - + device - + "is projecting, and message is coming from device's" - + " Bluetooth address" - + bluetoothAddress); - return true; - } - } - } - - // No projecting apps want to suppress this device, so let it through. - return false; - } - - /** Returns {@code true} if a projection app is active in the foreground. */ - private boolean isProjectionInActiveForeground() { - return mProjectionState == ProjectionStatus.PROJECTION_STATE_ACTIVE_FOREGROUND; - } -} diff --git a/src/com/android/car/messenger/impl/datamodels/ContentProviderLiveData.java b/src/com/android/car/messenger/impl/datamodels/ContentProviderLiveData.java deleted file mode 100644 index 24a2d97..0000000 --- a/src/com/android/car/messenger/impl/datamodels/ContentProviderLiveData.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl.datamodels; - -import androidx.lifecycle.MutableLiveData; -import android.content.Context; -import android.database.ContentObserver; -import android.net.Uri; - -import androidx.annotation.NonNull; - -import com.android.car.messenger.core.interfaces.AppFactory; - -/** - * Abstract class for Content Provider live data implementations - * - * @param <T> the class type emitted from the live data to observers - */ -public abstract class ContentProviderLiveData<T> extends MutableLiveData<T> { - @NonNull - private final ContentObserver mContentObserver = - new ContentObserver(null) { - @Override - public void onChange(boolean selfChange) { - onDataChange(); - } - }; - - @NonNull private final Uri[] mUris; - private boolean mIsRegistered = false; - - /** Constructor that takes in a list of content provider uris to observe */ - public ContentProviderLiveData(@NonNull Uri... uris) { - mUris = uris; - } - - @Override - protected void onActive() { - if (!mIsRegistered) { - for (Uri uri : mUris) { - getContext() - .getContentResolver() - .registerContentObserver( - uri, /* notifyForDescendants =*/ true, mContentObserver); - mIsRegistered = true; - } - } - } - - @Override - protected void onInactive() { - getContext().getContentResolver().unregisterContentObserver(mContentObserver); - mIsRegistered = false; - } - - /** Get Context for use in Live Data */ - @NonNull - public Context getContext() { - return AppFactory.get().getContext(); - } - - /** Abstract method called on data change */ - public abstract void onDataChange(); -} diff --git a/src/com/android/car/messenger/impl/datamodels/ConversationItemLiveData.java b/src/com/android/car/messenger/impl/datamodels/ConversationItemLiveData.java deleted file mode 100644 index 3d8184d..0000000 --- a/src/com/android/car/messenger/impl/datamodels/ConversationItemLiveData.java +++ /dev/null @@ -1,284 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl.datamodels; - -import static android.provider.Telephony.ThreadsColumns.READ; - -import static java.util.stream.Collectors.joining; - -import android.content.ContentValues; -import android.content.Context; -import android.content.SharedPreferences; -import android.database.Cursor; -import android.database.CursorIndexOutOfBoundsException; -import android.graphics.Bitmap; -import android.provider.Telephony.Mms; -import android.provider.Telephony.MmsSms; -import android.provider.Telephony.Sms; -import androidx.core.graphics.drawable.IconCompat; -import android.util.Pair; - -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.core.app.Person; - -import com.android.car.messenger.common.Conversation; -import com.android.car.messenger.common.Conversation.Message; -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.shared.MessageConstants; -import com.android.car.messenger.core.util.ConversationUtil; -import com.android.car.messenger.core.util.L; -import com.android.car.messenger.impl.datamodels.ConversationItemLiveData.ConversationChangeSet; -import com.android.car.messenger.impl.datamodels.util.AvatarUtil; -import com.android.car.messenger.impl.datamodels.util.ContactUtils; -import com.android.car.messenger.impl.datamodels.util.CursorUtils; -import com.android.car.messenger.impl.datamodels.util.MessageUtils; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -/** - * Conversation Item Observer for listening for changes for the Conversation Item Metadata - * - * <p>Returns a Conversation object holding the most relevant metadata for the Conversation. - * - * <p>{@link Conversation#getMessages() holds the relevant messages to read out to the user, - * in this priority: - * <ul> - * <li>Unread messages - * <li>Read messages after last reply - * </ul> - * <p>Given that the Content observer notifies for each cahgne to the telephony database, - * for optimization reasons, database search is limited number to - * the {@link ConversationItemLiveData#ITEM_LIMIT}. - */ -class ConversationItemLiveData extends ContentProviderLiveData<ConversationChangeSet> { - @NonNull private final String mConversationId; - private static final int ITEM_LIMIT = 10; - @NonNull private static final String COMMA_SEPARATOR = ", "; - - @NonNull - private final SharedPreferences.OnSharedPreferenceChangeListener mPreferenceChangeListener = - (sharedPreferences, key) -> onSharedPreferenceChangedInternal(key); - - /** Constructor that takes in a conversation id */ - ConversationItemLiveData(@NonNull String conversationId) { - super(Sms.CONTENT_URI, Mms.CONTENT_URI, MmsSms.CONTENT_URI); - mConversationId = conversationId; - } - - @Override - protected void onActive() { - super.onActive(); - SharedPreferences sharedPrefs = AppFactory.get().getSharedPreferences(); - sharedPrefs.registerOnSharedPreferenceChangeListener(mPreferenceChangeListener); - if (getValue() == null) { - onDataChange(); - } - } - - @Override - protected void onInactive() { - super.onInactive(); - SharedPreferences sharedPrefs = AppFactory.get().getSharedPreferences(); - sharedPrefs.unregisterOnSharedPreferenceChangeListener(mPreferenceChangeListener); - } - - // Handles on Data Change - @Override - public void onDataChange() { - try { - onDataChangeInternal(); - } catch (CursorIndexOutOfBoundsException e) { - L.w( - "Error fetching conversation item details. Posting null to trigger caller " - + "to remove conversation"); - postValue(null); - } - } - - private void onDataChangeInternal() { - if (!verifyChangeOccurred()) { - return; - } - Conversation conversation = getValue() == null ? null : getValue().mConversation; - Conversation.Builder conversationBuilder = - conversation == null - ? initConversationBuilder(mConversationId) - : conversation.toBuilder(); - Cursor messagesCursor = - CursorUtils.getMessagesCursor(mConversationId, ITEM_LIMIT, /* offset= */ 0); - // messages to read: first get unread messages - List<Message> messagesToRead = MessageUtils.getUnreadMessages(messagesCursor); - int unreadCount = messagesToRead.size(); - long lastReplyTimestamp = 0L; - - // if no unread messages and notify level is all, get read messages - if (messagesToRead.isEmpty()) { - Pair<List<Message>, Long> readMessagesAndReplyTimestamp = - MessageUtils.getReadMessagesAndReplyTimestamp(messagesCursor); - messagesToRead = readMessagesAndReplyTimestamp.first; - lastReplyTimestamp = readMessagesAndReplyTimestamp.second; - } - updateConversation(conversationBuilder, messagesToRead, unreadCount, lastReplyTimestamp); - } - - private boolean verifyChangeOccurred() { - // get the last message after offset to see if any change exists - // check the last message with only the connection time offset - Cursor messagesCursor = - CursorUtils.getMessagesCursor(mConversationId, /* limit= */ 1, /* offset= */ 0); - if (messagesCursor == null || !messagesCursor.moveToFirst()) { - return false; - } - Conversation conversation = getValue() == null ? null : getValue().mConversation; - Message prevLastMessage = ConversationUtil.getLastMessage(conversation); - Message message = MessageUtils.parseCurrentMessage(messagesCursor); - // checks to see if any change has been made to the last previous message, - // if status changes to read, message status will be different - // if status changes to replied messages, note: only timestamp will change, - // as we don't add reply messages to the message list - return message != null - && (prevLastMessage == null - || prevLastMessage.getMessageStatus() != message.getMessageStatus() - || ConversationUtil.getConversationTimestamp(conversation) - != message.getTimestamp()); - } - - @NonNull - private Conversation.Builder initConversationBuilder(@NonNull String conversationId) { - String userName = ContactUtils.DRIVER_NAME; - Conversation.Builder builder = - new Conversation.Builder( - new Person.Builder().setName(userName).build(), conversationId); - setConversationIconTitleAndParticipants(conversationId, builder); - builder.setMuted(getMutedList().contains(conversationId)); - return builder; - } - - private void setConversationIconTitleAndParticipants( - @NonNull String conversationId, Conversation.Builder builder) { - List<CharSequence> participantNames = new ArrayList<>(); - List<Bitmap> participantIcons = new ArrayList<>(); - List<Person> participants = - ContactUtils.getRecipients( - conversationId, - (name, bitmap) -> { - participantNames.add(name); - participantIcons.add(bitmap); - }); - builder.setParticipants(participants); - builder.setConversationTitle(participantNames.stream().collect(joining(COMMA_SEPARATOR))); - Bitmap bitmap = AvatarUtil.createGroupAvatar(getContext(), participantIcons); - if (bitmap != null) { - builder.setConversationIcon(IconCompat.createWithBitmap(bitmap)); - } - } - - private void updateConversation( - @NonNull Conversation.Builder conversationBuilder, - @NonNull List<Message> messages, - int unreadCount, - long lastReplyTimestamp) { - conversationBuilder.setMessages(messages).setUnreadCount(unreadCount); - ConversationUtil.setReplyTimestampAsAnExtra( - conversationBuilder, /* extras= */ null, lastReplyTimestamp); - Conversation conversation = conversationBuilder.build(); - postValue(new ConversationChangeSet(conversation, NotifyLevel.NEW_OR_UPDATED_MESSAGE)); - } - - @NonNull - private static Set<String> getMutedList() { - SharedPreferences sharedPreferences = AppFactory.get().getSharedPreferences(); - return sharedPreferences.getStringSet( - MessageConstants.KEY_MUTED_CONVERSATIONS, new HashSet<>()); - } - - private void onSharedPreferenceChangedInternal(@NonNull String key) { - Conversation item = getValue() == null ? null : getValue().mConversation; - if (!MessageConstants.KEY_MUTED_CONVERSATIONS.equals(key) || item == null) { - return; - } - Set<String> list = getMutedList(); - boolean isMuted = list.contains(mConversationId); - boolean wasPreviouslyMuted = item.isMuted(); - if (isMuted == wasPreviouslyMuted) { - return; - } - Conversation.Builder builder = item.toBuilder(); - builder.setMuted(isMuted); - postValue( - new ConversationChangeSet( - /* conversation= */ builder.build(), NotifyLevel.METADATA)); - } - - /** Mark conversation as Read */ - public static void markAsRead(@NonNull String conversationId) { - L.d("markAsRead for conversationId: " + conversationId); - Context context = AppFactory.get().getContext(); - ContentValues values = new ContentValues(); - values.put(READ, 1); - context.getContentResolver() - .update(CursorUtils.getConversationUri(conversationId), values, /* extras= */ null); - } - - public static class ConversationChangeSet { - - @NonNull private final Conversation mConversation; - @NotifyLevel private final int mChange; - - private ConversationChangeSet(@NonNull Conversation conversation, @NotifyLevel int change) { - mConversation = conversation; - mChange = change; - } - - @NonNull - public Conversation getConversation() { - return mConversation; - } - - @NotifyLevel - public int getChange() { - return mChange; - } - } - - /** Indicates the granularity of changes the observer wishes to observe */ - @IntDef( - value = { - NotifyLevel.METADATA, - NotifyLevel.NEW_OR_UPDATED_MESSAGE, - }) - @Retention(RetentionPolicy.SOURCE) - public @interface NotifyLevel { - /** - * When set, this indicates that the change included the metadata of the Conversation, such - * as the title, avatar or mute changes - */ - int METADATA = 0; - - /** - * When set, this indicates that the change included new or updated messages as part of the - * change set. - */ - int NEW_OR_UPDATED_MESSAGE = 1; - } -} diff --git a/src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceLiveData.java b/src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceLiveData.java deleted file mode 100644 index fddb9fc..0000000 --- a/src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceLiveData.java +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl.datamodels; - -import static android.provider.Telephony.MmsSms.CONTENT_CONVERSATIONS_URI; -import static android.provider.Telephony.TextBasedSmsColumns.SUBSCRIPTION_ID; -import static android.provider.Telephony.TextBasedSmsColumns.THREAD_ID; - -import android.content.ContentResolver; -import android.database.Cursor; -import android.net.Uri; -import android.telephony.SubscriptionInfo; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.util.L; -import com.android.car.messenger.impl.datamodels.ConversationsPerDeviceLiveData.ConversationIdChangeList; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** Returns {@link ConversationIdChangeList}, which holds the Conversation Ids Per SIM */ -class ConversationsPerDeviceLiveData extends ContentProviderLiveData<ConversationIdChangeList> { - @NonNull private static final Uri URI = CONTENT_CONVERSATIONS_URI; - - @NonNull - private static final String[] PROJECTION = { - SUBSCRIPTION_ID, THREAD_ID, - }; - - @NonNull private final ContentResolver mContentResolver; - private final int mAccountId; - - ConversationsPerDeviceLiveData(int accountId) { - super(URI); - mContentResolver = AppFactory.get().getContext().getContentResolver(); - mAccountId = accountId; - } - - @Nullable - private Cursor getCursor() { - return mContentResolver.query( - URI, - PROJECTION, - /* selection= */ SUBSCRIPTION_ID + "=" + mAccountId, - /* selectionArgs= */ null, - /* sortOrder= */ null, - /* cancellationSignal= */ null); - } - - @Override - protected void onActive() { - super.onActive(); - if (getValue() == null) { - onDataChange(); - } - } - - @Override - public void onDataChange() { - Cursor cursor = getCursor(); - if (cursor == null || !cursor.moveToFirst()) { - L.d("No conversation data found. Setting LiveData to null"); - postValue(new ConversationIdChangeList()); - return; - } - - ArrayList<String> currentConversationIds = new ArrayList<>(); - do { - String conversationId = cursor.getString(cursor.getColumnIndex(THREAD_ID)); - currentConversationIds.add(conversationId); - } while (cursor.moveToNext()); - - // get updated changes - Collection<String> prevConversationIds = getValueOrEmpty().getAllConversationIds(); - Set<String> newConversations = getDifference(currentConversationIds, prevConversationIds); - Set<String> removedConversations = - getDifference(prevConversationIds, currentConversationIds); - - if (newConversations.isEmpty() && removedConversations.isEmpty()) { - return; - } - - ConversationIdChangeList changeList = new ConversationIdChangeList(); - changeList.mConversationIds = currentConversationIds; - changeList.mAddedConversationIds = newConversations; - changeList.mRemovedConversationIds = removedConversations; - - postValue(changeList); - } - - @NonNull - private ConversationIdChangeList getValueOrEmpty() { - ConversationIdChangeList changeList = getValue(); - if (changeList == null) { - changeList = new ConversationIdChangeList(); - } - return changeList; - } - - /** - * Returns a set that contains a difference between the two lists - firstList - secondList = - * result - * - * <p>This essentially points out which items or changes are not present in firstList. - */ - @NonNull - private static Set<String> getDifference( - @NonNull Collection<String> firstList, @NonNull Collection<String> secondList) { - return firstList.stream() - .filter(it -> !secondList.contains(it)) - .collect(Collectors.toSet()); - } - - /** - * Holds the list of conversation ids per {@link SubscriptionInfo#getSubscriptionId()} - * Additional information such as which specific conversation ids have changed is also provided. - */ - public static class ConversationIdChangeList { - @NonNull private Collection<String> mConversationIds = new ArrayList<>(); - @NonNull private Collection<String> mRemovedConversationIds = new ArrayList<>(); - @NonNull private Collection<String> mAddedConversationIds = new ArrayList<>(); - - private ConversationIdChangeList() {} - - /* Returns the list of added conversation Ids */ - @NonNull - public Collection<String> getAllConversationIds() { - return mConversationIds; - } - - /* Returns the list of added conversation Ids */ - @NonNull - public Stream<String> getRemovedConversationIds() { - return mRemovedConversationIds.stream(); - } - - /* Returns the list of removed conversation Ids */ - @NonNull - public Stream<String> getAddedConversationIds() { - return mAddedConversationIds.stream(); - } - } -} diff --git a/src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java b/src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java deleted file mode 100644 index 7ae4a1f..0000000 --- a/src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java +++ /dev/null @@ -1,315 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl.datamodels; - -import static com.android.car.messenger.core.shared.MessageConstants.KEY_MUTED_CONVERSATIONS; - -import static java.util.Comparator.comparingLong; - -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MediatorLiveData; -import androidx.lifecycle.Observer; -import androidx.lifecycle.Transformations; -import android.content.SharedPreferences; -import android.net.Uri; -import android.provider.Telephony; -import android.telephony.SmsManager; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.common.Conversation; -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.interfaces.DataModel; -import com.android.car.messenger.core.models.UserAccount; -import com.android.car.messenger.core.util.ConversationUtil; -import com.android.car.messenger.core.util.L; -import com.android.car.messenger.impl.common.ProjectionStateListener; -import com.android.car.messenger.impl.datamodels.ConversationItemLiveData.ConversationChangeSet; -import com.android.car.messenger.impl.datamodels.ConversationItemLiveData.NotifyLevel; -import com.android.car.messenger.impl.datamodels.ConversationsPerDeviceLiveData.ConversationIdChangeList; -import com.android.car.messenger.impl.datamodels.UserAccountLiveData.UserAccountChangeList; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Set; -import java.util.stream.Collectors; - -/** Queries the telephony data model to retrieve the SMS/MMS messages */ -public class TelephonyDataModel implements DataModel { - - @NonNull - private final HashMap<String, LiveData<ConversationChangeSet>> mConvoIdToConversationLiveData = - new HashMap<>(); - - @NonNull - private final HashMap<Integer, ConversationsPerDeviceLiveData> - mAccountIdToConversationListLiveData = new HashMap<>(); - - @NonNull private final HashMap<String, Conversation> mConversationMap = new HashMap<>(); - - @NonNull - private static final Comparator<Conversation> sConversationComparator = - comparingLong(ConversationUtil::getConversationTimestamp).reversed(); - - @NonNull - ProjectionStateListener mProjectionStateListener = - new ProjectionStateListener(AppFactory.get().getContext()); - - @NonNull - @Override - public LiveData<Collection<UserAccount>> getAccounts() { - return Transformations.map( - UserAccountLiveData.getInstance(), UserAccountChangeList::getAccounts); - } - - @NonNull - @Override - public LiveData<Collection<Conversation>> getConversations(@NonNull UserAccount userAccount) { - MediatorLiveData<Collection<Conversation>> liveData = new MediatorLiveData<>(); - subscribeToConversationItemChanges( - userAccount, - liveData, - /* onConversationItemChanged= */ conversationChangeSet -> { - mConversationMap.put( - conversationChangeSet.getConversation().getId(), - conversationChangeSet.getConversation()); - liveData.postValue( - mConversationMap.values().stream() - .sorted(sConversationComparator) - .collect(Collectors.toList())); - }, - /* onConversationRemoved= */ conversationId -> { - mConversationMap.remove(conversationId); - liveData.postValue( - mConversationMap.values().stream() - .sorted(sConversationComparator) - .collect(Collectors.toList())); - }, - /* onEmpty= */ onEmpty -> liveData.postValue(new ArrayList<>())); - - return liveData; - } - - @NonNull - @Override - public LiveData<Conversation> getUnreadMessages() { - MediatorLiveData<Conversation> liveData = new MediatorLiveData<>(); - subscribeToUserAccountConversationItem( - liveData, - conversationChangeSet -> { - UserAccount userAccount = conversationChangeSet.first; - ConversationChangeSet conversationItemChangeSet = conversationChangeSet.second; - Conversation conversation = conversationItemChangeSet.getConversation(); - if (conversation.getUnreadCount() > 0 - && conversationItemChangeSet.getChange() - == NotifyLevel.NEW_OR_UPDATED_MESSAGE - && userAccount - .getConnectionTime() - .isBefore( - Instant.ofEpochMilli( - ConversationUtil.getConversationTimestamp( - conversation)))) { - if (hasProjectionInForeground(userAccount)) { - L.d("Ignoring new message as projection is in foreground"); - return; - } - liveData.postValue(conversation); - } - }); - return liveData; - } - - @Override - public void muteConversation(@NonNull String conversationId, boolean mute) { - SharedPreferences sharedPreferences = AppFactory.get().getSharedPreferences(); - Set<String> mutedConversations = - sharedPreferences.getStringSet(KEY_MUTED_CONVERSATIONS, new HashSet<>()); - Set<String> finalSet = new HashSet<>(mutedConversations); - if (mute) { - finalSet.add(conversationId); - } else { - finalSet.remove(conversationId); - } - sharedPreferences.edit().putStringSet(KEY_MUTED_CONVERSATIONS, finalSet).apply(); - } - - @Override - public void markAsRead(@NonNull String conversationId) { - ConversationItemLiveData.markAsRead(conversationId); - } - - @Override - public void sendMessage(@NonNull String conversationId, @NonNull String message) { - L.d("Sending a message to a conversation"); - String destination = - Uri.withAppendedPath(Telephony.Threads.CONTENT_URI, conversationId).toString(); - SmsManager.getDefault() - .sendTextMessage( - destination, - /* scAddress= */ null, - message, - /* sentIntent= */ null, - /* deliveryIntent= */ null); - } - - @Override - public void sendMessage(int accountId, @NonNull String phoneNumber, @NonNull String message) { - L.d("Sending a message to a phone number"); - SmsManager.getSmsManagerForSubscriptionId(accountId) - .sendTextMessage( - phoneNumber, - /* scAddress= */ null, - message, - /* sentIntent= */ null, - /* deliveryIntent= */ null); - } - - @Override - public void sendMessage( - @NonNull String iccId, @NonNull String phoneNumber, @NonNull String message) { - UserAccount userAccount = UserAccountLiveData.getUserAccount(iccId); - if (userAccount == null) { - L.d("Could not find User Account with specified iccId. Unable to send message"); - return; - } - sendMessage(userAccount.getId(), phoneNumber, message); - } - - /** Avoids crash when adding source */ - private <T, S> void safeAddSource( - @NonNull MediatorLiveData<T> liveData, - @NonNull LiveData<S> source, - @NonNull Observer<? super S> onChanged) { - try { - liveData.addSource(source, onChanged); - } catch (IllegalArgumentException e) { - L.w("We are already subscribed to the source, ignoring."); - } - } - - private <T> void subscribeToUserAccountConversationItem( - @NonNull MediatorLiveData<T> liveData, - @NonNull - Observer<? super Pair<UserAccount, ConversationChangeSet>> - onConversationChanged) { - safeAddSource( - liveData, - UserAccountLiveData.getInstance(), - userAccountChangeList -> { - userAccountChangeList - .getAddedAccounts() - .forEach( - userAccount -> - subscribeToConversationItemChanges( - userAccount, - liveData, - it -> - onConversationChanged.onChanged( - new Pair<>(userAccount, it)), - /* onConversationRemoved= */ null, - /* onEmpty= */ null)); - - userAccountChangeList - .getRemovedAccounts() - .forEach( - userAccount -> - liveData.removeSource( - getConversationIds(userAccount.getId()))); - }); - } - - private <T> void subscribeToConversationItemChanges( - @NonNull UserAccount userAccount, - @NonNull MediatorLiveData<T> liveData, - @NonNull Observer<? super ConversationChangeSet> onConversationItemChanged, - @Nullable Observer<String> onConversationRemoved, - @Nullable Observer<Boolean> onEmpty) { - safeAddSource( - liveData, - getConversationIds(userAccount.getId()), - conversationIdChangeList -> - subscribeToConversationItemChanges( - conversationIdChangeList, - liveData, - onConversationItemChanged, - onConversationRemoved, - onEmpty)); - } - - private <T> void subscribeToConversationItemChanges( - @NonNull ConversationIdChangeList conversationIdChangeList, - @NonNull MediatorLiveData<T> liveData, - @NonNull Observer<? super ConversationChangeSet> onConversationItemChanged, - @Nullable Observer<String> onConversationRemoved, - @Nullable Observer<Boolean> onEmpty) { - conversationIdChangeList - .getAddedConversationIds() - .forEach( - conversationId -> - safeAddSource( - liveData, - getConversationItem(conversationId), - it -> { - if (it == null) { - if (onConversationRemoved != null) { - onConversationRemoved.onChanged(conversationId); - } - return; - } - onConversationItemChanged.onChanged(it); - })); - - conversationIdChangeList - .getRemovedConversationIds() - .forEach( - conversationId -> { - liveData.removeSource(getConversationItem(conversationId)); - if (onConversationRemoved != null) { - onConversationRemoved.onChanged(conversationId); - } - }); - - if (conversationIdChangeList.getAllConversationIds().isEmpty()) { - L.d("No conversation lists found for user account."); - if (onEmpty != null) { - onEmpty.onChanged(true); - } - } - } - - private boolean hasProjectionInForeground(@NonNull UserAccount userAccount) { - return mProjectionStateListener.isProjectionInActiveForeground(userAccount.getIccId()); - } - - @NonNull - private ConversationsPerDeviceLiveData getConversationIds(int accountId) { - return mAccountIdToConversationListLiveData.computeIfAbsent( - accountId, it -> new ConversationsPerDeviceLiveData(accountId)); - } - - @NonNull - private LiveData<ConversationChangeSet> getConversationItem(@NonNull String conversationId) { - return mConvoIdToConversationLiveData.computeIfAbsent( - conversationId, it -> new ConversationItemLiveData(conversationId)); - } -} diff --git a/src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java b/src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java deleted file mode 100644 index ab09cbc..0000000 --- a/src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.car.messenger.impl.datamodels; - -import androidx.lifecycle.LiveData; -import android.content.Context; -import android.telephony.SubscriptionInfo; -import android.telephony.SubscriptionManager; -import android.telephony.SubscriptionManager.OnSubscriptionsChangedListener; -import android.telephony.TelephonyManager; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.models.UserAccount; -import com.android.car.messenger.impl.datamodels.UserAccountLiveData.UserAccountChangeList; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** - * Get the UserAccount of the currently active remote bluetooth SIM(s). The records will be sorted - * by {@link SubscriptionInfo#getSimSlotIndex} then by {@link SubscriptionInfo#getSubscriptionId}. - * - * <p>Requires Permission: {@link android.Manifest.permission#READ_PHONE_STATE READ_PHONE_STATE} or - * that the calling app has carrier privileges (see {@link TelephonyManager#hasCarrierPrivileges}). - * In the latter case, only records accessible to the calling app are returned. - * - * <p>Listens for changes via {@link OnSubscriptionsChangedListener} and the latest data can be - * observed and retrieved via {@link LiveData#getValue()}. - * - * <p>Sorts list of the currently {@link SubscriptionInfo} records available on the device - * - * <ul> - * <li>If the list is empty then there are no {@link SubscriptionInfo} records currently - * available. - * <li>if the list is non-empty the list is sorted by {@link SubscriptionInfo#getSimSlotIndex} - * then by {@link SubscriptionInfo#getSubscriptionId}. - * </ul> - */ -public class UserAccountLiveData extends LiveData<UserAccountChangeList> { - @NonNull private final SubscriptionManager mSubscriptionManager; - - @NonNull - private final OnSubscriptionsChangedListener mOnChangeListener = - new OnSubscriptionsChangedListener() { - @Override - public void onSubscriptionsChanged() { - loadValue(); - } - }; - - @Nullable private static UserAccountLiveData sInstance; - - private UserAccountLiveData() { - Context context = AppFactory.get().getContext(); - mSubscriptionManager = context.getSystemService(SubscriptionManager.class); - mSubscriptionManager.addOnSubscriptionsChangedListener(mOnChangeListener); - } - - /** Gets the instance of {@link UserAccountLiveData} */ - @NonNull - public static UserAccountLiveData getInstance() { - if (sInstance == null) { - sInstance = new UserAccountLiveData(); - } - return sInstance; - } - - @Override - protected void onActive() { - if (getValue() == null) { - loadValue(); - } - } - - private void loadValue() { - List<UserAccount> accounts = - mSubscriptionManager.getActiveSubscriptionInfoList().stream() - .map( - it -> { - int subscriptionId = it.getSubscriptionId(); - String iccId = it.getIccId(); - String displayName = it.getDisplayName().toString(); - return new UserAccount( - subscriptionId, displayName, iccId, Instant.now()); - }) - .collect(Collectors.toList()); - - // get the removed accounts and added accounts. - Collection<UserAccount> prevUserAccounts = getValueOrEmpty().mAccounts; - Set<UserAccount> addedAccounts = getDifference(accounts, prevUserAccounts); - Set<UserAccount> removedAccounts = getDifference(prevUserAccounts, accounts); - - if (addedAccounts.isEmpty() && removedAccounts.isEmpty()) { - return; - } - - UserAccountChangeList newAccountChangeList = new UserAccountChangeList(); - newAccountChangeList.mAccounts = accounts; - newAccountChangeList.mAddedAccounts = addedAccounts; - newAccountChangeList.mRemovedAccounts = removedAccounts; - postValue(newAccountChangeList); - } - - /** - * Returns User Account with the given iccId - * - * @param iccId Maps to the {@link SubscriptionInfo#getIccId()} - */ - @Nullable - public static UserAccount getUserAccount(@NonNull String iccId) { - Collection<UserAccount> userAccounts = getValueOrEmpty().getAccounts(); - for (UserAccount account : userAccounts) { - if (iccId.equals(account.getIccId())) { - return account; - } - } - return null; - } - - /** - * Returns a list that contains a difference between the two lists - firstList - secondList = - * result This essentially points out which items or changes are not present in firstList. - */ - @NonNull - public static Set<UserAccount> getDifference( - @NonNull Collection<UserAccount> firstList, - @NonNull Collection<UserAccount> secondList) { - return firstList.stream() - .filter(it -> secondList.stream().noneMatch(item -> item.getId() == it.getId())) - .collect(Collectors.toSet()); - } - - /** A list of {@link UserAccount} with information on what changed */ - public static class UserAccountChangeList { - @NonNull private Collection<UserAccount> mAccounts = new ArrayList<>(); - @NonNull private Collection<UserAccount> mRemovedAccounts = new ArrayList<>(); - @NonNull private Collection<UserAccount> mAddedAccounts = new ArrayList<>(); - - /** Get all user accounts */ - @NonNull - public Collection<UserAccount> getAccounts() { - return mAccounts; - } - - /** Get removed accounts for this change list */ - @NonNull - public Stream<UserAccount> getRemovedAccounts() { - return mRemovedAccounts.stream(); - } - - /** Gets added accounts for this change list */ - @NonNull - public Stream<UserAccount> getAddedAccounts() { - return mAddedAccounts.stream(); - } - } - - /** Returns the value or empty changelist, if value is null */ - @NonNull - private static UserAccountChangeList getValueOrEmpty() { - UserAccountChangeList value = sInstance != null ? sInstance.getValue() : null; - if (value == null) { - value = new UserAccountChangeList(); - } - return value; - } -} diff --git a/src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java b/src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java deleted file mode 100644 index fbd9818..0000000 --- a/src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java +++ /dev/null @@ -1,412 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl.datamodels.util; - -import static java.lang.Math.min; - -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.Bitmap.Config; -import android.graphics.BitmapShader; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Matrix; -import android.graphics.Paint; -import android.graphics.Path; -import android.graphics.RectF; -import android.graphics.Shader.TileMode; -import android.text.TextUtils; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.R; -import com.android.car.messenger.core.ui.shared.LetterTileDrawable; - -import java.util.List; - -/** - * Avatar Utils for generating conversation and contact avatars - * - * <p>For historical context, AvatarUtil is derived from Android Messages implementation of group - * avatars particularly from these sources: - * - * <p>AvatarGroupRequestDescriptor#generateDestRectArray: - * packages/apps/Messaging/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor - * - * <p>CompositeImageRequest#loadMediaInternal: - * packages/apps/Messaging/src/com/android/messaging/datamodel/media/CompositeImageRequest - * - * <p>ImageUtils#drawBitmapWithCircleOnCanvas: - * packages/apps/Messaging/src/com/android/messaging/util/ImageUtils.java - * - * <p>Current implementation is close to reference. However, future iterations can diverge. - */ -public final class AvatarUtil { - - private AvatarUtil() {} - - private static final class GroupAvatarConfigs { - int mWidth; - int mHeight; - int mMaximumGroupSize; - int mBackgroundColor; - int mStrokeColor; - boolean mFillBackground; - } - - /** - * Supports creating a group avatar: a minimum of 1 avatar and a maximum of four avatars are - * supported. Any avatars beyond the 4th index is ignored. - */ - @Nullable - public static Bitmap createGroupAvatar( - @NonNull Context context, @Nullable List<Bitmap> participantsIcon) { - if (participantsIcon == null || participantsIcon.isEmpty()) { - return null; - } - - GroupAvatarConfigs groupAvatarConfigs = new GroupAvatarConfigs(); - Resources resources = context.getResources(); - int size = resources.getDimensionPixelSize(R.dimen.conversation_avatar_width); - groupAvatarConfigs.mWidth = size; - groupAvatarConfigs.mHeight = size; - groupAvatarConfigs.mMaximumGroupSize = - resources.getInteger(R.integer.group_avatar_max_group_size); - groupAvatarConfigs.mBackgroundColor = - resources.getColor(R.color.group_avatar_background_color, context.getTheme()); - groupAvatarConfigs.mStrokeColor = - resources.getColor(R.color.group_avatar_stroke_color, context.getTheme()); - groupAvatarConfigs.mFillBackground = - context.getResources().getBoolean(R.bool.group_avatar_fill_background); - - if (participantsIcon.size() == 1 || groupAvatarConfigs.mMaximumGroupSize == 1) { - return participantsIcon.get(0); - } - - return createGroupAvatarBitmap(participantsIcon, groupAvatarConfigs); - } - - /** - * Resolves person avatar to either the provided bitmap clipped into a circle or a letter - * drawable - */ - @Nullable - public static Bitmap resolvePersonAvatar( - @NonNull Context context, @Nullable Bitmap bitmap, @Nullable CharSequence name) { - if (bitmap != null) { - return AvatarUtil.createClippedCircle(bitmap); - } else { - return createLetterTile(context, name); - } - } - - /** - * Create a {@link Bitmap} for the given name - * - * @param name will decide the color for the drawable. If null, a default color will be used. - */ - @Nullable - private static Bitmap createLetterTile(@NonNull Context context, @Nullable CharSequence name) { - if (TextUtils.isEmpty(name)) { - return null; - } - char firstInitial = name.charAt(0); - String letters = Character.isLetter(firstInitial) ? Character.toString(firstInitial) : null; - LetterTileDrawable drawable = - new LetterTileDrawable(context.getResources(), letters, name.toString()); - int size = context.getResources().getDimensionPixelSize(R.dimen.conversation_avatar_width); - return drawable.toBitmap(size); - } - - /** Returns a circle-clipped bitmap */ - @NonNull - private static Bitmap createClippedCircle(Bitmap bitmap) { - final int width = bitmap.getWidth(); - final int height = bitmap.getHeight(); - final Bitmap outputBitmap = Bitmap.createBitmap(width, height, Config.ARGB_8888); - - final Path path = new Path(); - path.addCircle( - (float) (width / 2), - (float) (height / 2), - (float) min(width, (height / 2)), - Path.Direction.CCW); - - final Canvas canvas = new Canvas(outputBitmap); - canvas.clipPath(path); - canvas.drawBitmap(bitmap, 0, 0, null); - return outputBitmap; - } - - /** Creates a group avatar bitmap */ - @NonNull - private static Bitmap createGroupAvatarBitmap( - @NonNull List<Bitmap> participantsIcon, GroupAvatarConfigs groupAvatarConfigs) { - int width = groupAvatarConfigs.mWidth; - int height = groupAvatarConfigs.mHeight; - Bitmap bitmap = createOrReuseBitmap(width, height, Color.TRANSPARENT); - Canvas canvas = new Canvas(bitmap); - RectF[] rect = - generateDestRectArray( - width, - height, - /* cropToCircle= */ true, - min(participantsIcon.size(), groupAvatarConfigs.mMaximumGroupSize)); - - for (int i = 0; i < rect.length; i++) { - RectF avatarDestOnGroup = rect[i]; - // Draw the bitmap into a smaller size with a circle mask. - Bitmap resourceBitmap = participantsIcon.get(i); - RectF resourceRect = - new RectF( - /* left= */ 0, - /* top= */ 0, - resourceBitmap.getWidth(), - resourceBitmap.getHeight()); - Bitmap smallCircleBitmap = - createOrReuseBitmap( - Math.round(avatarDestOnGroup.width()), - Math.round(avatarDestOnGroup.height()), - Color.TRANSPARENT); - RectF smallCircleRect = - new RectF( - /* left= */ 0, - /* top= */ 0, - smallCircleBitmap.getWidth(), - smallCircleBitmap.getHeight()); - Canvas smallCircleCanvas = new Canvas(smallCircleBitmap); - drawBitmapWithCircleOnCanvas( - resourceBitmap, - smallCircleCanvas, - resourceRect, - smallCircleRect, - groupAvatarConfigs.mFillBackground, - groupAvatarConfigs.mBackgroundColor, - groupAvatarConfigs.mStrokeColor); - Matrix matrix = new Matrix(); - matrix.setRectToRect(smallCircleRect, avatarDestOnGroup, Matrix.ScaleToFit.FILL); - canvas.drawBitmap(smallCircleBitmap, matrix, new Paint(Paint.ANTI_ALIAS_FLAG)); - } - - return bitmap; - } - - /** - * Given the source bitmap and a canvas, draws the bitmap through a circular mask. Only draws a - * circle with diameter equal to the destination width. - * - * @param bitmap The source bitmap to draw. - * @param canvas The canvas to draw it on. - * @param source The source bound of the bitmap. - * @param dest The destination bound on the canvas. - * @param fillBackground when set, fill the circle with backgroundColor - * @param strokeColor draw a border outside the circle with strokeColor - */ - private static void drawBitmapWithCircleOnCanvas( - @NonNull Bitmap bitmap, - @NonNull Canvas canvas, - @NonNull RectF source, - @NonNull RectF dest, - boolean fillBackground, - int backgroundColor, - int strokeColor) { - // Draw bitmap through shader first. - final BitmapShader shader = new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP); - final Matrix matrix = new Matrix(); - - // Fit bitmap to bounds. - matrix.setRectToRect(source, dest, Matrix.ScaleToFit.CENTER); - - shader.setLocalMatrix(matrix); - Paint bitmapPaint = new Paint(); - - bitmapPaint.setAntiAlias(true); - if (fillBackground) { - bitmapPaint.setColor(backgroundColor); - canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint); - } - - bitmapPaint.setShader(shader); - canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint); - bitmapPaint.setShader(null); - - if (strokeColor != Color.TRANSPARENT) { - final Paint stroke = new Paint(); - stroke.setAntiAlias(true); - stroke.setColor(strokeColor); - stroke.setStyle(Paint.Style.STROKE); - final float strokeWidth = 6f; - stroke.setStrokeWidth(strokeWidth); - canvas.drawCircle( - dest.centerX(), - dest.centerX(), - /* radius= */ dest.width() / 2f - stroke.getStrokeWidth() / 2f, - stroke); - } - } - - @NonNull - private static Bitmap createOrReuseBitmap(int width, int height, @ColorInt int background) { - Bitmap bitmap = - Bitmap.createBitmap(width, height, /* Bitmap.Config= */ Bitmap.Config.ARGB_8888); - bitmap.eraseColor(background); - return bitmap; - } - - /** - * Generates an array of {@link RectF} which represents where each of the individual avatar - * should be located in the final group avatar image. The location of each avatar depends on the - * size of the group and the size of the overall group avatar size. If we're cropping to a - * circle, inset the rects so the circle surrounds all the mini-avatars. - */ - public static RectF[] generateDestRectArray( - int desiredWidth, int desiredHeight, boolean cropToCircle, int groupSize) { - float halfWidth = desiredWidth / 2F; - float halfHeight = desiredHeight / 2F; - - // If we're cropping to a circle, calculate an inset so that all the mini-avatars will fit - // inside the circle. - float inset = - cropToCircle ? (float) ((Math.hypot(halfWidth, halfHeight) - halfWidth) / 2f) : 0F; - RectF[] destArray = new RectF[groupSize]; - switch (groupSize) { - case 2: - /* - * +-------+ - * | 0 | | - * +-------+ - * | | 1 | - * +-------+ * - * We want two circles which touches in the center. To get this we know that - * the diagonal - * of the overall group avatar is squareRoot(2) * w We also know that the two - * circles - * touches the at the center of the overall group avatar and the distance from - * the center of - * the circle to the corner of the group avatar is radius * squareRoot(2). - * Therefore, the - * following emerges. - * - * w * squareRoot(2) = 2 (radius + radius * squareRoot(2)) Solving for radius - * we get: d = - * 2 * radius = ( squareRoot(2) / (squareRoot(2) + 1)) * w d = (2 - squareRoot(2) - * ) * w - */ - float diameter = (float) ((2 - Math.sqrt(2)) * ((float) desiredWidth - inset)); - destArray[0] = new RectF(inset, inset, diameter, diameter); - destArray[1] = - new RectF( - /* left= */ (float) desiredWidth - diameter, - /* top= */ (float) desiredHeight - diameter, - /* right= */ (float) desiredWidth - inset, - /* bottom= */ (float) desiredHeight - inset); - break; - case 3: - /* - * +-------+ - * | | 0 | | - * +-------+ - * | 1 | 2 | - * +-------+ - * i0 - * |\ - * a | \ c - * --- i2 - * b - * - * a = radius * squareRoot(3) due to the triangle being a 30-60-90 right - * triangle. b = - * radius of circle c = 2 * radius of circle - * - * All three of the images are circles and therefore image zero will not touch - * image one - * or image two. Move image zero down so it touches image one and image two. This - * can be - * done by keeping image zero in the center and moving it down slightly. The - * amount to move - * down can be calculated by solving a right triangle. We know that the center x - * of image - * two to the center x of image zero is the radius of the circle, this is the - * length of edge - * b. Also we know that the distance from image zero to image two's center is 2 * - * radius, - * edge c. From this we know that the distance from center y of image two to - * center y of - * image one, edge a, is equal to radius * squareRoot(3) due to this triangle - * being a - * 30-60-90 right triangle. - */ - float quarterWidth = (float) desiredWidth / 4F; - float threeQuarterWidth = 3 * quarterWidth; - float radius = cropToCircle ? (halfHeight - inset) / 2 : (float) desiredHeight / 4F; - float imageTwoCenterY = (float) desiredHeight - radius; - float lengthOfEdgeA = (float) (radius * Math.sqrt(3)); - float imageZeroCenterY = imageTwoCenterY - lengthOfEdgeA; - float imageZeroTop = imageZeroCenterY - radius - 2 * inset; - float imageZeroBottom = imageZeroCenterY + radius - 2 * inset; - destArray[0] = - new RectF( - quarterWidth, imageZeroTop, - threeQuarterWidth, imageZeroBottom); - destArray[1] = - new RectF( - inset, - /* top= */ halfHeight - inset, - halfWidth, - /* bottom= */ (float) desiredHeight - 2 * inset); - destArray[2] = - new RectF( - halfWidth, - /* top= */ halfHeight - inset, - /* right= */ (float) desiredWidth - inset, - /* bottom= */ (float) desiredHeight - 2 * inset); - break; - default: - /* - * +-------+ - * | 0 | 1 | - * +-------+ - * | 2 | 3 | - * +-------+ - */ - destArray[0] = new RectF(inset, inset, halfWidth, halfHeight); - destArray[1] = - new RectF( - halfWidth, - inset, - /* right= */ (float) desiredWidth - inset, - halfHeight); - destArray[2] = - new RectF( - inset, - halfHeight, - halfWidth, - /* bottom= */ (float) desiredHeight - inset); - destArray[3] = - new RectF( - halfWidth, - halfHeight, - /* right= */ (float) desiredWidth - inset, - /* bottom= */ (float) desiredHeight - inset); - break; - } - return destArray; - } -} diff --git a/src/com/android/car/messenger/impl/datamodels/util/ContactUtils.java b/src/com/android/car/messenger/impl/datamodels/util/ContactUtils.java deleted file mode 100644 index 26f1906..0000000 --- a/src/com/android/car/messenger/impl/datamodels/util/ContactUtils.java +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.car.messenger.impl.datamodels.util; - -import static android.provider.BaseColumns._ID; -import static android.provider.ContactsContract.PhoneLookup.CONTENT_FILTER_URI; -import static android.provider.Telephony.ThreadsColumns.RECIPIENT_IDS; - -import android.content.ContentUris; -import android.content.Context; -import android.content.res.AssetFileDescriptor; -import android.database.Cursor; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.net.Uri; -import android.provider.ContactsContract; -import android.provider.Telephony.MmsSms; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.Person; - -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.util.L; - -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.List; -import java.util.function.BiConsumer; - -/** Contact Utils for getting information on a contact */ -public class ContactUtils { - @NonNull - private static final Uri SINGLE_CANONICAL_ADDRESS_URI = - MmsSms.CONTENT_URI.buildUpon().appendPath("canonical-address").build(); - - @NonNull private static final String RECIPIENT_SPLIT_SEPARATOR = " "; - @NonNull public static final String DRIVER_NAME = "Driver"; - - @NonNull - private static final String[] PROJECTION = - new String[] { - ContactsContract.PhoneLookup.DISPLAY_NAME, - ContactsContract.PhoneLookup.CONTACT_ID, - _ID, - ContactsContract.PhoneLookup.PHOTO_ID, - ContactsContract.PhoneLookup.PHOTO_THUMBNAIL_URI, - ContactsContract.PhoneLookup.PHOTO_FILE_ID, - ContactsContract.PhoneLookup.PHOTO_URI - }; - - private ContactUtils() {} - /** - * Get the list of recipients as {@link Person} for the given conversation id - * - * @param conversationId The conversation id to retrieve the list of participants - * @param processParticipant A nullable method to further process an individual participant - */ - public static List<Person> getRecipients( - @NonNull String conversationId, - @Nullable BiConsumer<String, Bitmap> processParticipant) { - String[] recipientIds = getRecipientIds(conversationId); - List<Person> participants = new ArrayList<>(); - Context context = AppFactory.get().getContext(); - for (String contactId : recipientIds) { - long contactIdLong = Long.parseLong(contactId); - String number = getCanonicalAddressesFromRecipientIds(context, contactIdLong); - if (number == null) { - L.e("No phone number found for contactId: " + contactId); - continue; - } - Person person = getPerson(context, number, processParticipant); - participants.add(person); - } - - return participants; - } - - private static String[] getRecipientIds(@NonNull String conversationId) { - Cursor threadCursor = CursorUtils.getThreadCursor(conversationId); - threadCursor.moveToFirst(); - return threadCursor - .getString(threadCursor.getColumnIndex(RECIPIENT_IDS)) - .split(RECIPIENT_SPLIT_SEPARATOR); - } - - /** - * Get Profile information for the contact, including the contact name and the contact avatar if - * available - */ - @NonNull - static Person getPerson( - @NonNull Context context, - @NonNull String phoneNo, - @Nullable BiConsumer<String, Bitmap> processParticipant) { - String name = phoneNo; - Bitmap bitmap = null; - - Uri uri = CONTENT_FILTER_URI.buildUpon().appendEncodedPath(Uri.encode(phoneNo)).build(); - Cursor cursor = CursorUtils.simpleQueryWithProjection(context, uri, PROJECTION); - if (cursor != null && cursor.moveToFirst()) { - name = - cursor.getString( - cursor.getColumnIndex(ContactsContract.PhoneLookup.DISPLAY_NAME)); - String thumbnailPath = - cursor.getString(cursor.getColumnIndex(ContactsContract.PhoneLookup.PHOTO_URI)); - - if (thumbnailPath != null && processParticipant != null) { - try { - Uri thumbnailUri = Uri.parse(thumbnailPath); - AssetFileDescriptor fd = - context.getContentResolver().openAssetFileDescriptor(thumbnailUri, "r"); - if (fd != null) { - InputStream stream = fd.createInputStream(); - bitmap = BitmapFactory.decodeStream(stream); - fd.close(); - } - } catch (IOException e) { - L.e(e.toString()); - } - } - } - - if (cursor != null && !cursor.isClosed()) { - cursor.close(); - } - - // don't include icon when building out the Person class in order - // to reduce the size of individual messages, instead pass it to the caller - // to build out avatar for the entire conversation - if (processParticipant != null) { - bitmap = AvatarUtil.resolvePersonAvatar(context, bitmap, name); - processParticipant.accept(name, bitmap); - } - - return new Person.Builder().setUri(phoneNo).setName(name).build(); - } - - @Nullable - private static String getCanonicalAddressesFromRecipientIds( - @NonNull Context context, long contactId) { - Cursor cursor = - CursorUtils.simpleQuery( - context, - ContentUris.withAppendedId(SINGLE_CANONICAL_ADDRESS_URI, contactId)); - if (cursor != null) { - try { - if (cursor.moveToFirst()) { - String rawNumber = cursor.getString(0); - if (!TextUtils.isEmpty(rawNumber)) { - return rawNumber; - } - } - } finally { - cursor.close(); - } - } - L.d("No canonical address found for recipient id"); - return null; - } -} diff --git a/src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java b/src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java deleted file mode 100644 index 0292ebe..0000000 --- a/src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.car.messenger.impl.datamodels.util; - -import static android.provider.BaseColumns._ID; -import static android.provider.Telephony.BaseMmsColumns.CONTENT_TYPE; -import static android.provider.Telephony.MmsSms.CONTENT_CONVERSATIONS_URI; -import static android.provider.Telephony.TextBasedSmsColumns.ADDRESS; -import static android.provider.Telephony.TextBasedSmsColumns.BODY; -import static android.provider.Telephony.TextBasedSmsColumns.SUBSCRIPTION_ID; -import static android.provider.Telephony.TextBasedSmsColumns.THREAD_ID; -import static android.provider.Telephony.TextBasedSmsColumns.TYPE; -import static android.provider.Telephony.ThreadsColumns.DATE; -import static android.provider.Telephony.ThreadsColumns.READ; -import static android.provider.Telephony.ThreadsColumns.RECIPIENT_IDS; - -import android.content.ContentResolver; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.provider.Telephony; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.car.messenger.core.interfaces.AppFactory; - -/** Cursor Utils to get quick cursor or uri telephony information */ -public class CursorUtils { - private CursorUtils() {} - // This URI provides all the metadata for a thread. - // Appending simple=true is important to get the recipient_ids for the conversation. - @NonNull - public static final Uri THREAD_INFO_URI = - CONTENT_CONVERSATIONS_URI.buildUpon().appendQueryParameter("simple", "true").build(); - - @NonNull protected static final String[] THREAD_INFO_PROJECTION = {_ID, RECIPIENT_IDS, READ}; - - @NonNull - protected static final String[] CONTENT_CONVERSATION_PROJECTION = { - _ID, TYPE, DATE, READ, CONTENT_TYPE, BODY, ADDRESS, THREAD_ID, SUBSCRIPTION_ID - }; - - @NonNull - private static final String DEFAULT_SORT_ORDER = Telephony.TextBasedSmsColumns.DATE + " DESC"; - - /** - * Get simplified thread cursor with metadata information on the thread, such as recipient ids - */ - @Nullable - public static Cursor getThreadCursor(@NonNull String threadId) { - Context context = AppFactory.get().getContext(); - ContentResolver contentResolver = context.getContentResolver(); - return contentResolver.query( - THREAD_INFO_URI, - THREAD_INFO_PROJECTION, - _ID + "=" + threadId, - /* selectionArgs= */ null, - DEFAULT_SORT_ORDER); - } - - /** - * Get the message cursor in descending order for - * - * @param conversationId The conversation or thread id for the conversation - * @param limit The maximum number of message rows to fetch - * @param offset The starting point in timestamp in millisecond to fetch for data - */ - @Nullable - public static Cursor getMessagesCursor(@NonNull String conversationId, int limit, long offset) { - Context context = AppFactory.get().getContext(); - ContentResolver contentResolver = context.getContentResolver(); - return contentResolver.query( - getConversationUri(conversationId), - CONTENT_CONVERSATION_PROJECTION, - DATE + " > " + offset, - /* selectionArgs= */ null, - DEFAULT_SORT_ORDER + " LIMIT " + limit); - } - - /** Gets the Conversation Uri for the Conversation with specified conversationId */ - @NonNull - public static Uri getConversationUri(@NonNull String conversationId) { - return CONTENT_CONVERSATIONS_URI.buildUpon().appendPath(conversationId).build(); - } - - /** Returns a cursor query with the uri provided, with no filtering or projection */ - @Nullable - public static Cursor simpleQuery(@NonNull Context context, @NonNull Uri uri) { - return context.getContentResolver().query(uri, null, null, null, null); - } - - /** Returns a cursor query given a uri and projection */ - @Nullable - public static Cursor simpleQueryWithProjection( - @NonNull Context context, @NonNull Uri uri, @Nullable String[] projection) { - return context.getContentResolver().query(uri, projection, null, null, null); - } - - /** Returns a cursor query given a uri and selection */ - @Nullable - public static Cursor simpleQueryWithSelection( - @NonNull Context context, @NonNull Uri uri, @Nullable String selection) { - return context.getContentResolver().query(uri, null, selection, null, null); - } -} diff --git a/src/com/android/car/messenger/impl/datamodels/util/MessageUtils.java b/src/com/android/car/messenger/impl/datamodels/util/MessageUtils.java deleted file mode 100644 index 3aa4c86..0000000 --- a/src/com/android/car/messenger/impl/datamodels/util/MessageUtils.java +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl.datamodels.util; - -import static com.android.car.messenger.common.Conversation.Message.MessageStatus.MESSAGE_STATUS_NONE; -import static com.android.car.messenger.common.Conversation.Message.MessageStatus.MESSAGE_STATUS_READ; -import static com.android.car.messenger.common.Conversation.Message.MessageStatus.MESSAGE_STATUS_UNREAD; - -import static java.util.Comparator.comparingLong; - -import android.content.Context; -import android.database.Cursor; -import android.provider.Telephony.TextBasedSmsColumns; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.Person; - -import com.android.car.messenger.common.Conversation; -import com.android.car.messenger.common.Conversation.Message; -import com.android.car.messenger.common.Conversation.Message.MessageStatus; -import com.android.car.messenger.common.Conversation.Message.MessageType; -import com.android.car.messenger.core.interfaces.AppFactory; -import com.android.car.messenger.core.util.L; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Function; - -/** Message Parser that provides useful static methods to parse 1-1 and Group MMS messages. */ -public final class MessageUtils { - - /** - * Gets all unread messages in cursor - * - * @param messagesCursor The messageCursor in descending order - */ - @NonNull - public static List<Message> getUnreadMessages(@Nullable Cursor messagesCursor) { - List<Message> unreadMessages = new ArrayList<>(); - MessageUtils.forEachDesc( - messagesCursor, - message -> { - if (message.getMessageStatus() == MessageStatus.MESSAGE_STATUS_UNREAD) { - unreadMessages.add(message); - return true; - } - return false; - }); - unreadMessages.sort(comparingLong(Message::getTimestamp)); - return unreadMessages; - } - - /** - * Gets Read Messages and Reply Timestamp. - * - * @param messagesCursor MessageCursor in descending order - */ - @NonNull - public static Pair<List<Message>, Long> getReadMessagesAndReplyTimestamp( - @Nullable Cursor messagesCursor) { - List<Message> readMessages = new ArrayList<>(); - AtomicReference<Long> lastReply = new AtomicReference<>(0L); - MessageUtils.forEachDesc( - messagesCursor, - message -> { - // Desired impact: 4. Reply -> 3. Messages -> 2. Reply -> 1 Messages (stop - // parsing at 2.) - // lastReply references 4., messages references 3. - // Desired impact: 3. Messages -> 2. Reply -> 1. Messages (stop parsing at 2.) - // lastReply references 2., messages references 3. - int messageStatus = message.getMessageStatus(); - if (message.getMessageType() == MessageType.MESSAGE_TYPE_SENT) { - lastReply.set(message.getTimestamp()); - return readMessages.isEmpty(); - } else { - if (messageStatus == MessageStatus.MESSAGE_STATUS_READ - || messageStatus == MessageStatus.MESSAGE_STATUS_NONE) { - readMessages.add(message); - return true; - } - } - return false; - }); - readMessages.sort(comparingLong(Message::getTimestamp)); - return new Pair<>(readMessages, lastReply.get()); - } - - /** - * Parses each message in the cursor and returns the item for further processing - * - * @param messageCursor The message cursor to be parsed for SMS and MMS messages - * @param processor A consumer that takes in the {@link Message} and returns true for the method - * to continue parsing the cursor or false to return. - */ - public static void forEachDesc( - @Nullable Cursor messageCursor, @NonNull Function<Message, Boolean> processor) { - if (messageCursor == null || !messageCursor.moveToFirst()) { - return; - } - Context context = AppFactory.get().getContext(); - boolean moveToNext = true; - boolean hasBeenRepliedTo = false; - do { - Message message; - try { - message = parseMessageAtPoint(context, messageCursor, hasBeenRepliedTo); - } catch (IllegalArgumentException e) { - e.printStackTrace(); - L.d("Message was not able to be parsed. Skipping."); - continue; - } - if (message.getMessageType() == MessageType.MESSAGE_TYPE_SENT) { - hasBeenRepliedTo = true; - } - moveToNext = processor.apply(message); - } while (messageCursor.moveToNext() && moveToNext); - } - - /** - * Parses each message in the cursor and returns the item for further processing - * - * @param messageCursor The message cursor to be parsed for SMS and MMS messages and returns - * true for the method to continue parsing the cursor or false to return. - */ - @Nullable - public static Message parseCurrentMessage(@NonNull Cursor messageCursor) { - Message message = null; - Context context = AppFactory.get().getContext(); - try { - message = parseMessageAtPoint(context, messageCursor, false); - } catch (IllegalArgumentException e) { - e.printStackTrace(); - L.d("Message was not able to be parsed. Skipping."); - } - return message; - } - - /** - * Parses message at the point in cursor. - * - * @throws IllegalArgumentException if desired columns are missing. - * @see CursorUtils#CONTENT_CONVERSATION_PROJECTION - */ - @NonNull - private static Conversation.Message parseMessageAtPoint( - @NonNull Context context, @NonNull Cursor cursor, boolean userHasReplied) { - MmsSmsMessage msg = - MmsUtils.isMms(cursor) - ? MmsUtils.parseMms(context, cursor) - : SmsUtils.parseSms(cursor); - Person person = - ContactUtils.getPerson(context, msg.mPhoneNumber, /* processParticipant= */ null); - Conversation.Message message = - new Conversation.Message(msg.mBody, msg.mDate.toEpochMilli(), person); - if (msg.mType == TextBasedSmsColumns.MESSAGE_TYPE_SENT) { - message.setMessageType(MessageType.MESSAGE_TYPE_SENT); - message.setMessageStatus(MESSAGE_STATUS_NONE); - } else { - int status = - (msg.mRead || userHasReplied) ? MESSAGE_STATUS_READ : MESSAGE_STATUS_UNREAD; - message.setMessageType(MessageType.MESSAGE_TYPE_INBOX); - message.setMessageStatus(status); - } - return message; - } - - private MessageUtils() {} -} diff --git a/src/com/android/car/messenger/impl/datamodels/util/MmsSmsMessage.java b/src/com/android/car/messenger/impl/datamodels/util/MmsSmsMessage.java deleted file mode 100644 index cb76308..0000000 --- a/src/com/android/car/messenger/impl/datamodels/util/MmsSmsMessage.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl.datamodels.util; - -import java.time.Instant; - -/** MmsSmsMessage to hold metadata common to MMS and SMS Messages */ -class MmsSmsMessage { - protected MmsSmsMessage() {} - - String mId; - long mThreadId; - String mPhoneNumber; - String mBody; - int mType; - int mSubscriptionId; - Instant mDate; - boolean mRead; -} diff --git a/src/com/android/car/messenger/impl/datamodels/util/MmsUtils.java b/src/com/android/car/messenger/impl/datamodels/util/MmsUtils.java deleted file mode 100644 index 4513f78..0000000 --- a/src/com/android/car/messenger/impl/datamodels/util/MmsUtils.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl.datamodels.util; - -import static android.provider.BaseColumns._ID; -import static android.provider.Telephony.BaseMmsColumns.CONTENT_TYPE; -import static android.provider.Telephony.TextBasedSmsColumns.TYPE; - -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.provider.Telephony.Mms.Addr; -import android.provider.Telephony.Mms.Part; -import android.provider.Telephony.Sms; - -import androidx.annotation.NonNull; - -import java.text.MessageFormat; -import java.time.Instant; - -/** MMS Utils for parsing MMS Telephony Content */ -class MmsUtils { - - @NonNull public static final String FORMAT_CONTENT_MMS_PART = "content://mms/{0}/part"; - @NonNull public static final String FORMAT_CONTENT_MMS_ADDR = "content://mms/{0}/addr"; - @NonNull public static final String FORMAT_TYPE_AND_MSG_ID = "type={0} AND msg_id={1}"; - - private MmsUtils() {} - - @NonNull static final String MMS_CONTENT_TYPE = "application/vnd.wap.multipart.related"; - private static final int ORIGINATOR_ADDRESS_TYPE = 137; - - /** Returns true, if item on cursor position is an MMS message */ - static Boolean isMms(@NonNull Cursor cursor) { - String contentType = cursor.getString(cursor.getColumnIndex(CONTENT_TYPE)); - return MMS_CONTENT_TYPE.equals(contentType); - } - - /** - * Returns the parsed result as {link @MmsSmsMessage} - * - * @throws IllegalArgumentException if desired columns are missing. - * @see CursorUtils#CONTENT_CONVERSATION_PROJECTION - */ - @NonNull - static MmsSmsMessage parseMms(@NonNull Context context, @NonNull Cursor cursor) { - MmsSmsMessage message = new MmsSmsMessage(); - message.mId = cursor.getString(cursor.getColumnIndex(_ID)); - message.mThreadId = cursor.getInt(cursor.getColumnIndex(Sms.THREAD_ID)); - message.mType = cursor.getInt(cursor.getColumnIndex(TYPE)); - message.mSubscriptionId = cursor.getInt(cursor.getColumnIndex(Sms.SUBSCRIPTION_ID)); - message.mDate = Instant.ofEpochSecond(cursor.getLong(cursor.getColumnIndex(Sms.DATE))); - message.mRead = cursor.getInt(cursor.getColumnIndex(Sms.READ)) == 1; - message.mPhoneNumber = getOriginator(context, message.mId); - message.mBody = getMmsBody(context, message.mId); - return message; - } - - private static String getMmsBody(@NonNull Context context, @NonNull String id) { - String uriStr = MessageFormat.format(FORMAT_CONTENT_MMS_PART, id); - Uri uriAddress = Uri.parse(uriStr); - Cursor cursor = CursorUtils.simpleQuery(context, uriAddress); - StringBuilder stringBuilder = new StringBuilder(); - while (cursor != null && cursor.moveToNext()) { - stringBuilder.append(cursor.getString(cursor.getColumnIndex(Part.TEXT))); - stringBuilder.append(" "); - } - return stringBuilder.toString(); - } - - @NonNull - private static String getOriginator(@NonNull Context context, @NonNull String id) { - String selection = - MessageFormat.format(FORMAT_TYPE_AND_MSG_ID, ORIGINATOR_ADDRESS_TYPE, id); - String uriStr = MessageFormat.format(FORMAT_CONTENT_MMS_ADDR, id); - Cursor cursor = CursorUtils.simpleQueryWithSelection(context, Uri.parse(uriStr), selection); - String phoneNum = ""; - if (cursor != null && cursor.moveToFirst()) { - cursor.moveToFirst(); - phoneNum = cursor.getString(cursor.getColumnIndex(Addr.ADDRESS)); - cursor.close(); - } - return phoneNum; - } -} diff --git a/src/com/android/car/messenger/impl/datamodels/util/SmsUtils.java b/src/com/android/car/messenger/impl/datamodels/util/SmsUtils.java deleted file mode 100644 index c7bd3dd..0000000 --- a/src/com/android/car/messenger/impl/datamodels/util/SmsUtils.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.car.messenger.impl.datamodels.util; - -import static android.provider.BaseColumns._ID; - -import android.database.Cursor; -import android.provider.Telephony.Sms; - -import androidx.annotation.NonNull; - -import java.time.Instant; - -/** SMS Utils for parsing SMS Telephony Content */ -class SmsUtils { - - SmsUtils() {} - - /** - * Returns the parsed sms result as a {@link MmsSmsMessage} - * - * @throws IllegalArgumentException if desired columns are missing. - * @see CursorUtils#CONTENT_CONVERSATION_PROJECTION - */ - @NonNull - static MmsSmsMessage parseSms(@NonNull Cursor cursor) { - int threadIdIndex = cursor.getColumnIndex(Sms.THREAD_ID); - int recipientsIndex = cursor.getColumnIndex(Sms.ADDRESS); - int bodyIndex = cursor.getColumnIndex(Sms.BODY); - int subscriptionIdIndex = cursor.getColumnIndex(Sms.SUBSCRIPTION_ID); - int dateIndex = cursor.getColumnIndex(Sms.DATE); - int typeIndex = cursor.getColumnIndex(Sms.TYPE); - int readIndex = cursor.getColumnIndex(Sms.READ); - - MmsSmsMessage message = new MmsSmsMessage(); - message.mThreadId = cursor.getInt(threadIdIndex); - message.mPhoneNumber = cursor.getString(recipientsIndex); - message.mBody = cursor.getString(bodyIndex); - message.mSubscriptionId = cursor.getInt(subscriptionIdIndex); - message.mType = cursor.getInt(typeIndex); - message.mDate = Instant.ofEpochMilli(cursor.getLong(dateIndex)); - message.mRead = cursor.getInt(readIndex) == 1; - message.mId = cursor.getString(cursor.getColumnIndex(_ID)); - return message; - } -} diff --git a/src/com/android/car/messenger/log/L.java b/src/com/android/car/messenger/log/L.java new file mode 100644 index 0000000..3d42c28 --- /dev/null +++ b/src/com/android/car/messenger/log/L.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.messenger.log; + +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; + +/** + * Util class for logging. + */ +public class L { + + /** + * Logs verbose level logs if loggable. + * + * @param tag logging tag + * @param msg the message to log, as a format string + * @param args arguments referenced by the format string + */ + public static void v(String tag, @NonNull String msg, Object... args) { + if (Log.isLoggable(tag, Log.VERBOSE) || Build.IS_DEBUGGABLE) { + Log.v(tag, String.format(msg, args)); + } + } + + /** + * Logs debug level logs if loggable. + * + * @param tag logging tag + * @param msg the message to log, as a format string + * @param args arguments referenced by the format string + */ + public static void d(String tag, @NonNull String msg, Object... args) { + if (Log.isLoggable(tag, Log.DEBUG) || Build.IS_DEBUGGABLE) { + Log.d(tag, String.format(msg, args)); + } + } + + /** + * Logs info level logs if loggable. + * + * @param tag logging tag + * @param msg the message to log, as a format string + * @param args arguments referenced by the format string + */ + public static void i(String tag, @NonNull String msg, Object... args) { + if (Log.isLoggable(tag, Log.INFO) || Build.IS_DEBUGGABLE) { + Log.i(tag, String.format(msg, args)); + } + } + + /** + * Logs warning level logs if loggable. + * + * @param tag logging tag + * @param msg the message to log, as a format string + * @param args arguments referenced by the format string + */ + public static void w(String tag, @NonNull String msg, Object... args) { + if (Log.isLoggable(tag, Log.WARN) || Build.IS_DEBUGGABLE) { + Log.w(tag, String.format(msg, args)); + } + } + + /** + * Logs error level logs. + * + * @param tag logging tag + * @param msg the message to log, as a format string + * @param args arguments referenced by the format string + */ + public static void e(String tag, @NonNull String msg, Object... args) { + Log.e(tag, String.format(msg, args)); + } + + /** + * Logs warning level logs. + * + * @param tag logging tag + * @param e an exception to log + * @param msg the message to log, as a format string + * @param args arguments referenced by the format string + */ + public static void e(String tag, Exception e, @NonNull String msg, Object... args) { + Log.e(tag, String.format(msg, args), e); + } + + /** + * Logs conditions that should never happen. + * + * @param tag logging tag + * @param msg the message to log, as a format string + * @param args arguments referenced by the format string + */ + public static void wtf(String tag, @NonNull String msg, Object... args) { + Log.wtf(tag, String.format(msg, args)); + } + + /** + * Logs conditions that should never happen. + * + * @param tag logging tag + * @param e an exception to log + * @param msg the message to log, as a format string + * @param args arguments referenced by the format string + */ + public static void wtf(String tag, Exception e, @NonNull String msg, Object... args) { + Log.wtf(tag, String.format(msg, args), e); + } +} diff --git a/tests/robotests/Android.bp b/tests/robotests/Android.bp new file mode 100644 index 0000000..342dfe6 --- /dev/null +++ b/tests/robotests/Android.bp @@ -0,0 +1,22 @@ +//############################################################ +// Car Messenger Robolectric test target. # +//############################################################ + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_robolectric_test { + name: "CarMessengerRoboTests", + + srcs: ["src/**/*.java"], + + java_resource_dirs: ["config"], + + // Include the testing libraries + libs: [ + "testng", + ], + + instrumentation_for: "CarMessengerApp", +} diff --git a/res/layout/list_fragment.xml b/tests/robotests/AndroidManifest.xml index 5dfe1e7..152fc5d 100644 --- a/res/layout/list_fragment.xml +++ b/tests/robotests/AndroidManifest.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <!-- - Copyright (C) 2020 The Android Open Source Project + Copyright (C) 2016 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. --> -<com.android.car.ui.recyclerview.CarUiRecyclerView - xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/list_view" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:clipToPadding="false" /> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + coreApp="true" + package="com.android.car.messenger.robotests"> + <application/> + +</manifest> diff --git a/tests/robotests/config/robolectric.properties b/tests/robotests/config/robolectric.properties new file mode 100644 index 0000000..fab7251 --- /dev/null +++ b/tests/robotests/config/robolectric.properties @@ -0,0 +1 @@ +sdk=NEWEST_SDK diff --git a/tests/robotests/readme.md b/tests/robotests/readme.md new file mode 100644 index 0000000..84f52b5 --- /dev/null +++ b/tests/robotests/readme.md @@ -0,0 +1,6 @@ +Unit test suite for CarMessengerApp using Robolectric. + +``` +$ croot +$ make RunCarMessengerRoboTests -j96 +```
\ No newline at end of file diff --git a/tests/robotests/src/com/android/car/messenger/MessengerDelegateTest.java b/tests/robotests/src/com/android/car/messenger/MessengerDelegateTest.java new file mode 100644 index 0000000..8068fb9 --- /dev/null +++ b/tests/robotests/src/com/android/car/messenger/MessengerDelegateTest.java @@ -0,0 +1,168 @@ +package com.android.car.messenger; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import android.app.AppOpsManager; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothMapClient; +import android.content.Context; +import android.content.Intent; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.Shadows; +import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowBluetoothAdapter; + +import java.util.Arrays; +import java.util.HashSet; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {ShadowBluetoothAdapter.class}) +public class MessengerDelegateTest { + + private static final String BLUETOOTH_ADDRESS_ONE = "FA:F8:14:CA:32:39"; + private static final String BLUETOOTH_ADDRESS_TWO = "FA:F8:33:44:32:39"; + + @Mock + private BluetoothDevice mMockBluetoothDeviceOne; + @Mock + private BluetoothDevice mMockBluetoothDeviceTwo; + @Mock + AppOpsManager mMockAppOpsManager; + + private Context mContext = RuntimeEnvironment.application; + private MessageNotificationDelegate mMessengerDelegate; + private ShadowBluetoothAdapter mShadowBluetoothAdapter; + private Intent mMessageOneIntent; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + // Add AppOps permissions required to write to Telephony.SMS database. + when(mMockAppOpsManager.checkOpNoThrow(anyInt(), anyInt(), anyString())).thenReturn( + AppOpsManager.MODE_DEFAULT); + Shadows.shadowOf(RuntimeEnvironment.application) + .setSystemService(Context.APP_OPS_SERVICE, mMockAppOpsManager); + + when(mMockBluetoothDeviceOne.getAddress()).thenReturn(BLUETOOTH_ADDRESS_ONE); + when(mMockBluetoothDeviceTwo.getAddress()).thenReturn(BLUETOOTH_ADDRESS_TWO); + mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + + createMockMessages(); + mMessengerDelegate = new MessageNotificationDelegate(mContext); + mMessengerDelegate.onDeviceConnected(mMockBluetoothDeviceOne); + } + + @Test + public void testDeviceConnections() { + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).containsKey( + BLUETOOTH_ADDRESS_ONE); + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).hasSize(1); + + mMessengerDelegate.onDeviceConnected(mMockBluetoothDeviceTwo); + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).containsKey( + BLUETOOTH_ADDRESS_TWO); + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).hasSize(2); + + mMessengerDelegate.onDeviceConnected(mMockBluetoothDeviceOne); + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).hasSize(2); + } + + @Test + public void testDeviceConnection_hasCorrectTimestamp() { + long timestamp = System.currentTimeMillis(); + mMessengerDelegate.onDeviceConnected(mMockBluetoothDeviceTwo); + + long deviceConnectionTimestamp = + mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp.get(BLUETOOTH_ADDRESS_TWO); + + // Sometimes there is slight flakiness in the timestamps. + assertThat(deviceConnectionTimestamp-timestamp).isLessThan(5L); + } + + @Test + public void testOnDeviceDisconnected_notConnectedDevice() { + mMessengerDelegate.onDeviceDisconnected(mMockBluetoothDeviceTwo); + + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).containsKey( + BLUETOOTH_ADDRESS_ONE); + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).hasSize(1); + } + + @Test + public void testOnDeviceDisconnected_connectedDevice() { + mMessengerDelegate.onDeviceConnected(mMockBluetoothDeviceTwo); + + mMessengerDelegate.onDeviceDisconnected(mMockBluetoothDeviceOne); + + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).containsKey( + BLUETOOTH_ADDRESS_TWO); + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).hasSize(1); + } + + @Test + public void testOnDeviceDisconnected_connectedDevice_withMessages() { + // Disconnect a connected device, and ensure its messages are removed. + mMessengerDelegate.onMessageReceived(mMessageOneIntent); + mMessengerDelegate.onDeviceDisconnected(mMockBluetoothDeviceOne); + + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).isEmpty(); + assertThat(mMessengerDelegate.mGeneratedGroupConversationTitles).isEmpty(); + assertThat(mMessengerDelegate.mSenderToLargeIconBitmap).isEmpty(); + assertThat(mMessengerDelegate.mUriToSenderNameMap).isEmpty(); + } + + @Test + public void testOnDeviceDisconnected_notConnectedDevice_withMessagesFromConnectedDevice() { + // Disconnect a not connected device, and ensure device one's messages are still saved. + mMessengerDelegate.onMessageReceived(mMessageOneIntent); + mMessengerDelegate.onDeviceDisconnected(mMockBluetoothDeviceTwo); + + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).hasSize(1); + assertThat(mMessengerDelegate.mUriToSenderNameMap).hasSize(1); + } + + @Test + public void testConnectedDevices_areNotAddedFromBTAdapterBondedDevices() { + mShadowBluetoothAdapter.setBondedDevices( + new HashSet<>(Arrays.asList(mMockBluetoothDeviceTwo))); + mMessengerDelegate = new MessageNotificationDelegate(mContext); + + assertThat(mMessengerDelegate.mBtDeviceAddressToConnectionTimestamp).isEmpty(); + } + + private Intent createMessageIntent(BluetoothDevice device, String handle, String senderUri, + String senderName, String messageText, Long timestamp, boolean isReadOnPhone) { + Intent intent = new Intent(); + intent.setAction(BluetoothMapClient.ACTION_MESSAGE_RECEIVED); + intent.putExtra(BluetoothDevice.EXTRA_DEVICE, device); + intent.putExtra(BluetoothMapClient.EXTRA_MESSAGE_HANDLE, handle); + intent.putExtra(BluetoothMapClient.EXTRA_SENDER_CONTACT_URI, senderUri); + intent.putExtra(BluetoothMapClient.EXTRA_SENDER_CONTACT_NAME, senderName); + intent.putExtra(BluetoothMapClient.EXTRA_MESSAGE_READ_STATUS, isReadOnPhone); + intent.putExtra(android.content.Intent.EXTRA_TEXT, messageText); + if (timestamp != null) { + intent.putExtra(BluetoothMapClient.EXTRA_MESSAGE_TIMESTAMP, timestamp); + } + return intent; + } + + private void createMockMessages() { + mMessageOneIntent= createMessageIntent(mMockBluetoothDeviceOne, "mockHandle", + "510-111-2222", "testSender", + "Hello", /* timestamp= */ null, /* isReadOnPhone */ false); + } +} diff --git a/tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothHelperTest.java b/tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothHelperTest.java new file mode 100644 index 0000000..7e9e9ac --- /dev/null +++ b/tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothHelperTest.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.messenger.bluetooth; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertThrows; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothMapClient; + +import com.android.car.messenger.testutils.ShadowBluetoothAdapter; + +import com.google.common.collect.Sets; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; + +import java.util.Collections; +import java.util.Set; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {ShadowBluetoothAdapter.class}) +public class BluetoothHelperTest { + + private static final String BLUETOOTH_ADDRESS_ONE = "FA:F8:14:CA:32:39"; + private static final String BLUETOOTH_ADDRESS_TWO = "FA:F8:33:44:32:39"; + + private BluetoothMapClient mMockMapClient; + @Mock + private BluetoothDevice mMockDeviceOne; + @Mock + private BluetoothDevice mMockDeviceTwo; + + @Before + public void setUp() { + Answer returnTrue = new Answer() { + public Object answer(InvocationOnMock invocation) { + return true; + } + }; + MockitoAnnotations.initMocks(this); + when(mMockDeviceOne.getAddress()).thenReturn(BLUETOOTH_ADDRESS_ONE); + when(mMockDeviceTwo.getAddress()).thenReturn(BLUETOOTH_ADDRESS_TWO); + mMockMapClient = Mockito.mock(BluetoothMapClient.class, returnTrue); + + BluetoothAdapter.getDefaultAdapter().enable(); + } + + @After + public void tearDown() { + ShadowBluetoothAdapter.reset(); + } + + @Test + public void testGetPairedDevices_nullAdapter() { + ShadowBluetoothAdapter.setBluetoothEnabled(false); + + assertThat(BluetoothHelper.getBondedDevices()).isEmpty(); + } + + @Test + public void testGetPairedDevices_noBondedDevices() { + getShadowBluetoothAdapter().setBondedDevices(Collections.emptySet()); + + assertThat(BluetoothHelper.getBondedDevices()).isEmpty(); + } + + @Test + public void testGetPairedDevices_bondedDevices() { + getShadowBluetoothAdapter().setBondedDevices( + Sets.newHashSet(mMockDeviceOne, mMockDeviceTwo)); + + Set<BluetoothDevice> bondedDevices = BluetoothHelper.getBondedDevices(); + assertThat(bondedDevices).hasSize(2); + assertThat(bondedDevices).contains(mMockDeviceOne); + assertThat(bondedDevices).contains(mMockDeviceTwo); + } + + @Test + public void testSendMessage_nullAdapter() { + ShadowBluetoothAdapter.setBluetoothEnabled(false); + + assertThat(BluetoothHelper.sendMessage(mMockMapClient, BLUETOOTH_ADDRESS_ONE, null, + "test message", null, null)).isFalse(); + } + + @Test + public void testSendMessage_existingDevice() { + getShadowBluetoothAdapter().addRemoteDevice(mMockDeviceOne); + + assertThat(BluetoothHelper.sendMessage(mMockMapClient, BLUETOOTH_ADDRESS_ONE, null, + "test message", null, null)).isTrue(); + } + + @Test + public void testSendMessage_invalidDeviceAddress() { + getShadowBluetoothAdapter().addRemoteDevice(mMockDeviceTwo); + + assertThrows(IllegalArgumentException.class, + () -> BluetoothHelper.sendMessage(mMockMapClient, BLUETOOTH_ADDRESS_ONE, null, + "test message", null, null)); + } + + private ShadowBluetoothAdapter getShadowBluetoothAdapter() { + return (ShadowBluetoothAdapter) + Shadow.extract( + BluetoothAdapter.getDefaultAdapter()); + } +} diff --git a/tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothMonitorTest.java b/tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothMonitorTest.java new file mode 100644 index 0000000..addb69f --- /dev/null +++ b/tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothMonitorTest.java @@ -0,0 +1,56 @@ +package com.android.car.messenger.bluetooth; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.verify; + +import android.bluetooth.BluetoothMapClient; +import android.bluetooth.BluetoothProfile; +import android.content.Context; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class BluetoothMonitorTest { + + @Mock + BluetoothMapClient mockMapClient; + @Mock + BluetoothMonitor.OnBluetoothEventListener mockBluetoothEventListener; + + private Context mContext; + private BluetoothMonitor mBluetoothMonitor; + private BluetoothProfile.ServiceListener mServiceListener; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = RuntimeEnvironment.application; + mBluetoothMonitor = new BluetoothMonitor(mContext); + mServiceListener = mBluetoothMonitor.getServiceListener(); + mBluetoothMonitor.registerListener(mockBluetoothEventListener); + } + + @Test + public void testServiceListener() { + mServiceListener.onServiceConnected(BluetoothProfile.MAP_CLIENT, mockMapClient); + verify(mockBluetoothEventListener).onMapConnected(mockMapClient); + + mServiceListener.onServiceDisconnected(BluetoothProfile.MAP_CLIENT); + verify(mockBluetoothEventListener).onMapDisconnected(); + } + + @Test + public void testRegisterListener() { + assertThat(mBluetoothMonitor.registerListener(mockBluetoothEventListener)).isFalse(); + assertThat(mBluetoothMonitor.unregisterListener(mockBluetoothEventListener)).isTrue(); + assertThat(mBluetoothMonitor.registerListener(mockBluetoothEventListener)).isTrue(); + mBluetoothMonitor.onDestroy(); + assertThat(mBluetoothMonitor.registerListener(mockBluetoothEventListener)).isTrue(); + } +} diff --git a/tests/robotests/src/com/android/car/messenger/testutils/ShadowBluetoothAdapter.java b/tests/robotests/src/com/android/car/messenger/testutils/ShadowBluetoothAdapter.java new file mode 100644 index 0000000..8554ef5 --- /dev/null +++ b/tests/robotests/src/com/android/car/messenger/testutils/ShadowBluetoothAdapter.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.messenger.testutils; + +import android.annotation.Nullable; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; + +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.annotation.Resetter; +import org.robolectric.shadows.ShadowApplication; + +import java.util.HashMap; +import java.util.Map; + +@Implements(BluetoothAdapter.class) +public class ShadowBluetoothAdapter extends org.robolectric.shadows.ShadowBluetoothAdapter { + + private static boolean mBluetoothEnabled = true; + private static Map<String, BluetoothDevice> mDeviceAddressToDeviceMap = new HashMap<>(); + + @Nullable + @Implementation + protected static synchronized BluetoothAdapter getDefaultAdapter() { + if (mBluetoothEnabled) { + return (BluetoothAdapter) ShadowApplication.getInstance().getBluetoothAdapter(); + } + return null; + } + + public static void setBluetoothEnabled(boolean enabled) { + mBluetoothEnabled = enabled; + } + + public static void addRemoteDevice(BluetoothDevice device) { + mDeviceAddressToDeviceMap.put(device.getAddress(), device); + } + + @Implementation + protected BluetoothDevice getRemoteDevice(String address) throws IllegalArgumentException { + if (!mDeviceAddressToDeviceMap.containsKey(address)) { + throw new IllegalArgumentException(); + } + return mDeviceAddressToDeviceMap.get(address); + } + + @Resetter + public static void reset() { + mBluetoothEnabled = true; + mDeviceAddressToDeviceMap.clear(); + } +} |