summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSrinivas Visvanathan <sriniv@google.com>2017-02-10 11:37:41 -0800
committerSrinivas Visvanathan <sriniv@google.com>2017-03-16 16:20:59 -0700
commit0217b09b25c8a2cb0408790979eff327aec42d45 (patch)
treea88a422518c053c7f601f8a241039043e264f74a
parent7b962348aadc774bddc6e3314d18282192d3a4ba (diff)
downloadMessenger-0217b09b25c8a2cb0408790979eff327aec42d45.tar.gz
Initial working SMS handling app
- App comprises MessengerService which starts, connects to BT MAP service and monitors for new messages. It generates notifications for new messages grouped by sender. Notifications are updated as new messages arrive. - Notifications offer actions to play/auto-reply which are handled by MessengerService also. - Items left to handle: * Proper layout of notification to match AAP/AAV. * Better handling of devices switching, MAP disconnects. * Bring up TTS engine only when needed. Bug: 34352716,34352440 Test: With Android and iPhone, receiving/sending/playout. Android works fine. iPhone had MAP issues. Need to consult with BT folks. Change-Id: I97f0ec0c54490d0e783854b150245251a0eadde8
-rw-r--r--Android.mk39
-rw-r--r--AndroidManifest.xml36
-rw-r--r--res/values/strings.xml29
-rw-r--r--src/com/android/car/messenger/MapMessage.java124
-rw-r--r--src/com/android/car/messenger/MapMessageMonitor.java362
-rw-r--r--src/com/android/car/messenger/MessengerReceiver.java34
-rw-r--r--src/com/android/car/messenger/MessengerService.java233
-rw-r--r--src/com/android/car/messenger/TTSHelper.java181
8 files changed, 1038 insertions, 0 deletions
diff --git a/Android.mk b/Android.mk
new file mode 100644
index 0000000..6d173c5
--- /dev/null
+++ b/Android.mk
@@ -0,0 +1,39 @@
+#
+# 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.
+#
+
+
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+LOCAL_MODULE_TAGS := optional
+
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+
+LOCAL_PACKAGE_NAME := CarMessengerApp
+
+LOCAL_OVERRIDES_PACKAGES := messaging
+
+LOCAL_MODULE_TAGS := optional
+
+LOCAL_PROGUARD_ENABLED := disabled
+
+LOCAL_PRIVILEGED_MODULE := true
+
+LOCAL_STATIC_JAVA_LIBRARIES += android-support-v4
+
+LOCAL_DEX_PREOPT := false
+
+include $(BUILD_PACKAGE)
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
new file mode 100644
index 0000000..5d0391a
--- /dev/null
+++ b/AndroidManifest.xml
@@ -0,0 +1,36 @@
+<?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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.car.messenger">
+
+ <uses-sdk android:minSdkVersion="25" android:targetSdkVersion="25"/>
+
+ <uses-permission android:name="android.permission.BLUETOOTH" />
+ <uses-permission android:name="android.permission.SEND_SMS" />
+ <uses-permission android:name="android.permission.READ_SMS"/>
+
+ <application android:label="CarMessenger">
+ <service android:name=".MessengerService" android:exported="false">
+ </service>
+
+ <receiver android:name=".MessengerReceiver" android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.BOOT_COMPLETED"/>
+ </intent-filter>
+ </receiver>
+ </application>
+</manifest>
diff --git a/res/values/strings.xml b/res/values/strings.xml
new file mode 100644
index 0000000..9fab76b
--- /dev/null
+++ b/res/values/strings.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2007 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<resources>
+ <plurals name="notification_new_message">
+ <item quantity="one">New message</item>
+ <item quantity="other">%d new messages</item>
+ </plurals>
+
+ <string name="auto_reply_message">I\'m driving right now</string>
+ <string name="auto_reply_failed_message">Unable to send reply. Please try again.</string>
+
+ <string name="tts_says_verb">says</string>
+
+ <string name="tts_failed_toast">Text playout failed!</string>
+</resources>
diff --git a/src/com/android/car/messenger/MapMessage.java b/src/com/android/car/messenger/MapMessage.java
new file mode 100644
index 0000000..1369976
--- /dev/null
+++ b/src/com/android/car/messenger/MapMessage.java
@@ -0,0 +1,124 @@
+/*
+ * 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.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothMapClient;
+import android.content.Intent;
+import android.support.annotation.Nullable;
+
+/**
+ * Represents a message obtained via MAP service from a connected Bluetooth device.
+ */
+class MapMessage {
+ private BluetoothDevice mDevice;
+ private String mHandle;
+ private long mReceivedTimeMs;
+ private String mText;
+ @Nullable
+ private String mSenderContactUri;
+ @Nullable
+ private String mSenderName;
+
+ /**
+ * Constructs Message 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.
+ */
+ 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(), text,
+ senderContactUri, senderContactName);
+ }
+
+ private MapMessage(BluetoothDevice device,
+ String handle,
+ long receivedTimeMs,
+ String text,
+ @Nullable String senderContactUri,
+ @Nullable String senderName) {
+ boolean missingDevice = (device == null);
+ boolean missingHandle = (handle == null);
+ boolean missingText = (text == null);
+ if (missingDevice || missingHandle || missingText) {
+ StringBuilder builder = new StringBuilder("Missing required fields:");
+ if (missingDevice) {
+ builder.append(" device");
+ }
+ if (missingHandle) {
+ builder.append(" handle");
+ }
+ if (missingText) {
+ builder.append(" text");
+ }
+ throw new IllegalArgumentException(builder.toString());
+ }
+ mDevice = device;
+ mHandle = handle;
+ mReceivedTimeMs = receivedTimeMs;
+ mText = text;
+ mSenderContactUri = senderContactUri;
+ mSenderName = senderName;
+ }
+
+ public BluetoothDevice getDevice() {
+ return mDevice;
+ }
+
+ public String getHandle() {
+ return mHandle;
+ }
+
+ public long getReceivedTimeMs() {
+ return mReceivedTimeMs;
+ }
+
+ public String getText() {
+ return mText;
+ }
+
+ @Nullable
+ public String getSenderContactUri() {
+ return mSenderContactUri;
+ }
+
+ @Nullable
+ public String getSenderName() {
+ return mSenderName;
+ }
+
+ @Override
+ public String toString() {
+ return "MapMessage{" +
+ "mDevice=" + mDevice +
+ ", mHandle='" + mHandle + '\'' +
+ ", mReceivedTimeMs=" + mReceivedTimeMs +
+ ", mText='" + mText + '\'' +
+ ", mSenderContactUri='" + mSenderContactUri + '\'' +
+ ", mSenderName='" + mSenderName + '\'' +
+ '}';
+ }
+}
diff --git a/src/com/android/car/messenger/MapMessageMonitor.java b/src/com/android/car/messenger/MapMessageMonitor.java
new file mode 100644
index 0000000..0de9d1e
--- /dev/null
+++ b/src/com/android/car/messenger/MapMessageMonitor.java
@@ -0,0 +1,362 @@
+/*
+ * 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.NotificationManager;
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothMapClient;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.v4.app.NotificationCompat;
+import android.util.Log;
+import android.widget.Toast;
+
+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;
+
+/**
+ * Component that 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 {
+ private static final String TAG = "Messenger.MsgMonitor";
+ private static final boolean DBG = MessengerService.DBG;
+
+ private final Context mContext;
+ private final BluetoothMapReceiver mBluetoothMapReceiver;
+ private final NotificationManager mNotificationManager;
+ private final Map<MessageKey, MapMessage> mMessages = new HashMap<>();
+ private final Map<SenderKey, NotificationInfo> mNotificationInfos = new HashMap<>();
+ private final TTSHelper mTTSHelper;
+
+ MapMessageMonitor(Context context) {
+ mContext = context;
+ mBluetoothMapReceiver = new BluetoothMapReceiver();
+ mNotificationManager =
+ (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
+ mTTSHelper = new TTSHelper(mContext);
+ }
+
+ private void handleNewMessage(Intent intent) {
+ if (DBG) {
+ Log.d(TAG, "Handling new message");
+ }
+ try {
+ MapMessage message = MapMessage.parseFrom(intent);
+ if (MessengerService.VDBG) {
+ 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);
+ }
+ }
+
+ // TODO(sriniv): Handle unknown senders. b/33280056
+ private void updateNotificationInfo(MapMessage message, MessageKey messageKey) {
+ SenderKey senderKey = new SenderKey(message);
+ NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
+ if (notificationInfo == null) {
+ notificationInfo = new NotificationInfo(message.getSenderName());
+ mNotificationInfos.put(senderKey, notificationInfo);
+ }
+ notificationInfo.mMessageKeys.add(messageKey);
+ updateNotificationFor(senderKey, notificationInfo, false /* ttsPlaying */);
+ }
+
+ private void updateNotificationFor(SenderKey senderKey,
+ NotificationInfo notificationInfo, boolean ttsPlaying) {
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext);
+ // TODO(sriniv): Use right icon when switching to correct layout. b/33280056.
+ builder.setSmallIcon(android.R.drawable.btn_plus);
+ builder.setContentTitle(notificationInfo.mSenderName);
+ builder.setContentText(mContext.getResources().getQuantityString(
+ R.plurals.notification_new_message, notificationInfo.mMessageKeys.size(),
+ notificationInfo.mMessageKeys.size()));
+
+ Intent deleteIntent = new Intent(mContext, MessengerService.class)
+ .setAction(MessengerService.ACTION_CLEAR_NOTIFICATION_STATE)
+ .putExtra(MessengerService.EXTRA_SENDER_KEY, senderKey);
+ builder.setDeleteIntent(
+ PendingIntent.getService(mContext, notificationInfo.mNotificationId, deleteIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT));
+
+ String messageActions[] = {
+ MessengerService.ACTION_AUTO_REPLY,
+ MessengerService.ACTION_PLAY_MESSAGES
+ };
+ // TODO(sriniv): Actual spec does not have any of these strings. Remove later. b/33280056.
+ // is implemented for notifications.
+ String actionTexts[] = { "Reply", "Play" };
+ if (ttsPlaying) {
+ messageActions[1] = MessengerService.ACTION_STOP_PLAYOUT;
+ actionTexts[1] = "Stop";
+ }
+ for (int i = 0; i < messageActions.length; i++) {
+ Intent intent = new Intent(mContext, MessengerService.class)
+ .setAction(messageActions[i])
+ .putExtra(MessengerService.EXTRA_SENDER_KEY, senderKey);
+ PendingIntent pendingIntent = PendingIntent.getService(mContext,
+ notificationInfo.mNotificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+ builder.addAction(android.R.drawable.ic_media_play, actionTexts[i], pendingIntent);
+ }
+ mNotificationManager.notify(notificationInfo.mNotificationId, builder.build());
+ }
+
+ 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;
+ }
+
+ StringBuilder ttsMessage = new StringBuilder();
+ ttsMessage.append(notificationInfo.mSenderName)
+ .append(" ").append(mContext.getString(R.string.tts_says_verb));
+ for (MessageKey messageKey : notificationInfo.mMessageKeys) {
+ MapMessage message = mMessages.get(messageKey);
+ if (message != null) {
+ ttsMessage.append(". ").append(message.getText());
+ }
+ }
+
+ mTTSHelper.requestPlay(ttsMessage.toString(),
+ new TTSHelper.Listener() {
+ @Override
+ public void onTTSStarted() {
+ updateNotificationFor(senderKey, notificationInfo, true);
+ }
+
+ @Override
+ public void onTTSStopped() {
+ updateNotificationFor(senderKey, notificationInfo, false);
+ }
+
+ @Override
+ public void onTTSError() {
+ Toast.makeText(mContext, R.string.tts_failed_toast, Toast.LENGTH_SHORT).show();
+ onTTSStopped();
+ }
+ });
+ }
+
+ void stopPlayout() {
+ mTTSHelper.requestStop();
+ }
+
+ boolean sendAutoReply(SenderKey senderKey, BluetoothMapClient mapClient) {
+ if (DBG) {
+ Log.d(TAG, "Sending auto-reply to: " + senderKey);
+ }
+ BluetoothDevice device =
+ BluetoothAdapter.getDefaultAdapter().getRemoteDevice(senderKey.mDeviceAddress);
+ Uri recipientUris[] = { Uri.parse(senderKey.mSubKey) };
+
+ final int requestCode = senderKey.hashCode();
+ PendingIntent sentIntent =
+ PendingIntent.getBroadcast(mContext, requestCode, new Intent(
+ BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY),
+ PendingIntent.FLAG_ONE_SHOT);
+ String message = mContext.getString(R.string.auto_reply_message);
+ 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();
+ mTTSHelper.cleanup();
+ }
+
+ // 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 new messages");
+ }
+ 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())) {
+ 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);
+ }
+ }
+
+ // CompositeKey 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 senderContactUri as
+ // the secondary key.
+ static class SenderKey extends CompositeKey implements Parcelable {
+ private SenderKey(String deviceAddress, String key) {
+ super(deviceAddress, key);
+ }
+
+ SenderKey(MapMessage message) {
+ this(message.getDevice().getAddress(), 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 displayed.
+ private static class NotificationInfo {
+ private static int NEXT_NOTIFICATION_ID = 0;
+
+ final int mNotificationId = NEXT_NOTIFICATION_ID++;
+ final String mSenderName;
+ final List<MessageKey> mMessageKeys = new LinkedList<>();
+
+ NotificationInfo(String senderName) {
+ mSenderName = senderName;
+ }
+ }
+}
diff --git a/src/com/android/car/messenger/MessengerReceiver.java b/src/com/android/car/messenger/MessengerReceiver.java
new file mode 100644
index 0000000..c3af2f8
--- /dev/null
+++ b/src/com/android/car/messenger/MessengerReceiver.java
@@ -0,0 +1,34 @@
+/*
+ * 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.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+/**
+ * Minimal receiver that starts up MessengerService on boot-completion.
+ */
+public class MessengerReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Intent startIntent =
+ new Intent(MessengerService.ACTION_START).setClass(context, MessengerService.class);
+ context.startService(startIntent);
+ }
+}
diff --git a/src/com/android/car/messenger/MessengerService.java b/src/com/android/car/messenger/MessengerService.java
new file mode 100644
index 0000000..963d17d
--- /dev/null
+++ b/src/com/android/car/messenger/MessengerService.java
@@ -0,0 +1,233 @@
+/*
+ * 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.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.os.IBinder;
+import android.util.Log;
+import android.widget.Toast;
+
+/**
+ * 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.
+ */
+public class MessengerService extends Service {
+ static final String TAG = "MessengerService";
+ static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
+ static final boolean VDBG = Log.isLoggable(TAG, Log.VERBOSE);
+
+ // 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 clear notification state when user dismisses notification.
+ 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";
+
+ private MapMessageMonitor mMessageMonitor;
+ private MapDeviceMonitor mDeviceMonitor;
+ private BluetoothMapClient mMapClient;
+
+ @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.getAction());
+ }
+
+ // 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 failed = true;
+ if (mMapClient != null) {
+ failed = mMessageMonitor.sendAutoReply(
+ intent.getParcelableExtra(EXTRA_SENDER_KEY), mMapClient);
+ } else {
+ Log.e(TAG, "Unable to send reply; MAP profile disconnected!");
+ }
+ if (failed) {
+ 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_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;
+ }
+
+ private boolean hasRequiredArgs(Intent intent) {
+ switch (intent.getAction()) {
+ case ACTION_AUTO_REPLY:
+ case ACTION_PLAY_MESSAGES:
+ 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 null;
+ }
+
+ // 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!");
+ }
+
+ // Since we're connected, we will received broadcasts for any new messages
+ // in the MapMessageMonitor.
+ }
+
+ @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);
+ }
+
+ void cleanup() {
+ unregisterReceiver(this);
+ }
+
+ @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);
+ }
+ mMessageMonitor.handleDeviceDisconnect(device);
+ }
+ }
+ }
+}
diff --git a/src/com/android/car/messenger/TTSHelper.java b/src/com/android/car/messenger/TTSHelper.java
new file mode 100644
index 0000000..ff8ed3b
--- /dev/null
+++ b/src/com/android/car/messenger/TTSHelper.java
@@ -0,0 +1,181 @@
+/*
+ * 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.content.Context;
+import android.os.Handler;
+import android.speech.tts.TextToSpeech;
+import android.speech.tts.UtteranceProgressListener;
+import android.util.Log;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+
+/**
+ * Component that wraps platform TTS engine and supports queued playout.
+ * <p>
+ * It takes care of initializing the TTS engine. TTS requests made are queued up and played when the
+ * engine is setup. It only supports one queued requests; any new requests will cause the existing
+ * one to be dropped. Similarly, if a new one is queued while an existing message is already playing
+ * the existing one will be stopped/interrupted and the new one will start playing.
+ */
+class TTSHelper {
+ interface Listener {
+ // Called when playout is about to start.
+ void onTTSStarted();
+
+ // The following two are terminal callbacks and no further callbacks should be expected.
+ // Called when playout finishes or playout is cancelled/never started because another TTS
+ // request was made.
+ void onTTSStopped();
+ // Called when there's an internal error.
+ void onTTSError();
+ }
+
+ private static final String TAG = "Messenger.TTSHelper";
+ private static final boolean DBG = MessengerService.DBG;
+
+ private final Handler mHandler = new Handler();
+ private final TextToSpeech mTextToSpeech;
+ private int mInitStatus;
+ private SpeechRequest mPendingRequest;
+ private final Map<String, Listener> mListeners = new HashMap<>();
+
+ TTSHelper(Context context) {
+ // OnInitListener will only set to SUCCESS/ERROR. So we initialize to STOPPED.
+ mInitStatus = TextToSpeech.STOPPED;
+ // TODO(sriniv): Init this only when needed and shutdown to free resources.
+ mTextToSpeech = new TextToSpeech(context, this::handleInitCompleted);
+ mTextToSpeech.setOnUtteranceProgressListener(mProgressListener);
+ }
+
+ 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;
+ }
+ }
+
+ void requestPlay(CharSequence textToSpeak, Listener listener) {
+ // Check if its still initializing.
+ if (mInitStatus == TextToSpeech.STOPPED) {
+ // Squash any already queued request.
+ if (mPendingRequest != null) {
+ mPendingRequest.mListener.onTTSStopped();
+ }
+ mPendingRequest = new SpeechRequest(textToSpeak, listener);
+ } else {
+ playInternal(textToSpeak, listener);
+ }
+ }
+
+ void requestStop() {
+ mTextToSpeech.stop();
+ }
+
+ private void playInternal(CharSequence textToSpeak, Listener listener) {
+ if (mInitStatus == TextToSpeech.ERROR) {
+ Log.e(TAG, "TTS setup failed!");
+ mHandler.post(listener::onTTSError);
+ return;
+ }
+
+ String id = Integer.toString(listener.hashCode());
+ if (DBG) {
+ Log.d(TAG, String.format("Queueing text in TTS: [%s], id=%s", textToSpeak, id));
+ }
+ if (mTextToSpeech.speak(textToSpeak, TextToSpeech.QUEUE_FLUSH, null, id)
+ != TextToSpeech.SUCCESS) {
+ Log.e(TAG, "Queuing text failed!");
+ mHandler.post(listener::onTTSError);
+ return;
+ }
+ mListeners.put(id, listener);
+ }
+
+ void cleanup() {
+ mTextToSpeech.stop();
+ mTextToSpeech.shutdown();
+ }
+
+ // The TTS engine will invoke onStart and then invoke either onDone, onStop or onError.
+ // Since these callbacks can come on other threads, we push updates back on to the TTSHelper's
+ // Handler.
+ private final UtteranceProgressListener mProgressListener = new UtteranceProgressListener() {
+ private void safeInvokeAsync(String id, boolean cleanup,
+ Consumer<Listener> callbackCaller) {
+ mHandler.post(() -> {
+ Listener listener = mListeners.get(id);
+ if (listener == null) {
+ Log.e(TAG, "No listener found for: " + id);
+ return;
+ }
+ callbackCaller.accept(listener);
+ if (cleanup) {
+ mListeners.remove(id);
+ }
+ });
+ }
+
+ @Override
+ public void onStart(String id) {
+ if (DBG) {
+ Log.d(TAG, "TTS engine onStart: " + id);
+ }
+ safeInvokeAsync(id, false, Listener::onTTSStarted);
+ }
+
+ @Override
+ public void onDone(String id) {
+ if (DBG) {
+ Log.d(TAG, "TTS engine onDone: " + id);
+ }
+ safeInvokeAsync(id, true, Listener::onTTSStopped);
+ }
+
+ @Override
+ public void onStop(String id, boolean interrupted) {
+ if (DBG) {
+ Log.d(TAG, "TTS engine onStop: " + id);
+ }
+ safeInvokeAsync(id, true, Listener::onTTSStopped);
+ }
+
+ @Override
+ public void onError(String id) {
+ if (DBG) {
+ Log.d(TAG, "TTS engine onError: " + id);
+ }
+ safeInvokeAsync(id, true, Listener::onTTSError);
+ }
+ };
+
+ private static class SpeechRequest {
+ final CharSequence mTextToSpeak;
+ final Listener mListener;
+
+ public SpeechRequest(CharSequence textToSpeak, Listener listener) {
+ mTextToSpeak = textToSpeak;
+ mListener = listener;
+ }
+ };
+}