blob: 0952a40170f2ac2846be144d225d583185d14d06 [file] [log] [blame]
/*
* 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;
boolean isPrefetch();
}
private static class NotifyOriginalReady implements NotifyReady {
private final OriginalReady mCallback;
private File mFile;
public NotifyOriginalReady(OriginalReady callback) {
mCallback = callback;
}
@Override
public void notifyReady() {
if (mCallback != null) {
mCallback.originalReady(mFile);
}
}
@Override
public void setFile(File file) {
mFile = file;
}
@Override
public boolean isPrefetch() {
return mCallback == null;
}
}
private static class NotifyImageReady implements NotifyReady {
private final ImageReady mCallback;
private InputStream mInputStream;
public NotifyImageReady(ImageReady callback) {
mCallback = callback;
}
@Override
public void notifyReady() {
if (mCallback != null) {
mCallback.imageReady(mInputStream);
}
}
@Override
public void setFile(File file) throws FileNotFoundException {
mInputStream = new FileInputStream(file);
}
public void setBytes(byte[] bytes) {
mInputStream = new ByteArrayInputStream(bytes);
}
@Override
public boolean isPrefetch() {
return mCallback == null;
}
}
/** 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;
}
public 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);
if (!file.exists()) {
mDatabaseHelper.delete(contentUri, size, mDeleteFile);
file = null;
}
}
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);
File cachedFile = getCachedFile(uri, size);
if (cachedFile != null) {
addNotification(complete, cachedFile);
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);
if (complete.isPrefetch()) {
tasks.add(job);
} else {
int index = tasks.size() - 1;
while (index >= 0 && tasks.get(index).complete.isPrefetch()) {
index--;
}
tasks.add(index + 1, 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) {
File cachedFile = getCachedFile(job.contentUri, job.size);
if (cachedFile != null) {
addNotification(job.complete, cachedFile);
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);
}
}