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