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