/* Copyright © 2013-2014, Silent Circle, LLC. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Any redistribution, use, or modification is done solely for personal benefit and not for any commercial purpose or for monetary gain * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name Silent Circle nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL SILENT CIRCLE, LLC BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ /* * This implementation is edited version of original Android sources. */ /* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License */ package com.silentcircle.contacts.providers; import android.content.Context; import android.content.SharedPreferences; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; import android.preference.PreferenceManager; import android.text.TextUtils; import android.util.Log; import java.util.HashMap; import java.util.Map; import java.util.regex.Pattern; import com.silentcircle.silentcontacts.ScContactsContract; /** * Cache for the "fast scrolling index". * * It's a cache from "keys" and "bundles" (see {@link #mCache} for what they are). The cache * content is also persisted in the shared preferences, so it'll survive even if the process * is killed or the device reboots. * * All the content will be invalidated when the provider detects an operation that could potentially * change the index. * * There's no maximum number for cached entries. It's okay because we store keys and values in * a compact form in both the in-memory cache and the preferences. Also the query in question * (the query for contact lists) has relatively low number of variations. * * This class is thread-safe. */ public class FastScrollingIndexCache { private static final String TAG = "LetterCountCache"; // @VisibleForTesting static final String PREFERENCE_KEY = "LetterCountCache"; /** * Separator used for in-memory structure. */ private static final String SEPARATOR = "\u0001"; private static final Pattern SEPARATOR_PATTERN = Pattern.compile(SEPARATOR); /** * Separator used for serializing values for preferences. */ private static final String SAVE_SEPARATOR = "\u0002"; private static final Pattern SAVE_SEPARATOR_PATTERN = Pattern.compile(SAVE_SEPARATOR); private final SharedPreferences mPrefs; private boolean mPreferenceLoaded; /** * In-memory cache. * * It's essentially a map from keys, which are query parameters passed to {@link #get}, to * values, which are {@link Bundle}s that will be appended to a {@link Cursor} as extras. * * However, in order to save memory, we store stringified keys and values in the cache. * Key strings are generated by {@link #buildCacheKey} and values are generated by * {@link #buildCacheValue}. * * We store those strings joined with {@link #SAVE_SEPARATOR} as the separator when saving * to shared preferences. */ private final Map<String, String> mCache = new HashMap<String, String>(); public FastScrollingIndexCache(Context context) { this(PreferenceManager.getDefaultSharedPreferences(context)); // At this point, the SharedPreferences might just have been generated and may still be // loading from the file, in which case loading from the preferences would be blocked. // To avoid that, we load lazily. } // @VisibleForTesting FastScrollingIndexCache(SharedPreferences prefs) { mPrefs = prefs; } /** * Append a {@link String} to a {@link StringBuilder}. * * Unlike the original {@link StringBuilder#append}, it does *not* append the string "null" if * {@code value} is null. */ private static void appendIfNotNull(StringBuilder sb, Object value) { if (value != null) { sb.append(value.toString()); } } private static String buildCacheKey(Uri queryUri, String selection, String[] selectionArgs, String sortOrder, String countExpression) { final StringBuilder sb = new StringBuilder(); appendIfNotNull(sb, queryUri); appendIfNotNull(sb, SEPARATOR); appendIfNotNull(sb, selection); appendIfNotNull(sb, SEPARATOR); appendIfNotNull(sb, sortOrder); appendIfNotNull(sb, SEPARATOR); appendIfNotNull(sb, countExpression); if (selectionArgs != null) { for (int i = 0; i < selectionArgs.length; i++) { appendIfNotNull(sb, SEPARATOR); appendIfNotNull(sb, selectionArgs[i]); } } return sb.toString(); } // @VisibleForTesting static String buildCacheValue(String[] titles, int[] counts) { final StringBuilder sb = new StringBuilder(); for (int i = 0; i < titles.length; i++) { if (i > 0) { appendIfNotNull(sb, SEPARATOR); } appendIfNotNull(sb, titles[i]); appendIfNotNull(sb, SEPARATOR); appendIfNotNull(sb, Integer.toString(counts[i])); } return sb.toString(); } /** * Creates and returns a {@link Bundle} that is appended to a {@link Cursor} as extras. */ public static final Bundle buildExtraBundle(String[] titles, int[] counts) { Bundle bundle = new Bundle(); bundle.putStringArray(ScContactsContract.ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_TITLES, titles); bundle.putIntArray(ScContactsContract.ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS, counts); return bundle; } // @VisibleForTesting static Bundle buildExtraBundleFromValue(String value) { final String[] values; if (TextUtils.isEmpty(value)) { values = new String[0]; } else { values = SEPARATOR_PATTERN.split(value); } if ((values.length) % 2 != 0) { return null; // malformed } try { final int numTitles = values.length / 2; final String[] titles = new String[numTitles]; final int[] counts = new int[numTitles]; for (int i = 0; i < numTitles; i++) { titles[i] = values[i * 2]; counts[i] = Integer.parseInt(values[i * 2 + 1]); } return buildExtraBundle(titles, counts); } catch (RuntimeException e) { Log.w(TAG, "Failed to parse cached value", e); return null; // malformed } } public Bundle get(Uri queryUri, String selection, String[] selectionArgs, String sortOrder, String countExpression) { synchronized (mCache) { ensureLoaded(); final String key = buildCacheKey(queryUri, selection, selectionArgs, sortOrder, countExpression); final String value = mCache.get(key); if (value == null) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Miss: " + key); } return null; } final Bundle b = buildExtraBundleFromValue(value); if (b == null) { // Value was malformed for whatever reason. mCache.remove(key); save(); } else { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Hit: " + key); } } return b; } } /** * Put a {@link Bundle} into the cache. {@link Bundle} MUST be built with * {@link #buildExtraBundle(String[], int[])}. */ public void put(Uri queryUri, String selection, String[] selectionArgs, String sortOrder, String countExpression, Bundle bundle) { synchronized (mCache) { ensureLoaded(); final String key = buildCacheKey(queryUri, selection, selectionArgs, sortOrder, countExpression); mCache.put( key, buildCacheValue(bundle.getStringArray(ScContactsContract.ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_TITLES), bundle.getIntArray(ScContactsContract.ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS))); save(); if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Put: " + key); } } } public void invalidate() { synchronized (mCache) { mPrefs.edit().remove(PREFERENCE_KEY).apply(); mCache.clear(); mPreferenceLoaded = true; if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Invalidated"); } } } /** * Store the cache to the preferences. * * We concatenate all key+value pairs into one string and save it. */ private void save() { final StringBuilder sb = new StringBuilder(); for (String key : mCache.keySet()) { if (sb.length() > 0) { appendIfNotNull(sb, SAVE_SEPARATOR); } appendIfNotNull(sb, key); appendIfNotNull(sb, SAVE_SEPARATOR); appendIfNotNull(sb, mCache.get(key)); } mPrefs.edit().putString(PREFERENCE_KEY, sb.toString()).apply(); } private void ensureLoaded() { if (mPreferenceLoaded) return; if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Loading..."); } // Even when we fail to load, don't retry loading again. mPreferenceLoaded = true; boolean successfullyLoaded = false; try { final String savedValue = mPrefs.getString(PREFERENCE_KEY, null); if (!TextUtils.isEmpty(savedValue)) { final String[] keysAndValues = SAVE_SEPARATOR_PATTERN.split(savedValue); if ((keysAndValues.length % 2) != 0) { return; // malformed } for (int i = 1; i < keysAndValues.length; i += 2) { final String key = keysAndValues[i - 1]; final String value = keysAndValues[i]; if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Loaded: " + key); } mCache.put(key, value); } } successfullyLoaded = true; } catch (RuntimeException e) { Log.w(TAG, "Failed to load from preferences", e); // But don't crash apps! } finally { if (!successfullyLoaded) { invalidate(); } } } }