blob: c52cc567cdf944c7f5fbdb847f700b21995dd567 [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.messenger.common;
import static com.android.car.apps.common.util.SafeLog.logw;
import android.bluetooth.BluetoothDevice;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.text.BidiFormatter;
import android.text.TextDirectionHeuristics;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
import com.android.car.apps.common.LetterTileDrawable;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.AvatarIconSync;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.ConversationNotification;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyle;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.MessagingStyleMessage;
import com.android.car.messenger.NotificationMsgProto.NotificationMsg.Person;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/** Utils methods for the car-messenger-common lib. **/
public class Utils {
private static final String TAG = "CMC.Utils";
/**
* Represents the maximum length of a message substring to be used when constructing the
* message's unique handle/key.
*/
private static final int MAX_SUB_MESSAGE_LENGTH = 5;
/** The Regex format of a telephone number in a BluetoothMapClient contact URI. **/
private static final String MAP_CLIENT_URI_REGEX = "tel:(.+)";
/** The starting substring index for a string formatted with the MAP_CLIENT_URI_REGEX above. **/
private static final int MAP_CLIENT_URI_PHONE_NUMBER_SUBSTRING_INDEX = 4;
// TODO (150711637): Reference BluetoothMapClient Extras once BluetoothMapClient is SystemApi.
protected static final String BMC_EXTRA_MESSAGE_HANDLE =
"android.bluetooth.mapmce.profile.extra.MESSAGE_HANDLE";
protected static final String BMC_EXTRA_SENDER_CONTACT_URI =
"android.bluetooth.mapmce.profile.extra.SENDER_CONTACT_URI";
protected static final String BMC_EXTRA_SENDER_CONTACT_NAME =
"android.bluetooth.mapmce.profile.extra.SENDER_CONTACT_NAME";
protected static final String BMC_EXTRA_MESSAGE_TIMESTAMP =
"android.bluetooth.mapmce.profile.extra.MESSAGE_TIMESTAMP";
protected static final String BMC_EXTRA_MESSAGE_READ_STATUS =
"android.bluetooth.mapmce.profile.extra.MESSAGE_READ_STATUS";
/** Gets the latest message for a {@link NotificationMsg} Conversation. **/
public static MessagingStyleMessage getLatestMessage(
ConversationNotification notification) {
MessagingStyle messagingStyle = notification.getMessagingStyle();
long latestTime = 0;
MessagingStyleMessage latestMessage = null;
for (MessagingStyleMessage message : messagingStyle.getMessagingStyleMsgList()) {
if (message.getTimestamp() > latestTime) {
latestTime = message.getTimestamp();
latestMessage = message;
}
}
return latestMessage;
}
/**
* Helper method to create a unique handle/key for this message. This is used as this Message's
* {@link MessageKey#getSubKey()}.
*/
public static String createMessageHandle(MessagingStyleMessage message) {
String textMessage = message.getTextMessage();
String subMessage = textMessage.substring(
Math.min(MAX_SUB_MESSAGE_LENGTH, textMessage.length()));
return message.getTimestamp() + "/" + message.getSender().getName() + "/" + subMessage;
}
/**
* Ensure the {@link ConversationNotification} object has all the required fields.
*
* @param isShallowCheck should be {@code true} if the caller only wants to verify the
* notification and its {@link MessagingStyle} is valid, without checking
* all of the notification's {@link MessagingStyleMessage}s.
**/
public static boolean isValidConversationNotification(ConversationNotification notification,
boolean isShallowCheck) {
if (notification == null) {
logw(TAG, "ConversationNotification is null");
return false;
} else if (!notification.hasMessagingStyle()) {
logw(TAG, "ConversationNotification is missing required field: messagingStyle");
return false;
} else if (notification.getMessagingAppDisplayName() == null) {
logw(TAG, "ConversationNotification is missing required field: appDisplayName");
return false;
} else if (notification.getMessagingAppPackageName() == null) {
logw(TAG, "ConversationNotification is missing required field: appPackageName");
return false;
}
return isValidMessagingStyle(notification.getMessagingStyle(), isShallowCheck);
}
/**
* Ensure the {@link MessagingStyle} object has all the required fields.
**/
private static boolean isValidMessagingStyle(MessagingStyle messagingStyle,
boolean isShallowCheck) {
if (messagingStyle == null) {
logw(TAG, "MessagingStyle is null");
return false;
} else if (messagingStyle.getConvoTitle() == null) {
logw(TAG, "MessagingStyle is missing required field: convoTitle");
return false;
} else if (messagingStyle.getUserDisplayName() == null) {
logw(TAG, "MessagingStyle is missing required field: userDisplayName");
return false;
} else if (messagingStyle.getMessagingStyleMsgCount() == 0) {
logw(TAG, "MessagingStyle is missing required field: messagingStyleMsg");
return false;
}
if (!isShallowCheck) {
for (MessagingStyleMessage message : messagingStyle.getMessagingStyleMsgList()) {
if (!isValidMessagingStyleMessage(message)) {
return false;
}
}
}
return true;
}
/**
* Ensure the {@link MessagingStyleMessage} object has all the required fields.
**/
public static boolean isValidMessagingStyleMessage(MessagingStyleMessage message) {
if (message == null) {
logw(TAG, "MessagingStyleMessage is null");
return false;
} else if (message.getTextMessage() == null) {
logw(TAG, "MessagingStyleMessage is missing required field: textMessage");
return false;
} else if (!message.hasSender()) {
logw(TAG, "MessagingStyleMessage is missing required field: sender");
return false;
}
return isValidSender(message.getSender());
}
/**
* Ensure the {@link Person} object has all the required fields.
**/
public static boolean isValidSender(Person person) {
if (person.getName() == null) {
logw(TAG, "Person is missing required field: name");
return false;
}
return true;
}
/**
* Ensure the {@link AvatarIconSync} object has all the required fields.
**/
public static boolean isValidAvatarIconSync(AvatarIconSync iconSync) {
if (iconSync == null) {
logw(TAG, "AvatarIconSync is null");
return false;
} else if (iconSync.getMessagingAppPackageName() == null) {
logw(TAG, "AvatarIconSync is missing required field: appPackageName");
return false;
} else if (iconSync.getPerson().getName() == null) {
logw(TAG, "AvatarIconSync is missing required field: Person's name");
return false;
} else if (iconSync.getPerson().getAvatar() == null) {
logw(TAG, "AvatarIconSync is missing required field: Person's avatar");
return false;
}
return true;
}
/**
* Ensure the BluetoothMapClient intent has all the required fields.
**/
public static boolean isValidMapClientIntent(Intent intent) {
if (intent == null) {
logw(TAG, "BluetoothMapClient intent is null");
return false;
} else if (intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) == null) {
logw(TAG, "BluetoothMapClient intent is missing required field: device");
return false;
} else if (intent.getStringExtra(BMC_EXTRA_MESSAGE_HANDLE) == null) {
logw(TAG, "BluetoothMapClient intent is missing required field: senderName");
return false;
} else if (intent.getStringExtra(BMC_EXTRA_SENDER_CONTACT_NAME) == null) {
logw(TAG, "BluetoothMapClient intent is missing required field: handle");
return false;
} else if (intent.getStringExtra(android.content.Intent.EXTRA_TEXT) == null) {
logw(TAG, "BluetoothMapClient intent is missing required field: messageText");
return false;
}
return true;
}
/**
* Creates a Letter Tile Icon that will display the given initials. If the initials are null,
* then an avatar anonymous icon will be drawn.
**/
public static Bitmap createLetterTile(Context context, @Nullable String initials,
String identifier, int avatarSize, float cornerRadiusPercent) {
// TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp.
LetterTileDrawable letterTileDrawable = createLetterTileDrawable(context, initials,
identifier);
RoundedBitmapDrawable roundedBitmapDrawable = RoundedBitmapDrawableFactory.create(
context.getResources(), letterTileDrawable.toBitmap(avatarSize));
return createFromRoundedBitmapDrawable(roundedBitmapDrawable, avatarSize,
cornerRadiusPercent);
}
/** Creates an Icon based on the given roundedBitmapDrawable. **/
private static Bitmap createFromRoundedBitmapDrawable(
RoundedBitmapDrawable roundedBitmapDrawable, int avatarSize,
float cornerRadiusPercent) {
// TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp.
float radius = avatarSize * cornerRadiusPercent;
roundedBitmapDrawable.setCornerRadius(radius);
final Bitmap result = Bitmap.createBitmap(avatarSize, avatarSize,
Bitmap.Config.ARGB_8888);
final Canvas canvas = new Canvas(result);
roundedBitmapDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
roundedBitmapDrawable.draw(canvas);
return roundedBitmapDrawable.getBitmap();
}
/**
* Create a {@link LetterTileDrawable} for the given initials.
*
* @param initials is the letters that will be drawn on the canvas. If it is null, then an
* avatar anonymous icon will be drawn
* @param identifier will decide the color for the drawable. If null, a default color will be
* used.
*/
private static LetterTileDrawable createLetterTileDrawable(
Context context,
@Nullable String initials,
@Nullable String identifier) {
// TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp.
int numberOfLetter = context.getResources().getInteger(
R.integer.config_number_of_letters_shown_for_avatar);
String letters = initials != null
? initials.substring(0, Math.min(initials.length(), numberOfLetter)) : null;
LetterTileDrawable letterTileDrawable = new LetterTileDrawable(context.getResources(),
letters, identifier);
return letterTileDrawable;
}
/** Returns whether the BluetoothMapClient intent represents a group conversation. **/
public static boolean isGroupConversation(Intent intent) {
return (intent.getStringArrayExtra(Intent.EXTRA_CC) != null
&& intent.getStringArrayExtra(Intent.EXTRA_CC).length > 1);
}
/**
* Returns the initials based on the name and nameAlt.
*
* @param name should be the display name of a contact.
* @param nameAlt should be alternative display name of a contact.
*/
public static String getInitials(String name, String nameAlt) {
// TODO(b/135446418): use TelecomUtils once car-telephony-common supports bp.
StringBuilder initials = new StringBuilder();
if (!TextUtils.isEmpty(name) && Character.isLetter(name.charAt(0))) {
initials.append(Character.toUpperCase(name.charAt(0)));
}
if (!TextUtils.isEmpty(nameAlt)
&& !TextUtils.equals(name, nameAlt)
&& Character.isLetter(nameAlt.charAt(0))) {
initials.append(Character.toUpperCase(nameAlt.charAt(0)));
}
return initials.toString();
}
/** Returns the list of sender uri for a BluetoothMapClient intent. **/
public static String getSenderUri(Intent intent) {
return intent.getStringExtra(BMC_EXTRA_SENDER_CONTACT_URI);
}
/** Returns the sender name for a BluetoothMapClient intent. **/
public static String getSenderName(Intent intent) {
return intent.getStringExtra(BMC_EXTRA_SENDER_CONTACT_NAME);
}
/** Returns the list of recipient uris for a BluetoothMapClient intent. **/
public static List<String> getInclusiveRecipientsUrisList(Intent intent) {
List<String> ccUris = new ArrayList<>();
String uri = getSenderUri(intent);
if (isGroupConversation(intent)) {
ccUris.addAll(Arrays.asList(intent.getStringArrayExtra(Intent.EXTRA_CC)));
}
if (!ccUris.contains(uri)) {
ccUris.add(uri);
}
// TODO (b/169183358): remove sorting.
Collections.sort(ccUris);
return ccUris;
}
/**
* Extracts the phone number from the BluetoothMapClient contact Uri.
**/
@Nullable
public static String getPhoneNumberFromMapClient(@Nullable String senderContactUri) {
if (senderContactUri == null || !senderContactUri.matches(MAP_CLIENT_URI_REGEX)) {
logw(TAG, " contactUri is malformed! " + senderContactUri);
return null;
}
return senderContactUri.substring(MAP_CLIENT_URI_PHONE_NUMBER_SUBSTRING_INDEX);
}
/**
* Creates a Header for a group conversation, where the senderName and groupName are both shown,
* separated by a delimiter.
*
* @param senderName Sender's name.
* @param groupName Group conversation's name.
* @param delimiter delimiter that separates each element.
*/
public static String constructGroupConversationHeader(String senderName, String groupName,
String delimiter) {
return constructGroupConversationHeader(senderName, groupName, delimiter,
BidiFormatter.getInstance());
}
/**
* Creates a Header for a group conversation, where the senderName and groupName are both shown,
* separated by a delimiter.
*
* @param senderName Sender's name.
* @param groupName Group conversation's name.
* @param delimiter delimiter that separates each element.
* @param bidiFormatter formatter for the context's locale.
*/
public static String constructGroupConversationHeader(String senderName, String groupName,
String delimiter, BidiFormatter bidiFormatter) {
String formattedSenderName = bidiFormatter.unicodeWrap(senderName,
TextDirectionHeuristics.FIRSTSTRONG_LTR, /* isolate= */ true);
String formattedGroupName = bidiFormatter.unicodeWrap(groupName,
TextDirectionHeuristics.FIRSTSTRONG_LTR, /* isolate= */ true);
String title = String.join(delimiter, formattedSenderName, formattedGroupName);
return bidiFormatter.unicodeWrap(title, TextDirectionHeuristics.LOCALE);
}
/**
* Given a name of all the participants in a group conversation (some names might be phone
* numbers), this function creates the conversation title by putting the names in alphabetical
* order first, then adding any phone numbers. This title should not exceed the
* conversationTitleLength, so not all participants' names are guaranteed to be
* in the conversation title.
*/
public static String constructGroupConversationTitle(List<String> names, String delimiter,
int conversationTitleLength) {
return constructGroupConversationTitle(names, delimiter, conversationTitleLength,
BidiFormatter.getInstance());
}
/**
* Given a name of all the participants in a group conversation (some names might be phone
* numbers), this function creates the conversation title by putting the names in alphabetical
* order first, then adding any phone numbers. This title should not exceed the
* conversationTitleLength, so not all participants' names are guaranteed to be
* in the conversation title.
*/
public static String constructGroupConversationTitle(List<String> names, String delimiter,
int conversationTitleLength, BidiFormatter bidiFormatter) {
List<String> sortedNames = getSortedSubsetNames(names, conversationTitleLength,
delimiter.length());
String formattedDelimiter = bidiFormatter.unicodeWrap(delimiter,
TextDirectionHeuristics.LOCALE);
String conversationName = sortedNames.stream().map(name -> bidiFormatter.unicodeWrap(name,
TextDirectionHeuristics.FIRSTSTRONG_LTR))
.collect(Collectors.joining(formattedDelimiter));
return bidiFormatter.unicodeWrap(conversationName, TextDirectionHeuristics.LOCALE);
}
/**
* Sorts the list, and returns the first elements whose total length is less than the given
* conversationTitleLength.
*/
private static List<String> getSortedSubsetNames(List<String> names,
int conversationTitleLength,
int delimiterLength) {
Collections.sort(names, Utils.ALPHA_THEN_NUMERIC_COMPARATOR);
int namesCounter = 0;
int indexCounter = 0;
while (namesCounter < conversationTitleLength && indexCounter < names.size()) {
namesCounter = namesCounter + names.get(indexCounter).length() + delimiterLength;
indexCounter = indexCounter + 1;
}
return names.subList(0, indexCounter);
}
/** Comparator that sorts names alphabetically first, then phone numbers numerically. **/
public static final Comparator<String> ALPHA_THEN_NUMERIC_COMPARATOR =
new Comparator<String>() {
private boolean isPhoneNumber(String input) {
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
try {
Phonenumber.PhoneNumber phoneNumber = util.parse(input, /* defaultRegion */
null);
return util.isValidNumber(phoneNumber);
} catch (NumberParseException e) {
return false;
}
}
private boolean isOfSameType(String o1, String o2) {
boolean isO1PhoneNumber = isPhoneNumber(o1);
boolean isO2PhoneNumber = isPhoneNumber(o2);
return isO1PhoneNumber == isO2PhoneNumber;
}
@Override
public int compare(String o1, String o2) {
// if both are names, sort based on names.
// if both are number, sort numerically.
// if one is phone number and the other is a name, give name precedence.
if (!isOfSameType(o1, o2)) {
return isPhoneNumber(o1) ? 1 : -1;
} else {
return o1.compareTo(o2);
}
}
};
}