Home | History | Annotate | Download | only in contacts
      1 /*
      2  * Copyright (C) 2012 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License
     15  */
     16 
     17 package com.android.providers.contacts;
     18 
     19 import android.content.Context;
     20 import android.content.SharedPreferences;
     21 import android.database.Cursor;
     22 import android.net.Uri;
     23 import android.os.Bundle;
     24 import android.preference.PreferenceManager;
     25 import android.provider.ContactsContract.ContactCounts;
     26 import android.text.TextUtils;
     27 import android.util.Log;
     28 
     29 import com.google.android.collect.Maps;
     30 import com.google.common.annotations.VisibleForTesting;
     31 
     32 import java.util.Map;
     33 import java.util.regex.Pattern;
     34 
     35 /**
     36  * Cache for the "fast scrolling index".
     37  *
     38  * It's a cache from "keys" and "bundles" (see {@link #mCache} for what they are).  The cache
     39  * content is also persisted in the shared preferences, so it'll survive even if the process
     40  * is killed or the device reboots.
     41  *
     42  * All the content will be invalidated when the provider detects an operation that could potentially
     43  * change the index.
     44  *
     45  * There's no maximum number for cached entries.  It's okay because we store keys and values in
     46  * a compact form in both the in-memory cache and the preferences.  Also the query in question
     47  * (the query for contact lists) has relatively low number of variations.
     48  *
     49  * This class is thread-safe.
     50  */
     51 public class FastScrollingIndexCache {
     52     private static final String TAG = "LetterCountCache";
     53 
     54     @VisibleForTesting
     55     static final String PREFERENCE_KEY = "LetterCountCache";
     56 
     57     /**
     58      * Separator used for in-memory structure.
     59      */
     60     private static final String SEPARATOR = "\u0001";
     61     private static final Pattern SEPARATOR_PATTERN = Pattern.compile(SEPARATOR);
     62 
     63     /**
     64      * Separator used for serializing values for preferences.
     65      */
     66     private static final String SAVE_SEPARATOR = "\u0002";
     67     private static final Pattern SAVE_SEPARATOR_PATTERN = Pattern.compile(SAVE_SEPARATOR);
     68 
     69     private final SharedPreferences mPrefs;
     70 
     71     private boolean mPreferenceLoaded;
     72 
     73     /**
     74      * In-memory cache.
     75      *
     76      * It's essentially a map from keys, which are query parameters passed to {@link #get}, to
     77      * values, which are {@link Bundle}s that will be appended to a {@link Cursor} as extras.
     78      *
     79      * However, in order to save memory, we store stringified keys and values in the cache.
     80      * Key strings are generated by {@link #buildCacheKey} and values are generated by
     81      * {@link #buildCacheValue}.
     82      *
     83      * We store those strings joined with {@link #SAVE_SEPARATOR} as the separator when saving
     84      * to shared preferences.
     85      */
     86     private final Map<String, String> mCache = Maps.newHashMap();
     87 
     88     private static FastScrollingIndexCache sSingleton;
     89 
     90     public static FastScrollingIndexCache getInstance(Context context) {
     91         if (sSingleton == null) {
     92             SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
     93             sSingleton = new FastScrollingIndexCache(prefs);
     94         }
     95         return sSingleton;
     96     }
     97 
     98     @VisibleForTesting
     99     static synchronized FastScrollingIndexCache getInstanceForTest(
    100             SharedPreferences prefs) {
    101         sSingleton = new FastScrollingIndexCache(prefs);
    102         return sSingleton;
    103     }
    104 
    105     private FastScrollingIndexCache(SharedPreferences prefs) {
    106         mPrefs = prefs;
    107     }
    108 
    109     /**
    110      * Append a {@link String} to a {@link StringBuilder}.
    111      *
    112      * Unlike the original {@link StringBuilder#append}, it does *not* append the string "null" if
    113      * {@code value} is null.
    114      */
    115     private static void appendIfNotNull(StringBuilder sb, Object value) {
    116         if (value != null) {
    117             sb.append(value.toString());
    118         }
    119     }
    120 
    121     private static String buildCacheKey(Uri queryUri, String selection, String[] selectionArgs,
    122             String sortOrder, String countExpression) {
    123         final StringBuilder sb = new StringBuilder();
    124 
    125         appendIfNotNull(sb, queryUri);
    126         appendIfNotNull(sb, SEPARATOR);
    127         appendIfNotNull(sb, selection);
    128         appendIfNotNull(sb, SEPARATOR);
    129         appendIfNotNull(sb, sortOrder);
    130         appendIfNotNull(sb, SEPARATOR);
    131         appendIfNotNull(sb, countExpression);
    132 
    133         if (selectionArgs != null) {
    134             for (int i = 0; i < selectionArgs.length; i++) {
    135                 appendIfNotNull(sb, SEPARATOR);
    136                 appendIfNotNull(sb, selectionArgs[i]);
    137             }
    138         }
    139         return sb.toString();
    140     }
    141 
    142     @VisibleForTesting
    143     static String buildCacheValue(String[] titles, int[] counts) {
    144         final StringBuilder sb = new StringBuilder();
    145 
    146         for (int i = 0; i < titles.length; i++) {
    147             if (i > 0) {
    148                 appendIfNotNull(sb, SEPARATOR);
    149             }
    150             appendIfNotNull(sb, titles[i]);
    151             appendIfNotNull(sb, SEPARATOR);
    152             appendIfNotNull(sb, Integer.toString(counts[i]));
    153         }
    154 
    155         return sb.toString();
    156     }
    157 
    158     /**
    159      * Creates and returns a {@link Bundle} that is appended to a {@link Cursor} as extras.
    160      */
    161     public static final Bundle buildExtraBundle(String[] titles, int[] counts) {
    162         Bundle bundle = new Bundle();
    163         bundle.putStringArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_TITLES, titles);
    164         bundle.putIntArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS, counts);
    165         return bundle;
    166     }
    167 
    168     @VisibleForTesting
    169     static Bundle buildExtraBundleFromValue(String value) {
    170         final String[] values;
    171         if (TextUtils.isEmpty(value)) {
    172             values = new String[0];
    173         } else {
    174             values = SEPARATOR_PATTERN.split(value);
    175         }
    176 
    177         if ((values.length) % 2 != 0) {
    178             return null; // malformed
    179         }
    180 
    181         try {
    182             final int numTitles = values.length / 2;
    183             final String[] titles = new String[numTitles];
    184             final int[] counts = new int[numTitles];
    185 
    186             for (int i = 0; i < numTitles; i++) {
    187                 titles[i] = values[i * 2];
    188                 counts[i] = Integer.parseInt(values[i * 2 + 1]);
    189             }
    190 
    191             return buildExtraBundle(titles, counts);
    192         } catch (RuntimeException e) {
    193             Log.w(TAG, "Failed to parse cached value", e);
    194             return null; // malformed
    195         }
    196     }
    197 
    198     public Bundle get(Uri queryUri, String selection, String[] selectionArgs, String sortOrder,
    199             String countExpression) {
    200         synchronized (mCache) {
    201             ensureLoaded();
    202             final String key = buildCacheKey(queryUri, selection, selectionArgs, sortOrder,
    203                     countExpression);
    204             final String value = mCache.get(key);
    205             if (value == null) {
    206                 if (Log.isLoggable(TAG, Log.VERBOSE)) {
    207                     Log.v(TAG, "Miss: " + key);
    208                 }
    209                 return null;
    210             }
    211 
    212             final Bundle b = buildExtraBundleFromValue(value);
    213             if (b == null) {
    214                 // Value was malformed for whatever reason.
    215                 mCache.remove(key);
    216                 save();
    217             } else {
    218                 if (Log.isLoggable(TAG, Log.VERBOSE)) {
    219                     Log.v(TAG, "Hit:  " + key);
    220                 }
    221             }
    222             return b;
    223         }
    224     }
    225 
    226     /**
    227      * Put a {@link Bundle} into the cache.  {@link Bundle} MUST be built with
    228      * {@link #buildExtraBundle(String[], int[])}.
    229      */
    230     public void put(Uri queryUri, String selection, String[] selectionArgs, String sortOrder,
    231             String countExpression, Bundle bundle) {
    232         synchronized (mCache) {
    233             ensureLoaded();
    234             final String key = buildCacheKey(queryUri, selection, selectionArgs, sortOrder,
    235                     countExpression);
    236             mCache.put(key, buildCacheValue(
    237                     bundle.getStringArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_TITLES),
    238                     bundle.getIntArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS)));
    239             save();
    240 
    241             if (Log.isLoggable(TAG, Log.VERBOSE)) {
    242                 Log.v(TAG, "Put: " + key);
    243             }
    244         }
    245     }
    246 
    247     public void invalidate() {
    248         synchronized (mCache) {
    249             mPrefs.edit().remove(PREFERENCE_KEY).commit();
    250             mCache.clear();
    251             mPreferenceLoaded = true;
    252 
    253             if (Log.isLoggable(TAG, Log.VERBOSE)) {
    254                 Log.v(TAG, "Invalidated");
    255             }
    256         }
    257     }
    258 
    259     /**
    260      * Store the cache to the preferences.
    261      *
    262      * We concatenate all key+value pairs into one string and save it.
    263      */
    264     private void save() {
    265         final StringBuilder sb = new StringBuilder();
    266         for (String key : mCache.keySet()) {
    267             if (sb.length() > 0) {
    268                 appendIfNotNull(sb, SAVE_SEPARATOR);
    269             }
    270             appendIfNotNull(sb, key);
    271             appendIfNotNull(sb, SAVE_SEPARATOR);
    272             appendIfNotNull(sb, mCache.get(key));
    273         }
    274         mPrefs.edit().putString(PREFERENCE_KEY, sb.toString()).apply();
    275     }
    276 
    277     private void ensureLoaded() {
    278         if (mPreferenceLoaded) return;
    279 
    280         if (Log.isLoggable(TAG, Log.VERBOSE)) {
    281             Log.v(TAG, "Loading...");
    282         }
    283 
    284         // Even when we fail to load, don't retry loading again.
    285         mPreferenceLoaded = true;
    286 
    287         boolean successfullyLoaded = false;
    288         try {
    289             final String savedValue = mPrefs.getString(PREFERENCE_KEY, null);
    290 
    291             if (!TextUtils.isEmpty(savedValue)) {
    292 
    293                 final String[] keysAndValues = SAVE_SEPARATOR_PATTERN.split(savedValue);
    294 
    295                 if ((keysAndValues.length % 2) != 0) {
    296                     return; // malformed
    297                 }
    298 
    299                 for (int i = 1; i < keysAndValues.length; i += 2) {
    300                     final String key = keysAndValues[i - 1];
    301                     final String value = keysAndValues[i];
    302 
    303                     if (Log.isLoggable(TAG, Log.VERBOSE)) {
    304                         Log.v(TAG, "Loaded: " + key);
    305                     }
    306 
    307                     mCache.put(key, value);
    308                 }
    309             }
    310             successfullyLoaded = true;
    311         } catch (RuntimeException e) {
    312             Log.w(TAG, "Failed to load from preferences", e);
    313             // But don't crash apps!
    314         } finally {
    315             if (!successfullyLoaded) {
    316                 invalidate();
    317             }
    318         }
    319     }
    320 }
    321