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