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.telephony.common; 18 19 import android.Manifest; 20 import android.content.ContentResolver; 21 import android.content.ContentUris; 22 import android.content.ContentValues; 23 import android.content.Context; 24 import android.content.pm.PackageManager; 25 import android.content.res.Resources; 26 import android.database.Cursor; 27 import android.graphics.Bitmap; 28 import android.graphics.Canvas; 29 import android.graphics.drawable.Icon; 30 import android.location.Country; 31 import android.location.CountryDetector; 32 import android.net.Uri; 33 import android.provider.CallLog; 34 import android.provider.ContactsContract; 35 import android.provider.ContactsContract.CommonDataKinds.Phone; 36 import android.provider.ContactsContract.PhoneLookup; 37 import android.provider.Settings; 38 import android.telecom.Call; 39 import android.telephony.PhoneNumberUtils; 40 import android.telephony.TelephonyManager; 41 import android.text.TextUtils; 42 import android.util.Log; 43 import android.widget.ImageView; 44 45 import androidx.annotation.Nullable; 46 import androidx.core.content.ContextCompat; 47 import androidx.core.graphics.drawable.RoundedBitmapDrawable; 48 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; 49 50 import com.android.car.apps.common.LetterTileDrawable; 51 52 import com.bumptech.glide.Glide; 53 import com.bumptech.glide.request.RequestOptions; 54 import com.google.i18n.phonenumbers.NumberParseException; 55 import com.google.i18n.phonenumbers.PhoneNumberUtil; 56 import com.google.i18n.phonenumbers.Phonenumber; 57 58 import java.util.ArrayList; 59 import java.util.List; 60 import java.util.Locale; 61 import java.util.concurrent.CompletableFuture; 62 63 /** 64 * Helper methods. 65 */ 66 public class TelecomUtils { 67 private static final String TAG = "CD.TelecomUtils"; 68 69 private static String sVoicemailNumber; 70 private static TelephonyManager sTelephonyManager; 71 72 /** 73 * Get the voicemail number. 74 */ getVoicemailNumber(Context context)75 public static String getVoicemailNumber(Context context) { 76 if (sVoicemailNumber == null) { 77 sVoicemailNumber = getTelephonyManager(context).getVoiceMailNumber(); 78 } 79 return sVoicemailNumber; 80 } 81 82 /** 83 * Returns {@code true} if the given number is a voice mail number. 84 * 85 * @see TelephonyManager#getVoiceMailNumber() 86 */ isVoicemailNumber(Context context, String number)87 public static boolean isVoicemailNumber(Context context, String number) { 88 if (TextUtils.isEmpty(number)) { 89 return false; 90 } 91 92 if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) 93 != PackageManager.PERMISSION_GRANTED) { 94 return false; 95 } 96 97 return number.equals(getVoicemailNumber(context)); 98 } 99 100 /** 101 * Get the {@link TelephonyManager} instance. 102 */ 103 // TODO(deanh): remove this, getSystemService is not slow. getTelephonyManager(Context context)104 public static TelephonyManager getTelephonyManager(Context context) { 105 if (sTelephonyManager == null) { 106 sTelephonyManager = 107 (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); 108 } 109 return sTelephonyManager; 110 } 111 112 /** 113 * Format a number as a phone number. 114 */ getFormattedNumber(Context context, String number)115 public static String getFormattedNumber(Context context, String number) { 116 if (Log.isLoggable(TAG, Log.DEBUG)) { 117 Log.d(TAG, "getFormattedNumber: " + number); 118 } 119 if (number == null) { 120 return ""; 121 } 122 123 String countryIso = getCurrentCountryIso(context); 124 if (Log.isLoggable(TAG, Log.DEBUG)) { 125 Log.d(TAG, "PhoneNumberUtils.formatNumberToE16, number: " 126 + number + ", country: " + countryIso); 127 } 128 String e164 = PhoneNumberUtils.formatNumberToE164(number, countryIso); 129 String formattedNumber = PhoneNumberUtils.formatNumber(number, e164, countryIso); 130 formattedNumber = TextUtils.isEmpty(formattedNumber) ? number : formattedNumber; 131 if (Log.isLoggable(TAG, Log.DEBUG)) { 132 Log.d(TAG, "getFormattedNumber, result: " + formattedNumber); 133 } 134 return formattedNumber; 135 } 136 137 /** 138 * @return The ISO 3166-1 two letters country code of the country the user is in. 139 */ getCurrentCountryIso(Context context, Locale locale)140 private static String getCurrentCountryIso(Context context, Locale locale) { 141 String countryIso = null; 142 CountryDetector detector = (CountryDetector) context.getSystemService( 143 Context.COUNTRY_DETECTOR); 144 if (detector != null) { 145 Country country = detector.detectCountry(); 146 if (country != null) { 147 countryIso = country.getCountryIso(); 148 } else { 149 Log.e(TAG, "CountryDetector.detectCountry() returned null."); 150 } 151 } 152 if (countryIso == null) { 153 countryIso = locale.getCountry(); 154 Log.w(TAG, "No CountryDetector; falling back to countryIso based on locale: " 155 + countryIso); 156 } 157 if (countryIso == null || countryIso.length() != 2) { 158 Log.w(TAG, "Invalid locale, falling back to US"); 159 countryIso = "US"; 160 } 161 return countryIso; 162 } 163 getCurrentCountryIso(Context context)164 private static String getCurrentCountryIso(Context context) { 165 return getCurrentCountryIso(context, Locale.getDefault()); 166 } 167 168 /** 169 * Creates a new instance of {@link Phonenumber.PhoneNumber} base on the given number and sim 170 * card country code. Returns {@code null} if the number in an invalid number. 171 */ 172 @Nullable createI18nPhoneNumber(Context context, String number)173 public static Phonenumber.PhoneNumber createI18nPhoneNumber(Context context, String number) { 174 try { 175 return PhoneNumberUtil.getInstance().parse(number, getCurrentCountryIso(context)); 176 } catch (NumberParseException e) { 177 return null; 178 } 179 } 180 181 /** 182 * Contains all the info used to display a phone number on the screen. Returned by {@link 183 * #getPhoneNumberInfo(Context, String)} 184 */ 185 public static final class PhoneNumberInfo { 186 private final String mPhoneNumber; 187 private final String mDisplayName; 188 private final String mInitials; 189 private final Uri mAvatarUri; 190 private final String mTypeLabel; 191 PhoneNumberInfo(String phoneNumber, String displayName, String initials, Uri avatarUri, String typeLabel)192 public PhoneNumberInfo(String phoneNumber, String displayName, 193 String initials, Uri avatarUri, String typeLabel) { 194 mPhoneNumber = phoneNumber; 195 mDisplayName = displayName; 196 mInitials = initials; 197 mAvatarUri = avatarUri; 198 mTypeLabel = typeLabel; 199 } 200 getPhoneNumber()201 public String getPhoneNumber() { 202 return mPhoneNumber; 203 } 204 getDisplayName()205 public String getDisplayName() { 206 return mDisplayName; 207 } 208 209 /** 210 * Returns the initials of the contact related to the phone number. Returns null if there is 211 * no related contact. 212 */ 213 @Nullable getInitials()214 public String getInitials() { 215 return mInitials; 216 } 217 218 @Nullable getAvatarUri()219 public Uri getAvatarUri() { 220 return mAvatarUri; 221 } 222 getTypeLabel()223 public String getTypeLabel() { 224 return mTypeLabel; 225 } 226 227 } 228 229 /** 230 * Gets all the info needed to properly display a phone number to the UI. (e.g. if it's the 231 * voicemail number, return a string and a uri that represents voicemail, if it's a contact, get 232 * the contact's name, its avatar uri, the phone number's label, etc). 233 */ getPhoneNumberInfo( Context context, String number)234 public static CompletableFuture<PhoneNumberInfo> getPhoneNumberInfo( 235 Context context, String number) { 236 237 if (TextUtils.isEmpty(number)) { 238 return CompletableFuture.completedFuture(new PhoneNumberInfo( 239 number, 240 context.getString(R.string.unknown), 241 null, 242 null, 243 "")); 244 } 245 246 if (isVoicemailNumber(context, number)) { 247 return CompletableFuture.completedFuture(new PhoneNumberInfo( 248 number, 249 context.getString(R.string.voicemail), 250 null, 251 makeResourceUri(context, R.drawable.ic_voicemail), 252 "")); 253 } 254 255 if (InMemoryPhoneBook.isInitialized()) { 256 Contact contact = InMemoryPhoneBook.get().lookupContactEntry(number); 257 if (contact != null) { 258 String name = contact.getDisplayName(); 259 if (name == null) { 260 name = getFormattedNumber(context, number); 261 } 262 263 if (name == null) { 264 name = context.getString(R.string.unknown); 265 } 266 267 PhoneNumber phoneNumber = contact.getPhoneNumber(context, number); 268 CharSequence typeLabel = ""; 269 if (phoneNumber != null) { 270 typeLabel = Phone.getTypeLabel(context.getResources(), 271 phoneNumber.getType(), 272 phoneNumber.getLabel()); 273 } 274 275 return CompletableFuture.completedFuture(new PhoneNumberInfo( 276 number, 277 name, 278 contact.getInitials(), 279 contact.getAvatarUri(), 280 typeLabel.toString())); 281 } 282 } 283 284 return CompletableFuture.supplyAsync(() -> { 285 String name = null; 286 String nameAlt = null; 287 String photoUriString = null; 288 CharSequence typeLabel = ""; 289 ContentResolver cr = context.getContentResolver(); 290 String initials; 291 try (Cursor cursor = cr.query( 292 Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)), 293 new String[]{ 294 PhoneLookup.DISPLAY_NAME, 295 PhoneLookup.DISPLAY_NAME_ALTERNATIVE, 296 PhoneLookup.PHOTO_URI, 297 PhoneLookup.TYPE, 298 PhoneLookup.LABEL, 299 }, 300 null, null, null)) { 301 302 if (cursor != null && cursor.moveToFirst()) { 303 int nameColumn = cursor.getColumnIndex(PhoneLookup.DISPLAY_NAME); 304 int altNameColumn = cursor.getColumnIndex(PhoneLookup.DISPLAY_NAME_ALTERNATIVE); 305 int photoUriColumn = cursor.getColumnIndex(PhoneLookup.PHOTO_URI); 306 int typeColumn = cursor.getColumnIndex(PhoneLookup.TYPE); 307 int labelColumn = cursor.getColumnIndex(PhoneLookup.LABEL); 308 309 name = cursor.getString(nameColumn); 310 nameAlt = cursor.getString(altNameColumn); 311 photoUriString = cursor.getString(photoUriColumn); 312 int type = cursor.getInt(typeColumn); 313 String label = cursor.getString(labelColumn); 314 typeLabel = Phone.getTypeLabel(context.getResources(), type, label); 315 } 316 } 317 318 initials = getInitials(name, nameAlt); 319 320 if (name == null) { 321 name = getFormattedNumber(context, number); 322 } 323 324 if (name == null) { 325 name = context.getString(R.string.unknown); 326 } 327 328 return new PhoneNumberInfo(number, name, initials, 329 TextUtils.isEmpty(photoUriString) ? null : Uri.parse(photoUriString), 330 typeLabel.toString()); 331 }); 332 } 333 334 /** 335 * @return A string representation of the call state that can be presented to a user. 336 */ callStateToUiString(Context context, int state)337 public static String callStateToUiString(Context context, int state) { 338 Resources res = context.getResources(); 339 switch (state) { 340 case Call.STATE_ACTIVE: 341 return res.getString(R.string.call_state_call_active); 342 case Call.STATE_HOLDING: 343 return res.getString(R.string.call_state_hold); 344 case Call.STATE_NEW: 345 case Call.STATE_CONNECTING: 346 return res.getString(R.string.call_state_connecting); 347 case Call.STATE_SELECT_PHONE_ACCOUNT: 348 case Call.STATE_DIALING: 349 return res.getString(R.string.call_state_dialing); 350 case Call.STATE_DISCONNECTED: 351 return res.getString(R.string.call_state_call_ended); 352 case Call.STATE_RINGING: 353 return res.getString(R.string.call_state_call_ringing); 354 case Call.STATE_DISCONNECTING: 355 return res.getString(R.string.call_state_call_ending); 356 default: 357 throw new IllegalStateException("Unknown Call State: " + state); 358 } 359 } 360 361 /** 362 * Returns true if the telephony network is available. 363 */ isNetworkAvailable(Context context)364 public static boolean isNetworkAvailable(Context context) { 365 TelephonyManager tm = 366 (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); 367 return tm.getNetworkType() != TelephonyManager.NETWORK_TYPE_UNKNOWN 368 && tm.getSimState() == TelephonyManager.SIM_STATE_READY; 369 } 370 371 /** 372 * Returns true if airplane mode is on. 373 */ isAirplaneModeOn(Context context)374 public static boolean isAirplaneModeOn(Context context) { 375 return Settings.System.getInt(context.getContentResolver(), 376 Settings.Global.AIRPLANE_MODE_ON, 0) != 0; 377 } 378 379 /** 380 * Sets a Contact avatar onto the provided {@code icon}. The first letter or both letters of the 381 * contact's initials. 382 */ setContactBitmapAsync( Context context, @Nullable final ImageView icon, @Nullable final Contact contact)383 public static void setContactBitmapAsync( 384 Context context, 385 @Nullable final ImageView icon, 386 @Nullable final Contact contact) { 387 setContactBitmapAsync(context, icon, contact, null); 388 } 389 390 /** 391 * Sets a Contact avatar onto the provided {@code icon}. The first letter or both letters of the 392 * contact's initials or {@code fallbackDisplayName} will be used as a fallback resource if 393 * avatar loading fails. 394 */ setContactBitmapAsync( Context context, @Nullable final ImageView icon, @Nullable final Contact contact, @Nullable final String fallbackDisplayName)395 public static void setContactBitmapAsync( 396 Context context, 397 @Nullable final ImageView icon, 398 @Nullable final Contact contact, 399 @Nullable final String fallbackDisplayName) { 400 Uri avatarUri = contact != null ? contact.getAvatarUri() : null; 401 String initials = contact != null ? contact.getInitials() 402 : (fallbackDisplayName == null ? null : getInitials(fallbackDisplayName, null)); 403 String identifier = contact == null ? fallbackDisplayName : contact.getDisplayName(); 404 405 setContactBitmapAsync(context, icon, avatarUri, initials, identifier); 406 } 407 408 /** 409 * Sets a Contact avatar onto the provided {@code icon}. A letter tile base on the contact's 410 * initials and identifier will be used as a fallback resource if avatar loading fails. 411 */ setContactBitmapAsync( Context context, @Nullable final ImageView icon, @Nullable final Uri avatarUri, @Nullable final String initials, @Nullable final String identifier)412 public static void setContactBitmapAsync( 413 Context context, 414 @Nullable final ImageView icon, 415 @Nullable final Uri avatarUri, 416 @Nullable final String initials, 417 @Nullable final String identifier) { 418 if (icon == null) { 419 return; 420 } 421 422 LetterTileDrawable letterTileDrawable = createLetterTile(context, initials, identifier); 423 424 Glide.with(context) 425 .load(avatarUri) 426 .apply(new RequestOptions().centerCrop().error(letterTileDrawable)) 427 .into(icon); 428 } 429 430 /** 431 * Create a {@link LetterTileDrawable} for the given initials. 432 * 433 * @param initials is the letters that will be drawn on the canvas. If it is null, then an 434 * avatar anonymous icon will be drawn 435 * @param identifier will decide the color for the drawable. If null, a default color will be 436 * used. 437 */ createLetterTile( Context context, @Nullable String initials, @Nullable String identifier)438 public static LetterTileDrawable createLetterTile( 439 Context context, 440 @Nullable String initials, 441 @Nullable String identifier) { 442 int numberOfLetter = context.getResources().getInteger( 443 R.integer.config_number_of_letters_shown_for_avatar); 444 String letters = initials != null 445 ? initials.substring(0, Math.min(initials.length(), numberOfLetter)) : null; 446 LetterTileDrawable letterTileDrawable = new LetterTileDrawable(context.getResources(), 447 letters, identifier); 448 return letterTileDrawable; 449 } 450 451 /** 452 * Set the given phone number as the primary phone number for its associated contact. 453 */ setAsPrimaryPhoneNumber(Context context, PhoneNumber phoneNumber)454 public static void setAsPrimaryPhoneNumber(Context context, PhoneNumber phoneNumber) { 455 // Update the primary values in the data record. 456 ContentValues values = new ContentValues(1); 457 values.put(ContactsContract.Data.IS_SUPER_PRIMARY, 1); 458 values.put(ContactsContract.Data.IS_PRIMARY, 1); 459 460 context.getContentResolver().update( 461 ContentUris.withAppendedId(ContactsContract.Data.CONTENT_URI, phoneNumber.getId()), 462 values, null, null); 463 } 464 465 /** 466 * Add a contact to favorite or remove it from favorite. 467 */ setAsFavoriteContact(Context context, Contact contact, boolean isFavorite)468 public static int setAsFavoriteContact(Context context, Contact contact, boolean isFavorite) { 469 if (contact.isStarred() == isFavorite) { 470 return 0; 471 } 472 473 ContentValues values = new ContentValues(1); 474 values.put(ContactsContract.Contacts.STARRED, isFavorite ? 1 : 0); 475 476 String where = ContactsContract.Contacts._ID + " = ?"; 477 String[] selectionArgs = new String[]{Long.toString(contact.getId())}; 478 return context.getContentResolver().update(ContactsContract.Contacts.CONTENT_URI, values, 479 where, selectionArgs); 480 } 481 482 /** 483 * Mark missed call log matching given phone number as read. If phone number string is not 484 * valid, it will mark all new missed call log as read. 485 */ markCallLogAsRead(Context context, String phoneNumberString)486 public static void markCallLogAsRead(Context context, String phoneNumberString) { 487 if (context.checkSelfPermission(Manifest.permission.WRITE_CALL_LOG) 488 != PackageManager.PERMISSION_GRANTED) { 489 Log.w(TAG, "Missing WRITE_CALL_LOG permission; not marking missed calls as read."); 490 return; 491 } 492 ContentValues contentValues = new ContentValues(); 493 contentValues.put(CallLog.Calls.NEW, 0); 494 contentValues.put(CallLog.Calls.IS_READ, 1); 495 496 List<String> selectionArgs = new ArrayList<>(); 497 StringBuilder where = new StringBuilder(); 498 where.append(CallLog.Calls.NEW); 499 where.append(" = 1 AND "); 500 where.append(CallLog.Calls.TYPE); 501 where.append(" = ?"); 502 selectionArgs.add(Integer.toString(CallLog.Calls.MISSED_TYPE)); 503 if (!TextUtils.isEmpty(phoneNumberString)) { 504 where.append(" AND "); 505 where.append(CallLog.Calls.NUMBER); 506 where.append(" = ?"); 507 selectionArgs.add(phoneNumberString); 508 } 509 String[] selectionArgsArray = new String[0]; 510 try { 511 context 512 .getContentResolver() 513 .update( 514 CallLog.Calls.CONTENT_URI, 515 contentValues, 516 where.toString(), 517 selectionArgs.toArray(selectionArgsArray)); 518 } catch (IllegalArgumentException e) { 519 Log.e(TAG, "markCallLogAsRead failed", e); 520 } 521 } 522 523 /** 524 * Returns the initials based on the name and nameAlt. 525 * 526 * @param name should be the display name of a contact. 527 * @param nameAlt should be alternative display name of a contact. 528 */ getInitials(String name, String nameAlt)529 public static String getInitials(String name, String nameAlt) { 530 StringBuilder initials = new StringBuilder(); 531 if (!TextUtils.isEmpty(name) && Character.isLetter(name.charAt(0))) { 532 initials.append(Character.toUpperCase(name.charAt(0))); 533 } 534 if (!TextUtils.isEmpty(nameAlt) 535 && !TextUtils.equals(name, nameAlt) 536 && Character.isLetter(nameAlt.charAt(0))) { 537 initials.append(Character.toUpperCase(nameAlt.charAt(0))); 538 } 539 return initials.toString(); 540 } 541 542 /** 543 * Creates a Letter Tile Icon that will display the given initials. If the initials are null, 544 * then an avatar anonymous icon will be drawn. 545 **/ createLetterTile(Context context, @Nullable String initials, String identifier, int avatarSize, float cornerRadiusPercent)546 public static Icon createLetterTile(Context context, @Nullable String initials, 547 String identifier, int avatarSize, float cornerRadiusPercent) { 548 LetterTileDrawable letterTileDrawable = TelecomUtils.createLetterTile(context, initials, 549 identifier); 550 RoundedBitmapDrawable roundedBitmapDrawable = RoundedBitmapDrawableFactory.create( 551 context.getResources(), letterTileDrawable.toBitmap(avatarSize)); 552 return createFromRoundedBitmapDrawable(roundedBitmapDrawable, avatarSize, 553 cornerRadiusPercent); 554 } 555 556 /** Creates an Icon based on the given roundedBitmapDrawable. **/ createFromRoundedBitmapDrawable(RoundedBitmapDrawable roundedBitmapDrawable, int avatarSize, float cornerRadiusPercent)557 public static Icon createFromRoundedBitmapDrawable(RoundedBitmapDrawable roundedBitmapDrawable, 558 int avatarSize, float cornerRadiusPercent) { 559 float radius = avatarSize * cornerRadiusPercent; 560 roundedBitmapDrawable.setCornerRadius(radius); 561 562 final Bitmap result = Bitmap.createBitmap(avatarSize, avatarSize, 563 Bitmap.Config.ARGB_8888); 564 final Canvas canvas = new Canvas(result); 565 roundedBitmapDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); 566 roundedBitmapDrawable.draw(canvas); 567 return Icon.createWithBitmap(result); 568 } 569 makeResourceUri(Context context, int resourceId)570 private static Uri makeResourceUri(Context context, int resourceId) { 571 return new Uri.Builder() 572 .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) 573 .encodedAuthority(context.getBasePackageName()) 574 .appendEncodedPath(String.valueOf(resourceId)) 575 .build(); 576 } 577 578 } 579