1 package com.android.car.messenger;
2 
3 
4 import android.app.Notification;
5 import android.app.NotificationManager;
6 import android.app.PendingIntent;
7 import android.bluetooth.BluetoothAdapter;
8 import android.bluetooth.BluetoothDevice;
9 import android.bluetooth.BluetoothMapClient;
10 import android.content.Context;
11 import android.content.Intent;
12 import android.content.res.Resources.NotFoundException;
13 import android.graphics.Bitmap;
14 import android.graphics.drawable.Drawable;
15 import android.graphics.drawable.Icon;
16 import android.net.Uri;
17 import android.widget.Toast;
18 import androidx.annotation.Nullable;
19 import androidx.annotation.VisibleForTesting;
20 import androidx.core.app.NotificationCompat;
21 import androidx.core.app.NotificationCompat.Action;
22 import androidx.core.app.NotificationCompat.MessagingStyle;
23 import androidx.core.app.Person;
24 import androidx.core.app.RemoteInput;
25 import androidx.core.graphics.drawable.RoundedBitmapDrawable;
26 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
27 import com.android.car.apps.common.LetterTileDrawable;
28 import com.android.car.messenger.bluetooth.BluetoothHelper;
29 import com.android.car.messenger.bluetooth.BluetoothMonitor;
30 import com.android.car.messenger.common.ProjectionStateListener;
31 import com.android.car.messenger.log.L;
32 import com.android.car.telephony.common.TelecomUtils;
33 import com.android.internal.annotations.GuardedBy;
34 import com.bumptech.glide.Glide;
35 import com.bumptech.glide.request.RequestOptions;
36 import com.bumptech.glide.request.target.SimpleTarget;
37 import com.bumptech.glide.request.transition.Transition;
38 import java.util.ArrayList;
39 import java.util.HashMap;
40 import java.util.LinkedList;
41 import java.util.List;
42 import java.util.Map;
43 import java.util.concurrent.CompletableFuture;
44 import java.util.function.Predicate;
45 
46 /** Delegate class responsible for handling messaging service actions */
47 public class MessengerDelegate implements BluetoothMonitor.OnBluetoothEventListener {
48     private static final String TAG = "CM.MessengerDelegate";
49     private static final Object mMapClientLock = new Object();
50 
51     private final Context mContext;
52     @GuardedBy("mMapClientLock")
53     private BluetoothMapClient mBluetoothMapClient;
54     private final NotificationManager mNotificationManager;
55     private final SmsDatabaseHandler mSmsDatabaseHandler;
56     private final int mBitmapSize;
57     private final float mCornerRadiusPercent;
58     private boolean mShouldLoadExistingMessages;
59     private CompletableFuture<Void> mPhoneNumberInfoFuture;
60 
61     @VisibleForTesting
62     final Map<MessageKey, MapMessage> mMessages = new HashMap<>();
63     @VisibleForTesting
64     final Map<SenderKey, NotificationInfo> mNotificationInfos = new HashMap<>();
65     // Mapping of when a device was connected via BluetoothMapClient. Used so we don't show
66     // Notifications for messages received before this time.
67     @VisibleForTesting
68     final Map<String, Long> mBTDeviceAddressToConnectionTimestamp = new HashMap<>();
69     final Map<SenderKey, Bitmap> mSenderToLargeIconBitmap = new HashMap<>();
70 
71     /** Tracks whether a projection application is active in the foreground. **/
72     private ProjectionStateListener mProjectionStateListener;
73 
MessengerDelegate(Context context)74     public MessengerDelegate(Context context) {
75         mContext = context;
76 
77         mProjectionStateListener = new ProjectionStateListener(context);
78         mProjectionStateListener.start();
79 
80         mNotificationManager =
81                 (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
82         mSmsDatabaseHandler = new SmsDatabaseHandler(mContext);
83 
84         mBitmapSize =
85             mContext.getResources()
86                 .getDimensionPixelSize(R.dimen.notification_contact_photo_size);
87         mCornerRadiusPercent = mContext.getResources()
88             .getFloat(R.dimen.contact_avatar_corner_radius_percent);
89         try {
90             mShouldLoadExistingMessages =
91                     mContext.getResources().getBoolean(R.bool.config_loadExistingMessages);
92         } catch(NotFoundException e) {
93             // Should only happen for robolectric unit tests;
94             L.e(TAG, e, "Disabling loading of existing messages");
95             mShouldLoadExistingMessages = false;
96         }
97     }
98 
99     @Override
onMessageReceived(Intent intent)100     public void onMessageReceived(Intent intent) {
101         try {
102             MapMessage message = MapMessage.parseFrom(intent);
103             if (message == null) return;
104             L.d(TAG, "Received message from " + message.getDeviceAddress());
105 
106             MessageKey messageKey = new MessageKey(message);
107             boolean repeatMessage = mMessages.containsKey(messageKey);
108             mMessages.put(messageKey, message);
109             if (!repeatMessage) {
110                 mSmsDatabaseHandler.addOrUpdate(message);
111                 updateNotification(messageKey, message);
112             }
113         } catch (IllegalArgumentException e) {
114             L.e(TAG, e, "Dropping invalid MAP message.");
115         }
116     }
117 
118     @Override
onMessageSent(Intent intent)119     public void onMessageSent(Intent intent) {
120         /* NO-OP */
121     }
122 
123     @Override
onDeviceConnected(BluetoothDevice device)124     public void onDeviceConnected(BluetoothDevice device) {
125         L.d(TAG, "Device connected: \t%s", device.getAddress());
126         mBTDeviceAddressToConnectionTimestamp.put(device.getAddress(), System.currentTimeMillis());
127         synchronized (mMapClientLock) {
128             if (mBluetoothMapClient != null) {
129                 if (mShouldLoadExistingMessages) {
130                     mBluetoothMapClient.getUnreadMessages(device);
131                 }
132             } else {
133                 // onDeviceConnected should be sent by BluetoothMapClient, so log if we run into
134                 // this strange case.
135                 L.e(TAG, "BluetoothMapClient is null after connecting to device.");
136             }
137         }
138     }
139 
140     @Override
onDeviceDisconnected(BluetoothDevice device)141     public void onDeviceDisconnected(BluetoothDevice device) {
142         L.d(TAG, "Device disconnected: \t%s", device.getAddress());
143         cleanupMessagesAndNotifications(key -> key.matches(device.getAddress()));
144         mBTDeviceAddressToConnectionTimestamp.remove(device.getAddress());
145         mSmsDatabaseHandler.removeMessagesForDevice(device.getAddress());
146     }
147 
148     @Override
onMapConnected(BluetoothMapClient client)149     public void onMapConnected(BluetoothMapClient client) {
150         L.d(TAG, "Connected to BluetoothMapClient");
151         List<BluetoothDevice> connectedDevices;
152         synchronized (mMapClientLock) {
153             if (mBluetoothMapClient == client) {
154                 return;
155             }
156 
157             mBluetoothMapClient = client;
158             connectedDevices = mBluetoothMapClient.getConnectedDevices();
159         }
160         if (connectedDevices != null) {
161             for (BluetoothDevice device : connectedDevices) {
162                 onDeviceConnected(device);
163             }
164         }
165     }
166 
167     @Override
onMapDisconnected()168     public void onMapDisconnected() {
169         L.d(TAG, "Disconnected from BluetoothMapClient");
170         cleanupMessagesAndNotifications(key -> true);
171         synchronized (mMapClientLock) {
172             mBluetoothMapClient = null;
173         }
174     }
175 
176     @Override
onSdpRecord(BluetoothDevice device, boolean supportsReply)177     public void onSdpRecord(BluetoothDevice device, boolean supportsReply) {
178         /* NO_OP */
179     }
180 
sendMessage(SenderKey senderKey, String messageText)181     protected void sendMessage(SenderKey senderKey, String messageText) {
182         boolean success = false;
183         // Even if the device is not connected, try anyway so that the reply in enqueued.
184         synchronized (mMapClientLock) {
185             if (mBluetoothMapClient != null) {
186                 NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
187                 if (notificationInfo == null) {
188                     L.w(TAG, "No notificationInfo found for senderKey: %s", senderKey);
189                 } else if (notificationInfo.mSenderContactUri == null) {
190                     L.w(TAG, "Do not have contact URI for sender!");
191                 } else {
192                     Uri[] recipientUris = {Uri.parse(notificationInfo.mSenderContactUri)};
193 
194                     final int requestCode = senderKey.hashCode();
195 
196                     Intent intent = new Intent(BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY);
197                     PendingIntent sentIntent = PendingIntent.getBroadcast(mContext, requestCode,
198                             intent,
199                             PendingIntent.FLAG_ONE_SHOT);
200 
201                     success = BluetoothHelper.sendMessage(mBluetoothMapClient,
202                             senderKey.getDeviceAddress(), recipientUris, messageText,
203                             sentIntent, null);
204                 }
205             }
206         }
207 
208         final boolean deviceConnected = mBTDeviceAddressToConnectionTimestamp.containsKey(
209                 senderKey.getDeviceAddress());
210         if (!success || !deviceConnected) {
211             L.e(TAG, "Unable to send reply!");
212             final int toastResource = deviceConnected
213                     ? R.string.auto_reply_failed_message
214                     : R.string.auto_reply_device_disconnected;
215 
216             Toast.makeText(mContext, toastResource, Toast.LENGTH_SHORT).show();
217         }
218     }
219 
220 
221     /**
222      * Excludes messages from a notification so that the messages are not shown to the user once
223      * the notification gets updated with newer messages.
224      */
excludeFromNotification(SenderKey senderKey)225     protected void excludeFromNotification(SenderKey senderKey) {
226         NotificationInfo info = mNotificationInfos.get(senderKey);
227         for (MessageKey key : info.mMessageKeys) {
228             MapMessage message = mMessages.get(key);
229             if (message.shouldIncludeInNotification()) {
230                 message.excludeFromNotification();
231                 mSmsDatabaseHandler.addOrUpdate(message);
232             }
233         }
234     }
235 
onDestroy()236     protected void onDestroy() {
237         cleanupMessagesAndNotifications(key -> true);
238 
239         if (mPhoneNumberInfoFuture != null) {
240             mPhoneNumberInfoFuture.cancel(true);
241         }
242         mProjectionStateListener.stop();
243     }
244 
245     /**
246      * Clears all notifications matching the {@param predicate}. Example method calls are when user
247      * wants to clear (a) message notification(s), or when the Bluetooth device that received the
248      * messages has been disconnected.
249      */
clearNotifications(Predicate<CompositeKey> predicate)250     protected void clearNotifications(Predicate<CompositeKey> predicate) {
251         mNotificationInfos.forEach((senderKey, notificationInfo) -> {
252             if (predicate.test(senderKey)) {
253                 mNotificationManager.cancel(notificationInfo.mNotificationId);
254             }
255             excludeFromNotification(senderKey);
256         });
257     }
258 
259     /** Removes all messages related to the inputted predicate, and cancels their notifications. **/
cleanupMessagesAndNotifications(Predicate<CompositeKey> predicate)260     private void cleanupMessagesAndNotifications(Predicate<CompositeKey> predicate) {
261         for (MessageKey key : mMessages.keySet()) {
262             if (predicate.test(key)) {
263                 mSmsDatabaseHandler.removeMessagesForDevice(key.getDeviceAddress());
264             }
265         }
266         clearNotifications(predicate);
267         mNotificationInfos.entrySet().removeIf(entry -> predicate.test(entry.getKey()));
268         mSenderToLargeIconBitmap.entrySet().removeIf(entry -> predicate.test(entry.getKey()));
269         mMessages.entrySet().removeIf(
270                 messageKeyMapMessageEntry -> predicate.test(messageKeyMapMessageEntry.getKey()));
271     }
272 
updateNotification(MessageKey messageKey, MapMessage mapMessage)273     private void updateNotification(MessageKey messageKey, MapMessage mapMessage) {
274         // Only show notifications for messages received AFTER phone was connected.
275         if (mapMessage.getReceiveTime()
276                 < mBTDeviceAddressToConnectionTimestamp.get(mapMessage.getDeviceAddress())) {
277             return;
278         }
279 
280         SmsDatabaseHandler.readDatabase(mContext);
281         SenderKey senderKey = new SenderKey(mapMessage);
282         if (!mNotificationInfos.containsKey(senderKey)) {
283             mNotificationInfos.put(senderKey, new NotificationInfo(mapMessage.getSenderName(),
284                     mapMessage.getSenderContactUri()));
285         }
286         NotificationInfo notificationInfo = mNotificationInfos.get(senderKey);
287         notificationInfo.mMessageKeys.add(messageKey);
288 
289         updateNotificationWithIcon(senderKey, notificationInfo);
290     }
291 
updateNotificationWithIcon(SenderKey senderKey, NotificationInfo notificationInfo)292     private void updateNotificationWithIcon(SenderKey senderKey,
293         NotificationInfo notificationInfo) {
294         String phoneNumber = getPhoneNumber(notificationInfo.mSenderContactUri);
295         if (mSenderToLargeIconBitmap.get(senderKey) != null || phoneNumber == null) {
296             postNotification(senderKey, notificationInfo);
297         }
298 
299         if (mPhoneNumberInfoFuture != null) {
300             mPhoneNumberInfoFuture.cancel(/* mayInterruptRunning= */ true);
301         }
302 
303         LetterTileDrawable errorDrawable = TelecomUtils.createLetterTile(mContext,
304             notificationInfo.mSenderName, notificationInfo.mSenderName);
305 
306         mPhoneNumberInfoFuture = TelecomUtils.getPhoneNumberInfo(mContext, phoneNumber)
307             .thenAcceptAsync(phoneNumberInfo -> {
308                 if (phoneNumberInfo == null) {
309                     postNotification(senderKey, notificationInfo);
310                 }
311                 Glide.with(mContext)
312                     .asBitmap()
313                     .load(phoneNumberInfo.getAvatarUri())
314                     .apply(new RequestOptions().override(mBitmapSize).error(errorDrawable))
315                     .into(new SimpleTarget<Bitmap>() {
316                         @Override
317                         public void onResourceReady(Bitmap bitmap,
318                             Transition<? super Bitmap> transition) {
319                             RoundedBitmapDrawable roundedBitmapDrawable =
320                                 RoundedBitmapDrawableFactory
321                                     .create(mContext.getResources(), bitmap);
322                             Icon avatarIcon = TelecomUtils
323                                 .createFromRoundedBitmapDrawable(roundedBitmapDrawable, mBitmapSize,
324                                     mCornerRadiusPercent);
325                             mSenderToLargeIconBitmap.put(senderKey, avatarIcon.getBitmap());
326                             postNotification(senderKey, notificationInfo);
327                         }
328 
329                         @Override
330                         public void onLoadFailed(@Nullable Drawable fallback) {
331                             postNotification(senderKey, notificationInfo);
332                         }
333 
334                     });
335             }, mContext.getMainExecutor());
336     }
337 
postNotification(SenderKey senderKey, NotificationInfo notificationInfo)338     private void postNotification(SenderKey senderKey, NotificationInfo notificationInfo) {
339         mNotificationManager.notify(
340             notificationInfo.mNotificationId,
341             createNotification(senderKey, notificationInfo));
342     }
343 
createNotification( SenderKey senderKey, NotificationInfo notificationInfo)344     private Notification createNotification(
345         SenderKey senderKey, NotificationInfo notificationInfo) {
346         String contentText = mContext.getResources().getQuantityString(
347                 R.plurals.notification_new_message, notificationInfo.mMessageKeys.size(),
348                 notificationInfo.mMessageKeys.size());
349         long lastReceiveTime = mMessages.get(notificationInfo.mMessageKeys.getLast())
350                 .getReceiveTime();
351 
352         Bitmap largeIcon = mSenderToLargeIconBitmap.get(senderKey);
353         if (largeIcon == null) {
354             largeIcon =
355                 TelecomUtils.createLetterTile(mContext,
356                     TelecomUtils.getInitials(notificationInfo.mSenderName, ""),
357                     notificationInfo.mSenderName, mBitmapSize, mCornerRadiusPercent).getBitmap();
358         }
359 
360         final String senderName = notificationInfo.mSenderName;
361         final int notificationId = notificationInfo.mNotificationId;
362 
363         // Create the Content Intent
364         PendingIntent deleteIntent = createServiceIntent(senderKey, notificationId,
365                 MessengerService.ACTION_CLEAR_NOTIFICATION_STATE);
366 
367         List<Action> actions = getNotificationActions(senderKey, notificationId);
368 
369         Person user = new Person.Builder()
370                 .setName(mContext.getString(R.string.name_not_available))
371                 .build();
372         MessagingStyle messagingStyle = new MessagingStyle(user);
373         Person sender = new Person.Builder()
374                 .setName(senderName)
375                 .setUri(notificationInfo.mSenderContactUri)
376                 .build();
377         notificationInfo.mMessageKeys.stream().map(mMessages::get).forEachOrdered(message -> {
378             if (message.shouldIncludeInNotification()) {
379                 messagingStyle.addMessage(
380                         message.getMessageText(),
381                         message.getReceiveTime(),
382                         sender);
383             } else {
384                 L.d(TAG, "excluding message received at: " + message.getReceiveTime()
385                         + " from notification.");
386             }
387         });
388 
389         NotificationCompat.Builder builder;
390         if (mProjectionStateListener.isProjectionInActiveForeground(senderKey.getDeviceAddress())) {
391             builder = new NotificationCompat.Builder(mContext,
392                     MessengerService.SILENT_SMS_CHANNEL_ID);
393         } else {
394             builder = new NotificationCompat.Builder(mContext, MessengerService.SMS_CHANNEL_ID);
395         }
396 
397         builder.setContentTitle(senderName)
398                 .setContentText(contentText)
399                 .setStyle(messagingStyle)
400                 .setCategory(Notification.CATEGORY_MESSAGE)
401                 .setLargeIcon(largeIcon)
402                 .setSmallIcon(R.drawable.ic_message)
403                 .setWhen(lastReceiveTime)
404                 .setShowWhen(true)
405                 .setDeleteIntent(deleteIntent);
406 
407         for (final Action action : actions) {
408             builder.addAction(action);
409         }
410 
411         return builder.build();
412     }
413 
createServiceIntent(SenderKey senderKey, int notificationId, String action)414     private PendingIntent createServiceIntent(SenderKey senderKey, int notificationId,
415             String action) {
416         Intent intent = new Intent(mContext, MessengerService.class)
417                 .setAction(action)
418                 .putExtra(MessengerService.EXTRA_SENDER_KEY, senderKey);
419 
420         return PendingIntent.getForegroundService(mContext, notificationId, intent,
421                 PendingIntent.FLAG_UPDATE_CURRENT);
422     }
423 
getNotificationActions(SenderKey senderKey, int notificationId)424     private List<Action> getNotificationActions(SenderKey senderKey, int notificationId) {
425 
426         final int icon = android.R.drawable.ic_media_play;
427 
428         final List<Action> actionList = new ArrayList<>();
429 
430         // Reply action
431         if (shouldAddReplyAction(senderKey)) {
432             final String replyString = mContext.getString(R.string.action_reply);
433             PendingIntent replyIntent = createServiceIntent(senderKey, notificationId,
434                     MessengerService.ACTION_VOICE_REPLY);
435             actionList.add(
436                     new Action.Builder(icon, replyString, replyIntent)
437                             .setSemanticAction(Action.SEMANTIC_ACTION_REPLY)
438                             .setShowsUserInterface(false)
439                             .addRemoteInput(
440                                     new RemoteInput.Builder(MessengerService.REMOTE_INPUT_KEY)
441                                             .build()
442                             )
443                             .build()
444             );
445         } else {
446             L.d(TAG, "Not adding Reply action for " + senderKey.getDeviceAddress());
447         }
448 
449         // Mark-as-read Action. This will be the callback of Notification Center's "Read" action.
450         final String markAsRead = mContext.getString(R.string.action_mark_as_read);
451         PendingIntent markAsReadIntent = createServiceIntent(senderKey, notificationId,
452                 MessengerService.ACTION_MARK_AS_READ);
453         actionList.add(
454                 new Action.Builder(icon, markAsRead, markAsReadIntent)
455                         .setSemanticAction(Action.SEMANTIC_ACTION_MARK_AS_READ)
456                         .setShowsUserInterface(false)
457                         .build()
458         );
459 
460         return actionList;
461     }
462 
shouldAddReplyAction(SenderKey senderKey)463     private boolean shouldAddReplyAction(SenderKey senderKey) {
464         if (mNotificationInfos.get(senderKey).mSenderContactUri == null) {
465             return false;
466         }
467 
468         BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
469         if (adapter == null) {
470             return false;
471         }
472         BluetoothDevice device = adapter.getRemoteDevice(senderKey.getDeviceAddress());
473 
474         synchronized (mMapClientLock) {
475             return (mBluetoothMapClient != null) && mBluetoothMapClient.isUploadingSupported(
476                     device);
477         }
478     }
479 
480     /**
481      * Extracts the phone number from the {@link BluetoothMapClient} formatted URI.
482      **/
483     @Nullable
getPhoneNumber(String senderContactUri)484     private String getPhoneNumber(String senderContactUri) {
485         if (senderContactUri == null || !senderContactUri.matches("tel:(.+)")) {
486             return null;
487         }
488 
489         return senderContactUri.substring(4);
490     }
491 
492     /**
493      * Contains information about a single notification that is displayed, with grouped messages.
494      */
495     @VisibleForTesting
496     static class NotificationInfo {
497         private static int NEXT_NOTIFICATION_ID = 0;
498 
499         final int mNotificationId = NEXT_NOTIFICATION_ID++;
500         final String mSenderName;
501         @Nullable
502         final String mSenderContactUri;
503         final LinkedList<MessageKey> mMessageKeys = new LinkedList<>();
504 
NotificationInfo(String senderName, @Nullable String senderContactUri)505         NotificationInfo(String senderName, @Nullable String senderContactUri) {
506             mSenderName = senderName;
507             mSenderContactUri = senderContactUri;
508         }
509     }
510 
511     /**
512      * {@link CompositeKey} subclass used to identify specific messages; it uses message-handle as
513      * the secondary key.
514      */
515     public static class MessageKey extends CompositeKey {
MessageKey(MapMessage message)516         MessageKey(MapMessage message) {
517             super(message.getDeviceAddress(), message.getHandle());
518         }
519     }
520 }
521