Improving photo cache

Now we have a fixed size LRU cache of compressed
images and on top of that a soft cache of
inflated bitmaps.

Change-Id: I0ea37be41d8f5c6e361bdf160e3e518b014f3615
diff --git a/src/com/android/contacts/ContactPhotoManager.java b/src/com/android/contacts/ContactPhotoManager.java
index ddd6a0e..4df7bbb 100644
--- a/src/com/android/contacts/ContactPhotoManager.java
+++ b/src/com/android/contacts/ContactPhotoManager.java
@@ -21,6 +21,7 @@
 
 import android.content.ContentResolver;
 import android.content.Context;
+import android.content.res.Resources;
 import android.database.Cursor;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
@@ -29,8 +30,11 @@
 import android.os.Handler.Callback;
 import android.os.HandlerThread;
 import android.os.Message;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
 import android.provider.ContactsContract.Contacts.Photo;
 import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Directory;
 import android.util.Log;
 import android.widget.ImageView;
 
@@ -39,6 +43,8 @@
 import java.lang.ref.SoftReference;
 import java.util.ArrayList;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 
 /**
@@ -104,6 +110,12 @@
      * if so.
      */
     public abstract void refreshCache();
+
+    /**
+     * Initiates a background process that over time will fill up cache with
+     * preload photos.
+     */
+    public abstract void preloadPhotosInBackground();
 }
 
 class ContactPhotoManagerImpl extends ContactPhotoManager implements Callback {
@@ -123,29 +135,40 @@
 
     private static final String[] EMPTY_STRING_ARRAY = new String[0];
 
-    private final String[] COLUMNS = new String[] { Photo._ID, Photo.PHOTO };
+    private static final String[] COLUMNS = new String[] { Photo._ID, Photo.PHOTO };
 
     /**
      * Maintains the state of a particular photo.
      */
     private static class BitmapHolder {
-        private static final int NEEDED = 0;
-        private static final int LOADING = 1;
-        private static final int LOADED = 2;
-        private static final int LOADED_NEEDS_RELOAD = 3;
+        final byte[] bytes;
 
-        int state;
+        volatile boolean fresh;
         Bitmap bitmap;
         SoftReference<Bitmap> bitmapRef;
+
+        public BitmapHolder(byte[] bytes) {
+            this.bytes = bytes;
+            this.fresh = true;
+        }
     }
 
     private final Context mContext;
 
+    private static final int BITMAP_CACHE_INITIAL_CAPACITY = 32;
+
     /**
-     * A soft cache for photos.
+     * An LRU cache for bitmap holders. The cache contains bytes for photos just
+     * as they come from the database. It also softly retains the actual bitmap.
      */
-    private final ConcurrentHashMap<Object, BitmapHolder> mBitmapCache =
-            new ConcurrentHashMap<Object, BitmapHolder>();
+    private final BitmapHolderCache mBitmapHolderCache;
+
+    /**
+     * Level 2 LRU cache for bitmaps. This is a smaller cache that holds
+     * the most recently used bitmaps to save time on decoding
+     * them from bytes (the bytes are stored in {@link #mBitmapHolderCache}.
+     */
+    private final BitmapCache mBitmapCache;
 
     /**
      * A map from ImageView to the corresponding photo ID. Please note that this
@@ -177,6 +200,18 @@
 
     public ContactPhotoManagerImpl(Context context) {
         mContext = context;
+
+        Resources resources = context.getResources();
+        mBitmapCache = new BitmapCache(
+                resources.getInteger(R.integer.config_photo_cache_max_bitmaps));
+        mBitmapHolderCache = new BitmapHolderCache(mBitmapCache,
+                resources.getInteger(R.integer.config_photo_cache_max_bytes));
+    }
+
+    @Override
+    public void preloadPhotosInBackground() {
+        ensureLoaderThread();
+        mLoaderThread.requestPreloading();
     }
 
     @Override
@@ -216,58 +251,75 @@
 
     @Override
     public void refreshCache() {
-        for (BitmapHolder holder : mBitmapCache.values()) {
-            if (holder.state == BitmapHolder.LOADED) {
-                holder.state = BitmapHolder.LOADED_NEEDS_RELOAD;
-            }
+        for (BitmapHolder holder : mBitmapHolderCache.values()) {
+            holder.fresh = false;
         }
     }
 
     /**
-     * Checks if the photo is present in cache.  If so, sets the photo on the view,
-     * otherwise sets the state of the photo to {@link BitmapHolder#NEEDED} and
-     * temporarily set the image to the default resource ID.
+     * Checks if the photo is present in cache.  If so, sets the photo on the view.
+     *
+     * @return false if the photo needs to be (re)loaded from the provider.
      */
     private boolean loadCachedPhoto(ImageView view, Object key) {
-        BitmapHolder holder = mBitmapCache.get(key);
+        BitmapHolder holder = mBitmapHolderCache.get(key);
         if (holder == null) {
-            holder = new BitmapHolder();
-            mBitmapCache.put(key, holder);
-        } else {
-            boolean loaded = (holder.state == BitmapHolder.LOADED);
-            boolean loadedNeedsReload = (holder.state == BitmapHolder.LOADED_NEEDS_RELOAD);
-            if (loadedNeedsReload) {
-                holder.state = BitmapHolder.NEEDED;
-            }
+            // The bitmap has not been loaded - should display the placeholder image.
+            view.setImageResource(mDefaultResourceId);
+            return false;
+        }
 
-            // Null bitmap reference means that database contains no bytes for the photo
-            if ((loaded || loadedNeedsReload) && holder.bitmapRef == null) {
-                view.setImageResource(mDefaultResourceId);
-                return loaded;
-            }
+        if (holder.bytes == null) {
+            view.setImageResource(mDefaultResourceId);
+            return holder.fresh;
+        }
 
-            if (holder.bitmapRef != null) {
-                Bitmap bitmap = holder.bitmapRef.get();
-                if (bitmap != null) {
-                    view.setImageBitmap(bitmap);
-                    return loaded;
-                }
+        // Optionally decode bytes into a bitmap
+        inflateBitmap(holder);
 
-                // Null bitmap means that the soft reference was released by the GC
-                // and we need to reload the photo.
-                holder.bitmapRef = null;
+        view.setImageBitmap(holder.bitmap);
+
+        // Put the bitmap in the LRU cache
+        mBitmapCache.put(key, holder.bitmap);
+
+        // Soften the reference
+        holder.bitmap = null;
+
+        return holder.fresh;
+    }
+
+    /**
+     * If necessary, decodes bytes stored in the holder to Bitmap.  As long as the
+     * bitmap is held either by {@link #mBitmapCache} or by a soft reference in
+     * the holder, it will not be necessary to decode the bitmap.
+     */
+    private void inflateBitmap(BitmapHolder holder) {
+        byte[] bytes = holder.bytes;
+        if (bytes == null || bytes.length == 0) {
+            return;
+        }
+
+        // Check the soft reference.  If will be retained if the bitmap is also
+        // in the LRU cache, so we don't need to check the LRU cache explicitly.
+        if (holder.bitmapRef != null) {
+            holder.bitmap = holder.bitmapRef.get();
+            if (holder.bitmap != null) {
+                return;
             }
         }
 
-        // The bitmap has not been loaded - should display the placeholder image.
-        view.setImageResource(mDefaultResourceId);
-        holder.state = BitmapHolder.NEEDED;
-        return false;
+        try {
+            Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, null);
+            holder.bitmap = bitmap;
+            holder.bitmapRef = new SoftReference<Bitmap>(bitmap);
+        } catch (OutOfMemoryError e) {
+            // Do nothing - the photo will appear to be missing
+        }
     }
 
     public void clear() {
         mPendingRequests.clear();
-        mBitmapCache.clear();
+        mBitmapHolderCache.clear();
     }
 
     @Override
@@ -304,11 +356,7 @@
             case MESSAGE_REQUEST_LOADING: {
                 mLoadingRequested = false;
                 if (!mPaused) {
-                    if (mLoaderThread == null) {
-                        mLoaderThread = new LoaderThread(mContext.getContentResolver());
-                        mLoaderThread.start();
-                    }
-
+                    ensureLoaderThread();
                     mLoaderThread.requestLoading();
                 }
                 return true;
@@ -324,6 +372,13 @@
         return false;
     }
 
+    public void ensureLoaderThread() {
+        if (mLoaderThread == null) {
+            mLoaderThread = new LoaderThread(mContext.getContentResolver());
+            mLoaderThread.start();
+        }
+    }
+
     /**
      * Goes over pending loading requests and displays loaded photos.  If some of the
      * photos still haven't been loaded, sends another request for image loading.
@@ -348,10 +403,10 @@
 
     /**
      * Removes strong references to loaded bitmaps to allow them to be garbage collected
-     * if needed.
+     * if needed.  Some of the bitmaps will still be retained by {@link #mBitmapCache}.
      */
     private void softenCache() {
-        for (BitmapHolder holder : mBitmapCache.values()) {
+        for (BitmapHolder holder : mBitmapHolderCache.values()) {
             holder.bitmap = null;
         }
     }
@@ -359,23 +414,17 @@
     /**
      * Stores the supplied bitmap in cache.
      */
-    private void cacheBitmap(Object key, byte[] bytes) {
-        if (mPaused) {
-            return;
+    private void cacheBitmap(Object key, byte[] bytes, boolean preloading) {
+        BitmapHolder holder = new BitmapHolder(bytes);
+        holder.fresh = true;
+
+        // Unless this image is being preloaded, decode it right away while
+        // we are still on the background thread.
+        if (!preloading) {
+            inflateBitmap(holder);
         }
 
-        BitmapHolder holder = new BitmapHolder();
-        holder.state = BitmapHolder.LOADED;
-        if (bytes != null) {
-            try {
-                Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, null);
-                holder.bitmap = bitmap;
-                holder.bitmapRef = new SoftReference<Bitmap>(bitmap);
-            } catch (OutOfMemoryError e) {
-                // Do nothing - the photo will appear to be missing
-            }
-        }
-        mBitmapCache.put(key, holder);
+        mBitmapHolderCache.put(key, holder);
     }
 
     /**
@@ -398,10 +447,8 @@
         Iterator<Object> iterator = mPendingRequests.values().iterator();
         while (iterator.hasNext()) {
             Object key = iterator.next();
-            BitmapHolder holder = mBitmapCache.get(key);
-            if (holder != null && holder.state == BitmapHolder.NEEDED) {
-                // Assuming atomic behavior
-                holder.state = BitmapHolder.LOADING;
+            BitmapHolder holder = mBitmapHolderCache.get(key);
+            if (holder == null || !holder.fresh) {
                 if (key instanceof Long) {
                     photoIds.add((Long)key);
                     photoIdsAsStrings.add(key.toString());
@@ -417,28 +464,82 @@
      */
     private class LoaderThread extends HandlerThread implements Callback {
         private static final int BUFFER_SIZE = 1024*16;
+        private static final int MESSAGE_PRELOAD_PHOTOS = 0;
+        private static final int MESSAGE_LOAD_PHOTOS = 1;
+
+        /**
+         * A pause between preload batches that yields to the UI thread.
+         */
+        private static final int PHOTO_PRELOAD_DELAY = 50;
+
+        /**
+         * Number of photos to preload per batch.
+         */
+        private static final int PRELOAD_BATCH = 25;
+
+        /**
+         * Maximum number of photos to preload.  If the cache size is 2Mb and
+         * the expected average size of a photo is 4kb, then this number should be 2Mb/4kb = 500.
+         */
+        private static final int MAX_PHOTOS_TO_PRELOAD = 500;
 
         private final ContentResolver mResolver;
         private final StringBuilder mStringBuilder = new StringBuilder();
         private final ArrayList<Long> mPhotoIds = Lists.newArrayList();
         private final ArrayList<String> mPhotoIdsAsStrings = Lists.newArrayList();
         private final ArrayList<Uri> mPhotoUris = Lists.newArrayList();
+        private ArrayList<Long> mPreloadPhotoIds = Lists.newArrayList();
+
         private Handler mLoaderThreadHandler;
         private byte mBuffer[];
 
+        private static final int PRELOAD_STATUS_NOT_STARTED = 0;
+        private static final int PRELOAD_STATUS_IN_PROGRESS = 1;
+        private static final int PRELOAD_STATUS_DONE = 2;
+
+        private int mPreloadStatus = PRELOAD_STATUS_NOT_STARTED;
+
         public LoaderThread(ContentResolver resolver) {
             super(LOADER_THREAD_NAME);
             mResolver = resolver;
         }
 
-        /**
-         * Sends a message to this thread to load requested photos.
-         */
-        public void requestLoading() {
+        public void ensureHandler() {
             if (mLoaderThreadHandler == null) {
                 mLoaderThreadHandler = new Handler(getLooper(), this);
             }
-            mLoaderThreadHandler.sendEmptyMessage(0);
+        }
+
+        /**
+         * Kicks off preloading of the next batch of photos on the background thread.
+         * Preloading will happen after a delay: we want to yield to the UI thread
+         * as much as possible.
+         * <p>
+         * If preloading is already complete, does nothing.
+         */
+        public void requestPreloading() {
+            if (mPreloadStatus == PRELOAD_STATUS_DONE) {
+                return;
+            }
+
+            ensureHandler();
+            if (mLoaderThreadHandler.hasMessages(MESSAGE_LOAD_PHOTOS)) {
+                return;
+            }
+
+            mLoaderThreadHandler.sendEmptyMessageDelayed(
+                    MESSAGE_PRELOAD_PHOTOS, PHOTO_PRELOAD_DELAY);
+        }
+
+        /**
+         * Sends a message to this thread to load requested photos.  Cancels a preloading
+         * request, if any: we don't want preloading to impede loading of the photos
+         * we need to display now.
+         */
+        public void requestLoading() {
+            ensureHandler();
+            mLoaderThreadHandler.removeMessages(MESSAGE_PRELOAD_PHOTOS);
+            mLoaderThreadHandler.sendEmptyMessage(MESSAGE_LOAD_PHOTOS);
         }
 
         /**
@@ -446,56 +547,164 @@
          * to the main thread to process them.
          */
         public boolean handleMessage(Message msg) {
-            loadPhotosFromDatabase();
+            switch (msg.what) {
+                case MESSAGE_PRELOAD_PHOTOS:
+                    preloadPhotosInBackground();
+                    break;
+                case MESSAGE_LOAD_PHOTOS:
+                    loadPhotosInBackground();
+                    break;
+            }
             return true;
         }
 
-        private void loadPhotosFromDatabase() {
-            obtainPhotoIdsAndUrisToLoad(mPhotoIds, mPhotoIdsAsStrings, mPhotoUris);
-
-            int count = mPhotoIds.size();
-            if (count != 0) {
-                mStringBuilder.setLength(0);
-                mStringBuilder.append(Photo._ID + " IN(");
-                for (int i = 0; i < count; i++) {
-                    if (i != 0) {
-                        mStringBuilder.append(',');
-                    }
-                    mStringBuilder.append('?');
-                }
-                mStringBuilder.append(')');
-
-                Cursor cursor = null;
-                try {
-                    cursor = mResolver.query(Data.CONTENT_URI,
-                            COLUMNS,
-                            mStringBuilder.toString(),
-                            mPhotoIdsAsStrings.toArray(EMPTY_STRING_ARRAY),
-                            null);
-
-                    if (cursor != null) {
-                        while (cursor.moveToNext()) {
-                            Long id = cursor.getLong(0);
-                            byte[] bytes = cursor.getBlob(1);
-                            cacheBitmap(id, bytes);
-                            mPhotoIds.remove(id);
-                        }
-                    }
-                } finally {
-                    if (cursor != null) {
-                        cursor.close();
-                    }
-                }
-
-                // Remaining photos were not found in the database - mark the cache accordingly.
-                count = mPhotoIds.size();
-                for (int i = 0; i < count; i++) {
-                    cacheBitmap(mPhotoIds.get(i), null);
-                }
-                mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED);
+        /**
+         * The first time it is called, figures out which photos need to be preloaded.
+         * Each subsequent call preloads the next batch of photos and requests
+         * another cycle of preloading after a delay.  The whole process ends when
+         * we either run out of photos to preload or fill up cache.
+         */
+        private void preloadPhotosInBackground() {
+            if (mPreloadStatus == PRELOAD_STATUS_DONE) {
+                return;
             }
 
-            count = mPhotoUris.size();
+            if (mPreloadStatus == PRELOAD_STATUS_NOT_STARTED) {
+                queryPhotosForPreload();
+                if (mPreloadPhotoIds.isEmpty()) {
+                    mPreloadStatus = PRELOAD_STATUS_DONE;
+                } else {
+                    mPreloadStatus = PRELOAD_STATUS_IN_PROGRESS;
+                }
+                requestPreloading();
+                return;
+            }
+
+            if (mBitmapHolderCache.isFull()) {
+                mPreloadStatus = PRELOAD_STATUS_DONE;
+                return;
+            }
+
+            mPhotoIds.clear();
+            mPhotoIdsAsStrings.clear();
+
+            int count = 0;
+            int preloadSize = mPreloadPhotoIds.size();
+            while(preloadSize > 0 && mPhotoIds.size() < PRELOAD_BATCH) {
+                preloadSize--;
+                count++;
+                Long photoId = mPreloadPhotoIds.get(preloadSize);
+                mPhotoIds.add(photoId);
+                mPhotoIdsAsStrings.add(photoId.toString());
+                mPreloadPhotoIds.remove(preloadSize);
+            }
+
+            loadPhotosFromDatabase(false);
+
+            if (preloadSize == 0) {
+                mPreloadStatus = PRELOAD_STATUS_DONE;
+            }
+
+            Log.v(TAG, "Preloaded " + count + " photos. Photos in cache: "
+                    + mBitmapHolderCache.size()
+                    + ". Total size: " + mBitmapHolderCache.getEstimatedSize());
+
+            requestPreloading();
+        }
+
+        private void queryPhotosForPreload() {
+            Cursor cursor = null;
+            try {
+                Uri uri = Contacts.CONTENT_URI.buildUpon().appendQueryParameter(
+                        ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT))
+                        .build();
+                cursor = mResolver.query(uri, new String[] { Contacts.PHOTO_ID },
+                        Contacts.PHOTO_ID + " NOT NULL AND " + Contacts.PHOTO_ID + "!=0",
+                        null,
+                        Contacts.STARRED + " DESC, " + Contacts.LAST_TIME_CONTACTED + " DESC"
+                                + " LIMIT " + MAX_PHOTOS_TO_PRELOAD);
+
+                if (cursor != null) {
+                    while (cursor.moveToNext()) {
+                        // Insert them in reverse order, because we will be taking
+                        // them from the end of the list for loading.
+                        mPreloadPhotoIds.add(0, cursor.getLong(0));
+                    }
+                }
+            } finally {
+                if (cursor != null) {
+                    cursor.close();
+                }
+            }
+        }
+
+        private void loadPhotosInBackground() {
+            obtainPhotoIdsAndUrisToLoad(mPhotoIds, mPhotoIdsAsStrings, mPhotoUris);
+            loadPhotosFromDatabase(true);
+            loadRemotePhotos();
+            requestPreloading();
+        }
+
+        private void loadPhotosFromDatabase(boolean preloading) {
+            int count = mPhotoIds.size();
+            if (count == 0) {
+                return;
+            }
+
+            // Remove loaded photos from the preload queue: we don't want
+            // the preloading process to load them again.
+            if (!preloading && mPreloadStatus == PRELOAD_STATUS_IN_PROGRESS) {
+                for (int i = 0; i < count; i++) {
+                    mPreloadPhotoIds.remove(mPhotoIds.get(i));
+                }
+                if (mPreloadPhotoIds.isEmpty()) {
+                    mPreloadStatus = PRELOAD_STATUS_DONE;
+                }
+            }
+
+            mStringBuilder.setLength(0);
+            mStringBuilder.append(Photo._ID + " IN(");
+            for (int i = 0; i < count; i++) {
+                if (i != 0) {
+                    mStringBuilder.append(',');
+                }
+                mStringBuilder.append('?');
+            }
+            mStringBuilder.append(')');
+
+            Cursor cursor = null;
+            try {
+                cursor = mResolver.query(Data.CONTENT_URI,
+                        COLUMNS,
+                        mStringBuilder.toString(),
+                        mPhotoIdsAsStrings.toArray(EMPTY_STRING_ARRAY),
+                        null);
+
+                if (cursor != null) {
+                    while (cursor.moveToNext()) {
+                        Long id = cursor.getLong(0);
+                        byte[] bytes = cursor.getBlob(1);
+                        cacheBitmap(id, bytes, preloading);
+                        mPhotoIds.remove(id);
+                    }
+                }
+            } finally {
+                if (cursor != null) {
+                    cursor.close();
+                }
+            }
+
+            // Remaining photos were not found in the database - mark the cache accordingly.
+            count = mPhotoIds.size();
+            for (int i = 0; i < count; i++) {
+                cacheBitmap(mPhotoIds.get(i), null, preloading);
+            }
+
+            mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED);
+        }
+
+        private void loadRemotePhotos() {
+            int count = mPhotoUris.size();
             for (int i = 0; i < count; i++) {
                 Uri uri = mPhotoUris.get(i);
                 if (mBuffer == null) {
@@ -513,17 +722,95 @@
                         } finally {
                             is.close();
                         }
-                        cacheBitmap(uri, baos.toByteArray());
+                        cacheBitmap(uri, baos.toByteArray(), false);
                         mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED);
                     } else {
                         Log.v(TAG, "Cannot load photo " + uri);
-                        cacheBitmap(uri, null);
+                        cacheBitmap(uri, null, false);
                     }
                 } catch (Exception ex) {
                     Log.v(TAG, "Cannot load photo " + uri, ex);
-                    cacheBitmap(uri, null);
+                    cacheBitmap(uri, null, false);
                 }
             }
         }
     }
+
+    /**
+     * An LRU cache of {@link BitmapHolder}'s.  It estimates the total size of
+     * loaded bitmaps and caps that number.
+     */
+    private static class BitmapHolderCache extends LinkedHashMap<Object, BitmapHolder> {
+        private final BitmapCache mBitmapCache;
+        private final int mMaxBytes;
+        private final int mRedZoneBytes;
+        private int mEstimatedBytes;
+
+        public BitmapHolderCache(BitmapCache bitmapCache, int maxBytes) {
+            super(BITMAP_CACHE_INITIAL_CAPACITY, 0.75f, true);
+            this.mBitmapCache = bitmapCache;
+            mMaxBytes = maxBytes;
+            mRedZoneBytes = (int) (mMaxBytes * 0.75);
+        }
+
+        // Leave unsynchronized: if the result is a bit off, that's ok
+        public boolean isFull() {
+            return mEstimatedBytes > mRedZoneBytes;
+        }
+
+        public int getEstimatedSize() {
+            return mEstimatedBytes;
+        }
+
+        @Override
+        public synchronized BitmapHolder get(Object key) {
+            return super.get(key);
+        }
+
+        @Override
+        public synchronized BitmapHolder put(Object key, BitmapHolder newValue) {
+            BitmapHolder oldValue = get(key);
+            if (oldValue != null && oldValue.bytes != null) {
+                mEstimatedBytes -= oldValue.bytes.length;
+            }
+            if (newValue.bytes != null) {
+                mEstimatedBytes += newValue.bytes.length;
+            }
+            return super.put(key, newValue);
+        }
+
+        @Override
+        public BitmapHolder remove(Object key) {
+            BitmapHolder value = get(key);
+            if (value != null && value.bytes != null) {
+                mEstimatedBytes -= value.bytes.length;
+            }
+            mBitmapCache.remove(key);
+            return super.remove(key);
+        }
+
+        @Override
+        protected boolean removeEldestEntry(Map.Entry<Object, BitmapHolder> eldest) {
+            return mEstimatedBytes > mMaxBytes;
+        }
+    }
+
+    /**
+     * An LRU cache of bitmaps.  These are the most recently used bitmaps that we want
+     * to protect from GC. The rest of bitmaps are softly retained and will be
+     * gradually released by GC.
+     */
+    private static class BitmapCache extends LinkedHashMap<Object, Bitmap> {
+        private int mMaxEntries;
+
+        public BitmapCache(int maxEntries) {
+            super(BITMAP_CACHE_INITIAL_CAPACITY, 0.75f, true);
+            mMaxEntries = maxEntries;
+        }
+
+        @Override
+        protected boolean removeEldestEntry(Map.Entry<Object, Bitmap> eldest) {
+            return size() > mMaxEntries;
+        }
+    }
 }
diff --git a/src/com/android/contacts/ContactsApplication.java b/src/com/android/contacts/ContactsApplication.java
index 42ea641..c925ec0 100644
--- a/src/com/android/contacts/ContactsApplication.java
+++ b/src/com/android/contacts/ContactsApplication.java
@@ -86,6 +86,7 @@
         if (ContactPhotoManager.CONTACT_PHOTO_SERVICE.equals(name)) {
             if (mContactPhotoManager == null) {
                 mContactPhotoManager = ContactPhotoManager.createContactPhotoManager(this);
+                mContactPhotoManager.preloadPhotosInBackground();
             }
             return mContactPhotoManager;
         }