/* * Copyright (C) 2009 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.camera.gallery; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Environment; import android.os.Handler; import android.os.ParcelFileDescriptor; import android.provider.BaseColumns; import android.provider.MediaStore.Images; import android.provider.MediaStore.MediaColumns; import android.provider.MediaStore.Images.ImageColumns; import android.provider.MediaStore.Images.Thumbnails; import android.util.Log; import com.android.camera.ExifInterface; import com.android.camera.ImageManager; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.OutputStream; import java.io.RandomAccessFile; import java.util.ArrayList; import java.util.HashMap; /** * A collection of BaseImages. */ public abstract class BaseImageList implements IImageList { private static final boolean VERBOSE = false; private static final String TAG = "BaseImageList"; private static final int MINI_THUMB_TARGET_SIZE = 96; private static final int THUMBNAIL_TARGET_SIZE = 320; private static final int MINI_THUMB_DATA_FILE_VERSION = 3; private static final String WHERE_CLAUSE = "(" + Images.Media.MIME_TYPE + " in (?, ?, ?))"; static final String[] IMAGE_PROJECTION = new String[] { "_id", "_data", ImageColumns.DATE_TAKEN, ImageColumns.MINI_THUMB_MAGIC, ImageColumns.ORIENTATION, ImageColumns.MIME_TYPE}; static final String[] THUMB_PROJECTION = new String[] { BaseColumns._ID, // 0 Images.Thumbnails.IMAGE_ID, // 1 Images.Thumbnails.WIDTH, Images.Thumbnails.HEIGHT}; static final int INDEX_ID = Util.indexOf(IMAGE_PROJECTION, "_id"); static final int INDEX_DATA = Util.indexOf(IMAGE_PROJECTION, "_data"); static final int INDEX_MIME_TYPE = Util.indexOf(IMAGE_PROJECTION, MediaColumns.MIME_TYPE); static final int INDEX_DATE_TAKEN = Util.indexOf(IMAGE_PROJECTION, ImageColumns.DATE_TAKEN); static final int INDEX_MINI_THUMB_MAGIC = Util.indexOf(IMAGE_PROJECTION, ImageColumns.MINI_THUMB_MAGIC); static final int INDEX_ORIENTATION = Util.indexOf(IMAGE_PROJECTION, ImageColumns.ORIENTATION); static final int INDEX_THUMB_ID = Util.indexOf(THUMB_PROJECTION, BaseColumns._ID); static final int INDEX_THUMB_IMAGE_ID = Util.indexOf(THUMB_PROJECTION, Images.Thumbnails.IMAGE_ID); static final int INDEX_THUMB_WIDTH = Util.indexOf(THUMB_PROJECTION, Images.Thumbnails.WIDTH); static final int INDEX_THUMB_HEIGHT = Util.indexOf(THUMB_PROJECTION, Images.Thumbnails.HEIGHT); protected static final String[] ACCEPTABLE_IMAGE_TYPES = new String[] { "image/jpeg", "image/png", "image/gif" }; protected static final String MINITHUMB_IS_NULL = "mini_thumb_magic isnull"; protected ContentResolver mContentResolver; protected int mSort; protected Uri mBaseUri; protected Cursor mCursor; protected IImageList.OnChange mListener = null; protected boolean mCursorDeactivated; protected String mBucketId; protected Context mContext; protected Uri mUri; protected HashMap mCache = new HashMap(); protected RandomAccessFile mMiniThumbData; protected Uri mThumbUri; public BaseImageList(Context ctx, ContentResolver cr, Uri uri, int sort, String bucketId) { mContext = ctx; mSort = sort; mUri = uri; mBaseUri = uri; mBucketId = bucketId; mContentResolver = cr; } String randomAccessFilePath(int version) { String directoryName = Environment.getExternalStorageDirectory().toString() + "/DCIM/.thumbnails"; return directoryName + "/.thumbdata" + version + "-" + mUri.hashCode(); } RandomAccessFile miniThumbDataFile() { if (mMiniThumbData == null) { String path = randomAccessFilePath(MINI_THUMB_DATA_FILE_VERSION); File directory = new File(new File(path).getParent()); if (!directory.isDirectory()) { if (!directory.mkdirs()) { Log.e(TAG, "!!!! unable to create .thumbnails directory " + directory.toString()); } } File f = new File(path); if (VERBOSE) Log.v(TAG, "file f is " + f.toString()); try { mMiniThumbData = new RandomAccessFile(f, "rw"); } catch (IOException ex) { // ignore exception } } return mMiniThumbData; } /** * Store a given thumbnail in the database. */ protected Bitmap storeThumbnail(Bitmap thumb, long imageId) { if (thumb == null) return null; try { Uri uri = getThumbnailUri( imageId, thumb.getWidth(), thumb.getHeight()); if (uri == null) { return thumb; } OutputStream thumbOut = mContentResolver.openOutputStream(uri); thumb.compress(Bitmap.CompressFormat.JPEG, 60, thumbOut); thumbOut.close(); return thumb; } catch (Exception ex) { if (VERBOSE) Log.d(TAG, "unable to store thumbnail: " + ex); return thumb; } } /** * Store a JPEG thumbnail from the EXIF header in the database. */ protected boolean storeThumbnail( byte[] jpegThumbnail, long imageId, int width, int height) { if (jpegThumbnail == null) return false; Uri uri = getThumbnailUri(imageId, width, height); if (uri == null) { return false; } try { OutputStream thumbOut = mContentResolver.openOutputStream(uri); thumbOut.write(jpegThumbnail); thumbOut.close(); return true; } catch (FileNotFoundException ex) { return false; } catch (IOException ex) { return false; } } private Uri getThumbnailUri(long imageId, int width, int height) { // we do not store thumbnails for DRM'd images if (mThumbUri == null) { return null; } Uri uri = null; Cursor c = mContentResolver.query(mThumbUri, THUMB_PROJECTION, Thumbnails.IMAGE_ID + "=?", new String[]{String.valueOf(imageId)}, null); try { if (c.moveToFirst()) { // If, for some reaosn, we already have a row with a matching // image id, then just update that row rather than creating a // new row. uri = ContentUris.withAppendedId( mThumbUri, c.getLong(indexThumbId())); c.commitUpdates(); } } finally { c.close(); } if (uri == null) { ContentValues values = new ContentValues(4); values.put(Images.Thumbnails.KIND, Images.Thumbnails.MINI_KIND); values.put(Images.Thumbnails.IMAGE_ID, imageId); values.put(Images.Thumbnails.HEIGHT, height); values.put(Images.Thumbnails.WIDTH, width); uri = mContentResolver.insert(mThumbUri, values); } return uri; } private static final java.util.Random sRandom = new java.util.Random(System.currentTimeMillis()); protected SomewhatFairLock mLock = new SomewhatFairLock(); private static class SomewhatFairLock { private boolean mLocked = false; private ArrayList mWaiting = new ArrayList(); public synchronized void lock() { while (mLocked) { try { mWaiting.add(Thread.currentThread()); wait(); if (mWaiting.get(0) == Thread.currentThread()) { mWaiting.remove(0); break; } } catch (InterruptedException ex) { throw new RuntimeException(ex); } } mLocked = true; } public synchronized void unlock() { mLocked = false; notifyAll(); } } // If the photo has an EXIF thumbnail and it's big enough, extract it and // save that JPEG as the large thumbnail without re-encoding it. We still // have to decompress it though, in order to generate the minithumb. private Bitmap createThumbnailFromEXIF(String filePath, long id) { if (filePath == null) return null; byte [] thumbData = null; synchronized (ImageManager.instance()) { thumbData = (new ExifInterface(filePath)).getThumbnail(); } if (thumbData == null) return null; // Sniff the size of the EXIF thumbnail before decoding it. Photos // from the device will pass, but images that are side loaded from // other cameras may not. BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeByteArray(thumbData, 0, thumbData.length, options); int width = options.outWidth; int height = options.outHeight; if (width >= THUMBNAIL_TARGET_SIZE && height >= THUMBNAIL_TARGET_SIZE && storeThumbnail(thumbData, id, width, height)) { // this is used for *encoding* the minithumb, so // we don't want to dither or convert to 565 here. // // Decode with a scaling factor // to match MINI_THUMB_TARGET_SIZE closely // which will produce much better scaling quality // and is significantly faster. options.inSampleSize = Util.computeSampleSize(options, THUMBNAIL_TARGET_SIZE); if (VERBOSE) { Log.v(TAG, "in createThumbnailFromExif using inSampleSize of " + options.inSampleSize); } options.inDither = false; options.inPreferredConfig = Bitmap.Config.ARGB_8888; options.inJustDecodeBounds = false; return BitmapFactory.decodeByteArray( thumbData, 0, thumbData.length, options); } return null; } // The fallback case is to decode the original photo to thumbnail size, // then encode it as a JPEG. We return the thumbnail Bitmap in order to // create the minithumb from it. private Bitmap createThumbnailFromUri(Cursor c, long id) { Uri uri = ContentUris.withAppendedId(mBaseUri, id); Bitmap bitmap = makeBitmap(THUMBNAIL_TARGET_SIZE, uri, null, null); if (bitmap != null) { storeThumbnail(bitmap, id); } else { uri = ContentUris.withAppendedId(mBaseUri, id); bitmap = makeBitmap(MINI_THUMB_TARGET_SIZE, uri, null, null); } return bitmap; } // returns id public long checkThumbnail(BaseImage existingImage, Cursor c, int i) throws IOException { return checkThumbnail(existingImage, c, i, null); } /** * Checks to see if a mini thumbnail exists in the cache. If not, tries to * create it and add it to the cache. * @param existingImage * @param c * @param i * @param createdThumbnailData if this parameter is non-null, and a new * mini-thumbnail bitmap is created, the new bitmap's data will be * stored in createdThumbnailData[0]. Note that if the sdcard is * full, it's possible that createdThumbnailData[0] will be set * even if the method throws an IOException. This is actually * useful, because it allows the caller to use the created * thumbnail even if the sdcard is full. * @throws IOException */ public long checkThumbnail(BaseImage existingImage, Cursor c, int i, byte[][] createdThumbnailData) throws IOException { long magic, fileMagic = 0, id; mLock.lock(); try { if (existingImage == null) { // if we don't have an Image object then get the id and magic // from the cursor. Synchronize on the cursor object. synchronized (c) { if (!c.moveToPosition(i)) { return -1; } magic = c.getLong(indexMiniThumbId()); id = c.getLong(indexId()); } } else { // if we have an Image object then ask them for the magic/id magic = existingImage.mMiniThumbMagic; id = existingImage.fullSizeImageId(); } if (magic != 0) { // check the mini thumb file for the right data. Right is // defined as having the right magic number at the offset // reserved for this "id". RandomAccessFile r = miniThumbDataFile(); if (r != null) { synchronized (r) { long pos = id * BaseImage.BYTES_PER_MINTHUMB; try { // check that we can read the following 9 bytes // (1 for the "status" and 8 for the long) if (r.length() >= pos + 1 + 8) { r.seek(pos); if (r.readByte() == 1) { fileMagic = r.readLong(); if (fileMagic == magic && magic != 0 && magic != id) { return magic; } } } } catch (IOException ex) { Log.v(TAG, "got exception checking file magic: " + ex); } } } if (VERBOSE) { Log.v(TAG, "didn't verify... fileMagic: " + fileMagic + "; magic: " + magic + "; id: " + id + "; "); } } // If we can't retrieve the thumbnail, first check if there is one // embedded in the EXIF data. If not, or it's not big enough, // decompress the full size image. Bitmap bitmap = null; String filePath = null; synchronized (c) { if (c.moveToPosition(i)) { filePath = c.getString(indexData()); } } if (filePath != null) { String mimeType = c.getString(indexMimeType()); boolean isVideo = Util.isVideoMimeType(mimeType); if (isVideo) { bitmap = Util.createVideoThumbnail(filePath); } else { bitmap = createThumbnailFromEXIF(filePath, id); if (bitmap == null) { bitmap = createThumbnailFromUri(c, id); } } synchronized (c) { int degrees = 0; if (c.moveToPosition(i)) { int column = indexOrientation(); if (column >= 0) degrees = c.getInt(column); } if (degrees != 0) { bitmap = Util.rotate(bitmap, degrees); } } } // make a new magic number since things are out of sync do { magic = sRandom.nextLong(); } while (magic == 0); if (bitmap != null) { byte [] data = Util.miniThumbData(bitmap); if (createdThumbnailData != null) { createdThumbnailData[0] = data; } saveMiniThumbToFile(data, id, magic); } synchronized (c) { c.moveToPosition(i); c.updateLong(indexMiniThumbId(), magic); c.commitUpdates(); c.requery(); c.moveToPosition(i); if (existingImage != null) { existingImage.mMiniThumbMagic = magic; } return magic; } } finally { mLock.unlock(); } } public void checkThumbnails(ThumbCheckCallback cb, int totalThumbnails) { Cursor c = Images.Media.query(mContentResolver, mBaseUri, new String[] { "_id", "mini_thumb_magic" }, thumbnailWhereClause(), thumbnailWhereClauseArgs(), "_id ASC"); int count = c.getCount(); if (VERBOSE) { Log.v(TAG, ">>>>>>>>>>> need to check " + c.getCount() + " rows"); } c.close(); if (!ImageManager.hasStorage()) { if (VERBOSE) { Log.v(TAG, "bailing from the image checker thread " + "-- no storage"); } return; } String oldPath = randomAccessFilePath(MINI_THUMB_DATA_FILE_VERSION - 1); File oldFile = new File(oldPath); if (count == 0) { // now check that we have the right thumbs file if (!oldFile.exists()) { return; } } c = getCursor(); try { if (VERBOSE) Log.v(TAG, "checkThumbnails found " + c.getCount()); int current = 0; for (int i = 0; i < c.getCount(); i++) { try { checkThumbnail(null, c, i); } catch (IOException ex) { Log.e(TAG, "!!!!! failed to check thumbnail..." + " was the sd card removed? - " + ex.getMessage()); break; } if (cb != null) { if (!cb.checking(current, totalThumbnails)) { if (VERBOSE) { Log.v(TAG, "got false from checking... break"); } break; } } current += 1; } } finally { if (VERBOSE) { Log.v(TAG, "checkThumbnails existing after reaching count " + c.getCount()); } try { oldFile.delete(); } catch (SecurityException ex) { // ignore } } } protected String thumbnailWhereClause() { return MINITHUMB_IS_NULL + " and " + WHERE_CLAUSE; } protected String[] thumbnailWhereClauseArgs() { return ACCEPTABLE_IMAGE_TYPES; } public void commitChanges() { synchronized (mCursor) { mCursor.commitUpdates(); requery(); } } protected Uri contentUri(long id) { try { // does our uri already have an id (single image query)? // if so just return it long existingId = ContentUris.parseId(mBaseUri); if (existingId != id) Log.e(TAG, "id mismatch"); return mBaseUri; } catch (NumberFormatException ex) { // otherwise tack on the id return ContentUris.withAppendedId(mBaseUri, id); } } public void deactivate() { mCursorDeactivated = true; try { mCursor.deactivate(); } catch (IllegalStateException e) { // IllegalStateException may be thrown if the cursor is stale. Log.e(TAG, "Caught exception while deactivating cursor.", e); } if (mMiniThumbData != null) { try { mMiniThumbData.close(); mMiniThumbData = null; } catch (IOException ex) { // ignore exception } } } public void dump(String msg) { int count = getCount(); if (VERBOSE) { Log.v(TAG, "dump ImageList (count is " + count + ") " + msg); } for (int i = 0; i < count; i++) { IImage img = getImageAt(i); if (VERBOSE) Log.v(TAG, " " + i + ": " + img); } if (VERBOSE) Log.v(TAG, "end of dump container"); } public int getCount() { Cursor c = getCursor(); synchronized (c) { try { return c.getCount(); } catch (RuntimeException ex) { return 0; } } } public boolean isEmpty() { return getCount() == 0; } protected Cursor getCursor() { synchronized (mCursor) { if (mCursorDeactivated) { activateCursor(); } return mCursor; } } protected void activateCursor() { requery(); } public IImage getImageAt(int i) { Cursor c = getCursor(); synchronized (c) { boolean moved; try { moved = c.moveToPosition(i); } catch (RuntimeException ex) { return null; } if (moved) { try { long id = c.getLong(0); long miniThumbId = 0; int rotation = 0; if (indexMiniThumbId() != -1) { miniThumbId = c.getLong(indexMiniThumbId()); } if (indexOrientation() != -1) { rotation = c.getInt(indexOrientation()); } long timestamp = c.getLong(1); IImage img = mCache.get(id); if (img == null) { img = make(id, miniThumbId, mContentResolver, this, timestamp, i, rotation); mCache.put(id, img); } return img; } catch (RuntimeException ex) { Log.e(TAG, "got this exception trying to create image: " + ex); return null; } } else { Log.e(TAG, "unable to moveTo to " + i + "; count is " + c.getCount()); return null; } } } public IImage getImageForUri(Uri uri) { // TODO: make this a hash lookup for (int i = 0; i < getCount(); i++) { if (getImageAt(i).fullSizeImageUri().equals(uri)) { return getImageAt(i); } } return null; } byte [] getMiniThumbFromFile(long id, byte [] data, long magicCheck) { RandomAccessFile r = miniThumbDataFile(); if (r == null) return null; long pos = id * BaseImage.BYTES_PER_MINTHUMB; synchronized (r) { try { r.seek(pos); if (r.readByte() == 1) { long magic = r.readLong(); if (magic != magicCheck) { if (VERBOSE) { Log.v(TAG, "for id " + id + "; magic: " + magic + "; magicCheck: " + magicCheck + " (fail)"); } return null; } int length = r.readInt(); r.read(data, 0, length); return data; } else { return null; } } catch (IOException ex) { long fileLength; try { fileLength = r.length(); } catch (IOException ex1) { fileLength = -1; } if (VERBOSE) { Log.e(TAG, "couldn't read thumbnail for " + id + "; " + ex.toString() + "; pos is " + pos + "; length is " + fileLength); } return null; } } } protected int getRowFor(IImage imageObj) { Cursor c = getCursor(); synchronized (c) { int index = 0; long targetId = imageObj.fullSizeImageId(); if (c.moveToFirst()) { do { if (c.getLong(0) == targetId) { return index; } index += 1; } while (c.moveToNext()); } return -1; } } protected abstract int indexOrientation(); protected abstract int indexDateTaken(); protected abstract int indexDescription(); protected abstract int indexMimeType(); protected abstract int indexData(); protected abstract int indexId(); protected abstract int indexLatitude(); protected abstract int indexLongitude(); protected abstract int indexMiniThumbId(); protected abstract int indexPicasaWeb(); protected abstract int indexPrivate(); protected abstract int indexTitle(); protected abstract int indexDisplayName(); protected abstract int indexThumbId(); protected IImage make(long id, long miniThumbId, ContentResolver cr, IImageList list, long timestamp, int index, int rotation) { return null; } protected abstract Bitmap makeBitmap( int targetWidthHeight, Uri uri, ParcelFileDescriptor pfdInput, BitmapFactory.Options options); public boolean removeImage(IImage image) { Cursor c = getCursor(); synchronized (c) { /* * TODO: consider putting the image in a holding area so * we can get it back as needed * TODO: need to delete the thumbnails as well */ boolean moved; try { moved = c.moveToPosition(image.getRow()); } catch (RuntimeException ex) { Log.e(TAG, "removeImage got exception " + ex.toString()); return false; } if (moved) { Uri u = image.fullSizeImageUri(); mContentResolver.delete(u, null, null); image.onRemove(); requery(); } } return true; } public void removeImageAt(int i) { Cursor c = getCursor(); synchronized (c) { /* * TODO: consider putting the image in a holding area so * we can get it back as needed * TODO: need to delete the thumbnails as well */ dump("before delete"); IImage image = getImageAt(i); boolean moved; try { moved = c.moveToPosition(i); } catch (RuntimeException ex) { Log.e(TAG, "removeImageAt " + i + " get " + ex); return; } if (moved) { Uri u = image.fullSizeImageUri(); mContentResolver.delete(u, null, null); requery(); image.onRemove(); } dump("after delete"); } } public void removeOnChangeListener(OnChange changeCallback) { if (changeCallback == mListener) mListener = null; } protected void requery() { mCache.clear(); mCursor.requery(); mCursorDeactivated = false; } protected void saveMiniThumbToFile(Bitmap bitmap, long id, long magic) throws IOException { byte[] data = Util.miniThumbData(bitmap); saveMiniThumbToFile(data, id, magic); } protected void saveMiniThumbToFile(byte[] data, long id, long magic) throws IOException { RandomAccessFile r = miniThumbDataFile(); if (r == null) return; long pos = id * BaseImage.BYTES_PER_MINTHUMB; long t0 = System.currentTimeMillis(); synchronized (r) { try { long t1 = System.currentTimeMillis(); long t2 = System.currentTimeMillis(); if (data != null) { if (data.length > BaseImage.BYTES_PER_MINTHUMB) { if (VERBOSE) { Log.v(TAG, "warning: " + data.length + " > " + BaseImage.BYTES_PER_MINTHUMB); } return; } r.seek(pos); r.writeByte(0); // we have no data in this slot // if magic is 0 then leave it alone if (magic == 0) { r.skipBytes(8); } else { r.writeLong(magic); } r.writeInt(data.length); r.write(data); // f.flush(); r.seek(pos); r.writeByte(1); // we have data in this slot long t3 = System.currentTimeMillis(); if (VERBOSE) { Log.v(TAG, "saveMiniThumbToFile took " + (t3 - t0) + "; " + (t1 - t0) + " " + (t2 - t1) + " " + (t3 - t2)); } } } catch (IOException ex) { Log.e(TAG, "couldn't save mini thumbnail data for " + id + "; " + ex.toString()); throw ex; } } } public void setOnChangeListener(OnChange changeCallback, Handler h) { mListener = changeCallback; } }