1 /* 2 * Copyright (C) 2009 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.providers.contacts; 18 19 import com.android.common.ArrayListCursor; 20 import com.android.providers.contacts.ContactsDatabaseHelper.AggregatedPresenceColumns; 21 import com.android.providers.contacts.ContactsDatabaseHelper.ContactsColumns; 22 import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns; 23 import com.android.providers.contacts.ContactsDatabaseHelper.RawContactsColumns; 24 import com.android.providers.contacts.ContactsDatabaseHelper.Tables; 25 26 import android.app.SearchManager; 27 import android.content.ContentUris; 28 import android.content.res.Resources; 29 import android.database.Cursor; 30 import android.database.sqlite.SQLiteDatabase; 31 import android.net.Uri; 32 import android.provider.Contacts.Intents; 33 import android.provider.ContactsContract.Contacts; 34 import android.provider.ContactsContract.Data; 35 import android.provider.ContactsContract.RawContacts; 36 import android.provider.ContactsContract.StatusUpdates; 37 import android.provider.ContactsContract.CommonDataKinds.Email; 38 import android.provider.ContactsContract.CommonDataKinds.Organization; 39 import android.provider.ContactsContract.CommonDataKinds.Phone; 40 import android.provider.ContactsContract.CommonDataKinds.StructuredName; 41 import android.provider.ContactsContract.Contacts.Photo; 42 import android.text.TextUtils; 43 44 import java.util.ArrayList; 45 import java.util.Collections; 46 import java.util.Comparator; 47 import java.util.HashMap; 48 49 /** 50 * Support for global search integration for Contacts. 51 */ 52 public class GlobalSearchSupport { 53 54 private static final String[] SEARCH_SUGGESTIONS_BASED_ON_PHONE_NUMBER_COLUMNS = { 55 "_id", 56 SearchManager.SUGGEST_COLUMN_TEXT_1, 57 SearchManager.SUGGEST_COLUMN_TEXT_2, 58 SearchManager.SUGGEST_COLUMN_ICON_1, 59 SearchManager.SUGGEST_COLUMN_INTENT_DATA, 60 SearchManager.SUGGEST_COLUMN_INTENT_ACTION, 61 SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, 62 }; 63 64 private static final String[] SEARCH_SUGGESTIONS_BASED_ON_NAME_COLUMNS = { 65 "_id", 66 SearchManager.SUGGEST_COLUMN_TEXT_1, 67 SearchManager.SUGGEST_COLUMN_TEXT_2, 68 SearchManager.SUGGEST_COLUMN_ICON_1, 69 SearchManager.SUGGEST_COLUMN_ICON_2, 70 SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID, 71 SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, 72 }; 73 74 private interface SearchSuggestionQuery { 75 public static final String TABLE = "data " 76 + " JOIN raw_contacts ON (data.raw_contact_id = raw_contacts._id) " 77 + " JOIN contacts ON (raw_contacts.contact_id = contacts._id)" 78 + " JOIN " + Tables.RAW_CONTACTS + " AS name_raw_contact ON (" 79 + Contacts.NAME_RAW_CONTACT_ID + "=name_raw_contact." + RawContacts._ID + ")"; 80 81 public static final String PRESENCE_SQL = 82 "(SELECT " + StatusUpdates.PRESENCE_STATUS + 83 " FROM " + Tables.AGGREGATED_PRESENCE + 84 " WHERE " + AggregatedPresenceColumns.CONTACT_ID 85 + "=" + ContactsColumns.CONCRETE_ID + ")"; 86 87 public static final String[] COLUMNS = { 88 ContactsColumns.CONCRETE_ID + " AS " + Contacts._ID, 89 "name_raw_contact." + RawContactsColumns.DISPLAY_NAME 90 + " AS " + Contacts.DISPLAY_NAME, 91 PRESENCE_SQL + " AS " + Contacts.CONTACT_PRESENCE, 92 DataColumns.CONCRETE_ID + " AS data_id", 93 DataColumns.MIMETYPE_ID, 94 Data.IS_SUPER_PRIMARY, 95 Data.DATA1, 96 Contacts.PHOTO_ID, 97 Contacts.LOOKUP_KEY, 98 }; 99 100 public static final int CONTACT_ID = 0; 101 public static final int DISPLAY_NAME = 1; 102 public static final int PRESENCE_STATUS = 2; 103 public static final int DATA_ID = 3; 104 public static final int MIMETYPE_ID = 4; 105 public static final int IS_SUPER_PRIMARY = 5; 106 public static final int ORGANIZATION = 6; 107 public static final int EMAIL = 6; 108 public static final int PHONE = 6; 109 public static final int PHOTO_ID = 7; 110 public static final int LOOKUP_KEY = 8; 111 } 112 113 private static class SearchSuggestion { 114 long contactId; 115 boolean titleIsName; 116 String organization; 117 String email; 118 String phoneNumber; 119 Uri photoUri; 120 String lookupKey; 121 String normalizedName; 122 int presence = -1; 123 boolean processed; 124 String text1; 125 String text2; 126 String icon1; 127 String icon2; 128 129 public SearchSuggestion(long contactId) { 130 this.contactId = contactId; 131 } 132 133 private void process() { 134 if (processed) { 135 return; 136 } 137 138 boolean hasOrganization = !TextUtils.isEmpty(organization); 139 boolean hasEmail = !TextUtils.isEmpty(email); 140 boolean hasPhone = !TextUtils.isEmpty(phoneNumber); 141 142 boolean titleIsOrganization = !titleIsName && hasOrganization; 143 boolean titleIsEmail = !titleIsName && !titleIsOrganization && hasEmail; 144 boolean titleIsPhone = !titleIsName && !titleIsOrganization && !titleIsEmail 145 && hasPhone; 146 147 if (!titleIsOrganization && hasOrganization) { 148 text2 = organization; 149 } else if (!titleIsPhone && hasPhone) { 150 text2 = phoneNumber; 151 } else if (!titleIsEmail && hasEmail) { 152 text2 = email; 153 } 154 155 if (photoUri != null) { 156 icon1 = photoUri.toString(); 157 } else { 158 icon1 = String.valueOf(com.android.internal.R.drawable.ic_contact_picture); 159 } 160 161 if (presence != -1) { 162 icon2 = String.valueOf(StatusUpdates.getPresenceIconResourceId(presence)); 163 } 164 165 processed = true; 166 } 167 168 /** 169 * Returns key for sorting search suggestions. 170 * 171 * <p>TODO: switch to new sort key 172 */ 173 public String getSortKey() { 174 if (normalizedName == null) { 175 process(); 176 normalizedName = text1 == null ? "" : NameNormalizer.normalize(text1); 177 } 178 return normalizedName; 179 } 180 181 @SuppressWarnings({"unchecked"}) 182 public ArrayList asList(String[] projection) { 183 process(); 184 185 ArrayList<Object> list = new ArrayList<Object>(); 186 if (projection == null) { 187 list.add(contactId); 188 list.add(text1); 189 list.add(text2); 190 list.add(icon1); 191 list.add(icon2); 192 list.add(lookupKey); 193 list.add(lookupKey); 194 } else { 195 for (int i = 0; i < projection.length; i++) { 196 addColumnValue(list, projection[i]); 197 } 198 } 199 return list; 200 } 201 202 private void addColumnValue(ArrayList<Object> list, String column) { 203 if ("_id".equals(column)) { 204 list.add(contactId); 205 } else if (SearchManager.SUGGEST_COLUMN_TEXT_1.equals(column)) { 206 list.add(text1); 207 } else if (SearchManager.SUGGEST_COLUMN_TEXT_2.equals(column)) { 208 list.add(text2); 209 } else if (SearchManager.SUGGEST_COLUMN_ICON_1.equals(column)) { 210 list.add(icon1); 211 } else if (SearchManager.SUGGEST_COLUMN_ICON_2.equals(column)) { 212 list.add(icon2); 213 } else if (SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID.equals(column)) { 214 list.add(lookupKey); 215 } else if (SearchManager.SUGGEST_COLUMN_SHORTCUT_ID.equals(column)) { 216 list.add(lookupKey); 217 } else { 218 throw new IllegalArgumentException("Invalid column name: " + column); 219 } 220 } 221 } 222 223 private final ContactsProvider2 mContactsProvider; 224 private boolean mMimeTypeIdsLoaded; 225 private long mMimeTypeIdEmail; 226 private long mMimeTypeIdStructuredName; 227 private long mMimeTypeIdOrganization; 228 private long mMimeTypeIdPhone; 229 230 @SuppressWarnings("all") 231 public GlobalSearchSupport(ContactsProvider2 contactsProvider) { 232 mContactsProvider = contactsProvider; 233 234 // To ensure the data column position. This is dead code if properly configured. 235 if (Organization.COMPANY != Data.DATA1 || Phone.NUMBER != Data.DATA1 236 || Email.DATA != Data.DATA1) { 237 throw new AssertionError("Some of ContactsContract.CommonDataKinds class primary" 238 + " data is not in DATA1 column"); 239 } 240 } 241 242 private void ensureMimetypeIdsLoaded() { 243 if (!mMimeTypeIdsLoaded) { 244 ContactsDatabaseHelper dbHelper = (ContactsDatabaseHelper)mContactsProvider 245 .getDatabaseHelper(); 246 mMimeTypeIdStructuredName = dbHelper.getMimeTypeId(StructuredName.CONTENT_ITEM_TYPE); 247 mMimeTypeIdOrganization = dbHelper.getMimeTypeId(Organization.CONTENT_ITEM_TYPE); 248 mMimeTypeIdPhone = dbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE); 249 mMimeTypeIdEmail = dbHelper.getMimeTypeId(Email.CONTENT_ITEM_TYPE); 250 mMimeTypeIdsLoaded = true; 251 } 252 } 253 254 public Cursor handleSearchSuggestionsQuery(SQLiteDatabase db, Uri uri, String limit) { 255 if (uri.getPathSegments().size() <= 1) { 256 return null; 257 } 258 259 final String searchClause = uri.getLastPathSegment(); 260 if (TextUtils.isDigitsOnly(searchClause)) { 261 return buildCursorForSearchSuggestionsBasedOnPhoneNumber(searchClause); 262 } else { 263 return buildCursorForSearchSuggestionsBasedOnName(db, searchClause, limit); 264 } 265 } 266 267 /** 268 * Returns a search suggestions cursor for the contact bearing the provided lookup key. If the 269 * lookup key cannot be found in the database, the contact name is decoded from the lookup key 270 * and used to re-identify the contact. If the contact still cannot be found, an empty cursor 271 * is returned. 272 * 273 * <p>Note that if {@code lookupKey} is not a valid lookup key, an empty cursor is returned 274 * silently. This would occur with old-style shortcuts that were created using the contact id 275 * instead of the lookup key. 276 */ 277 public Cursor handleSearchShortcutRefresh(SQLiteDatabase db, String lookupKey, 278 String[] projection) { 279 ensureMimetypeIdsLoaded(); 280 long contactId; 281 try { 282 contactId = mContactsProvider.lookupContactIdByLookupKey(db, lookupKey); 283 } catch (IllegalArgumentException e) { 284 contactId = -1L; 285 } 286 StringBuilder sb = new StringBuilder(); 287 sb.append(mContactsProvider.getContactsRestrictions()); 288 appendMimeTypeFilter(sb); 289 sb.append(" AND " + ContactsColumns.CONCRETE_ID + "=" + contactId); 290 return buildCursorForSearchSuggestions(db, sb.toString(), projection, null); 291 } 292 293 private Cursor buildCursorForSearchSuggestionsBasedOnPhoneNumber(String searchClause) { 294 Resources r = mContactsProvider.getContext().getResources(); 295 String s; 296 int i; 297 298 ArrayList<Object> dialNumber = new ArrayList<Object>(); 299 dialNumber.add(0); // _id 300 s = r.getString(com.android.internal.R.string.dial_number_using, searchClause); 301 i = s.indexOf('\n'); 302 if (i < 0) { 303 dialNumber.add(s); 304 dialNumber.add(""); 305 } else { 306 dialNumber.add(s.substring(0, i)); 307 dialNumber.add(s.substring(i + 1)); 308 } 309 dialNumber.add(String.valueOf(com.android.internal.R.drawable.call_contact)); 310 dialNumber.add("tel:" + searchClause); 311 dialNumber.add(Intents.SEARCH_SUGGESTION_DIAL_NUMBER_CLICKED); 312 dialNumber.add(null); 313 314 ArrayList<Object> createContact = new ArrayList<Object>(); 315 createContact.add(1); // _id 316 s = r.getString(com.android.internal.R.string.create_contact_using, searchClause); 317 i = s.indexOf('\n'); 318 if (i < 0) { 319 createContact.add(s); 320 createContact.add(""); 321 } else { 322 createContact.add(s.substring(0, i)); 323 createContact.add(s.substring(i + 1)); 324 } 325 createContact.add(String.valueOf(com.android.internal.R.drawable.create_contact)); 326 createContact.add("tel:" + searchClause); 327 createContact.add(Intents.SEARCH_SUGGESTION_CREATE_CONTACT_CLICKED); 328 createContact.add(SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT); 329 330 @SuppressWarnings({"unchecked"}) ArrayList<ArrayList> rows = new ArrayList<ArrayList>(); 331 rows.add(dialNumber); 332 rows.add(createContact); 333 334 return new ArrayListCursor(SEARCH_SUGGESTIONS_BASED_ON_PHONE_NUMBER_COLUMNS, rows); 335 } 336 337 private Cursor buildCursorForSearchSuggestionsBasedOnName(SQLiteDatabase db, 338 String searchClause, String limit) { 339 ensureMimetypeIdsLoaded(); 340 StringBuilder sb = new StringBuilder(); 341 sb.append(mContactsProvider.getContactsRestrictions()); 342 appendMimeTypeFilter(sb); 343 sb.append(" AND " + DataColumns.CONCRETE_RAW_CONTACT_ID + " IN "); 344 mContactsProvider.appendRawContactsByFilterAsNestedQuery(sb, searchClause); 345 346 /* 347 * Prepending "+" to the IN_VISIBLE_GROUP column disables the index on the 348 * that column. The logic is this: let's say we have 10,000 contacts 349 * of which 500 are visible. The first letter we type narrows this down 350 * to 10,000/26 = 384, which is already less than 500 that we would get 351 * from the IN_VISIBLE_GROUP index. Typing the second letter will narrow 352 * the search down to 10,000/26/26 = 14 contacts. And a lot of people 353 * will have more that 5% of their contacts visible, while the alphabet 354 * will always have 26 letters. 355 */ 356 sb.append(" AND " + "+" + Contacts.IN_VISIBLE_GROUP + "=1"); 357 String selection = sb.toString(); 358 359 return buildCursorForSearchSuggestions(db, selection, null, limit); 360 } 361 362 private void appendMimeTypeFilter(StringBuilder sb) { 363 364 /* 365 * The "+" syntax prevents the mime type index from being used - we just want 366 * to reduce the size of the result set, not actually search by mime types. 367 */ 368 sb.append(" AND " + "+" + DataColumns.MIMETYPE_ID + " IN (" + mMimeTypeIdEmail + "," + 369 mMimeTypeIdOrganization + "," + mMimeTypeIdPhone + "," + 370 mMimeTypeIdStructuredName + ")"); 371 } 372 373 private Cursor buildCursorForSearchSuggestions(SQLiteDatabase db, 374 String selection, String[] projection, String limit) { 375 ArrayList<SearchSuggestion> suggestionList = new ArrayList<SearchSuggestion>(); 376 HashMap<Long, SearchSuggestion> suggestionMap = new HashMap<Long, SearchSuggestion>(); 377 Cursor c = db.query(false, SearchSuggestionQuery.TABLE, 378 SearchSuggestionQuery.COLUMNS, selection, null, null, null, null, limit); 379 try { 380 while (c.moveToNext()) { 381 382 long contactId = c.getLong(SearchSuggestionQuery.CONTACT_ID); 383 SearchSuggestion suggestion = suggestionMap.get(contactId); 384 if (suggestion == null) { 385 suggestion = new SearchSuggestion(contactId); 386 suggestionList.add(suggestion); 387 suggestionMap.put(contactId, suggestion); 388 } 389 390 boolean isSuperPrimary = c.getInt(SearchSuggestionQuery.IS_SUPER_PRIMARY) != 0; 391 suggestion.text1 = c.getString(SearchSuggestionQuery.DISPLAY_NAME); 392 393 if (!c.isNull(SearchSuggestionQuery.PRESENCE_STATUS)) { 394 suggestion.presence = c.getInt(SearchSuggestionQuery.PRESENCE_STATUS); 395 } 396 397 long mimetype = c.getLong(SearchSuggestionQuery.MIMETYPE_ID); 398 if (mimetype == mMimeTypeIdStructuredName) { 399 suggestion.titleIsName = true; 400 } else if (mimetype == mMimeTypeIdOrganization) { 401 if (isSuperPrimary || suggestion.organization == null) { 402 suggestion.organization = c.getString(SearchSuggestionQuery.ORGANIZATION); 403 } 404 } else if (mimetype == mMimeTypeIdEmail) { 405 if (isSuperPrimary || suggestion.email == null) { 406 suggestion.email = c.getString(SearchSuggestionQuery.EMAIL); 407 } 408 } else if (mimetype == mMimeTypeIdPhone) { 409 if (isSuperPrimary || suggestion.phoneNumber == null) { 410 suggestion.phoneNumber = c.getString(SearchSuggestionQuery.PHONE); 411 } 412 } 413 414 if (!c.isNull(SearchSuggestionQuery.PHOTO_ID)) { 415 suggestion.photoUri = Uri.withAppendedPath( 416 ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId), 417 Photo.CONTENT_DIRECTORY); 418 } 419 420 suggestion.lookupKey = c.getString(SearchSuggestionQuery.LOOKUP_KEY); 421 } 422 } finally { 423 c.close(); 424 } 425 426 Collections.sort(suggestionList, new Comparator<SearchSuggestion>() { 427 public int compare(SearchSuggestion row1, SearchSuggestion row2) { 428 return row1.getSortKey().compareTo(row2.getSortKey()); 429 } 430 }); 431 432 @SuppressWarnings({"unchecked"}) ArrayList<ArrayList> rows = new ArrayList<ArrayList>(); 433 for (int i = 0; i < suggestionList.size(); i++) { 434 rows.add(suggestionList.get(i).asList(projection)); 435 } 436 437 return new ArrayListCursor(projection != null ? projection 438 : SEARCH_SUGGESTIONS_BASED_ON_NAME_COLUMNS, rows); 439 } 440 } 441