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 
getInstance(Context context)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
getInstanceForTest( SharedPreferences prefs)105     static synchronized FastScrollingIndexCache getInstanceForTest(
106             SharedPreferences prefs) {
107         sSingleton = new FastScrollingIndexCache(prefs);
108         return sSingleton;
109     }
110 
FastScrollingIndexCache(SharedPreferences prefs)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      */
appendIfNotNull(StringBuilder sb, Object value)121     private static void appendIfNotNull(StringBuilder sb, Object value) {
122         if (value != null) {
123             sb.append(value.toString());
124         }
125     }
126 
buildCacheKey(Uri queryUri, String selection, String[] selectionArgs, String sortOrder, String countExpression)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
buildCacheValue(String[] titles, int[] counts)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      */
buildExtraBundle(String[] titles, int[] counts)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
buildExtraBundleFromValue(String value)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 
get(Uri queryUri, String selection, String[] selectionArgs, String sortOrder, String countExpression)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      */
put(Uri queryUri, String selection, String[] selectionArgs, String sortOrder, String countExpression, Bundle bundle)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 
invalidate()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      */
save()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 
ensureLoaded()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