1 /* 2 * Copyright (C) 2011 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 package com.android.providers.contacts; 17 18 import android.content.ContentValues; 19 import android.database.Cursor; 20 import android.database.sqlite.SQLiteDatabase; 21 import android.os.SystemClock; 22 import android.provider.ContactsContract.CommonDataKinds.Email; 23 import android.provider.ContactsContract.CommonDataKinds.Nickname; 24 import android.provider.ContactsContract.CommonDataKinds.Organization; 25 import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; 26 import android.provider.ContactsContract.Data; 27 import android.provider.ContactsContract.ProviderStatus; 28 import android.provider.ContactsContract.RawContacts; 29 import android.text.TextUtils; 30 import android.util.Log; 31 32 import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns; 33 import com.android.providers.contacts.ContactsDatabaseHelper.MimetypesColumns; 34 import com.android.providers.contacts.ContactsDatabaseHelper.RawContactsColumns; 35 import com.android.providers.contacts.ContactsDatabaseHelper.SearchIndexColumns; 36 import com.android.providers.contacts.ContactsDatabaseHelper.Tables; 37 import com.google.android.collect.Lists; 38 import com.google.common.annotations.VisibleForTesting; 39 40 import java.util.ArrayList; 41 import java.util.HashSet; 42 import java.util.List; 43 import java.util.Set; 44 import java.util.regex.Pattern; 45 46 /** 47 * Maintains a search index for comprehensive contact search. 48 */ 49 public class SearchIndexManager { 50 private static final String TAG = "ContactsFTS"; 51 52 private static final boolean VERBOSE_LOGGING = Log.isLoggable(TAG, Log.VERBOSE); 53 54 public static final String PROPERTY_SEARCH_INDEX_VERSION = "search_index"; 55 private static final int SEARCH_INDEX_VERSION = 1; 56 57 private static final class ContactIndexQuery { 58 public static final String[] COLUMNS = { 59 Data.CONTACT_ID, 60 MimetypesColumns.MIMETYPE, 61 Data.DATA1, Data.DATA2, Data.DATA3, Data.DATA4, Data.DATA5, 62 Data.DATA6, Data.DATA7, Data.DATA8, Data.DATA9, Data.DATA10, Data.DATA11, 63 Data.DATA12, Data.DATA13, Data.DATA14 64 }; 65 66 public static final int MIMETYPE = 1; 67 } 68 69 public static class IndexBuilder { 70 public static final int SEPARATOR_SPACE = 0; 71 public static final int SEPARATOR_PARENTHESES = 1; 72 public static final int SEPARATOR_SLASH = 2; 73 public static final int SEPARATOR_COMMA = 3; 74 75 private StringBuilder mSbContent = new StringBuilder(); 76 private StringBuilder mSbName = new StringBuilder(); 77 private StringBuilder mSbTokens = new StringBuilder(); 78 private StringBuilder mSbElementContent = new StringBuilder(); 79 private HashSet<String> mUniqueElements = new HashSet<String>(); 80 private Cursor mCursor; 81 82 void setCursor(Cursor cursor) { 83 this.mCursor = cursor; 84 } 85 86 void reset() { 87 mSbContent.setLength(0); 88 mSbTokens.setLength(0); 89 mSbName.setLength(0); 90 mSbElementContent.setLength(0); 91 mUniqueElements.clear(); 92 } 93 94 public String getContent() { 95 return mSbContent.length() == 0 ? null : mSbContent.toString(); 96 } 97 98 public String getName() { 99 return mSbName.length() == 0 ? null : mSbName.toString(); 100 } 101 102 public String getTokens() { 103 return mSbTokens.length() == 0 ? null : mSbTokens.toString(); 104 } 105 106 public String getString(String columnName) { 107 return mCursor.getString(mCursor.getColumnIndex(columnName)); 108 } 109 110 public int getInt(String columnName) { 111 return mCursor.getInt(mCursor.getColumnIndex(columnName)); 112 } 113 114 @Override 115 public String toString() { 116 return "Content: " + mSbContent + "\n Name: " + mSbTokens + "\n Tokens: " + mSbTokens; 117 } 118 119 public void commit() { 120 if (mSbElementContent.length() != 0) { 121 String content = mSbElementContent.toString().replace('\n', ' '); 122 if (!mUniqueElements.contains(content)) { 123 if (mSbContent.length() != 0) { 124 mSbContent.append('\n'); 125 } 126 mSbContent.append(content); 127 mUniqueElements.add(content); 128 } 129 mSbElementContent.setLength(0); 130 } 131 } 132 133 public void appendContentFromColumn(String columnName) { 134 appendContentFromColumn(columnName, SEPARATOR_SPACE); 135 } 136 137 public void appendContentFromColumn(String columnName, int format) { 138 appendContent(getString(columnName), format); 139 } 140 141 public void appendContent(String value) { 142 appendContent(value, SEPARATOR_SPACE); 143 } 144 145 private void appendContent(String value, int format) { 146 if (TextUtils.isEmpty(value)) { 147 return; 148 } 149 150 switch (format) { 151 case SEPARATOR_SPACE: 152 if (mSbElementContent.length() > 0) { 153 mSbElementContent.append(' '); 154 } 155 mSbElementContent.append(value); 156 break; 157 158 case SEPARATOR_SLASH: 159 mSbElementContent.append('/').append(value); 160 break; 161 162 case SEPARATOR_PARENTHESES: 163 if (mSbElementContent.length() > 0) { 164 mSbElementContent.append(' '); 165 } 166 mSbElementContent.append('(').append(value).append(')'); 167 break; 168 169 case SEPARATOR_COMMA: 170 if (mSbElementContent.length() > 0) { 171 mSbElementContent.append(", "); 172 } 173 mSbElementContent.append(value); 174 break; 175 } 176 } 177 178 public void appendToken(String token) { 179 if (TextUtils.isEmpty(token)) { 180 return; 181 } 182 183 if (mSbTokens.length() != 0) { 184 mSbTokens.append(' '); 185 } 186 mSbTokens.append(token); 187 } 188 189 public void appendName(String name) { 190 if (TextUtils.isEmpty(name)) { 191 return; 192 } 193 // First, put the original name. 194 appendNameInternal(name); 195 196 // Then, if the name contains more than one FTS token, put each token into the index 197 // too. 198 // 199 // This is to make names with special characters searchable, such as "double-barrelled" 200 // "L'Image". 201 // 202 // Here's how it works: 203 // Because we "normalize" names when putting into the index, if we only put 204 // "double-barrelled", the index will only contain "doublebarrelled". 205 // Now, if the user searches for "double-barrelled", the searcher tokenizes it into 206 // two tokens, "double" and "barrelled". The first one matches "doublebarrelled" 207 // but the second one doesn't (because we only do the prefix match), so 208 // "doublebarrelled" doesn't match. 209 // So, here, we put each token in a name into the index too. In the case above, 210 // we put also "double" and "barrelled". 211 // With this, queries such as "double-barrelled", "double barrelled", "doublebarrelled" 212 // will all match "double-barrelled". 213 final List<String> nameParts = splitIntoFtsTokens(name); 214 if (nameParts.size() > 1) { 215 for (String namePart : nameParts) { 216 if (!TextUtils.isEmpty(namePart)) { 217 appendNameInternal(namePart); 218 } 219 } 220 } 221 } 222 223 /** 224 * Normalize a name and add to {@link #mSbName} 225 */ 226 private void appendNameInternal(String name) { 227 if (mSbName.length() != 0) { 228 mSbName.append(' '); 229 } 230 mSbName.append(NameNormalizer.normalize(name)); 231 } 232 } 233 234 private final ContactsProvider2 mContactsProvider; 235 private final ContactsDatabaseHelper mDbHelper; 236 private StringBuilder mSb = new StringBuilder(); 237 private IndexBuilder mIndexBuilder = new IndexBuilder(); 238 private ContentValues mValues = new ContentValues(); 239 private String[] mSelectionArgs1 = new String[1]; 240 241 public SearchIndexManager(ContactsProvider2 contactsProvider) { 242 this.mContactsProvider = contactsProvider; 243 mDbHelper = (ContactsDatabaseHelper) mContactsProvider.getDatabaseHelper(); 244 } 245 246 public void updateIndex(boolean force) { 247 if (force) { 248 setSearchIndexVersion(0); 249 } else { 250 if (getSearchIndexVersion() == SEARCH_INDEX_VERSION) { 251 return; 252 } 253 } 254 SQLiteDatabase db = mDbHelper.getWritableDatabase(); 255 db.beginTransaction(); 256 try { 257 // We do a version check again, because the version might have been modified after 258 // the first check. We need to do the check again in a transaction to make sure. 259 if (getSearchIndexVersion() != SEARCH_INDEX_VERSION) { 260 rebuildIndex(db); 261 setSearchIndexVersion(SEARCH_INDEX_VERSION); 262 db.setTransactionSuccessful(); 263 } 264 } finally { 265 db.endTransaction(); 266 } 267 } 268 269 private void rebuildIndex(SQLiteDatabase db) { 270 mContactsProvider.setProviderStatus(ProviderStatus.STATUS_UPGRADING); 271 final long start = SystemClock.elapsedRealtime(); 272 int count = 0; 273 try { 274 mDbHelper.createSearchIndexTable(db, true); 275 count = buildAndInsertIndex(db, null); 276 } finally { 277 mContactsProvider.setProviderStatus(ProviderStatus.STATUS_NORMAL); 278 279 final long end = SystemClock.elapsedRealtime(); 280 Log.i(TAG, "Rebuild contact search index in " + (end - start) + "ms, " 281 + count + " contacts"); 282 } 283 } 284 285 public void updateIndexForRawContacts(Set<Long> contactIds, Set<Long> rawContactIds) { 286 if (VERBOSE_LOGGING) { 287 Log.v(TAG, "Updating search index for " + contactIds.size() + 288 " contacts / " + rawContactIds.size() + " raw contacts"); 289 } 290 StringBuilder sb = new StringBuilder(); 291 sb.append("("); 292 if (!contactIds.isEmpty()) { 293 sb.append(RawContacts.CONTACT_ID + " IN ("); 294 for (Long contactId : contactIds) { 295 sb.append(contactId).append(","); 296 } 297 sb.setLength(sb.length() - 1); 298 sb.append(')'); 299 } 300 301 if (!rawContactIds.isEmpty()) { 302 if (!contactIds.isEmpty()) { 303 sb.append(" OR "); 304 } 305 sb.append(RawContactsColumns.CONCRETE_ID + " IN ("); 306 for (Long rawContactId : rawContactIds) { 307 sb.append(rawContactId).append(","); 308 } 309 sb.setLength(sb.length() - 1); 310 sb.append(')'); 311 } 312 313 sb.append(")"); 314 315 // The selection to select raw_contacts. 316 final String rawContactsSelection = sb.toString(); 317 318 // Remove affected search_index rows. 319 final SQLiteDatabase db = mDbHelper.getWritableDatabase(); 320 final int deleted = db.delete(Tables.SEARCH_INDEX, 321 SearchIndexColumns.CONTACT_ID + " IN (SELECT " + 322 RawContacts.CONTACT_ID + 323 " FROM " + Tables.RAW_CONTACTS + 324 " WHERE " + rawContactsSelection + 325 ")" 326 , null); 327 328 // Then rebuild index for them. 329 final int count = buildAndInsertIndex(db, rawContactsSelection); 330 if (VERBOSE_LOGGING) { 331 Log.v(TAG, "Updated search index for " + count + " contacts"); 332 } 333 } 334 335 private int buildAndInsertIndex(SQLiteDatabase db, String selection) { 336 mSb.setLength(0); 337 mSb.append(Data.CONTACT_ID + ", "); 338 mSb.append("(CASE WHEN " + DataColumns.MIMETYPE_ID + "="); 339 mSb.append(mDbHelper.getMimeTypeId(Nickname.CONTENT_ITEM_TYPE)); 340 mSb.append(" THEN -4 "); 341 mSb.append(" WHEN " + DataColumns.MIMETYPE_ID + "="); 342 mSb.append(mDbHelper.getMimeTypeId(Organization.CONTENT_ITEM_TYPE)); 343 mSb.append(" THEN -3 "); 344 mSb.append(" WHEN " + DataColumns.MIMETYPE_ID + "="); 345 mSb.append(mDbHelper.getMimeTypeId(StructuredPostal.CONTENT_ITEM_TYPE)); 346 mSb.append(" THEN -2"); 347 mSb.append(" WHEN " + DataColumns.MIMETYPE_ID + "="); 348 mSb.append(mDbHelper.getMimeTypeId(Email.CONTENT_ITEM_TYPE)); 349 mSb.append(" THEN -1"); 350 mSb.append(" ELSE " + DataColumns.MIMETYPE_ID); 351 mSb.append(" END), " + Data.IS_SUPER_PRIMARY + ", " + DataColumns.CONCRETE_ID); 352 353 int count = 0; 354 Cursor cursor = db.query(Tables.DATA_JOIN_MIMETYPE_RAW_CONTACTS, ContactIndexQuery.COLUMNS, 355 selection, null, null, null, mSb.toString()); 356 mIndexBuilder.setCursor(cursor); 357 mIndexBuilder.reset(); 358 try { 359 long currentContactId = -1; 360 while (cursor.moveToNext()) { 361 long contactId = cursor.getLong(0); 362 if (contactId != currentContactId) { 363 if (currentContactId != -1) { 364 insertIndexRow(db, currentContactId, mIndexBuilder); 365 count++; 366 } 367 currentContactId = contactId; 368 mIndexBuilder.reset(); 369 } 370 String mimetype = cursor.getString(ContactIndexQuery.MIMETYPE); 371 DataRowHandler dataRowHandler = mContactsProvider.getDataRowHandler(mimetype); 372 if (dataRowHandler.hasSearchableData()) { 373 dataRowHandler.appendSearchableData(mIndexBuilder); 374 mIndexBuilder.commit(); 375 } 376 } 377 if (currentContactId != -1) { 378 insertIndexRow(db, currentContactId, mIndexBuilder); 379 count++; 380 } 381 } finally { 382 cursor.close(); 383 } 384 return count; 385 } 386 387 private void insertIndexRow(SQLiteDatabase db, long contactId, IndexBuilder builder) { 388 mValues.clear(); 389 mValues.put(SearchIndexColumns.CONTENT, builder.getContent()); 390 mValues.put(SearchIndexColumns.NAME, builder.getName()); 391 mValues.put(SearchIndexColumns.TOKENS, builder.getTokens()); 392 mValues.put(SearchIndexColumns.CONTACT_ID, contactId); 393 db.insert(Tables.SEARCH_INDEX, null, mValues); 394 } 395 private int getSearchIndexVersion() { 396 return Integer.parseInt(mDbHelper.getProperty(PROPERTY_SEARCH_INDEX_VERSION, "0")); 397 } 398 399 private void setSearchIndexVersion(int version) { 400 mDbHelper.setProperty(PROPERTY_SEARCH_INDEX_VERSION, String.valueOf(version)); 401 } 402 403 /** 404 * Token separator that matches SQLite's "simple" tokenizer. 405 * - Unicode codepoints >= 128: Everything 406 * - Unicode codepoints < 128: Alphanumeric and "_" 407 * - Everything else is a separator of tokens 408 */ 409 private static final Pattern FTS_TOKEN_SEPARATOR_RE = 410 Pattern.compile("[^\u0080-\uffff\\p{Alnum}_]"); 411 412 /** 413 * Tokenize a string in the way as that of SQLite's "simple" tokenizer. 414 */ 415 @VisibleForTesting 416 static List<String> splitIntoFtsTokens(String s) { 417 final ArrayList<String> ret = Lists.newArrayList(); 418 for (String token : FTS_TOKEN_SEPARATOR_RE.split(s)) { 419 if (!TextUtils.isEmpty(token)) { 420 ret.add(token); 421 } 422 } 423 return ret; 424 } 425 426 /** 427 * Tokenizes the query and normalizes/hex encodes each token. The tokenizer uses the same 428 * rules as SQLite's "simple" tokenizer. Each token is added to the retokenizer and then 429 * returned as a String. 430 * @see FtsQueryBuilder#UNSCOPED_NORMALIZING 431 * @see FtsQueryBuilder#SCOPED_NAME_NORMALIZING 432 */ 433 public static String getFtsMatchQuery(String query, FtsQueryBuilder ftsQueryBuilder) { 434 final StringBuilder result = new StringBuilder(); 435 for (String token : splitIntoFtsTokens(query)) { 436 ftsQueryBuilder.addToken(result, token); 437 } 438 return result.toString(); 439 } 440 441 public static abstract class FtsQueryBuilder { 442 public abstract void addToken(StringBuilder builder, String token); 443 444 /** Normalizes and space-concatenates each token. Example: "a1b2c1* a2b3c2*" */ 445 public static final FtsQueryBuilder UNSCOPED_NORMALIZING = new UnscopedNormalizingBuilder(); 446 447 /** 448 * Scopes each token to a column and normalizes the name. 449 * Example: "content:foo* name:a1b2c1* tokens:foo* content:bar* name:a2b3c2* tokens:bar*" 450 */ 451 public static final FtsQueryBuilder SCOPED_NAME_NORMALIZING = 452 new ScopedNameNormalizingBuilder(); 453 454 /** 455 * Scopes each token to a the content column and also for name with normalization. 456 * Also adds a user-defined expression to each token. This allows common criteria to be 457 * concatenated to each token. 458 * Example (commonCriteria=" OR tokens:123*"): 459 * "content:650* OR name:1A1B1C* OR tokens:123* content:2A2B2C* OR name:foo* OR tokens:123*" 460 */ 461 public static FtsQueryBuilder getDigitsQueryBuilder(final String commonCriteria) { 462 return new FtsQueryBuilder() { 463 @Override 464 public void addToken(StringBuilder builder, String token) { 465 if (builder.length() != 0) builder.append(' '); 466 467 builder.append("content:"); 468 builder.append(token); 469 builder.append("* "); 470 471 final String normalizedToken = NameNormalizer.normalize(token); 472 if (!TextUtils.isEmpty(normalizedToken)) { 473 builder.append(" OR name:"); 474 builder.append(normalizedToken); 475 builder.append('*'); 476 } 477 478 builder.append(commonCriteria); 479 } 480 }; 481 } 482 } 483 484 private static class UnscopedNormalizingBuilder extends FtsQueryBuilder { 485 @Override 486 public void addToken(StringBuilder builder, String token) { 487 if (builder.length() != 0) builder.append(' '); 488 489 // the token could be empty (if the search query was "_"). we should still emit it 490 // here, as we otherwise risk to end up with an empty MATCH-expression MATCH "" 491 builder.append(NameNormalizer.normalize(token)); 492 builder.append('*'); 493 } 494 } 495 496 private static class ScopedNameNormalizingBuilder extends FtsQueryBuilder { 497 @Override 498 public void addToken(StringBuilder builder, String token) { 499 if (builder.length() != 0) builder.append(' '); 500 501 builder.append("content:"); 502 builder.append(token); 503 builder.append('*'); 504 505 final String normalizedToken = NameNormalizer.normalize(token); 506 if (!TextUtils.isEmpty(normalizedToken)) { 507 builder.append(" OR name:"); 508 builder.append(normalizedToken); 509 builder.append('*'); 510 } 511 512 builder.append(" OR tokens:"); 513 builder.append(token); 514 builder.append("*"); 515 } 516 } 517 } 518