Merge Android 12

Bug: 202323961
Merged-In: Ieb80ad22c6b1a6e973965eef2062d188742c0c83
Change-Id: I332487b33061626071407a892274b1951c6c2cb7
diff --git a/Android.bp b/Android.bp
index 4f57063..d671cef 100644
--- a/Android.bp
+++ b/Android.bp
@@ -25,7 +25,9 @@
 
     resource_dirs: ["res"],
 
-    platform_apis: true,
+    sdk_version: "system_current",
+
+    required: ["allowed_privapp_com.android.car.messenger"],
 
     overrides: ["messaging"],
 
@@ -35,14 +37,21 @@
 
     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,42 +1,110 @@
 <?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">
+    package="com.android.car.messenger">
 
-    <uses-sdk android:minSdkVersion="26" android:targetSdkVersion="29"/>
+    <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">
 
-    <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"/>
+        <activity
+            android:name=".core.ui.launcher.MessageLauncherActivity"
+            android:exported="true"
+            android:screenOrientation="landscape">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
 
-    <application android:label="@string/app_name">
-        <service android:name=".MessengerService"
-                 android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE"
-                 android:exported="true" >
+            <intent-filter>
+                <action android:name="android.intent.action.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>
+            <meta-data
+                android:name="distractionOptimized"
+                android:value="true" />
+        </activity>
+
+        <!-- BroadcastReceiver that listens for incoming SMS messages -->
+        <receiver
+            android:name=".impl.receivers.MmsReceiver"
+            android:exported="false"
+            android:permission="android.permission.BROADCAST_WAP_PUSH">
+            <intent-filter>
+                <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=".core.service.OnBootReceiver"
+            android:enabled="true"
+            android:exported="false"
+            android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
+            <intent-filter>
+                <action android:name="android.intent.action.BOOT_COMPLETED" />
+            </intent-filter>
+        </receiver>
+
+        <!-- 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.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.RESPOND_VIA_MESSAGE" />
                 <category android:name="android.intent.category.DEFAULT" />
+
                 <data android:scheme="sms" />
                 <data android:scheme="smsto" />
                 <data android:scheme="mms" />
@@ -44,48 +112,25 @@
             </intent-filter>
         </service>
 
-        <!-- BroadcastReceiver that listens for incoming SMS messages -->
-        <receiver android:name=".SmsReceiver"
-                  android:exported="true"
-                  android:permission="android.permission.BROADCAST_SMS">
-            <intent-filter>
-                <action android:name="android.provider.Telephony.SMS_DELIVER" />
-                <action android:name="android.provider.Telephony.SMS_RECEIVED" />
-            </intent-filter>
-        </receiver>
-
-        <!-- BroadcastReceiver that listens for incoming MMS messages -->
-        <receiver android:name=".MmsReceiver"
-                  android:exported="true"
-                  android:permission="android.permission.BROADCAST_WAP_PUSH">
-            <intent-filter>
-                <action android:name="android.provider.Telephony.WAP_PUSH_DELIVER" />
-                <data android:mimeType="application/vnd.wap.mms-message" />
-            </intent-filter>
-        </receiver>
-
-        <activity android:name=".MessengerActivity" android:exported="true">
-            <meta-data android:name="distractionOptimized" android:value="true"/>
-            <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>
-        </activity>
     </application>
+
+    <uses-permission android:name="android.permission.SEND_SMS" />
+    <uses-permission android:name="android.permission.RECEIVE_SMS" />
+    <uses-permission android:name="android.permission.READ_CONTACTS" />
+    <uses-permission android:name="android.permission.READ_PHONE_STATE" />
+    <uses-permission android:name="android.permission.READ_SMS" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+    <!-- Permissions required to know the current projection app status. -->
+    <uses-permission android:name="android.car.permission.ACCESS_CAR_PROJECTION_STATUS" />
+    <!-- Permissions required to retrieve the SubscriptionInfo#getIccId.
+         This maps to the bluetooth address and is necessary
+         for various functions such as Assistant device disambiguation,
+         checking the projection state and more etc.
+    -->
+    <uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" />
+
+    <uses-sdk
+        android:minSdkVersion="30"
+        android:targetSdkVersion="30" />
 </manifest>
diff --git a/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/tests/robotests/AndroidManifest.xml b/res/color/uxr_button_text_color_selector.xml
similarity index 61%
copy from tests/robotests/AndroidManifest.xml
copy to res/color/uxr_button_text_color_selector.xml
index 152fc5d..8c0f6c8 100644
--- a/tests/robotests/AndroidManifest.xml
+++ b/res/color/uxr_button_text_color_selector.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!--
-  Copyright (C) 2016 The Android Open Source Project
+  Copyright (C) 2019 The Android Open Source Project
 
   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
@@ -15,9 +15,8 @@
   limitations under the License.
   -->
 
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-          coreApp="true"
-          package="com.android.car.messenger.robotests">
-    <application/>
-
-</manifest>
+<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/drawable/ic_play.xml b/res/drawable/ic_play.xml
new file mode 100644
index 0000000..5e47e70
--- /dev/null
+++ b/res/drawable/ic_play.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<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
similarity index 64%
rename from tests/robotests/AndroidManifest.xml
rename to 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
-
-    Licensed under the Apache License, Version 2.0 (the "License");
-    you may not use this file except in compliance with the License.
-    You may obtain a copy of the License at
-
-        http://www.apache.org/licenses/LICENSE-2.0
-
-    Unless required by applicable law or agreed to in writing, software
-    distributed under the License is distributed on an "AS IS" BASIS,
-    WITHOUT 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>
-    <!-- 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>
+    <bool name="group_avatar_fill_background">false</bool>
+    <bool name="direct_send_supported">false</bool>
+    <bool name="ttr_conversation_supported">false</bool>
+
+    <!--
+    The maximum number of individual avatars used for group avatar.
+    A number between 1 and 4 is required.
+    When the value is 1, the first avatar is used for the group avatar.
+    When the value is 2-4, the first nth avatars make up the group avatar,
+    where n is the value.
+    -->
+    <integer name="group_avatar_max_group_size">4</integer>
+
+    <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>
+
 </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/integers.xml b/res/values/integers.xml
deleted file mode 100644
index 22ff0de..0000000
--- a/res/values/integers.xml
+++ /dev/null
@@ -1,21 +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
-  -->
-
-<resources>
-    <integer name="anim_time">1000</integer>
-    <integer name="notification_conversation_title_length">30</integer>
-</resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 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
similarity index 62%
rename from src/com/android/car/messenger/MmsReceiver.java
rename to 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
similarity index 62%
rename from src/com/android/car/messenger/SmsReceiver.java
rename to 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();
-    }
-}