/* * 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. *

* It also handles notifications requests e.g. sending auto-replies and message play-out. *

* 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 mMessages = new HashMap<>(); private final Map 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 predicate) { Iterator> messageIt = mMessages.entrySet().iterator(); while (messageIt.hasNext()) { if (predicate.test(messageIt.next().getKey())) { messageIt.remove(); } } Iterator> notificationIt = mNotificationInfos.entrySet().iterator(); while (notificationIt.hasNext()) { Map.Entry 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 CREATOR = new Parcelable.Creator() { @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 mMessageKeys = new LinkedList<>(); NotificationInfo(String senderName, @Nullable String senderContactUri) { mSenderName = senderName; mSenderContactUri = senderContactUri; } } }