1 /* 2 * Copyright (C) 2019 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.common; 18 19 import static com.android.car.apps.common.util.SafeLog.logw; 20 21 import android.bluetooth.BluetoothDevice; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.graphics.Bitmap; 25 import android.graphics.Canvas; 26 import android.text.BidiFormatter; 27 import android.text.TextDirectionHeuristics; 28 import android.text.TextUtils; 29 30 import androidx.annotation.Nullable; 31 import androidx.core.graphics.drawable.RoundedBitmapDrawable; 32 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; 33 34 import com.android.car.apps.common.LetterTileDrawable; 35 import com.android.car.messenger.NotificationMsgProto.NotificationMsg; 36 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.AvatarIconSync; 37 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.ConversationNotification; 38 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyle; 39 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyleMessage; 40 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.Person; 41 42 import com.google.i18n.phonenumbers.NumberParseException; 43 import com.google.i18n.phonenumbers.PhoneNumberUtil; 44 import com.google.i18n.phonenumbers.Phonenumber; 45 46 import java.util.ArrayList; 47 import java.util.Arrays; 48 import java.util.Collections; 49 import java.util.Comparator; 50 import java.util.List; 51 import java.util.stream.Collectors; 52 53 /** Utils methods for the car-messenger-common lib. **/ 54 public class Utils { 55 private static final String TAG = "CMC.Utils"; 56 /** 57 * Represents the maximum length of a message substring to be used when constructing the 58 * message's unique handle/key. 59 */ 60 private static final int MAX_SUB_MESSAGE_LENGTH = 5; 61 62 /** The Regex format of a telephone number in a BluetoothMapClient contact URI. **/ 63 private static final String MAP_CLIENT_URI_REGEX = "tel:(.+)"; 64 65 /** The starting substring index for a string formatted with the MAP_CLIENT_URI_REGEX above. **/ 66 private static final int MAP_CLIENT_URI_PHONE_NUMBER_SUBSTRING_INDEX = 4; 67 68 // TODO (150711637): Reference BluetoothMapClient Extras once BluetoothMapClient is SystemApi. 69 protected static final String BMC_EXTRA_MESSAGE_HANDLE = 70 "android.bluetooth.mapmce.profile.extra.MESSAGE_HANDLE"; 71 protected static final String BMC_EXTRA_SENDER_CONTACT_URI = 72 "android.bluetooth.mapmce.profile.extra.SENDER_CONTACT_URI"; 73 protected static final String BMC_EXTRA_SENDER_CONTACT_NAME = 74 "android.bluetooth.mapmce.profile.extra.SENDER_CONTACT_NAME"; 75 protected static final String BMC_EXTRA_MESSAGE_TIMESTAMP = 76 "android.bluetooth.mapmce.profile.extra.MESSAGE_TIMESTAMP"; 77 protected static final String BMC_EXTRA_MESSAGE_READ_STATUS = 78 "android.bluetooth.mapmce.profile.extra.MESSAGE_READ_STATUS"; 79 80 /** Gets the latest message for a {@link NotificationMsg} Conversation. **/ getLatestMessage( ConversationNotification notification)81 public static MessagingStyleMessage getLatestMessage( 82 ConversationNotification notification) { 83 MessagingStyle messagingStyle = notification.getMessagingStyle(); 84 long latestTime = 0; 85 MessagingStyleMessage latestMessage = null; 86 87 for (MessagingStyleMessage message : messagingStyle.getMessagingStyleMsgList()) { 88 if (message.getTimestamp() > latestTime) { 89 latestTime = message.getTimestamp(); 90 latestMessage = message; 91 } 92 } 93 return latestMessage; 94 } 95 96 /** 97 * Helper method to create a unique handle/key for this message. This is used as this Message's 98 * {@link MessageKey#getSubKey()}. 99 */ createMessageHandle(MessagingStyleMessage message)100 public static String createMessageHandle(MessagingStyleMessage message) { 101 String textMessage = message.getTextMessage(); 102 String subMessage = textMessage.substring( 103 Math.min(MAX_SUB_MESSAGE_LENGTH, textMessage.length())); 104 return message.getTimestamp() + "/" + message.getSender().getName() + "/" + subMessage; 105 } 106 107 /** 108 * Ensure the {@link ConversationNotification} object has all the required fields. 109 * 110 * @param isShallowCheck should be {@code true} if the caller only wants to verify the 111 * notification and its {@link MessagingStyle} is valid, without checking 112 * all of the notification's {@link MessagingStyleMessage}s. 113 **/ isValidConversationNotification(ConversationNotification notification, boolean isShallowCheck)114 public static boolean isValidConversationNotification(ConversationNotification notification, 115 boolean isShallowCheck) { 116 if (notification == null) { 117 logw(TAG, "ConversationNotification is null"); 118 return false; 119 } else if (!notification.hasMessagingStyle()) { 120 logw(TAG, "ConversationNotification is missing required field: messagingStyle"); 121 return false; 122 } else if (notification.getMessagingAppDisplayName() == null) { 123 logw(TAG, "ConversationNotification is missing required field: appDisplayName"); 124 return false; 125 } else if (notification.getMessagingAppPackageName() == null) { 126 logw(TAG, "ConversationNotification is missing required field: appPackageName"); 127 return false; 128 } 129 return isValidMessagingStyle(notification.getMessagingStyle(), isShallowCheck); 130 } 131 132 /** 133 * Ensure the {@link MessagingStyle} object has all the required fields. 134 **/ isValidMessagingStyle(MessagingStyle messagingStyle, boolean isShallowCheck)135 private static boolean isValidMessagingStyle(MessagingStyle messagingStyle, 136 boolean isShallowCheck) { 137 if (messagingStyle == null) { 138 logw(TAG, "MessagingStyle is null"); 139 return false; 140 } else if (messagingStyle.getConvoTitle() == null) { 141 logw(TAG, "MessagingStyle is missing required field: convoTitle"); 142 return false; 143 } else if (messagingStyle.getUserDisplayName() == null) { 144 logw(TAG, "MessagingStyle is missing required field: userDisplayName"); 145 return false; 146 } else if (messagingStyle.getMessagingStyleMsgCount() == 0) { 147 logw(TAG, "MessagingStyle is missing required field: messagingStyleMsg"); 148 return false; 149 } 150 if (!isShallowCheck) { 151 for (MessagingStyleMessage message : messagingStyle.getMessagingStyleMsgList()) { 152 if (!isValidMessagingStyleMessage(message)) { 153 return false; 154 } 155 } 156 } 157 return true; 158 } 159 160 /** 161 * Ensure the {@link MessagingStyleMessage} object has all the required fields. 162 **/ isValidMessagingStyleMessage(MessagingStyleMessage message)163 public static boolean isValidMessagingStyleMessage(MessagingStyleMessage message) { 164 if (message == null) { 165 logw(TAG, "MessagingStyleMessage is null"); 166 return false; 167 } else if (message.getTextMessage() == null) { 168 logw(TAG, "MessagingStyleMessage is missing required field: textMessage"); 169 return false; 170 } else if (!message.hasSender()) { 171 logw(TAG, "MessagingStyleMessage is missing required field: sender"); 172 return false; 173 } 174 return isValidSender(message.getSender()); 175 } 176 177 /** 178 * Ensure the {@link Person} object has all the required fields. 179 **/ isValidSender(Person person)180 public static boolean isValidSender(Person person) { 181 if (person.getName() == null) { 182 logw(TAG, "Person is missing required field: name"); 183 return false; 184 } 185 return true; 186 } 187 188 /** 189 * Ensure the {@link AvatarIconSync} object has all the required fields. 190 **/ isValidAvatarIconSync(AvatarIconSync iconSync)191 public static boolean isValidAvatarIconSync(AvatarIconSync iconSync) { 192 if (iconSync == null) { 193 logw(TAG, "AvatarIconSync is null"); 194 return false; 195 } else if (iconSync.getMessagingAppPackageName() == null) { 196 logw(TAG, "AvatarIconSync is missing required field: appPackageName"); 197 return false; 198 } else if (iconSync.getPerson().getName() == null) { 199 logw(TAG, "AvatarIconSync is missing required field: Person's name"); 200 return false; 201 } else if (iconSync.getPerson().getAvatar() == null) { 202 logw(TAG, "AvatarIconSync is missing required field: Person's avatar"); 203 return false; 204 } 205 return true; 206 } 207 208 /** 209 * Ensure the BluetoothMapClient intent has all the required fields. 210 **/ isValidMapClientIntent(Intent intent)211 public static boolean isValidMapClientIntent(Intent intent) { 212 if (intent == null) { 213 logw(TAG, "BluetoothMapClient intent is null"); 214 return false; 215 } else if (intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) == null) { 216 logw(TAG, "BluetoothMapClient intent is missing required field: device"); 217 return false; 218 } else if (intent.getStringExtra(BMC_EXTRA_MESSAGE_HANDLE) == null) { 219 logw(TAG, "BluetoothMapClient intent is missing required field: senderName"); 220 return false; 221 } else if (intent.getStringExtra(BMC_EXTRA_SENDER_CONTACT_NAME) == null) { 222 logw(TAG, "BluetoothMapClient intent is missing required field: handle"); 223 return false; 224 } else if (intent.getStringExtra(android.content.Intent.EXTRA_TEXT) == null) { 225 logw(TAG, "BluetoothMapClient intent is missing required field: messageText"); 226 return false; 227 } 228 return true; 229 } 230 231 /** 232 * Creates a Letter Tile Icon that will display the given initials. If the initials are null, 233 * then an avatar anonymous icon will be drawn. 234 **/ createLetterTile(Context context, @Nullable String initials, String identifier, int avatarSize, float cornerRadiusPercent)235 public static Bitmap createLetterTile(Context context, @Nullable String initials, 236 String identifier, int avatarSize, float cornerRadiusPercent) { 237 // TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp. 238 LetterTileDrawable letterTileDrawable = createLetterTileDrawable(context, initials, 239 identifier); 240 RoundedBitmapDrawable roundedBitmapDrawable = RoundedBitmapDrawableFactory.create( 241 context.getResources(), letterTileDrawable.toBitmap(avatarSize)); 242 return createFromRoundedBitmapDrawable(roundedBitmapDrawable, avatarSize, 243 cornerRadiusPercent); 244 } 245 246 /** Creates an Icon based on the given roundedBitmapDrawable. **/ createFromRoundedBitmapDrawable( RoundedBitmapDrawable roundedBitmapDrawable, int avatarSize, float cornerRadiusPercent)247 private static Bitmap createFromRoundedBitmapDrawable( 248 RoundedBitmapDrawable roundedBitmapDrawable, int avatarSize, 249 float cornerRadiusPercent) { 250 // TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp. 251 float radius = avatarSize * cornerRadiusPercent; 252 roundedBitmapDrawable.setCornerRadius(radius); 253 254 final Bitmap result = Bitmap.createBitmap(avatarSize, avatarSize, 255 Bitmap.Config.ARGB_8888); 256 final Canvas canvas = new Canvas(result); 257 roundedBitmapDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); 258 roundedBitmapDrawable.draw(canvas); 259 return roundedBitmapDrawable.getBitmap(); 260 } 261 262 263 /** 264 * Create a {@link LetterTileDrawable} for the given initials. 265 * 266 * @param initials is the letters that will be drawn on the canvas. If it is null, then an 267 * avatar anonymous icon will be drawn 268 * @param identifier will decide the color for the drawable. If null, a default color will be 269 * used. 270 */ createLetterTileDrawable( Context context, @Nullable String initials, @Nullable String identifier)271 private static LetterTileDrawable createLetterTileDrawable( 272 Context context, 273 @Nullable String initials, 274 @Nullable String identifier) { 275 // TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp. 276 int numberOfLetter = context.getResources().getInteger( 277 R.integer.config_number_of_letters_shown_for_avatar); 278 String letters = initials != null 279 ? initials.substring(0, Math.min(initials.length(), numberOfLetter)) : null; 280 LetterTileDrawable letterTileDrawable = new LetterTileDrawable(context.getResources(), 281 letters, identifier); 282 return letterTileDrawable; 283 } 284 285 /** Returns whether the BluetoothMapClient intent represents a group conversation. **/ isGroupConversation(Intent intent)286 public static boolean isGroupConversation(Intent intent) { 287 return (intent.getStringArrayExtra(Intent.EXTRA_CC) != null 288 && intent.getStringArrayExtra(Intent.EXTRA_CC).length > 1); 289 } 290 291 /** 292 * Returns the initials based on the name and nameAlt. 293 * 294 * @param name should be the display name of a contact. 295 * @param nameAlt should be alternative display name of a contact. 296 */ getInitials(String name, String nameAlt)297 public static String getInitials(String name, String nameAlt) { 298 // TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp. 299 StringBuilder initials = new StringBuilder(); 300 if (!TextUtils.isEmpty(name) && Character.isLetter(name.charAt(0))) { 301 initials.append(Character.toUpperCase(name.charAt(0))); 302 } 303 if (!TextUtils.isEmpty(nameAlt) 304 && !TextUtils.equals(name, nameAlt) 305 && Character.isLetter(nameAlt.charAt(0))) { 306 initials.append(Character.toUpperCase(nameAlt.charAt(0))); 307 } 308 return initials.toString(); 309 } 310 311 /** Returns the list of sender uri for a BluetoothMapClient intent. **/ getSenderUri(Intent intent)312 public static String getSenderUri(Intent intent) { 313 return intent.getStringExtra(BMC_EXTRA_SENDER_CONTACT_URI); 314 } 315 316 /** Returns the sender name for a BluetoothMapClient intent. **/ getSenderName(Intent intent)317 public static String getSenderName(Intent intent) { 318 return intent.getStringExtra(BMC_EXTRA_SENDER_CONTACT_NAME); 319 } 320 321 /** Returns the list of recipient uris for a BluetoothMapClient intent. **/ getInclusiveRecipientsUrisList(Intent intent)322 public static List<String> getInclusiveRecipientsUrisList(Intent intent) { 323 List<String> ccUris = new ArrayList<>(); 324 String uri = getSenderUri(intent); 325 if (isGroupConversation(intent)) { 326 ccUris.addAll(Arrays.asList(intent.getStringArrayExtra(Intent.EXTRA_CC))); 327 } 328 if (!ccUris.contains(uri)) { 329 ccUris.add(uri); 330 } 331 332 return ccUris; 333 } 334 335 /** 336 * Extracts the phone number from the BluetoothMapClient contact Uri. 337 **/ 338 @Nullable getPhoneNumberFromMapClient(@ullable String senderContactUri)339 public static String getPhoneNumberFromMapClient(@Nullable String senderContactUri) { 340 if (senderContactUri == null || !senderContactUri.matches(MAP_CLIENT_URI_REGEX)) { 341 logw(TAG, " contactUri is malformed! " + senderContactUri); 342 return null; 343 } 344 345 return senderContactUri.substring(MAP_CLIENT_URI_PHONE_NUMBER_SUBSTRING_INDEX); 346 } 347 348 /** 349 * Creates a Header for a group conversation, where the senderName and groupName are both shown, 350 * separated by a delimiter. 351 * 352 * @param senderName Sender's name. 353 * @param groupName Group conversation's name. 354 * @param delimiter delimiter that separates each element. 355 */ constructGroupConversationHeader(String senderName, String groupName, String delimiter)356 public static String constructGroupConversationHeader(String senderName, String groupName, 357 String delimiter) { 358 return constructGroupConversationHeader(senderName, groupName, delimiter, 359 BidiFormatter.getInstance()); 360 } 361 362 /** 363 * Creates a Header for a group conversation, where the senderName and groupName are both shown, 364 * separated by a delimiter. 365 * 366 * @param senderName Sender's name. 367 * @param groupName Group conversation's name. 368 * @param delimiter delimiter that separates each element. 369 * @param bidiFormatter formatter for the context's locale. 370 */ constructGroupConversationHeader(String senderName, String groupName, String delimiter, BidiFormatter bidiFormatter)371 public static String constructGroupConversationHeader(String senderName, String groupName, 372 String delimiter, BidiFormatter bidiFormatter) { 373 String formattedSenderName = bidiFormatter.unicodeWrap(senderName, 374 TextDirectionHeuristics.FIRSTSTRONG_LTR, /* isolate= */ true); 375 String formattedGroupName = bidiFormatter.unicodeWrap(groupName, 376 TextDirectionHeuristics.FIRSTSTRONG_LTR, /* isolate= */ true); 377 String title = String.join(delimiter, formattedSenderName, formattedGroupName); 378 return bidiFormatter.unicodeWrap(title, TextDirectionHeuristics.LOCALE); 379 } 380 381 /** 382 * Given a name of all the participants in a group conversation (some names might be phone 383 * numbers), this function creates the conversation title by putting the names in alphabetical 384 * order first, then adding any phone numbers. This title should not exceed the 385 * conversationTitleLength, so not all participants' names are guaranteed to be 386 * in the conversation title. 387 */ constructGroupConversationTitle(List<String> names, String delimiter, int conversationTitleLength)388 public static String constructGroupConversationTitle(List<String> names, String delimiter, 389 int conversationTitleLength) { 390 return constructGroupConversationTitle(names, delimiter, conversationTitleLength, 391 BidiFormatter.getInstance()); 392 } 393 394 /** 395 * Given a name of all the participants in a group conversation (some names might be phone 396 * numbers), this function creates the conversation title by putting the names in alphabetical 397 * order first, then adding any phone numbers. This title should not exceed the 398 * conversationTitleLength, so not all participants' names are guaranteed to be 399 * in the conversation title. 400 */ constructGroupConversationTitle(List<String> names, String delimiter, int conversationTitleLength, BidiFormatter bidiFormatter)401 public static String constructGroupConversationTitle(List<String> names, String delimiter, 402 int conversationTitleLength, BidiFormatter bidiFormatter) { 403 List<String> sortedNames = getSortedSubsetNames(names, conversationTitleLength, 404 delimiter.length()); 405 String formattedDelimiter = bidiFormatter.unicodeWrap(delimiter, 406 TextDirectionHeuristics.LOCALE); 407 408 String conversationName = sortedNames.stream().map(name -> bidiFormatter.unicodeWrap(name, 409 TextDirectionHeuristics.FIRSTSTRONG_LTR)) 410 .collect(Collectors.joining(formattedDelimiter)); 411 return bidiFormatter.unicodeWrap(conversationName, TextDirectionHeuristics.LOCALE); 412 } 413 414 /** 415 * Sorts the list, and returns the first elements whose total length is less than the given 416 * conversationTitleLength. 417 */ getSortedSubsetNames(List<String> names, int conversationTitleLength, int delimiterLength)418 private static List<String> getSortedSubsetNames(List<String> names, 419 int conversationTitleLength, 420 int delimiterLength) { 421 Collections.sort(names, Utils.ALPHA_THEN_NUMERIC_COMPARATOR); 422 int namesCounter = 0; 423 int indexCounter = 0; 424 while (namesCounter < conversationTitleLength && indexCounter < names.size()) { 425 namesCounter = namesCounter + names.get(indexCounter).length() + delimiterLength; 426 indexCounter = indexCounter + 1; 427 } 428 return names.subList(0, indexCounter); 429 } 430 431 /** Comparator that sorts names alphabetically first, then phone numbers numerically. **/ 432 public static final Comparator<String> ALPHA_THEN_NUMERIC_COMPARATOR = 433 new Comparator<String>() { 434 private boolean isPhoneNumber(String input) { 435 PhoneNumberUtil util = PhoneNumberUtil.getInstance(); 436 try { 437 Phonenumber.PhoneNumber phoneNumber = util.parse(input, /* defaultRegion */ 438 null); 439 return util.isValidNumber(phoneNumber); 440 } catch (NumberParseException e) { 441 // Phone numbers without country codes should still be classified as 442 // phone numbers. 443 return e.getErrorType().equals( 444 NumberParseException.ErrorType.INVALID_COUNTRY_CODE); 445 } 446 } 447 448 private boolean isOfSameType(String o1, String o2) { 449 boolean isO1PhoneNumber = isPhoneNumber(o1); 450 boolean isO2PhoneNumber = isPhoneNumber(o2); 451 return isO1PhoneNumber == isO2PhoneNumber; 452 } 453 454 @Override 455 public int compare(String o1, String o2) { 456 // if both are names, sort based on names. 457 // if both are number, sort numerically. 458 // if one is phone number and the other is a name, give name precedence. 459 if (!isOfSameType(o1, o2)) { 460 return isPhoneNumber(o1) ? 1 : -1; 461 } else { 462 return o1.compareTo(o2); 463 } 464 } 465 }; 466 467 } 468