| /* |
| * Copyright (C) 2011 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License |
| */ |
| package com.android.providers.contacts; |
| |
| import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns; |
| import com.android.providers.contacts.ContactsDatabaseHelper.MimetypesColumns; |
| import com.android.providers.contacts.ContactsDatabaseHelper.SearchIndexColumns; |
| import com.android.providers.contacts.ContactsDatabaseHelper.Tables; |
| |
| import android.content.ContentValues; |
| import android.database.Cursor; |
| import android.database.sqlite.SQLiteDatabase; |
| import android.os.SystemClock; |
| import android.provider.ContactsContract.CommonDataKinds.Email; |
| import android.provider.ContactsContract.CommonDataKinds.Nickname; |
| import android.provider.ContactsContract.CommonDataKinds.Organization; |
| import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; |
| import android.provider.ContactsContract.Data; |
| import android.provider.ContactsContract.ProviderStatus; |
| import android.text.TextUtils; |
| import android.util.Log; |
| |
| import java.util.HashSet; |
| import java.util.Set; |
| import java.util.regex.Pattern; |
| |
| /** |
| * Maintains a search index for comprehensive contact search. |
| */ |
| public class SearchIndexManager { |
| private static final String TAG = "ContactsFTS"; |
| |
| public static final String PROPERTY_SEARCH_INDEX_VERSION = "search_index"; |
| private static final int SEARCH_INDEX_VERSION = 1; |
| |
| private static final class ContactIndexQuery { |
| public static final String[] COLUMNS = { |
| Data.CONTACT_ID, |
| MimetypesColumns.MIMETYPE, |
| Data.DATA1, Data.DATA2, Data.DATA3, Data.DATA4, Data.DATA5, |
| Data.DATA6, Data.DATA7, Data.DATA8, Data.DATA9, Data.DATA10, Data.DATA11, |
| Data.DATA12, Data.DATA13, Data.DATA14 |
| }; |
| |
| public static final int MIMETYPE = 1; |
| } |
| |
| public static class IndexBuilder { |
| public static final int SEPARATOR_SPACE = 0; |
| public static final int SEPARATOR_PARENTHESES = 1; |
| public static final int SEPARATOR_SLASH = 2; |
| public static final int SEPARATOR_COMMA = 3; |
| |
| private StringBuilder mSbContent = new StringBuilder(); |
| private StringBuilder mSbName = new StringBuilder(); |
| private StringBuilder mSbTokens = new StringBuilder(); |
| private StringBuilder mSbElementContent = new StringBuilder(); |
| private HashSet<String> mUniqueElements = new HashSet<String>(); |
| private Cursor mCursor; |
| |
| void setCursor(Cursor cursor) { |
| this.mCursor = cursor; |
| } |
| |
| void reset() { |
| mSbContent.setLength(0); |
| mSbTokens.setLength(0); |
| mSbName.setLength(0); |
| mSbElementContent.setLength(0); |
| mUniqueElements.clear(); |
| } |
| |
| public String getContent() { |
| return mSbContent.length() == 0 ? null : mSbContent.toString(); |
| } |
| |
| public String getName() { |
| return mSbName.length() == 0 ? null : mSbName.toString(); |
| } |
| |
| public String getTokens() { |
| return mSbTokens.length() == 0 ? null : mSbTokens.toString(); |
| } |
| |
| public String getString(String columnName) { |
| return mCursor.getString(mCursor.getColumnIndex(columnName)); |
| } |
| |
| public int getInt(String columnName) { |
| return mCursor.getInt(mCursor.getColumnIndex(columnName)); |
| } |
| |
| @Override |
| public String toString() { |
| return "Content: " + mSbContent + "\n Name: " + mSbTokens + "\n Tokens: " + mSbTokens; |
| } |
| |
| public void commit() { |
| if (mSbElementContent.length() != 0) { |
| String content = mSbElementContent.toString().replace('\n', ' '); |
| if (!mUniqueElements.contains(content)) { |
| if (mSbContent.length() != 0) { |
| mSbContent.append('\n'); |
| } |
| mSbContent.append(content); |
| mUniqueElements.add(content); |
| } |
| mSbElementContent.setLength(0); |
| } |
| } |
| |
| public void appendContentFromColumn(String columnName) { |
| appendContentFromColumn(columnName, SEPARATOR_SPACE); |
| } |
| |
| public void appendContentFromColumn(String columnName, int format) { |
| appendContent(getString(columnName), format); |
| } |
| |
| public void appendContent(String value) { |
| appendContent(value, SEPARATOR_SPACE); |
| } |
| |
| public void appendContent(String value, int format) { |
| if (TextUtils.isEmpty(value)) { |
| return; |
| } |
| |
| switch (format) { |
| case SEPARATOR_SPACE: |
| if (mSbElementContent.length() > 0) { |
| mSbElementContent.append(' '); |
| } |
| mSbElementContent.append(value); |
| break; |
| |
| case SEPARATOR_SLASH: |
| mSbElementContent.append('/').append(value); |
| break; |
| |
| case SEPARATOR_PARENTHESES: |
| if (mSbElementContent.length() > 0) { |
| mSbElementContent.append(' '); |
| } |
| mSbElementContent.append('(').append(value).append(')'); |
| break; |
| |
| case SEPARATOR_COMMA: |
| if (mSbElementContent.length() > 0) { |
| mSbElementContent.append(", "); |
| } |
| mSbElementContent.append(value); |
| break; |
| } |
| } |
| |
| public void appendToken(String token) { |
| if (TextUtils.isEmpty(token)) { |
| return; |
| } |
| |
| if (mSbTokens.length() != 0) { |
| mSbTokens.append(' '); |
| } |
| mSbTokens.append(token); |
| } |
| |
| private static final Pattern PATTERN_HYPHEN = Pattern.compile("\\-"); |
| |
| public void appendName(String name) { |
| if (TextUtils.isEmpty(name)) { |
| return; |
| } |
| if (name.indexOf('-') < 0) { |
| // Common case -- no hyphens in it. |
| appendNameInternal(name); |
| } else { |
| // In order to make hyphenated names searchable, let's split names with '-'. |
| for (String namePart : PATTERN_HYPHEN.split(name)) { |
| if (!TextUtils.isEmpty(namePart)) { |
| appendNameInternal(namePart); |
| } |
| } |
| } |
| } |
| |
| private void appendNameInternal(String name) { |
| if (mSbName.length() != 0) { |
| mSbName.append(' '); |
| } |
| mSbName.append(NameNormalizer.normalize(name)); |
| } |
| } |
| |
| private final ContactsProvider2 mContactsProvider; |
| private final ContactsDatabaseHelper mDbHelper; |
| private StringBuilder mSb = new StringBuilder(); |
| private IndexBuilder mIndexBuilder = new IndexBuilder(); |
| private ContentValues mValues = new ContentValues(); |
| private String[] mSelectionArgs1 = new String[1]; |
| |
| public SearchIndexManager(ContactsProvider2 contactsProvider) { |
| this.mContactsProvider = contactsProvider; |
| mDbHelper = (ContactsDatabaseHelper) mContactsProvider.getDatabaseHelper(); |
| } |
| |
| public void updateIndex() { |
| if (getSearchIndexVersion() == SEARCH_INDEX_VERSION) { |
| return; |
| } |
| SQLiteDatabase db = mDbHelper.getWritableDatabase(); |
| db.beginTransaction(); |
| try { |
| if (getSearchIndexVersion() != SEARCH_INDEX_VERSION) { |
| rebuildIndex(db); |
| setSearchIndexVersion(SEARCH_INDEX_VERSION); |
| db.setTransactionSuccessful(); |
| } |
| } finally { |
| db.endTransaction(); |
| } |
| } |
| |
| private void rebuildIndex(SQLiteDatabase db) { |
| mContactsProvider.setProviderStatus(ProviderStatus.STATUS_UPGRADING); |
| long start = SystemClock.currentThreadTimeMillis(); |
| int count = 0; |
| try { |
| mDbHelper.createSearchIndexTable(db); |
| count = buildIndex(db, null, false); |
| } finally { |
| mContactsProvider.setProviderStatus(ProviderStatus.STATUS_NORMAL); |
| |
| long end = SystemClock.currentThreadTimeMillis(); |
| Log.i(TAG, "Rebuild contact search index in " + (end - start) + "ms, " |
| + count + " contacts"); |
| } |
| } |
| |
| public void updateIndexForRawContacts(Set<Long> contactIds, Set<Long> rawContactIds) { |
| mSb.setLength(0); |
| mSb.append("("); |
| if (!contactIds.isEmpty()) { |
| mSb.append(Data.CONTACT_ID + " IN ("); |
| for (Long contactId : contactIds) { |
| mSb.append(contactId).append(","); |
| } |
| mSb.setLength(mSb.length() - 1); |
| mSb.append(')'); |
| } |
| |
| if (!rawContactIds.isEmpty()) { |
| if (!contactIds.isEmpty()) { |
| mSb.append(" OR "); |
| } |
| mSb.append(Data.RAW_CONTACT_ID + " IN ("); |
| for (Long rawContactId : rawContactIds) { |
| mSb.append(rawContactId).append(","); |
| } |
| mSb.setLength(mSb.length() - 1); |
| mSb.append(')'); |
| } |
| |
| mSb.append(")"); |
| buildIndex(mDbHelper.getWritableDatabase(), mSb.toString(), true); |
| } |
| |
| private int buildIndex(SQLiteDatabase db, String selection, boolean replace) { |
| mSb.setLength(0); |
| mSb.append(Data.CONTACT_ID + ", "); |
| mSb.append("(CASE WHEN " + DataColumns.MIMETYPE_ID + "="); |
| mSb.append(mDbHelper.getMimeTypeId(Nickname.CONTENT_ITEM_TYPE)); |
| mSb.append(" THEN -4 "); |
| mSb.append(" WHEN " + DataColumns.MIMETYPE_ID + "="); |
| mSb.append(mDbHelper.getMimeTypeId(Organization.CONTENT_ITEM_TYPE)); |
| mSb.append(" THEN -3 "); |
| mSb.append(" WHEN " + DataColumns.MIMETYPE_ID + "="); |
| mSb.append(mDbHelper.getMimeTypeId(StructuredPostal.CONTENT_ITEM_TYPE)); |
| mSb.append(" THEN -2"); |
| mSb.append(" WHEN " + DataColumns.MIMETYPE_ID + "="); |
| mSb.append(mDbHelper.getMimeTypeId(Email.CONTENT_ITEM_TYPE)); |
| mSb.append(" THEN -1"); |
| mSb.append(" ELSE " + DataColumns.MIMETYPE_ID); |
| mSb.append(" END), " + Data.IS_SUPER_PRIMARY + ", " + DataColumns.CONCRETE_ID); |
| |
| int count = 0; |
| Cursor cursor = db.query(Tables.DATA_JOIN_MIMETYPE_RAW_CONTACTS, ContactIndexQuery.COLUMNS, |
| selection, null, null, null, mSb.toString()); |
| mIndexBuilder.setCursor(cursor); |
| mIndexBuilder.reset(); |
| try { |
| long currentContactId = -1; |
| while (cursor.moveToNext()) { |
| long contactId = cursor.getLong(0); |
| if (contactId != currentContactId) { |
| if (currentContactId != -1) { |
| saveContactIndex(db, currentContactId, mIndexBuilder, replace); |
| count++; |
| } |
| currentContactId = contactId; |
| mIndexBuilder.reset(); |
| } |
| String mimetype = cursor.getString(ContactIndexQuery.MIMETYPE); |
| DataRowHandler dataRowHandler = mContactsProvider.getDataRowHandler(mimetype); |
| if (dataRowHandler.hasSearchableData()) { |
| dataRowHandler.appendSearchableData(mIndexBuilder); |
| mIndexBuilder.commit(); |
| } |
| } |
| if (currentContactId != -1) { |
| saveContactIndex(db, currentContactId, mIndexBuilder, replace); |
| count++; |
| } |
| } finally { |
| cursor.close(); |
| } |
| return count; |
| } |
| |
| private void saveContactIndex( |
| SQLiteDatabase db, long contactId, IndexBuilder builder, boolean replace) { |
| mValues.clear(); |
| mValues.put(SearchIndexColumns.CONTENT, builder.getContent()); |
| mValues.put(SearchIndexColumns.NAME, builder.getName()); |
| mValues.put(SearchIndexColumns.TOKENS, builder.getTokens()); |
| if (replace) { |
| mSelectionArgs1[0] = String.valueOf(contactId); |
| int count = db.update(Tables.SEARCH_INDEX, mValues, |
| SearchIndexColumns.CONTACT_ID + "=CAST(? AS int)", mSelectionArgs1); |
| if (count == 0) { |
| mValues.put(SearchIndexColumns.CONTACT_ID, contactId); |
| db.insert(Tables.SEARCH_INDEX, null, mValues); |
| } |
| } else { |
| mValues.put(SearchIndexColumns.CONTACT_ID, contactId); |
| db.insert(Tables.SEARCH_INDEX, null, mValues); |
| } |
| } |
| private int getSearchIndexVersion() { |
| return Integer.parseInt(mDbHelper.getProperty(PROPERTY_SEARCH_INDEX_VERSION, "0")); |
| } |
| |
| private void setSearchIndexVersion(int version) { |
| mDbHelper.setProperty(PROPERTY_SEARCH_INDEX_VERSION, String.valueOf(version)); |
| } |
| |
| /** |
| * Tokenizes the query and normalizes/hex encodes each token. The tokenizer uses the same |
| * rules as SQLite's "simple" tokenizer. Each token is added to the retokenizer and then |
| * returned as a String. |
| * @see FtsQueryBuilder#UNSCOPED_NORMALIZING |
| * @see FtsQueryBuilder#SCOPED_NAME_NORMALIZING |
| */ |
| public static String getFtsMatchQuery(String query, FtsQueryBuilder ftsQueryBuilder) { |
| // SQLite's "simple" tokenizer uses the following rules to detect characters: |
| // - Unicode codepoints >= 128: Everything |
| // - Unicode codepoints < 128: Alphanumeric and "_" |
| // Everything else is a separator of tokens |
| int tokenStart = -1; |
| final StringBuilder result = new StringBuilder(); |
| for (int i = 0; i <= query.length(); i++) { |
| final boolean isChar; |
| if (i == query.length()) { |
| isChar = false; |
| } else { |
| final char ch = query.charAt(i); |
| if (ch >= 128) { |
| isChar = true; |
| } else { |
| isChar = Character.isLetterOrDigit(ch) || ch == '_'; |
| } |
| } |
| if (isChar) { |
| if (tokenStart == -1) { |
| tokenStart = i; |
| } |
| } else { |
| if (tokenStart != -1) { |
| final String token = query.substring(tokenStart, i); |
| ftsQueryBuilder.addToken(result, token); |
| tokenStart = -1; |
| } |
| } |
| } |
| return result.toString(); |
| } |
| |
| public static abstract class FtsQueryBuilder { |
| public abstract void addToken(StringBuilder builder, String token); |
| |
| /** Normalizes and space-concatenates each token. Example: "a1b2c1* a2b3c2*" */ |
| public static final FtsQueryBuilder UNSCOPED_NORMALIZING = new UnscopedNormalizingBuilder(); |
| |
| /** |
| * Scopes each token to a column and normalizes the name. |
| * Example: "content:foo* name:a1b2c1* tokens:foo* content:bar* name:a2b3c2* tokens:bar*" |
| */ |
| public static final FtsQueryBuilder SCOPED_NAME_NORMALIZING = |
| new ScopedNameNormalizingBuilder(); |
| |
| /** |
| * Scopes each token to a the content column and also for name with normalization. |
| * Also adds a user-defined expression to each token. This allows common criteria to be |
| * concatenated to each token. |
| * Example (commonCriteria=" OR tokens:123*"): |
| * "content:650* OR name:1A1B1C* OR tokens:123* content:2A2B2C* OR name:foo* OR tokens:123*" |
| */ |
| public static FtsQueryBuilder getDigitsQueryBuilder(final String commonCriteria) { |
| return new FtsQueryBuilder() { |
| @Override |
| public void addToken(StringBuilder builder, String token) { |
| if (builder.length() != 0) builder.append(' '); |
| |
| builder.append("content:"); |
| builder.append(token); |
| builder.append("* "); |
| |
| final String normalizedToken = NameNormalizer.normalize(token); |
| if (!TextUtils.isEmpty(normalizedToken)) { |
| builder.append(" OR name:"); |
| builder.append(normalizedToken); |
| builder.append('*'); |
| } |
| |
| builder.append(commonCriteria); |
| } |
| }; |
| } |
| } |
| |
| private static class UnscopedNormalizingBuilder extends FtsQueryBuilder { |
| @Override |
| public void addToken(StringBuilder builder, String token) { |
| if (builder.length() != 0) builder.append(' '); |
| |
| // the token could be empty (if the search query was "_"). we should still emit it |
| // here, as we otherwise risk to end up with an empty MATCH-expression MATCH "" |
| builder.append(NameNormalizer.normalize(token)); |
| builder.append('*'); |
| } |
| } |
| |
| private static class ScopedNameNormalizingBuilder extends FtsQueryBuilder { |
| @Override |
| public void addToken(StringBuilder builder, String token) { |
| if (builder.length() != 0) builder.append(' '); |
| |
| builder.append("content:"); |
| builder.append(token); |
| builder.append('*'); |
| |
| final String normalizedToken = NameNormalizer.normalize(token); |
| if (!TextUtils.isEmpty(normalizedToken)) { |
| builder.append(" OR name:"); |
| builder.append(normalizedToken); |
| builder.append('*'); |
| } |
| |
| builder.append(" OR tokens:"); |
| builder.append(token); |
| builder.append("*"); |
| } |
| } |
| } |