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