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