DO NOT MERGE - Merge qt-dev-plus-aosp-without-vendor (5699924) into stage-aosp-master
Bug: 134405016
Change-Id: Iab6149eee6565d0ce429257f9b8683430a88f3c6
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..b2a5cda
--- /dev/null
+++ b/src/com/android/car/messenger/MessengerDelegate.java
@@ -0,0 +1,568 @@
+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) {
+ 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;
+ }
+
+ SmsReceiver.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() {
+ for (String address : mBTDeviceAddressToConnectionTimestamp.keySet()) {
+ mSmsDatabaseHandler.removeMessagesForDevice(address);
+ }
+ 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 68%
rename from src/com/android/car/messenger/MessengerReceiver.java
rename to src/com/android/car/messenger/MmsReceiver.java
index c3af2f8..d9b9fd4 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.
@@ -21,14 +21,15 @@
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..36f4cec
--- /dev/null
+++ b/src/com/android/car/messenger/SmsDatabaseHandler.java
@@ -0,0 +1,155 @@
+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 androidx.core.content.ContextCompat;
+
+import com.android.car.messenger.log.L;
+
+/**
+ * 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 final ContentResolver mContentResolver;
+ private final boolean mCanWriteToDatabase;
+
+ protected SmsDatabaseHandler(Context context) {
+ mCanWriteToDatabase = canWriteToDatabase(context);
+ mContentResolver = context.getContentResolver();
+ SmsReceiver.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);
+ }
+
+ /** 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/SmsReceiver.java b/src/com/android/car/messenger/SmsReceiver.java
new file mode 100644
index 0000000..bbcc60c
--- /dev/null
+++ b/src/com/android/car/messenger/SmsReceiver.java
@@ -0,0 +1,134 @@
+/*
+ * 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.content.BroadcastReceiver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.Telephony;
+import android.telephony.SmsMessage;
+import android.util.Log;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * Receiver that listens for Telephony broadcasts when an SMS is received.
+ */
+public class SmsReceiver extends BroadcastReceiver {
+ private static final String TAG = "CM.SmsReceiver";
+ private static final Uri SMS_URI = Telephony.Sms.CONTENT_URI;
+ private static final SimpleDateFormat DATE_FORMATTER = new SimpleDateFormat(
+ "MMM dd,yyyy HH:mm");
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Intent startIntent = new Intent(context, MessengerService.class)
+ .setAction(MessengerService.ACTION_RECEIVED_SMS);
+ context.startForegroundService(startIntent);
+
+ // If we are the default SMS app, we only care about the
+ // Telephony.Sms.Intents.SMS_DELIVER_ACTION
+ if (isDefaultSmsApp(context) && Telephony.Sms.Intents.SMS_RECEIVED_ACTION.equals(
+ intent.getAction())) {
+ return;
+ }
+
+ if (isDefaultSmsApp(context)) parseSmsMessage(context, intent);
+ readDatabase(context);
+ }
+
+ /**
+ * Parses the {@link SmsMessage} from the intent to be inputted into the Telephony Database.
+ */
+ private void parseSmsMessage(Context context, Intent intent) {
+ SmsMessage[] messages = Telephony.Sms.Intents.getMessagesFromIntent(intent);
+ if (messages == null) {
+ Log.w(TAG, "No sms messages found in the intent");
+ return;
+ }
+
+ for (SmsMessage sms : messages) {
+ insertMessageToTelephonyDb(sms, context, intent);
+ }
+ }
+
+ private void insertMessageToTelephonyDb(SmsMessage sms, Context context, Intent intent) {
+ ContentValues values = new ContentValues();
+ values.put(Telephony.Sms.READ, 0);
+ values.put(Telephony.Sms.ADDRESS, sms.getDisplayOriginatingAddress());
+ values.put(Telephony.Sms.BODY, sms.getMessageBody());
+ values.put(Telephony.Sms.DATE, sms.getTimestampMillis());
+ values.put(Telephony.Sms.SUBSCRIPTION_ID, sms.getSubId());
+ if (sms.getPseudoSubject().length() > 0) {
+ values.put(Telephony.Sms.SUBJECT, sms.getPseudoSubject());
+ }
+ values.put(Telephony.Sms.PROTOCOL, sms.getProtocolIdentifier());
+ values.put(Telephony.Sms.REPLY_PATH_PRESENT, sms.isReplyPathPresent() ? 1 : 0);
+ values.put(Telephony.Sms.SERVICE_CENTER, sms.getServiceCenterAddress());
+ values.put(Telephony.Sms.ERROR_CODE, intent.getIntExtra("errorCode", 0));
+ values.put(Telephony.Sms.STATUS, sms.getStatus());
+
+ Uri uri = context.getApplicationContext().getContentResolver().insert(SMS_URI, values);
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "Finished inserting latest SMS: " + uri);
+ }
+ }
+
+ /**
+ * Reads the Telephony SMS Database, and logs all of the SMS messages that have been received
+ * in the last five minutes.
+ * @param context
+ */
+ public 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 --------");
+ }
+
+ private boolean isDefaultSmsApp(Context context) {
+ return Telephony.Sms.getDefaultSmsPackage(context).equals(context.getPackageName());
+ }
+}
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() {
- }
- }
-}