| /* |
| * 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); |
| } |
| } |