Home | History | Annotate | Download | only in contacts
      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);
    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