DO NOT MERGE - Merge Android 10 into master

Bug: 139893257
Change-Id: I984acb3bb82c4280d3389f0efbfad18b424e293f
diff --git a/Android.mk b/Android.mk
index 0404ee0..2c2372f 100644
--- a/Android.mk
+++ b/Android.mk
@@ -39,8 +39,11 @@
 LOCAL_USE_AAPT2 := true
 
 LOCAL_STATIC_ANDROID_LIBRARIES += \
-    androidx.car_car \
-    car-apps-common
+    car-apps-common \
+
+# Including the resources for the static android libraries allows to pick up their static overlays.
+LOCAL_RESOURCE_DIR += \
+    $(LOCAL_PATH)/../libs/car-apps-common/res
 
 LOCAL_STATIC_JAVA_LIBRARIES += \
     androidx.annotation_annotation \
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index b0e4502..75b7f8f 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -15,40 +15,83 @@
 -->
 
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
     package="com.android.car.messenger">
 
-    <uses-sdk android:minSdkVersion="25" android:targetSdkVersion="25"/>
+    <uses-sdk android:minSdkVersion="26" android:targetSdkVersion="26"/>
 
     <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.RECEIVE_BOOT_COMPLETED"/>
+    <uses-permission android:name="android.permission.WRITE_SMS"/>
 
     <application android:label="@string/app_name">
-        <service android:name=".MessengerService" android:exported="false">
+        <service android:name=".MessengerService"
+                 android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE"
+                 android:exported="true" >
+            <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" />
+                <data android:scheme="mmsto" />
+            </intent-filter>
         </service>
 
-        <receiver android:name=".MessengerReceiver" android:exported="true">
+        <!-- BroadcastReceiver that listens for incoming SMS messages -->
+        <receiver android:name=".SmsReceiver"
+                  android:permission="android.permission.BROADCAST_SMS">
             <intent-filter>
-                <action android:name="android.intent.action.BOOT_COMPLETED"/>
+                <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: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>
-        </activity>
-        <activity android:name=".PlayMessageActivity" android:exported="true"
-                  android:launchMode="singleTop"
-                  android:theme="@style/FloatingWindow">
             <intent-filter>
-                <action android:name="android.intent.action.MAIN"/>
-                <category android:name="android.intent.category.APP_MESSAGING"/>
+                <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>
+
+        <!-- Workaround for b/113294940 -->
+        <provider
+            android:name="androidx.lifecycle.ProcessLifecycleOwnerInitializer"
+            tools:replace="android:authorities"
+            android:authorities="${applicationId}.lifecycle-tests"
+            android:exported="false"
+            android:multiprocess="true" />
     </application>
 </manifest>
diff --git a/res/drawable-hdpi/ic_message.png b/res/drawable-hdpi/ic_message.png
deleted file mode 100644
index afb26e9..0000000
--- a/res/drawable-hdpi/ic_message.png
+++ /dev/null
Binary files differ
diff --git a/res/drawable-mdpi/ic_message.png b/res/drawable-mdpi/ic_message.png
deleted file mode 100644
index 35c99ea..0000000
--- a/res/drawable-mdpi/ic_message.png
+++ /dev/null
Binary files differ
diff --git a/res/drawable-xhdpi/ic_message.png b/res/drawable-xhdpi/ic_message.png
deleted file mode 100644
index bf95b52..0000000
--- a/res/drawable-xhdpi/ic_message.png
+++ /dev/null
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_message.png b/res/drawable-xxhdpi/ic_message.png
deleted file mode 100644
index 652dea8..0000000
--- a/res/drawable-xxhdpi/ic_message.png
+++ /dev/null
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_message.png b/res/drawable-xxxhdpi/ic_message.png
deleted file mode 100644
index a9c4a99..0000000
--- a/res/drawable-xxxhdpi/ic_message.png
+++ /dev/null
Binary files differ
diff --git a/res/drawable/circle_bg.xml b/res/drawable/circle_bg.xml
deleted file mode 100644
index 9bdfa0f..0000000
--- a/res/drawable/circle_bg.xml
+++ /dev/null
@@ -1,24 +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
-  -->
-
-<shape xmlns:android="http://schemas.android.com/apk/res/android"
-       android:shape="oval">
-    <solid android:color="@color/car_blue_grey_900" />
-    <size
-        android:height="@dimen/car_touch_target_size"
-        android:width="@dimen/car_touch_target_size" />
-</shape>
\ No newline at end of file
diff --git a/res/drawable/gradient_transparent.xml b/res/drawable/gradient_transparent.xml
deleted file mode 100644
index 369ccef..0000000
--- a/res/drawable/gradient_transparent.xml
+++ /dev/null
@@ -1,26 +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
-  -->
-
-<shape xmlns:android="http://schemas.android.com/apk/res/android"
-       android:shape="rectangle">
-    <gradient android:endColor="@android:color/transparent"
-              android:startColor="#88000000"
-              android:angle="90"
-              android:type="linear"
-              android:useLevel="true"
-    />
-</shape>
\ No newline at end of file
diff --git a/res/drawable/ic_message.xml b/res/drawable/ic_message.xml
new file mode 100644
index 0000000..3f9dbd7
--- /dev/null
+++ b/res/drawable/ic_message.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
+    android:viewportWidth="48"
+    android:viewportHeight="48"
+    android:width="48dp"
+    android:height="48dp">
+  <path
+      android:pathData="M40 4H8C5.79 4 4.02 5.79 4.02 8L4 44l8 -8h28c2.21 0 4 -1.79 4 -4V8c0 -2.21 -1.79 -4 -4 -4zM12 18h24v4H12v-4zm16 10H12v-4h16v4zm8 -12H12v-4h24v4z"
+      android:fillColor="#FFFFFF" />
+</vector>
\ No newline at end of file
diff --git a/res/drawable/ic_voice_out.xml b/res/drawable/ic_voice_out.xml
index a4a3c07..7672029 100644
--- a/res/drawable/ic_voice_out.xml
+++ b/res/drawable/ic_voice_out.xml
@@ -14,16 +14,19 @@
   ~ 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">
+<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"/>
+          android:strokeColor="#00000000" android:strokeWidth="1"/>
     <path android:fillColor="#00796B" android:pathData="M33,10h6v28h-6z"
-        android:strokeColor="#00000000" android:strokeWidth="1"/>
+          android:strokeColor="#00000000" android:strokeWidth="1"/>
     <path android:fillColor="#00796B" android:pathData="M11,10h6v28h-6z"
-        android:strokeColor="#00000000" android:strokeWidth="1"/>
+          android:strokeColor="#00000000" android:strokeWidth="1"/>
     <path android:fillColor="#00796B" android:pathData="M0,19h6v10h-6z"
-        android:strokeColor="#00000000" android:strokeWidth="1"/>
+          android:strokeColor="#00000000" android:strokeWidth="1"/>
     <path android:fillColor="#00796B" android:pathData="M44,19h6v10h-6z"
-        android:strokeColor="#00000000" android:strokeWidth="1"/>
+          android:strokeColor="#00000000" android:strokeWidth="1"/>
 </vector>
diff --git a/res/drawable/round_corner.xml b/res/drawable/round_corner.xml
deleted file mode 100644
index 0b5dba7..0000000
--- a/res/drawable/round_corner.xml
+++ /dev/null
@@ -1,29 +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
-  -->
-
-<shape xmlns:android="http://schemas.android.com/apk/res/android"
-       android:shape="rectangle">
-
-    <solid android:color="@color/car_card" />
-    <padding
-        android:bottom="5dp"
-        android:left="5dp"
-        android:right="5dp"
-        android:top="5dp" />
-    <corners android:topLeftRadius="20dp"
-             android:topRightRadius="20dp"/>
-
-</shape>
\ No newline at end of file
diff --git a/res/drawable/rounded_corner_btn_bg.xml b/res/drawable/rounded_corner_btn_bg.xml
deleted file mode 100644
index 27762ee..0000000
--- a/res/drawable/rounded_corner_btn_bg.xml
+++ /dev/null
@@ -1,25 +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
-  -->
-
-<shape xmlns:android="http://schemas.android.com/apk/res/android"
-       android:shape="rectangle">
-    <solid android:color="@color/car_blue_grey_900" />
-    <corners android:radius="55dp"/>
-    <size
-        android:height="@dimen/car_touch_target_size"
-        android:width="@dimen/big_btn_bg_width" />
-</shape>
\ No newline at end of file
diff --git a/res/layout/play_message_layout.xml b/res/layout/play_message_layout.xml
deleted file mode 100644
index 452adb1..0000000
--- a/res/layout/play_message_layout.xml
+++ /dev/null
@@ -1,148 +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
-  -->
-
-<LinearLayout
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    android:id="@+id/container"
-    android:orientation="vertical"
-    android:layout_gravity="bottom"
-    android:elevation="@dimen/car_card_view_elevation"
-    android:background="@drawable/round_corner"
-    android:animateLayoutChanges="true"
-    android:alpha="0"
-    android:layout_marginStart="@dimen/car_margin"
-    android:layout_marginEnd="@dimen/car_margin"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content">
-    <LinearLayout
-        android:id="@+id/message_container"
-        android:visibility="gone"
-        android:layout_marginStart="@dimen/car_keyline_3"
-        android:layout_marginEnd="@dimen/car_keyline_3"
-        android:orientation="vertical"
-        android:layout_weight="1"
-        android:layout_height="0dp"
-        android:layout_width="match_parent" >
-        <!-- expandable spacing -->
-        <View
-            android:layout_height="0dp"
-            android:layout_width="match_parent"
-            android:layout_weight="1"/>
-        <LinearLayout
-            android:layout_height="wrap_content"
-            android:layout_width="match_parent"
-            android:orientation="horizontal">
-            <TextView
-                android:id="@+id/emoji1"
-                style="@style/MessageEmoji">
-            </TextView>
-            <!-- expandable spacing -->
-            <View
-                android:layout_height="match_parent"
-                android:layout_width="0dp"
-                android:layout_weight="1"/>
-            <TextView
-                android:id="@+id/emoji2"
-                style="@style/MessageEmoji">
-            </TextView>
-            <!-- expandable spacing -->
-            <View
-                android:layout_height="match_parent"
-                android:layout_width="0dp"
-                android:layout_weight="1"/>
-            <TextView
-                android:id="@+id/emoji3"
-                style="@style/MessageEmoji">
-            </TextView>
-        </LinearLayout>
-        <!-- expandable spacing -->
-        <View
-            android:layout_height="0dp"
-            android:layout_width="match_parent"
-            android:layout_weight="1"/>
-        <TextView
-            android:id="@+id/canned_message"
-            style="@style/TextAppearance.Car.Body1.Light"
-            android:gravity="center"
-            android:textStyle="italic"
-            android:layout_gravity="center"
-            android:background="@drawable/rounded_corner_btn_bg"
-            android:text="@string/caned_message_driving_right_now"
-            android:layout_width="match_parent"
-            android:layout_height="@dimen/car_touch_target_size">
-        </TextView>
-        <!-- expandable spacing -->
-        <View
-            android:layout_height="0dp"
-            android:layout_width="match_parent"
-            android:layout_weight="1"/>
-    </LinearLayout>
-
-    <TextView
-        android:id="@+id/reply_notice"
-        style="@style/TextAppearance.Car.Body1"
-        android:layout_gravity="center"
-        android:gravity="center"
-        android:visibility="gone"
-        android:layout_width="match_parent"
-        android:layout_height="@dimen/car_single_line_list_item_height">
-    </TextView>
-
-    <LinearLayout
-        android:id="@+id/voice_plate"
-        android:background="@color/car_card"
-        android:orientation="horizontal"
-        android:layout_width="match_parent"
-        android:layout_height="@dimen/car_single_line_list_item_height">
-        <TextView
-            android:id="@+id/left_btn"
-            style="@style/TextAppearance.Car.Body1"
-            android:layout_weight="1"
-            android:gravity="center"
-            android:layout_gravity="center_vertical"
-            android:textAllCaps="true"
-            android:maxLines="1"
-            android:layout_marginStart="@dimen/car_keyline_1"
-            android:layout_marginEnd="@dimen/voice_plate_button_margin"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content">
-        </TextView>
-
-        <ImageView
-            android:id="@+id/voice_icon"
-            android:layout_width="@dimen/car_primary_icon_size"
-            android:layout_height="@dimen/car_primary_icon_size"
-            android:layout_gravity="center"
-            android:src="@drawable/ic_voice_out"
-            android:scaleType="fitCenter">
-        </ImageView>
-
-        <TextView
-            android:id="@+id/right_btn"
-            style="@style/TextAppearance.Car.Body1"
-            android:layout_weight="1"
-            android:gravity="center"
-            android:layout_gravity="center_vertical"
-            android:textAllCaps="true"
-            android:maxLines="1"
-            android:layout_marginStart="@dimen/voice_plate_button_margin"
-            android:layout_marginEnd="@dimen/car_keyline_1"
-            android:layout_width="0dp"
-            android:layout_height="wrap_content">
-        </TextView>
-    </LinearLayout>
-</LinearLayout>
diff --git a/res/values/config.xml b/res/values/config.xml
new file mode 100644
index 0000000..56cb667
--- /dev/null
+++ b/res/values/config.xml
@@ -0,0 +1,21 @@
+<?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.
+-->
+<resources>
+    <!-- Whether existing messages should be loaded. Recommended to turn off if head-unit's and
+     BT-paired phone's clocks are not synced.-->
+    <bool name="config_loadExistingMessages">false</bool>
+</resources>
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 1fb98d7..7308708 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -15,9 +15,5 @@
   ~ limitations under the License
   -->
 <resources>
-    <dimen name="car_card_view_elevation">8dp</dimen>
     <dimen name="notification_contact_photo_size">300dp</dimen>
-    <dimen name="voice_plate_button_margin">32dp</dimen>
-    <dimen name="big_btn_bg_width">614dp</dimen>
-    <dimen name="car_emoji_size">56dp</dimen>
 </resources>
diff --git a/res/values/integers.xml b/res/values/integers.xml
index bb8aa4b..97218aa 100644
--- a/res/values/integers.xml
+++ b/res/values/integers.xml
@@ -16,11 +16,5 @@
   -->
 
 <resources>
-    <integer name="emoji_thumb_up">0x1F44D</integer>
-    <integer name="emoji_thumb_down">0x1F44E</integer>
-    <integer name="emoji_ok_hand_sign">0x1F44C</integer>
-    <integer name="emoji_heart">0x1F49B</integer>
-    <integer name="emoji_smiling_face">0x1F642</integer>
-
     <integer name="anim_time">1000</integer>
-</resources>
\ No newline at end of file
+</resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 66d69ad..6f6cbf7 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -22,19 +22,26 @@
         <item quantity="other">%d new messages</item>
     </plurals>
 
-    <string name="action_mute">Mute</string>
-    <string name="action_unmute">Unmute</string>
     <string name="action_play">Play</string>
+    <string name="action_mark_as_read">Mark As Read</string>
     <string name="action_repeat">Repeat</string>
     <string name="action_reply">Reply</string>
     <string name="action_stop">Stop</string>
     <string name="action_close_messages">Close</string>
     <string name="auto_reply_failed_message">Unable to send reply. Please try again.</string>
+    <string name="auto_reply_device_disconnected">Unable to send reply. Device is not connected.
+    </string>
 
     <string name="tts_sender_says">%s says</string>
 
     <string name="tts_failed_toast">Text playout failed!</string>
     <string name="reply_message_display_template">\"%s\"</string>
-    <string name="caned_message_driving_right_now">Auto-reply: I’m driving.</string>
     <string name="message_sent_notice">Reply sent to %s</string>
+
+    <string name="sms_channel_name">SMS Channel</string>
+    <string name="sms_channel_description">Phone SMS Receiver Service</string>
+
+    <string name="app_running_msg_channel_name">Uncategorized</string>
+    <string name="app_running_msg_notification_title">Messaging service is active</string>
+    <string name="app_running_msg_notification_content">Receiving SMS through Bluetooth</string>
 </resources>
diff --git a/res/values/styles.xml b/res/values/styles.xml
deleted file mode 100644
index 05912be..0000000
--- a/res/values/styles.xml
+++ /dev/null
@@ -1,37 +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>
-    <!-- Pop up window that does not block interaction to other activity behind it. -->
-    <style name="FloatingWindow" parent="@android:style/Theme.Dialog">
-        <item name="android:windowIsTranslucent">true</item>
-        <item name="android:windowBackground">@drawable/gradient_transparent</item>
-        <item name="android:background">@android:color/transparent</item>
-        <item name="android:backgroundDimEnabled">false</item>
-        <item name="android:windowCloseOnTouchOutside">true</item>
-        <item name="android:windowIsFloating">false</item>
-        <item name="android:windowNoTitle">true</item>
-    </style>
-
-    <style name="MessageEmoji" parent="@style/TextAppearance.Car.Body1">
-        <item name="android:background">@drawable/circle_bg</item>
-        <item name="android:textSize">@dimen/car_emoji_size</item>
-        <item name="android:gravity">center</item>
-        <item name="android:layout_gravity">center</item>
-        <item name="android:layout_height">@dimen/car_touch_target_size</item>
-        <item name="android:layout_width">@dimen/car_touch_target_size</item>
-    </style>
-</resources>
diff --git a/src/com/android/car/messenger/MapMessage.java b/src/com/android/car/messenger/MapMessage.java
index 998e933..4eee956 100644
--- a/src/com/android/car/messenger/MapMessage.java
+++ b/src/com/android/car/messenger/MapMessage.java
@@ -26,45 +26,58 @@
  * Represents a message obtained via MAP service from a connected Bluetooth device.
  */
 class MapMessage {
-    private BluetoothDevice mDevice;
+    private String mDeviceAddress;
     private String mHandle;
-    private long mReceivedTimeMs;
     private String mSenderName;
     @Nullable
     private String mSenderContactUri;
-    private String mText;
+    private String mMessageText;
+    private long mReceiveTime;
+    private boolean mIsRead;
 
     /**
-     * Constructs Message from {@code intent} that was received from MAP service via
+     * Constructs a {@link MapMessage} from {@code intent} that was received from MAP service via
      * {@link BluetoothMapClient#ACTION_MESSAGE_RECEIVED} broadcast.
      *
-     * @param intent Intent received from MAP service.
-     * @return Message constructed from extras in {@code intent}.
-     * @throws IllegalArgumentException If {@code intent} is missing any required fields.
+     * @param intent intent received from MAP service
+     * @return message constructed from extras in {@code intent}
+     * @throws NullPointerException if {@code intent} is missing the device extra
+     * @throws IllegalArgumentException if {@code intent} is missing any other required extras
      */
     public static MapMessage parseFrom(Intent intent) {
         BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
         String handle = intent.getStringExtra(BluetoothMapClient.EXTRA_MESSAGE_HANDLE);
-        String senderContactUri = intent.getStringExtra(
-                BluetoothMapClient.EXTRA_SENDER_CONTACT_URI);
-        String senderContactName = intent.getStringExtra(
-                BluetoothMapClient.EXTRA_SENDER_CONTACT_NAME);
-        String text = intent.getStringExtra(android.content.Intent.EXTRA_TEXT);
-        return new MapMessage(device, handle, System.currentTimeMillis(), senderContactName,
-                senderContactUri, text);
+        String senderUri = intent.getStringExtra(BluetoothMapClient.EXTRA_SENDER_CONTACT_URI);
+        String senderName = intent.getStringExtra(BluetoothMapClient.EXTRA_SENDER_CONTACT_NAME);
+        String messageText = intent.getStringExtra(android.content.Intent.EXTRA_TEXT);
+        long receiveTime = intent.getLongExtra(BluetoothMapClient.EXTRA_MESSAGE_TIMESTAMP,
+                System.currentTimeMillis());
+        boolean isRead = intent.getBooleanExtra(BluetoothMapClient.EXTRA_MESSAGE_READ_STATUS,
+                false);
+
+        return new MapMessage(
+                device.getAddress(),
+                handle,
+                senderName,
+                senderUri,
+                messageText,
+                receiveTime,
+                isRead
+        );
     }
 
-    private MapMessage(BluetoothDevice device,
+    private MapMessage(String deviceAddress,
             String handle,
-            long receivedTimeMs,
             String senderName,
             @Nullable String senderContactUri,
-            String text) {
-        boolean missingDevice = (device == null);
+            String messageText,
+            long receiveTime,
+            boolean isRead) {
+        boolean missingDevice = (deviceAddress == null);
         boolean missingHandle = (handle == null);
         boolean missingSenderName = (senderName == null);
-        boolean missingText = (text == null);
-        if (missingDevice || missingHandle || missingText) {
+        boolean missingText = (messageText == null);
+        if (missingDevice || missingHandle || missingSenderName || missingText) {
             StringBuilder builder = new StringBuilder("Missing required fields:");
             if (missingDevice) {
                 builder.append(" device");
@@ -76,48 +89,54 @@
                 builder.append(" senderName");
             }
             if (missingText) {
-                builder.append(" text");
+                builder.append(" messageText");
             }
             throw new IllegalArgumentException(builder.toString());
         }
-        mDevice = device;
+        mDeviceAddress = deviceAddress;
         mHandle = handle;
-        mReceivedTimeMs = receivedTimeMs;
-        mText = text;
+        mMessageText = messageText;
         mSenderContactUri = senderContactUri;
         mSenderName = senderName;
-    }
-
-    public BluetoothDevice getDevice() {
-        return mDevice;
+        mReceiveTime = receiveTime;
+        mIsRead = isRead;
     }
 
     /**
-     * @return Unique handle for this message. NOTE: The handle is only required to be unique for
-     *      the lifetime of a single MAP session.
+     * Returns the bluetooth address of the device from which this message was received.
+     */
+    public String getDeviceAddress() {
+        return mDeviceAddress;
+    }
+
+    /**
+     * Returns a unique handle for this message.
+     * Note: The handle is only required to be unique for the lifetime of a single MAP session.
      */
     public String getHandle() {
         return mHandle;
     }
 
     /**
-     * @return Milliseconds since epoch at which this message notification was received on the head-
-     *      unit.
+     * Returns the milliseconds since epoch at which this message notification was received on the
+     * head-unit.
      */
-    public long getReceivedTimeMs() {
-        return mReceivedTimeMs;
+    public long getReceiveTime() {
+        return mReceiveTime;
     }
 
     /**
-     * @return Contact name as obtained from the device. If contact is in the device's address-book,
-     *       this is typically the contact name. Otherwise it will be the phone number.
+     * Returns the contact name as obtained from the device.
+     * If contact is in the device's address-book, this is typically the contact name.
+     * Otherwise it will be the phone number.
      */
     public String getSenderName() {
         return mSenderName;
     }
 
     /**
-     * @return Sender phone number available as a URI string. iPhone's don't provide these.
+     * Returns the sender's phone number available as a URI string.
+     * Note: iPhone's don't provide these.
      */
     @Nullable
     public String getSenderContactUri() {
@@ -125,21 +144,33 @@
     }
 
     /**
-     * @return Actual content of the message.
+     * Returns the actual content of the message.
      */
-    public String getText() {
-        return mText;
+    public String getMessageText() {
+        return mMessageText;
+    }
+
+    public void markMessageAsRead() {
+        mIsRead = true;
+    }
+
+    /**
+     * Returns the read status of the message.
+     */
+    public boolean isRead() {
+        return mIsRead;
     }
 
     @Override
     public String toString() {
         return "MapMessage{" +
-                "mDevice=" + mDevice +
+                "mDeviceAddress=" + mDeviceAddress +
                 ", mHandle='" + mHandle + '\'' +
-                ", mReceivedTimeMs=" + mReceivedTimeMs +
-                ", mText='" + mText + '\'' +
+                ", mMessageText='" + mMessageText + '\'' +
                 ", mSenderContactUri='" + mSenderContactUri + '\'' +
                 ", mSenderName='" + mSenderName + '\'' +
-                '}';
+                ", mReceiveTime=" + mReceiveTime +
+                ", mIsRead= " + mIsRead +
+                "}";
     }
 }
diff --git a/src/com/android/car/messenger/MapMessageMonitor.java b/src/com/android/car/messenger/MapMessageMonitor.java
deleted file mode 100644
index 3c427dd..0000000
--- a/src/com/android/car/messenger/MapMessageMonitor.java
+++ /dev/null
@@ -1,636 +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.
- */
-
-package com.android.car.messenger;
-
-import android.app.Notification;
-import android.app.NotificationChannel;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.bluetooth.BluetoothAdapter;
-import android.bluetooth.BluetoothDevice;
-import android.bluetooth.BluetoothMapClient;
-import android.bluetooth.BluetoothUuid;
-import android.bluetooth.SdpMasRecord;
-import android.content.BroadcastReceiver;
-import android.content.ContentResolver;
-import android.content.ContentUris;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.database.Cursor;
-import android.graphics.Bitmap;
-import android.graphics.drawable.Drawable;
-import android.graphics.drawable.Icon;
-import android.net.Uri;
-import android.os.Parcel;
-import android.os.Parcelable;
-import android.provider.ContactsContract;
-import android.provider.Settings;
-import android.text.TextUtils;
-import android.util.Log;
-import android.widget.Toast;
-
-import androidx.annotation.Nullable;
-
-import com.android.car.apps.common.LetterTileDrawable;
-import com.android.car.messenger.tts.TTSHelper;
-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.Iterator;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.function.Predicate;
-import java.util.stream.Collectors;
-
-/**
- * Monitors for incoming messages and posts/updates notifications.
- * <p>
- * It also handles notifications requests e.g. sending auto-replies and message play-out.
- * <p>
- * It will receive broadcasts for new incoming messages as long as the MapClient is connected in
- * {@link MessengerService}.
- */
-class MapMessageMonitor {
-    public static final String ACTION_MESSAGE_PLAY_START =
-            "car.messenger.action_message_play_start";
-    public static final String ACTION_MESSAGE_PLAY_STOP = "car.messenger.action_message_play_stop";
-    // reply or "upload" feature is indicated by the 3rd bit
-    private static final int REPLY_FEATURE_POS = 3;
-
-    private static final int REQUEST_CODE_VOICE_PLATE = 1;
-    private static final int REQUEST_CODE_AUTO_REPLY = 2;
-    private static final int ACTION_COUNT = 2;
-    private static final String TAG = "Messenger.MsgMonitor";
-    private static final boolean DBG = MessengerService.DBG;
-
-    private final Context mContext;
-    private final BluetoothMapReceiver mBluetoothMapReceiver;
-    private final BluetoothSdpReceiver mBluetoothSdpReceiver;
-    private final NotificationManager mNotificationManager;
-    private final Map<MessageKey, MapMessage> mMessages = new HashMap<>();
-    private final Map<SenderKey, NotificationInfo> mNotificationInfos = new HashMap<>();
-    private final TTSHelper mTTSHelper;
-    private final HashMap<String, Boolean> mReplyFeatureMap = new HashMap<>();
-
-    MapMessageMonitor(Context context) {
-        mContext = context;
-        mBluetoothMapReceiver = new BluetoothMapReceiver();
-        mBluetoothSdpReceiver = new BluetoothSdpReceiver();
-        mNotificationManager =
-                (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
-        mTTSHelper = new TTSHelper(mContext);
-    }
-
-    public boolean isPlaying() {
-        return mTTSHelper.isSpeaking();
-    }
-
-    private void handleNewMessage(Intent intent) {
-        if (DBG) {
-            Log.d(TAG, "Handling new message");
-        }
-        try {
-            MapMessage message = MapMessage.parseFrom(intent);
-            if (MessengerService.DBG) {
-                Log.v(TAG, "Parsed message: " + message);
-            }
-            MessageKey messageKey = new MessageKey(message);
-            boolean repeatMessage = mMessages.containsKey(messageKey);
-            mMessages.put(messageKey, message);
-            if (!repeatMessage) {
-                updateNotificationInfo(message, messageKey);
-            }
-        } catch (IllegalArgumentException e) {
-            Log.e(TAG, "Dropping invalid MAP message", e);
-        }
-    }
-
-    private void updateNotificationInfo(MapMessage message, MessageKey messageKey) {
-        SenderKey senderKey = new SenderKey(message);
-        // check the version/feature of the
-        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
-        adapter.getRemoteDevice(senderKey.mDeviceAddress).sdpSearch(BluetoothUuid.MAS);
-
-        NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
-        if (notificationInfo == null) {
-            notificationInfo =
-                    new NotificationInfo(message.getSenderName(), message.getSenderContactUri());
-            mNotificationInfos.put(senderKey, notificationInfo);
-        }
-        notificationInfo.mMessageKeys.add(messageKey);
-        updateNotificationFor(senderKey, notificationInfo);
-    }
-
-    private static final String[] CONTACT_ID = new String[] {
-            ContactsContract.PhoneLookup._ID
-    };
-
-    private static int getContactIdFromName(ContentResolver cr, String name) {
-        if (DBG) {
-            Log.d(TAG, "getting contactId for: " + name);
-        }
-        if (TextUtils.isEmpty(name)) {
-            return 0;
-        }
-
-        String[] mSelectionArgs = { name };
-
-        Cursor cursor =
-                cr.query(
-                        ContactsContract.Contacts.CONTENT_URI,
-                        CONTACT_ID,
-                        ContactsContract.Contacts.DISPLAY_NAME_PRIMARY + " LIKE ?",
-                        mSelectionArgs,
-                        null);
-        try {
-            if (cursor != null && cursor.moveToFirst()) {
-                int id = cursor.getInt(cursor.getColumnIndex(ContactsContract.PhoneLookup._ID));
-                return id;
-            }
-        } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
-        }
-        return 0;
-    }
-
-    private void updateNotificationFor(SenderKey senderKey, NotificationInfo notificationInfo) {
-        if (DBG) {
-            Log.d(TAG, "updateNotificationFor" + notificationInfo);
-        }
-        String contentText = mContext.getResources().getQuantityString(
-                R.plurals.notification_new_message, notificationInfo.mMessageKeys.size(),
-                notificationInfo.mMessageKeys.size());
-        long lastReceivedTimeMs =
-                mMessages.get(notificationInfo.mMessageKeys.getLast()).getReceivedTimeMs();
-
-        Uri photoUri = ContentUris.withAppendedId(
-                ContactsContract.Contacts.CONTENT_URI, getContactIdFromName(
-                        mContext.getContentResolver(), notificationInfo.mSenderName));
-        if (DBG) {
-            Log.d(TAG, "start Glide loading... " + photoUri);
-        }
-        Glide.with(mContext)
-                .asBitmap()
-                .load(photoUri)
-                .apply(RequestOptions.circleCropTransform())
-                .into(new SimpleTarget<Bitmap>() {
-                    @Override
-                    public void onResourceReady(Bitmap bitmap,
-                            Transition<? super Bitmap> transition) {
-                        sendNotification(bitmap);
-                    }
-
-                    @Override
-                    public void onLoadFailed(@Nullable Drawable fallback) {
-                        sendNotification(null);
-                    }
-
-                    private void sendNotification(Bitmap bitmap) {
-                        if (DBG) {
-                            Log.d(TAG, "Glide loaded. " + bitmap);
-                        }
-                        if (bitmap == null) {
-                            LetterTileDrawable letterTileDrawable =
-                                    new LetterTileDrawable(mContext.getResources());
-                            letterTileDrawable.setContactDetails(
-                                    notificationInfo.mSenderName, notificationInfo.mSenderName);
-                            letterTileDrawable.setIsCircular(true);
-                            bitmap = letterTileDrawable.toBitmap(
-                                    mContext.getResources().getDimensionPixelSize(
-                                            R.dimen.notification_contact_photo_size));
-                        }
-                        PendingIntent LaunchPlayMessageActivityIntent = PendingIntent.getActivity(
-                                mContext,
-                                REQUEST_CODE_VOICE_PLATE,
-                                getPlayMessageIntent(senderKey, notificationInfo),
-                                0);
-
-                        Notification.Builder builder = new Notification.Builder(
-                                mContext, NotificationChannel.DEFAULT_CHANNEL_ID)
-                                        .setContentIntent(LaunchPlayMessageActivityIntent)
-                                        .setLargeIcon(bitmap)
-                                        .setSmallIcon(R.drawable.ic_message)
-                                        .setContentTitle(notificationInfo.mSenderName)
-                                        .setContentText(contentText)
-                                        .setWhen(lastReceivedTimeMs)
-                                        .setShowWhen(true)
-                                        .setActions(getActionsFor(senderKey, notificationInfo))
-                                        .setDeleteIntent(buildIntentFor(
-                                                MessengerService.ACTION_CLEAR_NOTIFICATION_STATE,
-                                                senderKey, notificationInfo));
-                        if (notificationInfo.muted) {
-                            builder.setPriority(Notification.PRIORITY_MIN);
-                        } else {
-                            builder.setPriority(Notification.PRIORITY_HIGH)
-                                    .setSound(Settings.System.DEFAULT_NOTIFICATION_URI);
-                        }
-                        mNotificationManager.notify(
-                                notificationInfo.mNotificationId, builder.build());
-                    }
-                });
-    }
-
-    private Intent getPlayMessageIntent(SenderKey senderKey, NotificationInfo notificationInfo) {
-        Intent intent = new Intent(mContext, PlayMessageActivity.class);
-        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-        intent.putExtra(PlayMessageActivity.EXTRA_MESSAGE_KEY, senderKey);
-        intent.putExtra(
-                PlayMessageActivity.EXTRA_SENDER_NAME,
-                notificationInfo.mSenderName);
-        if (!supportsReply(senderKey.mDeviceAddress)) {
-            intent.putExtra(
-                    PlayMessageActivity.EXTRA_REPLY_DISABLED_FLAG,
-                    true);
-        }
-        return intent;
-    }
-
-    private boolean supportsReply(String deviceAddress) {
-        return mReplyFeatureMap.containsKey(deviceAddress)
-                && mReplyFeatureMap.get(deviceAddress);
-    }
-
-    private Notification.Action[] getActionsFor(
-            SenderKey senderKey,
-            NotificationInfo notificationInfo) {
-        // Icon doesn't appear to be used; using fixed icon for all actions.
-        final Icon icon = Icon.createWithResource(mContext, android.R.drawable.ic_media_play);
-
-        List<Notification.Action.Builder> builders = new ArrayList<>(ACTION_COUNT);
-
-        // show auto reply options of device supports it
-        if (supportsReply(senderKey.mDeviceAddress)) {
-            Intent replyIntent = getPlayMessageIntent(senderKey, notificationInfo);
-            replyIntent.putExtra(PlayMessageActivity.EXTRA_SHOW_REPLY_LIST_FLAG, true);
-            PendingIntent autoReplyIntent = PendingIntent.getActivity(
-                    mContext, REQUEST_CODE_AUTO_REPLY, replyIntent, 0);
-            builders.add(new Notification.Action.Builder(icon,
-                    mContext.getString(R.string.action_reply), autoReplyIntent));
-        }
-
-        // add mute/unmute.
-        if (notificationInfo.muted) {
-            PendingIntent muteIntent = buildIntentFor(MessengerService.ACTION_UNMUTE_CONVERSATION,
-                    senderKey, notificationInfo);
-            builders.add(new Notification.Action.Builder(icon,
-                    mContext.getString(R.string.action_unmute), muteIntent));
-        } else {
-            PendingIntent muteIntent = buildIntentFor(MessengerService.ACTION_MUTE_CONVERSATION,
-                    senderKey, notificationInfo);
-            builders.add(new Notification.Action.Builder(icon,
-                    mContext.getString(R.string.action_mute), muteIntent));
-        }
-
-        Notification.Action actions[] = new Notification.Action[builders.size()];
-        for (int i = 0; i < builders.size(); i++) {
-            actions[i] = builders.get(i).build();
-        }
-        return actions;
-    }
-
-    private PendingIntent buildIntentFor(String action, SenderKey senderKey,
-            NotificationInfo notificationInfo) {
-        Intent intent = new Intent(mContext, MessengerService.class)
-                .setAction(action)
-                .putExtra(MessengerService.EXTRA_SENDER_KEY, senderKey);
-        return PendingIntent.getService(mContext,
-                notificationInfo.mNotificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
-    }
-
-    void clearNotificationState(SenderKey senderKey) {
-        if (DBG) {
-            Log.d(TAG, "Clearing notification state for: " + senderKey);
-        }
-        mNotificationInfos.remove(senderKey);
-    }
-
-    void playMessages(SenderKey senderKey) {
-        NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
-        if (notificationInfo == null) {
-            Log.e(TAG, "Unknown senderKey! " + senderKey);
-            return;
-        }
-        List<CharSequence> ttsMessages = new ArrayList<>();
-        // TODO: play unread messages instead of the last.
-        String ttsMessage =
-                notificationInfo.mMessageKeys.stream().map((key) -> mMessages.get(key).getText())
-                        .collect(Collectors.toCollection(LinkedList::new)).getLast();
-        // Insert something like "foo says" before their message content.
-        ttsMessages.add(mContext.getString(R.string.tts_sender_says, notificationInfo.mSenderName));
-        ttsMessages.add(ttsMessage);
-
-        mTTSHelper.requestPlay(ttsMessages,
-                new TTSHelper.Listener() {
-                    @Override
-                    public void onTTSStarted() {
-                        Intent intent = new Intent(ACTION_MESSAGE_PLAY_START);
-                        mContext.sendBroadcast(intent);
-                    }
-
-                    @Override
-                    public void onTTSStopped(boolean error) {
-                        Intent intent = new Intent(ACTION_MESSAGE_PLAY_STOP);
-                        mContext.sendBroadcast(intent);
-                        if (error) {
-                            Toast.makeText(mContext, R.string.tts_failed_toast,
-                                    Toast.LENGTH_SHORT).show();
-                        }
-                    }
-
-                    @Override
-                    public void onAudioFocusFailed() {
-                        Log.w(TAG, "failed to require audio focus.");
-                    }
-                });
-    }
-
-    void stopPlayout() {
-        mTTSHelper.requestStop();
-    }
-
-    void toggleMuteConversation(SenderKey senderKey, boolean mute) {
-        NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
-        if (notificationInfo == null) {
-            Log.e(TAG, "Unknown senderKey! " + senderKey);
-            return;
-        }
-        notificationInfo.muted = mute;
-        updateNotificationFor(senderKey, notificationInfo);
-    }
-
-    boolean sendAutoReply(SenderKey senderKey, BluetoothMapClient mapClient, String message) {
-        if (DBG) {
-            Log.d(TAG, "Sending auto-reply to: " + senderKey);
-        }
-        BluetoothDevice device =
-                BluetoothAdapter.getDefaultAdapter().getRemoteDevice(senderKey.mDeviceAddress);
-        NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
-        if (notificationInfo == null) {
-            Log.w(TAG, "No notificationInfo found for senderKey: " + senderKey);
-            return false;
-        }
-        if (notificationInfo.mSenderContactUri == null) {
-            Log.w(TAG, "Do not have contact URI for sender!");
-            return false;
-        }
-        Uri recipientUris[] = { Uri.parse(notificationInfo.mSenderContactUri) };
-
-        final int requestCode = senderKey.hashCode();
-        PendingIntent sentIntent =
-                PendingIntent.getBroadcast(mContext, requestCode, new Intent(
-                                BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY),
-                        PendingIntent.FLAG_ONE_SHOT);
-        return mapClient.sendMessage(device, recipientUris, message, sentIntent, null);
-    }
-
-    void handleMapDisconnect() {
-        cleanupMessagesAndNotifications((key) -> true);
-    }
-
-    void handleDeviceDisconnect(BluetoothDevice device) {
-        cleanupMessagesAndNotifications((key) -> key.matches(device.getAddress()));
-    }
-
-    private void cleanupMessagesAndNotifications(Predicate<CompositeKey> predicate) {
-        Iterator<Map.Entry<MessageKey, MapMessage>> messageIt = mMessages.entrySet().iterator();
-        while (messageIt.hasNext()) {
-            if (predicate.test(messageIt.next().getKey())) {
-                messageIt.remove();
-            }
-        }
-        Iterator<Map.Entry<SenderKey, NotificationInfo>> notificationIt =
-                mNotificationInfos.entrySet().iterator();
-        while (notificationIt.hasNext()) {
-            Map.Entry<SenderKey, NotificationInfo> entry = notificationIt.next();
-            if (predicate.test(entry.getKey())) {
-                mNotificationManager.cancel(entry.getValue().mNotificationId);
-                notificationIt.remove();
-            }
-        }
-    }
-
-    void cleanup() {
-        mBluetoothMapReceiver.cleanup();
-        mBluetoothSdpReceiver.cleanup();
-        mTTSHelper.cleanup();
-    }
-
-    private class BluetoothSdpReceiver extends BroadcastReceiver {
-        BluetoothSdpReceiver() {
-            if (DBG) {
-                Log.d(TAG, "Registering receiver for sdp");
-            }
-            IntentFilter intentFilter = new IntentFilter();
-            intentFilter.addAction(BluetoothDevice.ACTION_SDP_RECORD);
-            mContext.registerReceiver(this, intentFilter);
-        }
-
-        void cleanup() {
-            mContext.unregisterReceiver(this);
-        }
-
-        @Override
-        public void onReceive(Context context, Intent intent) {
-            if (BluetoothDevice.ACTION_SDP_RECORD.equals(intent.getAction())) {
-                if (DBG) {
-                    Log.d(TAG, "get SDP record: " + intent.getExtras());
-                }
-                Parcelable parcelable = intent.getParcelableExtra(BluetoothDevice.EXTRA_SDP_RECORD);
-                if (!(parcelable instanceof SdpMasRecord)) {
-                    if (DBG) {
-                        Log.d(TAG, "not SdpMasRecord: " + parcelable);
-                    }
-                    return;
-                }
-                SdpMasRecord masRecord = (SdpMasRecord) parcelable;
-                int features = masRecord.getSupportedFeatures();
-                int version = masRecord.getProfileVersion();
-                boolean supportsReply = false;
-                // we only consider the device supports reply feature of the version
-                // is higher than 1.02 and the feature flag is turned on.
-                if (version >= 0x102 && isOn(features, REPLY_FEATURE_POS)) {
-                    supportsReply = true;
-                }
-                BluetoothDevice bluetoothDevice =
-                        intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
-                mReplyFeatureMap.put(bluetoothDevice.getAddress(), supportsReply);
-            } else {
-                Log.w(TAG, "Ignoring unknown broadcast " + intent.getAction());
-            }
-        }
-
-        private boolean isOn(int input, int postion) {
-            return ((input >> postion) & 1) == 1;
-        }
-    }
-
-    // Used to monitor for new incoming messages and sent-message broadcast.
-    private class BluetoothMapReceiver extends BroadcastReceiver {
-        BluetoothMapReceiver() {
-            if (DBG) {
-                Log.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 cleanup() {
-            mContext.unregisterReceiver(this);
-        }
-
-        @Override
-        public void onReceive(Context context, Intent intent) {
-            if (BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY.equals(intent.getAction())) {
-                if (DBG) {
-                    Log.d(TAG, "SMS was sent successfully!");
-                }
-            } else if (BluetoothMapClient.ACTION_MESSAGE_RECEIVED.equals(intent.getAction())) {
-                if (DBG) {
-                    Log.d(TAG, "SMS message received");
-                }
-                handleNewMessage(intent);
-            } else {
-                Log.w(TAG, "Ignoring unknown broadcast " + intent.getAction());
-            }
-        }
-    }
-
-    /**
-     * Key used in HashMap that is composed from a BT device-address and device-specific "sub key"
-     */
-    private abstract static class CompositeKey {
-        final String mDeviceAddress;
-        final String mSubKey;
-
-        CompositeKey(String deviceAddress, String subKey) {
-            mDeviceAddress = deviceAddress;
-            mSubKey = subKey;
-        }
-
-        @Override
-        public boolean equals(Object o) {
-            if (this == o) {
-                return true;
-            }
-            if (o == null || getClass() != o.getClass()) {
-                return false;
-            }
-
-            CompositeKey that = (CompositeKey) o;
-            return Objects.equals(mDeviceAddress, that.mDeviceAddress)
-                    && Objects.equals(mSubKey, that.mSubKey);
-        }
-
-        boolean matches(String deviceAddress) {
-            return mDeviceAddress.equals(deviceAddress);
-        }
-
-        @Override
-        public int hashCode() {
-            return Objects.hash(mDeviceAddress, mSubKey);
-        }
-
-        @Override
-        public String toString() {
-            return String.format("%s, deviceAddress: %s, subKey: %s",
-                    getClass().getSimpleName(), mDeviceAddress, mSubKey);
-        }
-    }
-
-    /**
-     * {@link CompositeKey} subclass used to identify specific messages; it uses message-handle as
-     * the secondary key.
-     */
-    private static class MessageKey extends CompositeKey {
-        MessageKey(MapMessage message) {
-            super(message.getDevice().getAddress(), message.getHandle());
-        }
-    }
-
-    /**
-     * CompositeKey used to identify Notification info for a sender; it uses a combination of
-     * senderContactUri and senderContactName as the secondary key.
-     */
-    static class SenderKey extends CompositeKey implements Parcelable {
-        private SenderKey(String deviceAddress, String key) {
-            super(deviceAddress, key);
-        }
-
-        SenderKey(MapMessage message) {
-            // Use a combination of senderName and senderContactUri for key. Ideally we would use
-            // only senderContactUri (which is encoded phone no.). However since some phones don't
-            // provide these, we fall back to senderName. Since senderName may not be unique, we
-            // include senderContactUri also to provide uniqueness in cases it is available.
-            this(message.getDevice().getAddress(),
-                    message.getSenderName() + "/" + message.getSenderContactUri());
-        }
-
-        @Override
-        public int describeContents() {
-            return 0;
-        }
-
-        @Override
-        public void writeToParcel(Parcel dest, int flags) {
-            dest.writeString(mDeviceAddress);
-            dest.writeString(mSubKey);
-        }
-
-        public static final Parcelable.Creator<SenderKey> CREATOR =
-                new Parcelable.Creator<SenderKey>() {
-                    @Override
-                    public SenderKey createFromParcel(Parcel source) {
-                        return new SenderKey(source.readString(), source.readString());
-                    }
-
-                    @Override
-                    public SenderKey[] newArray(int size) {
-                        return new SenderKey[size];
-                    }
-                };
-    }
-
-    /**
-     * Information about a single notification that is displayed.
-     */
-    private static class NotificationInfo {
-        private static int NEXT_NOTIFICATION_ID = 0;
-
-        final int mNotificationId = NEXT_NOTIFICATION_ID++;
-        final String mSenderName;
-        @Nullable
-        final String mSenderContactUri;
-        final LinkedList<MessageKey> mMessageKeys = new LinkedList<>();
-        boolean muted = false;
-
-        NotificationInfo(String senderName, @Nullable String senderContactUri) {
-            mSenderName = senderName;
-            mSenderContactUri = senderContactUri;
-        }
-    }
-}
diff --git a/src/com/android/car/messenger/MessengerActivity.java b/src/com/android/car/messenger/MessengerActivity.java
index 8352f7d..e350dce 100644
--- a/src/com/android/car/messenger/MessengerActivity.java
+++ b/src/com/android/car/messenger/MessengerActivity.java
@@ -1,11 +1,29 @@
+/*
+ * 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.annotation.Nullable;
+
 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
+ * 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
@@ -17,4 +35,11 @@
         super.onCreate(savedInstanceState);
         finish();
     }
+
+    @Override
+    public void startActivity(Intent intent) {
+        super.startActivity(intent);
+        finish();
+    }
+
 }
diff --git a/src/com/android/car/messenger/MessengerDelegate.java b/src/com/android/car/messenger/MessengerDelegate.java
new file mode 100644
index 0000000..6328905
--- /dev/null
+++ b/src/com/android/car/messenger/MessengerDelegate.java
@@ -0,0 +1,571 @@
+package com.android.car.messenger;
+
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothMapClient;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources.NotFoundException;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.ContactsContract;
+import android.text.TextUtils;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationCompat.Action;
+import androidx.core.app.NotificationCompat.MessagingStyle;
+import androidx.core.app.Person;
+import androidx.core.app.RemoteInput;
+
+import com.android.car.apps.common.LetterTileDrawable;
+import com.android.car.messenger.bluetooth.BluetoothHelper;
+import com.android.car.messenger.bluetooth.BluetoothMonitor;
+import com.android.car.messenger.log.L;
+
+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.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Predicate;
+
+/** Delegate class responsible for handling messaging service actions */
+public class MessengerDelegate implements BluetoothMonitor.OnBluetoothEventListener {
+    private static final String TAG = "CM.MessengerDelegate";
+    // Static user name for building a MessagingStyle.
+    private static final String STATIC_USER_NAME = "STATIC_USER_NAME";
+
+    private final Context mContext;
+    private BluetoothMapClient mBluetoothMapClient;
+    private NotificationManager mNotificationManager;
+    private final SmsDatabaseHandler mSmsDatabaseHandler;
+    private boolean mShouldLoadExistingMessages;
+
+    @VisibleForTesting
+    final Map<MessageKey, MapMessage> mMessages = new HashMap<>();
+    @VisibleForTesting
+    final Map<SenderKey, NotificationInfo> mNotificationInfos = new HashMap<>();
+    // Mapping of when a device was connected via BluetoothMapClient. Used so we don't show
+    // Notifications for messages received before this time.
+    @VisibleForTesting
+    final Map<String, Long> mBTDeviceAddressToConnectionTimestamp = new HashMap<>();
+
+    public MessengerDelegate(Context context) {
+        mContext = context;
+
+        mNotificationManager =
+                (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
+        mSmsDatabaseHandler = new SmsDatabaseHandler(mContext);
+
+        try {
+            mShouldLoadExistingMessages =
+                    mContext.getResources().getBoolean(R.bool.config_loadExistingMessages);
+        } catch(NotFoundException e) {
+            // Should only happen for robolectric unit tests;
+            L.e(TAG, e, "Disabling loading of existing messages");
+            mShouldLoadExistingMessages = false;
+        }
+    }
+
+    @Override
+    public void onMessageReceived(Intent intent) {
+        try {
+            MapMessage message = MapMessage.parseFrom(intent);
+
+            MessageKey messageKey = new MessageKey(message);
+            boolean repeatMessage = mMessages.containsKey(messageKey);
+            mMessages.put(messageKey, message);
+            if (!repeatMessage) {
+                mSmsDatabaseHandler.addOrUpdate(message);
+                updateNotification(messageKey, message);
+            }
+        } catch (IllegalArgumentException e) {
+            L.e(TAG, e, "Dropping invalid MAP message.");
+        }
+    }
+
+    @Override
+    public void onMessageSent(Intent intent) {
+        /* NO-OP */
+    }
+
+    @Override
+    public void onDeviceConnected(BluetoothDevice device) {
+        L.d(TAG, "Device connected: \t%s", device.getAddress());
+        mBTDeviceAddressToConnectionTimestamp.put(device.getAddress(), System.currentTimeMillis());
+        if (mBluetoothMapClient != null && mShouldLoadExistingMessages) {
+            mBluetoothMapClient.getUnreadMessages(device);
+        } else {
+            // onDeviceConnected should be sent by BluetoothMapClient, so log if we run into this
+            // strange case.
+            L.e(TAG, "BluetoothMapClient is null after connecting to device.");
+        }
+    }
+
+    @Override
+    public void onDeviceDisconnected(BluetoothDevice device) {
+        L.d(TAG, "Device disconnected: \t%s", device.getAddress());
+        cleanupMessagesAndNotifications(key -> key.matches(device.getAddress()));
+        mBTDeviceAddressToConnectionTimestamp.remove(device.getAddress());
+        mSmsDatabaseHandler.removeMessagesForDevice(device.getAddress());
+    }
+
+    @Override
+    public void onMapConnected(BluetoothMapClient client) {
+        if (mBluetoothMapClient == client) {
+            return;
+        }
+
+        if (mBluetoothMapClient != null) {
+            mBluetoothMapClient.close();
+        }
+
+        mBluetoothMapClient = client;
+        for (BluetoothDevice device : client.getConnectedDevices()) {
+            onDeviceConnected(device);
+        }
+    }
+
+    @Override
+    public void onMapDisconnected(int profile) {
+        mBluetoothMapClient = null;
+        cleanupMessagesAndNotifications(key -> true);
+    }
+
+    @Override
+    public void onSdpRecord(BluetoothDevice device, boolean supportsReply) {
+        /* NO_OP */
+    }
+
+    protected void sendMessage(SenderKey senderKey, String messageText) {
+        boolean success = false;
+        // Even if the device is not connected, try anyway so that the reply in enqueued.
+        if (mBluetoothMapClient != null) {
+            NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
+            if (notificationInfo == null) {
+                L.w(TAG, "No notificationInfo found for senderKey: %s", senderKey);
+            } else if (notificationInfo.mSenderContactUri == null) {
+                L.w(TAG, "Do not have contact URI for sender!");
+            } else {
+                Uri recipientUris[] = {Uri.parse(notificationInfo.mSenderContactUri)};
+
+                final int requestCode = senderKey.hashCode();
+
+                Intent intent = new Intent(BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY);
+                PendingIntent sentIntent = PendingIntent.getBroadcast(mContext, requestCode, intent,
+                        PendingIntent.FLAG_ONE_SHOT);
+
+                success = BluetoothHelper.sendMessage(mBluetoothMapClient,
+                        senderKey.getDeviceAddress(), recipientUris, messageText,
+                        sentIntent, null);
+            }
+        }
+
+        final boolean deviceConnected = mBTDeviceAddressToConnectionTimestamp.containsKey(
+                senderKey.getDeviceAddress());
+        if (!success || !deviceConnected) {
+            L.e(TAG, "Unable to send reply!");
+            final int toastResource = deviceConnected
+                    ? R.string.auto_reply_failed_message
+                    : R.string.auto_reply_device_disconnected;
+
+            Toast.makeText(mContext, toastResource, Toast.LENGTH_SHORT).show();
+        }
+    }
+
+    protected void markAsRead(SenderKey senderKey) {
+        NotificationInfo info = mNotificationInfos.get(senderKey);
+        for (MessageKey key : info.mMessageKeys) {
+            MapMessage message = mMessages.get(key);
+            if (!message.isRead()) {
+                message.markMessageAsRead();
+                mSmsDatabaseHandler.addOrUpdate(message);
+            }
+        }
+    }
+
+    /**
+     * Clears all notifications matching the {@param predicate}. Example method calls are when user
+     * wants to clear (a) message notification(s), or when the Bluetooth device that received the
+     * messages has been disconnected.
+     */
+    protected void clearNotifications(Predicate<CompositeKey> predicate) {
+        mNotificationInfos.forEach((senderKey, notificationInfo) -> {
+            if (predicate.test(senderKey)) {
+                mNotificationManager.cancel(notificationInfo.mNotificationId);
+            }
+        });
+    }
+
+    /** Removes all messages related to the inputted predicate, and cancels their notifications. **/
+    private void cleanupMessagesAndNotifications(Predicate<CompositeKey> predicate) {
+        for (MessageKey key : mMessages.keySet()) {
+            if (predicate.test(key)) {
+                mSmsDatabaseHandler.removeMessagesForDevice(key.getDeviceAddress());
+            }
+        }
+        mMessages.entrySet().removeIf(
+                messageKeyMapMessageEntry -> predicate.test(messageKeyMapMessageEntry.getKey()));
+        clearNotifications(predicate);
+        mNotificationInfos.entrySet().removeIf(entry -> predicate.test(entry.getKey()));
+    }
+
+    private void updateNotification(MessageKey messageKey, MapMessage mapMessage) {
+        // Only show notifications for messages received AFTER phone was connected.
+        if (mapMessage.getReceiveTime()
+                < mBTDeviceAddressToConnectionTimestamp.get(mapMessage.getDeviceAddress())) {
+            return;
+        }
+
+        SmsDatabaseHandler.readDatabase(mContext);
+        SenderKey senderKey = new SenderKey(mapMessage);
+        if (!mNotificationInfos.containsKey(senderKey)) {
+            mNotificationInfos.put(senderKey, new NotificationInfo(mapMessage.getSenderName(),
+                    mapMessage.getSenderContactUri()));
+        }
+        NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
+        notificationInfo.mMessageKeys.add(messageKey);
+
+        updateNotification(senderKey, notificationInfo);
+    }
+
+    private void updateNotification(SenderKey senderKey, NotificationInfo notificationInfo) {
+        final Uri photoUri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI,
+                getContactId(mContext.getContentResolver(), notificationInfo.mSenderContactUri));
+
+        Glide.with(mContext)
+                .asBitmap()
+                .load(photoUri)
+                .apply(RequestOptions.circleCropTransform())
+                .into(new SimpleTarget<Bitmap>() {
+                    @Override
+                    public void onResourceReady(Bitmap bitmap,
+                            Transition<? super Bitmap> transition) {
+                        sendNotification(bitmap);
+                    }
+
+                    @Override
+                    public void onLoadFailed(@Nullable Drawable fallback) {
+                        sendNotification(null);
+                    }
+
+                    private void sendNotification(Bitmap bitmap) {
+                        mNotificationManager.notify(
+                                notificationInfo.mNotificationId,
+                                createNotification(senderKey, notificationInfo, bitmap));
+                    }
+                });
+    }
+
+    // TODO: move out to a shared library.
+    protected 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;
+    }
+
+    protected void cleanup() {
+        cleanupMessagesAndNotifications(key -> true);
+        if (mBluetoothMapClient != null) {
+            mBluetoothMapClient.close();
+        }
+    }
+
+    private Notification createNotification(
+            SenderKey senderKey, NotificationInfo notificationInfo, Bitmap bitmap) {
+        String contentText = mContext.getResources().getQuantityString(
+                R.plurals.notification_new_message, notificationInfo.mMessageKeys.size(),
+                notificationInfo.mMessageKeys.size());
+        long lastReceiveTime = mMessages.get(notificationInfo.mMessageKeys.getLast())
+                .getReceiveTime();
+
+        if (bitmap == null) {
+            bitmap = letterTileBitmap(notificationInfo.mSenderName);
+        }
+
+        final String senderName = notificationInfo.mSenderName;
+        final int notificationId = notificationInfo.mNotificationId;
+
+        // Create the Content Intent
+        PendingIntent deleteIntent = createServiceIntent(senderKey, notificationId,
+                MessengerService.ACTION_CLEAR_NOTIFICATION_STATE);
+
+        List<Action> actions = getNotificationActions(senderKey, notificationId);
+
+        Person user = new Person.Builder()
+                .setName(STATIC_USER_NAME)
+                .build();
+        MessagingStyle messagingStyle = new MessagingStyle(user);
+        Person sender = new Person.Builder()
+                .setName(senderName)
+                .setUri(notificationInfo.mSenderContactUri)
+                .build();
+        notificationInfo.mMessageKeys.stream().map(mMessages::get).forEachOrdered(message -> {
+            if (!message.isRead()) {
+                messagingStyle.addMessage(
+                        message.getMessageText(),
+                        message.getReceiveTime(),
+                        sender);
+            }
+        });
+
+        NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext,
+                MessengerService.SMS_CHANNEL_ID)
+                .setContentTitle(senderName)
+                .setContentText(contentText)
+                .setStyle(messagingStyle)
+                .setCategory(Notification.CATEGORY_MESSAGE)
+                .setLargeIcon(bitmap)
+                .setSmallIcon(R.drawable.ic_message)
+                .setWhen(lastReceiveTime)
+                .setShowWhen(true)
+                .setDeleteIntent(deleteIntent);
+
+        for (final Action action : actions) {
+            builder.addAction(action);
+        }
+
+        return builder.build();
+    }
+
+    private Bitmap letterTileBitmap(String senderName) {
+        LetterTileDrawable letterTileDrawable = new LetterTileDrawable(mContext.getResources());
+        letterTileDrawable.setContactDetails(senderName, senderName);
+        letterTileDrawable.setIsCircular(true);
+
+        int bitmapSize = mContext.getResources()
+                .getDimensionPixelSize(R.dimen.notification_contact_photo_size);
+
+        return letterTileDrawable.toBitmap(bitmapSize);
+    }
+
+    private PendingIntent createServiceIntent(SenderKey senderKey, int notificationId,
+            String action) {
+        Intent intent = new Intent(mContext, MessengerService.class)
+                .setAction(action)
+                .putExtra(MessengerService.EXTRA_SENDER_KEY, senderKey);
+
+        return PendingIntent.getForegroundService(mContext, notificationId, intent,
+                PendingIntent.FLAG_UPDATE_CURRENT);
+    }
+
+    private List<Action> getNotificationActions(SenderKey senderKey, int notificationId) {
+
+        final int icon = android.R.drawable.ic_media_play;
+
+        final List<Action> actionList = new ArrayList<>();
+
+        // Reply action
+        if (shouldAddReplyAction(senderKey.getDeviceAddress())) {
+            final String replyString = mContext.getString(R.string.action_reply);
+            PendingIntent replyIntent = createServiceIntent(senderKey, notificationId,
+                    MessengerService.ACTION_VOICE_REPLY);
+            actionList.add(
+                    new Action.Builder(icon, replyString, replyIntent)
+                            .setSemanticAction(Action.SEMANTIC_ACTION_REPLY)
+                            .setShowsUserInterface(false)
+                            .addRemoteInput(
+                                    new RemoteInput.Builder(MessengerService.REMOTE_INPUT_KEY)
+                                            .build()
+                            )
+                            .build()
+            );
+        }
+
+        // Mark-as-read Action. This will be the callback of Notification Center's "Read" action.
+        final String markAsRead = mContext.getString(R.string.action_mark_as_read);
+        PendingIntent markAsReadIntent = createServiceIntent(senderKey, notificationId,
+                MessengerService.ACTION_MARK_AS_READ);
+        actionList.add(
+                new Action.Builder(icon, markAsRead, markAsReadIntent)
+                        .setSemanticAction(Action.SEMANTIC_ACTION_MARK_AS_READ)
+                        .setShowsUserInterface(false)
+                        .build()
+        );
+
+        return actionList;
+    }
+
+    private boolean shouldAddReplyAction(String deviceAddress) {
+        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+        if (adapter == null) {
+            return false;
+        }
+        BluetoothDevice device = adapter.getRemoteDevice(deviceAddress);
+
+        return mBluetoothMapClient.isUploadingSupported(device);
+    }
+
+    /**
+     * Contains information about a single notification that is displayed, with grouped messages.
+     */
+    @VisibleForTesting
+    static class NotificationInfo {
+        private static int NEXT_NOTIFICATION_ID = 0;
+
+        final int mNotificationId = NEXT_NOTIFICATION_ID++;
+        final String mSenderName;
+        @Nullable
+        final String mSenderContactUri;
+        final LinkedList<MessageKey> mMessageKeys = new LinkedList<>();
+
+        NotificationInfo(String senderName, @Nullable String senderContactUri) {
+            mSenderName = senderName;
+            mSenderContactUri = senderContactUri;
+        }
+    }
+
+    /**
+     * A composite key used for {@link Map} lookups, using two strings for
+     * checking equality and hashing.
+     */
+    public abstract static class CompositeKey {
+        private final String mDeviceAddress;
+        private final String mSubKey;
+
+        CompositeKey(String deviceAddress, String subKey) {
+            mDeviceAddress = deviceAddress;
+            mSubKey = subKey;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+
+            if (!(o instanceof CompositeKey)) {
+                return false;
+            }
+
+            CompositeKey that = (CompositeKey) o;
+            return Objects.equals(mDeviceAddress, that.mDeviceAddress)
+                    && Objects.equals(mSubKey, that.mSubKey);
+        }
+
+        /**
+         * Returns true if the device address of this composite key equals {@code deviceAddress}.
+         *
+         * @param deviceAddress the device address which is compared to this key's device address
+         * @return true if the device addresses match
+         */
+        public boolean matches(String deviceAddress) {
+            return mDeviceAddress.equals(deviceAddress);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(mDeviceAddress, mSubKey);
+        }
+
+        @Override
+        public String toString() {
+            return String.format("%s, deviceAddress: %s, subKey: %s",
+                    getClass().getSimpleName(), mDeviceAddress, mSubKey);
+        }
+
+        /** Returns this composite key's device address. */
+        public String getDeviceAddress() {
+            return mDeviceAddress;
+        }
+
+        /** Returns this composite key's sub key. */
+        public String getSubKey() {
+            return mSubKey;
+        }
+    }
+
+    /**
+     * {@link CompositeKey} subclass used to identify Notification info for a sender;
+     * it uses a combination of senderContactUri and senderContactName as the secondary key.
+     */
+    public static class SenderKey extends CompositeKey implements Parcelable {
+
+        private SenderKey(String deviceAddress, String key) {
+            super(deviceAddress, key);
+        }
+
+        SenderKey(MapMessage message) {
+            // Use a combination of senderName and senderContactUri for key. Ideally we would use
+            // only senderContactUri (which is encoded phone no.). However since some phones don't
+            // provide these, we fall back to senderName. Since senderName may not be unique, we
+            // include senderContactUri also to provide uniqueness in cases it is available.
+            this(message.getDeviceAddress(),
+                    message.getSenderName() + "/" + message.getSenderContactUri());
+        }
+
+        @Override
+        public String toString() {
+            return String.format("SenderKey: %s -- %s", getDeviceAddress(), getSubKey());
+        }
+
+        @Override
+        public int describeContents() {
+            return 0;
+        }
+
+        @Override
+        public void writeToParcel(Parcel dest, int flags) {
+            dest.writeString(getDeviceAddress());
+            dest.writeString(getSubKey());
+        }
+
+        /** Creates {@link SenderKey} instances from {@link Parcel} sources. */
+        public static final Parcelable.Creator<SenderKey> CREATOR =
+                new Parcelable.Creator<SenderKey>() {
+                    @Override
+                    public SenderKey createFromParcel(Parcel source) {
+                        return new SenderKey(source.readString(), source.readString());
+                    }
+
+                    @Override
+                    public SenderKey[] newArray(int size) {
+                        return new SenderKey[size];
+                    }
+                };
+
+    }
+
+    /**
+     * {@link CompositeKey} subclass used to identify specific messages; it uses message-handle as
+     * the secondary key.
+     */
+    public static class MessageKey extends CompositeKey {
+        MessageKey(MapMessage message) {
+            super(message.getDeviceAddress(), message.getHandle());
+        }
+    }
+}
diff --git a/src/com/android/car/messenger/MessengerService.java b/src/com/android/car/messenger/MessengerService.java
index 01fc954..11fdd13 100644
--- a/src/com/android/car/messenger/MessengerService.java
+++ b/src/com/android/car/messenger/MessengerService.java
@@ -1,82 +1,75 @@
-/*
- * 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.
- */
-
 package com.android.car.messenger;
 
+
+import android.app.Notification;
+import android.app.Notification.Action;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
 import android.app.Service;
-import android.bluetooth.BluetoothAdapter;
-import android.bluetooth.BluetoothDevice;
-import android.bluetooth.BluetoothMapClient;
-import android.bluetooth.BluetoothProfile;
-import android.content.BroadcastReceiver;
-import android.content.Context;
 import android.content.Intent;
-import android.content.IntentFilter;
+import android.media.AudioAttributes;
 import android.os.Binder;
+import android.os.Bundle;
 import android.os.IBinder;
-import android.util.Log;
-import android.widget.Toast;
+import android.provider.Settings;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
 
-/**
- * Background started service that hosts messaging components.
- * <p>
- * The MapConnector manages connecting to the BT MAP service and the MapMessageMonitor listens for
- * new incoming messages and publishes notifications. Actions in the notifications trigger command
- * intents to this service (e.g. auto-reply, play message).
- * <p>
- * This service and its helper components run entirely in the main thread.
- */
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.RemoteInput;
+
+import com.android.car.messenger.MessengerDelegate.SenderKey;
+import com.android.car.messenger.bluetooth.BluetoothMonitor;
+import com.android.car.messenger.log.L;
+
+/** Service responsible for handling SMS messaging events from paired Bluetooth devices. */
 public class MessengerService extends Service {
-    static final String TAG = "MessengerService";
-    static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
+    private final static String TAG = "CM.MessengerService";
 
-    // Used to start this service at boot-complete. Takes no arguments.
-    static final String ACTION_START = "com.android.car.messenger.ACTION_START";
-    // Used to auto-reply to messages from a sender (invoked from Notification).
-    static final String ACTION_AUTO_REPLY = "com.android.car.messenger.ACTION_AUTO_REPLY";
-    // Used to play-out messages from a sender (invoked from Notification).
-    static final String ACTION_PLAY_MESSAGES = "com.android.car.messenger.ACTION_PLAY_MESSAGES";
-    // Used to stop further audio notifications from the conversation.
-    static final String ACTION_MUTE_CONVERSATION =
-            "com.android.car.messenger.ACTION_MUTE_CONVERSATION";
-    // Used to resume further audio notifications from the conversation.
-    static final String ACTION_UNMUTE_CONVERSATION =
-            "com.android.car.messenger.ACTION_UNMUTE_CONVERSATION";
-    // Used to clear notification state when user dismisses notification.
-    static final String ACTION_CLEAR_NOTIFICATION_STATE =
+    /* 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 reply to message with voice input; triggered by an assistant. */
+    public static final String ACTION_VOICE_REPLY = "com.android.car.messenger.ACTION_VOICE_REPLY";
+
+    /** Used to clear notification state when user dismisses notification. */
+    public static final String ACTION_CLEAR_NOTIFICATION_STATE =
             "com.android.car.messenger.ACTION_CLEAR_NOTIFICATION_STATE";
-    // Used to stop current play-out (invoked from Notification).
-    static final String ACTION_STOP_PLAYOUT = "com.android.car.messenger.ACTION_STOP_PLAYOUT";
 
-    // Common extra for ACTION_AUTO_REPLY and ACTION_PLAY_MESSAGES.
-    static final String EXTRA_SENDER_KEY = "com.android.car.messenger.EXTRA_SENDER_KEY";
+    /** Used to mark a notification as read **/
+    public static final String ACTION_MARK_AS_READ =
+            "com.android.car.messenger.ACTION_MARK_AS_READ";
 
-    static final String EXTRA_REPLY_MESSAGE = "com.android.car.messenger.EXTRA_REPLY_MESSAGE";
+    /** 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 that this service started to play out the messages.
-    static final String ACTION_PLAY_MESSAGES_STARTED =
-            "com.android.car.messenger.ACTION_PLAY_MESSAGES_STARTED";
+    /** 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";
 
-    // Used to notify that this service finished playing out the messages.
-    static final String ACTION_PLAY_MESSAGES_STOPPED =
-            "com.android.car.messenger.ACTION_PLAY_MESSAGES_STOPPED";
+    /* EXTRAS */
+    /** Key under which the {@link SenderKey} is provided. */
+    public static final String EXTRA_SENDER_KEY = "com.android.car.messenger.EXTRA_SENDER_KEY";
 
-    private MapMessageMonitor mMessageMonitor;
-    private MapDeviceMonitor mDeviceMonitor;
-    private BluetoothMapClient mMapClient;
+    /**
+     * The resultKey of the {@link RemoteInput} which is sent in the reply callback {@link Action}.
+     */
+    public static final String REMOTE_INPUT_KEY = "REMOTE_INPUT_KEY";
+
+    /* NOTIFICATIONS */
+    static final String SMS_CHANNEL_ID = "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 MessengerDelegate mMessengerDelegate;
+
+    /** Notifies this service of new bluetooth actions */
+    private BluetoothMonitor mBluetoothMonitor;
+
+    /* Binding boilerplate */
     private final IBinder mBinder = new LocalBinder();
 
     public class LocalBinder extends Binder {
@@ -86,190 +79,186 @@
     }
 
     @Override
-    public void onCreate() {
-        if (DBG) {
-            Log.d(TAG, "onCreate");
-        }
-
-        mMessageMonitor = new MapMessageMonitor(this);
-        mDeviceMonitor = new MapDeviceMonitor();
-        connectToMap();
-    }
-
-    private void connectToMap() {
-        if (DBG) {
-            Log.d(TAG, "Connecting to MAP service");
-        }
-        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
-        if (adapter == null) {
-            // This *should* never happen. Unless there's some severe internal error?
-            Log.wtf(TAG, "BluetoothAdapter is null! Internal error?");
-            return;
-        }
-
-        if (!adapter.getProfileProxy(this, mMapServiceListener, BluetoothProfile.MAP_CLIENT)) {
-            // This *should* never happen.  Unless arguments passed are incorrect somehow...
-            Log.wtf(TAG, "Unable to get MAP profile! Possible programmer error?");
-            return;
-        }
-    }
-
-    @Override
-    public int onStartCommand(Intent intent, int flags, int startId) {
-        if (DBG) {
-            Log.d(TAG, "Handling intent: " + intent);
-        }
-
-        // Service will be restarted even if its killed/dies. It will never stop itself.
-        // It may be restarted with null intent or one of the other intents e.g. REPLY, PLAY etc.
-        final int result = START_STICKY;
-
-        if (intent == null || ACTION_START.equals(intent.getAction())) {
-            // These are NO-OP's since they're just used to bring up this service.
-            return result;
-        }
-
-        if (!hasRequiredArgs(intent)) {
-            return result;
-        }
-        switch (intent.getAction()) {
-            case ACTION_AUTO_REPLY:
-                boolean success;
-                if (mMapClient != null) {
-                    success = mMessageMonitor.sendAutoReply(
-                            intent.getParcelableExtra(EXTRA_SENDER_KEY),
-                            mMapClient,
-                            intent.getStringExtra(EXTRA_REPLY_MESSAGE));
-                } else {
-                    Log.e(TAG, "Unable to send reply; MAP profile disconnected!");
-                    success = false;
-                }
-                if (!success) {
-                    Toast.makeText(this, R.string.auto_reply_failed_message, Toast.LENGTH_SHORT)
-                            .show();
-                }
-                break;
-            case ACTION_PLAY_MESSAGES:
-                mMessageMonitor.playMessages(intent.getParcelableExtra(EXTRA_SENDER_KEY));
-                break;
-            case ACTION_MUTE_CONVERSATION:
-                mMessageMonitor.toggleMuteConversation(
-                        intent.getParcelableExtra(EXTRA_SENDER_KEY), true);
-                break;
-            case ACTION_UNMUTE_CONVERSATION:
-                mMessageMonitor.toggleMuteConversation(
-                        intent.getParcelableExtra(EXTRA_SENDER_KEY), false);
-                break;
-            case ACTION_STOP_PLAYOUT:
-                mMessageMonitor.stopPlayout();
-                break;
-            case ACTION_CLEAR_NOTIFICATION_STATE:
-                mMessageMonitor.clearNotificationState(intent.getParcelableExtra(EXTRA_SENDER_KEY));
-                break;
-            default:
-                Log.e(TAG, "Ignoring unknown intent: " + intent.getAction());
-        }
-        return result;
-    }
-
-    /**
-     * @return {code true} if the service is playing the TTS of the message.
-     */
-    public boolean isPlaying() {
-        return mMessageMonitor.isPlaying();
-    }
-
-    private boolean hasRequiredArgs(Intent intent) {
-        switch (intent.getAction()) {
-            case ACTION_AUTO_REPLY:
-            case ACTION_PLAY_MESSAGES:
-            case ACTION_MUTE_CONVERSATION:
-            case ACTION_CLEAR_NOTIFICATION_STATE:
-                if (!intent.hasExtra(EXTRA_SENDER_KEY)) {
-                    Log.w(TAG, "Intent is missing sender-key extra: " + intent.getAction());
-                    return false;
-                }
-                return true;
-            case ACTION_STOP_PLAYOUT:
-                // No args.
-                return true;
-            default:
-                // For unknown actions, default to true. We'll report error on these later.
-                return true;
-        }
-    }
-
-    @Override
-    public void onDestroy() {
-        if (DBG) {
-            Log.d(TAG, "onDestroy");
-        }
-        if (mMapClient != null) {
-            mMapClient.close();
-        }
-        mDeviceMonitor.cleanup();
-        mMessageMonitor.cleanup();
-    }
-
-    @Override
     public IBinder onBind(Intent intent) {
         return mBinder;
     }
 
-    // NOTE: These callbacks are invoked on the main thread.
-    private final BluetoothProfile.ServiceListener mMapServiceListener =
-            new BluetoothProfile.ServiceListener() {
-        @Override
-        public void onServiceConnected(int profile, BluetoothProfile proxy) {
-            mMapClient = (BluetoothMapClient) proxy;
-            if (MessengerService.DBG) {
-                Log.d(TAG, "Connected to MAP service!");
-            }
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        L.d(TAG, "onCreate");
 
-            // Since we're connected, we will received broadcasts for any new messages
-            // in the MapMessageMonitor.
+        mMessengerDelegate = new MessengerDelegate(this);
+        mBluetoothMonitor = new BluetoothMonitor(this);
+        mBluetoothMonitor.registerListener(mMessengerDelegate);
+        sendServiceRunningNotification();
+    }
+
+
+    private void sendServiceRunningNotification() {
+        NotificationManager notificationManager = getSystemService(NotificationManager.class);
+
+        if (notificationManager == null) {
+            L.e(TAG, "Failed to get NotificationManager instance");
+            return;
         }
 
-        @Override
-        public void onServiceDisconnected(int profile) {
-            if (MessengerService.DBG) {
-                Log.d(TAG, "Disconnected from MAP service!");
-            }
-            mMapClient = null;
-            mMessageMonitor.handleMapDisconnect();
-        }
-    };
-
-    private class MapDeviceMonitor extends BroadcastReceiver {
-        MapDeviceMonitor() {
-            if (DBG) {
-                Log.d(TAG, "Registering Map device monitor");
-            }
-            IntentFilter intentFilter = new IntentFilter();
-            intentFilter.addAction(BluetoothMapClient.ACTION_CONNECTION_STATE_CHANGED);
-            registerReceiver(this, intentFilter, android.Manifest.permission.BLUETOOTH, null);
+        // 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);
         }
 
-        void cleanup() {
-            unregisterReceiver(this);
+        {
+            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);
         }
 
-        @Override
-        public void onReceive(Context context, Intent intent) {
-            int state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1);
-            int previousState = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, -1);
-            BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
-            if (state == -1 || previousState == -1 || device == null) {
-                Log.w(TAG, "Skipping broadcast, missing required extra");
-                return;
-            }
-            if (previousState == BluetoothProfile.STATE_CONNECTED
-                    && state != BluetoothProfile.STATE_CONNECTED) {
-                if (DBG) {
-                    Log.d(TAG, "Device losing MAP connection: " + device);
+        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.cleanup();
+        mBluetoothMonitor.cleanup();
+    }
+
+    @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_VOICE_REPLY:
+                voiceReply(intent);
+                break;
+            case ACTION_CLEAR_NOTIFICATION_STATE:
+                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_VOICE_REPLY:
+            case ACTION_CLEAR_NOTIFICATION_STATE:
+            case ACTION_MARK_AS_READ:
+                if (!intent.hasExtra(EXTRA_SENDER_KEY)) {
+                    L.w(TAG, "Intent %s missing sender-key extra.", intent.getAction());
+                    return false;
                 }
-                mMessageMonitor.handleDeviceDisconnect(device);
-            }
+                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 MessengerService#EXTRA_SENDER_KEY} and
+     *               a {@link RemoteInput} with {@link MessengerService#REMOTE_INPUT_KEY} resultKey
+     */
+    public void voiceReply(Intent intent) {
+        final SenderKey senderKey = intent.getParcelableExtra(EXTRA_SENDER_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(REMOTE_INPUT_KEY);
+        L.d(TAG, "voiceReply");
+        if (!TextUtils.isEmpty(message)) {
+            mMessengerDelegate.sendMessage(senderKey, message.toString());
+        }
+    }
+
+    /**
+     * Clears notification(s) associated with a given sender key.
+     *
+     * @param intent intent containing {@link MessengerService#EXTRA_SENDER_KEY} bundle argument
+     */
+    public void clearNotificationState(Intent intent) {
+        final SenderKey senderKey = intent.getParcelableExtra(EXTRA_SENDER_KEY);
+        L.d(TAG, "clearNotificationState");
+        mMessengerDelegate.clearNotifications(key -> key.equals(senderKey));
+    }
+
+    /**
+     * Mark a conversation associated with a given sender key as read.
+     *
+     * @param intent intent containing {@link MessengerService#EXTRA_SENDER_KEY} bundle argument
+     */
+    public void markAsRead(Intent intent) {
+        final SenderKey senderKey = intent.getParcelableExtra(EXTRA_SENDER_KEY);
+        L.d(TAG, "markAsRead");
+        mMessengerDelegate.markAsRead(senderKey);
+    }
+
+    /**
+     * 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 senderKey from the recipient's address, and sendMessage() to it.
+    }
 }
diff --git a/src/com/android/car/messenger/MessengerReceiver.java b/src/com/android/car/messenger/MmsReceiver.java
similarity index 67%
copy from src/com/android/car/messenger/MessengerReceiver.java
copy to src/com/android/car/messenger/MmsReceiver.java
index c3af2f8..37cc5ef 100644
--- a/src/com/android/car/messenger/MessengerReceiver.java
+++ b/src/com/android/car/messenger/MmsReceiver.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2017 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.
@@ -19,16 +19,15 @@
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
-import android.util.Log;
 
 /**
- * Minimal receiver that starts up MessengerService on boot-completion.
+ * No-op Receiver that only exists in order to be eligible to be the default SMS app.
  */
-public class MessengerReceiver extends BroadcastReceiver {
+public class MmsReceiver extends BroadcastReceiver {
     @Override
     public void onReceive(Context context, Intent intent) {
-        Intent startIntent =
-                new Intent(MessengerService.ACTION_START).setClass(context, MessengerService.class);
-        context.startService(startIntent);
+        Intent startIntent = new Intent(context, MessengerService.class)
+                .setAction(MessengerService.ACTION_RECEIVED_MMS);
+        context.startForegroundService(startIntent);
     }
 }
diff --git a/src/com/android/car/messenger/PlayMessageActivity.java b/src/com/android/car/messenger/PlayMessageActivity.java
deleted file mode 100644
index 7bfc5f9..0000000
--- a/src/com/android/car/messenger/PlayMessageActivity.java
+++ /dev/null
@@ -1,326 +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
- */
-
-package com.android.car.messenger;
-
-import android.animation.AnimatorInflater;
-import android.animation.AnimatorSet;
-import android.annotation.Nullable;
-import android.app.Activity;
-import android.content.BroadcastReceiver;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.ServiceConnection;
-import android.os.Bundle;
-import android.os.IBinder;
-import android.util.Log;
-import android.view.MotionEvent;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import com.android.car.messenger.tts.TTSHelper;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Controls the TTS of the message received.
- */
-public class PlayMessageActivity extends Activity {
-    private static final String TAG = "PlayMessageActivity";
-
-    public static final String EXTRA_MESSAGE_KEY = "car.messenger.EXTRA_MESSAGE_KEY";
-    public static final String EXTRA_SENDER_NAME = "car.messenger.EXTRA_SENDER_NAME";
-    public static final String EXTRA_SHOW_REPLY_LIST_FLAG =
-            "car.messenger.EXTRA_SHOW_REPLY_LIST_FLAG";
-    public static final String EXTRA_REPLY_DISABLED_FLAG =
-            "car.messenger.EXTRA_REPLY_DISABLED_FLAG";
-    private View mContainer;
-    private View mMessageContainer;
-    private View mVoicePlate;
-    private TextView mReplyNotice;
-    private TextView mLeftButton;
-    private TextView mRightButton;
-    private ImageView mVoiceIcon;
-    private MessengerService mMessengerService;
-    private MessengerServiceBroadcastReceiver mMessengerServiceBroadcastReceiver =
-            new MessengerServiceBroadcastReceiver();
-    private MapMessageMonitor.SenderKey mSenderKey;
-    private TTSHelper mTTSHelper;
-
-    @Override
-    protected void onCreate(@Nullable Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        setContentView(R.layout.play_message_layout);
-        mContainer = findViewById(R.id.container);
-        mMessageContainer = findViewById(R.id.message_container);
-        mReplyNotice = (TextView) findViewById(R.id.reply_notice);
-        mVoicePlate = findViewById(R.id.voice_plate);
-        mLeftButton = (TextView) findViewById(R.id.left_btn);
-        mRightButton = (TextView) findViewById(R.id.right_btn);
-        mVoiceIcon = (ImageView) findViewById(R.id.voice_icon);
-
-        mTTSHelper = new TTSHelper(this);
-        setupEmojis();
-        hideAutoReply();
-        setupAutoReply();
-        updateViewForMessagePlaying();
-
-        AnimatorSet set = (AnimatorSet) AnimatorInflater.loadAnimator(this,
-                R.anim.trans_bottom_in);
-        set.setTarget(mContainer);
-        set.start();
-    }
-
-    private void setupEmojis() {
-        TextView emoji1 = (TextView) findViewById(R.id.emoji1);
-        emoji1.setText(getEmojiByUnicode(getResources().getInteger(R.integer.emoji_thumb_up)));
-        TextView emoji2 = (TextView) findViewById(R.id.emoji2);
-        emoji2.setText(getEmojiByUnicode(getResources().getInteger(R.integer.emoji_thumb_down)));
-        TextView emoji3 = (TextView) findViewById(R.id.emoji3);
-        emoji3.setText(getEmojiByUnicode(getResources().getInteger(R.integer.emoji_smiling_face)));
-    }
-
-    private String getEmojiByUnicode(int unicode){
-        return new String(Character.toChars(unicode));
-    }
-
-    private void setupAutoReply() {
-        TextView cannedMessage = (TextView) findViewById(R.id.canned_message);
-        cannedMessage.setText(getString(R.string.reply_message_display_template,
-                getString(R.string.caned_message_driving_right_now)));
-        cannedMessage.setOnClickListener(
-                v -> sendReply(getString(R.string.caned_message_driving_right_now)));
-        findViewById(R.id.emoji1).setOnClickListener(this::sendReply);
-        findViewById(R.id.emoji2).setOnClickListener(this::sendReply);
-        findViewById(R.id.emoji3).setOnClickListener(this::sendReply);
-    }
-
-    /**
-     * View needs to be TextView. Leave it as View, so can take advantage of lambda syntax
-     */
-    private void sendReply(View view) {
-        sendReply(((TextView) view).getText());
-    }
-
-    private void sendReply(CharSequence message) {
-        // send auto reply
-        Intent intent = new Intent(getBaseContext(), MessengerService.class)
-                .setAction(MessengerService.ACTION_AUTO_REPLY)
-                .putExtra(MessengerService.EXTRA_SENDER_KEY, mSenderKey)
-                .putExtra(
-                        MessengerService.EXTRA_REPLY_MESSAGE,
-                        message);
-        startService(intent);
-
-        String messageSent = getString(
-                R.string.message_sent_notice,
-                getIntent().getStringExtra(EXTRA_SENDER_NAME));
-        // hide all view and show reply sent notice text
-        mContainer.invalidate();
-        mMessageContainer.setVisibility(View.GONE);
-        mVoicePlate.setVisibility(View.GONE);
-        mReplyNotice.setText(messageSent);
-        mReplyNotice.setVisibility(View.VISIBLE);
-        ViewGroup.LayoutParams layoutParams = mContainer.getLayoutParams();
-        layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
-        mContainer.requestLayout();
-
-        // read out the reply sent notice. Finish activity after TTS is done.
-        List<CharSequence> ttsMessages = new ArrayList<>();
-        ttsMessages.add(messageSent);
-        mTTSHelper.requestPlay(ttsMessages,
-                new TTSHelper.Listener() {
-                    @Override
-                    public void onTTSStarted() {
-                    }
-
-                    @Override
-                    public void onTTSStopped(boolean error) {
-                        if (error) {
-                            Log.w(TAG, "TTS error.");
-                        }
-                        finish();
-                    }
-
-                    @Override
-                    public void onAudioFocusFailed() {
-                        Log.w(TAG, "failed to require audio focus.");
-                    }
-                });
-    }
-
-    private void showAutoReply() {
-        mContainer.invalidate();
-        mMessageContainer.setVisibility(View.VISIBLE);
-        mLeftButton.setText(getString(R.string.action_close_messages));
-        mLeftButton.setOnClickListener(v -> finish());
-        ViewGroup.LayoutParams layoutParams = mContainer.getLayoutParams();
-        layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT;
-        mContainer.requestLayout();
-    }
-
-    private void hideAutoReply() {
-        mContainer.invalidate();
-        mMessageContainer.setVisibility(View.GONE);
-        mLeftButton.setText(getString(R.string.action_reply));
-        mLeftButton.setOnClickListener(v -> showAutoReply());
-        ViewGroup.LayoutParams layoutParams = mContainer.getLayoutParams();
-        layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
-        mContainer.requestLayout();
-    }
-
-    /**
-     * If there's a touch outside the voice plate, exit the activity.
-     */
-    @Override
-    public boolean onTouchEvent(MotionEvent event) {
-        if (event.getAction() == MotionEvent.ACTION_UP) {
-            if (event.getX() < mContainer.getX()
-                    || event.getX() > mContainer.getX() + mContainer.getWidth()
-                    || event.getY() < mContainer.getY()
-                    || event.getY() > mContainer.getY() + mContainer.getHeight()) {
-                finish();
-            }
-        }
-        return super.onTouchEvent(event);
-    }
-
-    @Override
-    protected void onStart() {
-        super.onStart();
-
-        // Bind to LocalService
-        Intent intent = new Intent(this, MessengerService.class);
-        bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
-        mMessengerServiceBroadcastReceiver.start();
-        processIntent();
-    }
-
-    @Override
-    protected void onNewIntent(Intent intent) {
-        super.onNewIntent(intent);
-        processIntent();
-    }
-
-    private void processIntent() {
-        mSenderKey = getIntent().getParcelableExtra(EXTRA_MESSAGE_KEY);
-        playMessage();
-        if (getIntent().getBooleanExtra(EXTRA_SHOW_REPLY_LIST_FLAG, false)) {
-            showAutoReply();
-        }
-        if (getIntent().getBooleanExtra(EXTRA_REPLY_DISABLED_FLAG, false)) {
-            mLeftButton.setVisibility(View.GONE);
-        }
-    }
-
-    @Override
-    protected void onDestroy() {
-        AnimatorSet set = (AnimatorSet) AnimatorInflater.loadAnimator(this,
-                R.anim.trans_bottom_out);
-        set.setTarget(mContainer);
-        set.start();
-        super.onDestroy();
-    }
-
-    @Override
-    protected void onStop() {
-        super.onStop();
-        stopMessage();
-        mTTSHelper.cleanup();
-        mMessengerServiceBroadcastReceiver.cleanup();
-        unbindService(mConnection);
-    }
-
-    private void playMessage() {
-        Intent intent = new Intent(getBaseContext(), MessengerService.class)
-                .setAction(MessengerService.ACTION_PLAY_MESSAGES)
-                .putExtra(MessengerService.EXTRA_SENDER_KEY, mSenderKey);
-        startService(intent);
-    }
-
-    private void stopMessage() {
-        Intent intent = new Intent(getBaseContext(), MessengerService.class)
-                .setAction(MessengerService.ACTION_STOP_PLAYOUT)
-                .putExtra(MessengerService.EXTRA_SENDER_KEY, mSenderKey);
-        startService(intent);
-    }
-
-    private void updateViewForMessagePlaying() {
-        mRightButton.setText(getString(R.string.action_stop));
-        mRightButton.setOnClickListener(v -> stopMessage());
-        mVoiceIcon.setVisibility(View.VISIBLE);
-    }
-
-    private void updateViewForMessageStopped() {
-        mRightButton.setText(getString(R.string.action_repeat));
-        mRightButton.setOnClickListener(v -> playMessage());
-        mVoiceIcon.setVisibility(View.INVISIBLE);
-    }
-
-    private class MessengerServiceBroadcastReceiver extends BroadcastReceiver {
-        private final IntentFilter mIntentFilter;
-        MessengerServiceBroadcastReceiver() {
-            mIntentFilter = new IntentFilter();
-            mIntentFilter.addAction(MapMessageMonitor.ACTION_MESSAGE_PLAY_START);
-            mIntentFilter.addAction(MapMessageMonitor.ACTION_MESSAGE_PLAY_STOP);
-        }
-
-        void start() {
-            registerReceiver(this, mIntentFilter);
-        }
-
-        void cleanup() {
-            unregisterReceiver(this);
-        }
-
-        @Override
-        public void onReceive(Context context, Intent intent) {
-            switch (intent.getAction()) {
-                case MapMessageMonitor.ACTION_MESSAGE_PLAY_START:
-                    updateViewForMessagePlaying();
-                    break;
-                case MapMessageMonitor.ACTION_MESSAGE_PLAY_STOP:
-                    updateViewForMessageStopped();
-                    break;
-                default:
-                    break;
-            }
-        }
-    }
-
-    private final ServiceConnection mConnection = new ServiceConnection() {
-        @Override
-        public void onServiceConnected(ComponentName className, IBinder service) {
-            MessengerService.LocalBinder binder = (MessengerService.LocalBinder) service;
-            mMessengerService = binder.getService();
-            if (mMessengerService.isPlaying()) {
-                updateViewForMessagePlaying();
-            } else {
-                updateViewForMessageStopped();
-            }
-        }
-
-        @Override
-        public void onServiceDisconnected(ComponentName arg0) {
-            mMessengerService = null;
-        }
-    };
-}
diff --git a/src/com/android/car/messenger/SmsDatabaseHandler.java b/src/com/android/car/messenger/SmsDatabaseHandler.java
new file mode 100644
index 0000000..b672354
--- /dev/null
+++ b/src/com/android/car/messenger/SmsDatabaseHandler.java
@@ -0,0 +1,197 @@
+package com.android.car.messenger;
+
+
+import static com.android.car.messenger.MessengerDelegate.getContactId;
+
+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.Telephony;
+import android.util.Log;
+
+import androidx.core.content.ContextCompat;
+
+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(MapMessage message) {
+        if (!mCanWriteToDatabase) {
+            return;
+        }
+
+        int messageIndex = findMessageIndex(message);
+        switch(messageIndex) {
+            case DUPLICATE_MESSAGES_FOUND:
+                removePreviousAndInsert(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(message));
+                return;
+            case DATABASE_ERROR:
+                return;
+            default:
+                update(messageIndex, buildMessageContentValues(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(MapMessage message) {
+        String[] smsSelectionArgs = createSmsSelectionArgs(message);
+
+        mContentResolver.delete(SMS_URI, SMS_SELECTION, smsSelectionArgs);
+        mContentResolver.insert(SMS_URI, buildMessageContentValues(message));
+    }
+
+    private int findMessageIndex(MapMessage message) {
+        String[] smsSelectionArgs = createSmsSelectionArgs(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(MapMessage message) {
+        ContentValues newMessage = new ContentValues();
+        newMessage.put(Telephony.Sms.BODY, DatabaseUtils.sqlEscapeString(message.getMessageText()));
+        newMessage.put(Telephony.Sms.DATE, message.getReceiveTime());
+        newMessage.put(Telephony.Sms.ADDRESS, message.getDeviceAddress());
+        // TODO: if contactId is null, add it.
+        newMessage.put(Telephony.Sms.PERSON,
+                getContactId(mContentResolver,
+                        message.getSenderContactUri()));
+        newMessage.put(Telephony.Sms.READ, message.isRead());
+        return newMessage;
+    }
+
+    private String[] createSmsSelectionArgs(MapMessage message) {
+        String sqlFriendlyMessageText = DatabaseUtils.sqlEscapeString(message.getMessageText());
+        String[] smsSelectionArgs = {message.getDeviceAddress(), sqlFriendlyMessageText,
+                Long.toString(message.getReceiveTime() - 5000), Long.toString(
+                message.getReceiveTime() + 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;
+    }
+}
diff --git a/src/com/android/car/messenger/MessengerReceiver.java b/src/com/android/car/messenger/SmsReceiver.java
similarity index 67%
rename from src/com/android/car/messenger/MessengerReceiver.java
rename to src/com/android/car/messenger/SmsReceiver.java
index c3af2f8..25dbf89 100644
--- a/src/com/android/car/messenger/MessengerReceiver.java
+++ b/src/com/android/car/messenger/SmsReceiver.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2017 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.
@@ -19,16 +19,16 @@
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
-import android.util.Log;
 
 /**
- * Minimal receiver that starts up MessengerService on boot-completion.
+ *  No-op Receiver that only exists in order to be eligible to be the default SMS app.
  */
-public class MessengerReceiver extends BroadcastReceiver {
+public class SmsReceiver extends BroadcastReceiver {
+
     @Override
     public void onReceive(Context context, Intent intent) {
-        Intent startIntent =
-                new Intent(MessengerService.ACTION_START).setClass(context, MessengerService.class);
-        context.startService(startIntent);
+        Intent startIntent = new Intent(context, MessengerService.class)
+                .setAction(MessengerService.ACTION_RECEIVED_SMS);
+        context.startForegroundService(startIntent);
     }
 }
diff --git a/src/com/android/car/messenger/bluetooth/BluetoothHelper.java b/src/com/android/car/messenger/bluetooth/BluetoothHelper.java
new file mode 100644
index 0000000..09629b5
--- /dev/null
+++ b/src/com/android/car/messenger/bluetooth/BluetoothHelper.java
@@ -0,0 +1,64 @@
+package com.android.car.messenger.bluetooth;
+
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothMapClient;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * Provides helper methods for performing bluetooth actions.
+ */
+public class BluetoothHelper {
+
+    /**
+     * Returns a (potentially empty) immutable set of bonded (paired) devices.
+     */
+    public static Set<BluetoothDevice> getPairedDevices() {
+        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+
+        if (adapter != null) {
+            Set<BluetoothDevice> devices = adapter.getBondedDevices();
+            if (devices != null) {
+                return devices;
+            }
+        }
+
+        return Collections.emptySet();
+    }
+
+    /**
+     * Helper method to send an SMS message through bluetooth.
+     *
+     * @param client the MAP Client used to send the message
+     * @param deviceAddress the device used to send the SMS
+     * @param contacts contacts to send the message to
+     * @param message message to send
+     * @param sentIntent callback issued once the message was sent
+     * @param deliveredIntent callback issued once the message was delivered
+     * @return true if the message was enqueued, false on error
+     * @throws IllegalArgumentException if deviceAddress is invalid
+     */
+    public static boolean sendMessage(@NonNull BluetoothMapClient client,
+            String deviceAddress,
+            Uri[] contacts,
+            String message,
+            @Nullable PendingIntent sentIntent,
+            @Nullable PendingIntent deliveredIntent)
+            throws IllegalArgumentException {
+
+        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+        if (adapter == null) {
+            return false;
+        }
+        BluetoothDevice device = adapter.getRemoteDevice(deviceAddress);
+
+        return client.sendMessage(device, contacts, message, sentIntent, deliveredIntent);
+    }
+}
diff --git a/src/com/android/car/messenger/bluetooth/BluetoothMonitor.java b/src/com/android/car/messenger/bluetooth/BluetoothMonitor.java
new file mode 100644
index 0000000..7640c1c
--- /dev/null
+++ b/src/com/android/car/messenger/bluetooth/BluetoothMonitor.java
@@ -0,0 +1,327 @@
+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 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(profile);
+            }
+        };
+        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.
+         *
+         * @param profile see {@link BluetoothProfile.ServiceListener#onServiceDisconnected(int)}
+         */
+        void onMapDisconnected(int profile);
+
+        /**
+         * 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) {
+        mListeners.forEach(listener -> listener.onMapConnected(client));
+    }
+
+    private void onMapDisconnected(int profile) {
+        mListeners.forEach(listener -> listener.onMapDisconnected(profile));
+    }
+
+    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 cleanup() {
+        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/log/L.java b/src/com/android/car/messenger/log/L.java
new file mode 100644
index 0000000..3d42c28
--- /dev/null
+++ b/src/com/android/car/messenger/log/L.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.car.messenger.log;
+
+import android.os.Build;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Util class for logging.
+ */
+public class L {
+
+    /**
+     * Logs verbose level logs if loggable.
+     *
+     * @param tag logging tag
+     * @param msg the message to log, as a format string
+     * @param args arguments referenced by the format string
+     */
+    public static void v(String tag, @NonNull String msg, Object... args) {
+        if (Log.isLoggable(tag, Log.VERBOSE) || Build.IS_DEBUGGABLE) {
+            Log.v(tag, String.format(msg, args));
+        }
+    }
+
+    /**
+     * Logs debug level logs if loggable.
+     *
+     * @param tag logging tag
+     * @param msg the message to log, as a format string
+     * @param args arguments referenced by the format string
+     */
+    public static void d(String tag, @NonNull String msg, Object... args) {
+        if (Log.isLoggable(tag, Log.DEBUG) || Build.IS_DEBUGGABLE) {
+            Log.d(tag, String.format(msg, args));
+        }
+    }
+
+    /**
+     * Logs info level logs if loggable.
+     *
+     * @param tag logging tag
+     * @param msg the message to log, as a format string
+     * @param args arguments referenced by the format string
+     */
+    public static void i(String tag, @NonNull String msg, Object... args) {
+        if (Log.isLoggable(tag, Log.INFO) || Build.IS_DEBUGGABLE) {
+            Log.i(tag, String.format(msg, args));
+        }
+    }
+
+    /**
+     * Logs warning level logs if loggable.
+     *
+     * @param tag logging tag
+     * @param msg the message to log, as a format string
+     * @param args arguments referenced by the format string
+     */
+    public static void w(String tag, @NonNull String msg, Object... args) {
+        if (Log.isLoggable(tag, Log.WARN) || Build.IS_DEBUGGABLE) {
+            Log.w(tag, String.format(msg, args));
+        }
+    }
+
+    /**
+     * Logs error level logs.
+     *
+     * @param tag logging tag
+     * @param msg the message to log, as a format string
+     * @param args arguments referenced by the format string
+     */
+    public static void e(String tag, @NonNull String msg, Object... args) {
+        Log.e(tag, String.format(msg, args));
+    }
+
+    /**
+     * Logs warning level logs.
+     *
+     * @param tag logging tag
+     * @param e an exception to log
+     * @param msg the message to log, as a format string
+     * @param args arguments referenced by the format string
+     */
+    public static void e(String tag, Exception e, @NonNull String msg, Object... args) {
+        Log.e(tag, String.format(msg, args), e);
+    }
+
+    /**
+     * Logs conditions that should never happen.
+     *
+     * @param tag logging tag
+     * @param msg the message to log, as a format string
+     * @param args arguments referenced by the format string
+     */
+    public static void wtf(String tag, @NonNull String msg, Object... args) {
+        Log.wtf(tag, String.format(msg, args));
+    }
+
+    /**
+     * Logs conditions that should never happen.
+     *
+     * @param tag logging tag
+     * @param e an exception to log
+     * @param msg the message to log, as a format string
+     * @param args arguments referenced by the format string
+     */
+    public static void wtf(String tag, Exception e, @NonNull String msg, Object... args) {
+        Log.wtf(tag, String.format(msg, args), e);
+    }
+}
diff --git a/src/com/android/car/messenger/tts/AndroidTTSEngine.java b/src/com/android/car/messenger/tts/AndroidTTSEngine.java
deleted file mode 100644
index 70d0aff..0000000
--- a/src/com/android/car/messenger/tts/AndroidTTSEngine.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.android.car.messenger.tts;
-
-import android.content.Context;
-import android.os.Bundle;
-import android.speech.tts.TextToSpeech;
-import android.speech.tts.UtteranceProgressListener;
-
-/**
- * Implementation of {@link TTSEngine} that delegates to Android's {@link TextToSpeech} API.
- * <p>
- * NOTE: {@link #initialize(Context, TextToSpeech.OnInitListener)} must be called to use this
- * engine. After {@link #shutdown()}, {@link #initialize(Context, TextToSpeech.OnInitListener)} may
- * be called again to use it again.
- */
-class AndroidTTSEngine implements TTSEngine {
-    private TextToSpeech mTextToSpeech;
-
-    @Override
-    public void initialize(Context context, TextToSpeech.OnInitListener initListener) {
-        if (mTextToSpeech == null) {
-            mTextToSpeech = new TextToSpeech(context, initListener);
-        }
-    }
-
-    @Override
-    public boolean isInitialized() {
-        return mTextToSpeech != null;
-    }
-
-    @Override
-    public void setOnUtteranceProgressListener(UtteranceProgressListener progressListener) {
-        mTextToSpeech.setOnUtteranceProgressListener(progressListener);
-    }
-
-    @Override
-    public int speak(CharSequence text, int queueMode, Bundle params, String utteranceId) {
-        return mTextToSpeech.speak(text, queueMode, params, utteranceId);
-    }
-
-    @Override
-    public void stop() {
-        if (mTextToSpeech != null) {
-            mTextToSpeech.stop();
-        }
-    }
-
-    @Override
-    public boolean isSpeaking() {
-        return mTextToSpeech != null ? mTextToSpeech.isSpeaking() : false;
-    }
-
-    @Override
-    public void shutdown() {
-        mTextToSpeech.shutdown();
-        mTextToSpeech = null;
-    }
-
-    @Override
-    public int getStream() {
-        return TextToSpeech.Engine.DEFAULT_STREAM;
-    }
-}
diff --git a/src/com/android/car/messenger/tts/FakeTTSEngine.java b/src/com/android/car/messenger/tts/FakeTTSEngine.java
deleted file mode 100644
index 6af64e4..0000000
--- a/src/com/android/car/messenger/tts/FakeTTSEngine.java
+++ /dev/null
@@ -1,97 +0,0 @@
-package com.android.car.messenger.tts;
-
-import android.content.Context;
-import android.os.Bundle;
-import android.speech.tts.TextToSpeech;
-import android.speech.tts.UtteranceProgressListener;
-
-import java.util.LinkedList;
-
-/**
- * Fake implementation of {@link TTSEngine} for unit-testing.
- */
-class FakeTTSEngine implements TTSEngine {
-    TextToSpeech.OnInitListener mOnInitListener;
-    UtteranceProgressListener mProgressListener;
-    LinkedList<Request> mRequests = new LinkedList<>();
-
-    @Override
-    public void initialize(Context context, TextToSpeech.OnInitListener initListener) {
-        mOnInitListener = initListener;
-    }
-
-    @Override
-    public boolean isInitialized() {
-        return mOnInitListener != null;
-    }
-
-    @Override
-    public void setOnUtteranceProgressListener(UtteranceProgressListener progressListener) {
-        mProgressListener = progressListener;
-    }
-
-    @Override
-    public int speak(CharSequence text, int queueMode, Bundle params, String utteranceId) {
-        mRequests.add(new Request(text, queueMode, params, utteranceId));
-        return TextToSpeech.SUCCESS;
-    }
-
-    @Override
-    public void stop() {
-        mRequests.clear();
-    }
-
-    @Override
-    public boolean isSpeaking() {
-        // NOTE: currently not used in tests.
-        return false;
-    }
-
-    @Override
-    public void shutdown() {
-        stop();
-        mOnInitListener = null;
-    }
-
-    @Override
-    public int getStream() {
-        return TextToSpeech.Engine.DEFAULT_STREAM;
-    }
-
-    void startRequest(String utteranceId) {
-        mProgressListener.onStart(utteranceId);
-    }
-
-    void finishRequest(String utteranceId) {
-        removeRequest(utteranceId);
-        mProgressListener.onDone(utteranceId);
-    }
-
-    void interruptRequest(String utteranceId, boolean interrupted) {
-        removeRequest(utteranceId);
-        mProgressListener.onStop(utteranceId, interrupted);
-    }
-
-    void failRequest(String utteranceId, int errorCode) {
-        removeRequest(utteranceId);
-        mProgressListener.onError(utteranceId, errorCode);
-    }
-
-    private void removeRequest(String utteranceId) {
-        mRequests.removeIf((request) -> request.mUtteranceId.equals(utteranceId));
-    }
-
-    static class Request {
-        CharSequence mText;
-        int mQueueMode;
-        Bundle mParams;
-        String mUtteranceId;
-
-        public Request(CharSequence text, int queueMode, Bundle params, String utteranceId) {
-            mText = text;
-            mQueueMode = queueMode;
-            mParams = params;
-            mUtteranceId = utteranceId;
-        }
-    }
-}
diff --git a/src/com/android/car/messenger/tts/TTSEngine.java b/src/com/android/car/messenger/tts/TTSEngine.java
deleted file mode 100644
index e789327..0000000
--- a/src/com/android/car/messenger/tts/TTSEngine.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package com.android.car.messenger.tts;
-
-import android.content.Context;
-import android.os.Bundle;
-import android.speech.tts.TextToSpeech;
-import android.speech.tts.UtteranceProgressListener;
-
-/**
- * Interface for TTS Engine that closely matches {@link TextToSpeech}; facilitates mocking/faking.
- */
-public interface TTSEngine {
-    /**
-     * Initializes engine.
-     *
-     * @param context Context to use.
-     * @param initListener Listener to monitor initialization result.
-     */
-    void initialize(Context context, TextToSpeech.OnInitListener initListener);
-
-    /**
-     * @return Whether engine is already initialized.
-     */
-    boolean isInitialized();
-
-    /**
-     * @see TextToSpeech#setOnUtteranceProgressListener(UtteranceProgressListener)
-     */
-    void setOnUtteranceProgressListener(UtteranceProgressListener progressListener);
-
-    /**
-     * @see TextToSpeech#speak(CharSequence, int, Bundle, String)
-     */
-    int speak(CharSequence text, int queueMode, Bundle params, String utteranceId);
-
-    /**
-     * @see TextToSpeech#stop()
-     */
-    void stop();
-
-    /**
-     * @return Whether TTS is playing out currently.
-     */
-    boolean isSpeaking();
-
-    /**
-     * Un-initialize engine and release resources.
-     * {@link #initialize(Context, TextToSpeech.OnInitListener)} will need to be called again before
-     * using this engine.
-     */
-    void shutdown();
-
-    /**
-     * Returns the stream used by this TTS engine.
-     */
-    int getStream();
-}
diff --git a/src/com/android/car/messenger/tts/TTSHelper.java b/src/com/android/car/messenger/tts/TTSHelper.java
deleted file mode 100644
index aef20d7..0000000
--- a/src/com/android/car/messenger/tts/TTSHelper.java
+++ /dev/null
@@ -1,370 +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.
- */
-
-package com.android.car.messenger.tts;
-
-import android.content.Context;
-import android.media.AudioManager;
-import android.os.Handler;
-import android.speech.tts.TextToSpeech;
-import android.speech.tts.UtteranceProgressListener;
-import android.util.Log;
-import android.util.Pair;
-
-import androidx.annotation.VisibleForTesting;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.function.BiConsumer;
-
-/**
- * Component that wraps platform TTS engine and supports play-out of batches of text.
- * <p>
- * It takes care of setting up TTS Engine when text is played out and shutting it down after an idle
- * period with no play-out. This is desirable since owning app is long-lived and TTS Engine brings
- * up another service-process.
- * <p>
- * As batch of text is played-out, it issues callbacks on {@link Listener} provided with the batch.
- */
-public class TTSHelper {
-    /**
-     * Listener interface used by clients to be notified as batch of text is played out.
-     */
-    public interface Listener {
-        /**
-         * Called when play-out starts for batch. May never get called if batch has errors or
-         * interruptions.
-         */
-         void onTTSStarted();
-
-        /**
-         * Called when play-out ends for batch.
-         *
-         * @param error Whether play-out ended due to an error or not. Not if it was aborted, its
-         *              not considered an error.
-         */
-        void onTTSStopped(boolean error);
-
-        /**
-         * Called when request to get audio focus failed. This happens before the requested TTS
-         * is played.
-         */
-        void onAudioFocusFailed();
-    }
-
-    private static final String TAG = "Messenger.TTSHelper";
-    private static boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
-
-    private static final char UTTERANCE_ID_SEPARATOR = ';';
-    private static final long DEFAULT_SHUTDOWN_DELAY_MILLIS = 60 * 1000;
-
-    private final Handler mHandler = new Handler();
-    private final Context mContext;
-    private final AudioManager mAudioManager;
-    private final AudioManager.OnAudioFocusChangeListener mNoOpAFChangeListener = (f) -> {};
-    private final long mShutdownDelayMillis;
-    private TTSEngine mTTSEngine;
-    private int mInitStatus;
-    private SpeechRequest mPendingRequest;
-    private final Map<String, BatchListener> mListeners = new HashMap<>();
-    private String currentBatchId;
-
-    /**
-     * Construct with default settings.
-     */
-    public TTSHelper(Context context) {
-        this(context, new AndroidTTSEngine(), DEFAULT_SHUTDOWN_DELAY_MILLIS);
-    }
-
-    @VisibleForTesting
-    TTSHelper(Context context, TTSEngine ttsEngine, long shutdownDelayMillis) {
-        mContext = context;
-        mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
-        mTTSEngine = ttsEngine;
-        mShutdownDelayMillis = shutdownDelayMillis;
-        // OnInitListener will only set to SUCCESS/ERROR. So we initialize to STOPPED.
-        mInitStatus = TextToSpeech.STOPPED;
-    }
-
-    private void initMaybeAndKeepAlive() {
-        if (!mTTSEngine.isInitialized()) {
-            if (DBG) {
-                Log.d(TAG, "Initializing TTS Engine");
-            }
-            mTTSEngine.initialize(mContext, this::handleInitCompleted);
-            mTTSEngine.setOnUtteranceProgressListener(mProgressListener);
-        }
-        // Since we're handling a request, delay engine shutdown.
-        mHandler.removeCallbacks(mMaybeShutdownRunnable);
-        mHandler.postDelayed(mMaybeShutdownRunnable, mShutdownDelayMillis);
-    }
-
-    private void handleInitCompleted(int initStatus) {
-        if (DBG) {
-            Log.d(TAG, "init completed: " + initStatus);
-        }
-        mInitStatus = initStatus;
-        if (mPendingRequest != null) {
-            playInternal(mPendingRequest.mTextToSpeak, mPendingRequest.mListener);
-            mPendingRequest = null;
-        }
-    }
-
-    private final Runnable mMaybeShutdownRunnable = new Runnable() {
-        @Override
-        public void run() {
-            if (mListeners.isEmpty() || mPendingRequest == null) {
-                shutdownEngine();
-            } else {
-                mHandler.postDelayed(this, mShutdownDelayMillis);
-            }
-        }
-    };
-
-    /**
-     * Plays out given batch of text. If engine is not active, it is setup and the request is stored
-     * until then. Only one batch is supported at a time; If a previous batch is waiting engine
-     * setup, that batch is dropped. If a previous batch is playing, the play-out is stopped and
-     * next one is passed to the TTS Engine. Callbacks are issued on the provided {@code listener}.
-     * Will request audio focus first, failure will trigger onAudioFocusFailed in listener.
-     *
-     * NOTE: Underlying engine may have limit on length of text in each element of the batch; it
-     * will reject anything longer. See {@link TextToSpeech#getMaxSpeechInputLength()}.
-     *
-     * @param textToSpeak Batch of text to play-out.
-     * @param listener Observer that will receive callbacks about play-out progress.
-     */
-    public void requestPlay(List<CharSequence> textToSpeak, Listener listener) {
-        if (textToSpeak == null || textToSpeak.isEmpty()) {
-            throw new IllegalArgumentException("Empty/null textToSpeak");
-        }
-        int result = mAudioManager.requestAudioFocus(mNoOpAFChangeListener,
-                getStream(),
-                AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
-        if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
-            listener.onAudioFocusFailed();
-            return;
-        }
-        initMaybeAndKeepAlive();
-
-        // Check if its still initializing.
-        if (mInitStatus == TextToSpeech.STOPPED) {
-            // Squash any already queued request.
-            if (mPendingRequest != null) {
-                onTtsStopped(mPendingRequest.mListener, false /* error */);
-            }
-            mPendingRequest = new SpeechRequest(textToSpeak, listener);
-        } else {
-            playInternal(textToSpeak, listener);
-        }
-    }
-
-    public void requestStop() {
-        mTTSEngine.stop();
-        currentBatchId = null;
-    }
-
-    public boolean isSpeaking() {
-        return mTTSEngine.isSpeaking();
-    }
-
-    // wrap call back to listener.onTTSStopped with adandonAudioFocus.
-    private void onTtsStopped(Listener listener, boolean error) {
-        mAudioManager.abandonAudioFocus(mNoOpAFChangeListener);
-        mHandler.post(() -> listener.onTTSStopped(error));
-    }
-
-    private void playInternal(List<CharSequence> textToSpeak, Listener listener) {
-        if (mInitStatus == TextToSpeech.ERROR) {
-            Log.e(TAG, "TTS setup failed!");
-            onTtsStopped(listener, true /* error */);
-            return;
-        }
-
-        // Abort anything currently playing and flushes queue.
-        mTTSEngine.stop();
-
-        // Queue up new batch. We assign id's = "batchId:index" where index decrements from
-        // batchSize - 1 down to 0. If queueing fails, we abort the whole batch.
-        currentBatchId = Integer.toString(listener.hashCode());
-        int index = textToSpeak.size() - 1;
-        for (CharSequence text : textToSpeak) {
-            String utteranceId =
-                    String.format("%s%c%d", currentBatchId, UTTERANCE_ID_SEPARATOR, index);
-            if (DBG) {
-                Log.d(TAG, String.format("Queueing tts: '%s' [%s]", text, utteranceId));
-            }
-            if (mTTSEngine.speak(text, TextToSpeech.QUEUE_ADD, null, utteranceId)
-                    != TextToSpeech.SUCCESS) {
-                mTTSEngine.stop();
-                currentBatchId = null;
-                Log.e(TAG, "Queuing text failed!");
-                onTtsStopped(listener, true /* error */);
-                return;
-            }
-            index--;
-        }
-        // Register BatchListener for entire batch. Will invoke callbacks on Listener as batch
-        // progresses.
-        mListeners.put(currentBatchId, new BatchListener(listener));
-    }
-
-    /**
-     * Releases resources and shuts down TTS Engine.
-     */
-    public void cleanup() {
-        mHandler.removeCallbacksAndMessages(null /* token */);
-        shutdownEngine();
-    }
-
-    public int getStream() {
-        return mTTSEngine.getStream();
-    }
-
-    private void shutdownEngine() {
-        if (mTTSEngine.isInitialized()) {
-            if (DBG) {
-                Log.d(TAG, "Shutting down TTS Engine");
-            }
-            mTTSEngine.stop();
-            mTTSEngine.shutdown();
-            mInitStatus = TextToSpeech.STOPPED;
-        }
-    }
-
-    private static Pair<String, Integer> parse(String utteranceId) {
-        int separatorIndex = utteranceId.indexOf(UTTERANCE_ID_SEPARATOR);
-        String batchId = utteranceId.substring(0, separatorIndex);
-        int index = Integer.parseInt(utteranceId.substring(separatorIndex + 1));
-        return Pair.create(batchId, index);
-    }
-
-    // Handles all callbacks from TTSEngine. Possible order of callbacks:
-    // - onStart, onDone: successful play-out.
-    // - onStart, onStop: play-out starts, but interrupted.
-    // - onStart, onError: play-out starts and fails.
-    // - onStop: play-out never starts, but aborted.
-    // - onError: play-out never starts, but fails.
-    // Since the callbacks arrive on other threads, they are dispatched onto mHandler where the
-    // appropriate BatchListener is invoked.
-    private final UtteranceProgressListener mProgressListener = new UtteranceProgressListener() {
-        private void safeInvokeAsync(String utteranceId,
-                BiConsumer<BatchListener, Pair<String, Integer>> callback) {
-            mHandler.post(() -> {
-                Pair<String, Integer> parsedId = parse(utteranceId);
-                BatchListener listener = mListeners.get(parsedId.first);
-                if (listener != null) {
-                    callback.accept(listener, parsedId);
-                } else {
-                    if (DBG) {
-                        Log.d(TAG, "Missing batch listener: " + utteranceId);
-                    }
-                }
-            });
-        }
-
-        @Override
-        public void onStart(String utteranceId) {
-            if (DBG) {
-                Log.d(TAG, "TTS onStart: " + utteranceId);
-            }
-            safeInvokeAsync(utteranceId, BatchListener::onStart);
-        }
-
-        @Override
-        public void onDone(String utteranceId) {
-            if (DBG) {
-                Log.d(TAG, "TTS onDone: " + utteranceId);
-            }
-            safeInvokeAsync(utteranceId, BatchListener::onDone);
-        }
-
-        @Override
-        public void onStop(String utteranceId, boolean interrupted) {
-            if (DBG) {
-                Log.d(TAG, "TTS onStop: " + utteranceId);
-            }
-            safeInvokeAsync(utteranceId, BatchListener::onStop);
-        }
-
-        @Override
-        public void onError(String utteranceId) {
-            if (DBG) {
-                Log.d(TAG, "TTS onError: " + utteranceId);
-            }
-            safeInvokeAsync(utteranceId, BatchListener::onError);
-        }
-    };
-
-    /**
-     * Handles callbacks for a single batch of TTS text and issues callbacks on wrapped
-     * {@link Listener} that client is listening on.
-     */
-    private class BatchListener {
-        private final Listener mListener;
-        private boolean mBatchStarted = false;
-
-        BatchListener(Listener listener) {
-            mListener = listener;
-        }
-
-        // Issues Listener.onTTSStarted when first item of batch starts.
-        void onStart(Pair<String, Integer> parsedId) {
-            if (!mBatchStarted) {
-                mBatchStarted = true;
-                mListener.onTTSStarted();
-            }
-        }
-
-        // Issues Listener.onTTSStopped when last item of batch finishes.
-        void onDone(Pair<String, Integer> parsedId) {
-            if (parsedId.second == 0) {
-                handleBatchFinished(parsedId, false /* error */);
-            }
-        }
-
-        // If any item of batch fails, abort the batch and issue Listener.onTTSStopped.
-        void onError(Pair<String, Integer> parsedId) {
-            if (parsedId.first.equals(currentBatchId)) {
-                mTTSEngine.stop();
-            }
-            handleBatchFinished(parsedId, true /* error */);
-        }
-
-        // If any item of batch is preempted (rest should also be), issue Listener.onTTSStopped.
-        void onStop(Pair<String, Integer> parsedId) {
-            handleBatchFinished(parsedId, false /* error */);
-        }
-
-        // Handles terminal callbacks for the batch. We invoke stopped and remove ourselves.
-        // No further callbacks will be handled for the batch.
-        private void handleBatchFinished(Pair<String, Integer> parsedId, boolean error) {
-            onTtsStopped(mListener, error);
-            mListeners.remove(parsedId.first);
-        }
-    }
-
-    private static class SpeechRequest {
-        final List<CharSequence> mTextToSpeak;
-        final Listener mListener;
-
-        SpeechRequest(List<CharSequence> textToSpeak, Listener listener) {
-            mTextToSpeak = textToSpeak;
-            mListener = listener;
-        }
-    }
-}
diff --git a/tests/robotests/Android.mk b/tests/robotests/Android.mk
index 89b6484..5f0b289 100644
--- a/tests/robotests/Android.mk
+++ b/tests/robotests/Android.mk
@@ -1,41 +1,47 @@
-#############################################
-# Messenger Robolectric test target. #
-#############################################
-LOCAL_PATH:= $(call my-dir)
+#############################################################
+# Car Messenger Robolectric test target.                    #
+#############################################################
+LOCAL_PATH := $(call my-dir)
 include $(CLEAR_VARS)
 
+LOCAL_MODULE := CarMessengerRoboTests
+
 LOCAL_SRC_FILES := $(call all-java-files-under, src)
 
-# Include the testing libraries (JUnit4 + Robolectric libs).
-LOCAL_STATIC_JAVA_LIBRARIES := \
-    truth-prebuilt \
-    mockito-robolectric-prebuilt
+LOCAL_RESOURCE_DIR := \
+    $(LOCAL_PATH)/res
 
+LOCAL_JAVA_RESOURCE_DIRS := config
+
+# Include the testing libraries
 LOCAL_JAVA_LIBRARIES := \
-    junit \
-    platform-robolectric-3.6.2-prebuilt
+    robolectric_android-all-stub \
+    Robolectric_all-target \
+    mockito-robolectric-prebuilt \
+    truth-prebuilt
 
 LOCAL_INSTRUMENTATION_FOR := CarMessengerApp
-LOCAL_MODULE := CarMessengerRoboTests
 
 LOCAL_MODULE_TAGS := optional
 
 include $(BUILD_STATIC_JAVA_LIBRARY)
 
 #############################################################
-# Messenger runner target to run the previous target. #
+# Car Messenger runner target to run the previous target.   #
 #############################################################
 include $(CLEAR_VARS)
 
 LOCAL_MODULE := RunCarMessengerRoboTests
 
-LOCAL_SDK_VERSION := current
-
-LOCAL_STATIC_JAVA_LIBRARIES := \
-    CarMessengerRoboTests
+LOCAL_JAVA_LIBRARIES := \
+    CarMessengerRoboTests \
+    robolectric_android-all-stub \
+    Robolectric_all-target \
+    mockito-robolectric-prebuilt \
+    truth-prebuilt
 
 LOCAL_TEST_PACKAGE := CarMessengerApp
 
 LOCAL_INSTRUMENT_SOURCE_DIRS := $(dir $(LOCAL_PATH))../src
 
-include prebuilts/misc/common/robolectric/3.6.2/run_robotests.mk
+include external/robolectric-shadows/run_robotests.mk
\ No newline at end of file
diff --git a/tests/robotests/config/robolectric.properties b/tests/robotests/config/robolectric.properties
new file mode 100644
index 0000000..0b179f9
--- /dev/null
+++ b/tests/robotests/config/robolectric.properties
@@ -0,0 +1,2 @@
+manifest=packages/apps/Car/Messenger/AndroidManifest.xml
+sdk=NEWEST_SDK
diff --git a/tests/robotests/src/com/android/car/messenger/MessengerDelegateTest.java b/tests/robotests/src/com/android/car/messenger/MessengerDelegateTest.java
new file mode 100644
index 0000000..6e730f0
--- /dev/null
+++ b/tests/robotests/src/com/android/car/messenger/MessengerDelegateTest.java
@@ -0,0 +1,248 @@
+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 android.os.Build.VERSION_CODES;
+
+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}, sdk = {
+        VERSION_CODES.O})
+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 MessengerDelegate mMessengerDelegate;
+    private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+    private Intent mMessageOneIntent;
+    private MapMessage mMessageOne;
+    private MessengerDelegate.MessageKey mMessageOneKey;
+    private MessengerDelegate.SenderKey mSenderKey;
+
+    @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 MessengerDelegate(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);
+
+        assertThat(deviceConnectionTimestamp).isEqualTo(timestamp);
+    }
+
+    @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.mMessages).isEmpty();
+        assertThat(mMessengerDelegate.mNotificationInfos).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.mMessages).hasSize(1);
+        assertThat(mMessengerDelegate.mNotificationInfos).hasSize(1);
+    }
+
+    @Test
+    public void testOnDeviceDisconnected_connectedDevice_retainsMessagesFromConnectedDevice() {
+        mMessengerDelegate.onDeviceConnected(mMockBluetoothDeviceTwo);
+
+        mMessengerDelegate.onMessageReceived(mMessageOneIntent);
+        mMessengerDelegate.onDeviceDisconnected(mMockBluetoothDeviceTwo);
+
+        assertThat(mMessengerDelegate.mMessages).containsKey(mMessageOneKey);
+        assertThat(mMessengerDelegate.mNotificationInfos).hasSize(1);
+    }
+
+    @Test
+    public void testConnectedDevices_areNotAddedFromBTAdapterBondedDevices() {
+        mShadowBluetoothAdapter.setBondedDevices(
+                new HashSet<>(Arrays.asList(mMockBluetoothDeviceTwo)));
+        mMessengerDelegate = new MessengerDelegate(mContext);
+
+        assertThat(mMessengerDelegate.mBTDeviceAddressToConnectionTimestamp).isEmpty();
+    }
+
+    @Test
+    public void testOnMessageReceived_newMessage() {
+        mMessengerDelegate.onMessageReceived(mMessageOneIntent);
+
+        assertThat(mapMessageEquals(mMessageOne,
+                mMessengerDelegate.mMessages.get(mMessageOneKey))).isTrue();
+        assertThat(mMessengerDelegate.mNotificationInfos.containsKey(mSenderKey)).isTrue();
+    }
+
+    @Test
+    public void testOnMessageReceived_duplicateMessage() {
+        mMessengerDelegate.onMessageReceived(mMessageOneIntent);
+        mMessengerDelegate.onMessageReceived(mMessageOneIntent);
+        MessengerDelegate.NotificationInfo info = mMessengerDelegate.mNotificationInfos.get(
+                mSenderKey);
+        assertThat(info.mMessageKeys).hasSize(1);
+    }
+
+    @Test
+    public void testClearNotification_keepsNotificationData() {
+        mMessengerDelegate.onMessageReceived(mMessageOneIntent);
+        mMessengerDelegate.clearNotifications(key -> key.equals(mSenderKey));
+        MessengerDelegate.NotificationInfo info = mMessengerDelegate.mNotificationInfos.get(
+                mSenderKey);
+        assertThat(info.mMessageKeys).hasSize(1);
+
+        assertThat(mMessengerDelegate.mMessages.containsKey(mMessageOneKey)).isTrue();
+    }
+
+    @Test
+    public void testHandleMarkAsRead() {
+        mMessengerDelegate.onMessageReceived(mMessageOneIntent);
+
+        mMessengerDelegate.markAsRead(mSenderKey);
+
+        MessengerDelegate.NotificationInfo info = mMessengerDelegate.mNotificationInfos.get(
+                mSenderKey);
+        MessengerDelegate.MessageKey key = info.mMessageKeys.get(0);
+        assertThat(mMessengerDelegate.mMessages.get(key).isRead()).isTrue();
+    }
+
+    @Test
+    public void testNotificationsNotShownForExistingMessages() {
+        Intent existingMessageIntent = createMessageIntent(mMockBluetoothDeviceTwo, "mockHandle",
+                "510-111-2222", "testSender",
+                "Hello", /* timestamp= */ System.currentTimeMillis() - 10000L);
+        mMessengerDelegate.onDeviceConnected(mMockBluetoothDeviceTwo);
+
+        mMessengerDelegate.onMessageReceived(existingMessageIntent);
+
+        assertThat(mMessengerDelegate.mNotificationInfos).isEmpty();
+
+    }
+
+    private Intent createMessageIntent(BluetoothDevice device, String handle, String senderUri,
+            String senderName, String messageText, Long timestamp) {
+        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(android.content.Intent.EXTRA_TEXT, messageText);
+        if (timestamp != null) {
+            intent.putExtra(BluetoothMapClient.EXTRA_MESSAGE_TIMESTAMP, timestamp);
+        }
+        return intent;
+    }
+
+    /**
+     * Checks to see if all properties, besides the timestamp, of two {@link MapMessage}s are equal.
+     **/
+    private boolean mapMessageEquals(MapMessage expected, MapMessage observed) {
+        return expected.getDeviceAddress().equals(observed.getDeviceAddress())
+                && expected.getHandle().equals(observed.getHandle())
+                && expected.getSenderName().equals(observed.getSenderName())
+                && expected.getSenderContactUri().equals(observed.getSenderContactUri())
+                && expected.getMessageText().equals(observed.getMessageText());
+    }
+
+    private void createMockMessages() {
+        mMessageOneIntent= createMessageIntent(mMockBluetoothDeviceOne, "mockHandle",
+                "510-111-2222", "testSender",
+                "Hello", /* timestamp= */ null);
+        mMessageOne = MapMessage.parseFrom(mMessageOneIntent);
+        mMessageOneKey = new MessengerDelegate.MessageKey(mMessageOne);
+        mSenderKey = new MessengerDelegate.SenderKey(mMessageOne);
+    }
+}
diff --git a/tests/robotests/src/com/android/car/messenger/TestConfig.java b/tests/robotests/src/com/android/car/messenger/TestConfig.java
deleted file mode 100644
index 016df1a..0000000
--- a/tests/robotests/src/com/android/car/messenger/TestConfig.java
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.car.messenger;
-
-public class TestConfig {
-    public static final int SDK_VERSION = 23;
-    public static final String MANIFEST_PATH =
-            "packages/apps/Car/Messenger/AndroidManifest.xml";
-}
diff --git a/tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothMonitorTest.java b/tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothMonitorTest.java
new file mode 100644
index 0000000..6b9e1af
--- /dev/null
+++ b/tests/robotests/src/com/android/car/messenger/bluetooth/BluetoothMonitorTest.java
@@ -0,0 +1,56 @@
+package com.android.car.messenger.bluetooth;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.verify;
+
+import android.bluetooth.BluetoothMapClient;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(RobolectricTestRunner.class)
+public class BluetoothMonitorTest {
+
+    @Mock
+    BluetoothMapClient mockMapClient;
+    @Mock
+    BluetoothMonitor.OnBluetoothEventListener mockBluetoothEventListener;
+
+    private Context mContext;
+    private BluetoothMonitor mBluetoothMonitor;
+    private BluetoothProfile.ServiceListener mServiceListener;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mContext = RuntimeEnvironment.application;
+        mBluetoothMonitor = new BluetoothMonitor(mContext);
+        mServiceListener = mBluetoothMonitor.getServiceListener();
+        mBluetoothMonitor.registerListener(mockBluetoothEventListener);
+    }
+
+    @Test
+    public void testServiceListener() {
+        mServiceListener.onServiceConnected(BluetoothProfile.MAP_CLIENT, mockMapClient);
+        verify(mockBluetoothEventListener).onMapConnected(mockMapClient);
+
+        mServiceListener.onServiceDisconnected(BluetoothProfile.MAP_CLIENT);
+        verify(mockBluetoothEventListener).onMapDisconnected(BluetoothProfile.MAP_CLIENT);
+    }
+
+    @Test
+    public void testRegisterListener() {
+        assertThat(mBluetoothMonitor.registerListener(mockBluetoothEventListener)).isFalse();
+        assertThat(mBluetoothMonitor.unregisterListener(mockBluetoothEventListener)).isTrue();
+        assertThat(mBluetoothMonitor.registerListener(mockBluetoothEventListener)).isTrue();
+        mBluetoothMonitor.cleanup();
+        assertThat(mBluetoothMonitor.registerListener(mockBluetoothEventListener)).isTrue();
+    }
+}
diff --git a/tests/robotests/src/com/android/car/messenger/tts/TTSHelperTest.java b/tests/robotests/src/com/android/car/messenger/tts/TTSHelperTest.java
deleted file mode 100644
index dbbb5e4..0000000
--- a/tests/robotests/src/com/android/car/messenger/tts/TTSHelperTest.java
+++ /dev/null
@@ -1,164 +0,0 @@
-package com.android.car.messenger.tts;
-
-import android.speech.tts.TextToSpeech;
-
-import com.android.car.messenger.TestConfig;
-
-import org.junit.Assert;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.robolectric.Robolectric;
-import org.robolectric.RobolectricTestRunner;
-import org.robolectric.RuntimeEnvironment;
-import org.robolectric.annotation.Config;
-import org.robolectric.util.Scheduler;
-
-import java.util.Arrays;
-import java.util.Collections;
-
-@RunWith(RobolectricTestRunner.class)
-@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION)
-public class TTSHelperTest {
-    private static final long SHUTDOWN_DELAY_MILLIS = 10;
-
-    private FakeTTSEngine mFakeTTS;
-    private TTSHelper mTTSHelper;
-
-    @Before
-    public void setup() {
-        mFakeTTS = new FakeTTSEngine();
-        mTTSHelper = new TTSHelper(RuntimeEnvironment.application, mFakeTTS, SHUTDOWN_DELAY_MILLIS);
-    }
-
-    @Test
-    public void testSpeakBeforeInit() {
-        // Should get queued since engine not initialized.
-        final RecordingTTSListener recorder1 = new RecordingTTSListener();
-        mTTSHelper.requestPlay(Collections.singletonList("hello"), recorder1);
-        // Will squash previous request.
-        final RecordingTTSListener recorder2 = new RecordingTTSListener();
-        mTTSHelper.requestPlay(Collections.singletonList("world"), recorder2);
-        Assert.assertFalse(recorder1.wasStarted());
-        Assert.assertTrue(recorder1.wasStoppedWithSuccess());
-
-        // Finish initialization.
-        mFakeTTS.mOnInitListener.onInit(TextToSpeech.SUCCESS);
-
-        // The queued request should have been passed for playout.
-        FakeTTSEngine.Request lastRequest = mFakeTTS.mRequests.getLast();
-        Assert.assertEquals("world", lastRequest.mText);
-        Assert.assertEquals(TextToSpeech.QUEUE_ADD, lastRequest.mQueueMode);
-
-        mFakeTTS.startRequest(lastRequest.mUtteranceId);
-        Assert.assertTrue(recorder2.wasStarted());
-        mFakeTTS.finishRequest(lastRequest.mUtteranceId);
-        Assert.assertTrue(recorder2.wasStoppedWithSuccess());
-    }
-
-    @Test
-    public void testSpeakInterruptedByNext() {
-        // First request made and starts playing.
-        final RecordingTTSListener recorder1 = new RecordingTTSListener();
-        mTTSHelper.requestPlay(Collections.singletonList("hello"), recorder1);
-        // Finish initialization.
-        mFakeTTS.mOnInitListener.onInit(TextToSpeech.SUCCESS);
-        final FakeTTSEngine.Request request1 = mFakeTTS.mRequests.getLast();
-        mFakeTTS.startRequest(request1.mUtteranceId);
-        Assert.assertTrue(recorder1.wasStarted());
-
-        // Second request made. It will flush first one.
-        final RecordingTTSListener recorder2 = new RecordingTTSListener();
-        mTTSHelper.requestPlay(Collections.singletonList("world"), recorder2);
-        final FakeTTSEngine.Request request2 = mFakeTTS.mRequests.getLast();
-        mFakeTTS.interruptRequest(request1.mUtteranceId, true /* interrupted */);
-        Assert.assertTrue(recorder1.wasStoppedWithSuccess());
-        mFakeTTS.startRequest(request2.mUtteranceId);
-        Assert.assertTrue(recorder2.wasStarted());
-        mFakeTTS.finishRequest(request2.mUtteranceId);
-        Assert.assertTrue(recorder2.wasStoppedWithSuccess());
-    }
-
-    @Test
-    public void testKeepAliveAndAutoShutdown() {
-        // Request made and starts playing.
-        final RecordingTTSListener recorder1 = new RecordingTTSListener();
-        mTTSHelper.requestPlay(Collections.singletonList("hello"), recorder1);
-        // Finish initialization.
-        mFakeTTS.mOnInitListener.onInit(TextToSpeech.SUCCESS);
-        final FakeTTSEngine.Request request1 = mFakeTTS.mRequests.getLast();
-        mFakeTTS.startRequest(request1.mUtteranceId);
-        Assert.assertTrue(recorder1.wasStarted());
-        mFakeTTS.finishRequest(request1.mUtteranceId);
-        Assert.assertTrue(recorder1.wasStoppedWithSuccess());
-
-        Scheduler scheduler = Robolectric.getForegroundThreadScheduler();
-        scheduler.advanceBy(SHUTDOWN_DELAY_MILLIS / 2);
-        Assert.assertTrue(mFakeTTS.isInitialized());
-
-        // Queue another request, forces keep-alive.
-        final RecordingTTSListener recorder2 = new RecordingTTSListener();
-        mTTSHelper.requestPlay(Collections.singletonList("world"), recorder2);
-        final FakeTTSEngine.Request request2 = mFakeTTS.mRequests.getLast();
-        mFakeTTS.failRequest(request2.mUtteranceId, -2 /* errorCode */);
-        Assert.assertTrue(recorder2.wasStoppedWithError());
-        Assert.assertTrue(mFakeTTS.isInitialized());
-
-        scheduler.advanceBy(SHUTDOWN_DELAY_MILLIS);
-        Assert.assertFalse(mFakeTTS.isInitialized());
-    }
-
-    @Test
-    public void testBatchPlayout() {
-        // Request made and starts playing.
-        final RecordingTTSListener recorder1 = new RecordingTTSListener();
-        mTTSHelper.requestPlay(Arrays.asList("hello", "world"), recorder1);
-        // Finish initialization.
-        mFakeTTS.mOnInitListener.onInit(TextToSpeech.SUCCESS);
-        // Two requests should be queued from batch.
-        Assert.assertEquals(2, mFakeTTS.mRequests.size());
-        final FakeTTSEngine.Request request1 = mFakeTTS.mRequests.getFirst();
-        final FakeTTSEngine.Request request2 = mFakeTTS.mRequests.getLast();
-        mFakeTTS.startRequest(request1.mUtteranceId);
-        Assert.assertTrue(recorder1.wasStarted());
-        mFakeTTS.finishRequest(request1.mUtteranceId);
-        Assert.assertFalse(recorder1.wasStoppedWithSuccess());
-        mFakeTTS.startRequest(request2.mUtteranceId);
-        Assert.assertTrue(recorder1.wasStarted());
-        mFakeTTS.finishRequest(request2.mUtteranceId);
-        Assert.assertTrue(recorder1.wasStoppedWithSuccess());
-    }
-
-    private static class RecordingTTSListener implements TTSHelper.Listener {
-        private boolean mStarted = false;
-        private boolean mStopped = false;
-        private boolean mError = false;
-
-        boolean wasStarted() {
-            return mStarted;
-        }
-
-        boolean wasStoppedWithSuccess() {
-            return mStopped && !mError;
-        }
-
-        boolean wasStoppedWithError() {
-            return mStopped && mError;
-        }
-
-        @Override
-        public void onTTSStarted() {
-            mStarted = true;
-        }
-
-        @Override
-        public void onTTSStopped(boolean error) {
-            mStopped = true;
-            mError = error;
-        }
-        
-        @Override
-        public void onAudioFocusFailed() {
-        }
-    }
-}