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 phones matching a search criteria. The search may be on contact name or 278 * phone number. In case search is on contact name, all matching contact's phone number 279 * will be returned. 280 * NOTE: This is visible for testing only, clients should only call filterDestination() since 281 * we support email addresses as well. 282 */ 283 @VisibleForTesting filterPhones(final Context context, final String query)284 public static CursorQueryData filterPhones(final Context context, final String query) { 285 if (!ContactUtil.hasReadContactsPermission()) { 286 return CursorQueryData.getEmptyQueryData(); 287 } 288 289 final Uri uri = Phone.CONTENT_FILTER_URI.buildUpon() 290 .appendPath(query).appendQueryParameter( 291 ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)) 292 .build(); 293 294 return new CursorQueryData(context, uri, PhoneQuery.PROJECTION, null, null, 295 PhoneQuery.SORT_KEY); 296 } 297 298 /** 299 * Lookup a phone based on a phone number. Supplied phone should be a relatively complete 300 * phone number for this to succeed. PhoneLookup URI will apply some smartness to do a 301 * loose match to see whether there is a contact that matches this phone. 302 * NOTE: This is visible for testing only, clients should only call lookupDestination() since 303 * we support email addresses as well. 304 */ 305 @VisibleForTesting lookupPhone(final Context context, final String phone)306 public static CursorQueryData lookupPhone(final Context context, final String phone) { 307 if (!ContactUtil.hasReadContactsPermission()) { 308 return CursorQueryData.getEmptyQueryData(); 309 } 310 311 final Uri uri = getPhoneLookupUri().buildUpon() 312 .appendPath(phone).build(); 313 314 return new CursorQueryData(context, uri, PhoneLookupQuery.PROJECTION, null, null, null); 315 } 316 317 /** 318 * Get frequently contacted people. This queries for Contacts.CONTENT_STREQUENT_URI, which 319 * includes both starred or frequently contacted people. 320 */ getFrequentContacts(final Context context)321 public static CursorQueryData getFrequentContacts(final Context context) { 322 if (!ContactUtil.hasReadContactsPermission()) { 323 return CursorQueryData.getEmptyQueryData(); 324 } 325 326 return new FrequentContactsCursorQueryData(context, FrequentContactQuery.PROJECTION, 327 null, null, null); 328 } 329 330 /** 331 * Get a list of emails matching a search criteria. In Bugle, since email is not a common 332 * usage scenario, we should only do email search after user typed in a query indicating 333 * an intention to search by email (for example, "joe@"). 334 * NOTE: This is visible for testing only, clients should only call filterDestination() since 335 * we support email addresses as well. 336 */ 337 @VisibleForTesting filterEmails(final Context context, final String query)338 public static CursorQueryData filterEmails(final Context context, final String query) { 339 if (!ContactUtil.hasReadContactsPermission()) { 340 return CursorQueryData.getEmptyQueryData(); 341 } 342 343 final Uri uri = Email.CONTENT_FILTER_URI.buildUpon() 344 .appendPath(query).appendQueryParameter( 345 ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)) 346 .build(); 347 348 return new CursorQueryData(context, uri, EmailQuery.PROJECTION, null, null, 349 EmailQuery.SORT_KEY); 350 } 351 352 /** 353 * Lookup emails based a complete email address. Since there is no special logic needed for 354 * email lookup, this simply calls filterEmails. 355 * NOTE: This is visible for testing only, clients should only call lookupDestination() since 356 * we support email addresses as well. 357 */ 358 @VisibleForTesting lookupEmail(final Context context, final String email)359 public static CursorQueryData lookupEmail(final Context context, final String email) { 360 if (!ContactUtil.hasReadContactsPermission()) { 361 return CursorQueryData.getEmptyQueryData(); 362 } 363 364 final Uri uri = getEmailContentLookupUri().buildUpon() 365 .appendPath(email).appendQueryParameter( 366 ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)) 367 .build(); 368 369 return new CursorQueryData(context, uri, EmailQuery.PROJECTION, null, null, 370 EmailQuery.SORT_KEY); 371 } 372 373 /** 374 * Looks up the structured name for a contact. 375 * 376 * @param primaryOnly If there are multiple raw contacts, set this flag to return only the 377 * name used as the primary display name. Otherwise, this method returns all names. 378 */ lookupStructuredName(final Context context, final long contactId, final boolean primaryOnly)379 private static CursorQueryData lookupStructuredName(final Context context, final long contactId, 380 final boolean primaryOnly) { 381 if (!ContactUtil.hasReadContactsPermission()) { 382 return CursorQueryData.getEmptyQueryData(); 383 } 384 385 // TODO: Handle enterprise contacts 386 final Uri uri = ContactsContract.Contacts.CONTENT_URI.buildUpon() 387 .appendPath(String.valueOf(contactId)) 388 .appendPath(ContactsContract.Contacts.Data.CONTENT_DIRECTORY).build(); 389 390 String selection = ContactsContract.Data.MIMETYPE + "=?"; 391 final String[] selectionArgs = { 392 StructuredName.CONTENT_ITEM_TYPE 393 }; 394 if (primaryOnly) { 395 selection += " AND " + Contacts.DISPLAY_NAME_PRIMARY + "=" 396 + StructuredName.DISPLAY_NAME; 397 } 398 399 return new CursorQueryData(context, uri, 400 StructuredNameQuery.PROJECTION, selection, selectionArgs, null); 401 } 402 403 /** 404 * Looks up the first name for a contact. If there are multiple raw 405 * contacts, this returns the name that is associated with the contact's 406 * primary display name. The name is null when contact id does not exist 407 * (possibly because it is a corp contact) or it does not have a first name. 408 */ lookupFirstName(final Context context, final long contactId)409 public static String lookupFirstName(final Context context, final long contactId) { 410 if (isEnterpriseContactId(contactId)) { 411 return null; 412 } 413 String firstName = null; 414 Cursor nameCursor = null; 415 try { 416 nameCursor = ContactUtil.lookupStructuredName(context, contactId, true) 417 .performSynchronousQuery(); 418 if (nameCursor != null && nameCursor.moveToFirst()) { 419 firstName = nameCursor.getString(ContactUtil.INDEX_STRUCTURED_NAME_GIVEN_NAME); 420 } 421 } finally { 422 if (nameCursor != null) { 423 nameCursor.close(); 424 } 425 } 426 return firstName; 427 } 428 429 /** 430 * Creates a RecipientEntry from the provided data fields (from the contacts cursor). 431 * @param firstLevel whether this item is the first entry of this contact in the list. 432 */ 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)433 public static RecipientEntry createRecipientEntry(final String displayName, 434 final int displayNameSource, final String destination, final int destinationType, 435 final String destinationLabel, final long contactId, final String lookupKey, 436 final long dataId, final String photoThumbnailUri, final boolean firstLevel) { 437 if (firstLevel) { 438 return RecipientEntry.constructTopLevelEntry(displayName, displayNameSource, 439 destination, destinationType, destinationLabel, contactId, null, dataId, 440 photoThumbnailUri, true, lookupKey); 441 } else { 442 return RecipientEntry.constructSecondLevelEntry(displayName, displayNameSource, 443 destination, destinationType, destinationLabel, contactId, null, dataId, 444 photoThumbnailUri, true, lookupKey); 445 } 446 } 447 448 /** 449 * Creates a RecipientEntry for PhoneQuery result. The result is then displayed in the 450 * contact search drop down or as replacement chips in the chips edit box. 451 */ createRecipientEntryForPhoneQuery(final Cursor cursor, final boolean isFirstLevel)452 public static RecipientEntry createRecipientEntryForPhoneQuery(final Cursor cursor, 453 final boolean isFirstLevel) { 454 final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID); 455 final String displayName = cursor.getString( 456 ContactUtil.INDEX_DISPLAY_NAME); 457 final String photoThumbnailUri = cursor.getString( 458 ContactUtil.INDEX_PHOTO_URI); 459 final String destination = cursor.getString( 460 ContactUtil.INDEX_PHONE_EMAIL); 461 final int destinationType = cursor.getInt( 462 ContactUtil.INDEX_PHONE_EMAIL_TYPE); 463 final String destinationLabel = cursor.getString( 464 ContactUtil.INDEX_PHONE_EMAIL_LABEL); 465 final String lookupKey = cursor.getString( 466 ContactUtil.INDEX_LOOKUP_KEY); 467 468 // PhoneQuery uses the contact id as the data id ("_id"). 469 final long dataId = contactId; 470 471 return createRecipientEntry(displayName, 472 DisplayNameSources.STRUCTURED_NAME, destination, destinationType, 473 destinationLabel, contactId, lookupKey, dataId, photoThumbnailUri, 474 isFirstLevel); 475 } 476 477 /** 478 * Returns if a given contact id is valid. 479 */ isValidContactId(final long contactId)480 public static boolean isValidContactId(final long contactId) { 481 return contactId >= 0; 482 } 483 484 /** 485 * Returns if a given contact id belongs to managed profile. 486 */ isEnterpriseContactId(final long contactId)487 public static boolean isEnterpriseContactId(final long contactId) { 488 return isWorkProfileSupported() 489 && ContactsContract.Contacts.isEnterpriseContactId(contactId); 490 } 491 492 /** 493 * Returns if managed profile is supported. 494 */ isWorkProfileSupported()495 public static boolean isWorkProfileSupported() { 496 final PackageManager pm = Factory.get().getApplicationContext().getPackageManager(); 497 return pm.hasSystemFeature(PackageManager.FEATURE_MANAGED_USERS); 498 } 499 500 /** 501 * Returns Email lookup uri that will query both primary and corp profile 502 */ getEmailContentLookupUri()503 private static Uri getEmailContentLookupUri() { 504 if (isWorkProfileSupported() && OsUtil.isAtLeastM()) { 505 // TODO: use Email.ENTERPRISE_CONTENT_LOOKUP_URI, which will be available in M SDK API 506 return Uri.parse("content://com.android.contacts/data/emails/lookup_enterprise"); 507 } 508 return Email.CONTENT_LOOKUP_URI; 509 } 510 511 /** 512 * Returns PhoneLookup URI. 513 */ getPhoneLookupUri()514 public static Uri getPhoneLookupUri() { 515 // Apply it to M only 516 if (isWorkProfileSupported() && OsUtil.isAtLeastM()) { 517 return PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI; 518 } 519 return PhoneLookup.CONTENT_FILTER_URI; 520 } 521 hasReadContactsPermission()522 public static boolean hasReadContactsPermission() { 523 return OsUtil.hasPermission(Manifest.permission.READ_CONTACTS); 524 } 525 } 526