diff options
Diffstat (limited to 'src')
3 files changed, 333 insertions, 22 deletions
diff --git a/src/com/android/contacts/common/list/ContactEntryListAdapter.java b/src/com/android/contacts/common/list/ContactEntryListAdapter.java index 9ebf9a2b..fb9c73a8 100644 --- a/src/com/android/contacts/common/list/ContactEntryListAdapter.java +++ b/src/com/android/contacts/common/list/ContactEntryListAdapter.java @@ -35,6 +35,7 @@ import android.widget.TextView; import com.android.contacts.common.ContactPhotoManager; import com.android.contacts.common.R; +import com.android.contacts.common.util.SearchUtil; import java.util.HashSet; @@ -225,7 +226,8 @@ public abstract class ContactEntryListAdapter extends IndexerListAdapter { if (TextUtils.isEmpty(queryString)) { mUpperCaseQueryString = null; } else { - mUpperCaseQueryString = queryString.toUpperCase(); + mUpperCaseQueryString = SearchUtil + .cleanStartAndEndOfSearchQuery(queryString.toUpperCase()) ; } } diff --git a/src/com/android/contacts/common/list/ContactListItemView.java b/src/com/android/contacts/common/list/ContactListItemView.java index 8354fd9d..89a67d58 100644 --- a/src/com/android/contacts/common/list/ContactListItemView.java +++ b/src/com/android/contacts/common/list/ContactListItemView.java @@ -48,6 +48,7 @@ import com.android.contacts.common.ContactPresenceIconUtil; import com.android.contacts.common.ContactStatusUtil; import com.android.contacts.common.R; import com.android.contacts.common.format.PrefixHighlighter; +import com.android.contacts.common.util.SearchUtil; import com.google.common.collect.Lists; import java.util.ArrayList; @@ -1148,33 +1149,21 @@ public class ContactListItemView extends ViewGroup } String snippet = cursor.getString(summarySnippetColumnIndex); + // Do client side snippeting if provider didn't do it final Bundle extras = cursor.getExtras(); if (extras.getBoolean(ContactsContract.DEFERRED_SNIPPETING)) { final String query = extras.getString(ContactsContract.DEFERRED_SNIPPETING_QUERY); - if (TextUtils.isEmpty(snippet) || TextUtils.isEmpty(query)) { - snippet = null; - } else { - // If the name matches, don't show snippets. - int displayNameIndex = cursor.getColumnIndex(Contacts.DISPLAY_NAME); - if (displayNameIndex >= 0) { - - final String displayName = cursor.getString(displayNameIndex); - if (TextUtils.isEmpty(displayName)) { - snippet = null; - } else { - final String lowerQuery = query.toLowerCase(); - final String lowerDisplayName = displayName.toLowerCase(); - final List<String> nameTokens = split(lowerDisplayName); - for (String nameToken : nameTokens) { - if (nameToken.startsWith(lowerQuery)) { - snippet = null; - } - } - } - } + + String displayName = null; + int displayNameIndex = cursor.getColumnIndex(Contacts.DISPLAY_NAME); + if (displayNameIndex >= 0) { + displayName = cursor.getString(displayNameIndex); } + + snippet = updateSnippet(snippet, query, displayName); + } else { if (snippet != null) { int from = 0; @@ -1207,9 +1196,116 @@ public class ContactListItemView extends ViewGroup } } } + setSnippet(snippet); } + /** + * Used for deferred snippets from the database. The contents come back as large strings which + * need to be extracted for display. + * + * @param snippet The snippet from the database. + * @param query The search query substring. + * @param displayName The contact display name. + * @return The proper snippet to display. + */ + private String updateSnippet(String snippet, String query, String displayName) { + + if (TextUtils.isEmpty(snippet) || TextUtils.isEmpty(query)) { + return null; + } + query = SearchUtil.cleanStartAndEndOfSearchQuery(query.toLowerCase()); + + // If the display name already contains the query term, return empty - snippets should + // not be needed in that case. + if (!TextUtils.isEmpty(displayName)) { + final String lowerDisplayName = displayName.toLowerCase(); + final List<String> nameTokens = split(lowerDisplayName); + for (String nameToken : nameTokens) { + if (nameToken.startsWith(query)) { + return null; + } + } + } + + // The snippet may contain multiple data lines. + // Show the first line that matches the query. + final SearchUtil.MatchedLine matched = SearchUtil.findMatchingLine(snippet, query); + + if (matched != null && matched.line != null) { + // Tokenize for long strings since the match may be at the end of it. + // Skip this part for short strings since the whole string will be displayed. + // Most contact strings are short so the snippetize method will be called infrequently. + final int lengthThreshold = getResources().getInteger( + R.integer.snippet_length_before_tokenize); + if (matched.line.length() > lengthThreshold) { + return snippetize(matched.line, matched.startIndex, lengthThreshold); + } else { + return matched.line; + } + } + + // No match found. + return null; + } + + private String snippetize(String line, int matchIndex, int maxLength) { + // Show up to maxLength characters. But we only show full tokens so show the last full token + // up to maxLength characters. So as many starting tokens as possible before trying ending + // tokens. + int remainingLength = maxLength; + int tempRemainingLength = remainingLength; + + // Start the end token after the matched query. + int index = matchIndex; + int endTokenIndex = index; + + // Find the match token first. + while (index < line.length()) { + if (!Character.isLetterOrDigit(line.charAt(index))) { + endTokenIndex = index; + remainingLength = tempRemainingLength; + break; + } + tempRemainingLength--; + index++; + } + + // Find as much content before the match. + index = matchIndex - 1; + tempRemainingLength = remainingLength; + int startTokenIndex = matchIndex; + while (index > -1 && tempRemainingLength > 0) { + if (!Character.isLetterOrDigit(line.charAt(index))) { + startTokenIndex = index; + remainingLength = tempRemainingLength; + } + tempRemainingLength--; + index--; + } + + index = endTokenIndex; + tempRemainingLength = remainingLength; + // Find remaining content at after match. + while (index < line.length() && tempRemainingLength > 0) { + if (!Character.isLetterOrDigit(line.charAt(index))) { + endTokenIndex = index; + } + tempRemainingLength--; + index++; + } + // Append ellipse if there is content before or after. + final StringBuilder sb = new StringBuilder(); + if (startTokenIndex > 0) { + sb.append("..."); + } + sb.append(line.substring(startTokenIndex, endTokenIndex)); + if (endTokenIndex < line.length()) { + sb.append("..."); + } + return sb.toString(); + } + private static final Pattern SPLIT_PATTERN = Pattern.compile( "([\\w-\\.]+)@((?:[\\w]+\\.)+)([a-zA-Z]{2,4})|[\\w]+"); diff --git a/src/com/android/contacts/common/util/SearchUtil.java b/src/com/android/contacts/common/util/SearchUtil.java new file mode 100644 index 00000000..b3428ff6 --- /dev/null +++ b/src/com/android/contacts/common/util/SearchUtil.java @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2012 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.contacts.common.util; + +import com.google.common.annotations.VisibleForTesting; + +/** + * Methods related to search. + */ +public class SearchUtil { + + public static class MatchedLine { + + public int startIndex = -1; + public String line; + + @Override + public String toString() { + return "MatchedLine{" + + "line='" + line + '\'' + + ", startIndex=" + startIndex + + '}'; + } + } + + /** + * Given a string with lines delimited with '\n', finds the matching line to the given + * substring. + * + * @param contents The string to search. + * @param substring The substring to search for. + * @return A MatchedLine object containing the matching line and the startIndex of the substring + * match within that line. + */ + public static MatchedLine findMatchingLine(String contents, String substring) { + final MatchedLine matched = new MatchedLine(); + + // Snippet may contain multiple lines separated by "\n". + // Locate the lines of the content that contain the substring. + final int index = SearchUtil.contains(contents, substring); + if (index != -1) { + // Match found. Find the corresponding line. + int start = index - 1; + while (start > -1) { + if (contents.charAt(start) == '\n') { + break; + } + start--; + } + int end = index + 1; + while (end < contents.length()) { + if (contents.charAt(end) == '\n') { + break; + } + end++; + } + matched.line = contents.substring(start + 1, end); + matched.startIndex = index - (start + 1); + } + return matched; + } + + /** + * Similar to String.contains() with two main differences: + * <p> + * 1) Only searches token prefixes. A token is defined as any combination of letters or + * numbers. + * <p> + * 2) Returns the starting index where the substring is found. + * + * @param value The string to search. + * @param substring The substring to look for. + * @return The starting index where the substring is found. {@literal -1} if substring is not + * found in value. + */ + @VisibleForTesting + static int contains(String value, String substring) { + if (value.length() < substring.length()) { + return -1; + } + + // i18n support + // Generate the code points for the substring once. + // There will be a maximum of substring.length code points. But may be fewer. + // Since the array length is not an accurate size, we need to keep a separate variable. + final int[] substringCodePoints = new int[substring.length()]; + int substringLength = 0; // may not equal substring.length()!! + for (int i = 0; i < substring.length(); ) { + final int codePoint = Character.codePointAt(substring, i); + substringCodePoints[substringLength] = codePoint; + substringLength++; + i += Character.charCount(codePoint); + } + + int valueCodePoint = 0; + for (int i = 0; i < value.length(); i = findNextTokenStart(value, i)) { + valueCodePoint = value.codePointAt(i); + + int numMatch = 0; + + // As an optimization, we lower here instead of making the parent do it. + //int substringCodePoint = substring.codePointAt(numMatch); + int valueCpIndex = 0; + int cp = Character.toLowerCase(valueCodePoint); + while (numMatch < substringLength && cp == substringCodePoints[numMatch]) { + numMatch++; + if (numMatch == substringLength) { + // Must exit loop here otherwise code below may cause IndexOutOfBoundsException. + // When query string matches content exactly. + return i; + } + valueCpIndex += Character.charCount(cp); + cp = Character.toLowerCase(value.codePointAt(i + valueCpIndex)); + + } + + } + return -1; + } + + /** + * Find the start of the next token. A token is composed of letters and numbers. Any other + * character are considered delimiters. + * + * @param line The string to search for the next token. + * @param startIndex The index to start searching. 0 based indexing. + * @return The index for the start of the next token. line.length() if next token not found. + */ + @VisibleForTesting + static int findNextTokenStart(String line, int startIndex) { + int index = startIndex; + + // If already in token, eat remainder of token. + while (index <= line.length()) { + if (index == line.length()) { + // No more tokens. + return index; + } + final int codePoint = line.codePointAt(index); + if (!Character.isLetterOrDigit(codePoint)) { + break; + } + index += Character.charCount(codePoint); + } + + // Out of token, eat all consecutive delimiters. + while (index <= line.length()) { + if (index == line.length()) { + return index; + } + final int codePoint = line.codePointAt(index); + if (Character.isLetterOrDigit(codePoint)) { + break; + } + index += Character.charCount(codePoint); + } + + return index; + } + + /** + * Anything other than letter and numbers are considered delimiters. Remove start and end + * delimiters since they are not relevant to search. + * + * @param query The query string to clean. + * @return The cleaned query. Empty string if all characters are cleaned out. + */ + public static String cleanStartAndEndOfSearchQuery(String query) { + int start = 0; + while (start < query.length()) { + int codePoint = query.codePointAt(start); + if (Character.isLetterOrDigit(codePoint)) { + break; + } + start += Character.charCount(codePoint); + } + + if (start == query.length()) { + // All characters are delimiters. + return ""; + } + + int end = query.length() - 1; + while (end > -1) { + if (Character.isLowSurrogate(query.charAt(end))) { + // Assume valid i18n string. There should be a matching high surrogate before it. + end--; + } + int codePoint = query.codePointAt(end); + if (Character.isLetterOrDigit(codePoint)) { + break; + } + end--; + } + + // end is a letter or digit. + return query.substring(start, end + 1); + } +} |