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