blob: 926579da57b4c6b91407f946bc1e1da0a27ec476 [file] [log] [blame]
/*
* 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.annotation.Nullable;
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);
}
}
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(), message.getSenderContactUri());
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);
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);
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);
}
}
/**
* {@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 List<MessageKey> mMessageKeys = new LinkedList<>();
NotificationInfo(String senderName, @Nullable String senderContactUri) {
mSenderName = senderName;
mSenderContactUri = senderContactUri;
}
}
}