diff options
author | Xin Li <delphij@google.com> | 2021-08-14 06:30:58 +0000 |
---|---|---|
committer | Xin Li <delphij@google.com> | 2021-08-14 06:30:58 +0000 |
commit | 7d8c6f0085575cfb8dd2a9d01a82caef02e36c23 (patch) | |
tree | abe9c36eb63a4fcc07540fa5fff0550437b3c0e6 | |
parent | 964f5ef4d1d30bd7124530055520ac57bed55ce2 (diff) | |
parent | 789e43ac8bb69f21c179beb7d4954ee3ff5264e0 (diff) | |
download | Messenger-7d8c6f0085575cfb8dd2a9d01a82caef02e36c23.tar.gz |
Merge sc-dev-plus-aosp-without-vendor@7634622temp_sam_202323961
Merged-In: I80e6c75f4d3d5f6329769444a7f0abce1d725773
Change-Id: Ieb80ad22c6b1a6e973965eef2062d188742c0c83
85 files changed, 6682 insertions, 2268 deletions
@@ -25,7 +25,9 @@ android_app { resource_dirs: ["res"], - platform_apis: true, + sdk_version: "system_current", + + required: ["allowed_privapp_com.android.car.messenger"], overrides: ["messaging"], @@ -35,14 +37,21 @@ android_app { privileged: true, - libs: ["android.car"], + libs: ["android.car-system-stubs"], + // must be unbundled dependencies static_libs: [ - "car-apps-common", - "car-messenger-common", + "androidx-constraintlayout_constraintlayout", + "androidx.lifecycle_lifecycle-common-java8", + "androidx.lifecycle_lifecycle-extensions", + "androidx.legacy_legacy-support-v4", + "androidx.preference_preference", + "androidx.recyclerview_recyclerview", + "car-assist-lib", + "car-messaging-models", "car-telephony-common", + "car-ui-lib", "androidx.annotation_annotation", - "glide-prebuilt", ], dex_preopt: { diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 5438926..b24137a 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,91 +1,136 @@ <?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2017 The Android Open Source Project +<!-- + 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 + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this 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"> - - <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" > + 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.RESPOND_VIA_MESSAGE" /> + <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> + <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="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> + <meta-data + android:name="distractionOptimized" + android:value="true" /> + </activity> <!-- BroadcastReceiver that listens for incoming SMS messages --> - <receiver android:name=".SmsReceiver" - android:exported="true" - android:permission="android.permission.BROADCAST_SMS"> + <receiver + android:name=".impl.receivers.MmsReceiver" + android:exported="false" + android:permission="android.permission.BROADCAST_WAP_PUSH"> <intent-filter> - <action android:name="android.provider.Telephony.SMS_DELIVER" /> - <action android:name="android.provider.Telephony.SMS_RECEIVED" /> + <action android:name="android.provider.Telephony.WAP_PUSH_DELIVER" /> + <data android:mimeType="application/vnd.wap.mms-message" /> </intent-filter> </receiver> <!-- BroadcastReceiver that listens for incoming MMS messages --> - <receiver android:name=".MmsReceiver" - android:exported="true" - android:permission="android.permission.BROADCAST_WAP_PUSH"> + <receiver + android:name=".core.service.OnBootReceiver" + android:enabled="true" + android:exported="false" + android:permission="android.permission.RECEIVE_BOOT_COMPLETED"> <intent-filter> - <action android:name="android.provider.Telephony.WAP_PUSH_DELIVER" /> - <data android:mimeType="application/vnd.wap.mms-message" /> + <action android:name="android.intent.action.BOOT_COMPLETED" /> </intent-filter> </receiver> - <activity android:name=".MessengerActivity" android:exported="true"> - <meta-data android:name="distractionOptimized" android:value="true"/> + <!-- BroadcastReceiver for car booting --> + <receiver + android:name=".impl.receivers.SmsReceiver" + android:exported="false" + android:permission="android.permission.BROADCAST_SMS"> <intent-filter> - <action android:name="android.intent.action.MAIN"/> - <category android:name="android.intent.category.APP_MESSAGING"/> + <action android:name="android.provider.Telephony.SMS_DELIVER" /> + <action android:name="android.provider.Telephony.SMS_RECEIVED" /> </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.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> - </activity> + </service> + </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/PREUPLOAD.cfg b/PREUPLOAD.cfg new file mode 100644 index 0000000..38f9800 --- /dev/null +++ b/PREUPLOAD.cfg @@ -0,0 +1,7 @@ +[Hook Scripts] +checkstyle_hook = ${REPO_ROOT}/prebuilts/checkstyle/checkstyle.py --sha ${PREUPLOAD_COMMIT} +ktlint_hook = ${REPO_ROOT}/prebuilts/ktlint/ktlint.py -f ${PREUPLOAD_FILES} + +[Builtin Hooks] +commit_msg_changeid_field = true +commit_msg_test_field = true diff --git a/res/anim/trans_bottom_in.xml b/res/anim/trans_bottom_in.xml deleted file mode 100644 index 0635de3..0000000 --- a/res/anim/trans_bottom_in.xml +++ /dev/null @@ -1,32 +0,0 @@ -<?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 deleted file mode 100644 index 81295e8..0000000 --- a/res/anim/trans_bottom_out.xml +++ /dev/null @@ -1,32 +0,0 @@ -<?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 new file mode 100644 index 0000000..8c0f6c8 --- /dev/null +++ b/res/color/uxr_button_text_color_selector.xml @@ -0,0 +1,22 @@ +<?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 new file mode 100644 index 0000000..3cdb44a --- /dev/null +++ b/res/drawable/car_ui_icon_reply.xml @@ -0,0 +1,27 @@ +<?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 new file mode 100644 index 0000000..faa8cd5 --- /dev/null +++ b/res/drawable/car_ui_icon_toggle_mute.xml @@ -0,0 +1,33 @@ +<?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 new file mode 100644 index 0000000..496ecfa --- /dev/null +++ b/res/drawable/hero_button_background.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2019 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> +<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 new file mode 100644 index 0000000..bf04507 --- /dev/null +++ b/res/drawable/ic_launcher_icon.xml @@ -0,0 +1,41 @@ +<?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 3f9dbd7..503f790 100644 --- a/res/drawable/ic_message.xml +++ b/res/drawable/ic_message.xml @@ -15,12 +15,13 @@ limitations under the License. --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" - android:viewportWidth="48" - android:viewportHeight="48" +<vector xmlns:android="http://schemas.android.com/apk/res/android" 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 + android:height="48dp" + 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> diff --git a/res/drawable/ic_person.xml b/res/drawable/ic_person.xml new file mode 100644 index 0000000..258d5a0 --- /dev/null +++ b/res/drawable/ic_person.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="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/values/integers.xml b/res/drawable/ic_play.xml index 22ff0de..5e47e70 100644 --- a/res/values/integers.xml +++ b/res/drawable/ic_play.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <!-- - ~ Copyright (C) 2017 The Android Open Source Project + ~ 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. @@ -12,10 +12,14 @@ ~ distributed under the License is distributed on an "AS IS" BASIS, ~ WITHOUT 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. --> - -<resources> - <integer name="anim_time">1000</integer> - <integer name="notification_conversation_title_length">30</integer> -</resources> +<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> diff --git a/res/drawable/ic_voice_out.xml b/res/drawable/ic_voice_out.xml deleted file mode 100644 index 7672029..0000000 --- a/res/drawable/ic_voice_out.xml +++ /dev/null @@ -1,32 +0,0 @@ -<!-- - ~ Copyright (C) 2017 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ http://www.apache.org/licenses/LICENSE-2.0 - ~ - ~ Unless required by applicable law or agreed to in writing, software - ~ distributed under the License is distributed on an "AS IS" BASIS, - ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - ~ See the License for the specific language governing permissions and - ~ limitations under the License - --> - -<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 new file mode 100644 index 0000000..647392b --- /dev/null +++ b/res/drawable/list_divider.xml @@ -0,0 +1,24 @@ +<?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 new file mode 100644 index 0000000..248184f --- /dev/null +++ b/res/layout/conversation_list_item.xml @@ -0,0 +1,169 @@ +<?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" + android:src="@color/unread_dot_color" + app:layout_constraintBottom_toBottomOf="@id/icon" + app:layout_constraintEnd_toStartOf="@id/icon" + app:layout_constraintTop_toTopOf="@id/icon" /> + + <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/tests/robotests/AndroidManifest.xml b/res/layout/list_fragment.xml index 152fc5d..5dfe1e7 100644 --- a/tests/robotests/AndroidManifest.xml +++ b/res/layout/list_fragment.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <!-- - Copyright (C) 2016 The Android Open Source Project + 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. @@ -14,10 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. --> - -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - coreApp="true" - package="com.android.car.messenger.robotests"> - <application/> - -</manifest> +<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" /> diff --git a/res/layout/loading_info_view.xml b/res/layout/loading_info_view.xml new file mode 100644 index 0000000..07ffb73 --- /dev/null +++ b/res/layout/loading_info_view.xml @@ -0,0 +1,60 @@ +<?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 new file mode 100644 index 0000000..b6c4765 --- /dev/null +++ b/res/layout/loading_list_fragment.xml @@ -0,0 +1,24 @@ +<?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 new file mode 100644 index 0000000..79eaf37 --- /dev/null +++ b/res/layout/loading_progress_view.xml @@ -0,0 +1,31 @@ +<?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 new file mode 100644 index 0000000..983562b --- /dev/null +++ b/res/values/attrs.xml @@ -0,0 +1,22 @@ +<?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 new file mode 100644 index 0000000..17d5cb2 --- /dev/null +++ b/res/values/colors.xml @@ -0,0 +1,68 @@ +<?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="unread_dot_color">#66B5FF</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 56cb667..644c7ff 100644 --- a/res/values/config.xml +++ b/res/values/config.xml @@ -1,21 +1,36 @@ <?xml version="1.0" encoding="utf-8"?> <!-- - Copyright (C) 2019 The Android Open Source Project + ~ 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> + <bool name="direct_send_supported">false</bool> + <bool name="ttr_conversation_supported">false</bool> - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at + <!-- + 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> - http://www.apache.org/licenses/LICENSE-2.0 + <integer name="config_letter_tile_text_style">0</integer> + + <!-- 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> - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT 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 1f9b6af..42d721b 100644 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -1,20 +1,70 @@ <?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 - --> +<!-- Copyright (C) 2020 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> <resources> - <dimen name="notification_contact_photo_size">300dp</dimen> + <!-- 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="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/values/strings.xml b/res/values/strings.xml index 1bd3c74..837ad3e 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1,52 +1,70 @@ <?xml version="1.0" encoding="utf-8"?> -<!-- 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 - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT 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> - <string name="app_name">Messenger</string> - - <plurals name="notification_new_message"> - <item quantity="one">New message</item> - <item quantity="other">%d new messages</item> +<!-- + ~ 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> - <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> + <!-- Button text for when disconnected from Bluetooth [CHAR LIMIT=40] --> + <string name="app_name" translatable="false">Car Messenger</string> - <string name="tts_sender_says">%s says</string> + <!-- Button text for connecting to Bluetooth [CHAR LIMIT=40] --> + <string name="bluetooth_disconnected" translatable="false">Bluetooth disconnected</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> + <!-- Status when no new messages[CHAR LIMIT=40] --> + <string name="connect_bluetooth_button_text" translatable="false">Connect to Bluetooth</string> - <string name="sms_channel_name">SMS Channel</string> - <string name="sms_channel_description">Phone SMS Receiver Service</string> + <!-- Status when replied [CHAR LIMIT=40] --> + <string name="no_new_messages" translatable="false">No new messages</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> + <!-- 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="app_running_msg_channel_name">Uncategorized</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> - <string name="app_running_msg_notification_content">Receiving SMS through Bluetooth</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> + + <!-- Status when there is a new message [CHAR LIMIT=40] --> + <string name="new_message">New Message</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> </resources> diff --git a/res/values/styles.xml b/res/values/styles.xml new file mode 100644 index 0000000..2568a05 --- /dev/null +++ b/res/values/styles.xml @@ -0,0 +1,105 @@ +<?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 new file mode 100644 index 0000000..f2ddf7a --- /dev/null +++ b/res/values/themes.xml @@ -0,0 +1,28 @@ +<?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 deleted file mode 100644 index 5fa6db0..0000000 --- a/src/com/android/car/messenger/MessageNotificationDelegate.java +++ /dev/null @@ -1,487 +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; - - -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 deleted file mode 100644 index e350dce..0000000 --- a/src/com/android/car/messenger/MessengerActivity.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.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 deleted file mode 100644 index 35c5900..0000000 --- a/src/com/android/car/messenger/MessengerService.java +++ /dev/null @@ -1,267 +0,0 @@ -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; -import com.android.car.telephony.common.InMemoryPhoneBook; - -/** 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(); - if (!InMemoryPhoneBook.isInitialized()) { - InMemoryPhoneBook.init(this); - } - } - - - 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/SmsDatabaseHandler.java b/src/com/android/car/messenger/SmsDatabaseHandler.java deleted file mode 100644 index a5d0472..0000000 --- a/src/com/android/car/messenger/SmsDatabaseHandler.java +++ /dev/null @@ -1,220 +0,0 @@ -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/bluetooth/BluetoothHelper.java b/src/com/android/car/messenger/bluetooth/BluetoothHelper.java deleted file mode 100644 index e95b386..0000000 --- a/src/com/android/car/messenger/bluetooth/BluetoothHelper.java +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 095474f..0000000 --- a/src/com/android/car/messenger/bluetooth/BluetoothMonitor.java +++ /dev/null @@ -1,329 +0,0 @@ -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 new file mode 100644 index 0000000..a88078d --- /dev/null +++ b/src/com/android/car/messenger/core/interfaces/AppFactory.java @@ -0,0 +1,70 @@ +/* + * 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 new file mode 100644 index 0000000..a2bc91b --- /dev/null +++ b/src/com/android/car/messenger/core/interfaces/DataModel.java @@ -0,0 +1,119 @@ +/* + * 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(); + + /** + * Call this to reload user account live data. This is useful when resuming an activity, to + * ensure no account changes was missed. + */ + void refreshUserAccounts(); + + /** + * 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); + + /** + * Callback is called when a conversation is removed from the telephony database. + * + * <p>All cached data specific to this conversation should be removed, including notifications, + * mute status and more. + */ + @NonNull + LiveData<String> onConversationRemoved(); + + /** + * 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 reply to a conversation + * + * @param accountId The user account/device id to send the message from + * @param conversationId The phone number to send message + * @param message The desired message to send to conversation thread + */ + void replyConversation( + @NonNull int accountId, @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 new file mode 100644 index 0000000..375dfde --- /dev/null +++ b/src/com/android/car/messenger/core/models/ConnectionStatus.java @@ -0,0 +1,31 @@ +/* + * 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 new file mode 100644 index 0000000..406d60a --- /dev/null +++ b/src/com/android/car/messenger/core/models/UserAccount.java @@ -0,0 +1,114 @@ +/* + * 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 new file mode 100644 index 0000000..aef64d6 --- /dev/null +++ b/src/com/android/car/messenger/core/service/MessengerService.java @@ -0,0 +1,193 @@ +/* + * 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.Handler; +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.interfaces.DataModel; +import com.android.car.messenger.core.shared.NotificationHandler; +import com.android.car.messenger.core.util.L; +import com.android.car.messenger.core.util.VoiceUtil; + +import java.time.Duration; + +/** 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(); + + /* Delay fetching to give time for the system to start up on boot */ + private static final Duration DELAY_FETCH_DURATION = Duration.ofSeconds(3); + + /** 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"); + Handler handler = new Handler(); + handler.postDelayed(this::subscribeToNotificationUpdates, DELAY_FETCH_DURATION.toMillis()); + + sendServiceRunningNotification(); + } + + private void subscribeToNotificationUpdates() { + DataModel dataModel = AppFactory.get().getDataModel(); + dataModel.getUnreadMessages().observeForever(NotificationHandler::postOrRemoveNotification); + dataModel.onConversationRemoved().observeForever(NotificationHandler::removeNotification); + } + + 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 new file mode 100644 index 0000000..fdadfe0 --- /dev/null +++ b/src/com/android/car/messenger/core/service/OnBootReceiver.java @@ -0,0 +1,39 @@ +/* + * 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 new file mode 100644 index 0000000..5ba2779 --- /dev/null +++ b/src/com/android/car/messenger/core/shared/MessageConstants.java @@ -0,0 +1,58 @@ +/* + * 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 a Conversation Key is provided. */ + @NonNull public static final String EXTRA_CONVERSATION_KEY = "EXTRA_CONVERSATION_KEY"; + + /** Key under which the user account/device id is provided. */ + @NonNull public static final String EXTRA_ACCOUNT_ID = "EXTRA_ACCOUNT_ID"; +} diff --git a/src/com/android/car/messenger/core/shared/NotificationHandler.java b/src/com/android/car/messenger/core/shared/NotificationHandler.java new file mode 100644 index 0000000..0087c58 --- /dev/null +++ b/src/com/android/car/messenger/core/shared/NotificationHandler.java @@ -0,0 +1,150 @@ +/* + * 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 static com.android.car.messenger.core.shared.MessageConstants.EXTRA_ACCOUNT_ID; + +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.L; +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(@NonNull Conversation conversation) { + if (conversation.isMuted()) { + removeNotification(conversation.getId()); + } else { + postNotification(conversation); + } + } + + /* Posts or updates a notification based on a conversation */ + private static void postNotification(Conversation conversation) { + int userAccountId = conversation.getExtras().getInt(EXTRA_ACCOUNT_ID, 0); + if (userAccountId == 0) { + L.w( + "posting Notification with null user account id. " + + "Note, reply would likely fail if user account id is not set."); + } + Conversation tapToReadConversation = + VoiceUtil.createTapToReadConversation(conversation, userAccountId); + 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 */ + public static void removeNotification(@NonNull String conversationId) { + Context context = AppFactory.get().getContext(); + NotificationManager notificationManager = + context.getSystemService(NotificationManager.class); + notificationManager.cancel(conversationId.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 new file mode 100644 index 0000000..e585dd1 --- /dev/null +++ b/src/com/android/car/messenger/core/ui/base/MessageListBaseFragment.java @@ -0,0 +1,125 @@ +/* + * 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 new file mode 100644 index 0000000..b54c82c --- /dev/null +++ b/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemAdapter.java @@ -0,0 +1,82 @@ +/* + * 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 new file mode 100644 index 0000000..67900c0 --- /dev/null +++ b/src/com/android/car/messenger/core/ui/conversationlist/ConversationItemViewHolder.java @@ -0,0 +1,159 @@ +/* + * 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.shared.NotificationHandler; +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); + if (mute) { + NotificationHandler.removeNotification(uiData.getConversationId()); + } + }); + } +} diff --git a/src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java b/src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java new file mode 100644 index 0000000..ccc0396 --- /dev/null +++ b/src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java @@ -0,0 +1,180 @@ +/* + * 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; + } + if (!getResources().getBoolean(R.bool.direct_send_supported)) { + 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) { + if (mUserAccount == null) { + return; + } + VoiceUtil.voiceRequestReadConversation(requireActivity(), mUserAccount, conversation); + } + + @Override + public void onReplyIconClicked(@NonNull Conversation conversation) { + if (mUserAccount == null) { + return; + } + VoiceUtil.voiceRequestReplyConversation(requireActivity(), mUserAccount, 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 new file mode 100644 index 0000000..8ac0851 --- /dev/null +++ b/src/com/android/car/messenger/core/ui/conversationlist/ConversationListViewModel.java @@ -0,0 +1,89 @@ +/* + * 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 new file mode 100644 index 0000000..bdd71e7 --- /dev/null +++ b/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItem.java @@ -0,0 +1,130 @@ +/* + * 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 new file mode 100644 index 0000000..94f597d --- /dev/null +++ b/src/com/android/car/messenger/core/ui/conversationlist/UIConversationItemConverter.java @@ -0,0 +1,94 @@ +/* + * 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 new file mode 100644 index 0000000..15f315f --- /dev/null +++ b/src/com/android/car/messenger/core/ui/conversationlist/UIConversationLog.java @@ -0,0 +1,77 @@ +/* + * 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 new file mode 100644 index 0000000..ddd227c --- /dev/null +++ b/src/com/android/car/messenger/core/ui/launcher/MessageLauncherActivity.java @@ -0,0 +1,97 @@ +/* + * 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.interfaces.AppFactory; +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); + } + + @Override + protected void onResume() { + super.onResume(); + L.d("On Resume of Message Activity."); + AppFactory.get().getDataModel().refreshUserAccounts(); + } + + 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 new file mode 100644 index 0000000..4b4d96a --- /dev/null +++ b/src/com/android/car/messenger/core/ui/launcher/MessageLauncherViewModel.java @@ -0,0 +1,61 @@ +/* + * 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 new file mode 100644 index 0000000..49577a9 --- /dev/null +++ b/src/com/android/car/messenger/core/ui/shared/CircularOutputlineProvider.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.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 new file mode 100644 index 0000000..c50c349 --- /dev/null +++ b/src/com/android/car/messenger/core/ui/shared/LetterTileDrawable.java @@ -0,0 +1,300 @@ +/* + * 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 new file mode 100644 index 0000000..a6f0bfa --- /dev/null +++ b/src/com/android/car/messenger/core/ui/shared/LoadingFrameLayout.java @@ -0,0 +1,321 @@ +/* + * 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 new file mode 100644 index 0000000..0e77b00 --- /dev/null +++ b/src/com/android/car/messenger/core/ui/shared/ViewUtils.java @@ -0,0 +1,34 @@ +/* + * 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 new file mode 100644 index 0000000..b56cbce --- /dev/null +++ b/src/com/android/car/messenger/core/util/ConversationUtil.java @@ -0,0 +1,116 @@ +/* + * 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 new file mode 100644 index 0000000..4e2fcb1 --- /dev/null +++ b/src/com/android/car/messenger/core/util/L.java @@ -0,0 +1,110 @@ +/* + * 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 new file mode 100644 index 0000000..6a7a049 --- /dev/null +++ b/src/com/android/car/messenger/core/util/VoiceUtil.java @@ -0,0 +1,290 @@ +/* + * 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_ACCOUNT_ID; +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.MessageConstants; +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 { + + /** Represents a null user account id */ + private static final int NULL_ACCOUNT_ID = 0; + + private VoiceUtil() {} + + /** Requests Voice request to read a conversation */ + public static void voiceRequestReadConversation( + @NonNull Activity activity, + @NonNull UserAccount userAccount, + @NonNull Conversation conversation) { + if (conversation.getMessages().isEmpty()) { + L.d("No messages to read from Conversation! Returning."); + return; + } + voiceRequestHelper( + activity, + conversation, + userAccount, + VOICE_ACTION_READ_CONVERSATION, + VOICE_ACTION_READ_NOTIFICATION); + } + + /** Requests Voice request to reply to a conversation */ + public static void voiceRequestReplyConversation( + @NonNull Activity activity, + @NonNull UserAccount userAccount, + @NonNull Conversation conversation) { + voiceRequestHelper( + activity, + conversation, + userAccount, + VOICE_ACTION_REPLY_CONVERSATION, + VOICE_ACTION_REPLY_NOTIFICATION); + } + + private static void voiceRequestHelper( + @NonNull Activity activity, + @NonNull Conversation conversation, + @NonNull UserAccount userAccount, + @NonNull String conversationAction, + @NonNull String notificationAction) { + Bundle args = new Bundle(); + Conversation tapToReadConversation = + createTapToReadConversation(conversation, userAccount.getId()); + boolean isConversationSupported = + activity.getResources().getBoolean(R.bool.ttr_conversation_supported); + if (isConversationSupported) { + // New API using generic Conversation class + // is currently limited in support by partner assistants and is being phased in. + args.putString(KEY_ACTION, conversationAction); + args.putBundle(KEY_CONVERSATION, tapToReadConversation.toBundle()); + } else { + // Continue using legacy SBN + StatusBarNotification sbn = + NotificationHandler.postNotificationForLegacyTapToRead(tapToReadConversation); + if (sbn == null) { + L.e("Failed to convert Conversation to SBN for Legacy Tap To Read."); + return; + } + args.putString(KEY_ACTION, notificationAction); + args.putParcelable(KEY_NOTIFICATION, sbn); + } + + activity.showAssist(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, userAccount.getId()); + bundle.putParcelable(KEY_SEND_PENDING_INTENT, sendIntent); + activity.showAssist(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, int userAccountId) { + 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, userAccountId); + 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, userAccountId); + 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, userAccountId); + 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, int userAccountId) { + Context context = AppFactory.get().getContext(); + Bundle bundle = new Bundle(); + if (conversationKey != null) { + bundle.putString(EXTRA_CONVERSATION_KEY, conversationKey); + } + bundle.putInt(EXTRA_ACCOUNT_ID, userAccountId); + 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_MUTABLE | 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 int accountId = + intent.getIntExtra(MessageConstants.EXTRA_ACCOUNT_ID, NULL_ACCOUNT_ID); + final Bundle bundle = RemoteInput.getResultsFromIntent(intent); + if (bundle == null || accountId == NULL_ACCOUNT_ID) { + L.e("Dropping voice reply. Received null bundle or no user account id in bundle!"); + return; + } + final CharSequence message = bundle.getCharSequence(Intent.EXTRA_TEXT); + L.d("voiceReply: " + message); + if (!TextUtils.isEmpty(message)) { + AppFactory.get() + .getDataModel() + .replyConversation(accountId, 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 new file mode 100644 index 0000000..cceb716 --- /dev/null +++ b/src/com/android/car/messenger/impl/AppFactoryImpl.java @@ -0,0 +1,105 @@ +/* + * 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 new file mode 100644 index 0000000..98fbf58 --- /dev/null +++ b/src/com/android/car/messenger/impl/CarMessengerApp.java @@ -0,0 +1,64 @@ +/* + * 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 new file mode 100644 index 0000000..cb3b26d --- /dev/null +++ b/src/com/android/car/messenger/impl/common/ProjectionStateListener.java @@ -0,0 +1,166 @@ +/* + * 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 new file mode 100644 index 0000000..59e7849 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/ContentProviderLiveData.java @@ -0,0 +1,80 @@ +/* + * 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.MediatorLiveData; +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 MediatorLiveData<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() { + super.onActive(); + if (!mIsRegistered) { + for (Uri uri : mUris) { + getContext() + .getContentResolver() + .registerContentObserver( + uri, /* notifyForDescendants =*/ true, mContentObserver); + mIsRegistered = true; + } + } + } + + @Override + protected void onInactive() { + super.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/ConversationListLiveData.java b/src/com/android/car/messenger/impl/datamodels/ConversationListLiveData.java new file mode 100644 index 0000000..5e71513 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/ConversationListLiveData.java @@ -0,0 +1,131 @@ +/* + * 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; + +import static android.provider.Telephony.TextBasedSmsColumns.THREAD_ID; + +import static com.android.car.messenger.impl.datamodels.util.ConversationFetchUtil.fetchConversation; +import static com.android.car.messenger.impl.datamodels.util.ConversationFetchUtil.loadMutedList; + +import static java.util.Comparator.comparingLong; + +import android.content.SharedPreferences; +import android.database.Cursor; +import android.database.CursorIndexOutOfBoundsException; +import android.provider.Telephony; + +import androidx.annotation.NonNull; + +import com.android.car.messenger.common.Conversation; +import com.android.car.messenger.core.interfaces.AppFactory; +import com.android.car.messenger.core.models.UserAccount; +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 java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Set; +import java.util.stream.Collectors; + +/** Publishes a list of {@link Conversation} for a {@link UserAccount} to subscribers */ +class ConversationListLiveData extends ContentProviderLiveData<Collection<Conversation>> { + @NonNull private final UserAccount mUserAccount; + + @NonNull + private static final Comparator<Conversation> sConversationComparator = + comparingLong(ConversationUtil::getConversationTimestamp).reversed(); + + @NonNull + private final SharedPreferences.OnSharedPreferenceChangeListener mPreferenceChangeListener = + (sharedPreferences, key) -> onSharedPreferenceChanged(key); + + ConversationListLiveData(@NonNull UserAccount userAccount) { + super(Telephony.MmsSms.CONTENT_URI); + mUserAccount = userAccount; + } + + @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); + } + + @Override + public void onDataChange() { + Cursor cursor = ConversationsPerDeviceFetchManager.getCursor(mUserAccount.getId()); + ArrayList<Conversation> conversations = new ArrayList<>(); + while (cursor != null && cursor.moveToNext()) { + String conversationId = cursor.getString(cursor.getColumnIndex(THREAD_ID)); + Conversation conversation = null; + try { + conversation = fetchConversation(conversationId); + } catch (CursorIndexOutOfBoundsException e) { + L.w("Error occurred fetching conversation Id " + conversationId); + } finally { + if (conversation != null) { + conversations.add(conversation); + } + } + } + Collections.sort(conversations, sConversationComparator); + postValue(conversations); + } + + private void onSharedPreferenceChanged(@NonNull String key) { + Collection<Conversation> conversations = getValue(); + if (!MessageConstants.KEY_MUTED_CONVERSATIONS.equals(key) || conversations == null) { + return; + } + Set<String> mutedList = loadMutedList(); + ArrayList<Conversation> finalConversations = new ArrayList<>(); + boolean muteChange = false; + for (Conversation conversation : conversations) { + String conversationId = conversation.getId(); + boolean wasPreviouslyMuted = conversation.isMuted(); + boolean isMuted = mutedList.contains(conversationId); + if (isMuted == wasPreviouslyMuted) { + finalConversations.add(conversation); + continue; + } + Conversation.Builder builder = conversation.toBuilder(); + builder.setMuted(isMuted); + finalConversations.add(builder.build()); + muteChange = true; + } + + if (muteChange) { + postValue( + finalConversations.stream() + .sorted(sConversationComparator) + .collect(Collectors.toList())); + } + } +} diff --git a/src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceFetchManager.java b/src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceFetchManager.java new file mode 100644 index 0000000..7618b15 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceFetchManager.java @@ -0,0 +1,256 @@ +/* + * 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 androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; +import androidx.lifecycle.Observer; +import android.content.Context; +import android.database.ContentObserver; +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.models.UserAccount; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Holds the information on any changes made to a conversation list per device/user account + * + * <p>To listen for specific changes such as removed conversations, observable data is also + * provided. + */ +class ConversationsPerDeviceFetchManager { + @Nullable private static ConversationsPerDeviceFetchManager sInstance; + + @NonNull + private final MediatorLiveData<String> mRemovedConversationLiveData = new MediatorLiveData<>(); + + @NonNull + private final HashMap<Integer, ConversationIdChangeList> mCachedResults = new HashMap<>(); + + @NonNull private static final Uri URI = CONTENT_CONVERSATIONS_URI; + + @NonNull + private static final String[] PROJECTION = { + SUBSCRIPTION_ID, THREAD_ID, + }; + + @NonNull private final Context mContext; + + @NonNull + private final ContentObserver mObserver = + new ContentObserver(null) { + @Override + public void onChange(boolean selfChange) { + onDataChange(); + } + }; + + private ConversationsPerDeviceFetchManager() { + mContext = AppFactory.get().getContext(); + mContext.getContentResolver() + .registerContentObserver(URI, /* notifyForDescendants= */ false, mObserver); + mRemovedConversationLiveData.addSource( + UserAccountLiveData.getInstance(), onUserAccountRemovedObserver()); + } + + /** + * Returns a cursor that searches the {@link android.provider.Telephony.MmsSms} database for a + * list of all conversations, based on the accountId provided + * + * @param accountId searches for conversations based on id provided + */ + @Nullable + public static Cursor getCursor(int accountId) { + Context context = AppFactory.get().getContext(); + return context.getContentResolver() + .query( + URI, + PROJECTION, + /* selection= */ SUBSCRIPTION_ID + "=" + accountId, + /* selectionArgs= */ null, + /* sortOrder= */ null, + /* cancellationSignal= */ null); + } + + private void onDataChange() { + UserAccountLiveData.UserAccountChangeList changeList = + UserAccountLiveData.getInstance().getValue(); + if (changeList == null) { + return; + } + Collection<UserAccount> userAccounts = changeList.getAccounts(); + for (UserAccount userAccount : userAccounts) { + boolean changeDetected = postChangeIfFound(userAccount.getId()); + // one change is posted per onDataUri call + if (changeDetected) { + break; + } + } + } + + /** + * Post a changelist if one is found for the id provided + * + * @return true if a change was posted and false, otherwise. + */ + private boolean postChangeIfFound(int userAccountId) { + Cursor cursor = getCursor(userAccountId); + ArrayList<String> currentConversationIds = new ArrayList<>(); + while (cursor != null && cursor.moveToNext()) { + String conversationId = cursor.getString(cursor.getColumnIndex(THREAD_ID)); + currentConversationIds.add(conversationId); + } + + // get updated changes + Collection<String> prevConversationIds = + getValueOrEmpty(userAccountId).getAllConversationIds(); + Set<String> newConversations = getDifference(currentConversationIds, prevConversationIds); + Set<String> removedConversations = + getDifference(prevConversationIds, currentConversationIds); + + if (newConversations.isEmpty() && removedConversations.isEmpty()) { + // Return early if no new conversations were added or removed since last change list. + // However, if no conversations is found, post an empty changelist to allow + // the subscriber update the UI with "no new conversations found" + if (currentConversationIds.isEmpty()) { + postValueInternal(new ConversationIdChangeList(userAccountId)); + } + return false; + } + + ConversationIdChangeList changeList = new ConversationIdChangeList(userAccountId); + changeList.mConversationIds = currentConversationIds; + changeList.mAddedConversationIds = newConversations; + changeList.mRemovedConversationIds = removedConversations; + + postValueInternal(changeList); + return true; + } + + private void postValueInternal(ConversationIdChangeList changeList) { + mCachedResults.put(changeList.mUserAccountId, changeList); + changeList.getRemovedConversationIds().forEach(mRemovedConversationLiveData::postValue); + } + + /** Returns a live data that emits removed conversation ids */ + public LiveData<String> getRemovedConversationLiveData() { + return mRemovedConversationLiveData; + } + + @NonNull + private ConversationIdChangeList getValueOrEmpty(int userAccountId) { + ConversationIdChangeList cache = mCachedResults.get(userAccountId); + if (cache == null) { + return new ConversationIdChangeList(userAccountId); + } + return cache; + } + + /** + * 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()); + } + + /** Gets the instance of {@link ConversationsPerDeviceFetchManager} */ + @NonNull + public static ConversationsPerDeviceFetchManager getInstance() { + if (sInstance == null) { + sInstance = new ConversationsPerDeviceFetchManager(); + } + return sInstance; + } + + @NonNull + private Observer<UserAccountLiveData.UserAccountChangeList> onUserAccountRemovedObserver() { + return userAccountChangeList -> { + if (userAccountChangeList == null) { + return; + } + userAccountChangeList + .getRemovedAccounts() + .forEach( + removedAccount -> { + ConversationIdChangeList conversationIdInfo = + mCachedResults.get(removedAccount.getId()); + if (conversationIdInfo == null) { + return; + } + conversationIdInfo.getAllConversationIds().stream() + .forEach(mRemovedConversationLiveData::postValue); + mCachedResults.remove(removedAccount.getId()); + }); + }; + } + + /** + * 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 { + private final int mUserAccountId; + @NonNull private Collection<String> mConversationIds = new ArrayList<>(); + @NonNull private Collection<String> mRemovedConversationIds = new ArrayList<>(); + @NonNull private Collection<String> mAddedConversationIds = new ArrayList<>(); + + private ConversationIdChangeList(int userAccountId) { + mUserAccountId = userAccountId; + } + + /* 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/NewMessageLiveData.java b/src/com/android/car/messenger/impl/datamodels/NewMessageLiveData.java new file mode 100644 index 0000000..8f6fb64 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/NewMessageLiveData.java @@ -0,0 +1,169 @@ +/* + * 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; + +import static com.android.car.messenger.impl.datamodels.util.ConversationFetchUtil.fetchConversation; +import static com.android.car.messenger.impl.datamodels.util.CursorUtils.DEFAULT_SORT_ORDER; + +import android.content.Context; +import android.database.Cursor; +import android.database.CursorIndexOutOfBoundsException; +import android.net.Uri; +import android.provider.Telephony; + +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.models.UserAccount; +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.common.ProjectionStateListener; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.Objects; + +/** + * Publishes a stream of {@link Conversation} with unread messages that was received on the user + * device after the car's connection to the{@link UserAccount}. + */ +public class NewMessageLiveData extends ContentProviderLiveData<Conversation> { + @NonNull + private final UserAccountLiveData mUserAccountLiveData = UserAccountLiveData.getInstance(); + + @NonNull private Collection<UserAccount> mUserAccounts = new ArrayList<>(); + @NonNull private final HashMap<Integer, Instant> mOffsetMap = new HashMap<>(); + + @NonNull + private static final String MESSAGE_QUERY = + Telephony.TextBasedSmsColumns.DATE + + " > %d AND " + + Telephony.TextBasedSmsColumns.SUBSCRIPTION_ID + + " = %d"; + + @NonNull + private final ProjectionStateListener mProjectionStateListener = + new ProjectionStateListener(AppFactory.get().getContext()); + + NewMessageLiveData() { + super(Telephony.Sms.CONTENT_URI, Telephony.Mms.CONTENT_URI, Telephony.MmsSms.CONTENT_URI); + } + + @Override + protected void onActive() { + super.onActive(); + addSource( + mUserAccountLiveData, + it -> { + mUserAccounts = it.getAccounts(); + it.getRemovedAccounts() + .forEach(userAccount -> mOffsetMap.remove(userAccount.getId())); + }); + if (getValue() == null) { + onDataChange(); + } + } + + @Override + protected void onInactive() { + super.onInactive(); + removeSource(mUserAccountLiveData); + mUserAccounts.clear(); + mOffsetMap.clear(); + } + + @Override + public void onDataChange() { + for (UserAccount userAccount : mUserAccounts) { + if (hasProjectionInForeground(userAccount)) { + continue; + } + Instant offset = + Objects.requireNonNull( + mOffsetMap.getOrDefault( + userAccount.getId(), userAccount.getConnectionTime())); + Cursor mmsCursor = getMmsCursor(userAccount, offset); + boolean foundNewMms = postNewMessageIfFound(mmsCursor, userAccount); + Cursor smsCursor = getSmsCursor(userAccount, offset); + boolean foundNewSms = postNewMessageIfFound(smsCursor, userAccount); + if (foundNewMms || foundNewSms) { + // onDataChange is called per one message insert, + // so once a new message is found we can exit early + break; + } + } + } + + /** Post a new message if one is found, and returns true if so, false otherwise */ + private boolean postNewMessageIfFound( + @Nullable Cursor cursor, @NonNull UserAccount userAccount) { + if (cursor == null || !cursor.moveToFirst()) { + return false; + } + String conversationId = + cursor.getString(cursor.getColumnIndex(Telephony.TextBasedSmsColumns.THREAD_ID)); + + Conversation conversation; + try { + conversation = fetchConversation(conversationId); + conversation.getExtras().putInt(MessageConstants.EXTRA_ACCOUNT_ID, userAccount.getId()); + } catch (CursorIndexOutOfBoundsException e) { + L.w("Error occurred fetching conversation Id " + conversationId); + return false; + } + Instant offset = + Instant.ofEpochMilli(ConversationUtil.getConversationTimestamp(conversation)); + mOffsetMap.put(userAccount.getId(), offset); + postValue(conversation); + return true; + } + + /** Get the last message cursor, taking into account the last message posted */ + @Nullable + private Cursor getMmsCursor(@NonNull UserAccount userAccount, @NonNull Instant offset) { + return getCursor(Telephony.Mms.Inbox.CONTENT_URI, userAccount, offset.getEpochSecond()); + } + + /** Get the last message cursor, taking into account the last message posted */ + @Nullable + private Cursor getSmsCursor(@NonNull UserAccount userAccount, @NonNull Instant offset) { + return getCursor(Telephony.Sms.Inbox.CONTENT_URI, userAccount, offset.toEpochMilli()); + } + /** Get the last message cursor, taking into account an offset and subscription id */ + @Nullable + private Cursor getCursor(Uri uri, @NonNull UserAccount userAccount, long offset) { + Context context = AppFactory.get().getContext(); + String query = String.format(Locale.ENGLISH, MESSAGE_QUERY, offset, userAccount.getId()); + return context.getContentResolver() + .query( + uri, + new String[] {Telephony.TextBasedSmsColumns.THREAD_ID}, + query, + /* selectionArgs= */ null, + DEFAULT_SORT_ORDER + " LIMIT 1"); + } + + private boolean hasProjectionInForeground(@NonNull UserAccount userAccount) { + return mProjectionStateListener.isProjectionInActiveForeground(userAccount.getIccId()); + } +} diff --git a/src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java b/src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java new file mode 100644 index 0000000..a1816e2 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java @@ -0,0 +1,147 @@ +/* + * 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 androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import android.content.ContentValues; +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.provider.Telephony; +import android.telephony.SmsManager; + +import androidx.annotation.NonNull; + +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.L; +import com.android.car.messenger.impl.datamodels.UserAccountLiveData.UserAccountChangeList; +import com.android.car.messenger.impl.datamodels.util.CursorUtils; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** Queries the telephony data model to retrieve the SMS/MMS messages */ +public class TelephonyDataModel implements DataModel { + + @NonNull + @Override + public LiveData<Collection<UserAccount>> getAccounts() { + return Transformations.map( + UserAccountLiveData.getInstance(), UserAccountChangeList::getAccounts); + } + + @Override + public void refreshUserAccounts() { + UserAccountLiveData.getInstance().refresh(); + } + + @NonNull + @Override + public LiveData<Collection<Conversation>> getConversations(@NonNull UserAccount userAccount) { + return new ConversationListLiveData(userAccount); + } + + @NonNull + @Override + public LiveData<Conversation> getUnreadMessages() { + return new NewMessageLiveData(); + } + + @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) { + L.d("markAsRead for conversationId: " + conversationId); + Context context = AppFactory.get().getContext(); + ContentValues values = new ContentValues(); + values.put(Telephony.ThreadsColumns.READ, 1); + context.getContentResolver() + .update(CursorUtils.getConversationUri(conversationId), values, /* extras= */ null); + } + + @Override + public void replyConversation( + int accountId, @NonNull String conversationId, @NonNull String message) { + if (accountId <= 0) { + L.e("Invalid user account id when replying conversation, dropping message"); + return; + } + L.d("Sending a message to a conversation"); + String destination = + Uri.withAppendedPath(Telephony.Threads.CONTENT_URI, conversationId).toString(); + SmsManager.getSmsManagerForSubscriptionId(accountId) + .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); + } + + @NonNull + @Override + public LiveData<String> onConversationRemoved() { + return Transformations.map( + ConversationsPerDeviceFetchManager.getInstance().getRemovedConversationLiveData(), + id -> { + muteConversation(id, false); + return id; + }); + } +} diff --git a/src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java b/src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java new file mode 100644 index 0000000..1a95a2f --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java @@ -0,0 +1,217 @@ +/* + * 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.Collections; +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); + loadValue(); + } + + /** + * Refresh the user accounts. Updates listeners if a change is found. Useful to call when + * something occurs that indicates a change in accounts, such as empty messages. This is useful + * as there are occasions when the subscription on change listener is not called after a + * subscription is deleted. + */ + public void refresh() { + loadValue(); + } + + /** Gets the instance of {@link UserAccountLiveData} */ + @NonNull + public static UserAccountLiveData getInstance() { + if (sInstance == null) { + sInstance = new UserAccountLiveData(); + } + return sInstance; + } + + private void loadValue() { + List<UserAccount> accounts = + getNullSafeSubscriptionInfoList().stream() + .map( + it -> { + int subscriptionId = it.getSubscriptionId(); + String iccId = it.getIccId(); + String displayName = + it.getDisplayName() != null + ? 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 early if no new accounts were added or removed since last change list. + // However, if no account is found, post an empty changelist to allow + // the subscriber update the UI with "no account found or all accounts disconnected" + if (accounts.isEmpty()) { + postValue(new UserAccountChangeList()); + } + 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; + } + + /** Returns null safe subscription info list */ + @NonNull + private List<SubscriptionInfo> getNullSafeSubscriptionInfoList() { + List<SubscriptionInfo> subscriptionInfos = + mSubscriptionManager.getActiveSubscriptionInfoList(); + if (subscriptionInfos == null) { + return new ArrayList<>(); + } + // The last added subscription is more likely the last device connection made + // and more likely relevant to the user. + // Reverse the subscription list to prioritize the last connected device. + Collections.reverse(subscriptionInfos); + return subscriptionInfos; + } +} diff --git a/src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java b/src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java new file mode 100644 index 0000000..fbd9818 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java @@ -0,0 +1,412 @@ +/* + * 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 new file mode 100644 index 0000000..a392538 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/util/ContactUtils.java @@ -0,0 +1,182 @@ +/* + * 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; + Cursor cursor = null; + try { + Uri uri = CONTENT_FILTER_URI.buildUpon().appendEncodedPath(Uri.encode(phoneNo)).build(); + cursor = CursorUtils.simpleQueryWithProjection(context, uri, PROJECTION); + } catch (IllegalArgumentException e) { + L.w("Unable to retrieve PhoneLookup cursor"); + L.w(e.toString()); + } + + 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.w("No canonical address found for recipient id"); + return null; + } +} diff --git a/src/com/android/car/messenger/impl/datamodels/util/ConversationFetchUtil.java b/src/com/android/car/messenger/impl/datamodels/util/ConversationFetchUtil.java new file mode 100644 index 0000000..42b97e4 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/util/ConversationFetchUtil.java @@ -0,0 +1,139 @@ +/* + * 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 android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.graphics.Bitmap; +import androidx.core.graphics.drawable.IconCompat; +import android.text.TextUtils; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.core.app.Person; + +import com.android.car.messenger.common.Conversation; +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 java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiConsumer; + +/** Utility class for retrieving and setting conversation items. */ +public class ConversationFetchUtil { + + private static final int MESSAGE_LIMIT = 10; + private static final String COMMA_DELIMITER = ", "; + + private ConversationFetchUtil() {} + + /** Fetches a conversation item based on a provided conversation id */ + public static Conversation fetchConversation(@NonNull String conversationId) { + L.d("Fetching latest data for Conversation " + conversationId); + Conversation.Builder conversationBuilder = initConversationBuilder(conversationId); + Cursor messagesCursor = + CursorUtils.getMessagesCursor(conversationId, MESSAGE_LIMIT, /* offset= */ 0); + // messages to read: first get unread messages + List<Conversation.Message> messagesToRead = MessageUtils.getUnreadMessages(messagesCursor); + int unreadCount = messagesToRead.size(); + long lastReplyTimestamp = 0L; + + // if no unread messages, get read messages + if (messagesToRead.isEmpty()) { + Pair<List<Conversation.Message>, Long> readMessagesAndReplyTimestamp = + MessageUtils.getReadMessagesAndReplyTimestamp(messagesCursor); + messagesToRead = readMessagesAndReplyTimestamp.first; + lastReplyTimestamp = readMessagesAndReplyTimestamp.second; + } + + conversationBuilder.setMessages(messagesToRead).setUnreadCount(unreadCount); + ConversationUtil.setReplyTimestampAsAnExtra( + conversationBuilder, /* extras= */ null, lastReplyTimestamp); + return conversationBuilder.build(); + } + + @NonNull + private static Conversation.Builder initConversationBuilder(@NonNull String conversationId) { + Context context = AppFactory.get().getContext(); + String userName = ContactUtils.DRIVER_NAME; + Conversation.Builder builder = + new Conversation.Builder( + new Person.Builder().setName(userName).build(), conversationId); + List<Person> participants = + fetchParticipants( + conversationId, + (names, icons) -> { + builder.setConversationTitle(TextUtils.join(COMMA_DELIMITER, names)); + Bitmap bitmap = AvatarUtil.createGroupAvatar(context, icons); + if (bitmap != null) { + builder.setConversationIcon(IconCompat.createWithBitmap(bitmap)); + } + }); + builder.setParticipants(participants); + builder.setMuted(loadMutedList().contains(conversationId)); + return builder; + } + + /** + * Fetches participants and allows caller to process names and icons before returning. + * + * <p>For context, a conversation often holds multiple messages, which holds multiple + * participant contacts, which each in turn could hold an avatar. + * + * <p>This leads to a very heavy conversation class and leads to problems down the road when + * sending this conversation as a bundle. + * + * <p>To mitigate this, {@link Person} classes do not hold an avatar. Instead, each contact + * avatar is channeled up to the caller during a fetch to make one avatar for the entire + * conversation. + * + * @param conversationId, id for conversation to fetch participants information + * @param processNamesAndIcons the method to process the names and icons of the participants + * @return list of participants as {@link Person}. For performance reasons, the objects do not + * contain an avatar, and a functional interface is needed in order to process the various + * participant icons nto one conversation icon. + */ + private static List<Person> fetchParticipants( + @NonNull String conversationId, + @NonNull BiConsumer<List<CharSequence>, List<Bitmap>> processNamesAndIcons) { + List<CharSequence> participantNames = new ArrayList<>(); + List<Bitmap> participantIcons = new ArrayList<>(); + List<Person> participants = + ContactUtils.getRecipients( + conversationId, + (name, bitmap) -> { + participantNames.add(name); + participantIcons.add(bitmap); + }); + processNamesAndIcons.accept(participantNames, participantIcons); + return participants; + } + + /** Returns a set of muted conversation items */ + @NonNull + public static Set<String> loadMutedList() { + SharedPreferences sharedPreferences = AppFactory.get().getSharedPreferences(); + return sharedPreferences.getStringSet( + MessageConstants.KEY_MUTED_CONVERSATIONS, new HashSet<>()); + } +} diff --git a/src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java b/src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java new file mode 100644 index 0000000..5ce2302 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java @@ -0,0 +1,121 @@ +/* + * 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.BaseMmsColumns.MESSAGE_BOX; +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, MESSAGE_BOX + }; + + /** Provides the default sort order for items in database. Default is DESC order by Date. */ + @NonNull + public 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 new file mode 100644 index 0000000..08dc1b3 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/util/MessageUtils.java @@ -0,0 +1,187 @@ +/* + * 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) { + if (lastReply.get() < message.getTimestamp()) { + lastReply.set(message.getTimestamp()); + } + return readMessages.isEmpty(); + } + + 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 new file mode 100644 index 0000000..cb76308 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/util/MmsSmsMessage.java @@ -0,0 +1,33 @@ +/* + * 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 new file mode 100644 index 0000000..808ce86 --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/util/MmsUtils.java @@ -0,0 +1,102 @@ +/* + * 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 android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.Telephony; +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}"; + + /** MMS text messages come with extra characters and new lines that need to be removed */ + @NonNull private static final String REPLACE_CHARS = "\r\n"; + + 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(Telephony.Mms.MESSAGE_BOX)); + 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().replace(REPLACE_CHARS, ""); + } + + @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 new file mode 100644 index 0000000..c7bd3dd --- /dev/null +++ b/src/com/android/car/messenger/impl/datamodels/util/SmsUtils.java @@ -0,0 +1,60 @@ +/* + * 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/MmsReceiver.java b/src/com/android/car/messenger/impl/receivers/MmsReceiver.java index 37cc5ef..6811d23 100644 --- a/src/com/android/car/messenger/MmsReceiver.java +++ b/src/com/android/car/messenger/impl/receivers/MmsReceiver.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 The Android Open Source Project + * 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. @@ -14,20 +14,16 @@ * limitations under the License. */ -package com.android.car.messenger; +package com.android.car.messenger.impl.receivers; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -/** - * No-op Receiver that only exists in order to be eligible to be the default SMS app. - */ +import androidx.annotation.NonNull; + +/** 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(Context context, Intent intent) { - Intent startIntent = new Intent(context, MessengerService.class) - .setAction(MessengerService.ACTION_RECEIVED_MMS); - context.startForegroundService(startIntent); - } + public void onReceive(@NonNull Context context, @NonNull Intent intent) {} } diff --git a/src/com/android/car/messenger/SmsReceiver.java b/src/com/android/car/messenger/impl/receivers/SmsReceiver.java index 25dbf89..9150e30 100644 --- a/src/com/android/car/messenger/SmsReceiver.java +++ b/src/com/android/car/messenger/impl/receivers/SmsReceiver.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 The Android Open Source Project + * 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. @@ -14,21 +14,17 @@ * limitations under the License. */ -package com.android.car.messenger; +package com.android.car.messenger.impl.receivers; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -/** - * No-op Receiver that only exists in order to be eligible to be the default SMS app. - */ +import androidx.annotation.NonNull; + +/** 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(Context context, Intent intent) { - Intent startIntent = new Intent(context, MessengerService.class) - .setAction(MessengerService.ACTION_RECEIVED_SMS); - context.startForegroundService(startIntent); - } + public void onReceive(@NonNull Context context, @NonNull Intent intent) {} } diff --git a/src/com/android/car/messenger/log/L.java b/src/com/android/car/messenger/log/L.java deleted file mode 100644 index 3d42c28..0000000 --- a/src/com/android/car/messenger/log/L.java +++ /dev/null @@ -1,126 +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.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 deleted file mode 100644 index 342dfe6..0000000 --- a/tests/robotests/Android.bp +++ /dev/null @@ -1,22 +0,0 @@ -//############################################################ -// 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/tests/robotests/config/robolectric.properties b/tests/robotests/config/robolectric.properties deleted file mode 100644 index fab7251..0000000 --- a/tests/robotests/config/robolectric.properties +++ /dev/null @@ -1 +0,0 @@ -sdk=NEWEST_SDK diff --git a/tests/robotests/readme.md b/tests/robotests/readme.md deleted file mode 100644 index 84f52b5..0000000 --- a/tests/robotests/readme.md +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 8068fb9..0000000 --- a/tests/robotests/src/com/android/car/messenger/MessengerDelegateTest.java +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index 7e9e9ac..0000000 --- a/tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothHelperTest.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.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 deleted file mode 100644 index addb69f..0000000 --- a/tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothMonitorTest.java +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 8554ef5..0000000 --- a/tests/robotests/src/com/android/car/messenger/testutils/ShadowBluetoothAdapter.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.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(); - } -} |