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.TextUtils; 27 28 import androidx.annotation.Nullable; 29 import androidx.core.graphics.drawable.RoundedBitmapDrawable; 30 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; 31 32 import com.android.car.apps.common.LetterTileDrawable; 33 import com.android.car.messenger.NotificationMsgProto.NotificationMsg; 34 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.AvatarIconSync; 35 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.ConversationNotification; 36 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyle; 37 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyleMessage; 38 import com.android.car.messenger.NotificationMsgProto.NotificationMsg.Person; 39 40 import com.google.i18n.phonenumbers.NumberParseException; 41 import com.google.i18n.phonenumbers.PhoneNumberUtil; 42 import com.google.i18n.phonenumbers.Phonenumber; 43 44 import java.util.ArrayList; 45 import java.util.Arrays; 46 import java.util.Collections; 47 import java.util.Comparator; 48 import java.util.List; 49 50 /** Utils methods for the car-messenger-common lib. **/ 51 public class Utils { 52 private static final String TAG = "CMC.Utils"; 53 /** 54 * Represents the maximum length of a message substring to be used when constructing the 55 * message's unique handle/key. 56 */ 57 private static final int MAX_SUB_MESSAGE_LENGTH = 5; 58 59 /** The Regex format of a telephone number in a BluetoothMapClient contact URI. **/ 60 private static final String MAP_CLIENT_URI_REGEX = "tel:(.+)"; 61 62 /** The starting substring index for a string formatted with the MAP_CLIENT_URI_REGEX above. **/ 63 private static final int MAP_CLIENT_URI_PHONE_NUMBER_SUBSTRING_INDEX = 4; 64 65 // TODO (150711637): Reference BluetoothMapClient Extras once BluetoothMapClient is SystemApi. 66 protected static final String BMC_EXTRA_MESSAGE_HANDLE = 67 "android.bluetooth.mapmce.profile.extra.MESSAGE_HANDLE"; 68 protected static final String BMC_EXTRA_SENDER_CONTACT_URI = 69 "android.bluetooth.mapmce.profile.extra.SENDER_CONTACT_URI"; 70 protected static final String BMC_EXTRA_SENDER_CONTACT_NAME = 71 "android.bluetooth.mapmce.profile.extra.SENDER_CONTACT_NAME"; 72 protected static final String BMC_EXTRA_MESSAGE_TIMESTAMP = 73 "android.bluetooth.mapmce.profile.extra.MESSAGE_TIMESTAMP"; 74 protected static final String BMC_EXTRA_MESSAGE_READ_STATUS = 75 "android.bluetooth.mapmce.profile.extra.MESSAGE_READ_STATUS"; 76 77 /** Gets the latest message for a {@link NotificationMsg} Conversation. **/ getLatestMessage( ConversationNotification notification)78 public static MessagingStyleMessage getLatestMessage( 79 ConversationNotification notification) { 80 MessagingStyle messagingStyle = notification.getMessagingStyle(); 81 long latestTime = 0; 82 MessagingStyleMessage latestMessage = null; 83 84 for (MessagingStyleMessage message : messagingStyle.getMessagingStyleMsgList()) { 85 if (message.getTimestamp() > latestTime) { 86 latestTime = message.getTimestamp(); 87 latestMessage = message; 88 } 89 } 90 return latestMessage; 91 } 92 93 /** 94 * Helper method to create a unique handle/key for this message. This is used as this Message's 95 * {@link MessageKey#getSubKey()}. 96 */ createMessageHandle(MessagingStyleMessage message)97 public static String createMessageHandle(MessagingStyleMessage message) { 98 String textMessage = message.getTextMessage(); 99 String subMessage = textMessage.substring( 100 Math.min(MAX_SUB_MESSAGE_LENGTH, textMessage.length())); 101 return message.getTimestamp() + "/" + message.getSender().getName() + "/" + subMessage; 102 } 103 104 /** 105 * Ensure the {@link ConversationNotification} object has all the required fields. 106 * 107 * @param isShallowCheck should be {@code true} if the caller only wants to verify the 108 * notification and its {@link MessagingStyle} is valid, without checking 109 * all of the notification's {@link MessagingStyleMessage}s. 110 **/ isValidConversationNotification(ConversationNotification notification, boolean isShallowCheck)111 public static boolean isValidConversationNotification(ConversationNotification notification, 112 boolean isShallowCheck) { 113 if (notification == null) { 114 logw(TAG, "ConversationNotification is null"); 115 return false; 116 } else if (!notification.hasMessagingStyle()) { 117 logw(TAG, "ConversationNotification is missing required field: messagingStyle"); 118 return false; 119 } else if (notification.getMessagingAppDisplayName() == null) { 120 logw(TAG, "ConversationNotification is missing required field: appDisplayName"); 121 return false; 122 } else if (notification.getMessagingAppPackageName() == null) { 123 logw(TAG, "ConversationNotification is missing required field: appPackageName"); 124 return false; 125 } 126 return isValidMessagingStyle(notification.getMessagingStyle(), isShallowCheck); 127 } 128 129 /** 130 * Ensure the {@link MessagingStyle} object has all the required fields. 131 **/ isValidMessagingStyle(MessagingStyle messagingStyle, boolean isShallowCheck)132 private static boolean isValidMessagingStyle(MessagingStyle messagingStyle, 133 boolean isShallowCheck) { 134 if (messagingStyle == null) { 135 logw(TAG, "MessagingStyle is null"); 136 return false; 137 } else if (messagingStyle.getConvoTitle() == null) { 138 logw(TAG, "MessagingStyle is missing required field: convoTitle"); 139 return false; 140 } else if (messagingStyle.getUserDisplayName() == null) { 141 logw(TAG, "MessagingStyle is missing required field: userDisplayName"); 142 return false; 143 } else if (messagingStyle.getMessagingStyleMsgCount() == 0) { 144 logw(TAG, "MessagingStyle is missing required field: messagingStyleMsg"); 145 return false; 146 } 147 if (!isShallowCheck) { 148 for (MessagingStyleMessage message : messagingStyle.getMessagingStyleMsgList()) { 149 if (!isValidMessagingStyleMessage(message)) { 150 return false; 151 } 152 } 153 } 154 return true; 155 } 156 157 /** 158 * Ensure the {@link MessagingStyleMessage} object has all the required fields. 159 **/ isValidMessagingStyleMessage(MessagingStyleMessage message)160 public static boolean isValidMessagingStyleMessage(MessagingStyleMessage message) { 161 if (message == null) { 162 logw(TAG, "MessagingStyleMessage is null"); 163 return false; 164 } else if (message.getTextMessage() == null) { 165 logw(TAG, "MessagingStyleMessage is missing required field: textMessage"); 166 return false; 167 } else if (!message.hasSender()) { 168 logw(TAG, "MessagingStyleMessage is missing required field: sender"); 169 return false; 170 } 171 return isValidSender(message.getSender()); 172 } 173 174 /** 175 * Ensure the {@link Person} object has all the required fields. 176 **/ isValidSender(Person person)177 public static boolean isValidSender(Person person) { 178 if (person.getName() == null) { 179 logw(TAG, "Person is missing required field: name"); 180 return false; 181 } 182 return true; 183 } 184 185 /** 186 * Ensure the {@link AvatarIconSync} object has all the required fields. 187 **/ isValidAvatarIconSync(AvatarIconSync iconSync)188 public static boolean isValidAvatarIconSync(AvatarIconSync iconSync) { 189 if (iconSync == null) { 190 logw(TAG, "AvatarIconSync is null"); 191 return false; 192 } else if (iconSync.getMessagingAppPackageName() == null) { 193 logw(TAG, "AvatarIconSync is missing required field: appPackageName"); 194 return false; 195 } else if (iconSync.getPerson().getName() == null) { 196 logw(TAG, "AvatarIconSync is missing required field: Person's name"); 197 return false; 198 } else if (iconSync.getPerson().getAvatar() == null) { 199 logw(TAG, "AvatarIconSync is missing required field: Person's avatar"); 200 return false; 201 } 202 return true; 203 } 204 205 /** 206 * Ensure the BluetoothMapClient intent has all the required fields. 207 **/ isValidMapClientIntent(Intent intent)208 public static boolean isValidMapClientIntent(Intent intent) { 209 if (intent == null) { 210 logw(TAG, "BluetoothMapClient intent is null"); 211 return false; 212 } else if (intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) == null) { 213 logw(TAG, "BluetoothMapClient intent is missing required field: device"); 214 return false; 215 } else if (intent.getStringExtra(BMC_EXTRA_MESSAGE_HANDLE) == null) { 216 logw(TAG, "BluetoothMapClient intent is missing required field: senderName"); 217 return false; 218 } else if (intent.getStringExtra(BMC_EXTRA_SENDER_CONTACT_NAME) == null) { 219 logw(TAG, "BluetoothMapClient intent is missing required field: handle"); 220 return false; 221 } else if (intent.getStringExtra(android.content.Intent.EXTRA_TEXT) == null) { 222 logw(TAG, "BluetoothMapClient intent is missing required field: messageText"); 223 return false; 224 } 225 return true; 226 } 227 228 /** 229 * Creates a Letter Tile Icon that will display the given initials. If the initials are null, 230 * then an avatar anonymous icon will be drawn. 231 **/ createLetterTile(Context context, @Nullable String initials, String identifier, int avatarSize, float cornerRadiusPercent)232 public static Bitmap createLetterTile(Context context, @Nullable String initials, 233 String identifier, int avatarSize, float cornerRadiusPercent) { 234 // TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp. 235 LetterTileDrawable letterTileDrawable = createLetterTileDrawable(context, initials, 236 identifier); 237 RoundedBitmapDrawable roundedBitmapDrawable = RoundedBitmapDrawableFactory.create( 238 context.getResources(), letterTileDrawable.toBitmap(avatarSize)); 239 return createFromRoundedBitmapDrawable(roundedBitmapDrawable, avatarSize, 240 cornerRadiusPercent); 241 } 242 243 /** Creates an Icon based on the given roundedBitmapDrawable. **/ createFromRoundedBitmapDrawable( RoundedBitmapDrawable roundedBitmapDrawable, int avatarSize, float cornerRadiusPercent)244 private static Bitmap createFromRoundedBitmapDrawable( 245 RoundedBitmapDrawable roundedBitmapDrawable, int avatarSize, 246 float cornerRadiusPercent) { 247 // TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp. 248 float radius = avatarSize * cornerRadiusPercent; 249 roundedBitmapDrawable.setCornerRadius(radius); 250 251 final Bitmap result = Bitmap.createBitmap(avatarSize, avatarSize, 252 Bitmap.Config.ARGB_8888); 253 final Canvas canvas = new Canvas(result); 254 roundedBitmapDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); 255 roundedBitmapDrawable.draw(canvas); 256 return roundedBitmapDrawable.getBitmap(); 257 } 258 259 260 /** 261 * Create a {@link LetterTileDrawable} for the given initials. 262 * 263 * @param initials is the letters that will be drawn on the canvas. If it is null, then an 264 * avatar anonymous icon will be drawn 265 * @param identifier will decide the color for the drawable. If null, a default color will be 266 * used. 267 */ createLetterTileDrawable( Context context, @Nullable String initials, @Nullable String identifier)268 private static LetterTileDrawable createLetterTileDrawable( 269 Context context, 270 @Nullable String initials, 271 @Nullable String identifier) { 272 // TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp. 273 int numberOfLetter = context.getResources().getInteger( 274 R.integer.config_number_of_letters_shown_for_avatar); 275 String letters = initials != null 276 ? initials.substring(0, Math.min(initials.length(), numberOfLetter)) : null; 277 LetterTileDrawable letterTileDrawable = new LetterTileDrawable(context.getResources(), 278 letters, identifier); 279 return letterTileDrawable; 280 } 281 282 /** Returns whether the BluetoothMapClient intent represents a group conversation. **/ isGroupConversation(Intent intent)283 public static boolean isGroupConversation(Intent intent) { 284 return (intent.getStringArrayExtra(Intent.EXTRA_CC) != null 285 && intent.getStringArrayExtra(Intent.EXTRA_CC).length > 0); 286 } 287 288 /** 289 * Returns the initials based on the name and nameAlt. 290 * 291 * @param name should be the display name of a contact. 292 * @param nameAlt should be alternative display name of a contact. 293 */ getInitials(String name, String nameAlt)294 public static String getInitials(String name, String nameAlt) { 295 // TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp. 296 StringBuilder initials = new StringBuilder(); 297 if (!TextUtils.isEmpty(name) && Character.isLetter(name.charAt(0))) { 298 initials.append(Character.toUpperCase(name.charAt(0))); 299 } 300 if (!TextUtils.isEmpty(nameAlt) 301 && !TextUtils.equals(name, nameAlt) 302 && Character.isLetter(nameAlt.charAt(0))) { 303 initials.append(Character.toUpperCase(nameAlt.charAt(0))); 304 } 305 return initials.toString(); 306 } 307 308 /** Returns the list of sender uri for a BluetoothMapClient intent. **/ getSenderUri(Intent intent)309 public static String getSenderUri(Intent intent) { 310 return intent.getStringExtra(BMC_EXTRA_SENDER_CONTACT_URI); 311 } 312 313 /** Returns the sender name for a BluetoothMapClient intent. **/ getSenderName(Intent intent)314 public static String getSenderName(Intent intent) { 315 return intent.getStringExtra(BMC_EXTRA_SENDER_CONTACT_NAME); 316 } 317 318 /** Returns the list of recipient uris for a BluetoothMapClient intent. **/ getInclusiveRecipientsUrisList(Intent intent)319 public static List<String> getInclusiveRecipientsUrisList(Intent intent) { 320 List<String> ccUris = new ArrayList<>(); 321 ccUris.add(getSenderUri(intent)); 322 if (isGroupConversation(intent)) { 323 ccUris.addAll(Arrays.asList(intent.getStringArrayExtra(Intent.EXTRA_CC))); 324 Collections.sort(ccUris); 325 } 326 return ccUris; 327 } 328 329 /** 330 * Extracts the phone number from the BluetoothMapClient contact Uri. 331 **/ 332 @Nullable getPhoneNumberFromMapClient(@ullable String senderContactUri)333 public static String getPhoneNumberFromMapClient(@Nullable String senderContactUri) { 334 if (senderContactUri == null || !senderContactUri.matches(MAP_CLIENT_URI_REGEX)) { 335 return null; 336 } 337 338 return senderContactUri.substring(MAP_CLIENT_URI_PHONE_NUMBER_SUBSTRING_INDEX); 339 } 340 341 /** Comparator that sorts names alphabetically first, then phone numbers numerically. **/ 342 public static final Comparator<String> ALPHA_THEN_NUMERIC_COMPARATOR = 343 new Comparator<String>() { 344 private boolean isPhoneNumber(String input) { 345 PhoneNumberUtil util = PhoneNumberUtil.getInstance(); 346 try { 347 Phonenumber.PhoneNumber phoneNumber = util.parse(input, /* defaultRegion */ 348 null); 349 return util.isValidNumber(phoneNumber); 350 } catch (NumberParseException e) { 351 return false; 352 } 353 } 354 355 private boolean isOfSameType(String o1, String o2) { 356 boolean isO1PhoneNumber = isPhoneNumber(o1); 357 boolean isO2PhoneNumber = isPhoneNumber(o2); 358 return isO1PhoneNumber == isO2PhoneNumber; 359 } 360 361 @Override 362 public int compare(String o1, String o2) { 363 // if both are names, sort based on names. 364 // if both are number, sort numerically. 365 // if one is phone number and the other is a name, give name precedence. 366 if (!isOfSameType(o1, o2)) { 367 return isPhoneNumber(o1) ? 1 : -1; 368 } else { 369 return o1.compareTo(o2); 370 } 371 } 372 }; 373 } 374