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.Contacts;
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 
getInstance(Context context)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
getInstanceForTest( SharedPreferences prefs)99     static synchronized FastScrollingIndexCache getInstanceForTest(
100             SharedPreferences prefs) {
101         sSingleton = new FastScrollingIndexCache(prefs);
102         return sSingleton;
103     }
104 
FastScrollingIndexCache(SharedPreferences prefs)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      */
appendIfNotNull(StringBuilder sb, Object value)115     private static void appendIfNotNull(StringBuilder sb, Object value) {
116         if (value != null) {
117             sb.append(value.toString());
118         }
119     }
120 
buildCacheKey(Uri queryUri, String selection, String[] selectionArgs, String sortOrder, String countExpression)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
buildCacheValue(String[] titles, int[] counts)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      */
buildExtraBundle(String[] titles, int[] counts)161     public static final Bundle buildExtraBundle(String[] titles, int[] counts) {
162         Bundle bundle = new Bundle();
163         bundle.putStringArray(Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES, titles);
164         bundle.putIntArray(Contacts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS, counts);
165         return bundle;
166     }
167 
168     @VisibleForTesting
buildExtraBundleFromValue(String value)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 
get(Uri queryUri, String selection, String[] selectionArgs, String sortOrder, String countExpression)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      */
put(Uri queryUri, String selection, String[] selectionArgs, String sortOrder, String countExpression, Bundle bundle)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(Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES),
238                     bundle.getIntArray(Contacts.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 
invalidate()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      */
save()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 
ensureLoaded()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