From 314de4034664ab668aea61e1ecd14946f1b4a978 Mon Sep 17 00:00:00 2001 From: George Mount Date: Mon, 18 Mar 2013 09:51:21 -0700 Subject: Add initial implementation of MediaCache. Change-Id: I94d357bab0e57cc60b8be790d912ba036730298b --- src/com/android/photos/data/FileRetriever.java | 109 ++++ src/com/android/photos/data/MediaCache.java | 649 +++++++++++++++++++++ .../android/photos/data/MediaCacheDatabase.java | 272 +++++++++ src/com/android/photos/data/MediaCacheUtils.java | 139 +++++ src/com/android/photos/data/MediaRetriever.java | 129 ++++ 5 files changed, 1298 insertions(+) create mode 100644 src/com/android/photos/data/FileRetriever.java create mode 100644 src/com/android/photos/data/MediaCache.java create mode 100644 src/com/android/photos/data/MediaCacheDatabase.java create mode 100644 src/com/android/photos/data/MediaCacheUtils.java create mode 100644 src/com/android/photos/data/MediaRetriever.java (limited to 'src/com/android/photos') diff --git a/src/com/android/photos/data/FileRetriever.java b/src/com/android/photos/data/FileRetriever.java new file mode 100644 index 000000000..eb7686ef6 --- /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 000000000..7b5eca558 --- /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. + *

+ * 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. + *

+ *

+ * Media items are uniquely identified by their content URIs. Each + * scheme/authority can offer its own MediaRetriever, running in its own thread. + *

+ *

+ * 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. + *

+ */ +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 mQueue; + + public ProcessQueue(Queue 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 mCallbacks = new LinkedList(); + private Map mRetrievers = new HashMap(); + private Map> mTasks = new HashMap>(); + private List mProcessingThreads = new ArrayList(); + 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 queue = new LinkedList(); + 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 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 000000000..16265b574 --- /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 000000000..1463d5241 --- /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 000000000..f383e5ffa --- /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 true 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); +} -- cgit v1.2.3