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