summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorXin Li <delphij@google.com>2021-08-14 06:30:58 +0000
committerXin Li <delphij@google.com>2021-08-14 06:30:58 +0000
commit7d8c6f0085575cfb8dd2a9d01a82caef02e36c23 (patch)
treeabe9c36eb63a4fcc07540fa5fff0550437b3c0e6
parent964f5ef4d1d30bd7124530055520ac57bed55ce2 (diff)
parent789e43ac8bb69f21c179beb7d4954ee3ff5264e0 (diff)
downloadMessenger-7d8c6f0085575cfb8dd2a9d01a82caef02e36c23.tar.gz
Merge sc-dev-plus-aosp-without-vendor@7634622temp_sam_202323961
Merged-In: I80e6c75f4d3d5f6329769444a7f0abce1d725773 Change-Id: Ieb80ad22c6b1a6e973965eef2062d188742c0c83
-rw-r--r--Android.bp19
-rw-r--r--AndroidManifest.xml157
-rw-r--r--PREUPLOAD.cfg7
-rw-r--r--res/anim/trans_bottom_in.xml32
-rw-r--r--res/anim/trans_bottom_out.xml32
-rw-r--r--res/color/uxr_button_text_color_selector.xml22
-rw-r--r--res/drawable/car_ui_icon_reply.xml27
-rw-r--r--res/drawable/car_ui_icon_toggle_mute.xml33
-rw-r--r--res/drawable/hero_button_background.xml24
-rw-r--r--res/drawable/ic_launcher_icon.xml41
-rw-r--r--res/drawable/ic_message.xml17
-rw-r--r--res/drawable/ic_person.xml26
-rw-r--r--res/drawable/ic_play.xml (renamed from res/values/integers.xml)18
-rw-r--r--res/drawable/ic_voice_out.xml32
-rw-r--r--res/drawable/list_divider.xml24
-rw-r--r--res/layout/conversation_list_item.xml169
-rw-r--r--res/layout/list_fragment.xml (renamed from tests/robotests/AndroidManifest.xml)15
-rw-r--r--res/layout/loading_info_view.xml60
-rw-r--r--res/layout/loading_list_fragment.xml24
-rw-r--r--res/layout/loading_progress_view.xml31
-rw-r--r--res/values/attrs.xml22
-rw-r--r--res/values/colors.xml68
-rw-r--r--res/values/config.xml45
-rw-r--r--res/values/dimens.xml82
-rw-r--r--res/values/strings.xml102
-rw-r--r--res/values/styles.xml105
-rw-r--r--res/values/themes.xml28
-rw-r--r--src/com/android/car/messenger/MessageNotificationDelegate.java487
-rw-r--r--src/com/android/car/messenger/MessengerActivity.java45
-rw-r--r--src/com/android/car/messenger/MessengerService.java267
-rw-r--r--src/com/android/car/messenger/SmsDatabaseHandler.java220
-rw-r--r--src/com/android/car/messenger/bluetooth/BluetoothHelper.java64
-rw-r--r--src/com/android/car/messenger/bluetooth/BluetoothMonitor.java329
-rw-r--r--src/com/android/car/messenger/core/interfaces/AppFactory.java70
-rw-r--r--src/com/android/car/messenger/core/interfaces/DataModel.java119
-rw-r--r--src/com/android/car/messenger/core/models/ConnectionStatus.java31
-rw-r--r--src/com/android/car/messenger/core/models/UserAccount.java114
-rw-r--r--src/com/android/car/messenger/core/service/MessengerService.java193
-rw-r--r--src/com/android/car/messenger/core/service/OnBootReceiver.java39
-rw-r--r--src/com/android/car/messenger/core/shared/MessageConstants.java58
-rw-r--r--src/com/android/car/messenger/core/shared/NotificationHandler.java150
-rw-r--r--src/com/android/car/messenger/core/ui/base/MessageListBaseFragment.java125
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/ConversationItemAdapter.java82
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/ConversationItemViewHolder.java159
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/ConversationListFragment.java180
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/ConversationListViewModel.java89
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/UIConversationItem.java130
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/UIConversationItemConverter.java94
-rw-r--r--src/com/android/car/messenger/core/ui/conversationlist/UIConversationLog.java77
-rw-r--r--src/com/android/car/messenger/core/ui/launcher/MessageLauncherActivity.java97
-rw-r--r--src/com/android/car/messenger/core/ui/launcher/MessageLauncherViewModel.java61
-rw-r--r--src/com/android/car/messenger/core/ui/shared/CircularOutputlineProvider.java51
-rw-r--r--src/com/android/car/messenger/core/ui/shared/LetterTileDrawable.java300
-rw-r--r--src/com/android/car/messenger/core/ui/shared/LoadingFrameLayout.java321
-rw-r--r--src/com/android/car/messenger/core/ui/shared/ViewUtils.java34
-rw-r--r--src/com/android/car/messenger/core/util/ConversationUtil.java116
-rw-r--r--src/com/android/car/messenger/core/util/L.java110
-rw-r--r--src/com/android/car/messenger/core/util/VoiceUtil.java290
-rw-r--r--src/com/android/car/messenger/impl/AppFactoryImpl.java105
-rw-r--r--src/com/android/car/messenger/impl/CarMessengerApp.java64
-rw-r--r--src/com/android/car/messenger/impl/common/ProjectionStateListener.java166
-rw-r--r--src/com/android/car/messenger/impl/datamodels/ContentProviderLiveData.java80
-rw-r--r--src/com/android/car/messenger/impl/datamodels/ConversationListLiveData.java131
-rw-r--r--src/com/android/car/messenger/impl/datamodels/ConversationsPerDeviceFetchManager.java256
-rw-r--r--src/com/android/car/messenger/impl/datamodels/NewMessageLiveData.java169
-rw-r--r--src/com/android/car/messenger/impl/datamodels/TelephonyDataModel.java147
-rw-r--r--src/com/android/car/messenger/impl/datamodels/UserAccountLiveData.java217
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/AvatarUtil.java412
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/ContactUtils.java182
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/ConversationFetchUtil.java139
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/CursorUtils.java121
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/MessageUtils.java187
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/MmsSmsMessage.java33
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/MmsUtils.java102
-rw-r--r--src/com/android/car/messenger/impl/datamodels/util/SmsUtils.java60
-rw-r--r--src/com/android/car/messenger/impl/receivers/MmsReceiver.java (renamed from src/com/android/car/messenger/MmsReceiver.java)16
-rw-r--r--src/com/android/car/messenger/impl/receivers/SmsReceiver.java (renamed from src/com/android/car/messenger/SmsReceiver.java)16
-rw-r--r--src/com/android/car/messenger/log/L.java126
-rw-r--r--tests/robotests/Android.bp22
-rw-r--r--tests/robotests/config/robolectric.properties1
-rw-r--r--tests/robotests/readme.md6
-rw-r--r--tests/robotests/src/com/android/car/messenger/MessengerDelegateTest.java168
-rw-r--r--tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothHelperTest.java137
-rw-r--r--tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothMonitorTest.java56
-rw-r--r--tests/robotests/src/com/android/car/messenger/testutils/ShadowBluetoothAdapter.java67
85 files changed, 6682 insertions, 2268 deletions
diff --git a/Android.bp b/Android.bp
index 4f57063..d671cef 100644
--- a/Android.bp
+++ b/Android.bp
@@ -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">,&#160;</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();
- }
-}