1 /* 2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.car.messenger; 18 19 20 import static com.android.car.apps.common.util.SafeLog.logd; 21 import static com.android.car.apps.common.util.SafeLog.loge; 22 import static com.android.car.apps.common.util.SafeLog.logw; 23 24 import android.annotation.Nullable; 25 import android.app.PendingIntent; 26 import android.bluetooth.BluetoothAdapter; 27 import android.bluetooth.BluetoothDevice; 28 import android.bluetooth.BluetoothMapClient; 29 import android.content.Context; 30 import android.content.Intent; 31 import android.content.res.Resources; 32 import android.graphics.Bitmap; 33 import android.graphics.drawable.Drawable; 34 import android.graphics.drawable.Icon; 35 import android.net.Uri; 36 import android.widget.Toast; 37 38 import androidx.core.graphics.drawable.RoundedBitmapDrawable; 39 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; 40 41 import com.android.car.messenger.bluetooth.BluetoothHelper; 42 import com.android.car.messenger.bluetooth.BluetoothMonitor; 43 import com.android.car.messenger.common.BaseNotificationDelegate; 44 import com.android.car.messenger.common.ConversationKey; 45 import com.android.car.messenger.common.ConversationNotificationInfo; 46 import com.android.car.messenger.common.Message; 47 import com.android.car.messenger.common.ProjectionStateListener; 48 import com.android.car.messenger.common.SenderKey; 49 import com.android.car.messenger.common.Utils; 50 import com.android.car.telephony.common.TelecomUtils; 51 import com.android.internal.annotations.GuardedBy; 52 53 import com.bumptech.glide.Glide; 54 import com.bumptech.glide.request.RequestOptions; 55 import com.bumptech.glide.request.target.SimpleTarget; 56 import com.bumptech.glide.request.transition.Transition; 57 58 import java.util.ArrayList; 59 import java.util.Collections; 60 import java.util.HashMap; 61 import java.util.HashSet; 62 import java.util.List; 63 import java.util.Map; 64 import java.util.Set; 65 import java.util.concurrent.CompletableFuture; 66 import java.util.function.Consumer; 67 import java.util.stream.Collectors; 68 69 /** Delegate class responsible for handling messaging service actions */ 70 public class MessageNotificationDelegate extends BaseNotificationDelegate implements 71 BluetoothMonitor.OnBluetoothEventListener { 72 private static final String TAG = "MsgNotiDelegate"; 73 private static final Object mMapClientLock = new Object(); 74 75 @GuardedBy("mMapClientLock") 76 private BluetoothMapClient mBluetoothMapClient; 77 /** Tracks whether a projection application is active in the foreground. **/ 78 private ProjectionStateListener mProjectionStateListener; 79 private CompletableFuture<Void> mPhoneNumberInfoFuture; 80 private static int mBitmapSize; 81 private static float mCornerRadiusPercent; 82 private static boolean mShouldLoadExistingMessages; 83 private static int mNotificationConversationTitleLength; 84 85 final Map<String, Long> mBtDeviceAddressToConnectionTimestamp = new HashMap<>(); 86 final Map<SenderKey, Bitmap> mSenderToLargeIconBitmap = new HashMap<>(); 87 final Map<String, String> mUriToSenderNameMap = new HashMap<>(); 88 final Set<ConversationKey> mGeneratedGroupConversationTitles = new HashSet<>(); 89 MessageNotificationDelegate(Context context)90 public MessageNotificationDelegate(Context context) { 91 super(context, /* useLetterTile */ true); 92 mProjectionStateListener = new ProjectionStateListener(context); 93 loadConfigValues(context); 94 } 95 96 /** Loads all necessary values from the config.xml at creation or when values are changed. **/ loadConfigValues(Context context)97 protected static void loadConfigValues(Context context) { 98 mBitmapSize = 300; 99 mCornerRadiusPercent = (float) 0.5; 100 mShouldLoadExistingMessages = false; 101 mNotificationConversationTitleLength = 30; 102 try { 103 mBitmapSize = 104 context.getResources() 105 .getDimensionPixelSize(R.dimen.notification_contact_photo_size); 106 mCornerRadiusPercent = context.getResources() 107 .getFloat(R.dimen.contact_avatar_corner_radius_percent); 108 mShouldLoadExistingMessages = 109 context.getResources().getBoolean(R.bool.config_loadExistingMessages); 110 mNotificationConversationTitleLength = context.getResources().getInteger( 111 R.integer.notification_conversation_title_length); 112 } catch (Resources.NotFoundException e) { 113 // Should only happen for robolectric unit tests; 114 loge(TAG, "Disabling loading of existing messages: " + e.getMessage()); 115 } 116 } 117 118 @Override onMessageReceived(Intent intent)119 public void onMessageReceived(Intent intent) { 120 addNamesToSenderMap(intent); 121 if (Utils.isGroupConversation(intent)) { 122 // Group Conversations have URIs of senders whose names we need to load from the DB. 123 loadNamesFromDatabase(intent); 124 } 125 loadAvatarIconAndProcessMessage(intent); 126 } 127 128 @Override onMessageSent(Intent intent)129 public void onMessageSent(Intent intent) { 130 logd(TAG, "onMessageSent"); 131 } 132 133 @Override onDeviceConnected(BluetoothDevice device)134 public void onDeviceConnected(BluetoothDevice device) { 135 logd(TAG, "Device connected: " + device.getAddress()); 136 mBtDeviceAddressToConnectionTimestamp.put(device.getAddress(), System.currentTimeMillis()); 137 synchronized (mMapClientLock) { 138 if (mBluetoothMapClient != null) { 139 if (mShouldLoadExistingMessages) { 140 mBluetoothMapClient.getUnreadMessages(device); 141 } 142 } else { 143 // onDeviceConnected should be sent by BluetoothMapClient, so log if we run into 144 // this strange case. 145 loge(TAG, "BluetoothMapClient is null after connecting to device."); 146 } 147 } 148 } 149 150 @Override onDeviceDisconnected(BluetoothDevice device)151 public void onDeviceDisconnected(BluetoothDevice device) { 152 logd(TAG, "Device disconnected: " + device.getAddress()); 153 cleanupMessagesAndNotifications(key -> key.matches(device.getAddress())); 154 mBtDeviceAddressToConnectionTimestamp.remove(device.getAddress()); 155 } 156 157 @Override onMapConnected(BluetoothMapClient client)158 public void onMapConnected(BluetoothMapClient client) { 159 logd(TAG, "Connected to BluetoothMapClient"); 160 List<BluetoothDevice> connectedDevices; 161 synchronized (mMapClientLock) { 162 if (mBluetoothMapClient == client) { 163 return; 164 } 165 166 mBluetoothMapClient = client; 167 connectedDevices = mBluetoothMapClient.getConnectedDevices(); 168 } 169 if (connectedDevices != null) { 170 for (BluetoothDevice device : connectedDevices) { 171 onDeviceConnected(device); 172 } 173 } 174 } 175 176 @Override onMapDisconnected()177 public void onMapDisconnected() { 178 logd(TAG, "Disconnected from BluetoothMapClient"); 179 resetInternalData(); 180 synchronized (mMapClientLock) { 181 mBluetoothMapClient = null; 182 } 183 } 184 185 @Override onSdpRecord(BluetoothDevice device, boolean supportsReply)186 public void onSdpRecord(BluetoothDevice device, boolean supportsReply) { 187 /* NO_OP */ 188 } 189 markAsRead(ConversationKey convoKey)190 protected void markAsRead(ConversationKey convoKey) { 191 excludeFromNotification(convoKey); 192 } 193 dismiss(ConversationKey convoKey)194 protected void dismiss(ConversationKey convoKey) { 195 super.dismissInternal(convoKey); 196 } 197 198 @Override shouldAddReplyAction(String deviceAddress)199 protected boolean shouldAddReplyAction(String deviceAddress) { 200 BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); 201 if (adapter == null) { 202 return false; 203 } 204 BluetoothDevice device = adapter.getRemoteDevice(deviceAddress); 205 206 synchronized (mMapClientLock) { 207 return (mBluetoothMapClient != null) && mBluetoothMapClient.isUploadingSupported( 208 device); 209 } 210 } 211 sendMessage(ConversationKey conversationKey, String messageText)212 protected void sendMessage(ConversationKey conversationKey, String messageText) { 213 final boolean deviceConnected = mBtDeviceAddressToConnectionTimestamp.containsKey( 214 conversationKey.getDeviceId()); 215 if (!deviceConnected) { 216 logw(TAG, "sendMessage: device disconnected, can't send message"); 217 return; 218 } 219 boolean success = false; 220 synchronized (mMapClientLock) { 221 if (mBluetoothMapClient != null) { 222 ConversationNotificationInfo notificationInfo = mNotificationInfos.get( 223 conversationKey); 224 if (notificationInfo == null) { 225 logw(TAG, "No notificationInfo found for senderKey " 226 + conversationKey.toString()); 227 } else if (notificationInfo.getCcRecipientsUris().isEmpty()) { 228 logw(TAG, "No contact URI for sender!"); 229 } else { 230 success = sendMessageInternal(conversationKey, messageText); 231 } 232 } 233 } 234 235 if (!success) { 236 Toast.makeText(mContext, R.string.auto_reply_failed_message, Toast.LENGTH_SHORT).show(); 237 } 238 } 239 onDestroy()240 protected void onDestroy() { 241 resetInternalData(); 242 if (mPhoneNumberInfoFuture != null) { 243 mPhoneNumberInfoFuture.cancel(true); 244 } 245 mProjectionStateListener.destroy(); 246 } 247 resetInternalData()248 private void resetInternalData() { 249 cleanupMessagesAndNotifications(key -> true); 250 mUriToSenderNameMap.clear(); 251 mSenderToLargeIconBitmap.clear(); 252 mBtDeviceAddressToConnectionTimestamp.clear(); 253 } 254 255 /** 256 * Creates a new message and links it to the conversation identified by the convoKey. Then 257 * posts the message notification after all loading queries from the database have finished. 258 */ initializeNewMessage(ConversationKey convoKey, Message message)259 private void initializeNewMessage(ConversationKey convoKey, Message message) { 260 addMessageToNotificationInfo(message, convoKey); 261 ConversationNotificationInfo notificationInfo = mNotificationInfos.get(convoKey); 262 // Only show notifications for messages received AFTER phone was connected. 263 mPhoneNumberInfoFuture.thenRun(() -> { 264 setGroupConversationTitle(convoKey); 265 // Only show notifications for messages received AFTER phone was connected. 266 if (message.getReceivedTime() 267 >= mBtDeviceAddressToConnectionTimestamp.get(convoKey.getDeviceId())) { 268 postNotification(convoKey, notificationInfo, getChannelId(convoKey.getDeviceId()), 269 mSenderToLargeIconBitmap.get(message.getSenderKey())); 270 } 271 }); 272 } 273 274 /** 275 * Creates a new conversation with all of the conversation metadata, and adds the first 276 * message to the conversation. 277 */ initializeNewConversation(ConversationKey convoKey, Intent intent)278 private void initializeNewConversation(ConversationKey convoKey, Intent intent) { 279 if (mNotificationInfos.containsKey(convoKey)) { 280 logw(TAG, "Conversation already exists! " + convoKey.toString()); 281 } 282 Message message = Message.parseFromIntent(intent); 283 ConversationNotificationInfo notiInfo; 284 try { 285 // Pass in null icon, since the fallback icon represents the system app's icon. 286 notiInfo = 287 ConversationNotificationInfo.createConversationNotificationInfo(intent, 288 message.getSenderName(), mContext.getClass().getName(), 289 /* appIcon */ null); 290 } catch (IllegalArgumentException e) { 291 logw(TAG, "initNewConvo: Message could not be created from the intent."); 292 return; 293 } 294 mNotificationInfos.put(convoKey, notiInfo); 295 initializeNewMessage(convoKey, message); 296 } 297 298 /** Loads the avatar icon, and processes the message after avatar is loaded. **/ loadAvatarIconAndProcessMessage(Intent intent)299 private void loadAvatarIconAndProcessMessage(Intent intent) { 300 SenderKey senderKey = SenderKey.createSenderKey(intent); 301 String phoneNumber = Utils.getPhoneNumberFromMapClient(Utils.getSenderUri(intent)); 302 if (mSenderToLargeIconBitmap.containsKey(senderKey) || phoneNumber == null) { 303 addMessageFromIntent(intent); 304 return; 305 } 306 loadPhoneNumberInfo(phoneNumber, phoneNumberInfo -> { 307 if (phoneNumberInfo == null) { 308 return; 309 } 310 Glide.with(mContext) 311 .asBitmap() 312 .load(phoneNumberInfo.getAvatarUri()) 313 .apply(new RequestOptions().override(mBitmapSize)) 314 .into(new SimpleTarget<Bitmap>() { 315 @Override 316 public void onResourceReady(Bitmap bitmap, 317 Transition<? super Bitmap> transition) { 318 RoundedBitmapDrawable roundedBitmapDrawable = 319 RoundedBitmapDrawableFactory 320 .create(mContext.getResources(), bitmap); 321 Icon avatarIcon = TelecomUtils 322 .createFromRoundedBitmapDrawable(roundedBitmapDrawable, 323 mBitmapSize, 324 mCornerRadiusPercent); 325 mSenderToLargeIconBitmap.put(senderKey, avatarIcon.getBitmap()); 326 addMessageFromIntent(intent); 327 return; 328 } 329 330 @Override 331 public void onLoadFailed(@Nullable Drawable fallback) { 332 addMessageFromIntent(intent); 333 return; 334 } 335 }); 336 }); 337 } 338 339 /** 340 * Extracts the message from the intent and creates a new conversation or message 341 * appropriately. 342 */ addMessageFromIntent(Intent intent)343 private void addMessageFromIntent(Intent intent) { 344 ConversationKey convoKey = ConversationKey.createConversationKey(intent); 345 346 if (convoKey == null) return; 347 logd(TAG, "Received message from " + convoKey.getDeviceId()); 348 if (mNotificationInfos.containsKey(convoKey)) { 349 try { 350 initializeNewMessage(convoKey, Message.parseFromIntent(intent)); 351 } catch (IllegalArgumentException e) { 352 logw(TAG, "addMessage: Message could not be created from the intent."); 353 return; 354 } 355 } else { 356 initializeNewConversation(convoKey, intent); 357 } 358 } 359 addNamesToSenderMap(Intent intent)360 private void addNamesToSenderMap(Intent intent) { 361 String senderUri = Utils.getSenderUri(intent); 362 String senderName = Utils.getSenderName(intent); 363 if (senderUri != null) { 364 mUriToSenderNameMap.put(senderUri, senderName); 365 } 366 } 367 368 /** 369 * Loads the name of a sender based on the sender's contact URI. 370 * 371 * This is needed to load the participants' names of a group conversation since 372 * {@link BluetoothMapClient} only sends the URIs of these participants. 373 */ loadNamesFromDatabase(Intent intent)374 private void loadNamesFromDatabase(Intent intent) { 375 for (String uri : Utils.getInclusiveRecipientsUrisList(intent)) { 376 String phoneNumber = Utils.getPhoneNumberFromMapClient(uri); 377 if (phoneNumber != null && !mUriToSenderNameMap.containsKey(uri)) { 378 loadPhoneNumberInfo(phoneNumber, (phoneNumberInfo) -> { 379 mUriToSenderNameMap.put(uri, phoneNumberInfo.getDisplayName()); 380 }); 381 } 382 } 383 } 384 385 /** 386 * Sets the group conversation title using the names of all the participants in the group. 387 * If all the participants' names have been loaded from the database, then we don't need 388 * to generate the title again. 389 * 390 * A group conversation's title should be an alphabetically sorted list of the participant's 391 * names, separated by commas. 392 */ setGroupConversationTitle(ConversationKey conversationKey)393 private void setGroupConversationTitle(ConversationKey conversationKey) { 394 ConversationNotificationInfo notificationInfo = mNotificationInfos.get(conversationKey); 395 if (!notificationInfo.isGroupConvo() 396 || mGeneratedGroupConversationTitles.contains(conversationKey)) { 397 return; 398 } 399 400 List<String> names = new ArrayList<>(); 401 402 boolean allNamesLoaded = true; 403 for (String uri : notificationInfo.getCcRecipientsUris()) { 404 if (mUriToSenderNameMap.containsKey(uri)) { 405 names.add(mUriToSenderNameMap.get(uri)); 406 } else { 407 names.add(Utils.getPhoneNumberFromMapClient(uri)); 408 // This URI has not been loaded from the database, set allNamesLoaded to false. 409 allNamesLoaded = false; 410 } 411 } 412 413 notificationInfo.setConvoTitle(constructGroupConversationTitle(names)); 414 if (allNamesLoaded) mGeneratedGroupConversationTitles.add(conversationKey); 415 } 416 417 /** 418 * Given a name of all the participants in a group conversation (some names might be phone 419 * numbers), this function creates the conversation title putting the names in alphabetical 420 * order first, then adding any phone numbers. This title should not exceed the 421 * mNotificationConversationTitleLength, so not all participants' names are guaranteed to be 422 * in the conversation title. 423 */ constructGroupConversationTitle(List<String> names)424 private String constructGroupConversationTitle(List<String> names) { 425 Collections.sort(names, Utils.ALPHA_THEN_NUMERIC_COMPARATOR); 426 427 return names.stream().map(String::valueOf).collect( 428 Collectors.joining(mContext.getString(R.string.name_separator))); 429 } 430 loadPhoneNumberInfo(@ullable String phoneNumber, Consumer<? super TelecomUtils.PhoneNumberInfo> action)431 private void loadPhoneNumberInfo(@Nullable String phoneNumber, 432 Consumer<? super TelecomUtils.PhoneNumberInfo> action) { 433 if (phoneNumber == null) { 434 logw(TAG, " Could not load PhoneNumberInfo due to null phone number"); 435 return; 436 } 437 438 mPhoneNumberInfoFuture = TelecomUtils.getPhoneNumberInfo(mContext, phoneNumber) 439 .thenAcceptAsync(action, mContext.getMainExecutor()); 440 } 441 getChannelId(String deviceAddress)442 private String getChannelId(String deviceAddress) { 443 if (mProjectionStateListener.isProjectionInActiveForeground(deviceAddress)) { 444 return MessengerService.SILENT_SMS_CHANNEL_ID; 445 } 446 return MessengerService.SMS_CHANNEL_ID; 447 } 448 449 /** Sends reply message to the BluetoothMapClient to send to the connected phone. **/ sendMessageInternal(ConversationKey conversationKey, String messageText)450 private boolean sendMessageInternal(ConversationKey conversationKey, String messageText) { 451 ConversationNotificationInfo notificationInfo = mNotificationInfos.get(conversationKey); 452 Uri[] recipientUrisArray = generateRecipientUriArray(notificationInfo); 453 454 final int requestCode = conversationKey.hashCode(); 455 456 Intent intent = new Intent(BluetoothMapClient.ACTION_MESSAGE_SENT_SUCCESSFULLY); 457 PendingIntent sentIntent = PendingIntent.getBroadcast(mContext, requestCode, 458 intent, 459 PendingIntent.FLAG_ONE_SHOT); 460 461 try { 462 return BluetoothHelper.sendMessage(mBluetoothMapClient, 463 conversationKey.getDeviceId(), recipientUrisArray, messageText, 464 sentIntent, null); 465 } catch (IllegalArgumentException e) { 466 logw(TAG, "Invalid device address: " + conversationKey.getDeviceId()); 467 } 468 return false; 469 } 470 471 /** 472 * Generate an array containing all the recipients' URIs that should receive the user's 473 * message for the given notificationInfo. 474 */ generateRecipientUriArray(ConversationNotificationInfo notificationInfo)475 private Uri[] generateRecipientUriArray(ConversationNotificationInfo notificationInfo) { 476 List<String> ccRecipientsUris = notificationInfo.getCcRecipientsUris(); 477 Uri[] recipientUris = new Uri[ccRecipientsUris.size()]; 478 479 for (int i = 0; i < ccRecipientsUris.size(); i++) { 480 recipientUris[i] = Uri.parse(ccRecipientsUris.get(i)); 481 } 482 return recipientUris; 483 } 484 } 485