diff options
Diffstat (limited to 'src/com')
4 files changed, 481 insertions, 127 deletions
diff --git a/src/com/android/providers/contacts/ContactsDatabaseHelper.java b/src/com/android/providers/contacts/ContactsDatabaseHelper.java index 3181375..bc76e24 100644 --- a/src/com/android/providers/contacts/ContactsDatabaseHelper.java +++ b/src/com/android/providers/contacts/ContactsDatabaseHelper.java @@ -2710,7 +2710,6 @@ import java.util.Locale; return; } - String mCountryIso = getCountryIso(); Cursor cursor = db.rawQuery( "SELECT _id, " + Phone.RAW_CONTACT_ID + ", " + Phone.NUMBER + " FROM " + Tables.DATA + @@ -2723,7 +2722,6 @@ import java.util.Locale; long dataID = cursor.getLong(0); long rawContactID = cursor.getLong(1); String number = cursor.getString(2); - String numberE164 = PhoneNumberUtils.formatNumberToE164(number, mCountryIso); String normalizedNumber = PhoneNumberUtils.normalizeNumber(number); if (!TextUtils.isEmpty(normalizedNumber)) { phoneValues.clear(); @@ -2733,13 +2731,6 @@ import java.util.Locale; phoneValues.put(PhoneLookupColumns.MIN_MATCH, PhoneNumberUtils.toCallerIDMinMatch(normalizedNumber)); db.insert(Tables.PHONE_LOOKUP, null, phoneValues); - - if (numberE164 != null && !numberE164.equals(normalizedNumber)) { - phoneValues.put(PhoneLookupColumns.NORMALIZED_NUMBER, numberE164); - phoneValues.put(PhoneLookupColumns.MIN_MATCH, - PhoneNumberUtils.toCallerIDMinMatch(numberE164)); - db.insert(Tables.PHONE_LOOKUP, null, phoneValues); - } } } } finally { diff --git a/src/com/android/providers/contacts/ContactsProvider2.java b/src/com/android/providers/contacts/ContactsProvider2.java index 4de1d13..c4b1697 100644 --- a/src/com/android/providers/contacts/ContactsProvider2.java +++ b/src/com/android/providers/contacts/ContactsProvider2.java @@ -121,10 +121,13 @@ import android.telephony.TelephonyManager; import android.text.TextUtils; import android.util.Log; +import java.io.BufferedWriter; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; @@ -198,6 +201,10 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun /* package */ static final String PHONEBOOK_COLLATOR_NAME = "PHONEBOOK"; + // Regex for splitting query strings - we split on any group of non-alphanumeric characters, + // excluding the @ symbol. + /* package */ static final String QUERY_TOKENIZER_REGEX = "[^\\w@]+"; + private static final int CONTACTS = 1000; private static final int CONTACTS_ID = 1001; private static final int CONTACTS_LOOKUP = 1002; @@ -3102,31 +3109,6 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun } } - private static class DirectoryCursorWrapper extends CursorWrapper - implements CrossProcessCursor { - private final CrossProcessCursor mCrossProcessCursor; - - public DirectoryCursorWrapper(Cursor cursor, CrossProcessCursor crossProcessCursor) { - super(cursor); - mCrossProcessCursor = crossProcessCursor; - } - - @Override - public void fillWindow(int pos, CursorWindow window) { - mCrossProcessCursor.fillWindow(pos, window); - } - - @Override - public CursorWindow getWindow() { - return mCrossProcessCursor.getWindow(); - } - - @Override - public boolean onMove(int oldPosition, int newPosition) { - return mCrossProcessCursor.onMove(oldPosition, newPosition); - } - } - @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { @@ -3135,13 +3117,16 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun String directory = getQueryParameter(uri, ContactsContract.DIRECTORY_PARAM_KEY); if (directory == null) { - return queryLocal(uri, projection, selection, selectionArgs, sortOrder, -1); + return wrapCursor(uri, + queryLocal(uri, projection, selection, selectionArgs, sortOrder, -1)); } else if (directory.equals("0")) { - return queryLocal(uri, projection, selection, selectionArgs, sortOrder, - Directory.DEFAULT); + return wrapCursor(uri, + queryLocal(uri, projection, selection, selectionArgs, sortOrder, + Directory.DEFAULT)); } else if (directory.equals("1")) { - return queryLocal(uri, projection, selection, selectionArgs, sortOrder, - Directory.LOCAL_INVISIBLE); + return wrapCursor(uri, + queryLocal(uri, projection, selection, selectionArgs, sortOrder, + Directory.LOCAL_INVISIBLE)); } DirectoryInfo directoryInfo = getDirectoryAuthority(directory); @@ -3181,12 +3166,41 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun CrossProcessCursor crossProcessCursor = getCrossProcessCursor(cursor); if (crossProcessCursor != null) { - return new DirectoryCursorWrapper(cursor, crossProcessCursor); + return wrapCursor(uri, cursor); } else { - return matrixCursorFromCursor(cursor); + return matrixCursorFromCursor(wrapCursor(uri, cursor)); } } + private Cursor wrapCursor(Uri uri, Cursor cursor) { + + // If the cursor doesn't contain a snippet column, don't bother wrapping it. + if (cursor.getColumnIndex(SearchSnippetColumns.SNIPPET) < 0) { + return cursor; + } + + // Parse out snippet arguments for use when snippets are retrieved from the cursor. + String[] args = null; + String snippetArgs = + getQueryParameter(uri, SearchSnippetColumns.SNIPPET_ARGS_PARAM_KEY); + if (snippetArgs != null) { + args = snippetArgs.split(","); + } + + String query = uri.getLastPathSegment(); + String startMatch = args != null && args.length > 0 ? args[0] + : DEFAULT_SNIPPET_ARG_START_MATCH; + String endMatch = args != null && args.length > 1 ? args[1] + : DEFAULT_SNIPPET_ARG_END_MATCH; + String ellipsis = args != null && args.length > 2 ? args[2] + : DEFAULT_SNIPPET_ARG_ELLIPSIS; + int maxTokens = args != null && args.length > 3 ? Integer.parseInt(args[3]) + : DEFAULT_SNIPPET_ARG_MAX_TOKENS; + + return new SnippetizingCursorWrapper(cursor, query, startMatch, endMatch, ellipsis, + maxTokens); + } + private CrossProcessCursor getCrossProcessCursor(Cursor cursor) { Cursor c = cursor; if (c instanceof CrossProcessCursor) { @@ -3604,6 +3618,12 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun case EMAILS_FILTER: { setTablesAndProjectionMapForData(qb, uri, projection, true); String filterParam = null; + + String primaryAccountName = + uri.getQueryParameter(ContactsContract.PRIMARY_ACCOUNT_NAME); + String primaryAccountType = + uri.getQueryParameter(ContactsContract.PRIMARY_ACCOUNT_TYPE); + if (uri.getPathSegments().size() > 3) { filterParam = uri.getLastPathSegment(); if (TextUtils.isEmpty(filterParam)) { @@ -3645,7 +3665,22 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun } groupBy = Email.DATA + "," + RawContacts.CONTACT_ID; if (sortOrder == null) { - sortOrder = EMAIL_FILTER_SORT_ORDER; + // Addresses associated with primary account should be promoted. + if (!TextUtils.isEmpty(primaryAccountName)) { + StringBuilder sb2 = new StringBuilder(); + sb2.append("(CASE WHEN " + RawContacts.ACCOUNT_NAME + "="); + DatabaseUtils.appendEscapedSQLString(sb2, primaryAccountName); + if (!TextUtils.isEmpty(primaryAccountType)) { + sb2.append(" AND " + RawContacts.ACCOUNT_TYPE + "="); + DatabaseUtils.appendEscapedSQLString(sb2, primaryAccountType); + } + sb2.append(" THEN 0 ELSE 1 END), "); + sb2.append(EMAIL_FILTER_SORT_ORDER); + + sortOrder = sb2.toString(); + } else { + sortOrder = EMAIL_FILTER_SORT_ORDER; + } } break; } @@ -3895,7 +3930,7 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun sortOrder, limit); } - qb.setStrictProjectionMap(true); + qb.setStrict(true); Cursor cursor = query(db, qb, projection, selection, selectionArgs, sortOrder, groupBy, limit); @@ -4395,11 +4430,20 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun String phoneNumber = null; String numberE164 = null; + // If the query consists of a single word, we can do snippetizing after-the-fact for a + // performance boost. + boolean singleTokenSearch = filter.split(QUERY_TOKENIZER_REGEX).length == 1; + if (filter.indexOf('@') != -1) { emailAddress = mDbHelper.extractAddressFromEmailAddress(filter); isEmailAddress = !TextUtils.isEmpty(emailAddress); } else { isPhoneNumber = isPhoneNumber(filter); + if (isPhoneNumber) { + phoneNumber = PhoneNumberUtils.normalizeNumber(filter); + numberE164 = PhoneNumberUtils.formatNumberToE164(phoneNumber, + mDbHelper.getCountryIso()); + } } sb.append(" JOIN (SELECT " + SearchIndexColumns.CONTACT_ID + " AS snippet_contact_id"); @@ -4408,89 +4452,87 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun if (isEmailAddress) { sb.append("ifnull("); DatabaseUtils.appendEscapedSQLString(sb, startMatch); - sb.append("||email_address||"); + sb.append("||(SELECT MIN(" + Email.ADDRESS + ")"); + sb.append(" FROM " + Tables.DATA_JOIN_RAW_CONTACTS); + sb.append(" WHERE " + Tables.SEARCH_INDEX + "." + SearchIndexColumns.CONTACT_ID); + sb.append("=" + RawContacts.CONTACT_ID + " AND " + Email.ADDRESS + " LIKE "); + DatabaseUtils.appendEscapedSQLString(sb, filter + "%"); + sb.append(")||"); DatabaseUtils.appendEscapedSQLString(sb, endMatch); sb.append(","); - appendSnippetFunction(sb, startMatch, endMatch, ellipsis, maxTokens); + + // Optimization for single-token search. + if (singleTokenSearch) { + sb.append(SearchIndexColumns.CONTENT); + } else { + appendSnippetFunction(sb, startMatch, endMatch, ellipsis, maxTokens); + } sb.append(")"); } else if (isPhoneNumber) { sb.append("ifnull("); DatabaseUtils.appendEscapedSQLString(sb, startMatch); - sb.append("||phone_number||"); + sb.append("||(SELECT MIN(" + Phone.NUMBER + ")"); + sb.append(" FROM " + + Tables.DATA_JOIN_RAW_CONTACTS + " JOIN " + Tables.PHONE_LOOKUP); + sb.append(" ON " + DataColumns.CONCRETE_ID); + sb.append("=" + Tables.PHONE_LOOKUP + "." + PhoneLookupColumns.DATA_ID); + sb.append(" WHERE " + Tables.SEARCH_INDEX + "." + SearchIndexColumns.CONTACT_ID); + sb.append("=" + RawContacts.CONTACT_ID); + sb.append(" AND " + PhoneLookupColumns.NORMALIZED_NUMBER + " LIKE '"); + sb.append(phoneNumber); + sb.append("%'"); + if (!TextUtils.isEmpty(numberE164)) { + sb.append(" OR " + PhoneLookupColumns.NORMALIZED_NUMBER + " LIKE '"); + sb.append(numberE164); + sb.append("%'"); + } + sb.append(")||"); DatabaseUtils.appendEscapedSQLString(sb, endMatch); sb.append(","); - appendSnippetFunction(sb, startMatch, endMatch, ellipsis, maxTokens); + + // Optimization for single-token search. + if (singleTokenSearch) { + sb.append(SearchIndexColumns.CONTENT); + } else { + appendSnippetFunction(sb, startMatch, endMatch, ellipsis, maxTokens); + } sb.append(")"); } else { - sb.append("(CASE WHEN name_contact_id NOT NULL THEN NULL ELSE "); - appendSnippetFunction(sb, startMatch, endMatch, ellipsis, maxTokens); - sb.append(" END)"); + final String normalizedFilter = NameNormalizer.normalize(filter); + if (!TextUtils.isEmpty(normalizedFilter)) { + // Optimization for single-token search. + if (singleTokenSearch) { + sb.append(SearchIndexColumns.CONTENT); + } else { + sb.append("(CASE WHEN EXISTS (SELECT 1 FROM "); + sb.append(Tables.RAW_CONTACTS + " AS rc INNER JOIN "); + sb.append(Tables.NAME_LOOKUP + " AS nl ON (rc." + RawContacts._ID); + sb.append("=nl." + NameLookupColumns.RAW_CONTACT_ID); + sb.append(") WHERE nl." + NameLookupColumns.NORMALIZED_NAME); + sb.append(" GLOB '" + normalizedFilter + "*' AND "); + sb.append("nl." + NameLookupColumns.NAME_TYPE + "="); + sb.append(NameLookupType.NAME_COLLATION_KEY + " AND "); + sb.append(Tables.SEARCH_INDEX + "." + SearchIndexColumns.CONTACT_ID); + sb.append("=rc." + RawContacts.CONTACT_ID); + sb.append(") THEN NULL ELSE "); + appendSnippetFunction(sb, startMatch, endMatch, ellipsis, maxTokens); + sb.append(" END)"); + } + } else { + sb.append("NULL"); + } } sb.append(" AS " + SearchSnippetColumns.SNIPPET); } sb.append(" FROM " + Tables.SEARCH_INDEX); - - if (isEmailAddress) { - sb.append(" LEFT OUTER JOIN " + - "(SELECT " - + RawContacts.CONTACT_ID + " AS email_contact_id," - + "MIN(" + Email.ADDRESS + ") AS email_address" + - " FROM " + Tables.DATA_JOIN_RAW_CONTACTS + - " WHERE " + Email.ADDRESS + " LIKE "); - DatabaseUtils.appendEscapedSQLString(sb, filter + "%"); - sb.append(") ON (email_contact_id=snippet_contact_id)"); - } else if (isPhoneNumber) { - phoneNumber = PhoneNumberUtils.normalizeNumber(filter); - sb.append(" LEFT OUTER JOIN " + - "(SELECT " - + RawContacts.CONTACT_ID + " AS phone_contact_id," - + "MIN(" + Phone.NUMBER + ") AS phone_number" + - " FROM " + Tables.DATA_JOIN_RAW_CONTACTS + - " JOIN " + Tables.PHONE_LOOKUP + - " ON(" + DataColumns.CONCRETE_ID + "=" - + Tables.PHONE_LOOKUP + "." + PhoneLookupColumns.DATA_ID + ")" + - " WHERE " + PhoneLookupColumns.NORMALIZED_NUMBER + " LIKE '"); - sb.append(phoneNumber); - sb.append("%'"); - - numberE164 = PhoneNumberUtils.formatNumberToE164(phoneNumber, - mDbHelper.getCountryIso()); - if (!TextUtils.isEmpty(numberE164)) { - sb.append(" OR " + PhoneLookupColumns.NORMALIZED_NUMBER + " LIKE '"); - sb.append(numberE164); - sb.append("%'"); - } - sb.append(" GROUP BY phone_contact_id"); - sb.append(") ON (phone_contact_id=snippet_contact_id)"); - } else { - sb.append(" LEFT OUTER JOIN " + - "(SELECT DISTINCT " - + RawContacts.CONTACT_ID + " AS name_contact_id" + - " FROM " + Tables.RAW_CONTACTS + - " JOIN " + Tables.NAME_LOOKUP + - " ON(" + RawContactsColumns.CONCRETE_ID + "=" - + NameLookupColumns.RAW_CONTACT_ID + ")"); - - String normalizedFilter = NameNormalizer.normalize(filter); - if (!TextUtils.isEmpty(normalizedFilter)) { - sb.append(" WHERE normalized_name GLOB '"); - sb.append(normalizedFilter); - sb.append("*' AND " + NameLookupColumns.NAME_TYPE + - "=" + NameLookupType.NAME_COLLATION_KEY); - } else { - sb.append(" WHERE 0"); - } - sb.append(") ON (name_contact_id=snippet_contact_id)"); - } - sb.append(" WHERE "); sb.append(Tables.SEARCH_INDEX + " MATCH "); if (isEmailAddress) { DatabaseUtils.appendEscapedSQLString(sb, "\"" + sanitizeMatch(filter) + "*\""); } else if (isPhoneNumber) { DatabaseUtils.appendEscapedSQLString(sb, - "\"" + sanitizeMatch(filter) + "*\" OR " + phoneNumber + "*" + "\"" + sanitizeMatch(filter) + "*\" OR \"" + phoneNumber + "*\"" + (numberE164 != null ? " OR \"" + numberE164 + "\"" : "")); } else { DatabaseUtils.appendEscapedSQLString(sb, sanitizeMatch(filter) + "*"); @@ -4875,20 +4917,30 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun final Context context = this.getContext(); final VCardComposer composer = new VCardComposer(context, VCardConfig.VCARD_TYPE_DEFAULT, false); - composer.addHandler(composer.new HandlerForOutputStream(stream)); - - // No extra checks since composer always uses restricted views - if (!composer.init(selection, selectionArgs)) { - Log.w(TAG, "Failed to init VCardComposer"); - return; - } + Writer writer = null; + try { + writer = new BufferedWriter(new OutputStreamWriter(stream)); + // No extra checks since composer always uses restricted views + if (!composer.init(selection, selectionArgs)) { + Log.w(TAG, "Failed to init VCardComposer"); + return; + } - while (!composer.isAfterLast()) { - if (!composer.createOneEntry()) { - Log.w(TAG, "Failed to output a contact."); + while (!composer.isAfterLast()) { + writer.write(composer.createOneEntry()); + } + } catch (IOException e) { + Log.e(TAG, "IOException: " + e); + } finally { + composer.terminate(); + if (writer != null) { + try { + writer.close(); + } catch (IOException e) { + Log.w(TAG, "IOException during closing output stream: " + e); + } } } - composer.terminate(); } @Override @@ -5216,6 +5268,17 @@ public class ContactsProvider2 extends SQLiteContentProvider implements OnAccoun return null; } + // Should match against the whole parameter instead of its suffix. + // e.g. The parameter "param" must not be found in "some_param=val". + if (index > 0) { + char prevChar = query.charAt(index - 1); + if (prevChar != '?' && prevChar != '&') { + // With "some_param=val1¶m=val2", we should find second "param" occurrence. + index += parameterLength; + continue; + } + } + index += parameterLength; if (queryLength == index) { diff --git a/src/com/android/providers/contacts/GlobalSearchSupport.java b/src/com/android/providers/contacts/GlobalSearchSupport.java index 7b9d4a6..5d0b6dc 100644 --- a/src/com/android/providers/contacts/GlobalSearchSupport.java +++ b/src/com/android/providers/contacts/GlobalSearchSupport.java @@ -96,6 +96,9 @@ public class GlobalSearchSupport { + "contacts." + Contacts.DISPLAY_NAME_PRIMARY + ", " + "contacts." + Contacts._ID; + private static final String RECENTLY_CONTACTED = + TIME_SINCE_LAST_CONTACTED + " < " + RECENT_CONTACTS; + private static class SearchSuggestion { long contactId; String photoUri; @@ -182,16 +185,22 @@ public class GlobalSearchSupport { public Cursor handleSearchSuggestionsQuery( SQLiteDatabase db, Uri uri, String[] projection, String limit) { + final String searchClause; + final String selection; if (uri.getPathSegments().size() <= 1) { - return null; + searchClause = null; + selection = RECENTLY_CONTACTED; + } else { + searchClause = uri.getLastPathSegment(); + selection = null; } - final String searchClause = uri.getLastPathSegment(); - if (TextUtils.isDigitsOnly(searchClause) && mContactsProvider.isPhone()) { + if (!TextUtils.isEmpty(searchClause) && TextUtils.isDigitsOnly(searchClause) + && mContactsProvider.isPhone()) { return buildCursorForSearchSuggestionsBasedOnPhoneNumber(searchClause); } else { return buildCursorForSearchSuggestionsBasedOnFilter( - db, projection, null, searchClause, limit); + db, projection, selection, searchClause, limit); } } @@ -265,17 +274,20 @@ public class GlobalSearchSupport { MatrixCursor cursor = new MatrixCursor( projection != null ? projection : SEARCH_SUGGESTIONS_BASED_ON_NAME_COLUMNS); StringBuilder sb = new StringBuilder(); + final boolean haveFilter = !TextUtils.isEmpty(filter); sb.append("SELECT " + Contacts._ID + ", " + Contacts.LOOKUP_KEY + ", " + Contacts.PHOTO_THUMBNAIL_URI + ", " + Contacts.DISPLAY_NAME + ", " - + SearchSnippetColumns.SNIPPET + ", " - + PRESENCE_SQL + " AS " + Contacts.CONTACT_PRESENCE + - " FROM "); + + PRESENCE_SQL + " AS " + Contacts.CONTACT_PRESENCE); + if (haveFilter) { + sb.append(", " + SearchSnippetColumns.SNIPPET); + } + sb.append(" FROM "); sb.append(getDatabaseHelper().getContactView(false)); sb.append(" AS contacts"); - if (filter != null) { + if (haveFilter) { mContactsProvider.appendSearchIndexJoin(sb, filter, true, String.valueOf(SNIPPET_START_MATCH), String.valueOf(SNIPPET_END_MATCH), SNIPPET_ELLIPSIS, SNIPPET_MAX_TOKENS); @@ -287,7 +299,13 @@ public class GlobalSearchSupport { if (limit != null) { sb.append(" LIMIT " + limit); } - Cursor c = db.rawQuery(sb.toString(), null); + Cursor c = new SnippetizingCursorWrapper( + db.rawQuery(sb.toString(), null), + haveFilter ? filter : "", + String.valueOf(SNIPPET_START_MATCH), + String.valueOf(SNIPPET_END_MATCH), + SNIPPET_ELLIPSIS, + SNIPPET_MAX_TOKENS); SearchSuggestion suggestion = new SearchSuggestion(); suggestion.filter = filter; try { @@ -296,8 +314,10 @@ public class GlobalSearchSupport { suggestion.lookupKey = c.getString(1); suggestion.photoUri = c.getString(2); suggestion.text1 = c.getString(3); - suggestion.text2 = shortenSnippet(c.getString(4)); - suggestion.presence = c.isNull(5) ? -1 : c.getInt(5); + suggestion.presence = c.isNull(4) ? -1 : c.getInt(4); + if (haveFilter) { + suggestion.text2 = shortenSnippet(c.getString(5)); + } cursor.addRow(suggestion.asList(projection)); } } finally { diff --git a/src/com/android/providers/contacts/SnippetizingCursorWrapper.java b/src/com/android/providers/contacts/SnippetizingCursorWrapper.java new file mode 100644 index 0000000..73fd2a3 --- /dev/null +++ b/src/com/android/providers/contacts/SnippetizingCursorWrapper.java @@ -0,0 +1,280 @@ +/* + * 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 android.database.CrossProcessCursor; +import android.database.Cursor; +import android.database.CursorWindow; +import android.database.CursorWrapper; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.SearchSnippetColumns; +import android.text.TextUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Cursor wrapper for use when the results include snippets. This wrapper does special processing + * when the snippet field is retrieved, converting the data in that column from the raw form that is + * retrieved from the database into a snippet before returning (all other columns are simply passed + * through). + * + * Note that this wrapper implements {@link CrossProcessCursor}, but will only behave as such if the + * cursor it is wrapping is itself a {@link CrossProcessCursor} or another wrapper around the same. + */ +public class SnippetizingCursorWrapper extends CursorWrapper implements CrossProcessCursor { + + // Pattern for splitting a line into tokens. This matches e-mail addresses as a single token, + // otherwise splitting on any group of non-alphanumeric characters. + Pattern SPLIT_PATTERN = Pattern.compile("([\\w-\\.]+)@((?:[\\w]+\\.)+)([a-zA-Z]{2,4})|[\\w]+"); + + // The cross process cursor. Only non-null if the wrapped cursor was a cross-process cursor. + private final CrossProcessCursor mCrossProcessCursor; + + // Index of the snippet field (if any). + private final int mSnippetIndex; + + // Parameters for snippetization. + private final String mQuery; + private final String mStartMatch; + private final String mEndMatch; + private final String mEllipsis; + private final int mMaxTokens; + + // Whether to invoke the snippeting logic. If the query consisted of multiple tokens, the DB + // snippet() function was already used, so we should just return the snippet value directly. + private final boolean mDoSnippetizing; + + /** + * Creates a cursor wrapper that does special handling on the snippet field (converting the + * raw content retrieved from the database into a snippet). + * @param cursor The cursor to wrap. + * @param query Query string. + * @param startMatch String to insert at the start of matches in the snippet. + * @param endMatch String to insert at the end of matches in the snippet. + * @param ellipsis Ellipsis characters to use at the start or end of the snippet if appropriate. + * @param maxTokens Maximum number of tokens to include in the snippet. + */ + SnippetizingCursorWrapper(Cursor cursor, String query, String startMatch, + String endMatch, String ellipsis, int maxTokens) { + super(cursor); + mCrossProcessCursor = getCrossProcessCursor(cursor); + mSnippetIndex = getColumnIndex(SearchSnippetColumns.SNIPPET); + mQuery = query; + mStartMatch = startMatch; + mEndMatch = endMatch; + mEllipsis = ellipsis; + mMaxTokens = maxTokens; + mDoSnippetizing = mQuery.split(ContactsProvider2.QUERY_TOKENIZER_REGEX).length == 1; + } + + private CrossProcessCursor getCrossProcessCursor(Cursor cursor) { + if (cursor instanceof CrossProcessCursor) { + return (CrossProcessCursor) cursor; + } else if (cursor instanceof CursorWrapper) { + return getCrossProcessCursor(((CursorWrapper) cursor).getWrappedCursor()); + } else { + return null; + } + } + + @Override + public void fillWindow(int pos, CursorWindow window) { + if (mCrossProcessCursor != null) { + mCrossProcessCursor.fillWindow(pos, window); + } else { + throw new UnsupportedOperationException("Wrapped cursor is not a cross-process cursor"); + } + } + + @Override + public CursorWindow getWindow() { + if (mCrossProcessCursor != null) { + return mCrossProcessCursor.getWindow(); + } else { + throw new UnsupportedOperationException("Wrapped cursor is not a cross-process cursor"); + } + } + + @Override + public boolean onMove(int oldPosition, int newPosition) { + if (mCrossProcessCursor != null) { + return mCrossProcessCursor.onMove(oldPosition, newPosition); + } else { + throw new UnsupportedOperationException("Wrapped cursor is not a cross-process cursor"); + } + } + + @Override + public String getString(int columnIndex) { + String columnContent = super.getString(columnIndex); + + // If the snippet column is being retrieved, do our custom snippetization logic. + if (mDoSnippetizing && columnIndex == mSnippetIndex) { + // Retrieve the display name - if it includes the query term, the snippet should be + // left empty. + int displayNameIndex = super.getColumnIndex(Contacts.DISPLAY_NAME); + String displayName = displayNameIndex < 0 ? null : super.getString(displayNameIndex); + return snippetize(columnContent, displayName); + } else { + return columnContent; + } + } + + /** + * Creates a snippet for the given content, with the following algorithm: + * <ul> + * <li>Check for the query term as a prefix of any token in the display name; if any match is + * found, no snippet should be computed (return null).</li> + * <li>Check for empty content (return null if found).</li> + * <li>Check for a custom snippet (phone number or email matches generate a usable snippet + * via a subquery)</li> + * <li>Check for a non-match of the query to the content - technically should never happen, + * but if it comes through, we'll just return null.</li> + * <li>Break the content into multiple lines. For each line that includes the query string: + * <ul> + * <li>Tokenize it into alphanumeric chunks.</li> + * <li>Do a prefix match for the query against each token.</li> + * <li>If there's a match, replace the token with a version with the query term surrounded + * by the start and end match strings.</li> + * <li>If this is the first token in the line that has a match, compute the start and end + * token positions to use for this line (which will be used to create the snippet).</li> + * <li>If the line just processed had a match, reassemble the tokens (with ellipses, as + * needed) to produce the snippet, and return it.</li> + * </ul> + * </li> + * </ul> + * @param content The content to snippetize. + * @param displayName Display name for the contact. + * @return The snippet for the content, or null if no snippet should be shown. + */ + // TODO: Tokenization is done based on alphanumeric characters, which may not be appropriate for + // some locales (but this matches what the item view does for display). + private String snippetize(String content, String displayName) { + // If the display name already contains the query term, return empty - snippets should + // not be needed in that case. + String lowerDisplayName = displayName != null ? displayName.toLowerCase() : ""; + String lowerQuery = mQuery.toLowerCase(); + List<String> nameTokens = new ArrayList<String>(); + List<Integer> nameTokenOffsets = new ArrayList<Integer>(); + split(lowerDisplayName.trim(), nameTokens, nameTokenOffsets); + for (String nameToken : nameTokens) { + if (nameToken.startsWith(lowerQuery)) { + return null; + } + } + + // If the content to snippetize is empty, return null. + if (TextUtils.isEmpty(content)) { + return null; + } + + // Check to see if a custom snippet was already returned (identified by the string having no + // newlines and beginning and ending with the split delimiters). + String[] contentLines = content.split("\n"); + if (contentLines.length == 1 && !TextUtils.isEmpty(contentLines[0]) + && contentLines[0].startsWith(mStartMatch) && contentLines[0].endsWith(mEndMatch)) { + // Custom snippet was retrieved - just return it. + return content; + } + + // If the content isn't a custom snippet and doesn't contain the query term, return null. + if (!content.toLowerCase().contains(lowerQuery)) { + return null; + } + + // Locate the lines of the content that contain the query term. + for (String contentLine : contentLines) { + if (contentLine.toLowerCase().contains(lowerQuery)) { + + // Line contains the query string - now search for it at the start of tokens. + List<String> lineTokens = new ArrayList<String>(); + List<Integer> tokenOffsets = new ArrayList<Integer>(); + split(contentLine.trim(), lineTokens, tokenOffsets); + + // As we find matches against the query, we'll populate this list with the marked + // (or unchanged) tokens. + List<String> markedTokens = new ArrayList<String>(); + + int firstToken = -1; + int lastToken = -1; + for (int i = 0; i < lineTokens.size(); i++) { + String token = lineTokens.get(i); + String lowerToken = token.toLowerCase(); + if (lowerToken.startsWith(lowerQuery)) { + + // Query term matched; surround the token with match markers. + markedTokens.add(mStartMatch + token + mEndMatch); + + // If this is the first token found with a match, mark the token + // positions to use for assembling the snippet. + if (firstToken == -1) { + firstToken = + Math.max(0, i - (int) Math.floor(Math.abs(mMaxTokens) / 2.0)); + lastToken = + Math.min(lineTokens.size(), firstToken + Math.abs(mMaxTokens)); + } + } else { + markedTokens.add(token); + } + } + + // Assemble the snippet by piecing the tokens back together. + if (firstToken > -1) { + StringBuilder sb = new StringBuilder(); + if (firstToken > 0) { + sb.append(mEllipsis); + } + for (int i = firstToken; i < lastToken; i++) { + String markedToken = markedTokens.get(i); + String originalToken = lineTokens.get(i); + sb.append(markedToken); + if (i < lastToken - 1) { + // Add the characters that appeared between this token and the next. + sb.append(contentLine.substring( + tokenOffsets.get(i) + originalToken.length(), + tokenOffsets.get(i + 1))); + } + } + if (lastToken < lineTokens.size()) { + sb.append(mEllipsis); + } + return sb.toString(); + } + } + } + return null; + } + + /** + * Helper method for splitting a string into tokens. The lists passed in are populated with the + * tokens and offsets into the content of each token. The tokenization function parses e-mail + * addresses as a single token; otherwise it splits on any non-alphanumeric character. + * @param content Content to split. + * @param tokens List of token strings to populate. + * @param offsets List of offsets into the content for each token returned. + */ + private void split(String content, List<String> tokens, List<Integer> offsets) { + Matcher matcher = SPLIT_PATTERN.matcher(content); + while (matcher.find()) { + tokens.add(matcher.group()); + offsets.add(matcher.start()); + } + } +}
\ No newline at end of file |