Cache fast scrolling index.
The cache content is also stored in the shared preferences.
The cache is invalidated when:
- contacts (or profile), raw_contacs, or data is modifiled.
- accounts are changed. (Strictly speaking we have to do this only when
an account is being removed, but not when added. But let's just always do this
for just in case.)
- visible contacts are updated; i.e. when custom filter is udpated.
- the locale changes.
Change-Id: I70cfe7c88d3b1a0a0f820338acbe885c136b6e10
diff --git a/src/com/android/providers/contacts/ContactsProvider2.java b/src/com/android/providers/contacts/ContactsProvider2.java
index 9710bb1..5a7e1a7 100644
--- a/src/com/android/providers/contacts/ContactsProvider2.java
+++ b/src/com/android/providers/contacts/ContactsProvider2.java
@@ -148,10 +148,12 @@
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.File;
+import java.io.FileDescriptor;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
import java.io.Writer;
import java.security.SecureRandom;
import java.text.SimpleDateFormat;
@@ -1345,6 +1347,14 @@
private long mLastPhotoCleanup = 0;
+ private FastScrollingIndexCache mFastScrollingIndexCache;
+ private final Object mFastScrollingIndexCacheLock = new Object();
+
+ // Stats about FastScrollingIndex.
+ private int mFastScrollingIndexCacheRequestCount;
+ private int mFastScrollingIndexCacheMissCount;
+ private long mTotalTimeFastScrollingIndexGenerate;
+
@Override
public boolean onCreate() {
if (Log.isLoggable(Constants.PERFORMANCE_TAG, Log.DEBUG)) {
@@ -1383,6 +1393,8 @@
mMaxThumbnailPhotoDim = resources.getInteger(
R.integer.config_max_thumbnail_photo_dim);
+ mFastScrollingIndexCache = new FastScrollingIndexCache(getContext());
+
mContactsHelper = getDatabaseHelper(getContext());
mDbHelper.set(mContactsHelper);
@@ -1633,6 +1645,7 @@
mContactsHelper.setLocale(this, currentLocale);
mProfileHelper.setLocale(this, currentLocale);
prefs.edit().putString(PREF_LOCALE, currentLocale.toString()).apply();
+ invalidateFastScrollingIndexCache();
setProviderStatus(providerStatus);
}
@@ -1836,6 +1849,7 @@
*/
@NeededForTesting
void wipeData() {
+ invalidateFastScrollingIndexCache();
mContactsHelper.wipeData();
mProfileHelper.wipeData();
mContactsPhotoStore.clear();
@@ -2122,6 +2136,9 @@
if (mVisibleTouched) {
mVisibleTouched = false;
mDbHelper.get().updateAllVisible();
+
+ // Need to rebuild the fast-indxer bundle.
+ invalidateFastScrollingIndexCache();
}
updateSearchIndexInTransaction();
@@ -2264,6 +2281,7 @@
break;
case CONTACTS: {
+ invalidateFastScrollingIndexCache();
insertContact(values);
break;
}
@@ -2275,6 +2293,7 @@
case RAW_CONTACTS:
case PROFILE_RAW_CONTACTS: {
+ invalidateFastScrollingIndexCache();
id = insertRawContact(uri, values, callerIsSyncAdapter);
mSyncToNetwork |= !callerIsSyncAdapter;
break;
@@ -2282,6 +2301,7 @@
case RAW_CONTACTS_ID_DATA:
case PROFILE_RAW_CONTACTS_ID_DATA: {
+ invalidateFastScrollingIndexCache();
int segment = match == RAW_CONTACTS_ID_DATA ? 1 : 2;
values.put(Data.RAW_CONTACT_ID, uri.getPathSegments().get(segment));
id = insertData(values, callerIsSyncAdapter);
@@ -2298,6 +2318,7 @@
case DATA:
case PROFILE_DATA: {
+ invalidateFastScrollingIndexCache();
id = insertData(values, callerIsSyncAdapter);
mSyncToNetwork |= !callerIsSyncAdapter;
break;
@@ -3245,16 +3266,19 @@
}
case CONTACTS: {
+ invalidateFastScrollingIndexCache();
// TODO
return 0;
}
case CONTACTS_ID: {
+ invalidateFastScrollingIndexCache();
long contactId = ContentUris.parseId(uri);
return deleteContact(contactId, callerIsSyncAdapter);
}
case CONTACTS_LOOKUP: {
+ invalidateFastScrollingIndexCache();
final List<String> pathSegments = uri.getPathSegments();
final int segmentCount = pathSegments.size();
if (segmentCount < 3) {
@@ -3267,6 +3291,7 @@
}
case CONTACTS_LOOKUP_ID: {
+ invalidateFastScrollingIndexCache();
// lookup contact by id and lookup key to see if they still match the actual record
final List<String> pathSegments = uri.getPathSegments();
final String lookupKey = pathSegments.get(2);
@@ -3301,6 +3326,7 @@
case RAW_CONTACTS:
case PROFILE_RAW_CONTACTS: {
+ invalidateFastScrollingIndexCache();
int numDeletes = 0;
Cursor c = mActiveDb.get().query(Tables.RAW_CONTACTS,
new String[]{RawContacts._ID, RawContacts.CONTACT_ID},
@@ -3321,6 +3347,7 @@
case RAW_CONTACTS_ID:
case PROFILE_RAW_CONTACTS_ID: {
+ invalidateFastScrollingIndexCache();
final long rawContactId = ContentUris.parseId(uri);
return deleteRawContact(rawContactId, mDbHelper.get().getContactId(rawContactId),
callerIsSyncAdapter);
@@ -3328,6 +3355,7 @@
case DATA:
case PROFILE_DATA: {
+ invalidateFastScrollingIndexCache();
mSyncToNetwork |= !callerIsSyncAdapter;
return deleteData(appendAccountToSelection(uri, selection), selectionArgs,
callerIsSyncAdapter);
@@ -3339,6 +3367,7 @@
case CALLABLES_ID:
case POSTALS_ID:
case PROFILE_DATA_ID: {
+ invalidateFastScrollingIndexCache();
long dataId = ContentUris.parseId(uri);
mSyncToNetwork |= !callerIsSyncAdapter;
mSelectionArgs1[0] = String.valueOf(dataId);
@@ -3627,17 +3656,20 @@
case CONTACTS:
case PROFILE: {
+ invalidateFastScrollingIndexCache();
count = updateContactOptions(values, selection, selectionArgs, callerIsSyncAdapter);
break;
}
case CONTACTS_ID: {
+ invalidateFastScrollingIndexCache();
count = updateContactOptions(ContentUris.parseId(uri), values, callerIsSyncAdapter);
break;
}
case CONTACTS_LOOKUP:
case CONTACTS_LOOKUP_ID: {
+ invalidateFastScrollingIndexCache();
final List<String> pathSegments = uri.getPathSegments();
final int segmentCount = pathSegments.size();
if (segmentCount < 3) {
@@ -3652,6 +3684,7 @@
case RAW_CONTACTS_ID_DATA:
case PROFILE_RAW_CONTACTS_ID_DATA: {
+ invalidateFastScrollingIndexCache();
int segment = match == RAW_CONTACTS_ID_DATA ? 1 : 2;
final String rawContactId = uri.getPathSegments().get(segment);
String selectionWithId = (Data.RAW_CONTACT_ID + "=" + rawContactId + " ")
@@ -3664,6 +3697,7 @@
case DATA:
case PROFILE_DATA: {
+ invalidateFastScrollingIndexCache();
count = updateData(uri, values, appendAccountToSelection(uri, selection),
selectionArgs, callerIsSyncAdapter);
if (count > 0) {
@@ -3677,6 +3711,7 @@
case EMAILS_ID:
case CALLABLES_ID:
case POSTALS_ID: {
+ invalidateFastScrollingIndexCache();
count = updateData(uri, values, selection, selectionArgs, callerIsSyncAdapter);
if (count > 0) {
mSyncToNetwork |= !callerIsSyncAdapter;
@@ -3686,12 +3721,14 @@
case RAW_CONTACTS:
case PROFILE_RAW_CONTACTS: {
+ invalidateFastScrollingIndexCache();
selection = appendAccountIdToSelection(uri, selection);
count = updateRawContacts(values, selection, selectionArgs, callerIsSyncAdapter);
break;
}
case RAW_CONTACTS_ID: {
+ invalidateFastScrollingIndexCache();
long rawContactId = ContentUris.parseId(uri);
if (selection != null) {
selectionArgs = insertSelectionArg(selectionArgs, String.valueOf(rawContactId));
@@ -4428,6 +4465,9 @@
return false;
}
Log.i(TAG, "Accounts changed");
+
+ invalidateFastScrollingIndexCache();
+
final ContactsDatabaseHelper dbHelper = mDbHelper.get();
final SQLiteDatabase db = dbHelper.getWritableDatabase();
mActiveDb.set(db);
@@ -4791,11 +4831,11 @@
}
}
- protected Cursor queryLocal(Uri uri, String[] projection, String selection,
- String[] selectionArgs, String sortOrder, long directoryId,
- CancellationSignal cancellationSignal) {
+ protected Cursor queryLocal(final Uri uri, final String[] projection, String selection,
+ String[] selectionArgs, String sortOrder, final long directoryId,
+ final CancellationSignal cancellationSignal) {
if (VERBOSE_LOGGING) {
- Log.v(TAG, "query=" + uri + " selection=" + selection);
+ Log.v(TAG, "query=" + uri + " selection=" + selection + " order=" + sortOrder);
}
// Default active DB to the contacts DB if none has been set.
@@ -5842,8 +5882,9 @@
limit, cancellationSignal);
if (readBooleanQueryParameter(uri, ContactCounts.ADDRESS_BOOK_INDEX_EXTRAS, false)) {
- cursor = bundleLetterCountExtras(cursor, mActiveDb.get(), qb, selection,
- selectionArgs, sortOrder, addressBookIndexerCountExpression, cancellationSignal);
+ bundleFastScrollingIndexExtras(cursor, uri, mActiveDb.get(), qb, selection,
+ selectionArgs, sortOrder, addressBookIndexerCountExpression,
+ cancellationSignal);
}
if (snippetDeferred) {
cursor = addDeferredSnippetingExtra(cursor);
@@ -5915,6 +5956,55 @@
return null;
}
+ private void invalidateFastScrollingIndexCache() {
+ if (VERBOSE_LOGGING) {
+ Log.v(TAG, "invalidatemFastScrollingIndexCache");
+ }
+ synchronized (mFastScrollingIndexCacheLock) {
+ mFastScrollingIndexCache.invalidate();
+ }
+ }
+
+ /**
+ * Add the "fast scrolling index" bundle, generated by {@link #getFastScrollingIndexExtras},
+ * to a cursor as extras. It first checks {@link FastScrollingIndexCache} to see if we
+ * already have a cached result.
+ */
+ private void bundleFastScrollingIndexExtras(Cursor cursor, Uri queryUri,
+ final SQLiteDatabase db, SQLiteQueryBuilder qb, String selection,
+ String[] selectionArgs, String sortOrder, String countExpression,
+ CancellationSignal cancellationSignal) {
+ if (!(cursor instanceof AbstractCursor)) {
+ Log.w(TAG, "Unable to bundle extras. Cursor is not AbstractCursor.");
+ return;
+ }
+ Bundle b;
+ synchronized (mFastScrollingIndexCacheLock) {
+ // First, try the cache.
+ mFastScrollingIndexCacheRequestCount++;
+ b = mFastScrollingIndexCache.get(queryUri, selection, selectionArgs, sortOrder,
+ countExpression);
+
+ if (b == null) {
+ mFastScrollingIndexCacheMissCount++;
+ // Not in the cache. Generate and put.
+ final long start = System.currentTimeMillis();
+
+ b = getFastScrollingIndexExtras(queryUri, db, qb, selection, selectionArgs,
+ sortOrder, countExpression, cancellationSignal, getLocale(),
+ mFastScrollingIndexCache);
+
+ final long end = System.currentTimeMillis();
+ final int time = (int) (end - start);
+ mTotalTimeFastScrollingIndexGenerate += time;
+ if (VERBOSE_LOGGING) {
+ Log.v(TAG, "getLetterCountExtraBundle took " + time + "ms");
+ }
+ }
+ }
+ ((AbstractCursor) cursor).setExtras(b);
+ }
+
private static final class AddressBookIndexQuery {
public static final String LETTER = "letter";
public static final String TITLE = "title";
@@ -5935,16 +6025,15 @@
}
/**
- * Computes counts by the address book index titles and adds the resulting tally
- * to the returned cursor as a bundle of extras.
+ * Computes counts by the address book index titles and returns it as {@link Bundle} which
+ * will be appended to a {@link Cursor} as extras. The result will also be cached to
+ * {@link FastScrollingIndexCache}.
*/
- private Cursor bundleLetterCountExtras(Cursor cursor, final SQLiteDatabase db,
- SQLiteQueryBuilder qb, String selection, String[] selectionArgs, String sortOrder,
- String countExpression, CancellationSignal cancellationSignal) {
- if (!(cursor instanceof AbstractCursor)) {
- Log.w(TAG, "Unable to bundle extras. Cursor is not AbstractCursor.");
- return cursor;
- }
+ private static Bundle getFastScrollingIndexExtras(final Uri queryUri, final SQLiteDatabase db,
+ final SQLiteQueryBuilder qb, final String selection, final String[] selectionArgs,
+ final String sortOrder, final String originalCountExpression,
+ final CancellationSignal cancellationSignal, final Locale currentLocale,
+ final FastScrollingIndexCache cache) {
String sortKey;
// The sort order suffix could be something like "DESC".
@@ -5963,7 +6052,6 @@
sortKey = Contacts.SORT_KEY_PRIMARY;
}
- String locale = getLocale().toString();
HashMap<String, String> projectionMap = Maps.newHashMap();
String sectionHeading = String.format(Locale.US, AddressBookIndexQuery.SECTION_HEADING,
sortKey);
@@ -5971,6 +6059,7 @@
sectionHeading + " AS " + AddressBookIndexQuery.LETTER);
// If "what to count" is not specified, we just count all records.
+ String countExpression = originalCountExpression;
if (TextUtils.isEmpty(countExpression)) {
countExpression = "*";
}
@@ -5983,7 +6072,7 @@
* than Katakana.
*/
projectionMap.put(AddressBookIndexQuery.TITLE,
- "GET_PHONEBOOK_INDEX(" + sectionHeading + ",'" + locale + "')"
+ "GET_PHONEBOOK_INDEX(" + sectionHeading + ",'" + currentLocale.toString() + "')"
+ " AS " + AddressBookIndexQuery.TITLE);
projectionMap.put(AddressBookIndexQuery.COUNT,
"COUNT(" + countExpression + ") AS " + AddressBookIndexQuery.COUNT);
@@ -6007,6 +6096,9 @@
for (int i = 0; i < groupCount; i++) {
indexCursor.moveToNext();
String title = indexCursor.getString(AddressBookIndexQuery.COLUMN_TITLE);
+ if (title == null) {
+ title = "";
+ }
int count = indexCursor.getInt(AddressBookIndexQuery.COLUMN_COUNT);
if (indexCount == 0 || !TextUtils.equals(title, currentTitle)) {
titles[indexCount] = currentTitle = title;
@@ -6026,13 +6118,11 @@
System.arraycopy(counts, 0, newCounts, 0, indexCount);
counts = newCounts;
}
-
- final Bundle bundle = new Bundle();
- bundle.putStringArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_TITLES, titles);
- bundle.putIntArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS, counts);
-
- ((AbstractCursor) cursor).setExtras(bundle);
- return cursor;
+ // Note: The parameters below are used as the cache key, so we need to use the
+ // *original* values that have been passed to this method.
+ // Otherwise originalCountExpression() would generate a different key than get() does.
+ return cache.putAndGetBundle(queryUri, selection, selectionArgs, sortOrder,
+ originalCountExpression, titles, counts);
} finally {
indexCursor.close();
}
@@ -8032,4 +8122,20 @@
public ContactsDatabaseHelper getThreadActiveDatabaseHelperForTest() {
return mDbHelper.get();
}
+
+ @Override
+ public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+ pw.print("FastScrollingIndex stats:\n");
+ pw.printf("request=%d miss=%d (%d%%) avg time=%dms\n",
+ mFastScrollingIndexCacheRequestCount,
+ mFastScrollingIndexCacheMissCount,
+ safeDiv(mFastScrollingIndexCacheMissCount * 100,
+ mFastScrollingIndexCacheRequestCount),
+ safeDiv(mTotalTimeFastScrollingIndexGenerate, mFastScrollingIndexCacheMissCount)
+ );
+ }
+
+ private static final long safeDiv(long dividend, long divisor) {
+ return (divisor == 0) ? 0 : dividend / divisor;
+ }
}
diff --git a/src/com/android/providers/contacts/FastScrollingIndexCache.java b/src/com/android/providers/contacts/FastScrollingIndexCache.java
new file mode 100644
index 0000000..0fc4b18
--- /dev/null
+++ b/src/com/android/providers/contacts/FastScrollingIndexCache.java
@@ -0,0 +1,300 @@
+/*
+ * 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.providers.contacts;
+
+import com.google.android.collect.Maps;
+import com.google.common.annotations.VisibleForTesting;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.provider.ContactsContract.ContactCounts;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * Cache for the "fast scrolling index".
+ *
+ * It's a cache from "keys" and "bundles" (see {@link #mCache} for what they are). The cache
+ * content is also persisted in the shared preferences, so it'll survive even if the process
+ * is killed or the device reboots.
+ *
+ * All the content will be invalidated when the provider detects an operation that could potentially
+ * change the index.
+ *
+ * There's no maximum number for cached entries. It's okay because we store keys and values in
+ * a compact form in both the in-memory cache and the preferences. Also the query in question
+ * (the query for contact lists) has relatively low number of variations.
+ *
+ * This class is not thread-safe.
+ */
+public class FastScrollingIndexCache {
+ private static final String TAG = "LetterCountCache";
+
+ @VisibleForTesting
+ static final String PREFERENCE_KEY = "LetterCountCache";
+
+ /**
+ * Separator used for in-memory structure.
+ */
+ private static final String SEPARATOR = "\u0001";
+ private static final Pattern SEPARATOR_PATTERN = Pattern.compile(SEPARATOR);
+
+ /**
+ * Separator used for serializing values for preferences.
+ */
+ private static final String SAVE_SEPARATOR = "\u0002";
+ private static final Pattern SAVE_SEPARATOR_PATTERN = Pattern.compile(SAVE_SEPARATOR);
+
+ private final SharedPreferences mPrefs;
+
+ private boolean mPreferenceLoaded;
+
+ /**
+ * In-memory cache.
+ *
+ * It's essentially a map from keys, which are query parameters passed to {@link #get}, to
+ * values, which are {@link Bundle}s that will be appended to a {@link Cursor} as extras.
+ *
+ * However, in order to save memory, we store stringified keys and values in the cache.
+ * Key strings are generated by {@link #buildCacheKey} and values are generated by
+ * {@link #buildCacheValue}.
+ *
+ * We store those strings joined with {@link #SAVE_SEPARATOR} as the separator when saving
+ * to shared preferences.
+ */
+ private final Map<String, String> mCache = Maps.newHashMap();
+
+ public FastScrollingIndexCache(Context context) {
+ this(PreferenceManager.getDefaultSharedPreferences(context));
+
+ // At this point, the SharedPreferences might just have been generated and may still be
+ // loading from the file, in which case loading from the preferences would be blocked.
+ // To avoid that, we load lazily.
+ }
+
+ @VisibleForTesting
+ FastScrollingIndexCache(SharedPreferences prefs) {
+ mPrefs = prefs;
+ }
+
+ /**
+ * Append a {@link String} to a {@link StringBuilder}.
+ *
+ * Unlike the original {@link StringBuilder#append}, it does *not* append the string "null" if
+ * {@code value} is null.
+ */
+ private static void appendIfNotNull(StringBuilder sb, Object value) {
+ if (value != null) {
+ sb.append(value.toString());
+ }
+ }
+
+ private static String buildCacheKey(Uri queryUri, String selection, String[] selectionArgs,
+ String sortOrder, String countExpression) {
+ final StringBuilder sb = new StringBuilder();
+
+ appendIfNotNull(sb, queryUri);
+ appendIfNotNull(sb, SEPARATOR);
+ appendIfNotNull(sb, selection);
+ appendIfNotNull(sb, SEPARATOR);
+ appendIfNotNull(sb, sortOrder);
+ appendIfNotNull(sb, SEPARATOR);
+ appendIfNotNull(sb, countExpression);
+
+ if (selectionArgs != null) {
+ for (int i = 0; i < selectionArgs.length; i++) {
+ appendIfNotNull(sb, SEPARATOR);
+ appendIfNotNull(sb, selectionArgs[i]);
+ }
+ }
+ return sb.toString();
+ }
+
+ @VisibleForTesting
+ static String buildCacheValue(String[] titles, int[] counts) {
+ final StringBuilder sb = new StringBuilder();
+
+ for (int i = 0; i < titles.length; i++) {
+ if (i > 0) {
+ appendIfNotNull(sb, SEPARATOR);
+ }
+ appendIfNotNull(sb, titles[i]);
+ appendIfNotNull(sb, SEPARATOR);
+ appendIfNotNull(sb, Integer.toString(counts[i]));
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Creates and returns a {@link Bundle} that is appended to a {@link Cursor} as extras.
+ */
+ private static final Bundle buildExtraBundle(String[] titles, int[] counts) {
+ Bundle bundle = new Bundle();
+ bundle.putStringArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_TITLES, titles);
+ bundle.putIntArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS, counts);
+ return bundle;
+ }
+
+ @VisibleForTesting
+ static Bundle buildExtraBundleFromValue(String value) {
+ final String[] values;
+ if (TextUtils.isEmpty(value)) {
+ values = new String[0];
+ } else {
+ values = SEPARATOR_PATTERN.split(value);
+ }
+
+ if ((values.length) % 2 != 0) {
+ return null; // malformed
+ }
+
+ try {
+ final int numTitles = values.length / 2;
+ final String[] titles = new String[numTitles];
+ final int[] counts = new int[numTitles];
+
+ for (int i = 0; i < numTitles; i++) {
+ titles[i] = values[i * 2];
+ counts[i] = Integer.parseInt(values[i * 2 + 1]);
+ }
+
+ return buildExtraBundle(titles, counts);
+ } catch (RuntimeException e) {
+ Log.w(TAG, "Failed to parse cached value", e);
+ return null; // malformed
+ }
+ }
+
+ public Bundle get(Uri queryUri, String selection, String[] selectionArgs, String sortOrder,
+ String countExpression) {
+ ensureLoaded();
+ final String key = buildCacheKey(queryUri, selection, selectionArgs, sortOrder,
+ countExpression);
+ final String value = mCache.get(key);
+ if (value == null) {
+ if (Log.isLoggable(TAG, Log.VERBOSE)) {
+ Log.v(TAG, "Miss: " + key);
+ }
+ return null;
+ }
+
+ final Bundle b = buildExtraBundleFromValue(value);
+ if (b == null) {
+ // Value was malformed for whatever reason.
+ mCache.remove(key);
+ save();
+ } else {
+ if (Log.isLoggable(TAG, Log.VERBOSE)) {
+ Log.v(TAG, "Hit: " + key);
+ }
+ }
+ return b;
+ }
+
+ public Bundle putAndGetBundle(Uri queryUri, String selection, String[] selectionArgs,
+ String sortOrder, String countExpression, String[] titles, int[] counts) {
+ ensureLoaded();
+ final String key = buildCacheKey(queryUri, selection, selectionArgs, sortOrder,
+ countExpression);
+ mCache.put(key, buildCacheValue(titles, counts));
+ save();
+
+ if (Log.isLoggable(TAG, Log.VERBOSE)) {
+ Log.v(TAG, "Put: " + key);
+ }
+ return buildExtraBundle(titles, counts);
+ }
+
+ public void invalidate() {
+ mPrefs.edit().remove(PREFERENCE_KEY).apply();
+ mCache.clear();
+ mPreferenceLoaded = true;
+
+ if (Log.isLoggable(TAG, Log.VERBOSE)) {
+ Log.v(TAG, "Invalidated");
+ }
+ }
+
+ /**
+ * Store the cache to the preferences.
+ *
+ * We concatenate all key+value pairs into one string and save it.
+ */
+ private void save() {
+ final StringBuilder sb = new StringBuilder();
+ for (String key : mCache.keySet()) {
+ if (sb.length() > 0) {
+ appendIfNotNull(sb, SAVE_SEPARATOR);
+ }
+ appendIfNotNull(sb, key);
+ appendIfNotNull(sb, SAVE_SEPARATOR);
+ appendIfNotNull(sb, mCache.get(key));
+ }
+ mPrefs.edit().putString(PREFERENCE_KEY, sb.toString()).apply();
+ }
+
+ private void ensureLoaded() {
+ if (mPreferenceLoaded) return;
+
+ if (Log.isLoggable(TAG, Log.VERBOSE)) {
+ Log.v(TAG, "Loading...");
+ }
+
+ // Even when we fail to load, don't retry loading again.
+ mPreferenceLoaded = true;
+
+ boolean successfullyLoaded = false;
+ try {
+ final String savedValue = mPrefs.getString(PREFERENCE_KEY, null);
+
+ if (!TextUtils.isEmpty(savedValue)) {
+
+ final String[] keysAndValues = SAVE_SEPARATOR_PATTERN.split(savedValue);
+
+ if ((keysAndValues.length % 2) != 0) {
+ return; // malformed
+ }
+
+ for (int i = 1; i < keysAndValues.length; i += 2) {
+ final String key = keysAndValues[i - 1];
+ final String value = keysAndValues[i];
+
+ if (Log.isLoggable(TAG, Log.VERBOSE)) {
+ Log.v(TAG, "Loaded: " + key);
+ }
+
+ mCache.put(key, value);
+ }
+ }
+ successfullyLoaded = true;
+ } catch (RuntimeException e) {
+ Log.w(TAG, "Failed to load from preferences", e);
+ // But don't crash apps!
+ } finally {
+ if (!successfullyLoaded) {
+ invalidate();
+ }
+ }
+ }
+}
diff --git a/tests/src/com/android/providers/contacts/ContactsActor.java b/tests/src/com/android/providers/contacts/ContactsActor.java
index e93bb7b..038eb97 100644
--- a/tests/src/com/android/providers/contacts/ContactsActor.java
+++ b/tests/src/com/android/providers/contacts/ContactsActor.java
@@ -16,6 +16,7 @@
package com.android.providers.contacts;
+import com.android.providers.contacts.util.MockSharedPreferences;
import com.google.android.collect.Sets;
import android.accounts.Account;
@@ -30,6 +31,7 @@
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
+import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ProviderInfo;
@@ -157,7 +159,8 @@
RenamingDelegatingContext targetContextWrapper = new RenamingDelegatingContext(context,
overallContext, FILENAME_PREFIX);
- mProviderContext = new IsolatedContext(resolver, targetContextWrapper){
+ mProviderContext = new IsolatedContext(resolver, targetContextWrapper) {
+ private final MockSharedPreferences mPrefs = new MockSharedPreferences();
@Override
public File getFilesDir() {
@@ -175,6 +178,11 @@
}
return super.getSystemService(name);
}
+
+ @Override
+ public SharedPreferences getSharedPreferences(String name, int mode) {
+ return mPrefs;
+ }
};
mMockAccountManager = new MockAccountManager(mProviderContext);
diff --git a/tests/src/com/android/providers/contacts/ContactsProvider2Test.java b/tests/src/com/android/providers/contacts/ContactsProvider2Test.java
index 046dec8..088e586 100644
--- a/tests/src/com/android/providers/contacts/ContactsProvider2Test.java
+++ b/tests/src/com/android/providers/contacts/ContactsProvider2Test.java
@@ -5664,7 +5664,7 @@
new String[]{Contacts.DISPLAY_NAME},
null, null, Contacts.SORT_KEY_PRIMARY + " COLLATE LOCALIZED");
- assertFirstLetterValues(cursor, null, "B", "J", "M", "R", "T");
+ assertFirstLetterValues(cursor, "", "B", "J", "M", "R", "T");
assertFirstLetterCounts(cursor, 1, 1, 1, 2, 2, 1);
cursor.close();
@@ -5672,7 +5672,7 @@
new String[]{Contacts.DISPLAY_NAME},
null, null, Contacts.SORT_KEY_ALTERNATIVE + " COLLATE LOCALIZED DESC");
- assertFirstLetterValues(cursor, "W", "S", "R", "M", "B", null);
+ assertFirstLetterValues(cursor, "W", "S", "R", "M", "B", "");
assertFirstLetterCounts(cursor, 1, 2, 1, 1, 2, 1);
cursor.close();
}
diff --git a/tests/src/com/android/providers/contacts/FastScrollingIndexCacheTest.java b/tests/src/com/android/providers/contacts/FastScrollingIndexCacheTest.java
new file mode 100644
index 0000000..784134c
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/FastScrollingIndexCacheTest.java
@@ -0,0 +1,148 @@
+/*
+ * 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.providers.contacts;
+
+import com.android.providers.contacts.util.MockSharedPreferences;
+
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.ContactsContract.ContactCounts;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.RawContacts;
+import android.test.AndroidTestCase;
+import android.test.MoreAsserts;
+import android.test.suitebuilder.annotation.SmallTest;
+
+@SmallTest
+public class FastScrollingIndexCacheTest extends AndroidTestCase {
+ private MockSharedPreferences mPrefs;
+ private FastScrollingIndexCache mCache;
+
+ private static final String[] TITLES_0 = new String[] {};
+ private static final String[] TITLES_1 = new String[] {"a"};
+ private static final String[] TITLES_2 = new String[] {"", "b"};
+ private static final String[] TITLES_3 = new String[] {"", "b", "aaa"};
+
+ private static final int[] COUNTS_0 = new int[] {};
+ private static final int[] COUNTS_1 = new int[] {1};
+ private static final int[] COUNTS_2 = new int[] {2, 3};
+ private static final int[] COUNTS_3 = new int[] {0, -1, 2};
+
+ private static final String[] PROJECTION_0 = new String[] {};
+ private static final String[] PROJECTION_1 = new String[] {"c1"};
+ private static final String[] PROJECTION_2 = new String[] {"c3", "c4"};
+
+ private static final Uri URI_A = Contacts.CONTENT_URI;
+ private static final Uri URI_B = RawContacts.CONTENT_URI;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+
+ mPrefs = new MockSharedPreferences();
+ mCache = new FastScrollingIndexCache(mPrefs);
+ }
+
+ private void assertBundle(String[] expectedTitles, int[] expectedCounts, Bundle actual) {
+ assertNotNull(actual);
+ MoreAsserts.assertEquals(expectedTitles,
+ actual.getStringArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_TITLES));
+ MoreAsserts.assertEquals(expectedCounts,
+ actual.getIntArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS));
+ }
+
+ /**
+ * Test for {@link FastScrollingIndexCache#buildExtraBundleFromValue} and
+ * {@link FastScrollingIndexCache#buildCacheValue}.
+ */
+ public void testBuildCacheValue() {
+ assertBundle(TITLES_0, COUNTS_0,
+ FastScrollingIndexCache.buildExtraBundleFromValue(
+ FastScrollingIndexCache.buildCacheValue(TITLES_0, COUNTS_0)));
+ assertBundle(TITLES_1, COUNTS_1,
+ FastScrollingIndexCache.buildExtraBundleFromValue(
+ FastScrollingIndexCache.buildCacheValue(TITLES_1, COUNTS_1)));
+ assertBundle(TITLES_2, COUNTS_2,
+ FastScrollingIndexCache.buildExtraBundleFromValue(
+ FastScrollingIndexCache.buildCacheValue(TITLES_2, COUNTS_2)));
+ }
+
+ public void testPutAndGet() {
+ // Initially the cache is empty
+ assertNull(mCache.get(null, null, null, null, null));
+ assertNull(mCache.get(URI_A, "*s*", PROJECTION_0, "*so*", "*ce*"));
+ assertNull(mCache.get(URI_A, "*s*", PROJECTION_1, "*so*", "*ce*"));
+ assertNull(mCache.get(URI_B, "s", PROJECTION_2, "so", "ce"));
+
+ // Put...
+ Bundle b;
+ b = mCache.putAndGetBundle(null, null, null, null, null, TITLES_0, COUNTS_0);
+ assertBundle(TITLES_0, COUNTS_0, b);
+
+ b = mCache.putAndGetBundle(URI_A, "*s*", PROJECTION_0, "*so*", "*ce*", TITLES_1, COUNTS_1);
+ assertBundle(TITLES_1, COUNTS_1, b);
+
+ b = mCache.putAndGetBundle(URI_A, "*s*", PROJECTION_1, "*so*", "*ce*", TITLES_2, COUNTS_2);
+ assertBundle(TITLES_2, COUNTS_2, b);
+
+ b = mCache.putAndGetBundle(URI_B, "s", PROJECTION_2, "so", "ce", TITLES_3, COUNTS_3);
+ assertBundle(TITLES_3, COUNTS_3, b);
+
+ // Get...
+ assertBundle(TITLES_0, COUNTS_0, mCache.get(null, null, null, null, null));
+ assertBundle(TITLES_1, COUNTS_1, mCache.get(URI_A, "*s*", PROJECTION_0, "*so*", "*ce*"));
+ assertBundle(TITLES_2, COUNTS_2, mCache.get(URI_A, "*s*", PROJECTION_1, "*so*", "*ce*"));
+ assertBundle(TITLES_3, COUNTS_3, mCache.get(URI_B, "s", PROJECTION_2, "so", "ce"));
+
+ // Invalidate...
+ mCache.invalidate();
+
+ // Get again... Nothing shoul be cached...
+ assertNull(mCache.get(null, null, null, null, null));
+ assertNull(mCache.get(URI_A, "*s*", PROJECTION_0, "*so*", "*ce*"));
+ assertNull(mCache.get(URI_A, "*s*", PROJECTION_1, "*so*", "*ce*"));
+ assertNull(mCache.get(URI_B, "s", PROJECTION_2, "so", "ce"));
+
+ // Put again...
+ b = mCache.putAndGetBundle(null, null, null, null, null, TITLES_0, COUNTS_0);
+ assertBundle(TITLES_0, COUNTS_0, b);
+
+ b = mCache.putAndGetBundle(URI_A, "*s*", PROJECTION_0, "*so*", "*ce*", TITLES_1, COUNTS_1);
+ assertBundle(TITLES_1, COUNTS_1, b);
+
+ b = mCache.putAndGetBundle(URI_A, "*s*", PROJECTION_1, "*so*", "*ce*", TITLES_2, COUNTS_2);
+ assertBundle(TITLES_2, COUNTS_2, b);
+
+ b = mCache.putAndGetBundle(URI_B, "s", PROJECTION_2, "so", "ce", TITLES_2, COUNTS_2);
+ assertBundle(TITLES_2, COUNTS_2, b);
+
+ // Now, create a new cache instance (with the same shared preferences)
+ // It should restore the cache content from the preferences...
+
+ FastScrollingIndexCache cache2 = new FastScrollingIndexCache(mPrefs);
+ assertBundle(TITLES_0, COUNTS_0, cache2.get(null, null, null, null, null));
+ assertBundle(TITLES_1, COUNTS_1, cache2.get(URI_A, "*s*", PROJECTION_0, "*so*", "*ce*"));
+ assertBundle(TITLES_2, COUNTS_2, cache2.get(URI_A, "*s*", PROJECTION_1, "*so*", "*ce*"));
+ assertBundle(TITLES_2, COUNTS_2, cache2.get(URI_B, "s", PROJECTION_2, "so", "ce"));
+ }
+
+ public void testMalformedPreferences() {
+ mPrefs.edit().putString(FastScrollingIndexCache.PREFERENCE_KEY, "123");
+ // get() shouldn't crash
+ assertNull(mCache.get(null, null, null, null, null));
+ }
+}
diff --git a/tests/src/com/android/providers/contacts/util/MockSharedPreferences.java b/tests/src/com/android/providers/contacts/util/MockSharedPreferences.java
new file mode 100644
index 0000000..d00e711
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/util/MockSharedPreferences.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2010 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.util;
+
+import com.google.android.collect.Maps;
+
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+
+/**
+ * A programmable mock content provider.
+ */
+public class MockSharedPreferences implements SharedPreferences, SharedPreferences.Editor {
+
+ private HashMap<String, Object> mValues = Maps.newHashMap();
+ private HashMap<String, Object> mTempValues = Maps.newHashMap();
+
+ public Editor edit() {
+ return this;
+ }
+
+ public boolean contains(String key) {
+ return mValues.containsKey(key);
+ }
+
+ public Map<String, ?> getAll() {
+ return new HashMap<String, Object>(mValues);
+ }
+
+ public boolean getBoolean(String key, boolean defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Boolean)mValues.get(key)).booleanValue();
+ }
+ return defValue;
+ }
+
+ public float getFloat(String key, float defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Float)mValues.get(key)).floatValue();
+ }
+ return defValue;
+ }
+
+ public int getInt(String key, int defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Integer)mValues.get(key)).intValue();
+ }
+ return defValue;
+ }
+
+ public long getLong(String key, long defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Long)mValues.get(key)).longValue();
+ }
+ return defValue;
+ }
+
+ public String getString(String key, String defValue) {
+ if (mValues.containsKey(key))
+ return (String)mValues.get(key);
+ return defValue;
+ }
+
+ @SuppressWarnings("unchecked")
+ public Set<String> getStringSet(String key, Set<String> defValues) {
+ if (mValues.containsKey(key)) {
+ return (Set<String>) mValues.get(key);
+ }
+ return defValues;
+ }
+
+ public void registerOnSharedPreferenceChangeListener(
+ OnSharedPreferenceChangeListener listener) {
+ throw new UnsupportedOperationException();
+ }
+
+ public void unregisterOnSharedPreferenceChangeListener(
+ OnSharedPreferenceChangeListener listener) {
+ throw new UnsupportedOperationException();
+ }
+
+ public Editor putBoolean(String key, boolean value) {
+ mTempValues.put(key, Boolean.valueOf(value));
+ return this;
+ }
+
+ public Editor putFloat(String key, float value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putInt(String key, int value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putLong(String key, long value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putString(String key, String value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putStringSet(String key, Set<String> values) {
+ mTempValues.put(key, values);
+ return this;
+ }
+
+ public Editor remove(String key) {
+ mTempValues.remove(key);
+ return this;
+ }
+
+ public Editor clear() {
+ mTempValues.clear();
+ return this;
+ }
+
+ @SuppressWarnings("unchecked")
+ public boolean commit() {
+ mValues = (HashMap<String, Object>)mTempValues.clone();
+ return true;
+ }
+
+ public void apply() {
+ commit();
+ }
+}