/*
 * Copyright (C) 2010 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.android.email.provider;

import android.content.ContentValues;
import android.database.CrossProcessCursor;
import android.database.Cursor;
import android.database.CursorWindow;
import android.database.CursorWrapper;
import android.database.MatrixCursor;
import android.net.Uri;
import android.util.Log;
import android.util.LruCache;

import com.android.email.Email;
import com.google.common.annotations.VisibleForTesting;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

/**
 * An LRU cache for EmailContent (Account, HostAuth, Mailbox, and Message, thus far).  The intended
 * user of this cache is EmailProvider itself; caching is entirely transparent to users of the
 * provider.
 *
 * Usage examples; id is a String representation of a row id (_id), as it might be retrieved from
 * a uri via getPathSegment
 *
 * To create a cache:
 *    ContentCache cache = new ContentCache(name, projection, max);
 *
 * To (try to) get a cursor from a cache:
 *    Cursor cursor = cache.getCursor(id, projection);
 *
 * To read from a table and cache the resulting cursor:
 * 1. Get a CacheToken: CacheToken token = cache.getToken(id);
 * 2. Get a cursor from the database: Cursor cursor = db.query(....);
 * 3. Put the cursor in the cache: cache.putCursor(cursor, id, token);
 * Only cursors with the projection given in the definition of the cache can be cached
 *
 * To delete one or more rows or update multiple rows from a table that uses cached data:
 * 1. Lock the row in the cache: cache.lock(id);
 * 2. Delete/update the row(s): db.delete(...);
 * 3. Invalidate any other caches that might be affected by the delete/update:
 *      The entire cache: affectedCache.invalidate()*
 *      A specific row in a cache: affectedCache.invalidate(rowId)
 * 4. Unlock the row in the cache: cache.unlock(id);
 *
 * To update a single row from a table that uses cached data:
 * 1. Lock the row in the cache: cache.lock(id);
 * 2. Update the row: db.update(...);
 * 3. Unlock the row in the cache, passing in the new values: cache.unlock(id, values);
 *
 * Synchronization note: All of the public methods in ContentCache are synchronized (i.e. on the
 * cache itself) except for methods that are solely used for debugging and do not modify the cache.
 * All references to ContentCache that are external to the ContentCache class MUST synchronize on
 * the ContentCache instance (e.g. CachedCursor.close())
 */
public final class ContentCache {
    private static final boolean DEBUG_CACHE = false;  // DO NOT CHECK IN TRUE
    private static final boolean DEBUG_TOKENS = false;  // DO NOT CHECK IN TRUE
    private static final boolean DEBUG_NOT_CACHEABLE = false;  // DO NOT CHECK IN TRUE
    private static final boolean DEBUG_STATISTICS = false; // DO NOT CHECK THIS IN TRUE

    // If false, reads will not use the cache; this is intended for debugging only
    private static final boolean READ_CACHE_ENABLED = true;  // DO NOT CHECK IN FALSE

    // Count of non-cacheable queries (debug only)
    private static int sNotCacheable = 0;
    // A map of queries that aren't cacheable (debug only)
    private static final CounterMap<String> sNotCacheableMap = new CounterMap<String>();

    private final LruCache<String, Cursor> mLruCache;

    // All defined caches
    private static final ArrayList<ContentCache> sContentCaches = new ArrayList<ContentCache>();
    // A set of all unclosed, cached cursors; this will typically be a very small set, as cursors
    // tend to be closed quickly after use.  The value, for each cursor, is its reference count
    /*package*/ static final CounterMap<Cursor> sActiveCursors = new CounterMap<Cursor>(24);

    // A set of locked content id's
    private final CounterMap<String> mLockMap = new CounterMap<String>(4);
    // A set of active tokens
    /*package*/ TokenList mTokenList;

    // The name of the cache (used for logging)
    private final String mName;
    // The base projection (only queries in which all columns exist in this projection will be
    // able to avoid a cache miss)
    private final String[] mBaseProjection;
    // The tag used for logging
    private final String mLogTag;
    // Cache statistics
    private final Statistics mStats;
    /** If {@code true}, lock the cache for all writes */
    private static boolean sLockCache;

    /**
     * A synchronized reference counter for arbitrary objects
     */
    /*package*/ static class CounterMap<T> {
        private HashMap<T, Integer> mMap;

        /*package*/ CounterMap(int maxSize) {
            mMap = new HashMap<T, Integer>(maxSize);
        }

        /*package*/ CounterMap() {
            mMap = new HashMap<T, Integer>();
        }

        /*package*/ synchronized int subtract(T object) {
            Integer refCount = mMap.get(object);
            int newCount;
            if (refCount == null || refCount.intValue() == 0) {
                throw new IllegalStateException();
            }
            if (refCount > 1) {
                newCount = refCount - 1;
                mMap.put(object, newCount);
            } else {
                newCount = 0;
                mMap.remove(object);
            }
            return newCount;
        }

        /*package*/ synchronized void add(T object) {
            Integer refCount = mMap.get(object);
            if (refCount == null) {
                mMap.put(object, 1);
            } else {
                mMap.put(object, refCount + 1);
            }
        }

        /*package*/ synchronized boolean contains(T object) {
            return mMap.containsKey(object);
        }

        /*package*/ synchronized int getCount(T object) {
            Integer refCount = mMap.get(object);
            return (refCount == null) ? 0 : refCount.intValue();
        }

        synchronized int size() {
            return mMap.size();
        }

        /**
         * For Debugging Only - not efficient
         */
        synchronized Set<HashMap.Entry<T, Integer>> entrySet() {
            return mMap.entrySet();
        }
    }

    /**
     * A list of tokens that are in use at any moment; there can be more than one token for an id
     */
    /*package*/ static class TokenList extends ArrayList<CacheToken> {
        private static final long serialVersionUID = 1L;
        private final String mLogTag;

        /*package*/ TokenList(String name) {
            mLogTag = "TokenList-" + name;
        }

        /*package*/ int invalidateTokens(String id) {
            if (Email.DEBUG && DEBUG_TOKENS) {
                Log.d(mLogTag, "============ Invalidate tokens for: " + id);
            }
            ArrayList<CacheToken> removeList = new ArrayList<CacheToken>();
            int count = 0;
            for (CacheToken token: this) {
                if (token.getId().equals(id)) {
                    token.invalidate();
                    removeList.add(token);
                    count++;
                }
            }
            for (CacheToken token: removeList) {
                remove(token);
            }
            return count;
        }

        /*package*/ void invalidate() {
            if (Email.DEBUG && DEBUG_TOKENS) {
                Log.d(mLogTag, "============ List invalidated");
            }
            for (CacheToken token: this) {
                token.invalidate();
            }
            clear();
        }

        /*package*/ boolean remove(CacheToken token) {
            boolean result = super.remove(token);
            if (Email.DEBUG && DEBUG_TOKENS) {
                if (result) {
                    Log.d(mLogTag, "============ Removing token for: " + token.mId);
                } else {
                    Log.d(mLogTag, "============ No token found for: " + token.mId);
                }
            }
            return result;
        }

        public CacheToken add(String id) {
            CacheToken token = new CacheToken(id);
            super.add(token);
            if (Email.DEBUG && DEBUG_TOKENS) {
                Log.d(mLogTag, "============ Taking token for: " + token.mId);
            }
            return token;
        }
    }

    /**
     * A CacheToken is an opaque object that must be passed into putCursor in order to attempt to
     * write into the cache.  The token becomes invalidated by any intervening write to the cached
     * record.
     */
    public static final class CacheToken {
        private final String mId;
        private boolean mIsValid = READ_CACHE_ENABLED;

        /*package*/ CacheToken(String id) {
            mId = id;
        }

        /*package*/ String getId() {
            return mId;
        }

        /*package*/ boolean isValid() {
            return mIsValid;
        }

        /*package*/ void invalidate() {
            mIsValid = false;
        }

        @Override
        public boolean equals(Object token) {
            return ((token instanceof CacheToken) && ((CacheToken)token).mId.equals(mId));
        }

        @Override
        public int hashCode() {
            return mId.hashCode();
        }
    }

    /**
     * The cached cursor is simply a CursorWrapper whose underlying cursor contains zero or one
     * rows.  We handle simple movement (moveToFirst(), moveToNext(), etc.), and override close()
     * to keep the underlying cursor alive (unless it's no longer cached due to an invalidation).
     * Multiple CachedCursor's can use the same underlying cursor, so we override the various
     * moveX methods such that each CachedCursor can have its own position information
     */
    public static final class CachedCursor extends CursorWrapper implements CrossProcessCursor {
        // The cursor we're wrapping
        private final Cursor mCursor;
        // The cache which generated this cursor
        private final ContentCache mCache;
        private final String mId;
        // The current position of the cursor (can only be 0 or 1)
        private int mPosition = -1;
        // The number of rows in this cursor (-1 = not determined)
        private int mCount = -1;
        private boolean isClosed = false;

        public CachedCursor(Cursor cursor, ContentCache cache, String id) {
            super(cursor);
            mCursor = cursor;
            mCache = cache;
            mId = id;
            // Add this to our set of active cursors
            sActiveCursors.add(cursor);
        }

        /**
         * Close this cursor; if the cursor's cache no longer contains the underlying cursor, and
         * there are no other users of that cursor, we'll close it here. In any event,
         * we'll remove the cursor from our set of active cursors.
         */
        @Override
        public void close() {
            synchronized(mCache) {
                int count = sActiveCursors.subtract(mCursor);
                if ((count == 0) && mCache.mLruCache.get(mId) != (mCursor)) {
                    super.close();
                }
            }
            isClosed = true;
        }

        @Override
        public boolean isClosed() {
            return isClosed;
        }

        @Override
        public int getCount() {
            if (mCount < 0) {
                mCount = super.getCount();
            }
            return mCount;
        }

        /**
         * We'll be happy to move to position 0 or -1
         */
        @Override
        public boolean moveToPosition(int pos) {
            if (pos >= getCount() || pos < -1) {
                return false;
            }
            mPosition = pos;
            return true;
        }

        @Override
        public boolean moveToFirst() {
            return moveToPosition(0);
        }

        @Override
        public boolean moveToNext() {
            return moveToPosition(mPosition + 1);
        }

        @Override
        public boolean moveToPrevious() {
            return moveToPosition(mPosition - 1);
        }

        @Override
        public int getPosition() {
            return mPosition;
        }

        @Override
        public final boolean move(int offset) {
            return moveToPosition(mPosition + offset);
        }

        @Override
        public final boolean moveToLast() {
            return moveToPosition(getCount() - 1);
        }

        @Override
        public final boolean isLast() {
            return mPosition == (getCount() - 1);
        }

        @Override
        public final boolean isBeforeFirst() {
            return mPosition == -1;
        }

        @Override
        public final boolean isAfterLast() {
            return mPosition == 1;
        }

        @Override
        public CursorWindow getWindow() {
           return ((CrossProcessCursor)mCursor).getWindow();
        }

        @Override
        public void fillWindow(int pos, CursorWindow window) {
            ((CrossProcessCursor)mCursor).fillWindow(pos, window);
        }

        @Override
        public boolean onMove(int oldPosition, int newPosition) {
            return true;
        }
    }

    /**
     * Public constructor
     * @param name the name of the cache (used for logging)
     * @param baseProjection the projection used for cached cursors; queries whose columns are not
     *  included in baseProjection will always generate a cache miss
     * @param maxSize the maximum number of content cursors to cache
     */
    public ContentCache(String name, String[] baseProjection, int maxSize) {
        mName = name;
        mLruCache = new LruCache<String, Cursor>(maxSize) {
            @Override
            protected void entryRemoved(
                    boolean evicted, String key, Cursor oldValue, Cursor newValue) {
                // Close this cursor if it's no longer being used
                if (evicted && !sActiveCursors.contains(oldValue)) {
                    oldValue.close();
                }
            }
        };
        mBaseProjection = baseProjection;
        mLogTag = "ContentCache-" + name;
        sContentCaches.add(this);
        mTokenList = new TokenList(mName);
        mStats = new Statistics(this);
    }

    /**
     * Return the base projection for cached rows
     * Get the projection used for cached rows (typically, the largest possible projection)
     * @return
     */
    public String[] getProjection() {
        return mBaseProjection;
    }


    /**
     * Get a CacheToken for a row as specified by its id (_id column)
     * @param id the id of the record
     * @return a CacheToken needed in order to write data for the record back to the cache
     */
    public synchronized CacheToken getCacheToken(String id) {
        // If another thread is already writing the data, return an invalid token
        CacheToken token = mTokenList.add(id);
        if (mLockMap.contains(id)) {
            token.invalidate();
        }
        return token;
    }

    public int size() {
        return mLruCache.size();
    }

    @VisibleForTesting
    Cursor get(String id) {
        return mLruCache.get(id);
    }

    protected Map<String, Cursor> getSnapshot() {
        return mLruCache.snapshot();
    }
    /**
     * Try to cache a cursor for the given id and projection; returns a valid cursor, either a
     * cached cursor (if caching was successful) or the original cursor
     *
     * @param c the cursor to be cached
     * @param id the record id (_id) of the content
     * @param projection the projection represented by the cursor
     * @return whether or not the cursor was cached
     */
    public Cursor putCursor(Cursor c, String id, String[] projection, CacheToken token) {
        // Make sure the underlying cursor is at the first row, and do this without synchronizing,
        // to prevent deadlock with a writing thread (which might, for example, be calling into
        // CachedCursor.invalidate)
        c.moveToPosition(0);
        return putCursorImpl(c, id, projection, token);
    }
    public synchronized Cursor putCursorImpl(Cursor c, String id, String[] projection,
            CacheToken token) {
        try {
            if (!token.isValid()) {
                if (Email.DEBUG && DEBUG_CACHE) {
                    Log.d(mLogTag, "============ Stale token for " + id);
                }
                mStats.mStaleCount++;
                return c;
            }
            if (c != null && Arrays.equals(projection, mBaseProjection) && !sLockCache) {
                if (Email.DEBUG && DEBUG_CACHE) {
                    Log.d(mLogTag, "============ Caching cursor for: " + id);
                }
                // If we've already cached this cursor, invalidate the older one
                Cursor existingCursor = get(id);
                if (existingCursor != null) {
                   unlockImpl(id, null, false);
                }
                mLruCache.put(id, c);
                return new CachedCursor(c, this, id);
            }
            return c;
        } finally {
            mTokenList.remove(token);
        }
    }

    /**
     * Find and, if found, return a cursor, based on cached values, for the supplied id
     * @param id the _id column of the desired row
     * @param projection the requested projection for a query
     * @return a cursor based on cached values, or null if the row is not cached
     */
    public synchronized Cursor getCachedCursor(String id, String[] projection) {
        if (Email.DEBUG && DEBUG_STATISTICS) {
            // Every 200 calls to getCursor, report cache statistics
            dumpOnCount(200);
        }
        if (projection == mBaseProjection) {
            return getCachedCursorImpl(id);
        } else {
            return getMatrixCursor(id, projection);
        }
    }

    private CachedCursor getCachedCursorImpl(String id) {
        Cursor c = get(id);
        if (c != null) {
            mStats.mHitCount++;
            return new CachedCursor(c, this, id);
        }
        mStats.mMissCount++;
        return null;
    }

    private MatrixCursor getMatrixCursor(String id, String[] projection) {
        return getMatrixCursor(id, projection, null);
    }

    private MatrixCursor getMatrixCursor(String id, String[] projection,
            ContentValues values) {
        Cursor c = get(id);
        if (c != null) {
            // Make a new MatrixCursor with the requested columns
            MatrixCursor mc = new MatrixCursor(projection, 1);
            if (c.getCount() == 0) {
                return mc;
            }
            Object[] row = new Object[projection.length];
            if (values != null) {
                // Make a copy; we don't want to change the original
                values = new ContentValues(values);
            }
            int i = 0;
            for (String column: projection) {
                int columnIndex = c.getColumnIndex(column);
                if (columnIndex < 0) {
                    mStats.mProjectionMissCount++;
                    return null;
                } else {
                    String value;
                    if (values != null && values.containsKey(column)) {
                        Object val = values.get(column);
                        if (val instanceof Boolean) {
                            value = (val == Boolean.TRUE) ? "1" : "0";
                        } else {
                            value = values.getAsString(column);
                        }
                        values.remove(column);
                    } else {
                        value = c.getString(columnIndex);
                    }
                    row[i++] = value;
                }
            }
            if (values != null && values.size() != 0) {
                return null;
            }
            mc.addRow(row);
            mStats.mHitCount++;
            return mc;
        }
        mStats.mMissCount++;
        return null;
    }

    /**
     * Lock a given row, such that no new valid CacheTokens can be created for the passed-in id.
     * @param id the id of the row to lock
     */
    public synchronized void lock(String id) {
        // Prevent new valid tokens from being created
        mLockMap.add(id);
        // Invalidate current tokens
        int count = mTokenList.invalidateTokens(id);
        if (Email.DEBUG && DEBUG_TOKENS) {
            Log.d(mTokenList.mLogTag, "============ Lock invalidated " + count +
                    " tokens for: " + id);
        }
    }

    /**
     * Unlock a given row, allowing new valid CacheTokens to be created for the passed-in id.
     * @param id the id of the item whose cursor is cached
     */
    public synchronized void unlock(String id) {
        unlockImpl(id, null, true);
    }

    /**
     * If the row with id is currently cached, replaces the cached values with the supplied
     * ContentValues.  Then, unlock the row, so that new valid CacheTokens can be created.
     *
     * @param id the id of the item whose cursor is cached
     * @param values updated values for this row
     */
    public synchronized void unlock(String id, ContentValues values) {
        unlockImpl(id, values, true);
    }

    /**
     * If values are passed in, replaces any cached cursor with one containing new values, and
     * then closes the previously cached one (if any, and if not in use)
     * If values are not passed in, removes the row from cache
     * If the row was locked, unlock it
     * @param id the id of the row
     * @param values new ContentValues for the row (or null if row should simply be removed)
     * @param wasLocked whether or not the row was locked; if so, the lock will be removed
     */
    private void unlockImpl(String id, ContentValues values, boolean wasLocked) {
        Cursor c = get(id);
        if (c != null) {
            if (Email.DEBUG && DEBUG_CACHE) {
                Log.d(mLogTag, "=========== Unlocking cache for: " + id);
            }
            if (values != null && !sLockCache) {
                MatrixCursor cursor = getMatrixCursor(id, mBaseProjection, values);
                if (cursor != null) {
                    if (Email.DEBUG && DEBUG_CACHE) {
                        Log.d(mLogTag, "=========== Recaching with new values: " + id);
                    }
                    cursor.moveToFirst();
                    mLruCache.put(id, cursor);
                } else {
                    mLruCache.remove(id);
                }
            } else {
                mLruCache.remove(id);
            }
            // If there are no cursors using the old cached cursor, close it
            if (!sActiveCursors.contains(c)) {
                c.close();
            }
        }
        if (wasLocked) {
            mLockMap.subtract(id);
        }
    }

    /**
     * Invalidate the entire cache, without logging
     */
    public synchronized void invalidate() {
        invalidate(null, null, null);
    }

    /**
     * Invalidate the entire cache; the arguments are used for logging only, and indicate the
     * write operation that caused the invalidation
     *
     * @param operation a string describing the operation causing the invalidate (or null)
     * @param uri the uri causing the invalidate (or null)
     * @param selection the selection used with the uri (or null)
     */
    public synchronized void invalidate(String operation, Uri uri, String selection) {
        if (DEBUG_CACHE && (operation != null)) {
            Log.d(mLogTag, "============ INVALIDATED BY " + operation + ": " + uri +
                    ", SELECTION: " + selection);
        }
        mStats.mInvalidateCount++;
        // Close all cached cursors that are no longer in use
        mLruCache.evictAll();
        // Invalidate all current tokens
        mTokenList.invalidate();
    }

    // Debugging code below

    private void dumpOnCount(int num) {
        mStats.mOpCount++;
        if ((mStats.mOpCount % num) == 0) {
            dumpStats();
        }
    }

    /*package*/ void recordQueryTime(Cursor c, long nanoTime) {
        if (c instanceof CachedCursor) {
            mStats.hitTimes += nanoTime;
            mStats.hits++;
        } else {
            if (c.getCount() == 1) {
                mStats.missTimes += nanoTime;
                mStats.miss++;
            }
        }
    }

    public static synchronized void notCacheable(Uri uri, String selection) {
        if (DEBUG_NOT_CACHEABLE) {
            sNotCacheable++;
            String str = uri.toString() + "$" + selection;
            sNotCacheableMap.add(str);
        }
    }

    private static class CacheCounter implements Comparable<CacheCounter> {
        String uri;
        Integer count;

        CacheCounter(String _uri, Integer _count) {
            uri = _uri;
            count = _count;
        }

        @Override
        public int compareTo(CacheCounter another) {
            return another.count > count ? 1 : another.count == count ? 0 : -1;
        }
    }

    private static void dumpNotCacheableQueries() {
        int size = sNotCacheableMap.size();
        CacheCounter[] array = new CacheCounter[size];

        int i = 0;
        for (Map.Entry<String, Integer> entry: sNotCacheableMap.entrySet()) {
            array[i++] = new CacheCounter(entry.getKey(), entry.getValue());
        }
        Arrays.sort(array);
        for (CacheCounter cc: array) {
            Log.d("NotCacheable", cc.count + ": " + cc.uri);
        }
    }

    // For use with unit tests
    public static void invalidateAllCaches() {
        for (ContentCache cache: sContentCaches) {
            cache.invalidate();
        }
    }

    /** Sets the cache lock. If the lock is {@code true}, also invalidates all cached items. */
    public static void setLockCacheForTest(boolean lock) {
        sLockCache = lock;
        if (sLockCache) {
            invalidateAllCaches();
        }
    }

    static class Statistics {
        private final ContentCache mCache;
        private final String mName;

        // Cache statistics
        // The item is in the cache AND is used to create a cursor
        private int mHitCount = 0;
        // Basic cache miss (the item is not cached)
        private int mMissCount = 0;
        // Incremented when a cachePut is invalid due to an intervening write
        private int mStaleCount = 0;
        // A projection miss occurs when the item is cached, but not all requested columns are
        // available in the base projection
        private int mProjectionMissCount = 0;
        // Incremented whenever the entire cache is invalidated
        private int mInvalidateCount = 0;
        // Count of operations put/get
        private int mOpCount = 0;
        // The following are for timing statistics
        private long hits = 0;
        private long hitTimes = 0;
        private long miss = 0;
        private long missTimes = 0;

        // Used in toString() and addCacheStatistics()
        private int mCursorCount = 0;
        private int mTokenCount = 0;

        Statistics(ContentCache cache) {
            mCache = cache;
            mName = mCache.mName;
        }

        Statistics(String name) {
            mCache = null;
            mName = name;
        }

        private void addCacheStatistics(ContentCache cache) {
            if (cache != null) {
                mHitCount += cache.mStats.mHitCount;
                mMissCount += cache.mStats.mMissCount;
                mProjectionMissCount += cache.mStats.mProjectionMissCount;
                mStaleCount += cache.mStats.mStaleCount;
                hitTimes += cache.mStats.hitTimes;
                missTimes += cache.mStats.missTimes;
                hits += cache.mStats.hits;
                miss += cache.mStats.miss;
                mCursorCount += cache.size();
                mTokenCount += cache.mTokenList.size();
            }
        }

        private void append(StringBuilder sb, String name, Object value) {
            sb.append(", ");
            sb.append(name);
            sb.append(": ");
            sb.append(value);
        }

        @Override
        public String toString() {
            if (mHitCount + mMissCount == 0) return "No cache";
            int totalTries = mMissCount + mProjectionMissCount + mHitCount;
            StringBuilder sb = new StringBuilder();
            sb.append("Cache " + mName);
            append(sb, "Cursors", mCache == null ? mCursorCount : mCache.size());
            append(sb, "Hits", mHitCount);
            append(sb, "Misses", mMissCount + mProjectionMissCount);
            append(sb, "Inval", mInvalidateCount);
            append(sb, "Tokens", mCache == null ? mTokenCount : mCache.mTokenList.size());
            append(sb, "Hit%", mHitCount * 100 / totalTries);
            append(sb, "\nHit time", hitTimes / 1000000.0 / hits);
            append(sb, "Miss time", missTimes / 1000000.0 / miss);
            return sb.toString();
        }
    }

    public static void dumpStats() {
        Statistics totals = new Statistics("Totals");

        for (ContentCache cache: sContentCaches) {
            if (cache != null) {
                Log.d(cache.mName, cache.mStats.toString());
                totals.addCacheStatistics(cache);
            }
        }
        Log.d(totals.mName, totals.toString());
    }
}
