Merge "Add initial implementation of MediaCache." into gb-ub-photos-bryce
diff --git a/src/com/android/gallery3d/app/GalleryAppImpl.java b/src/com/android/gallery3d/app/GalleryAppImpl.java
index 5b4a872..2abdaa0 100644
--- a/src/com/android/gallery3d/app/GalleryAppImpl.java
+++ b/src/com/android/gallery3d/app/GalleryAppImpl.java
@@ -17,12 +17,9 @@
 package com.android.gallery3d.app;
 
 import android.app.Application;
-import android.content.ComponentName;
 import android.content.Context;
-import android.content.pm.PackageManager;
 import android.os.AsyncTask;
 
-import com.android.gallery3d.common.ApiHelper;
 import com.android.gallery3d.data.DataManager;
 import com.android.gallery3d.data.DownloadCache;
 import com.android.gallery3d.data.ImageCacheService;
@@ -32,6 +29,7 @@
 import com.android.gallery3d.util.LightCycleHelper;
 import com.android.gallery3d.util.ThreadPool;
 import com.android.gallery3d.util.UsageStatistics;
+import com.android.photos.data.MediaCache;
 
 import java.io.File;
 
@@ -56,6 +54,7 @@
         WidgetUtils.initialize(this);
         PicasaSource.initialize(this);
         UsageStatistics.initialize(this);
+        MediaCache.initialize(this);
 
         mStitchingProgressManager = LightCycleHelper.createStitchingManagerInstance(this);
         if (mStitchingProgressManager != null) {
diff --git a/src/com/android/photos/data/FileRetriever.java b/src/com/android/photos/data/FileRetriever.java
new file mode 100644
index 0000000..eb7686e
--- /dev/null
+++ b/src/com/android/photos/data/FileRetriever.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.graphics.Bitmap;
+import android.media.ExifInterface;
+import android.net.Uri;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import com.android.gallery3d.common.BitmapUtils;
+
+import java.io.File;
+import java.io.IOException;
+
+public class FileRetriever implements MediaRetriever {
+    private static final String TAG = FileRetriever.class.getSimpleName();
+
+    @Override
+    public File getLocalFile(Uri contentUri) {
+        return new File(contentUri.getPath());
+    }
+
+    @Override
+    public MediaSize getFastImageSize(Uri contentUri, MediaSize size) {
+        if (isVideo(contentUri)) {
+            return null;
+        }
+        return MediaSize.TemporaryThumbnail;
+    }
+
+    @Override
+    public byte[] getTemporaryImage(Uri contentUri, MediaSize fastImageSize) {
+
+        try {
+            ExifInterface exif = new ExifInterface(contentUri.getPath());
+            if (exif.hasThumbnail()) {
+                return exif.getThumbnail();
+            }
+        } catch (IOException e) {
+            Log.w(TAG, "Unable to load exif for " + contentUri);
+        }
+        return null;
+    }
+
+    @Override
+    public boolean getMedia(Uri contentUri, MediaSize imageSize, File tempFile) {
+        if (imageSize == MediaSize.Original) {
+            return false; // getLocalFile should always return the original.
+        }
+        if (imageSize == MediaSize.Thumbnail) {
+            File preview = MediaCache.getInstance().getCachedFile(contentUri, MediaSize.Preview);
+            if (preview != null) {
+                // Just downsample the preview, it is faster.
+                return MediaCacheUtils.downsample(preview, imageSize, tempFile);
+            }
+        }
+        File highRes = new File(contentUri.getPath());
+        boolean success;
+        if (!isVideo(contentUri)) {
+            success = MediaCacheUtils.downsample(highRes, imageSize, tempFile);
+        } else {
+            // Video needs to extract the bitmap.
+            Bitmap bitmap = BitmapUtils.createVideoThumbnail(highRes.getPath());
+            if (bitmap == null) {
+                return false;
+            } else if (imageSize == MediaSize.Thumbnail
+                    && !MediaCacheUtils.needsDownsample(bitmap, MediaSize.Preview)
+                    && MediaCacheUtils.writeToFile(bitmap, tempFile)) {
+                // Opportunistically save preview
+                MediaCache mediaCache = MediaCache.getInstance();
+                mediaCache.insertIntoCache(contentUri, MediaSize.Preview, tempFile);
+            }
+            // Now scale the image
+            success = MediaCacheUtils.downsample(bitmap, imageSize, tempFile);
+        }
+        return success;
+    }
+
+    @Override
+    public Uri normalizeUri(Uri contentUri, MediaSize size) {
+        return contentUri;
+    }
+
+    @Override
+    public MediaSize normalizeMediaSize(Uri contentUri, MediaSize size) {
+        return size;
+    }
+
+    private static boolean isVideo(Uri uri) {
+        MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
+        String extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString());
+        String mimeType = mimeTypeMap.getMimeTypeFromExtension(extension);
+        return (mimeType != null && mimeType.startsWith("video/"));
+    }
+}
diff --git a/src/com/android/photos/data/MediaCache.java b/src/com/android/photos/data/MediaCache.java
new file mode 100644
index 0000000..7b5eca5
--- /dev/null
+++ b/src/com/android/photos/data/MediaCache.java
@@ -0,0 +1,649 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.os.Environment;
+import android.util.Log;
+
+import com.android.photos.data.MediaCacheDatabase.Action;
+import com.android.photos.data.MediaRetriever.MediaSize;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+
+/**
+ * MediaCache keeps a cache of images, videos, thumbnails and previews. Calls to
+ * retrieve a specific media item are executed asynchronously. The caller has an
+ * option to receive a notification for lower resolution images that happen to
+ * be available prior to the one requested.
+ * <p>
+ * When an media item has been retrieved, the notification for it is called on a
+ * separate notifier thread. This thread should not be held for a long time so
+ * that other notifications may happen.
+ * </p>
+ * <p>
+ * Media items are uniquely identified by their content URIs. Each
+ * scheme/authority can offer its own MediaRetriever, running in its own thread.
+ * </p>
+ * <p>
+ * The MediaCache is an LRU cache, but does not allow the thumbnail cache to
+ * drop below a minimum size. This prevents browsing through original images to
+ * wipe out the thumbnails.
+ * </p>
+ */
+public class MediaCache {
+    static final String TAG = MediaCache.class.getSimpleName();
+    /** Subdirectory containing the image cache. */
+    static final String IMAGE_CACHE_SUBDIR = "image_cache";
+    /** File name extension to use for cached images. */
+    static final String IMAGE_EXTENSION = ".cache";
+    /** File name extension to use for temporary cached images while retrieving. */
+    static final String TEMP_IMAGE_EXTENSION = ".temp";
+
+    public static interface ImageReady {
+        void imageReady(InputStream bitmapInputStream);
+    }
+
+    public static interface OriginalReady {
+        void originalReady(File originalFile);
+    }
+
+    /** A Thread for each MediaRetriever */
+    private class ProcessQueue extends Thread {
+        private Queue<ProcessingJob> mQueue;
+
+        public ProcessQueue(Queue<ProcessingJob> queue) {
+            mQueue = queue;
+        }
+
+        @Override
+        public void run() {
+            while (mRunning) {
+                ProcessingJob status;
+                synchronized (mQueue) {
+                    while (mQueue.isEmpty()) {
+                        try {
+                            mQueue.wait();
+                        } catch (InterruptedException e) {
+                            if (!mRunning) {
+                                return;
+                            }
+                            Log.w(TAG, "Unexpected interruption", e);
+                        }
+                    }
+                    status = mQueue.remove();
+                }
+                processTask(status);
+            }
+        }
+    };
+
+    private interface NotifyReady {
+        void notifyReady();
+
+        void setFile(File file) throws FileNotFoundException;
+    }
+
+    private static class NotifyOriginalReady implements NotifyReady {
+        private final OriginalReady mCallback;
+        private File mFile;
+
+        public NotifyOriginalReady(OriginalReady callback) {
+            mCallback = callback;
+        }
+
+        @Override
+        public void notifyReady() {
+            mCallback.originalReady(mFile);
+        }
+
+        @Override
+        public void setFile(File file) {
+            mFile = file;
+        }
+    }
+
+    private static class NotifyImageReady implements NotifyReady {
+        private final ImageReady mCallback;
+        private InputStream mInputStream;
+
+        public NotifyImageReady(ImageReady callback) {
+            mCallback = callback;
+        }
+
+        @Override
+        public void notifyReady() {
+            mCallback.imageReady(mInputStream);
+        }
+
+        @Override
+        public void setFile(File file) throws FileNotFoundException {
+            mInputStream = new FileInputStream(file);
+        }
+
+        public void setBytes(byte[] bytes) {
+            mInputStream = new ByteArrayInputStream(bytes);
+        }
+    }
+
+    /** A media item to be retrieved and its notifications. */
+    private static class ProcessingJob {
+        public ProcessingJob(Uri uri, MediaSize size, NotifyReady complete,
+                NotifyImageReady lowResolution) {
+            this.contentUri = uri;
+            this.size = size;
+            this.complete = complete;
+            this.lowResolution = lowResolution;
+        }
+        public Uri contentUri;
+        public MediaSize size;
+        public NotifyImageReady lowResolution;
+        public NotifyReady complete;
+    }
+
+    private boolean mRunning = true;
+    private static MediaCache sInstance;
+    private File mCacheDir;
+    private Context mContext;
+    private Queue<NotifyReady> mCallbacks = new LinkedList<NotifyReady>();
+    private Map<String, MediaRetriever> mRetrievers = new HashMap<String, MediaRetriever>();
+    private Map<String, List<ProcessingJob>> mTasks = new HashMap<String, List<ProcessingJob>>();
+    private List<ProcessQueue> mProcessingThreads = new ArrayList<ProcessQueue>();
+    private MediaCacheDatabase mDatabaseHelper;
+    private long mTempImageNumber = 1;
+    private Object mTempImageNumberLock = new Object();
+
+    private long mMaxCacheSize = 40 * 1024 * 1024; // 40 MB
+    private long mMinThumbCacheSize = 4 * 1024 * 1024; // 4 MB
+    private long mCacheSize = -1;
+    private long mThumbCacheSize = -1;
+    private Object mCacheSizeLock = new Object();
+
+    private Action mNotifyCachedLowResolution = new Action() {
+        @Override
+        public void execute(Uri uri, long id, MediaSize size, Object parameter) {
+            ProcessingJob job = (ProcessingJob) parameter;
+            File file = createCacheImagePath(id);
+            addNotification(job.lowResolution, file);
+        }
+    };
+
+    private Action mMoveTempToCache = new Action() {
+        @Override
+        public void execute(Uri uri, long id, MediaSize size, Object parameter) {
+            File tempFile = (File) parameter;
+            File cacheFile = createCacheImagePath(id);
+            tempFile.renameTo(cacheFile);
+        }
+    };
+
+    private Action mDeleteFile = new Action() {
+        @Override
+        public void execute(Uri uri, long id, MediaSize size, Object parameter) {
+            File file = createCacheImagePath(id);
+            file.delete();
+            synchronized (mCacheSizeLock) {
+                if (mCacheSize != -1) {
+                    long length = (Long) parameter;
+                    mCacheSize -= length;
+                    if (size == MediaSize.Thumbnail) {
+                        mThumbCacheSize -= length;
+                    }
+                }
+            }
+        }
+    };
+
+    /** The thread used to make ImageReady and OriginalReady callbacks. */
+    private Thread mProcessNotifications = new Thread() {
+        @Override
+        public void run() {
+            while (mRunning) {
+                NotifyReady notifyImage;
+                synchronized (mCallbacks) {
+                    while (mCallbacks.isEmpty()) {
+                        try {
+                            mCallbacks.wait();
+                        } catch (InterruptedException e) {
+                            if (!mRunning) {
+                                return;
+                            }
+                            Log.w(TAG, "Unexpected Interruption, continuing");
+                        }
+                    }
+                    notifyImage = mCallbacks.remove();
+                }
+
+                notifyImage.notifyReady();
+            }
+        }
+    };
+
+    public static synchronized void initialize(Context context) {
+        if (sInstance == null) {
+            sInstance = new MediaCache(context);
+            MediaCacheUtils.initialize(context);
+        }
+    }
+
+    public static MediaCache getInstance() {
+        return sInstance;
+    }
+
+    public static synchronized void shutdown() {
+        sInstance.mRunning = false;
+        sInstance.mProcessNotifications.interrupt();
+        for (ProcessQueue processingThread : sInstance.mProcessingThreads) {
+            processingThread.interrupt();
+        }
+        sInstance = null;
+    }
+
+    private MediaCache(Context context) {
+        mDatabaseHelper = new MediaCacheDatabase(context);
+        mProcessNotifications.start();
+        mContext = context;
+    }
+
+    // This is used for testing.
+    public void setCacheDir(File cacheDir) {
+        cacheDir.mkdirs();
+        mCacheDir = cacheDir;
+    }
+
+    private File getCacheDir() {
+        synchronized (mContext) {
+            if (mCacheDir == null) {
+                String state = Environment.getExternalStorageState();
+                File baseDir;
+                if (Environment.MEDIA_MOUNTED.equals(state)) {
+                    baseDir = mContext.getExternalCacheDir();
+                } else {
+                    // Stored in internal cache
+                    baseDir = mContext.getCacheDir();
+                }
+                mCacheDir = new File(baseDir, IMAGE_CACHE_SUBDIR);
+                mCacheDir.mkdirs();
+            }
+            return mCacheDir;
+        }
+    }
+
+    /**
+     * Invalidates all cached images related to a given contentUri. This call
+     * doesn't complete until the images have been removed from the cache.
+     */
+    public void invalidate(Uri contentUri) {
+        mDatabaseHelper.delete(contentUri, mDeleteFile);
+    }
+
+    public void clearCacheDir() {
+        File[] cachedFiles = getCacheDir().listFiles();
+        if (cachedFiles != null) {
+            for (File cachedFile : cachedFiles) {
+                cachedFile.delete();
+            }
+        }
+    }
+
+    /**
+     * Add a MediaRetriever for a Uri scheme and authority. This MediaRetriever
+     * will be granted its own thread for retrieving images.
+     */
+    public void addRetriever(String scheme, String authority, MediaRetriever retriever) {
+        String differentiator = getDifferentiator(scheme, authority);
+        synchronized (mRetrievers) {
+            mRetrievers.put(differentiator, retriever);
+        }
+        synchronized (mTasks) {
+            LinkedList<ProcessingJob> queue = new LinkedList<ProcessingJob>();
+            mTasks.put(differentiator, queue);
+            new ProcessQueue(queue).start();
+        }
+    }
+
+    /**
+     * Retrieves a thumbnail. complete will be called when the thumbnail is
+     * available. If lowResolution is not null and a lower resolution thumbnail
+     * is available before the thumbnail, lowResolution will be called prior to
+     * complete. All callbacks will be made on a thread other than the calling
+     * thread.
+     *
+     * @param contentUri The URI for the full resolution image to search for.
+     * @param complete Callback for when the image has been retrieved.
+     * @param lowResolution If not null and a lower resolution image is
+     *            available prior to retrieving the thumbnail, this will be
+     *            called with the low resolution bitmap.
+     */
+    public void retrieveThumbnail(Uri contentUri, ImageReady complete, ImageReady lowResolution) {
+        addTask(contentUri, complete, lowResolution, MediaSize.Thumbnail);
+    }
+
+    /**
+     * Retrieves a preview. complete will be called when the preview is
+     * available. If lowResolution is not null and a lower resolution preview is
+     * available before the preview, lowResolution will be called prior to
+     * complete. All callbacks will be made on a thread other than the calling
+     * thread.
+     *
+     * @param contentUri The URI for the full resolution image to search for.
+     * @param complete Callback for when the image has been retrieved.
+     * @param lowResolution If not null and a lower resolution image is
+     *            available prior to retrieving the preview, this will be called
+     *            with the low resolution bitmap.
+     */
+    public void retrievePreview(Uri contentUri, ImageReady complete, ImageReady lowResolution) {
+        addTask(contentUri, complete, lowResolution, MediaSize.Preview);
+    }
+
+    /**
+     * Retrieves the original image or video. complete will be called when the
+     * media is available on the local file system. If lowResolution is not null
+     * and a lower resolution preview is available before the original,
+     * lowResolution will be called prior to complete. All callbacks will be
+     * made on a thread other than the calling thread.
+     *
+     * @param contentUri The URI for the full resolution image to search for.
+     * @param complete Callback for when the image has been retrieved.
+     * @param lowResolution If not null and a lower resolution image is
+     *            available prior to retrieving the preview, this will be called
+     *            with the low resolution bitmap.
+     */
+    public void retrieveOriginal(Uri contentUri, OriginalReady complete, ImageReady lowResolution) {
+        File localFile = getLocalFile(contentUri);
+        if (localFile != null) {
+            addNotification(new NotifyOriginalReady(complete), localFile);
+        } else {
+            NotifyImageReady notifyLowResolution = (lowResolution == null) ? null
+                    : new NotifyImageReady(lowResolution);
+            addTask(contentUri, new NotifyOriginalReady(complete), notifyLowResolution,
+                    MediaSize.Original);
+        }
+    }
+
+    /**
+     * Looks for an already cached media at a specific size.
+     *
+     * @param contentUri The original media item content URI
+     * @param size The target size to search for in the cache
+     * @return The cached file location or null if it is not cached.
+     */
+    public File getCachedFile(Uri contentUri, MediaSize size) {
+        Long cachedId = mDatabaseHelper.getCached(contentUri, size);
+        File file = null;
+        if (cachedId != null) {
+            file = createCacheImagePath(cachedId);
+        }
+        return file;
+    }
+
+    /**
+     * Inserts a media item into the cache.
+     *
+     * @param contentUri The original media item URI.
+     * @param size The size of the media item to store in the cache.
+     * @param tempFile The temporary file where the image is stored. This file
+     *            will no longer exist after executing this method.
+     * @return The new location, in the cache, of the media item or null if it
+     *         wasn't possible to move into the cache.
+     */
+    public File insertIntoCache(Uri contentUri, MediaSize size, File tempFile) {
+        long fileSize = tempFile.length();
+        if (fileSize == 0) {
+            return null;
+        }
+        File cacheFile = null;
+        SQLiteDatabase db = mDatabaseHelper.getWritableDatabase();
+        // Ensure that this step is atomic
+        db.beginTransaction();
+        try {
+            Long id = mDatabaseHelper.getCached(contentUri, size);
+            if (id != null) {
+                cacheFile = createCacheImagePath(id);
+                if (tempFile.renameTo(cacheFile)) {
+                    mDatabaseHelper.updateLength(id, fileSize);
+                } else {
+                    Log.w(TAG, "Could not update cached file with " + tempFile);
+                    tempFile.delete();
+                    cacheFile = null;
+                }
+            } else {
+                ensureFreeCacheSpace(tempFile.length(), size);
+                id = mDatabaseHelper.insert(contentUri, size, mMoveTempToCache, tempFile);
+                cacheFile = createCacheImagePath(id);
+            }
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+        return cacheFile;
+    }
+
+    /**
+     * For testing purposes.
+     */
+    public void setMaxCacheSize(long maxCacheSize) {
+        synchronized (mCacheSizeLock) {
+            mMaxCacheSize = maxCacheSize;
+            mMinThumbCacheSize = mMaxCacheSize / 10;
+            mCacheSize = -1;
+            mThumbCacheSize = -1;
+        }
+    }
+
+    private File createCacheImagePath(long id) {
+        return new File(getCacheDir(), String.valueOf(id) + IMAGE_EXTENSION);
+    }
+
+    private void addTask(Uri contentUri, ImageReady complete, ImageReady lowResolution,
+            MediaSize size) {
+        NotifyReady notifyComplete = new NotifyImageReady(complete);
+        NotifyImageReady notifyLowResolution = null;
+        if (lowResolution != null) {
+            notifyLowResolution = new NotifyImageReady(lowResolution);
+        }
+        addTask(contentUri, notifyComplete, notifyLowResolution, size);
+    }
+
+    private void addTask(Uri contentUri, NotifyReady complete, NotifyImageReady lowResolution,
+            MediaSize size) {
+        MediaRetriever retriever = getMediaRetriever(contentUri);
+        Uri uri = retriever.normalizeUri(contentUri, size);
+        if (uri == null) {
+            throw new IllegalArgumentException("No MediaRetriever for " + contentUri);
+        }
+        size = retriever.normalizeMediaSize(uri, size);
+
+        Long cachedId = mDatabaseHelper.getCached(uri, size);
+        if (cachedId != null) {
+            addNotification(complete, createCacheImagePath(cachedId));
+            return;
+        }
+        String differentiator = getDifferentiator(uri.getScheme(), uri.getAuthority());
+        synchronized (mTasks) {
+            List<ProcessingJob> tasks = mTasks.get(differentiator);
+            if (tasks == null) {
+                throw new IllegalArgumentException("Cannot find retriever for: " + uri);
+            }
+            synchronized (tasks) {
+                ProcessingJob job = new ProcessingJob(uri, size, complete, lowResolution);
+                tasks.add(job);
+                tasks.notifyAll();
+            }
+        }
+    }
+
+    private MediaRetriever getMediaRetriever(Uri uri) {
+        String differentiator = getDifferentiator(uri.getScheme(), uri.getAuthority());
+        MediaRetriever retriever;
+        synchronized (mRetrievers) {
+            retriever = mRetrievers.get(differentiator);
+        }
+        if (retriever == null) {
+            throw new IllegalArgumentException("No MediaRetriever for " + uri);
+        }
+        return retriever;
+    }
+
+    private File getLocalFile(Uri uri) {
+        MediaRetriever retriever = getMediaRetriever(uri);
+        File localFile = null;
+        if (retriever != null) {
+            localFile = retriever.getLocalFile(uri);
+        }
+        return localFile;
+    }
+
+    private MediaSize getFastImageSize(Uri uri, MediaSize size) {
+        MediaRetriever retriever = getMediaRetriever(uri);
+        return retriever.getFastImageSize(uri, size);
+    }
+
+    private boolean isFastImageBetter(MediaSize fastImageType, MediaSize size) {
+        if (fastImageType == null) {
+            return false;
+        }
+        if (size == null) {
+            return true;
+        }
+        return fastImageType.isBetterThan(size);
+    }
+
+    private byte[] getTemporaryImage(Uri uri, MediaSize fastImageType) {
+        MediaRetriever retriever = getMediaRetriever(uri);
+        return retriever.getTemporaryImage(uri, fastImageType);
+    }
+
+    private void processTask(ProcessingJob job) {
+        Long cachedId = mDatabaseHelper.getCached(job.contentUri, job.size);
+        if (cachedId != null) {
+            File file = createCacheImagePath(cachedId);
+            addNotification(job.complete, file);
+            return;
+        }
+
+        boolean hasLowResolution = job.lowResolution != null;
+        if (hasLowResolution) {
+            MediaSize cachedSize = mDatabaseHelper.executeOnBestCached(job.contentUri, job.size,
+                    mNotifyCachedLowResolution);
+            MediaSize fastImageSize = getFastImageSize(job.contentUri, job.size);
+            if (isFastImageBetter(fastImageSize, cachedSize)) {
+                if (fastImageSize.isTemporary()) {
+                    byte[] bytes = getTemporaryImage(job.contentUri, fastImageSize);
+                    if (bytes != null) {
+                        addNotification(job.lowResolution, bytes);
+                    }
+                } else {
+                    File lowFile = getMedia(job.contentUri, fastImageSize);
+                    if (lowFile != null) {
+                        addNotification(job.lowResolution, lowFile);
+                    }
+                }
+            }
+        }
+
+        // Now get the full size desired
+        File fullSizeFile = getMedia(job.contentUri, job.size);
+        if (fullSizeFile != null) {
+            addNotification(job.complete, fullSizeFile);
+        }
+    }
+
+    private void addNotification(NotifyReady callback, File file) {
+        try {
+            callback.setFile(file);
+            synchronized (mCallbacks) {
+                mCallbacks.add(callback);
+                mCallbacks.notifyAll();
+            }
+        } catch (FileNotFoundException e) {
+            Log.e(TAG, "Unable to read file " + file, e);
+        }
+    }
+
+    private void addNotification(NotifyImageReady callback, byte[] bytes) {
+        callback.setBytes(bytes);
+        synchronized (mCallbacks) {
+            mCallbacks.add(callback);
+            mCallbacks.notifyAll();
+        }
+    }
+
+    private File getMedia(Uri uri, MediaSize size) {
+        long imageNumber;
+        synchronized (mTempImageNumberLock) {
+            imageNumber = mTempImageNumber++;
+        }
+        File tempFile = new File(getCacheDir(), String.valueOf(imageNumber) + TEMP_IMAGE_EXTENSION);
+        MediaRetriever retriever = getMediaRetriever(uri);
+        boolean retrieved = retriever.getMedia(uri, size, tempFile);
+        File cachedFile = null;
+        if (retrieved) {
+            ensureFreeCacheSpace(tempFile.length(), size);
+            long id = mDatabaseHelper.insert(uri, size, mMoveTempToCache, tempFile);
+            cachedFile = createCacheImagePath(id);
+        }
+        return cachedFile;
+    }
+
+    private static String getDifferentiator(String scheme, String authority) {
+        if (authority == null) {
+            return scheme;
+        }
+        StringBuilder differentiator = new StringBuilder(scheme);
+        differentiator.append(':');
+        differentiator.append(authority);
+        return differentiator.toString();
+    }
+
+    private void ensureFreeCacheSpace(long size, MediaSize mediaSize) {
+        synchronized (mCacheSizeLock) {
+            if (mCacheSize == -1 || mThumbCacheSize == -1) {
+                mCacheSize = mDatabaseHelper.getCacheSize();
+                mThumbCacheSize = mDatabaseHelper.getThumbnailCacheSize();
+                if (mCacheSize == -1 || mThumbCacheSize == -1) {
+                    Log.e(TAG, "Can't determine size of the image cache");
+                    return;
+                }
+            }
+            mCacheSize += size;
+            if (mediaSize == MediaSize.Thumbnail) {
+                mThumbCacheSize += size;
+            }
+            if (mCacheSize > mMaxCacheSize) {
+                shrinkCacheLocked();
+            }
+        }
+    }
+
+    private void shrinkCacheLocked() {
+        long deleteSize = mMinThumbCacheSize;
+        boolean includeThumbnails = (mThumbCacheSize - deleteSize) > mMinThumbCacheSize;
+        mDatabaseHelper.deleteOldCached(includeThumbnails, deleteSize, mDeleteFile);
+    }
+}
diff --git a/src/com/android/photos/data/MediaCacheDatabase.java b/src/com/android/photos/data/MediaCacheDatabase.java
new file mode 100644
index 0000000..16265b5
--- /dev/null
+++ b/src/com/android/photos/data/MediaCacheDatabase.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
+import android.provider.BaseColumns;
+
+import com.android.photos.data.MediaRetriever.MediaSize;
+
+import java.io.File;
+
+class MediaCacheDatabase extends SQLiteOpenHelper {
+    public static final int DB_VERSION = 1;
+    public static final String DB_NAME = "mediacache.db";
+
+    /** Internal database table used for the media cache */
+    public static final String TABLE = "media_cache";
+
+    private static interface Columns extends BaseColumns {
+        /** The Content URI of the original image. */
+        public static final String URI = "uri";
+        /** MediaSize.getValue() values. */
+        public static final String MEDIA_SIZE = "media_size";
+        /** The last time this image was queried. */
+        public static final String LAST_ACCESS = "last_access";
+        /** The image size in bytes. */
+        public static final String SIZE_IN_BYTES = "size";
+    }
+
+    static interface Action {
+        void execute(Uri uri, long id, MediaRetriever.MediaSize size, Object parameter);
+    }
+
+    private static final String[] PROJECTION_ID = {
+        Columns._ID,
+    };
+
+    private static final String[] PROJECTION_CACHED = {
+        Columns._ID, Columns.MEDIA_SIZE, Columns.SIZE_IN_BYTES,
+    };
+
+    private static final String[] PROJECTION_CACHE_SIZE = {
+        "SUM(" + Columns.SIZE_IN_BYTES + ")"
+    };
+
+    private static final String[] PROJECTION_DELETE_OLD = {
+        Columns._ID, Columns.URI, Columns.MEDIA_SIZE, Columns.SIZE_IN_BYTES, Columns.LAST_ACCESS,
+    };
+
+    public static final String CREATE_TABLE = "CREATE TABLE " + TABLE + "("
+            + Columns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
+            + Columns.URI + " TEXT NOT NULL,"
+            + Columns.MEDIA_SIZE + " INTEGER NOT NULL,"
+            + Columns.LAST_ACCESS + " INTEGER NOT NULL,"
+            + Columns.SIZE_IN_BYTES + " INTEGER NOT NULL,"
+            + "UNIQUE(" + Columns.URI + ", " + Columns.MEDIA_SIZE + "))";
+
+    public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE;
+
+    public static final String WHERE_THUMBNAIL = Columns.MEDIA_SIZE + " = "
+            + MediaSize.Thumbnail.getValue();
+
+    public static final String WHERE_NOT_THUMBNAIL = Columns.MEDIA_SIZE + " <> "
+            + MediaSize.Thumbnail.getValue();
+
+    public static final String WHERE_CLEAR_CACHE = Columns.LAST_ACCESS + " <= ?";
+
+    public static final String WHERE_CLEAR_CACHE_LARGE = WHERE_CLEAR_CACHE + " AND "
+            + WHERE_NOT_THUMBNAIL;
+
+    static class QueryCacheResults {
+        public QueryCacheResults(long id, int sizeVal) {
+            this.id = id;
+            this.size = MediaRetriever.MediaSize.fromInteger(sizeVal);
+        }
+        public long id;
+        public MediaRetriever.MediaSize size;
+    }
+
+    public MediaCacheDatabase(Context context) {
+        super(context, DB_NAME, null, DB_VERSION);
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        db.execSQL(CREATE_TABLE);
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        db.execSQL(DROP_TABLE);
+        onCreate(db);
+        MediaCache.getInstance().clearCacheDir();
+    }
+
+    public Long getCached(Uri uri, MediaRetriever.MediaSize size) {
+        String where = Columns.URI + " = ? AND " + Columns.MEDIA_SIZE + " = ?";
+        SQLiteDatabase db = getWritableDatabase();
+        String[] whereArgs = {
+                uri.toString(), String.valueOf(size.getValue()),
+        };
+        Cursor cursor = db.query(TABLE, PROJECTION_ID, where, whereArgs, null, null, null);
+        Long id = null;
+        if (cursor.moveToNext()) {
+            id = cursor.getLong(0);
+        }
+        cursor.close();
+        if (id != null) {
+            String[] updateArgs = {
+                id.toString()
+            };
+            ContentValues values = new ContentValues();
+            values.put(Columns.LAST_ACCESS, System.currentTimeMillis());
+            db.beginTransaction();
+            try {
+                db.update(TABLE, values, Columns._ID + " = ?", updateArgs);
+                db.setTransactionSuccessful();
+            } finally {
+                db.endTransaction();
+            }
+        }
+        return id;
+    }
+
+    public MediaRetriever.MediaSize executeOnBestCached(Uri uri, MediaRetriever.MediaSize size, Action action) {
+        String where = Columns.URI + " = ? AND " + Columns.MEDIA_SIZE + " < ?";
+        String orderBy = Columns.MEDIA_SIZE + " DESC";
+        SQLiteDatabase db = getReadableDatabase();
+        String[] whereArgs = {
+                uri.toString(), String.valueOf(size.getValue()),
+        };
+        Cursor cursor = db.query(TABLE, PROJECTION_CACHED, where, whereArgs, null, null, orderBy);
+        MediaRetriever.MediaSize bestSize = null;
+        if (cursor.moveToNext()) {
+            long id = cursor.getLong(0);
+            bestSize = MediaRetriever.MediaSize.fromInteger(cursor.getInt(1));
+            long fileSize = cursor.getLong(2);
+            action.execute(uri, id, bestSize, fileSize);
+        }
+        cursor.close();
+        return bestSize;
+    }
+
+    public long insert(Uri uri, MediaRetriever.MediaSize size, Action action, File tempFile) {
+        SQLiteDatabase db = getWritableDatabase();
+        db.beginTransaction();
+        try {
+            ContentValues values = new ContentValues();
+            values.put(Columns.LAST_ACCESS, System.currentTimeMillis());
+            values.put(Columns.MEDIA_SIZE, size.getValue());
+            values.put(Columns.URI, uri.toString());
+            values.put(Columns.SIZE_IN_BYTES, tempFile.length());
+            long id = db.insert(TABLE, null, values);
+            if (id != -1) {
+                action.execute(uri, id, size, tempFile);
+                db.setTransactionSuccessful();
+            }
+            return id;
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    public void updateLength(long id, long fileSize) {
+        ContentValues values = new ContentValues();
+        values.put(Columns.SIZE_IN_BYTES, fileSize);
+        String[] whereArgs = {
+            String.valueOf(id)
+        };
+        SQLiteDatabase db = getWritableDatabase();
+        db.beginTransaction();
+        try {
+            db.update(TABLE, values, Columns._ID + " = ?", whereArgs);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    public void delete(Uri uri, Action action) {
+        SQLiteDatabase db = getWritableDatabase();
+        String where = Columns.URI + " = ?";
+        String[] whereArgs = {
+            uri.toString()
+        };
+        Cursor cursor = db.query(TABLE, PROJECTION_CACHED, where, whereArgs, null, null, null);
+        while (cursor.moveToNext()) {
+            long id = cursor.getLong(0);
+            MediaRetriever.MediaSize size = MediaRetriever.MediaSize.fromInteger(cursor.getInt(1));
+            action.execute(uri, id, size, null);
+        }
+        cursor.close();
+        db.beginTransaction();
+        try {
+            db.delete(TABLE, where, whereArgs);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    public void deleteOldCached(boolean includeThumbnails, long deleteSize, Action action) {
+        String where = includeThumbnails ? null : WHERE_NOT_THUMBNAIL;
+        long lastAccess = 0;
+        SQLiteDatabase db = getWritableDatabase();
+        db.beginTransaction();
+        try {
+            Cursor cursor = db.query(TABLE, PROJECTION_DELETE_OLD, where, null, null, null,
+                    Columns.LAST_ACCESS);
+            while (cursor.moveToNext()) {
+                long id = cursor.getLong(0);
+                String uri = cursor.getString(1);
+                MediaSize size = MediaSize.fromInteger(cursor.getInt(2));
+                long length = cursor.getLong(3);
+                long imageLastAccess = cursor.getLong(4);
+
+                if (imageLastAccess != lastAccess && deleteSize < 0) {
+                    break; // We've deleted enough.
+                }
+                lastAccess = imageLastAccess;
+                action.execute(Uri.parse(uri), id, size, length);
+                deleteSize -= length;
+            }
+            cursor.close();
+            String[] whereArgs = {
+                String.valueOf(lastAccess),
+            };
+            String whereDelete = includeThumbnails ? WHERE_CLEAR_CACHE : WHERE_CLEAR_CACHE_LARGE;
+            db.delete(TABLE, whereDelete, whereArgs);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    public long getCacheSize() {
+        return getCacheSize(null);
+    }
+
+    public long getThumbnailCacheSize() {
+        return getCacheSize(WHERE_THUMBNAIL);
+    }
+
+    private long getCacheSize(String where) {
+        SQLiteDatabase db = getReadableDatabase();
+        Cursor cursor = db.query(TABLE, PROJECTION_CACHE_SIZE, where, null, null, null, null);
+        long size = -1;
+        if (cursor.moveToNext()) {
+            size = cursor.getLong(0);
+        }
+        cursor.close();
+        return size;
+    }
+}
diff --git a/src/com/android/photos/data/MediaCacheUtils.java b/src/com/android/photos/data/MediaCacheUtils.java
new file mode 100644
index 0000000..1463d52
--- /dev/null
+++ b/src/com/android/photos/data/MediaCacheUtils.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.CompressFormat;
+import android.graphics.BitmapFactory;
+import android.util.Log;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.common.BitmapUtils;
+import com.android.gallery3d.data.DecodeUtils;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.util.ThreadPool.CancelListener;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+import com.android.photos.data.MediaRetriever.MediaSize;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+public class MediaCacheUtils {
+    private static final String TAG = MediaCacheUtils.class.getSimpleName();
+    private static int QUALITY = 80;
+    private static final JobContext sJobStub = new JobContext() {
+
+        @Override
+        public boolean isCancelled() {
+            return false;
+        }
+
+        @Override
+        public void setCancelListener(CancelListener listener) {
+        }
+
+        @Override
+        public boolean setMode(int mode) {
+            return true;
+        }
+    };
+
+    private static int mTargetThumbnailSize;
+    private static int mTargetPreviewSize;
+
+    public static void initialize(Context context) {
+        Resources resources = context.getResources();
+        mTargetThumbnailSize = resources.getDimensionPixelSize(R.dimen.size_thumbnail);
+        mTargetPreviewSize = resources.getDimensionPixelSize(R.dimen.size_preview);
+    }
+
+    public static int getTargetSize(MediaSize size) {
+        return (size == MediaSize.Thumbnail) ? mTargetThumbnailSize : mTargetPreviewSize;
+    }
+
+    public static boolean downsample(File inBitmap, MediaSize targetSize, File outBitmap) {
+        if (MediaSize.Original == targetSize) {
+            return false; // MediaCache should use the local path for this.
+        }
+        int size = getTargetSize(targetSize);
+        BitmapFactory.Options options = new BitmapFactory.Options();
+        options.inPreferredConfig = Bitmap.Config.ARGB_8888;
+        // TODO: remove unnecessary job context from DecodeUtils.
+        Bitmap bitmap = DecodeUtils.decodeThumbnail(sJobStub, inBitmap.getPath(), options, size,
+                MediaItem.TYPE_THUMBNAIL);
+        boolean success = (bitmap != null);
+        if (success) {
+            success = writeAndRecycle(bitmap, outBitmap);
+        }
+        return success;
+    }
+
+    public static boolean downsample(Bitmap inBitmap, MediaSize size, File outBitmap) {
+        if (MediaSize.Original == size) {
+            return false; // MediaCache should use the local path for this.
+        }
+        int targetSize = getTargetSize(size);
+        boolean success;
+        if (!needsDownsample(inBitmap, size)) {
+            success = writeAndRecycle(inBitmap, outBitmap);
+        } else {
+            float maxDimension = Math.max(inBitmap.getWidth(), inBitmap.getHeight());
+            float scale = targetSize / maxDimension;
+            int targetWidth = Math.round(scale * inBitmap.getWidth());
+            int targetHeight = Math.round(scale * inBitmap.getHeight());
+            Bitmap scaled = Bitmap.createScaledBitmap(inBitmap, targetWidth, targetHeight, false);
+            success = writeAndRecycle(scaled, outBitmap);
+            inBitmap.recycle();
+        }
+        return success;
+    }
+
+    public static boolean extractImageFromVideo(File inVideo, File outBitmap) {
+        Bitmap bitmap = BitmapUtils.createVideoThumbnail(inVideo.getPath());
+        return writeAndRecycle(bitmap, outBitmap);
+    }
+
+    public static boolean needsDownsample(Bitmap bitmap, MediaSize size) {
+        if (size == MediaSize.Original) {
+            return false;
+        }
+        int targetSize = getTargetSize(size);
+        int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight());
+        return maxDimension > (targetSize * 4 / 3);
+    }
+
+    public static boolean writeAndRecycle(Bitmap bitmap, File outBitmap) {
+        boolean success = writeToFile(bitmap, outBitmap);
+        bitmap.recycle();
+        return success;
+    }
+
+    public static boolean writeToFile(Bitmap bitmap, File outBitmap) {
+        boolean success = false;
+        try {
+            FileOutputStream out = new FileOutputStream(outBitmap);
+            success = bitmap.compress(CompressFormat.JPEG, QUALITY, out);
+            out.close();
+        } catch (IOException e) {
+            Log.w(TAG, "Couldn't write bitmap to cache", e);
+            // success is already false
+        }
+        return success;
+    }
+}
diff --git a/src/com/android/photos/data/MediaRetriever.java b/src/com/android/photos/data/MediaRetriever.java
new file mode 100644
index 0000000..f383e5f
--- /dev/null
+++ b/src/com/android/photos/data/MediaRetriever.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.net.Uri;
+
+import java.io.File;
+
+public interface MediaRetriever {
+    public enum MediaSize {
+        TemporaryThumbnail(5), Thumbnail(10), TemporaryPreview(15), Preview(20), Original(30);
+
+        private final int mValue;
+
+        private MediaSize(int value) {
+            mValue = value;
+        }
+
+        public int getValue() {
+            return mValue;
+        }
+
+        static MediaSize fromInteger(int value) {
+            switch (value) {
+                case 10:
+                    return MediaSize.Thumbnail;
+                case 20:
+                    return MediaSize.Preview;
+                case 30:
+                    return MediaSize.Original;
+                default:
+                    throw new IllegalArgumentException();
+            }
+        }
+
+        public boolean isBetterThan(MediaSize that) {
+            return mValue > that.mValue;
+        }
+
+        public boolean isTemporary() {
+            return this == TemporaryThumbnail || this == TemporaryPreview;
+        }
+    }
+
+    /**
+     * Returns the local File for the given Uri. If the image is not stored
+     * locally, null should be returned. The image should not be retrieved if it
+     * isn't already available.
+     *
+     * @param contentUri The media URI to search for.
+     * @return The local File of the image if it is available or null if it
+     *         isn't.
+     */
+    File getLocalFile(Uri contentUri);
+
+    /**
+     * Returns the fast access image type for a given image size, if supported.
+     * This image should be smaller than size and should be quick to retrieve.
+     * It does not have to obey the expected aspect ratio.
+     *
+     * @param contentUri The original media Uri.
+     * @param size The target size to search for a fast-access image.
+     * @return The fast image type supported for the given image size or null of
+     *         no fast image is supported.
+     */
+    MediaSize getFastImageSize(Uri contentUri, MediaSize size);
+
+    /**
+     * Returns a byte array containing the contents of the fast temporary image
+     * for a given image size. For example, a thumbnail may be smaller or of a
+     * different aspect ratio than the generated thumbnail.
+     *
+     * @param contentUri The original media Uri.
+     * @param temporarySize The target media size. Guaranteed to be a MediaSize
+     *            for which isTemporary() returns true.
+     * @return A byte array of contents for for the given contentUri and
+     *         fastImageType. null can be retrieved if the quick retrieval
+     *         fails.
+     */
+    byte[] getTemporaryImage(Uri contentUri, MediaSize temporarySize);
+
+    /**
+     * Retrieves an image and saves it to a file.
+     *
+     * @param contentUri The original media Uri.
+     * @param size The target media size.
+     * @param tempFile The file to write the bitmap to.
+     * @return <code>true</code> on success.
+     */
+    boolean getMedia(Uri contentUri, MediaSize imageSize, File tempFile);
+
+    /**
+     * Normalizes a URI that may have additional parameters. It is fine to
+     * return contentUri. This is executed on the calling thread, so it must be
+     * a fast access operation and cannot depend, for example, on I/O.
+     *
+     * @param contentUri The URI to normalize
+     * @param size The size of the image being requested
+     * @return The normalized URI representation of contentUri.
+     */
+    Uri normalizeUri(Uri contentUri, MediaSize size);
+
+    /**
+     * Normalize the MediaSize for a given URI. Typically the size returned
+     * would be the passed-in size. Some URIs may only have one size used and
+     * should be treaded as Thumbnails, for example. This is executed on the
+     * calling thread, so it must be a fast access operation and cannot depend,
+     * for example, on I/O.
+     *
+     * @param contentUri The URI for the size being normalized.
+     * @param size The size to be normalized.
+     * @return The normalized size of the given URI.
+     */
+    MediaSize normalizeMediaSize(Uri contentUri, MediaSize size);
+}
diff --git a/tests/src/com/android/photos/data/DataTestRunner.java b/tests/src/com/android/photos/data/DataTestRunner.java
index 4322585..10618d6 100644
--- a/tests/src/com/android/photos/data/DataTestRunner.java
+++ b/tests/src/com/android/photos/data/DataTestRunner.java
@@ -18,6 +18,9 @@
 import android.test.InstrumentationTestRunner;
 import android.test.InstrumentationTestSuite;
 
+import com.android.photos.data.TestHelper.TestInitialization;
+
+import junit.framework.TestCase;
 import junit.framework.TestSuite;
 
 public class DataTestRunner extends InstrumentationTestRunner {
@@ -26,6 +29,13 @@
         TestSuite suite = new InstrumentationTestSuite(this);
         suite.addTestSuite(PhotoDatabaseTest.class);
         suite.addTestSuite(PhotoProviderTest.class);
+        TestHelper.addTests(MediaCacheTest.class, suite, new TestInitialization() {
+            @Override
+            public void initialize(TestCase testCase) {
+                MediaCacheTest test = (MediaCacheTest) testCase;
+                test.setLocalContext(getContext());
+            }
+        });
         return suite;
     }
 
diff --git a/tests/src/com/android/photos/data/MediaCacheTest.java b/tests/src/com/android/photos/data/MediaCacheTest.java
new file mode 100644
index 0000000..df990ed
--- /dev/null
+++ b/tests/src/com/android/photos/data/MediaCacheTest.java
@@ -0,0 +1,388 @@
+/*
+ * Copyright (C) 2013 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.photos.data;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.Environment;
+import android.os.SystemClock;
+import android.test.ProviderTestCase2;
+
+import com.android.gallery3d.tests.R;
+import com.android.photos.data.MediaCache.ImageReady;
+import com.android.photos.data.MediaCache.OriginalReady;
+import com.android.photos.data.MediaRetriever.MediaSize;
+import com.android.photos.data.PhotoProvider.Photos;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class MediaCacheTest extends ProviderTestCase2<PhotoProvider> {
+    @SuppressWarnings("unused")
+    private static final String TAG = MediaCacheTest.class.getSimpleName();
+
+    private File mDir;
+    private File mImage;
+    private File mCacheDir;
+    private Resources mResources;
+    private MediaCache mMediaCache;
+    private ReadyCollector mReady;
+
+    public static final long MAX_WAIT = 2000;
+
+    private static class ReadyCollector implements ImageReady, OriginalReady {
+        public File mOriginalFile;
+        public InputStream mInputStream;
+
+        @Override
+        public synchronized void originalReady(File originalFile) {
+            mOriginalFile = originalFile;
+            notifyAll();
+        }
+
+        @Override
+        public synchronized void imageReady(InputStream bitmapInputStream) {
+            mInputStream = bitmapInputStream;
+            notifyAll();
+        }
+
+        public synchronized boolean waitForNotification() {
+            long endWait = SystemClock.uptimeMillis() + MAX_WAIT;
+
+            try {
+                while (mInputStream == null && mOriginalFile == null
+                        && SystemClock.uptimeMillis() < endWait) {
+                    wait(endWait - SystemClock.uptimeMillis());
+                }
+            } catch (InterruptedException e) {
+            }
+            return mInputStream != null || mOriginalFile != null;
+        }
+    }
+
+    private static class DummyMediaRetriever implements MediaRetriever {
+        private boolean mNullUri = false;
+        @Override
+        public File getLocalFile(Uri contentUri) {
+            return null;
+        }
+
+        @Override
+        public MediaSize getFastImageSize(Uri contentUri, MediaSize size) {
+            return null;
+        }
+
+        @Override
+        public byte[] getTemporaryImage(Uri contentUri, MediaSize temporarySize) {
+            return null;
+        }
+
+        @Override
+        public boolean getMedia(Uri contentUri, MediaSize imageSize, File tempFile) {
+            return false;
+        }
+
+        @Override
+        public Uri normalizeUri(Uri contentUri, MediaSize size) {
+            if (mNullUri) {
+                return null;
+            } else {
+                return contentUri;
+            }
+        }
+
+        @Override
+        public MediaSize normalizeMediaSize(Uri contentUri, MediaSize size) {
+            return size;
+        }
+
+        public void setNullUri() {
+            mNullUri = true;
+        }
+    };
+
+    public MediaCacheTest() {
+        super(PhotoProvider.class, PhotoProvider.AUTHORITY);
+    }
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+
+        mReady = new ReadyCollector();
+        File externalDir = Environment.getExternalStorageDirectory();
+        mDir = new File(externalDir, "test");
+        mDir.mkdirs();
+        mCacheDir = new File(externalDir, "test_cache");
+        mImage = new File(mDir, "original.jpg");
+        MediaCache.initialize(getMockContext());
+        MediaCache.getInstance().setCacheDir(mCacheDir);
+        mMediaCache = MediaCache.getInstance();
+        mMediaCache.addRetriever("file", "", new FileRetriever());
+    }
+
+    @Override
+    public void tearDown() throws Exception {
+        super.tearDown();
+        mMediaCache.clearCacheDir();
+        MediaCache.shutdown();
+        mMediaCache = null;
+        mImage.delete();
+        mDir.delete();
+        mCacheDir.delete();
+    }
+
+    public void setLocalContext(Context context) {
+        mResources = context.getResources();
+    }
+
+    public void testRetrieveOriginal() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        mMediaCache.retrieveOriginal(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNull(mReady.mInputStream);
+        assertEquals(mImage, mReady.mOriginalFile);
+    }
+
+    public void testRetrievePreview() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        mMediaCache.retrievePreview(uri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+        assertNotNull(mReady.mInputStream);
+        assertNull(mReady.mOriginalFile);
+        Bitmap bitmap = BitmapFactory.decodeStream(mReady.mInputStream);
+        mReady.mInputStream.close();
+        assertNotNull(bitmap);
+        Bitmap original = BitmapFactory.decodeFile(mImage.getPath());
+        assertTrue(bitmap.getWidth() < original.getWidth());
+        assertTrue(bitmap.getHeight() < original.getHeight());
+        int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight());
+        int targetSize = MediaCacheUtils.getTargetSize(MediaSize.Preview);
+        assertTrue(maxDimension >= targetSize);
+        assertTrue(maxDimension < (targetSize * 2));
+    }
+
+    public void testRetrieveExifThumb() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        ReadyCollector done = new ReadyCollector();
+        mMediaCache.retrieveThumbnail(uri, done, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNotNull(mReady.mInputStream);
+        assertNull(mReady.mOriginalFile);
+        Bitmap bitmap = BitmapFactory.decodeStream(mReady.mInputStream);
+        mReady.mInputStream.close();
+        assertTrue(done.waitForNotification());
+        assertNotNull(done.mInputStream);
+        done.mInputStream.close();
+        assertNotNull(bitmap);
+        assertEquals(320, bitmap.getWidth());
+        assertEquals(240, bitmap.getHeight());
+    }
+
+    public void testRetrieveThumb() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        long downsampleStart = SystemClock.uptimeMillis();
+        mMediaCache.retrieveThumbnail(uri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+        long downsampleEnd = SystemClock.uptimeMillis();
+        assertNotNull(mReady.mInputStream);
+        assertNull(mReady.mOriginalFile);
+        Bitmap bitmap = BitmapFactory.decodeStream(mReady.mInputStream);
+        mReady.mInputStream.close();
+        assertNotNull(bitmap);
+        Bitmap original = BitmapFactory.decodeFile(mImage.getPath());
+        assertTrue(bitmap.getWidth() < original.getWidth());
+        assertTrue(bitmap.getHeight() < original.getHeight());
+        int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight());
+        int targetSize = MediaCacheUtils.getTargetSize(MediaSize.Thumbnail);
+        assertTrue(maxDimension >= targetSize);
+        assertTrue(maxDimension < (targetSize * 2));
+
+        // Retrieve cached thumb.
+        mReady = new ReadyCollector();
+        long start = SystemClock.uptimeMillis();
+        mMediaCache.retrieveThumbnail(uri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+        mReady.mInputStream.close();
+        long end = SystemClock.uptimeMillis();
+        // Already cached. Wait shorter time.
+        assertTrue((end - start) < (downsampleEnd - downsampleStart) / 2);
+    }
+
+    public void testGetVideo() throws IOException {
+        mImage = new File(mDir, "original.mp4");
+        copyResourceToFile(R.raw.android_lawn, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+
+        mMediaCache.retrieveOriginal(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNull(mReady.mInputStream);
+        assertNotNull(mReady.mOriginalFile);
+
+        mReady = new ReadyCollector();
+        mMediaCache.retrievePreview(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNotNull(mReady.mInputStream);
+        assertNull(mReady.mOriginalFile);
+        Bitmap bitmap = BitmapFactory.decodeStream(mReady.mInputStream);
+        mReady.mInputStream.close();
+        int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight());
+        int targetSize = MediaCacheUtils.getTargetSize(MediaSize.Preview);
+        assertTrue(maxDimension >= targetSize);
+        assertTrue(maxDimension < (targetSize * 2));
+
+        mReady = new ReadyCollector();
+        mMediaCache.retrieveThumbnail(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNotNull(mReady.mInputStream);
+        assertNull(mReady.mOriginalFile);
+        bitmap = BitmapFactory.decodeStream(mReady.mInputStream);
+        mReady.mInputStream.close();
+        maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight());
+        targetSize = MediaCacheUtils.getTargetSize(MediaSize.Thumbnail);
+        assertTrue(maxDimension >= targetSize);
+        assertTrue(maxDimension < (targetSize * 2));
+    }
+
+    public void testFastImage() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        mMediaCache.retrieveThumbnail(uri, mReady, mReady);
+        mReady.waitForNotification();
+        mReady.mInputStream.close();
+
+        mMediaCache.retrieveOriginal(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNotNull(mReady.mInputStream);
+        mReady.mInputStream.close();
+    }
+
+    public void testBadRetriever() {
+        Uri uri = Photos.CONTENT_URI;
+        try {
+            mMediaCache.retrieveOriginal(uri, mReady, mReady);
+            fail("Expected exception");
+        } catch (IllegalArgumentException e) {
+            // expected
+        }
+    }
+
+    public void testInsertIntoCache() throws IOException {
+        // FileRetriever inserts into the cache opportunistically with Videos
+        mImage = new File(mDir, "original.mp4");
+        copyResourceToFile(R.raw.android_lawn, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+
+        mMediaCache.retrieveThumbnail(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        mReady.mInputStream.close();
+        assertNotNull(mMediaCache.getCachedFile(uri, MediaSize.Preview));
+    }
+
+    public void testBadNormalizedUri() {
+        DummyMediaRetriever retriever = new DummyMediaRetriever();
+        Uri uri = Uri.fromParts("http", "world", "morestuff");
+        mMediaCache.addRetriever(uri.getScheme(), uri.getAuthority(), retriever);
+        retriever.setNullUri();
+        try {
+            mMediaCache.retrieveOriginal(uri, mReady, mReady);
+            fail("Expected IllegalArgumentException");
+        } catch (IllegalArgumentException e) {
+            // expected
+        }
+    }
+
+    public void testClearOldCache() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        mMediaCache.retrievePreview(uri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+        mReady.mInputStream.close();
+        mMediaCache.setMaxCacheSize(mMediaCache.getCachedFile(uri, MediaSize.Preview).length());
+        assertNotNull(mMediaCache.getCachedFile(uri, MediaSize.Preview));
+
+        mReady = new ReadyCollector();
+        // This should kick the preview image out of the cache.
+        mMediaCache.retrieveThumbnail(uri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+        mReady.mInputStream.close();
+        assertNull(mMediaCache.getCachedFile(uri, MediaSize.Preview));
+        assertNotNull(mMediaCache.getCachedFile(uri, MediaSize.Thumbnail));
+    }
+
+    public void testClearLargeInCache() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri imageUri = Uri.fromFile(mImage);
+        mMediaCache.retrieveThumbnail(imageUri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+            mReady.mInputStream.close();
+        assertNotNull(mMediaCache.getCachedFile(imageUri, MediaSize.Thumbnail));
+        long thumbSize = mMediaCache.getCachedFile(imageUri, MediaSize.Thumbnail).length();
+        mMediaCache.setMaxCacheSize(thumbSize * 10);
+
+        for (int i = 0; i < 9; i++) {
+            File tempImage = new File(mDir, "image" + i + ".jpg");
+            mImage.renameTo(tempImage);
+            Uri tempImageUri = Uri.fromFile(tempImage);
+            mReady = new ReadyCollector();
+            mMediaCache.retrieveThumbnail(tempImageUri, mReady, null);
+            assertTrue(mReady.waitForNotification());
+                mReady.mInputStream.close();
+            tempImage.renameTo(mImage);
+        }
+        assertNotNull(mMediaCache.getCachedFile(imageUri, MediaSize.Thumbnail));
+
+        for (int i = 0; i < 9; i++) {
+            File tempImage = new File(mDir, "image" + i + ".jpg");
+            mImage.renameTo(tempImage);
+            Uri tempImageUri = Uri.fromFile(tempImage);
+            mReady = new ReadyCollector();
+            mMediaCache.retrievePreview(tempImageUri, mReady, null);
+            assertTrue(mReady.waitForNotification());
+                mReady.mInputStream.close();
+            tempImage.renameTo(mImage);
+        }
+        assertNotNull(mMediaCache.getCachedFile(imageUri, MediaSize.Thumbnail));
+        Uri oldestUri = Uri.fromFile(new File(mDir, "image0.jpg"));
+        assertNull(mMediaCache.getCachedFile(oldestUri, MediaSize.Thumbnail));
+    }
+
+    private void copyResourceToFile(int resourceId, String path) throws IOException {
+        File outputDir = new File(path).getParentFile();
+        outputDir.mkdirs();
+
+        InputStream in = mResources.openRawResource(resourceId);
+        FileOutputStream out = new FileOutputStream(path);
+        byte[] buffer = new byte[1000];
+        int bytesRead;
+
+        while ((bytesRead = in.read(buffer)) >= 0) {
+            out.write(buffer, 0, bytesRead);
+        }
+
+        in.close();
+        out.close();
+    }
+}