diff options
Diffstat (limited to 'src/com/android/photos/data')
-rw-r--r-- | src/com/android/photos/data/AlbumSetLoader.java | 54 | ||||
-rw-r--r-- | src/com/android/photos/data/BitmapDecoder.java | 224 | ||||
-rw-r--r-- | src/com/android/photos/data/FileRetriever.java | 109 | ||||
-rw-r--r-- | src/com/android/photos/data/GalleryBitmapPool.java | 161 | ||||
-rw-r--r-- | src/com/android/photos/data/MediaCache.java | 676 | ||||
-rw-r--r-- | src/com/android/photos/data/MediaCacheDatabase.java | 286 | ||||
-rw-r--r-- | src/com/android/photos/data/MediaCacheUtils.java | 167 | ||||
-rw-r--r-- | src/com/android/photos/data/MediaRetriever.java | 129 | ||||
-rw-r--r-- | src/com/android/photos/data/NotificationWatcher.java | 55 | ||||
-rw-r--r-- | src/com/android/photos/data/PhotoDatabase.java | 195 | ||||
-rw-r--r-- | src/com/android/photos/data/PhotoProvider.java | 536 | ||||
-rw-r--r-- | src/com/android/photos/data/PhotoSetLoader.java | 115 | ||||
-rw-r--r-- | src/com/android/photos/data/SQLiteContentProvider.java | 265 | ||||
-rw-r--r-- | src/com/android/photos/data/SparseArrayBitmapPool.java | 212 |
14 files changed, 3184 insertions, 0 deletions
diff --git a/src/com/android/photos/data/AlbumSetLoader.java b/src/com/android/photos/data/AlbumSetLoader.java new file mode 100644 index 000000000..940473255 --- /dev/null +++ b/src/com/android/photos/data/AlbumSetLoader.java @@ -0,0 +1,54 @@ +package com.android.photos.data; + +import android.database.MatrixCursor; + + +public class AlbumSetLoader { + public static final int INDEX_ID = 0; + public static final int INDEX_TITLE = 1; + public static final int INDEX_TIMESTAMP = 2; + public static final int INDEX_THUMBNAIL_URI = 3; + public static final int INDEX_THUMBNAIL_WIDTH = 4; + public static final int INDEX_THUMBNAIL_HEIGHT = 5; + public static final int INDEX_COUNT_PENDING_UPLOAD = 6; + public static final int INDEX_COUNT = 7; + public static final int INDEX_SUPPORTED_OPERATIONS = 8; + + public static final String[] PROJECTION = { + "_id", + "title", + "timestamp", + "thumb_uri", + "thumb_width", + "thumb_height", + "count_pending_upload", + "_count", + "supported_operations" + }; + public static final MatrixCursor MOCK = createRandomCursor(30); + + private static MatrixCursor createRandomCursor(int count) { + MatrixCursor c = new MatrixCursor(PROJECTION, count); + for (int i = 0; i < count; i++) { + c.addRow(createRandomRow()); + } + return c; + } + + private static Object[] createRandomRow() { + double random = Math.random(); + int id = (int) (500 * random); + Object[] row = { + id, + "Fun times " + id, + (long) (System.currentTimeMillis() * random), + null, + 0, + 0, + (random < .3 ? 1 : 0), + 1, + 0 + }; + return row; + } +}
\ No newline at end of file diff --git a/src/com/android/photos/data/BitmapDecoder.java b/src/com/android/photos/data/BitmapDecoder.java new file mode 100644 index 000000000..0671e73ca --- /dev/null +++ b/src/com/android/photos/data/BitmapDecoder.java @@ -0,0 +1,224 @@ +/* + * 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.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.graphics.BitmapFactory.Options; +import android.util.Log; +import android.util.Pools.Pool; +import android.util.Pools.SynchronizedPool; + +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.common.Utils; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * BitmapDecoder keeps a pool of temporary storage to reuse for decoding + * bitmaps. It also simplifies the multi-stage decoding required to efficiently + * use GalleryBitmapPool. The static methods decode and decodeFile can be used + * to decode a bitmap from GalleryBitmapPool. The bitmap may be returned + * directly to GalleryBitmapPool or use the put method here when the bitmap is + * ready to be recycled. + */ +public class BitmapDecoder { + private static final String TAG = BitmapDecoder.class.getSimpleName(); + private static final int POOL_SIZE = 4; + private static final int TEMP_STORAGE_SIZE_BYTES = 16 * 1024; + private static final int HEADER_MAX_SIZE = 128 * 1024; + private static final int NO_SCALING = -1; + + private static final Pool<BitmapFactory.Options> sOptions = + new SynchronizedPool<BitmapFactory.Options>(POOL_SIZE); + + private interface Decoder<T> { + Bitmap decode(T input, BitmapFactory.Options options); + + boolean decodeBounds(T input, BitmapFactory.Options options); + } + + private static abstract class OnlyDecode<T> implements Decoder<T> { + @Override + public boolean decodeBounds(T input, BitmapFactory.Options options) { + decode(input, options); + return true; + } + } + + private static final Decoder<InputStream> sStreamDecoder = new Decoder<InputStream>() { + @Override + public Bitmap decode(InputStream is, Options options) { + return BitmapFactory.decodeStream(is, null, options); + } + + @Override + public boolean decodeBounds(InputStream is, Options options) { + is.mark(HEADER_MAX_SIZE); + BitmapFactory.decodeStream(is, null, options); + try { + is.reset(); + return true; + } catch (IOException e) { + Log.e(TAG, "Could not decode stream to bitmap", e); + return false; + } + } + }; + + private static final Decoder<String> sFileDecoder = new OnlyDecode<String>() { + @Override + public Bitmap decode(String filePath, Options options) { + return BitmapFactory.decodeFile(filePath, options); + } + }; + + private static final Decoder<byte[]> sByteArrayDecoder = new OnlyDecode<byte[]>() { + @Override + public Bitmap decode(byte[] data, Options options) { + return BitmapFactory.decodeByteArray(data, 0, data.length, options); + } + }; + + private static <T> Bitmap delegateDecode(Decoder<T> decoder, T input, int width, int height) { + BitmapFactory.Options options = getOptions(); + GalleryBitmapPool pool = GalleryBitmapPool.getInstance(); + try { + options.inJustDecodeBounds = true; + if (!decoder.decodeBounds(input, options)) { + return null; + } + options.inJustDecodeBounds = false; + Bitmap reuseBitmap = null; + if (width != NO_SCALING && options.outWidth >= width && options.outHeight >= height) { + setScaling(options, width, height); + } else { + reuseBitmap = pool.get(options.outWidth, options.outHeight); + } + options.inBitmap = reuseBitmap; + Bitmap decodedBitmap = decoder.decode(input, options); + if (reuseBitmap != null && decodedBitmap != reuseBitmap) { + pool.put(reuseBitmap); + } + return decodedBitmap; + } catch (IllegalArgumentException e) { + if (options.inBitmap == null) { + throw e; + } + pool.put(options.inBitmap); + options.inBitmap = null; + return decoder.decode(input, options); + } finally { + options.inBitmap = null; + options.inJustDecodeBounds = false; + sOptions.release(options); + } + } + + public static Bitmap decode(InputStream in) { + try { + if (!in.markSupported()) { + in = new BufferedInputStream(in); + } + return delegateDecode(sStreamDecoder, in, NO_SCALING, NO_SCALING); + } finally { + Utils.closeSilently(in); + } + } + + public static Bitmap decode(File file) { + return decodeFile(file.getPath()); + } + + public static Bitmap decodeFile(String path) { + return delegateDecode(sFileDecoder, path, NO_SCALING, NO_SCALING); + } + + public static Bitmap decodeByteArray(byte[] data) { + return delegateDecode(sByteArrayDecoder, data, NO_SCALING, NO_SCALING); + } + + public static void put(Bitmap bitmap) { + GalleryBitmapPool.getInstance().put(bitmap); + } + + /** + * Decodes to a specific size. If the dimensions of the image don't match + * width x height, the resulting image will be in the proportions of the + * decoded image, but will be scaled to fill the dimensions. For example, if + * width and height are 10x10 and the image is 200x100, the resulting image + * will be scaled/sampled to 20x10. + */ + public static Bitmap decodeFile(String path, int width, int height) { + return delegateDecode(sFileDecoder, path, width, height); + } + + /** @see #decodeFile(String, int, int) */ + public static Bitmap decodeByteArray(byte[] data, int width, int height) { + return delegateDecode(sByteArrayDecoder, data, width, height); + } + + /** @see #decodeFile(String, int, int) */ + public static Bitmap decode(InputStream in, int width, int height) { + try { + if (!in.markSupported()) { + in = new BufferedInputStream(in); + } + return delegateDecode(sStreamDecoder, in, width, height); + } finally { + Utils.closeSilently(in); + } + } + + private static BitmapFactory.Options getOptions() { + BitmapFactory.Options opts = sOptions.acquire(); + if (opts == null) { + opts = new BitmapFactory.Options(); + opts.inMutable = true; + opts.inPreferredConfig = Config.ARGB_8888; + opts.inTempStorage = new byte[TEMP_STORAGE_SIZE_BYTES]; + } + opts.inSampleSize = 1; + opts.inDensity = 1; + opts.inTargetDensity = 1; + + return opts; + } + + // Sets the options to sample then scale the image so that the image's + // minimum dimension will match side. + private static void setScaling(BitmapFactory.Options options, int width, int height) { + float widthScale = ((float)options.outWidth)/ width; + float heightScale = ((float) options.outHeight)/height; + int side = (widthScale < heightScale) ? width : height; + options.inSampleSize = BitmapUtils.computeSampleSize(options.outWidth, options.outHeight, + side, BitmapUtils.UNCONSTRAINED); + int constraint; + if (options.outWidth < options.outHeight) { + // Width is the constraint. Scale so that width = side. + constraint = options.outWidth; + } else { + // Height is the constraint. Scale so that height = side. + constraint = options.outHeight; + } + options.inDensity = constraint / options.inSampleSize; + options.inTargetDensity = side; + } +} 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/GalleryBitmapPool.java b/src/com/android/photos/data/GalleryBitmapPool.java new file mode 100644 index 000000000..390a0d42f --- /dev/null +++ b/src/com/android/photos/data/GalleryBitmapPool.java @@ -0,0 +1,161 @@ +/* + * 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.graphics.Point; +import android.util.Pools.Pool; +import android.util.Pools.SynchronizedPool; + +import com.android.photos.data.SparseArrayBitmapPool.Node; + +/** + * Pool allowing the efficient reuse of bitmaps in order to avoid long + * garbage collection pauses. + */ +public class GalleryBitmapPool { + + private static final int CAPACITY_BYTES = 20971520; + + // We found that Gallery uses bitmaps that are either square (for example, + // tiles of large images or square thumbnails), match one of the common + // photo aspect ratios (4x3, 3x2, or 16x9), or, less commonly, are of some + // other aspect ratio. Taking advantage of this information, we use 3 + // SparseArrayBitmapPool instances to back the GalleryBitmapPool, which affords + // O(1) lookups for square bitmaps, and average-case - but *not* asymptotically - + // O(1) lookups for common photo aspect ratios and other miscellaneous aspect + // ratios. Beware of the pathological case where there are many bitmaps added + // to the pool with different non-square aspect ratios but the same width, as + // performance will degrade and the average case lookup will approach + // O(# of different aspect ratios). + private static final int POOL_INDEX_NONE = -1; + private static final int POOL_INDEX_SQUARE = 0; + private static final int POOL_INDEX_PHOTO = 1; + private static final int POOL_INDEX_MISC = 2; + + private static final Point[] COMMON_PHOTO_ASPECT_RATIOS = + { new Point(4, 3), new Point(3, 2), new Point(16, 9) }; + + private int mCapacityBytes; + private SparseArrayBitmapPool [] mPools; + private Pool<Node> mSharedNodePool = new SynchronizedPool<Node>(128); + + private GalleryBitmapPool(int capacityBytes) { + mPools = new SparseArrayBitmapPool[3]; + mPools[POOL_INDEX_SQUARE] = new SparseArrayBitmapPool(capacityBytes / 3, mSharedNodePool); + mPools[POOL_INDEX_PHOTO] = new SparseArrayBitmapPool(capacityBytes / 3, mSharedNodePool); + mPools[POOL_INDEX_MISC] = new SparseArrayBitmapPool(capacityBytes / 3, mSharedNodePool); + mCapacityBytes = capacityBytes; + } + + private static GalleryBitmapPool sInstance = new GalleryBitmapPool(CAPACITY_BYTES); + + public static GalleryBitmapPool getInstance() { + return sInstance; + } + + private SparseArrayBitmapPool getPoolForDimensions(int width, int height) { + int index = getPoolIndexForDimensions(width, height); + if (index == POOL_INDEX_NONE) { + return null; + } else { + return mPools[index]; + } + } + + private int getPoolIndexForDimensions(int width, int height) { + if (width <= 0 || height <= 0) { + return POOL_INDEX_NONE; + } + if (width == height) { + return POOL_INDEX_SQUARE; + } + int min, max; + if (width > height) { + min = height; + max = width; + } else { + min = width; + max = height; + } + for (Point ar : COMMON_PHOTO_ASPECT_RATIOS) { + if (min * ar.x == max * ar.y) { + return POOL_INDEX_PHOTO; + } + } + return POOL_INDEX_MISC; + } + + /** + * @return Capacity of the pool in bytes. + */ + public synchronized int getCapacity() { + return mCapacityBytes; + } + + /** + * @return Approximate total size in bytes of the bitmaps stored in the pool. + */ + public int getSize() { + // Note that this only returns an approximate size, since multiple threads + // might be getting and putting Bitmaps from the pool and we lock at the + // sub-pool level to avoid unnecessary blocking. + int total = 0; + for (SparseArrayBitmapPool p : mPools) { + total += p.getSize(); + } + return total; + } + + /** + * @return Bitmap from the pool with the desired height/width or null if none available. + */ + public Bitmap get(int width, int height) { + SparseArrayBitmapPool pool = getPoolForDimensions(width, height); + if (pool == null) { + return null; + } else { + return pool.get(width, height); + } + } + + /** + * Adds the given bitmap to the pool. + * @return Whether the bitmap was added to the pool. + */ + public boolean put(Bitmap b) { + if (b == null || b.getConfig() != Bitmap.Config.ARGB_8888) { + return false; + } + SparseArrayBitmapPool pool = getPoolForDimensions(b.getWidth(), b.getHeight()); + if (pool == null) { + b.recycle(); + return false; + } else { + return pool.put(b); + } + } + + /** + * Empty the pool, recycling all the bitmaps currently in it. + */ + public void clear() { + for (SparseArrayBitmapPool p : mPools) { + p.clear(); + } + } +} diff --git a/src/com/android/photos/data/MediaCache.java b/src/com/android/photos/data/MediaCache.java new file mode 100644 index 000000000..0952a4017 --- /dev/null +++ b/src/com/android/photos/data/MediaCache.java @@ -0,0 +1,676 @@ +/* + * 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); + } +} diff --git a/src/com/android/photos/data/MediaCacheDatabase.java b/src/com/android/photos/data/MediaCacheDatabase.java new file mode 100644 index 000000000..c92ac0fdf --- /dev/null +++ b/src/com/android/photos/data/MediaCacheDatabase.java @@ -0,0 +1,286 @@ +/* + * 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, 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 = MediaSize.fromInteger(sizeVal); + } + public long id; + public 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, 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 MediaSize executeOnBestCached(Uri uri, 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); + MediaSize bestSize = null; + if (cursor.moveToNext()) { + long id = cursor.getLong(0); + bestSize = 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, 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, MediaSize size, Action action) { + String where = Columns.URI + " = ? AND " + Columns.MEDIA_SIZE + " = ?"; + String[] whereArgs = { + uri.toString(), String.valueOf(size.getValue()), + }; + deleteRows(uri, where, whereArgs, action); + } + + public void delete(Uri uri, Action action) { + String where = Columns.URI + " = ?"; + String[] whereArgs = { + uri.toString() + }; + deleteRows(uri, where, whereArgs, action); + } + + private void deleteRows(Uri uri, String where, String[] whereArgs, Action action) { + SQLiteDatabase db = getWritableDatabase(); + // Make this an atomic operation + db.beginTransaction(); + Cursor cursor = db.query(TABLE, PROJECTION_CACHED, where, whereArgs, null, null, null); + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + MediaSize size = MediaSize.fromInteger(cursor.getInt(1)); + long length = cursor.getLong(2); + action.execute(uri, id, size, length); + } + cursor.close(); + 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..e3ccd1402 --- /dev/null +++ b/src/com/android/photos/data/MediaCacheUtils.java @@ -0,0 +1,167 @@ +/* + * 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 android.util.Pools.SimplePool; +import android.util.Pools.SynchronizedPool; + +import com.android.gallery3d.R; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.common.Utils; +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; +import java.io.InputStream; +import java.io.OutputStream; + +public class MediaCacheUtils { + private static final String TAG = MediaCacheUtils.class.getSimpleName(); + private static int QUALITY = 80; + private static final int BUFFER_SIZE = 4096; + private static final SimplePool<byte[]> mBufferPool = new SynchronizedPool<byte[]>(5); + + 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; + } + + public static int copyStream(InputStream in, OutputStream out) throws IOException { + byte[] buffer = mBufferPool.acquire(); + if (buffer == null) { + buffer = new byte[BUFFER_SIZE]; + } + try { + int totalWritten = 0; + int bytesRead; + while ((bytesRead = in.read(buffer)) >= 0) { + out.write(buffer, 0, bytesRead); + totalWritten += bytesRead; + } + return totalWritten; + } finally { + Utils.closeSilently(in); + Utils.closeSilently(out); + mBufferPool.release(buffer); + } + } +} 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 <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/src/com/android/photos/data/NotificationWatcher.java b/src/com/android/photos/data/NotificationWatcher.java new file mode 100644 index 000000000..9041c236f --- /dev/null +++ b/src/com/android/photos/data/NotificationWatcher.java @@ -0,0 +1,55 @@ +/* + * 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 com.android.photos.data.PhotoProvider.ChangeNotification; + +import java.util.ArrayList; + +/** + * Used for capturing notifications from PhotoProvider without relying on + * ContentResolver. MockContentResolver does not allow sending notification to + * ContentObservers, so PhotoProvider allows this alternative for testing. + */ +public class NotificationWatcher implements ChangeNotification { + private ArrayList<Uri> mUris = new ArrayList<Uri>(); + private boolean mSyncToNetwork = false; + + @Override + public void notifyChange(Uri uri, boolean syncToNetwork) { + mUris.add(uri); + mSyncToNetwork = mSyncToNetwork || syncToNetwork; + } + + public boolean isNotified(Uri uri) { + return mUris.contains(uri); + } + + public int notificationCount() { + return mUris.size(); + } + + public boolean syncToNetwork() { + return mSyncToNetwork; + } + + public void reset() { + mUris.clear(); + mSyncToNetwork = false; + } +} diff --git a/src/com/android/photos/data/PhotoDatabase.java b/src/com/android/photos/data/PhotoDatabase.java new file mode 100644 index 000000000..0c7b22730 --- /dev/null +++ b/src/com/android/photos/data/PhotoDatabase.java @@ -0,0 +1,195 @@ +/* + * 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.database.sqlite.SQLiteOpenHelper; + +import com.android.photos.data.PhotoProvider.Accounts; +import com.android.photos.data.PhotoProvider.Albums; +import com.android.photos.data.PhotoProvider.Metadata; +import com.android.photos.data.PhotoProvider.Photos; + +import java.util.ArrayList; +import java.util.List; + +/** + * Used in PhotoProvider to create and access the database containing + * information about photo and video information stored on the server. + */ +public class PhotoDatabase extends SQLiteOpenHelper { + @SuppressWarnings("unused") + private static final String TAG = PhotoDatabase.class.getSimpleName(); + static final int DB_VERSION = 3; + + private static final String SQL_CREATE_TABLE = "CREATE TABLE "; + + private static final String[][] CREATE_PHOTO = { + { Photos._ID, "INTEGER PRIMARY KEY AUTOINCREMENT" }, + // Photos.ACCOUNT_ID is a foreign key to Accounts._ID + { Photos.ACCOUNT_ID, "INTEGER NOT NULL" }, + { Photos.WIDTH, "INTEGER NOT NULL" }, + { Photos.HEIGHT, "INTEGER NOT NULL" }, + { Photos.DATE_TAKEN, "INTEGER NOT NULL" }, + // Photos.ALBUM_ID is a foreign key to Albums._ID + { Photos.ALBUM_ID, "INTEGER" }, + { Photos.MIME_TYPE, "TEXT NOT NULL" }, + { Photos.TITLE, "TEXT" }, + { Photos.DATE_MODIFIED, "INTEGER" }, + { Photos.ROTATION, "INTEGER" }, + }; + + private static final String[][] CREATE_ALBUM = { + { Albums._ID, "INTEGER PRIMARY KEY AUTOINCREMENT" }, + // Albums.ACCOUNT_ID is a foreign key to Accounts._ID + { Albums.ACCOUNT_ID, "INTEGER NOT NULL" }, + // Albums.PARENT_ID is a foreign key to Albums._ID + { Albums.PARENT_ID, "INTEGER" }, + { Albums.ALBUM_TYPE, "TEXT" }, + { Albums.VISIBILITY, "INTEGER NOT NULL" }, + { Albums.LOCATION_STRING, "TEXT" }, + { Albums.TITLE, "TEXT NOT NULL" }, + { Albums.SUMMARY, "TEXT" }, + { Albums.DATE_PUBLISHED, "INTEGER" }, + { Albums.DATE_MODIFIED, "INTEGER" }, + createUniqueConstraint(Albums.PARENT_ID, Albums.TITLE), + }; + + private static final String[][] CREATE_METADATA = { + { Metadata._ID, "INTEGER PRIMARY KEY AUTOINCREMENT" }, + // Metadata.PHOTO_ID is a foreign key to Photos._ID + { Metadata.PHOTO_ID, "INTEGER NOT NULL" }, + { Metadata.KEY, "TEXT NOT NULL" }, + { Metadata.VALUE, "TEXT NOT NULL" }, + createUniqueConstraint(Metadata.PHOTO_ID, Metadata.KEY), + }; + + private static final String[][] CREATE_ACCOUNT = { + { Accounts._ID, "INTEGER PRIMARY KEY AUTOINCREMENT" }, + { Accounts.ACCOUNT_NAME, "TEXT UNIQUE NOT NULL" }, + }; + + @Override + public void onCreate(SQLiteDatabase db) { + createTable(db, Accounts.TABLE, getAccountTableDefinition()); + createTable(db, Albums.TABLE, getAlbumTableDefinition()); + createTable(db, Photos.TABLE, getPhotoTableDefinition()); + createTable(db, Metadata.TABLE, getMetadataTableDefinition()); + } + + public PhotoDatabase(Context context, String dbName, int dbVersion) { + super(context, dbName, null, dbVersion); + } + + public PhotoDatabase(Context context, String dbName) { + super(context, dbName, null, DB_VERSION); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + recreate(db); + } + + @Override + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + recreate(db); + } + + private void recreate(SQLiteDatabase db) { + dropTable(db, Metadata.TABLE); + dropTable(db, Photos.TABLE); + dropTable(db, Albums.TABLE); + dropTable(db, Accounts.TABLE); + onCreate(db); + } + + protected List<String[]> getAlbumTableDefinition() { + return tableCreationStrings(CREATE_ALBUM); + } + + protected List<String[]> getPhotoTableDefinition() { + return tableCreationStrings(CREATE_PHOTO); + } + + protected List<String[]> getMetadataTableDefinition() { + return tableCreationStrings(CREATE_METADATA); + } + + protected List<String[]> getAccountTableDefinition() { + return tableCreationStrings(CREATE_ACCOUNT); + } + + protected static void createTable(SQLiteDatabase db, String table, List<String[]> columns) { + StringBuilder create = new StringBuilder(SQL_CREATE_TABLE); + create.append(table).append('('); + boolean first = true; + for (String[] column : columns) { + if (!first) { + create.append(','); + } + first = false; + for (String val: column) { + create.append(val).append(' '); + } + } + create.append(')'); + db.beginTransaction(); + try { + db.execSQL(create.toString()); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + protected static String[] createUniqueConstraint(String column1, String column2) { + return new String[] { + "UNIQUE(", column1, ",", column2, ")" + }; + } + + protected static List<String[]> tableCreationStrings(String[][] createTable) { + ArrayList<String[]> create = new ArrayList<String[]>(createTable.length); + for (String[] line: createTable) { + create.add(line); + } + return create; + } + + protected static void addToTable(List<String[]> createTable, String[][] columns, String[][] constraints) { + if (columns != null) { + for (String[] column: columns) { + createTable.add(0, column); + } + } + if (constraints != null) { + for (String[] constraint: constraints) { + createTable.add(constraint); + } + } + } + + protected static void dropTable(SQLiteDatabase db, String table) { + db.beginTransaction(); + try { + db.execSQL("drop table if exists " + table); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } +} diff --git a/src/com/android/photos/data/PhotoProvider.java b/src/com/android/photos/data/PhotoProvider.java new file mode 100644 index 000000000..d4310ca95 --- /dev/null +++ b/src/com/android/photos/data/PhotoProvider.java @@ -0,0 +1,536 @@ +/* + * 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.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteQueryBuilder; +import android.media.ExifInterface; +import android.net.Uri; +import android.os.CancellationSignal; +import android.provider.BaseColumns; + +import com.android.gallery3d.common.ApiHelper; + +import java.util.List; + +/** + * A provider that gives access to photo and video information for media stored + * on the server. Only media that is or will be put on the server will be + * accessed by this provider. Use Photos.CONTENT_URI to query all photos and + * videos. Use Albums.CONTENT_URI to query all albums. Use Metadata.CONTENT_URI + * to query metadata about a photo or video, based on the ID of the media. Use + * ImageCache.THUMBNAIL_CONTENT_URI, ImageCache.PREVIEW_CONTENT_URI, or + * ImageCache.ORIGINAL_CONTENT_URI to query the path of the thumbnail, preview, + * or original-sized image respectfully. <br/> + * To add or update metadata, use the update function rather than insert. All + * values for the metadata must be in the ContentValues, even if they are also + * in the selection. The selection and selectionArgs are not used when updating + * metadata. If the metadata values are null, the row will be deleted. + */ +public class PhotoProvider extends SQLiteContentProvider { + @SuppressWarnings("unused") + private static final String TAG = PhotoProvider.class.getSimpleName(); + + protected static final String DB_NAME = "photo.db"; + public static final String AUTHORITY = PhotoProviderAuthority.AUTHORITY; + static final Uri BASE_CONTENT_URI = new Uri.Builder().scheme("content").authority(AUTHORITY) + .build(); + + // Used to allow mocking out the change notification because + // MockContextResolver disallows system-wide notification. + public static interface ChangeNotification { + void notifyChange(Uri uri, boolean syncToNetwork); + } + + /** + * Contains columns that can be accessed via Accounts.CONTENT_URI + */ + public static interface Accounts extends BaseColumns { + /** + * Internal database table used for account information + */ + public static final String TABLE = "accounts"; + /** + * Content URI for account information + */ + public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE); + /** + * User name for this account. + */ + public static final String ACCOUNT_NAME = "name"; + } + + /** + * Contains columns that can be accessed via Photos.CONTENT_URI. + */ + public static interface Photos extends BaseColumns { + /** + * The image_type query parameter required for requesting a specific + * size of image. + */ + public static final String MEDIA_SIZE_QUERY_PARAMETER = "media_size"; + + /** Internal database table used for basic photo information. */ + public static final String TABLE = "photos"; + /** Content URI for basic photo and video information. */ + public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE); + + /** Long foreign key to Accounts._ID */ + public static final String ACCOUNT_ID = "account_id"; + /** Column name for the width of the original image. Integer value. */ + public static final String WIDTH = "width"; + /** Column name for the height of the original image. Integer value. */ + public static final String HEIGHT = "height"; + /** + * Column name for the date that the original image was taken. Long + * value indicating the milliseconds since epoch in the GMT time zone. + */ + public static final String DATE_TAKEN = "date_taken"; + /** + * Column name indicating the long value of the album id that this image + * resides in. Will be NULL if it it has not been uploaded to the + * server. + */ + public static final String ALBUM_ID = "album_id"; + /** The column name for the mime-type String. */ + public static final String MIME_TYPE = "mime_type"; + /** The title of the photo. String value. */ + public static final String TITLE = "title"; + /** The date the photo entry was last updated. Long value. */ + public static final String DATE_MODIFIED = "date_modified"; + /** + * The rotation of the photo in degrees, if rotation has not already + * been applied. Integer value. + */ + public static final String ROTATION = "rotation"; + } + + /** + * Contains columns and Uri for accessing album information. + */ + public static interface Albums extends BaseColumns { + /** Internal database table used album information. */ + public static final String TABLE = "albums"; + /** Content URI for album information. */ + public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE); + + /** Long foreign key to Accounts._ID */ + public static final String ACCOUNT_ID = "account_id"; + /** Parent directory or null if this is in the root. */ + public static final String PARENT_ID = "parent_id"; + /** The type of album. Non-null, if album is auto-generated. String value. */ + public static final String ALBUM_TYPE = "album_type"; + /** + * Column name for the visibility level of the album. Can be any of the + * VISIBILITY_* values. + */ + public static final String VISIBILITY = "visibility"; + /** The user-specified location associated with the album. String value. */ + public static final String LOCATION_STRING = "location_string"; + /** The title of the album. String value. */ + public static final String TITLE = "title"; + /** A short summary of the contents of the album. String value. */ + public static final String SUMMARY = "summary"; + /** The date the album was created. Long value */ + public static final String DATE_PUBLISHED = "date_published"; + /** The date the album entry was last updated. Long value. */ + public static final String DATE_MODIFIED = "date_modified"; + + // Privacy values for Albums.VISIBILITY + public static final int VISIBILITY_PRIVATE = 1; + public static final int VISIBILITY_SHARED = 2; + public static final int VISIBILITY_PUBLIC = 3; + } + + /** + * Contains columns and Uri for accessing photo and video metadata + */ + public static interface Metadata extends BaseColumns { + /** Internal database table used metadata information. */ + public static final String TABLE = "metadata"; + /** Content URI for photo and video metadata. */ + public static final Uri CONTENT_URI = Uri.withAppendedPath(BASE_CONTENT_URI, TABLE); + /** Foreign key to photo_id. Long value. */ + public static final String PHOTO_ID = "photo_id"; + /** Metadata key. String value */ + public static final String KEY = "key"; + /** + * Metadata value. Type is based on key. + */ + public static final String VALUE = "value"; + + /** A short summary of the photo. String value. */ + public static final String KEY_SUMMARY = "summary"; + /** The date the photo was added. Long value. */ + public static final String KEY_PUBLISHED = "date_published"; + /** The date the photo was last updated. Long value. */ + public static final String KEY_DATE_UPDATED = "date_updated"; + /** The size of the photo is bytes. Integer value. */ + public static final String KEY_SIZE_IN_BTYES = "size"; + /** The latitude associated with the photo. Double value. */ + public static final String KEY_LATITUDE = "latitude"; + /** The longitude associated with the photo. Double value. */ + public static final String KEY_LONGITUDE = "longitude"; + + /** The make of the camera used. String value. */ + public static final String KEY_EXIF_MAKE = ExifInterface.TAG_MAKE; + /** The model of the camera used. String value. */ + public static final String KEY_EXIF_MODEL = ExifInterface.TAG_MODEL;; + /** The exposure time used. Float value. */ + public static final String KEY_EXIF_EXPOSURE = ExifInterface.TAG_EXPOSURE_TIME; + /** Whether the flash was used. Boolean value. */ + public static final String KEY_EXIF_FLASH = ExifInterface.TAG_FLASH; + /** The focal length used. Float value. */ + public static final String KEY_EXIF_FOCAL_LENGTH = ExifInterface.TAG_FOCAL_LENGTH; + /** The fstop value used. Float value. */ + public static final String KEY_EXIF_FSTOP = ExifInterface.TAG_APERTURE; + /** The ISO equivalent value used. Integer value. */ + public static final String KEY_EXIF_ISO = ExifInterface.TAG_ISO; + } + + // SQL used within this class. + protected static final String WHERE_ID = BaseColumns._ID + " = ?"; + protected static final String WHERE_METADATA_ID = Metadata.PHOTO_ID + " = ? AND " + + Metadata.KEY + " = ?"; + + protected static final String SELECT_ALBUM_ID = "SELECT " + Albums._ID + " FROM " + + Albums.TABLE; + protected static final String SELECT_PHOTO_ID = "SELECT " + Photos._ID + " FROM " + + Photos.TABLE; + protected static final String SELECT_PHOTO_COUNT = "SELECT COUNT(*) FROM " + Photos.TABLE; + protected static final String DELETE_PHOTOS = "DELETE FROM " + Photos.TABLE; + protected static final String DELETE_METADATA = "DELETE FROM " + Metadata.TABLE; + protected static final String SELECT_METADATA_COUNT = "SELECT COUNT(*) FROM " + Metadata.TABLE; + protected static final String WHERE = " WHERE "; + protected static final String IN = " IN "; + protected static final String NESTED_SELECT_START = "("; + protected static final String NESTED_SELECT_END = ")"; + protected static final String[] PROJECTION_COUNT = { + "COUNT(*)" + }; + + /** + * For selecting the mime-type for an image. + */ + private static final String[] PROJECTION_MIME_TYPE = { + Photos.MIME_TYPE, + }; + + protected static final String[] BASE_COLUMNS_ID = { + BaseColumns._ID, + }; + + protected ChangeNotification mNotifier = null; + protected static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + + protected static final int MATCH_PHOTO = 1; + protected static final int MATCH_PHOTO_ID = 2; + protected static final int MATCH_ALBUM = 3; + protected static final int MATCH_ALBUM_ID = 4; + protected static final int MATCH_METADATA = 5; + protected static final int MATCH_METADATA_ID = 6; + protected static final int MATCH_ACCOUNT = 7; + protected static final int MATCH_ACCOUNT_ID = 8; + + static { + sUriMatcher.addURI(AUTHORITY, Photos.TABLE, MATCH_PHOTO); + // match against Photos._ID + sUriMatcher.addURI(AUTHORITY, Photos.TABLE + "/#", MATCH_PHOTO_ID); + sUriMatcher.addURI(AUTHORITY, Albums.TABLE, MATCH_ALBUM); + // match against Albums._ID + sUriMatcher.addURI(AUTHORITY, Albums.TABLE + "/#", MATCH_ALBUM_ID); + sUriMatcher.addURI(AUTHORITY, Metadata.TABLE, MATCH_METADATA); + // match against metadata/<Metadata._ID> + sUriMatcher.addURI(AUTHORITY, Metadata.TABLE + "/#", MATCH_METADATA_ID); + sUriMatcher.addURI(AUTHORITY, Accounts.TABLE, MATCH_ACCOUNT); + // match against Accounts._ID + sUriMatcher.addURI(AUTHORITY, Accounts.TABLE + "/#", MATCH_ACCOUNT_ID); + } + + @Override + public int deleteInTransaction(Uri uri, String selection, String[] selectionArgs, + boolean callerIsSyncAdapter) { + int match = matchUri(uri); + selection = addIdToSelection(match, selection); + selectionArgs = addIdToSelectionArgs(match, uri, selectionArgs); + return deleteCascade(uri, match, selection, selectionArgs); + } + + @Override + public String getType(Uri uri) { + Cursor cursor = query(uri, PROJECTION_MIME_TYPE, null, null, null); + String mimeType = null; + if (cursor.moveToNext()) { + mimeType = cursor.getString(0); + } + cursor.close(); + return mimeType; + } + + @Override + public Uri insertInTransaction(Uri uri, ContentValues values, boolean callerIsSyncAdapter) { + int match = matchUri(uri); + validateMatchTable(match); + String table = getTableFromMatch(match, uri); + SQLiteDatabase db = getDatabaseHelper().getWritableDatabase(); + Uri insertedUri = null; + long id = db.insert(table, null, values); + if (id != -1) { + // uri already matches the table. + insertedUri = ContentUris.withAppendedId(uri, id); + postNotifyUri(insertedUri); + } + return insertedUri; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + return query(uri, projection, selection, selectionArgs, sortOrder, null); + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder, CancellationSignal cancellationSignal) { + projection = replaceCount(projection); + int match = matchUri(uri); + selection = addIdToSelection(match, selection); + selectionArgs = addIdToSelectionArgs(match, uri, selectionArgs); + String table = getTableFromMatch(match, uri); + Cursor c = query(table, projection, selection, selectionArgs, sortOrder, cancellationSignal); + if (c != null) { + c.setNotificationUri(getContext().getContentResolver(), uri); + } + return c; + } + + @Override + public int updateInTransaction(Uri uri, ContentValues values, String selection, + String[] selectionArgs, boolean callerIsSyncAdapter) { + int match = matchUri(uri); + int rowsUpdated = 0; + SQLiteDatabase db = getDatabaseHelper().getWritableDatabase(); + if (match == MATCH_METADATA) { + rowsUpdated = modifyMetadata(db, values); + } else { + selection = addIdToSelection(match, selection); + selectionArgs = addIdToSelectionArgs(match, uri, selectionArgs); + String table = getTableFromMatch(match, uri); + rowsUpdated = db.update(table, values, selection, selectionArgs); + } + postNotifyUri(uri); + return rowsUpdated; + } + + public void setMockNotification(ChangeNotification notification) { + mNotifier = notification; + } + + protected static String addIdToSelection(int match, String selection) { + String where; + switch (match) { + case MATCH_PHOTO_ID: + case MATCH_ALBUM_ID: + case MATCH_METADATA_ID: + where = WHERE_ID; + break; + default: + return selection; + } + return DatabaseUtils.concatenateWhere(selection, where); + } + + protected static String[] addIdToSelectionArgs(int match, Uri uri, String[] selectionArgs) { + String[] whereArgs; + switch (match) { + case MATCH_PHOTO_ID: + case MATCH_ALBUM_ID: + case MATCH_METADATA_ID: + whereArgs = new String[] { + uri.getPathSegments().get(1), + }; + break; + default: + return selectionArgs; + } + return DatabaseUtils.appendSelectionArgs(selectionArgs, whereArgs); + } + + protected static String[] addMetadataKeysToSelectionArgs(String[] selectionArgs, Uri uri) { + List<String> segments = uri.getPathSegments(); + String[] additionalArgs = { + segments.get(1), + segments.get(2), + }; + + return DatabaseUtils.appendSelectionArgs(selectionArgs, additionalArgs); + } + + protected static String getTableFromMatch(int match, Uri uri) { + String table; + switch (match) { + case MATCH_PHOTO: + case MATCH_PHOTO_ID: + table = Photos.TABLE; + break; + case MATCH_ALBUM: + case MATCH_ALBUM_ID: + table = Albums.TABLE; + break; + case MATCH_METADATA: + case MATCH_METADATA_ID: + table = Metadata.TABLE; + break; + case MATCH_ACCOUNT: + case MATCH_ACCOUNT_ID: + table = Accounts.TABLE; + break; + default: + throw unknownUri(uri); + } + return table; + } + + @Override + public SQLiteOpenHelper getDatabaseHelper(Context context) { + return new PhotoDatabase(context, DB_NAME); + } + + private int modifyMetadata(SQLiteDatabase db, ContentValues values) { + int rowCount; + if (values.get(Metadata.VALUE) == null) { + String[] selectionArgs = { + values.getAsString(Metadata.PHOTO_ID), values.getAsString(Metadata.KEY), + }; + rowCount = db.delete(Metadata.TABLE, WHERE_METADATA_ID, selectionArgs); + } else { + long rowId = db.replace(Metadata.TABLE, null, values); + rowCount = (rowId == -1) ? 0 : 1; + } + return rowCount; + } + + private int matchUri(Uri uri) { + int match = sUriMatcher.match(uri); + if (match == UriMatcher.NO_MATCH) { + throw unknownUri(uri); + } + return match; + } + + @Override + protected void notifyChange(ContentResolver resolver, Uri uri, boolean syncToNetwork) { + if (mNotifier != null) { + mNotifier.notifyChange(uri, syncToNetwork); + } else { + super.notifyChange(resolver, uri, syncToNetwork); + } + } + + protected static IllegalArgumentException unknownUri(Uri uri) { + return new IllegalArgumentException("Unknown Uri format: " + uri); + } + + protected static String nestWhere(String matchColumn, String table, String nestedWhere) { + String query = SQLiteQueryBuilder.buildQueryString(false, table, BASE_COLUMNS_ID, + nestedWhere, null, null, null, null); + return matchColumn + IN + NESTED_SELECT_START + query + NESTED_SELECT_END; + } + + protected static String metadataSelectionFromPhotos(String where) { + return nestWhere(Metadata.PHOTO_ID, Photos.TABLE, where); + } + + protected static String photoSelectionFromAlbums(String where) { + return nestWhere(Photos.ALBUM_ID, Albums.TABLE, where); + } + + protected static String photoSelectionFromAccounts(String where) { + return nestWhere(Photos.ACCOUNT_ID, Accounts.TABLE, where); + } + + protected static String albumSelectionFromAccounts(String where) { + return nestWhere(Albums.ACCOUNT_ID, Accounts.TABLE, where); + } + + protected int deleteCascade(Uri uri, int match, String selection, String[] selectionArgs) { + switch (match) { + case MATCH_PHOTO: + case MATCH_PHOTO_ID: + deleteCascade(Metadata.CONTENT_URI, MATCH_METADATA, + metadataSelectionFromPhotos(selection), selectionArgs); + break; + case MATCH_ALBUM: + case MATCH_ALBUM_ID: + deleteCascade(Photos.CONTENT_URI, MATCH_PHOTO, + photoSelectionFromAlbums(selection), selectionArgs); + break; + case MATCH_ACCOUNT: + case MATCH_ACCOUNT_ID: + deleteCascade(Photos.CONTENT_URI, MATCH_PHOTO, + photoSelectionFromAccounts(selection), selectionArgs); + deleteCascade(Albums.CONTENT_URI, MATCH_ALBUM, + albumSelectionFromAccounts(selection), selectionArgs); + break; + } + SQLiteDatabase db = getDatabaseHelper().getWritableDatabase(); + String table = getTableFromMatch(match, uri); + int deleted = db.delete(table, selection, selectionArgs); + if (deleted > 0) { + postNotifyUri(uri); + } + return deleted; + } + + private static void validateMatchTable(int match) { + switch (match) { + case MATCH_PHOTO: + case MATCH_ALBUM: + case MATCH_METADATA: + case MATCH_ACCOUNT: + break; + default: + throw new IllegalArgumentException("Operation not allowed on an existing row."); + } + } + + protected Cursor query(String table, String[] columns, String selection, + String[] selectionArgs, String orderBy, CancellationSignal cancellationSignal) { + SQLiteDatabase db = getDatabaseHelper().getReadableDatabase(); + if (ApiHelper.HAS_CANCELLATION_SIGNAL) { + return db.query(false, table, columns, selection, selectionArgs, null, null, + orderBy, null, cancellationSignal); + } else { + return db.query(table, columns, selection, selectionArgs, null, null, orderBy); + } + } + + protected static String[] replaceCount(String[] projection) { + if (projection != null && projection.length == 1 + && BaseColumns._COUNT.equals(projection[0])) { + return PROJECTION_COUNT; + } + return projection; + } +} diff --git a/src/com/android/photos/data/PhotoSetLoader.java b/src/com/android/photos/data/PhotoSetLoader.java new file mode 100644 index 000000000..56c82c4a9 --- /dev/null +++ b/src/com/android/photos/data/PhotoSetLoader.java @@ -0,0 +1,115 @@ +/* + * 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.CursorLoader; +import android.database.ContentObserver; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.provider.MediaStore; +import android.provider.MediaStore.Files; +import android.provider.MediaStore.Files.FileColumns; + +import com.android.photos.drawables.DataUriThumbnailDrawable; +import com.android.photos.shims.LoaderCompatShim; + +import java.util.ArrayList; + +public class PhotoSetLoader extends CursorLoader implements LoaderCompatShim<Cursor> { + + public static final String SUPPORTED_OPERATIONS = "supported_operations"; + + private static final Uri CONTENT_URI = Files.getContentUri("external"); + public static final String[] PROJECTION = new String[] { + FileColumns._ID, + FileColumns.DATA, + FileColumns.WIDTH, + FileColumns.HEIGHT, + FileColumns.DATE_ADDED, + FileColumns.MEDIA_TYPE, + SUPPORTED_OPERATIONS, + }; + + private static final String SORT_ORDER = FileColumns.DATE_ADDED + " DESC"; + private static final String SELECTION = + FileColumns.MEDIA_TYPE + " == " + FileColumns.MEDIA_TYPE_IMAGE + + " OR " + + FileColumns.MEDIA_TYPE + " == " + FileColumns.MEDIA_TYPE_VIDEO; + + public static final int INDEX_ID = 0; + public static final int INDEX_DATA = 1; + public static final int INDEX_WIDTH = 2; + public static final int INDEX_HEIGHT = 3; + public static final int INDEX_DATE_ADDED = 4; + public static final int INDEX_MEDIA_TYPE = 5; + public static final int INDEX_SUPPORTED_OPERATIONS = 6; + + private static final Uri GLOBAL_CONTENT_URI = Uri.parse("content://" + MediaStore.AUTHORITY + "/external/"); + private final ContentObserver mGlobalObserver = new ForceLoadContentObserver(); + + public PhotoSetLoader(Context context) { + super(context, CONTENT_URI, PROJECTION, SELECTION, null, SORT_ORDER); + } + + @Override + protected void onStartLoading() { + super.onStartLoading(); + getContext().getContentResolver().registerContentObserver(GLOBAL_CONTENT_URI, + true, mGlobalObserver); + } + + @Override + protected void onReset() { + super.onReset(); + getContext().getContentResolver().unregisterContentObserver(mGlobalObserver); + } + + @Override + public Drawable drawableForItem(Cursor item, Drawable recycle) { + DataUriThumbnailDrawable drawable = null; + if (recycle == null || !(recycle instanceof DataUriThumbnailDrawable)) { + drawable = new DataUriThumbnailDrawable(); + } else { + drawable = (DataUriThumbnailDrawable) recycle; + } + drawable.setImage(item.getString(INDEX_DATA), + item.getInt(INDEX_WIDTH), item.getInt(INDEX_HEIGHT)); + return drawable; + } + + @Override + public Uri uriForItem(Cursor item) { + return null; + } + + @Override + public ArrayList<Uri> urisForSubItems(Cursor item) { + return null; + } + + @Override + public void deleteItemWithPath(Object path) { + + } + + @Override + public Object getPathForItem(Cursor item) { + return null; + } +} diff --git a/src/com/android/photos/data/SQLiteContentProvider.java b/src/com/android/photos/data/SQLiteContentProvider.java new file mode 100644 index 000000000..daffa6e79 --- /dev/null +++ b/src/com/android/photos/data/SQLiteContentProvider.java @@ -0,0 +1,265 @@ +/* + * 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.ContentProvider; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +/** + * General purpose {@link ContentProvider} base class that uses SQLiteDatabase + * for storage. + */ +public abstract class SQLiteContentProvider extends ContentProvider { + + @SuppressWarnings("unused") + private static final String TAG = "SQLiteContentProvider"; + + private SQLiteOpenHelper mOpenHelper; + private Set<Uri> mChangedUris; + + private final ThreadLocal<Boolean> mApplyingBatch = new ThreadLocal<Boolean>(); + private static final int SLEEP_AFTER_YIELD_DELAY = 4000; + + /** + * Maximum number of operations allowed in a batch between yield points. + */ + private static final int MAX_OPERATIONS_PER_YIELD_POINT = 500; + + @Override + public boolean onCreate() { + Context context = getContext(); + mOpenHelper = getDatabaseHelper(context); + mChangedUris = new HashSet<Uri>(); + return true; + } + + @Override + public void shutdown() { + getDatabaseHelper().close(); + } + + /** + * Returns a {@link SQLiteOpenHelper} that can open the database. + */ + public abstract SQLiteOpenHelper getDatabaseHelper(Context context); + + /** + * The equivalent of the {@link #insert} method, but invoked within a + * transaction. + */ + public abstract Uri insertInTransaction(Uri uri, ContentValues values, + boolean callerIsSyncAdapter); + + /** + * The equivalent of the {@link #update} method, but invoked within a + * transaction. + */ + public abstract int updateInTransaction(Uri uri, ContentValues values, String selection, + String[] selectionArgs, boolean callerIsSyncAdapter); + + /** + * The equivalent of the {@link #delete} method, but invoked within a + * transaction. + */ + public abstract int deleteInTransaction(Uri uri, String selection, String[] selectionArgs, + boolean callerIsSyncAdapter); + + /** + * Call this to add a URI to the list of URIs to be notified when the + * transaction is committed. + */ + protected void postNotifyUri(Uri uri) { + synchronized (mChangedUris) { + mChangedUris.add(uri); + } + } + + public boolean isCallerSyncAdapter(Uri uri) { + return false; + } + + public SQLiteOpenHelper getDatabaseHelper() { + return mOpenHelper; + } + + private boolean applyingBatch() { + return mApplyingBatch.get() != null && mApplyingBatch.get(); + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + Uri result = null; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + if (!applyingBatch) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { + result = insertInTransaction(uri, values, callerIsSyncAdapter); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + onEndTransaction(callerIsSyncAdapter); + } else { + result = insertInTransaction(uri, values, callerIsSyncAdapter); + } + return result; + } + + @Override + public int bulkInsert(Uri uri, ContentValues[] values) { + int numValues = values.length; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { + for (int i = 0; i < numValues; i++) { + @SuppressWarnings("unused") + Uri result = insertInTransaction(uri, values[i], callerIsSyncAdapter); + db.yieldIfContendedSafely(); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + onEndTransaction(callerIsSyncAdapter); + return numValues; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + int count = 0; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + if (!applyingBatch) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { + count = updateInTransaction(uri, values, selection, selectionArgs, + callerIsSyncAdapter); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + onEndTransaction(callerIsSyncAdapter); + } else { + count = updateInTransaction(uri, values, selection, selectionArgs, callerIsSyncAdapter); + } + + return count; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + int count = 0; + boolean callerIsSyncAdapter = isCallerSyncAdapter(uri); + boolean applyingBatch = applyingBatch(); + if (!applyingBatch) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { + count = deleteInTransaction(uri, selection, selectionArgs, callerIsSyncAdapter); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + onEndTransaction(callerIsSyncAdapter); + } else { + count = deleteInTransaction(uri, selection, selectionArgs, callerIsSyncAdapter); + } + return count; + } + + @Override + public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations) + throws OperationApplicationException { + int ypCount = 0; + int opCount = 0; + boolean callerIsSyncAdapter = false; + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.beginTransaction(); + try { + mApplyingBatch.set(true); + final int numOperations = operations.size(); + final ContentProviderResult[] results = new ContentProviderResult[numOperations]; + for (int i = 0; i < numOperations; i++) { + if (++opCount >= MAX_OPERATIONS_PER_YIELD_POINT) { + throw new OperationApplicationException( + "Too many content provider operations between yield points. " + + "The maximum number of operations per yield point is " + + MAX_OPERATIONS_PER_YIELD_POINT, ypCount); + } + final ContentProviderOperation operation = operations.get(i); + if (!callerIsSyncAdapter && isCallerSyncAdapter(operation.getUri())) { + callerIsSyncAdapter = true; + } + if (i > 0 && operation.isYieldAllowed()) { + opCount = 0; + if (db.yieldIfContendedSafely(SLEEP_AFTER_YIELD_DELAY)) { + ypCount++; + } + } + results[i] = operation.apply(this, results, i); + } + db.setTransactionSuccessful(); + return results; + } finally { + mApplyingBatch.set(false); + db.endTransaction(); + onEndTransaction(callerIsSyncAdapter); + } + } + + protected Set<Uri> onEndTransaction(boolean callerIsSyncAdapter) { + Set<Uri> changed; + synchronized (mChangedUris) { + changed = new HashSet<Uri>(mChangedUris); + mChangedUris.clear(); + } + ContentResolver resolver = getContext().getContentResolver(); + for (Uri uri : changed) { + boolean syncToNetwork = !callerIsSyncAdapter && syncToNetwork(uri); + notifyChange(resolver, uri, syncToNetwork); + } + return changed; + } + + protected void notifyChange(ContentResolver resolver, Uri uri, boolean syncToNetwork) { + resolver.notifyChange(uri, null, syncToNetwork); + } + + protected boolean syncToNetwork(Uri uri) { + return false; + } +}
\ No newline at end of file diff --git a/src/com/android/photos/data/SparseArrayBitmapPool.java b/src/com/android/photos/data/SparseArrayBitmapPool.java new file mode 100644 index 000000000..95e10267b --- /dev/null +++ b/src/com/android/photos/data/SparseArrayBitmapPool.java @@ -0,0 +1,212 @@ +/* + * 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.util.SparseArray; + +import android.util.Pools.Pool; +import android.util.Pools.SimplePool; + +/** + * Bitmap pool backed by a sparse array indexing linked lists of bitmaps + * sharing the same width. Performance will degrade if using this to store + * many bitmaps with the same width but many different heights. + */ +public class SparseArrayBitmapPool { + + private int mCapacityBytes; + private SparseArray<Node> mStore = new SparseArray<Node>(); + private int mSizeBytes = 0; + + private Pool<Node> mNodePool; + private Node mPoolNodesHead = null; + private Node mPoolNodesTail = null; + + protected static class Node { + Bitmap bitmap; + + // Each node is part of two doubly linked lists: + // - A pool-level list (accessed by mPoolNodesHead and mPoolNodesTail) + // that is used for FIFO eviction of nodes when the pool gets full. + // - A bucket-level list for each index of the sparse array, so that + // each index can store more than one item. + Node prevInBucket; + Node nextInBucket; + Node nextInPool; + Node prevInPool; + } + + /** + * @param capacityBytes Maximum capacity of the pool in bytes. + * @param nodePool Shared pool to use for recycling linked list nodes, or null. + */ + public SparseArrayBitmapPool(int capacityBytes, Pool<Node> nodePool) { + mCapacityBytes = capacityBytes; + if (nodePool == null) { + mNodePool = new SimplePool<Node>(32); + } else { + mNodePool = nodePool; + } + } + + /** + * Set the maximum capacity of the pool, and if necessary trim it down to size. + */ + public synchronized void setCapacity(int capacityBytes) { + mCapacityBytes = capacityBytes; + + // No-op unless current size exceeds the new capacity. + freeUpCapacity(0); + } + + private void freeUpCapacity(int bytesNeeded) { + int targetSize = mCapacityBytes - bytesNeeded; + // Repeatedly remove the oldest node until we have freed up at least bytesNeeded. + while (mPoolNodesTail != null && mSizeBytes > targetSize) { + unlinkAndRecycleNode(mPoolNodesTail, true); + } + } + + private void unlinkAndRecycleNode(Node n, boolean recycleBitmap) { + // Unlink the node from its sparse array bucket list. + if (n.prevInBucket != null) { + // This wasn't the head, update the previous node. + n.prevInBucket.nextInBucket = n.nextInBucket; + } else { + // This was the head of the bucket, replace it with the next node. + mStore.put(n.bitmap.getWidth(), n.nextInBucket); + } + if (n.nextInBucket != null) { + // This wasn't the tail, update the next node. + n.nextInBucket.prevInBucket = n.prevInBucket; + } + + // Unlink the node from the pool-wide list. + if (n.prevInPool != null) { + // This wasn't the head, update the previous node. + n.prevInPool.nextInPool = n.nextInPool; + } else { + // This was the head of the pool-wide list, update the head pointer. + mPoolNodesHead = n.nextInPool; + } + if (n.nextInPool != null) { + // This wasn't the tail, update the next node. + n.nextInPool.prevInPool = n.prevInPool; + } else { + // This was the tail, update the tail pointer. + mPoolNodesTail = n.prevInPool; + } + + // Recycle the node. + n.nextInBucket = null; + n.nextInPool = null; + n.prevInBucket = null; + n.prevInPool = null; + mSizeBytes -= n.bitmap.getByteCount(); + if (recycleBitmap) n.bitmap.recycle(); + n.bitmap = null; + mNodePool.release(n); + } + + /** + * @return Capacity of the pool in bytes. + */ + public synchronized int getCapacity() { + return mCapacityBytes; + } + + /** + * @return Total size in bytes of the bitmaps stored in the pool. + */ + public synchronized int getSize() { + return mSizeBytes; + } + + /** + * @return Bitmap from the pool with the desired height/width or null if none available. + */ + public synchronized Bitmap get(int width, int height) { + Node cur = mStore.get(width); + + // Traverse the list corresponding to the width bucket in the + // sparse array, and unlink and return the first bitmap that + // also has the correct height. + while (cur != null) { + if (cur.bitmap.getHeight() == height) { + Bitmap b = cur.bitmap; + unlinkAndRecycleNode(cur, false); + return b; + } + cur = cur.nextInBucket; + } + return null; + } + + /** + * Adds the given bitmap to the pool. + * @return Whether the bitmap was added to the pool. + */ + public synchronized boolean put(Bitmap b) { + if (b == null) { + return false; + } + + // Ensure there is enough room to contain the new bitmap. + int bytes = b.getByteCount(); + freeUpCapacity(bytes); + + Node newNode = mNodePool.acquire(); + if (newNode == null) { + newNode = new Node(); + } + newNode.bitmap = b; + + // We append to the head, and freeUpCapacity clears from the tail, + // resulting in FIFO eviction. + newNode.prevInBucket = null; + newNode.prevInPool = null; + newNode.nextInPool = mPoolNodesHead; + mPoolNodesHead = newNode; + + // Insert the node into its appropriate bucket based on width. + int key = b.getWidth(); + newNode.nextInBucket = mStore.get(key); + if (newNode.nextInBucket != null) { + // The bucket already had nodes, update the old head. + newNode.nextInBucket.prevInBucket = newNode; + } + mStore.put(key, newNode); + + if (newNode.nextInPool == null) { + // This is the only node in the list, update the tail pointer. + mPoolNodesTail = newNode; + } else { + newNode.nextInPool.prevInPool = newNode; + } + mSizeBytes += bytes; + return true; + } + + /** + * Empty the pool, recycling all the bitmaps currently in it. + */ + public synchronized void clear() { + // Clearing is equivalent to ensuring all the capacity is available. + freeUpCapacity(mCapacityBytes); + } +} |