• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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