blob: 8f7f8bab34553a7c5a0aae9a051cf0a364ca4789 [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.telephony.common;
import android.Manifest;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.Icon;
import android.net.Uri;
import android.provider.CallLog;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.PhoneLookup;
import android.provider.Settings;
import android.telecom.Call;
import android.telephony.PhoneNumberUtils;
import android.telephony.TelephonyManager;
import android.text.BidiFormatter;
import android.text.TextDirectionHeuristics;
import android.text.TextUtils;
import android.widget.ImageView;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
import com.android.car.apps.common.LetterTileDrawable;
import com.android.car.apps.common.log.L;
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;
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.List;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
/**
* Helper methods.
*/
public class TelecomUtils {
private static final String TAG = "CD.TelecomUtils";
private static final int PII_STRING_LENGTH = 4;
private static final String COUNTRY_US = "US";
/**
* A reference to keep track of the soring method of sorting by the contact's first name.
*/
public static final Integer SORT_BY_FIRST_NAME = 1;
/**
* A reference to keep track of the soring method of sorting by the contact's last name.
*/
public static final Integer SORT_BY_LAST_NAME = 2;
private static String sVoicemailNumber;
private static TelephonyManager sTelephonyManager;
/**
* Get the voicemail number.
*/
public static String getVoicemailNumber(Context context) {
if (sVoicemailNumber == null) {
sVoicemailNumber = getTelephonyManager(context).getVoiceMailNumber();
}
return sVoicemailNumber;
}
/**
* Returns {@code true} if the given number is a voice mail number.
*
* @see TelephonyManager#getVoiceMailNumber()
*/
public static boolean isVoicemailNumber(Context context, String number) {
if (TextUtils.isEmpty(number)) {
return false;
}
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE)
!= PackageManager.PERMISSION_GRANTED) {
return false;
}
return number.equals(getVoicemailNumber(context));
}
/**
* Get the {@link TelephonyManager} instance.
*/
// TODO(deanh): remove this, getSystemService is not slow.
public static TelephonyManager getTelephonyManager(Context context) {
if (sTelephonyManager == null) {
sTelephonyManager =
(TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
}
return sTelephonyManager;
}
/**
* Format a number as a phone number.
*/
public static String getFormattedNumber(Context context, String number) {
L.d(TAG, "getFormattedNumber: " + piiLog(number));
if (number == null) {
return "";
}
String countryIso = getCurrentCountryIsoFromLocale(context);
L.d(TAG, "PhoneNumberUtils.formatNumber, number: " + piiLog(number)
+ ", country: " + countryIso);
String formattedNumber = PhoneNumberUtils.formatNumber(number, countryIso);
formattedNumber = TextUtils.isEmpty(formattedNumber) ? number : formattedNumber;
L.d(TAG, "getFormattedNumber, result: " + piiLog(formattedNumber));
return formattedNumber;
}
/**
* @return The ISO 3166-1 two letters country code of the country the user is in.
*/
private static String getCurrentCountryIso(Context context, Locale locale) {
String countryIso = locale.getCountry();
if (countryIso == null || countryIso.length() != 2) {
L.w(TAG, "Invalid locale, falling back to US");
countryIso = COUNTRY_US;
}
return countryIso;
}
private static String getCurrentCountryIso(Context context) {
return getCurrentCountryIso(context, Locale.getDefault());
}
private static String getCurrentCountryIsoFromLocale(Context context) {
String countryIso;
countryIso = context.getResources().getConfiguration().getLocales().get(0).getCountry();
if (countryIso == null) {
L.w(TAG, "Invalid locale, falling back to US");
countryIso = COUNTRY_US;
}
return countryIso;
}
/**
* Creates a new instance of {@link Phonenumber.PhoneNumber} base on the given number and sim
* card country code. Returns {@code null} if the number in an invalid number.
*/
@Nullable
public static Phonenumber.PhoneNumber createI18nPhoneNumber(Context context, String number) {
try {
return PhoneNumberUtil.getInstance().parse(number, getCurrentCountryIso(context));
} catch (NumberParseException e) {
return null;
}
}
/**
* Contains all the info used to display a phone number on the screen. Returned by {@link
* #getPhoneNumberInfo(Context, String)}
*/
public static final class PhoneNumberInfo {
private final String mPhoneNumber;
private final String mDisplayName;
private final String mInitials;
private final Uri mAvatarUri;
private final String mTypeLabel;
public PhoneNumberInfo(String phoneNumber, String displayName,
String initials, Uri avatarUri, String typeLabel) {
mPhoneNumber = phoneNumber;
mDisplayName = displayName;
mInitials = initials;
mAvatarUri = avatarUri;
mTypeLabel = typeLabel;
}
public String getPhoneNumber() {
return mPhoneNumber;
}
public String getDisplayName() {
return mDisplayName;
}
/**
* Returns the initials of the contact related to the phone number. Returns null if there is
* no related contact.
*/
@Nullable
public String getInitials() {
return mInitials;
}
@Nullable
public Uri getAvatarUri() {
return mAvatarUri;
}
public String getTypeLabel() {
return mTypeLabel;
}
}
/**
* Gets all the info needed to properly display a phone number to the UI. (e.g. if it's the
* voicemail number, return a string and a uri that represents voicemail, if it's a contact, get
* the contact's name, its avatar uri, the phone number's label, etc).
*/
public static CompletableFuture<PhoneNumberInfo> getPhoneNumberInfo(
Context context, String number) {
if (TextUtils.isEmpty(number)) {
return CompletableFuture.completedFuture(new PhoneNumberInfo(
number,
context.getString(R.string.unknown),
null,
null,
""));
}
if (isVoicemailNumber(context, number)) {
return CompletableFuture.completedFuture(new PhoneNumberInfo(
number,
context.getString(R.string.voicemail),
null,
makeResourceUri(context, R.drawable.ic_voicemail),
""));
}
if (InMemoryPhoneBook.isInitialized()) {
Contact contact = InMemoryPhoneBook.get().lookupContactEntry(number);
if (contact != null) {
String name = contact.getDisplayName();
if (name == null) {
name = getFormattedNumber(context, number);
}
if (name == null) {
name = context.getString(R.string.unknown);
}
PhoneNumber phoneNumber = contact.getPhoneNumber(context, number);
CharSequence typeLabel = "";
if (phoneNumber != null) {
typeLabel = Phone.getTypeLabel(context.getResources(),
phoneNumber.getType(),
phoneNumber.getLabel());
}
return CompletableFuture.completedFuture(new PhoneNumberInfo(
number,
name,
contact.getInitials(),
contact.getAvatarUri(),
typeLabel.toString()));
}
}
return CompletableFuture.supplyAsync(() -> {
String name = null;
String nameAlt = null;
String photoUriString = null;
CharSequence typeLabel = "";
ContentResolver cr = context.getContentResolver();
String initials;
try (Cursor cursor = cr.query(
Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)),
new String[]{
PhoneLookup.DISPLAY_NAME,
PhoneLookup.DISPLAY_NAME_ALTERNATIVE,
PhoneLookup.PHOTO_URI,
PhoneLookup.TYPE,
PhoneLookup.LABEL,
},
null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int nameColumn = cursor.getColumnIndex(PhoneLookup.DISPLAY_NAME);
int altNameColumn = cursor.getColumnIndex(PhoneLookup.DISPLAY_NAME_ALTERNATIVE);
int photoUriColumn = cursor.getColumnIndex(PhoneLookup.PHOTO_URI);
int typeColumn = cursor.getColumnIndex(PhoneLookup.TYPE);
int labelColumn = cursor.getColumnIndex(PhoneLookup.LABEL);
name = cursor.getString(nameColumn);
nameAlt = cursor.getString(altNameColumn);
photoUriString = cursor.getString(photoUriColumn);
int type = cursor.getInt(typeColumn);
String label = cursor.getString(labelColumn);
typeLabel = Phone.getTypeLabel(context.getResources(), type, label);
}
}
initials = getInitials(name, nameAlt);
if (name == null) {
name = getFormattedNumber(context, number);
}
if (name == null) {
name = context.getString(R.string.unknown);
}
return new PhoneNumberInfo(number, name, initials,
TextUtils.isEmpty(photoUriString) ? null : Uri.parse(photoUriString),
typeLabel.toString());
});
}
/**
* @return A string representation of the call state that can be presented to a user.
*/
public static String callStateToUiString(Context context, int state) {
Resources res = context.getResources();
switch (state) {
case Call.STATE_ACTIVE:
return res.getString(R.string.call_state_call_active);
case Call.STATE_HOLDING:
return res.getString(R.string.call_state_hold);
case Call.STATE_NEW:
case Call.STATE_CONNECTING:
return res.getString(R.string.call_state_connecting);
case Call.STATE_SELECT_PHONE_ACCOUNT:
case Call.STATE_DIALING:
return res.getString(R.string.call_state_dialing);
case Call.STATE_DISCONNECTED:
return res.getString(R.string.call_state_call_ended);
case Call.STATE_RINGING:
return res.getString(R.string.call_state_call_ringing);
case Call.STATE_DISCONNECTING:
return res.getString(R.string.call_state_call_ending);
default:
throw new IllegalStateException("Unknown Call State: " + state);
}
}
/**
* Returns true if the telephony network is available.
*/
public static boolean isNetworkAvailable(Context context) {
TelephonyManager tm =
(TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
return tm.getNetworkType() != TelephonyManager.NETWORK_TYPE_UNKNOWN
&& tm.getSimState() == TelephonyManager.SIM_STATE_READY;
}
/**
* Returns true if airplane mode is on.
*/
public static boolean isAirplaneModeOn(Context context) {
return Settings.System.getInt(context.getContentResolver(),
Settings.Global.AIRPLANE_MODE_ON, 0) != 0;
}
/**
* Sets a Contact avatar onto the provided {@code icon}. The first letter or both letters of the
* contact's initials.
*
* @param sortMethod can be either {@link #SORT_BY_FIRST_NAME} or {@link #SORT_BY_LAST_NAME}.
*/
public static void setContactBitmapAsync(
Context context,
@Nullable final ImageView icon,
@Nullable final Contact contact,
Integer sortMethod) {
setContactBitmapAsync(context, icon, contact, null, sortMethod);
}
/**
* Sets a Contact avatar onto the provided {@code icon}. The first letter or both letters of the
* contact's initials. Will start with first name by default.
*/
public static void setContactBitmapAsync(
Context context,
@Nullable final ImageView icon,
@Nullable final Contact contact,
@Nullable final String fallbackDisplayName) {
setContactBitmapAsync(context, icon, contact, fallbackDisplayName, SORT_BY_FIRST_NAME);
}
/**
* Sets a Contact avatar onto the provided {@code icon}. The first letter or both letters of the
* contact's initials or {@code fallbackDisplayName} will be used as a fallback resource if
* avatar loading fails.
*
* @param sortMethod can be either {@link #SORT_BY_FIRST_NAME} or {@link #SORT_BY_LAST_NAME}. If
* the value is {@link #SORT_BY_FIRST_NAME}, the name and initials order will
* be first name first. Otherwise, the order will be last name first.
*/
public static void setContactBitmapAsync(
Context context,
@Nullable final ImageView icon,
@Nullable final Contact contact,
@Nullable final String fallbackDisplayName,
Integer sortMethod) {
Uri avatarUri = contact != null ? contact.getAvatarUri() : null;
boolean startWithFirstName = isSortByFirstName(sortMethod);
String initials = contact != null
? contact.getInitialsBasedOnDisplayOrder(startWithFirstName)
: (fallbackDisplayName == null ? null : getInitials(fallbackDisplayName, null));
String identifier = contact == null ? fallbackDisplayName : contact.getDisplayName();
setContactBitmapAsync(context, icon, avatarUri, initials, identifier);
}
/**
* Sets a Contact avatar onto the provided {@code icon}. A letter tile base on the contact's
* initials and identifier will be used as a fallback resource if avatar loading fails.
*/
public static void setContactBitmapAsync(
Context context,
@Nullable final ImageView icon,
@Nullable final Uri avatarUri,
@Nullable final String initials,
@Nullable final String identifier) {
if (icon == null) {
return;
}
LetterTileDrawable letterTileDrawable = createLetterTile(context, initials, identifier);
Glide.with(context)
.load(avatarUri)
.apply(new RequestOptions().centerCrop().error(letterTileDrawable))
.into(icon);
}
/**
* 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.
*/
public static LetterTileDrawable createLetterTile(
Context context,
@Nullable String initials,
@Nullable String identifier) {
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;
}
/**
* Set the given phone number as the primary phone number for its associated contact.
*/
public static void setAsPrimaryPhoneNumber(Context context, PhoneNumber phoneNumber) {
// Update the primary values in the data record.
ContentValues values = new ContentValues(1);
values.put(ContactsContract.Data.IS_SUPER_PRIMARY, 1);
values.put(ContactsContract.Data.IS_PRIMARY, 1);
context.getContentResolver().update(
ContentUris.withAppendedId(ContactsContract.Data.CONTENT_URI, phoneNumber.getId()),
values, null, null);
}
/**
* Add a contact to favorite or remove it from favorite.
*/
public static int setAsFavoriteContact(Context context, Contact contact, boolean isFavorite) {
if (contact.isStarred() == isFavorite) {
return 0;
}
ContentValues values = new ContentValues(1);
values.put(ContactsContract.Contacts.STARRED, isFavorite ? 1 : 0);
String where = ContactsContract.Contacts._ID + " = ?";
String[] selectionArgs = new String[]{Long.toString(contact.getId())};
return context.getContentResolver().update(ContactsContract.Contacts.CONTENT_URI, values,
where, selectionArgs);
}
/**
* Mark missed call log matching given phone number as read. If phone number string is not
* valid, it will mark all new missed call log as read.
*/
public static void markCallLogAsRead(Context context, String phoneNumberString) {
if (context.checkSelfPermission(Manifest.permission.WRITE_CALL_LOG)
!= PackageManager.PERMISSION_GRANTED) {
L.w(TAG, "Missing WRITE_CALL_LOG permission; not marking missed calls as read.");
return;
}
ContentValues contentValues = new ContentValues();
contentValues.put(CallLog.Calls.NEW, 0);
contentValues.put(CallLog.Calls.IS_READ, 1);
List<String> selectionArgs = new ArrayList<>();
StringBuilder where = new StringBuilder();
where.append(CallLog.Calls.NEW);
where.append(" = 1 AND ");
where.append(CallLog.Calls.TYPE);
where.append(" = ?");
selectionArgs.add(Integer.toString(CallLog.Calls.MISSED_TYPE));
if (!TextUtils.isEmpty(phoneNumberString)) {
where.append(" AND ");
where.append(CallLog.Calls.NUMBER);
where.append(" = ?");
selectionArgs.add(phoneNumberString);
}
String[] selectionArgsArray = new String[0];
try {
context
.getContentResolver()
.update(
CallLog.Calls.CONTENT_URI,
contentValues,
where.toString(),
selectionArgs.toArray(selectionArgsArray));
} catch (IllegalArgumentException e) {
L.e(TAG, "markCallLogAsRead failed", e);
}
}
/**
* 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) {
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();
}
/**
* 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 Icon createLetterTile(Context context, @Nullable String initials,
String identifier, int avatarSize, float cornerRadiusPercent) {
LetterTileDrawable letterTileDrawable = TelecomUtils.createLetterTile(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. **/
public static Icon createFromRoundedBitmapDrawable(RoundedBitmapDrawable roundedBitmapDrawable,
int avatarSize, float cornerRadiusPercent) {
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 Icon.createWithBitmap(result);
}
/**
* Sets the direction of a string, used for displaying phone numbers.
*/
public static String getBidiWrappedNumber(String string) {
return BidiFormatter.getInstance().unicodeWrap(string, TextDirectionHeuristics.LTR);
}
private static Uri makeResourceUri(Context context, int resourceId) {
return new Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.encodedAuthority(context.getPackageName())
.appendEncodedPath(String.valueOf(resourceId))
.build();
}
/**
* This is a workaround for Log.Pii(). It will only show the last {@link #PII_STRING_LENGTH}
* characters.
*/
public static String piiLog(Object pii) {
String piiString = String.valueOf(pii);
return piiString.length() >= PII_STRING_LENGTH ? "*" + piiString.substring(
piiString.length() - PII_STRING_LENGTH) : piiString;
}
/**
* Returns true if contacts are sorted by their first names. Returns false if they are sorted by
* last names.
*/
public static boolean isSortByFirstName(Integer sortMethod) {
return SORT_BY_FIRST_NAME.equals(sortMethod);
}
}