1 /*
2  * Copyright (C) 2015 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.messaging.util;
18 
19 import android.Manifest;
20 import android.content.Context;
21 import android.content.pm.PackageManager;
22 import android.database.Cursor;
23 import android.net.Uri;
24 import android.provider.ContactsContract;
25 import android.provider.ContactsContract.CommonDataKinds.Email;
26 import android.provider.ContactsContract.CommonDataKinds.Phone;
27 import android.provider.ContactsContract.CommonDataKinds.StructuredName;
28 import android.provider.ContactsContract.Contacts;
29 import android.provider.ContactsContract.Directory;
30 import android.provider.ContactsContract.DisplayNameSources;
31 import android.provider.ContactsContract.PhoneLookup;
32 import android.provider.ContactsContract.Profile;
33 import android.text.TextUtils;
34 import android.view.View;
35 
36 import com.android.ex.chips.RecipientEntry;
37 import com.android.messaging.Factory;
38 import com.android.messaging.datamodel.CursorQueryData;
39 import com.android.messaging.datamodel.FrequentContactsCursorQueryData;
40 import com.android.messaging.datamodel.data.ParticipantData;
41 import com.android.messaging.sms.MmsSmsUtils;
42 import com.android.messaging.ui.contact.AddContactsConfirmationDialog;
43 import com.google.common.annotations.VisibleForTesting;
44 
45 /**
46  * Utility class including logic to list, filter, and lookup phone and emails in CP2.
47  */
48 @VisibleForTesting
49 public class ContactUtil {
50 
51     /**
52      * Index of different columns in phone or email queries. All queries below should confirm to
53      * this column content and ordering so that caller can use the uniformed way to process
54      * returned cursors.
55      */
56     public static final int INDEX_CONTACT_ID              = 0;
57     public static final int INDEX_DISPLAY_NAME            = 1;
58     public static final int INDEX_PHOTO_URI               = 2;
59     public static final int INDEX_PHONE_EMAIL             = 3;
60     public static final int INDEX_PHONE_EMAIL_TYPE        = 4;
61     public static final int INDEX_PHONE_EMAIL_LABEL       = 5;
62 
63     // An optional lookup_id column used by PhoneLookupQuery that is needed when querying for
64     // contact information.
65     public static final int INDEX_LOOKUP_KEY              = 6;
66 
67     // An optional _id column to query results that need to be displayed in a list view.
68     public static final int INDEX_DATA_ID                 = 7;
69 
70     // An optional sort_key column for displaying contact section labels.
71     public static final int INDEX_SORT_KEY                = 8;
72 
73     // Lookup key column index specific to frequent contacts query.
74     public static final int INDEX_LOOKUP_KEY_FREQUENT     = 3;
75 
76     /**
77      * Constants for listing and filtering phones.
78      */
79     public static class PhoneQuery {
80         public static final String SORT_KEY = Phone.SORT_KEY_PRIMARY;
81 
82         public static final String[] PROJECTION = new String[] {
83             Phone.CONTACT_ID,                   // 0
84             Phone.DISPLAY_NAME_PRIMARY,         // 1
85             Phone.PHOTO_THUMBNAIL_URI,          // 2
86             Phone.NUMBER,                       // 3
87             Phone.TYPE,                         // 4
88             Phone.LABEL,                        // 5
89             Phone.LOOKUP_KEY,                   // 6
90             Phone._ID,                          // 7
91             PhoneQuery.SORT_KEY,                // 8
92         };
93     }
94 
95     /**
96      * Constants for looking up phone numbers.
97      */
98     public static class PhoneLookupQuery {
99         public static final String[] PROJECTION = new String[] {
100             // The _ID field points to the contact id of the content
101             PhoneLookup._ID,                          // 0
102             PhoneLookup.DISPLAY_NAME,                 // 1
103             PhoneLookup.PHOTO_THUMBNAIL_URI,          // 2
104             PhoneLookup.NUMBER,                       // 3
105             PhoneLookup.TYPE,                         // 4
106             PhoneLookup.LABEL,                        // 5
107             PhoneLookup.LOOKUP_KEY,                   // 6
108             // The data id is not included as part of the projection since it's not part of
109             // PhoneLookup. This is okay because the _id field serves as both the data id and
110             // contact id. Also we never show the results directly in a list view so we are not
111             // concerned about duplicated _id's (namely, the same contact has two same phone
112             // numbers)
113         };
114     }
115 
116     public static class FrequentContactQuery {
117         public static final String[] PROJECTION = new String[] {
118             Contacts._ID,                       // 0
119             Contacts.DISPLAY_NAME,              // 1
120             Contacts.PHOTO_URI,                 // 2
121             Phone.LOOKUP_KEY,                   // 3
122         };
123     }
124 
125     /**
126      * Constants for listing and filtering emails.
127      */
128     public static class EmailQuery {
129         public static final String SORT_KEY = Email.SORT_KEY_PRIMARY;
130 
131         public static final String[] PROJECTION = new String[] {
132             Email.CONTACT_ID,                   // 0
133             Email.DISPLAY_NAME_PRIMARY,         // 1
134             Email.PHOTO_THUMBNAIL_URI,          // 2
135             Email.ADDRESS,                      // 3
136             Email.TYPE,                         // 4
137             Email.LABEL,                        // 5
138             Email.LOOKUP_KEY,                   // 6
139             Email._ID,                          // 7
140             EmailQuery.SORT_KEY,                // 8
141         };
142     }
143 
144     public static final int INDEX_SELF_QUERY_LOOKUP_KEY = 3;
145 
146     /**
147      * Constants for querying self from CP2.
148      */
149     public static class SelfQuery {
150         public static final String[] PROJECTION = new String[] {
151             Profile._ID,                        // 0
152             Profile.DISPLAY_NAME_PRIMARY,       // 1
153             Profile.PHOTO_THUMBNAIL_URI,        // 2
154             Profile.LOOKUP_KEY                  // 3
155             // Phone number, type, label and data_id is not provided in this projection since
156             // Profile CONTENT_URI doesn't include this information. Also, we don't need it
157             // we just need the name and avatar url.
158         };
159     }
160 
161     public static class StructuredNameQuery {
162         public static final String[] PROJECTION = new String[] {
163             StructuredName.DISPLAY_NAME,
164             StructuredName.GIVEN_NAME,
165             StructuredName.FAMILY_NAME,
166             StructuredName.PREFIX,
167             StructuredName.MIDDLE_NAME,
168             StructuredName.SUFFIX
169         };
170     }
171 
172     public static final int INDEX_STRUCTURED_NAME_DISPLAY_NAME = 0;
173     public static final int INDEX_STRUCTURED_NAME_GIVEN_NAME = 1;
174     public static final int INDEX_STRUCTURED_NAME_FAMILY_NAME = 2;
175     public static final int INDEX_STRUCTURED_NAME_PREFIX = 3;
176     public static final int INDEX_STRUCTURED_NAME_MIDDLE_NAME = 4;
177     public static final int INDEX_STRUCTURED_NAME_SUFFIX = 5;
178 
179     public static final long INVALID_CONTACT_ID = -1;
180 
181     /**
182      * This class is static. No need to create an instance.
183      */
ContactUtil()184     private ContactUtil() {
185     }
186 
187     /**
188      * Shows a contact card or add to contacts dialog for the given contact info
189      * @param view The view whose click triggered this to show
190      * @param contactId The id of the contact in the android contacts DB
191      * @param contactLookupKey The lookup key from contacts DB
192      * @param avatarUri Uri to the avatar image if available
193      * @param normalizedDestination The normalized phone number or email
194      */
showOrAddContact(final View view, final long contactId, final String contactLookupKey, final Uri avatarUri, final String normalizedDestination)195     public static void showOrAddContact(final View view, final long contactId,
196             final String contactLookupKey, final Uri avatarUri,
197             final String normalizedDestination) {
198         if (contactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED
199                 && !TextUtils.isEmpty(contactLookupKey)) {
200             final Uri lookupUri =
201                     ContactsContract.Contacts.getLookupUri(contactId, contactLookupKey);
202             ContactsContract.QuickContact.showQuickContact(view.getContext(), view, lookupUri,
203                     ContactsContract.QuickContact.MODE_LARGE, null);
204         } else if (!TextUtils.isEmpty(normalizedDestination) && !TextUtils.equals(
205                 normalizedDestination, ParticipantData.getUnknownSenderDestination())) {
206             final AddContactsConfirmationDialog dialog = new AddContactsConfirmationDialog(
207                     view.getContext(), avatarUri, normalizedDestination);
208             dialog.show();
209         }
210     }
211 
212     @VisibleForTesting
getSelf(final Context context)213     public static CursorQueryData getSelf(final Context context) {
214         if (!ContactUtil.hasReadContactsPermission()) {
215             return CursorQueryData.getEmptyQueryData();
216         }
217         return new CursorQueryData(context, Profile.CONTENT_URI, SelfQuery.PROJECTION, null, null,
218                 null);
219     }
220 
221     /**
222      * Get a list of phones sorted by contact name. One contact may have multiple phones.
223      * In that case, each phone will be returned as a separate record in the result cursor.
224      */
225     @VisibleForTesting
getPhones(final Context context)226     public static CursorQueryData getPhones(final Context context) {
227         if (!ContactUtil.hasReadContactsPermission()) {
228             return CursorQueryData.getEmptyQueryData();
229         }
230 
231         // The AOSP Contacts provider allows adding a ContactsContract.REMOVE_DUPLICATE_ENTRIES
232         // query parameter that removes duplicate (raw) numbers. Unfortunately, we can't use that
233         // because it causes the some phones' contacts provider to return incorrect sections.
234         final Uri uri = Phone.CONTENT_URI.buildUpon().appendQueryParameter(
235                 ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT))
236                 .appendQueryParameter(Contacts.EXTRA_ADDRESS_BOOK_INDEX, "true")
237                 .build();
238 
239         return new CursorQueryData(context, uri, PhoneQuery.PROJECTION, null, null,
240                 PhoneQuery.SORT_KEY);
241     }
242 
243     /**
244      * Lookup a destination (phone, email). Supplied destination should be a relatively complete
245      * one for this to succeed. PhoneLookup / EmailLookup URI will apply some smartness to do a
246      * loose match to see whether there is a contact that matches this destination.
247      */
lookupDestination(final Context context, final String destination)248     public static CursorQueryData lookupDestination(final Context context,
249             final String destination) {
250         if (MmsSmsUtils.isEmailAddress(destination)) {
251             return ContactUtil.lookupEmail(context, destination);
252         } else {
253             return ContactUtil.lookupPhone(context, destination);
254         }
255     }
256 
257     /**
258      * Returns whether the search text indicates an email based search or a phone number based one.
259      */
shouldFilterForEmail(final String searchText)260     private static boolean shouldFilterForEmail(final String searchText) {
261         return searchText != null && searchText.contains("@");
262     }
263 
264     /**
265      * Get a list of destinations (phone, email) matching the partial destination.
266      */
filterDestination(final Context context, final String destination)267     public static CursorQueryData filterDestination(final Context context,
268             final String destination) {
269         if (shouldFilterForEmail(destination)) {
270             return ContactUtil.filterEmails(context, destination);
271         } else {
272             return ContactUtil.filterPhones(context, destination);
273         }
274     }
275 
276     /**
277      * Get a list of destinations (phone, email) matching the partial destination in work profile.
278      */
filterDestinationEnterprise(final Context context, final String destination)279     public static CursorQueryData filterDestinationEnterprise(final Context context,
280             final String destination) {
281         if (shouldFilterForEmail(destination)) {
282             return ContactUtil.filterEmailsEnterprise(context, destination);
283         } else {
284             return ContactUtil.filterPhonesEnterprise(context, destination);
285         }
286     }
287 
288     /**
289      * Get a list of phones matching a search criteria. The search may be on contact name or
290      * phone number. In case search is on contact name, all matching contact's phone number
291      * will be returned.
292      * NOTE: This is visible for testing only, clients should only call filterDestination() since
293      * we support email addresses as well.
294      */
295     @VisibleForTesting
filterPhones(final Context context, final String query)296     public static CursorQueryData filterPhones(final Context context, final String query) {
297         return filterPhonesInternal(context, Phone.CONTENT_FILTER_URI, query, Directory.DEFAULT);
298     }
299 
300     /**
301      * Similar to {@link #filterPhones(Context, String)}, but search in work profile instead.
302      */
filterPhonesEnterprise(final Context context, final String query)303     public static CursorQueryData filterPhonesEnterprise(final Context context,
304             final String query) {
305         return filterPhonesInternal(context, Phone.ENTERPRISE_CONTENT_FILTER_URI, query,
306                 Directory.ENTERPRISE_DEFAULT);
307     }
308 
filterPhonesInternal(final Context context, final Uri phoneFilterBaseUri, final String query, final long directoryId)309     private static CursorQueryData filterPhonesInternal(final Context context,
310             final Uri phoneFilterBaseUri, final String query, final long directoryId) {
311         if (!ContactUtil.hasReadContactsPermission()) {
312             return CursorQueryData.getEmptyQueryData();
313         }
314         Uri phoneFilterUri = buildDirectorySearchUri(phoneFilterBaseUri, query, directoryId);
315         return new CursorQueryData(context,
316                 phoneFilterUri,
317                 PhoneQuery.PROJECTION, null, null,
318                 PhoneQuery.SORT_KEY);
319     }
320     /**
321      * Lookup a phone based on a phone number. Supplied phone should be a relatively complete
322      * phone number for this to succeed. PhoneLookup URI will apply some smartness to do a
323      * loose match to see whether there is a contact that matches this phone.
324      * NOTE: This is visible for testing only, clients should only call lookupDestination() since
325      * we support email addresses as well.
326      */
327     @VisibleForTesting
lookupPhone(final Context context, final String phone)328     public static CursorQueryData lookupPhone(final Context context, final String phone) {
329         if (!ContactUtil.hasReadContactsPermission()) {
330             return CursorQueryData.getEmptyQueryData();
331         }
332 
333         final Uri uri = getPhoneLookupUri().buildUpon()
334                 .appendPath(phone).build();
335 
336         return new CursorQueryData(context, uri, PhoneLookupQuery.PROJECTION, null, null, null);
337     }
338 
339     /**
340      * Get frequently contacted people. This queries for Contacts.CONTENT_STREQUENT_URI, which
341      * includes both starred or frequently contacted people.
342      */
getFrequentContacts(final Context context)343     public static CursorQueryData getFrequentContacts(final Context context) {
344         if (!ContactUtil.hasReadContactsPermission()) {
345             return CursorQueryData.getEmptyQueryData();
346         }
347 
348         return new FrequentContactsCursorQueryData(context, FrequentContactQuery.PROJECTION,
349                 null, null, null);
350     }
351 
352     /**
353      * Get a list of emails matching a search criteria. In Bugle, since email is not a common
354      * usage scenario, we should only do email search after user typed in a query indicating
355      * an intention to search by email (for example, "joe@").
356      * NOTE: This is visible for testing only, clients should only call filterDestination() since
357      * we support email addresses as well.
358      */
359     @VisibleForTesting
filterEmails(final Context context, final String query)360     public static CursorQueryData filterEmails(final Context context, final String query) {
361         return filterEmailsInternal(context, Email.CONTENT_FILTER_URI, query, Directory.DEFAULT);
362     }
363 
364     /**
365      * Similar to {@link #filterEmails(Context, String)}, but search in work profile instead.
366      */
filterEmailsEnterprise(final Context context, final String query)367     public static CursorQueryData filterEmailsEnterprise(final Context context,
368             final String query) {
369         return filterEmailsInternal(context, Email.ENTERPRISE_CONTENT_FILTER_URI, query,
370                 Directory.ENTERPRISE_DEFAULT);
371     }
372 
filterEmailsInternal(final Context context, final Uri filterEmailsBaseUri, final String query, final long directoryId)373     private static CursorQueryData filterEmailsInternal(final Context context,
374             final Uri filterEmailsBaseUri, final String query, final long directoryId) {
375         if (!ContactUtil.hasReadContactsPermission()) {
376             return CursorQueryData.getEmptyQueryData();
377         }
378         final Uri filterEmailsUri = buildDirectorySearchUri(filterEmailsBaseUri, query,
379                 directoryId);
380         return new CursorQueryData(context,
381                 filterEmailsUri,
382                 PhoneQuery.PROJECTION, null, null,
383                 PhoneQuery.SORT_KEY);
384     }
385 
386     /**
387      * Lookup emails based a complete email address. Since there is no special logic needed for
388      * email lookup, this simply calls filterEmails.
389      * NOTE: This is visible for testing only, clients should only call lookupDestination() since
390      * we support email addresses as well.
391      */
392     @VisibleForTesting
lookupEmail(final Context context, final String email)393     public static CursorQueryData lookupEmail(final Context context, final String email) {
394         if (!ContactUtil.hasReadContactsPermission()) {
395             return CursorQueryData.getEmptyQueryData();
396         }
397 
398         final Uri uri = getEmailContentLookupUri().buildUpon()
399                 .appendPath(email).appendQueryParameter(
400                         ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT))
401                         .build();
402 
403         return new CursorQueryData(context, uri, EmailQuery.PROJECTION, null, null,
404                 EmailQuery.SORT_KEY);
405     }
406 
407     /**
408      * Looks up the structured name for a contact.
409      *
410      * @param primaryOnly If there are multiple raw contacts, set this flag to return only the
411      * name used as the primary display name. Otherwise, this method returns all names.
412      */
lookupStructuredName(final Context context, final long contactId, final boolean primaryOnly)413     private static CursorQueryData lookupStructuredName(final Context context, final long contactId,
414             final boolean primaryOnly) {
415         if (!ContactUtil.hasReadContactsPermission()) {
416             return CursorQueryData.getEmptyQueryData();
417         }
418 
419         // TODO: Handle enterprise contacts
420         final Uri uri = ContactsContract.Contacts.CONTENT_URI.buildUpon()
421                 .appendPath(String.valueOf(contactId))
422                 .appendPath(ContactsContract.Contacts.Data.CONTENT_DIRECTORY).build();
423 
424         String selection = ContactsContract.Data.MIMETYPE + "=?";
425         final String[] selectionArgs = {
426                 StructuredName.CONTENT_ITEM_TYPE
427         };
428         if (primaryOnly) {
429             selection += " AND " + Contacts.DISPLAY_NAME_PRIMARY + "="
430                     + StructuredName.DISPLAY_NAME;
431         }
432 
433         return new CursorQueryData(context, uri,
434                 StructuredNameQuery.PROJECTION, selection, selectionArgs, null);
435     }
436 
437     /**
438      * Looks up the first name for a contact. If there are multiple raw
439      * contacts, this returns the name that is associated with the contact's
440      * primary display name. The name is null when contact id does not exist
441      * (possibly because it is a corp contact) or it does not have a first name.
442      */
lookupFirstName(final Context context, final long contactId)443     public static String lookupFirstName(final Context context, final long contactId) {
444         if (isEnterpriseContactId(contactId)) {
445             return null;
446         }
447         String firstName = null;
448         Cursor nameCursor = null;
449         try {
450             nameCursor = ContactUtil.lookupStructuredName(context, contactId, true)
451                     .performSynchronousQuery();
452             if (nameCursor != null && nameCursor.moveToFirst()) {
453                 firstName = nameCursor.getString(ContactUtil.INDEX_STRUCTURED_NAME_GIVEN_NAME);
454             }
455         } finally {
456             if (nameCursor != null) {
457                 nameCursor.close();
458             }
459         }
460         return firstName;
461     }
462 
463     /**
464      * Creates a RecipientEntry from the provided data fields (from the contacts cursor).
465      * @param firstLevel whether this item is the first entry of this contact in the list.
466      */
createRecipientEntry(final String displayName, final int displayNameSource, final String destination, final int destinationType, final String destinationLabel, final long contactId, final String lookupKey, final long dataId, final String photoThumbnailUri, final boolean firstLevel)467     public static RecipientEntry createRecipientEntry(final String displayName,
468             final int displayNameSource, final String destination, final int destinationType,
469             final String destinationLabel, final long contactId, final String lookupKey,
470             final long dataId, final String photoThumbnailUri, final boolean firstLevel) {
471         if (firstLevel) {
472             return RecipientEntry.constructTopLevelEntry(displayName, displayNameSource,
473                     destination, destinationType, destinationLabel, contactId, null, dataId,
474                     photoThumbnailUri, true, lookupKey);
475         } else {
476             return RecipientEntry.constructSecondLevelEntry(displayName, displayNameSource,
477                     destination, destinationType, destinationLabel, contactId, null, dataId,
478                     photoThumbnailUri, true, lookupKey);
479         }
480     }
481 
482     /**
483      * Creates a RecipientEntry for PhoneQuery result. The result is then displayed in the
484      * contact search drop down or as replacement chips in the chips edit box.
485      */
createRecipientEntryForPhoneQuery(final Cursor cursor, final boolean isFirstLevel)486     public static RecipientEntry createRecipientEntryForPhoneQuery(final Cursor cursor,
487             final boolean isFirstLevel) {
488         final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID);
489         final String displayName = cursor.getString(
490                 ContactUtil.INDEX_DISPLAY_NAME);
491         final String photoThumbnailUri = cursor.getString(
492                 ContactUtil.INDEX_PHOTO_URI);
493         final String destination = cursor.getString(
494                 ContactUtil.INDEX_PHONE_EMAIL);
495         final int destinationType = cursor.getInt(
496                 ContactUtil.INDEX_PHONE_EMAIL_TYPE);
497         final String destinationLabel = cursor.getString(
498                 ContactUtil.INDEX_PHONE_EMAIL_LABEL);
499         final String lookupKey = cursor.getString(
500                 ContactUtil.INDEX_LOOKUP_KEY);
501 
502         // PhoneQuery uses the contact id as the data id ("_id").
503         final long dataId = contactId;
504 
505         return createRecipientEntry(displayName,
506                 DisplayNameSources.STRUCTURED_NAME, destination, destinationType,
507                 destinationLabel, contactId, lookupKey, dataId, photoThumbnailUri,
508                 isFirstLevel);
509     }
510 
511     /**
512      * Returns if a given contact id is valid.
513      */
isValidContactId(final long contactId)514     public static boolean isValidContactId(final long contactId) {
515         return contactId >= 0;
516     }
517 
518     /**
519      * Returns if a given contact id belongs to managed profile.
520      */
isEnterpriseContactId(final long contactId)521     public static boolean isEnterpriseContactId(final long contactId) {
522         return OsUtil.isAtLeastL() && ContactsContract.Contacts.isEnterpriseContactId(contactId);
523     }
524 
525     /**
526      * Returns Email lookup uri that will query both primary and corp profile
527      */
getEmailContentLookupUri()528     private static Uri getEmailContentLookupUri() {
529         if (OsUtil.isAtLeastM()) {
530             return Email.ENTERPRISE_CONTENT_LOOKUP_URI;
531         }
532         return Email.CONTENT_LOOKUP_URI;
533     }
534 
535     /**
536      * Returns PhoneLookup URI.
537      */
getPhoneLookupUri()538     public static Uri getPhoneLookupUri() {
539         if (OsUtil.isAtLeastM()) {
540             return PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI;
541         }
542         return PhoneLookup.CONTENT_FILTER_URI;
543     }
544 
hasReadContactsPermission()545     public static boolean hasReadContactsPermission() {
546         return OsUtil.hasPermission(Manifest.permission.READ_CONTACTS);
547     }
548 
buildDirectorySearchUri(final Uri uri, final String query, final long directoryId)549     private static Uri buildDirectorySearchUri(final Uri uri, final String query,
550             final long directoryId) {
551         return uri.buildUpon()
552                 .appendPath(query).appendQueryParameter(
553                         ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId))
554                 .build();
555     }
556 }
557