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