diff options
Diffstat (limited to 'src/com/android/gallery3d/data')
59 files changed, 9074 insertions, 0 deletions
diff --git a/src/com/android/gallery3d/data/ActionImage.java b/src/com/android/gallery3d/data/ActionImage.java new file mode 100644 index 000000000..58e30b146 --- /dev/null +++ b/src/com/android/gallery3d/data/ActionImage.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.net.Uri; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +public class ActionImage extends MediaItem { + @SuppressWarnings("unused") + private static final String TAG = "ActionImage"; + private GalleryApp mApplication; + private int mResourceId; + + public ActionImage(Path path, GalleryApp application, int resourceId) { + super(path, nextVersionNumber()); + mApplication = Utils.checkNotNull(application); + mResourceId = resourceId; + } + + @Override + public Job<Bitmap> requestImage(int type) { + return new BitmapJob(type); + } + + @Override + public Job<BitmapRegionDecoder> requestLargeImage() { + return null; + } + + private class BitmapJob implements Job<Bitmap> { + private int mType; + + protected BitmapJob(int type) { + mType = type; + } + + @Override + public Bitmap run(JobContext jc) { + int targetSize = MediaItem.getTargetSize(mType); + Bitmap bitmap = BitmapFactory.decodeResource(mApplication.getResources(), + mResourceId); + + if (mType == MediaItem.TYPE_MICROTHUMBNAIL) { + bitmap = BitmapUtils.resizeAndCropCenter(bitmap, targetSize, true); + } else { + bitmap = BitmapUtils.resizeDownBySideLength(bitmap, targetSize, true); + } + return bitmap; + } + } + + @Override + public int getSupportedOperations() { + return SUPPORT_ACTION; + } + + @Override + public int getMediaType() { + return MEDIA_TYPE_UNKNOWN; + } + + @Override + public Uri getContentUri() { + return null; + } + + @Override + public String getMimeType() { + return ""; + } + + @Override + public int getWidth() { + return 0; + } + + @Override + public int getHeight() { + return 0; + } +} diff --git a/src/com/android/gallery3d/data/BucketHelper.java b/src/com/android/gallery3d/data/BucketHelper.java new file mode 100644 index 000000000..3418dafb7 --- /dev/null +++ b/src/com/android/gallery3d/data/BucketHelper.java @@ -0,0 +1,241 @@ +package com.android.gallery3d.data; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.database.Cursor; +import android.net.Uri; +import android.provider.MediaStore.Files; +import android.provider.MediaStore.Files.FileColumns; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.Images.ImageColumns; +import android.provider.MediaStore.Video; +import android.util.Log; + +import com.android.gallery3d.common.ApiHelper; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; + +class BucketHelper { + + private static final String TAG = "BucketHelper"; + private static final String EXTERNAL_MEDIA = "external"; + + // BUCKET_DISPLAY_NAME is a string like "Camera" which is the directory + // name of where an image or video is in. BUCKET_ID is a hash of the path + // name of that directory (see computeBucketValues() in MediaProvider for + // details). MEDIA_TYPE is video, image, audio, etc. + // + // The "albums" are not explicitly recorded in the database, but each image + // or video has the two columns (BUCKET_ID, MEDIA_TYPE). We define an + // "album" to be the collection of images/videos which have the same value + // for the two columns. + // + // The goal of the query (used in loadSubMediaSetsFromFilesTable()) is to + // find all albums, that is, all unique values for (BUCKET_ID, MEDIA_TYPE). + // In the meantime sort them by the timestamp of the latest image/video in + // each of the album. + // + // The order of columns below is important: it must match to the index in + // MediaStore. + private static final String[] PROJECTION_BUCKET = { + ImageColumns.BUCKET_ID, + FileColumns.MEDIA_TYPE, + ImageColumns.BUCKET_DISPLAY_NAME}; + + // The indices should match the above projections. + private static final int INDEX_BUCKET_ID = 0; + private static final int INDEX_MEDIA_TYPE = 1; + private static final int INDEX_BUCKET_NAME = 2; + + // We want to order the albums by reverse chronological order. We abuse the + // "WHERE" parameter to insert a "GROUP BY" clause into the SQL statement. + // The template for "WHERE" parameter is like: + // SELECT ... FROM ... WHERE (%s) + // and we make it look like: + // SELECT ... FROM ... WHERE (1) GROUP BY 1,(2) + // The "(1)" means true. The "1,(2)" means the first two columns specified + // after SELECT. Note that because there is a ")" in the template, we use + // "(2" to match it. + private static final String BUCKET_GROUP_BY = "1) GROUP BY 1,(2"; + + private static final String BUCKET_ORDER_BY = "MAX(datetaken) DESC"; + + // Before HoneyComb there is no Files table. Thus, we need to query the + // bucket info from the Images and Video tables and then merge them + // together. + // + // A bucket can exist in both tables. In this case, we need to find the + // latest timestamp from the two tables and sort ourselves. So we add the + // MAX(date_taken) to the projection and remove the media_type since we + // already know the media type from the table we query from. + private static final String[] PROJECTION_BUCKET_IN_ONE_TABLE = { + ImageColumns.BUCKET_ID, + "MAX(datetaken)", + ImageColumns.BUCKET_DISPLAY_NAME}; + + // We keep the INDEX_BUCKET_ID and INDEX_BUCKET_NAME the same as + // PROJECTION_BUCKET so we can reuse the values defined before. + private static final int INDEX_DATE_TAKEN = 1; + + // When query from the Images or Video tables, we only need to group by BUCKET_ID. + private static final String BUCKET_GROUP_BY_IN_ONE_TABLE = "1) GROUP BY (1"; + + public static BucketEntry[] loadBucketEntries( + JobContext jc, ContentResolver resolver, int type) { + if (ApiHelper.HAS_MEDIA_PROVIDER_FILES_TABLE) { + return loadBucketEntriesFromFilesTable(jc, resolver, type); + } else { + return loadBucketEntriesFromImagesAndVideoTable(jc, resolver, type); + } + } + + private static void updateBucketEntriesFromTable(JobContext jc, + ContentResolver resolver, Uri tableUri, HashMap<Integer, BucketEntry> buckets) { + Cursor cursor = resolver.query(tableUri, PROJECTION_BUCKET_IN_ONE_TABLE, + BUCKET_GROUP_BY_IN_ONE_TABLE, null, null); + if (cursor == null) { + Log.w(TAG, "cannot open media database: " + tableUri); + return; + } + try { + while (cursor.moveToNext()) { + int bucketId = cursor.getInt(INDEX_BUCKET_ID); + int dateTaken = cursor.getInt(INDEX_DATE_TAKEN); + BucketEntry entry = buckets.get(bucketId); + if (entry == null) { + entry = new BucketEntry(bucketId, cursor.getString(INDEX_BUCKET_NAME)); + buckets.put(bucketId, entry); + entry.dateTaken = dateTaken; + } else { + entry.dateTaken = Math.max(entry.dateTaken, dateTaken); + } + } + } finally { + Utils.closeSilently(cursor); + } + } + + private static BucketEntry[] loadBucketEntriesFromImagesAndVideoTable( + JobContext jc, ContentResolver resolver, int type) { + HashMap<Integer, BucketEntry> buckets = new HashMap<Integer, BucketEntry>(64); + if ((type & MediaObject.MEDIA_TYPE_IMAGE) != 0) { + updateBucketEntriesFromTable( + jc, resolver, Images.Media.EXTERNAL_CONTENT_URI, buckets); + } + if ((type & MediaObject.MEDIA_TYPE_VIDEO) != 0) { + updateBucketEntriesFromTable( + jc, resolver, Video.Media.EXTERNAL_CONTENT_URI, buckets); + } + BucketEntry[] entries = buckets.values().toArray(new BucketEntry[buckets.size()]); + Arrays.sort(entries, new Comparator<BucketEntry>() { + @Override + public int compare(BucketEntry a, BucketEntry b) { + // sorted by dateTaken in descending order + return b.dateTaken - a.dateTaken; + } + }); + return entries; + } + + private static BucketEntry[] loadBucketEntriesFromFilesTable( + JobContext jc, ContentResolver resolver, int type) { + Uri uri = getFilesContentUri(); + + Cursor cursor = resolver.query(uri, + PROJECTION_BUCKET, BUCKET_GROUP_BY, + null, BUCKET_ORDER_BY); + if (cursor == null) { + Log.w(TAG, "cannot open local database: " + uri); + return new BucketEntry[0]; + } + ArrayList<BucketEntry> buffer = new ArrayList<BucketEntry>(); + int typeBits = 0; + if ((type & MediaObject.MEDIA_TYPE_IMAGE) != 0) { + typeBits |= (1 << FileColumns.MEDIA_TYPE_IMAGE); + } + if ((type & MediaObject.MEDIA_TYPE_VIDEO) != 0) { + typeBits |= (1 << FileColumns.MEDIA_TYPE_VIDEO); + } + try { + while (cursor.moveToNext()) { + if ((typeBits & (1 << cursor.getInt(INDEX_MEDIA_TYPE))) != 0) { + BucketEntry entry = new BucketEntry( + cursor.getInt(INDEX_BUCKET_ID), + cursor.getString(INDEX_BUCKET_NAME)); + if (!buffer.contains(entry)) { + buffer.add(entry); + } + } + if (jc.isCancelled()) return null; + } + } finally { + Utils.closeSilently(cursor); + } + return buffer.toArray(new BucketEntry[buffer.size()]); + } + + private static String getBucketNameInTable( + ContentResolver resolver, Uri tableUri, int bucketId) { + String selectionArgs[] = new String[] {String.valueOf(bucketId)}; + Uri uri = tableUri.buildUpon() + .appendQueryParameter("limit", "1") + .build(); + Cursor cursor = resolver.query(uri, PROJECTION_BUCKET_IN_ONE_TABLE, + "bucket_id = ?", selectionArgs, null); + try { + if (cursor != null && cursor.moveToNext()) { + return cursor.getString(INDEX_BUCKET_NAME); + } + } finally { + Utils.closeSilently(cursor); + } + return null; + } + + @TargetApi(ApiHelper.VERSION_CODES.HONEYCOMB) + private static Uri getFilesContentUri() { + return Files.getContentUri(EXTERNAL_MEDIA); + } + + public static String getBucketName(ContentResolver resolver, int bucketId) { + if (ApiHelper.HAS_MEDIA_PROVIDER_FILES_TABLE) { + String result = getBucketNameInTable(resolver, getFilesContentUri(), bucketId); + return result == null ? "" : result; + } else { + String result = getBucketNameInTable( + resolver, Images.Media.EXTERNAL_CONTENT_URI, bucketId); + if (result != null) return result; + result = getBucketNameInTable( + resolver, Video.Media.EXTERNAL_CONTENT_URI, bucketId); + return result == null ? "" : result; + } + } + + public static class BucketEntry { + public String bucketName; + public int bucketId; + public int dateTaken; + + public BucketEntry(int id, String name) { + bucketId = id; + bucketName = Utils.ensureNotNull(name); + } + + @Override + public int hashCode() { + return bucketId; + } + + @Override + public boolean equals(Object object) { + if (!(object instanceof BucketEntry)) return false; + BucketEntry entry = (BucketEntry) object; + return bucketId == entry.bucketId; + } + } +} diff --git a/src/com/android/gallery3d/data/BytesBufferPool.java b/src/com/android/gallery3d/data/BytesBufferPool.java new file mode 100644 index 000000000..d2da323fc --- /dev/null +++ b/src/com/android/gallery3d/data/BytesBufferPool.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import com.android.gallery3d.util.ThreadPool.JobContext; + +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; + +public class BytesBufferPool { + + private static final int READ_STEP = 4096; + + public static class BytesBuffer { + public byte[] data; + public int offset; + public int length; + + private BytesBuffer(int capacity) { + this.data = new byte[capacity]; + } + + // an helper function to read content from FileDescriptor + public void readFrom(JobContext jc, FileDescriptor fd) throws IOException { + FileInputStream fis = new FileInputStream(fd); + length = 0; + try { + int capacity = data.length; + while (true) { + int step = Math.min(READ_STEP, capacity - length); + int rc = fis.read(data, length, step); + if (rc < 0 || jc.isCancelled()) return; + length += rc; + + if (length == capacity) { + byte[] newData = new byte[data.length * 2]; + System.arraycopy(data, 0, newData, 0, data.length); + data = newData; + capacity = data.length; + } + } + } finally { + fis.close(); + } + } + } + + private final int mPoolSize; + private final int mBufferSize; + private final ArrayList<BytesBuffer> mList; + + public BytesBufferPool(int poolSize, int bufferSize) { + mList = new ArrayList<BytesBuffer>(poolSize); + mPoolSize = poolSize; + mBufferSize = bufferSize; + } + + public synchronized BytesBuffer get() { + int n = mList.size(); + return n > 0 ? mList.remove(n - 1) : new BytesBuffer(mBufferSize); + } + + public synchronized void recycle(BytesBuffer buffer) { + if (buffer.data.length != mBufferSize) return; + if (mList.size() < mPoolSize) { + buffer.offset = 0; + buffer.length = 0; + mList.add(buffer); + } + } + + public synchronized void clear() { + mList.clear(); + } +} diff --git a/src/com/android/gallery3d/data/CameraShortcutImage.java b/src/com/android/gallery3d/data/CameraShortcutImage.java new file mode 100644 index 000000000..865270b4c --- /dev/null +++ b/src/com/android/gallery3d/data/CameraShortcutImage.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.GalleryApp; + +public class CameraShortcutImage extends ActionImage { + @SuppressWarnings("unused") + private static final String TAG = "CameraShortcutImage"; + + public CameraShortcutImage(Path path, GalleryApp application) { + super(path, application, R.drawable.placeholder_camera); + } + + @Override + public int getSupportedOperations() { + return super.getSupportedOperations() | SUPPORT_CAMERA_SHORTCUT; + } +} diff --git a/src/com/android/gallery3d/data/ChangeNotifier.java b/src/com/android/gallery3d/data/ChangeNotifier.java new file mode 100644 index 000000000..558a8648e --- /dev/null +++ b/src/com/android/gallery3d/data/ChangeNotifier.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.net.Uri; + +import com.android.gallery3d.app.GalleryApp; + +import java.util.concurrent.atomic.AtomicBoolean; + +// This handles change notification for media sets. +public class ChangeNotifier { + + private MediaSet mMediaSet; + private AtomicBoolean mContentDirty = new AtomicBoolean(true); + + public ChangeNotifier(MediaSet set, Uri uri, GalleryApp application) { + mMediaSet = set; + application.getDataManager().registerChangeNotifier(uri, this); + } + + public ChangeNotifier(MediaSet set, Uri[] uris, GalleryApp application) { + mMediaSet = set; + for (int i = 0; i < uris.length; i++) { + application.getDataManager().registerChangeNotifier(uris[i], this); + } + } + + // Returns the dirty flag and clear it. + public boolean isDirty() { + return mContentDirty.compareAndSet(true, false); + } + + public void fakeChange() { + onChange(false); + } + + protected void onChange(boolean selfChange) { + if (mContentDirty.compareAndSet(false, true)) { + mMediaSet.notifyContentChanged(); + } + } +}
\ No newline at end of file diff --git a/src/com/android/gallery3d/data/ClusterAlbum.java b/src/com/android/gallery3d/data/ClusterAlbum.java new file mode 100644 index 000000000..8681952bf --- /dev/null +++ b/src/com/android/gallery3d/data/ClusterAlbum.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import java.util.ArrayList; + +public class ClusterAlbum extends MediaSet implements ContentListener { + @SuppressWarnings("unused") + private static final String TAG = "ClusterAlbum"; + private ArrayList<Path> mPaths = new ArrayList<Path>(); + private String mName = ""; + private DataManager mDataManager; + private MediaSet mClusterAlbumSet; + private MediaItem mCover; + + public ClusterAlbum(Path path, DataManager dataManager, + MediaSet clusterAlbumSet) { + super(path, nextVersionNumber()); + mDataManager = dataManager; + mClusterAlbumSet = clusterAlbumSet; + mClusterAlbumSet.addContentListener(this); + } + + public void setCoverMediaItem(MediaItem cover) { + mCover = cover; + } + + @Override + public MediaItem getCoverMediaItem() { + return mCover != null ? mCover : super.getCoverMediaItem(); + } + + void setMediaItems(ArrayList<Path> paths) { + mPaths = paths; + } + + ArrayList<Path> getMediaItems() { + return mPaths; + } + + public void setName(String name) { + mName = name; + } + + @Override + public String getName() { + return mName; + } + + @Override + public int getMediaItemCount() { + return mPaths.size(); + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + return getMediaItemFromPath(mPaths, start, count, mDataManager); + } + + public static ArrayList<MediaItem> getMediaItemFromPath( + ArrayList<Path> paths, int start, int count, + DataManager dataManager) { + if (start >= paths.size()) { + return new ArrayList<MediaItem>(); + } + int end = Math.min(start + count, paths.size()); + ArrayList<Path> subset = new ArrayList<Path>(paths.subList(start, end)); + final MediaItem[] buf = new MediaItem[end - start]; + ItemConsumer consumer = new ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + buf[index] = item; + } + }; + dataManager.mapMediaItems(subset, consumer, 0); + ArrayList<MediaItem> result = new ArrayList<MediaItem>(end - start); + for (int i = 0; i < buf.length; i++) { + result.add(buf[i]); + } + return result; + } + + @Override + protected int enumerateMediaItems(ItemConsumer consumer, int startIndex) { + mDataManager.mapMediaItems(mPaths, consumer, startIndex); + return mPaths.size(); + } + + @Override + public int getTotalMediaItemCount() { + return mPaths.size(); + } + + @Override + public long reload() { + if (mClusterAlbumSet.reload() > mDataVersion) { + mDataVersion = nextVersionNumber(); + } + return mDataVersion; + } + + @Override + public void onContentDirty() { + notifyContentChanged(); + } + + @Override + public int getSupportedOperations() { + return SUPPORT_SHARE | SUPPORT_DELETE | SUPPORT_INFO; + } + + @Override + public void delete() { + ItemConsumer consumer = new ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + if ((item.getSupportedOperations() & SUPPORT_DELETE) != 0) { + item.delete(); + } + } + }; + mDataManager.mapMediaItems(mPaths, consumer, 0); + } + + @Override + public boolean isLeafAlbum() { + return true; + } +} diff --git a/src/com/android/gallery3d/data/ClusterAlbumSet.java b/src/com/android/gallery3d/data/ClusterAlbumSet.java new file mode 100644 index 000000000..cb212ba36 --- /dev/null +++ b/src/com/android/gallery3d/data/ClusterAlbumSet.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.Context; +import android.net.Uri; + +import com.android.gallery3d.app.GalleryApp; + +import java.util.ArrayList; +import java.util.HashSet; + +public class ClusterAlbumSet extends MediaSet implements ContentListener { + @SuppressWarnings("unused") + private static final String TAG = "ClusterAlbumSet"; + private GalleryApp mApplication; + private MediaSet mBaseSet; + private int mKind; + private ArrayList<ClusterAlbum> mAlbums = new ArrayList<ClusterAlbum>(); + private boolean mFirstReloadDone; + + public ClusterAlbumSet(Path path, GalleryApp application, + MediaSet baseSet, int kind) { + super(path, INVALID_DATA_VERSION); + mApplication = application; + mBaseSet = baseSet; + mKind = kind; + baseSet.addContentListener(this); + } + + @Override + public MediaSet getSubMediaSet(int index) { + return mAlbums.get(index); + } + + @Override + public int getSubMediaSetCount() { + return mAlbums.size(); + } + + @Override + public String getName() { + return mBaseSet.getName(); + } + + @Override + public long reload() { + if (mBaseSet.reload() > mDataVersion) { + if (mFirstReloadDone) { + updateClustersContents(); + } else { + updateClusters(); + mFirstReloadDone = true; + } + mDataVersion = nextVersionNumber(); + } + return mDataVersion; + } + + @Override + public void onContentDirty() { + notifyContentChanged(); + } + + private void updateClusters() { + mAlbums.clear(); + Clustering clustering; + Context context = mApplication.getAndroidContext(); + switch (mKind) { + case ClusterSource.CLUSTER_ALBUMSET_TIME: + clustering = new TimeClustering(context); + break; + case ClusterSource.CLUSTER_ALBUMSET_LOCATION: + clustering = new LocationClustering(context); + break; + case ClusterSource.CLUSTER_ALBUMSET_TAG: + clustering = new TagClustering(context); + break; + case ClusterSource.CLUSTER_ALBUMSET_FACE: + clustering = new FaceClustering(context); + break; + default: /* CLUSTER_ALBUMSET_SIZE */ + clustering = new SizeClustering(context); + break; + } + + clustering.run(mBaseSet); + int n = clustering.getNumberOfClusters(); + DataManager dataManager = mApplication.getDataManager(); + for (int i = 0; i < n; i++) { + Path childPath; + String childName = clustering.getClusterName(i); + if (mKind == ClusterSource.CLUSTER_ALBUMSET_TAG) { + childPath = mPath.getChild(Uri.encode(childName)); + } else if (mKind == ClusterSource.CLUSTER_ALBUMSET_SIZE) { + long minSize = ((SizeClustering) clustering).getMinSize(i); + childPath = mPath.getChild(minSize); + } else { + childPath = mPath.getChild(i); + } + + ClusterAlbum album; + synchronized (DataManager.LOCK) { + album = (ClusterAlbum) dataManager.peekMediaObject(childPath); + if (album == null) { + album = new ClusterAlbum(childPath, dataManager, this); + } + } + album.setMediaItems(clustering.getCluster(i)); + album.setName(childName); + album.setCoverMediaItem(clustering.getClusterCover(i)); + mAlbums.add(album); + } + } + + private void updateClustersContents() { + final HashSet<Path> existing = new HashSet<Path>(); + mBaseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + existing.add(item.getPath()); + } + }); + + int n = mAlbums.size(); + + // The loop goes backwards because we may remove empty albums from + // mAlbums. + for (int i = n - 1; i >= 0; i--) { + ArrayList<Path> oldPaths = mAlbums.get(i).getMediaItems(); + ArrayList<Path> newPaths = new ArrayList<Path>(); + int m = oldPaths.size(); + for (int j = 0; j < m; j++) { + Path p = oldPaths.get(j); + if (existing.contains(p)) { + newPaths.add(p); + } + } + mAlbums.get(i).setMediaItems(newPaths); + if (newPaths.isEmpty()) { + mAlbums.remove(i); + } + } + } +} diff --git a/src/com/android/gallery3d/data/ClusterSource.java b/src/com/android/gallery3d/data/ClusterSource.java new file mode 100644 index 000000000..a1f22e57a --- /dev/null +++ b/src/com/android/gallery3d/data/ClusterSource.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +class ClusterSource extends MediaSource { + static final int CLUSTER_ALBUMSET_TIME = 0; + static final int CLUSTER_ALBUMSET_LOCATION = 1; + static final int CLUSTER_ALBUMSET_TAG = 2; + static final int CLUSTER_ALBUMSET_SIZE = 3; + static final int CLUSTER_ALBUMSET_FACE = 4; + + static final int CLUSTER_ALBUM_TIME = 0x100; + static final int CLUSTER_ALBUM_LOCATION = 0x101; + static final int CLUSTER_ALBUM_TAG = 0x102; + static final int CLUSTER_ALBUM_SIZE = 0x103; + static final int CLUSTER_ALBUM_FACE = 0x104; + + GalleryApp mApplication; + PathMatcher mMatcher; + + public ClusterSource(GalleryApp application) { + super("cluster"); + mApplication = application; + mMatcher = new PathMatcher(); + mMatcher.add("/cluster/*/time", CLUSTER_ALBUMSET_TIME); + mMatcher.add("/cluster/*/location", CLUSTER_ALBUMSET_LOCATION); + mMatcher.add("/cluster/*/tag", CLUSTER_ALBUMSET_TAG); + mMatcher.add("/cluster/*/size", CLUSTER_ALBUMSET_SIZE); + mMatcher.add("/cluster/*/face", CLUSTER_ALBUMSET_FACE); + + mMatcher.add("/cluster/*/time/*", CLUSTER_ALBUM_TIME); + mMatcher.add("/cluster/*/location/*", CLUSTER_ALBUM_LOCATION); + mMatcher.add("/cluster/*/tag/*", CLUSTER_ALBUM_TAG); + mMatcher.add("/cluster/*/size/*", CLUSTER_ALBUM_SIZE); + mMatcher.add("/cluster/*/face/*", CLUSTER_ALBUM_FACE); + } + + // The names we accept are: + // /cluster/{set}/time /cluster/{set}/time/k + // /cluster/{set}/location /cluster/{set}/location/k + // /cluster/{set}/tag /cluster/{set}/tag/encoded_tag + // /cluster/{set}/size /cluster/{set}/size/min_size + @Override + public MediaObject createMediaObject(Path path) { + int matchType = mMatcher.match(path); + String setsName = mMatcher.getVar(0); + DataManager dataManager = mApplication.getDataManager(); + MediaSet[] sets = dataManager.getMediaSetsFromString(setsName); + switch (matchType) { + case CLUSTER_ALBUMSET_TIME: + case CLUSTER_ALBUMSET_LOCATION: + case CLUSTER_ALBUMSET_TAG: + case CLUSTER_ALBUMSET_SIZE: + case CLUSTER_ALBUMSET_FACE: + return new ClusterAlbumSet(path, mApplication, sets[0], matchType); + case CLUSTER_ALBUM_TIME: + case CLUSTER_ALBUM_LOCATION: + case CLUSTER_ALBUM_TAG: + case CLUSTER_ALBUM_SIZE: + case CLUSTER_ALBUM_FACE: { + MediaSet parent = dataManager.getMediaSet(path.getParent()); + // The actual content in the ClusterAlbum will be filled later + // when the reload() method in the parent is run. + return new ClusterAlbum(path, dataManager, parent); + } + default: + throw new RuntimeException("bad path: " + path); + } + } +} diff --git a/src/com/android/gallery3d/data/Clustering.java b/src/com/android/gallery3d/data/Clustering.java new file mode 100644 index 000000000..4072bf57b --- /dev/null +++ b/src/com/android/gallery3d/data/Clustering.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import java.util.ArrayList; + +public abstract class Clustering { + public abstract void run(MediaSet baseSet); + public abstract int getNumberOfClusters(); + public abstract ArrayList<Path> getCluster(int index); + public abstract String getClusterName(int index); + public MediaItem getClusterCover(int index) { + return null; + } +} diff --git a/src/com/android/gallery3d/data/ComboAlbum.java b/src/com/android/gallery3d/data/ComboAlbum.java new file mode 100644 index 000000000..cadd9f8af --- /dev/null +++ b/src/com/android/gallery3d/data/ComboAlbum.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2011 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.gallery3d.data; + +import com.android.gallery3d.util.Future; + +import java.util.ArrayList; + +// ComboAlbum combines multiple media sets into one. It lists all media items +// from the input albums. +// This only handles SubMediaSets, not MediaItems. (That's all we need now) +public class ComboAlbum extends MediaSet implements ContentListener { + @SuppressWarnings("unused") + private static final String TAG = "ComboAlbum"; + private final MediaSet[] mSets; + private String mName; + + public ComboAlbum(Path path, MediaSet[] mediaSets, String name) { + super(path, nextVersionNumber()); + mSets = mediaSets; + for (MediaSet set : mSets) { + set.addContentListener(this); + } + mName = name; + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + ArrayList<MediaItem> items = new ArrayList<MediaItem>(); + for (MediaSet set : mSets) { + int size = set.getMediaItemCount(); + if (count < 1) break; + if (start < size) { + int fetchCount = (start + count <= size) ? count : size - start; + ArrayList<MediaItem> fetchItems = set.getMediaItem(start, fetchCount); + items.addAll(fetchItems); + count -= fetchItems.size(); + start = 0; + } else { + start -= size; + } + } + return items; + } + + @Override + public int getMediaItemCount() { + int count = 0; + for (MediaSet set : mSets) { + count += set.getMediaItemCount(); + } + return count; + } + + @Override + public boolean isLeafAlbum() { + return true; + } + + @Override + public String getName() { + return mName; + } + + public void useNameOfChild(int i) { + if (i < mSets.length) mName = mSets[i].getName(); + } + + @Override + public long reload() { + boolean changed = false; + for (int i = 0, n = mSets.length; i < n; ++i) { + long version = mSets[i].reload(); + if (version > mDataVersion) changed = true; + } + if (changed) mDataVersion = nextVersionNumber(); + return mDataVersion; + } + + @Override + public void onContentDirty() { + notifyContentChanged(); + } + + @Override + public Future<Integer> requestSync(SyncListener listener) { + return requestSyncOnMultipleSets(mSets, listener); + } +} diff --git a/src/com/android/gallery3d/data/ComboAlbumSet.java b/src/com/android/gallery3d/data/ComboAlbumSet.java new file mode 100644 index 000000000..3f3674500 --- /dev/null +++ b/src/com/android/gallery3d/data/ComboAlbumSet.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.util.Future; + +// ComboAlbumSet combines multiple media sets into one. It lists all sub +// media sets from the input album sets. +// This only handles SubMediaSets, not MediaItems. (That's all we need now) +public class ComboAlbumSet extends MediaSet implements ContentListener { + @SuppressWarnings("unused") + private static final String TAG = "ComboAlbumSet"; + private final MediaSet[] mSets; + private final String mName; + + public ComboAlbumSet(Path path, GalleryApp application, MediaSet[] mediaSets) { + super(path, nextVersionNumber()); + mSets = mediaSets; + for (MediaSet set : mSets) { + set.addContentListener(this); + } + mName = application.getResources().getString( + R.string.set_label_all_albums); + } + + @Override + public MediaSet getSubMediaSet(int index) { + for (MediaSet set : mSets) { + int size = set.getSubMediaSetCount(); + if (index < size) { + return set.getSubMediaSet(index); + } + index -= size; + } + return null; + } + + @Override + public int getSubMediaSetCount() { + int count = 0; + for (MediaSet set : mSets) { + count += set.getSubMediaSetCount(); + } + return count; + } + + @Override + public String getName() { + return mName; + } + + @Override + public boolean isLoading() { + for (int i = 0, n = mSets.length; i < n; ++i) { + if (mSets[i].isLoading()) return true; + } + return false; + } + + @Override + public long reload() { + boolean changed = false; + for (int i = 0, n = mSets.length; i < n; ++i) { + long version = mSets[i].reload(); + if (version > mDataVersion) changed = true; + } + if (changed) mDataVersion = nextVersionNumber(); + return mDataVersion; + } + + @Override + public void onContentDirty() { + notifyContentChanged(); + } + + @Override + public Future<Integer> requestSync(SyncListener listener) { + return requestSyncOnMultipleSets(mSets, listener); + } +} diff --git a/src/com/android/gallery3d/data/ComboSource.java b/src/com/android/gallery3d/data/ComboSource.java new file mode 100644 index 000000000..867d47e64 --- /dev/null +++ b/src/com/android/gallery3d/data/ComboSource.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +class ComboSource extends MediaSource { + private static final int COMBO_ALBUMSET = 0; + private static final int COMBO_ALBUM = 1; + private GalleryApp mApplication; + private PathMatcher mMatcher; + + public ComboSource(GalleryApp application) { + super("combo"); + mApplication = application; + mMatcher = new PathMatcher(); + mMatcher.add("/combo/*", COMBO_ALBUMSET); + mMatcher.add("/combo/*/*", COMBO_ALBUM); + } + + // The only path we accept is "/combo/{set1, set2, ...} and /combo/item/{set1, set2, ...}" + @Override + public MediaObject createMediaObject(Path path) { + String[] segments = path.split(); + if (segments.length < 2) { + throw new RuntimeException("bad path: " + path); + } + + DataManager dataManager = mApplication.getDataManager(); + switch (mMatcher.match(path)) { + case COMBO_ALBUMSET: + return new ComboAlbumSet(path, mApplication, + dataManager.getMediaSetsFromString(segments[1])); + + case COMBO_ALBUM: + return new ComboAlbum(path, + dataManager.getMediaSetsFromString(segments[2]), segments[1]); + } + return null; + } +} diff --git a/src/com/android/gallery3d/data/ContentListener.java b/src/com/android/gallery3d/data/ContentListener.java new file mode 100644 index 000000000..5e2952685 --- /dev/null +++ b/src/com/android/gallery3d/data/ContentListener.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +public interface ContentListener { + public void onContentDirty(); +}
\ No newline at end of file diff --git a/src/com/android/gallery3d/data/DataManager.java b/src/com/android/gallery3d/data/DataManager.java new file mode 100644 index 000000000..38865e9f1 --- /dev/null +++ b/src/com/android/gallery3d/data/DataManager.java @@ -0,0 +1,371 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.Context; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.Handler; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.app.StitchingChangeListener; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.MediaObject.PanoramaSupportCallback; +import com.android.gallery3d.data.MediaSet.ItemConsumer; +import com.android.gallery3d.data.MediaSource.PathId; +import com.android.gallery3d.picasasource.PicasaSource; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map.Entry; +import java.util.WeakHashMap; + +// DataManager manages all media sets and media items in the system. +// +// Each MediaSet and MediaItem has a unique 64 bits id. The most significant +// 32 bits represents its parent, and the least significant 32 bits represents +// the self id. For MediaSet the self id is is globally unique, but for +// MediaItem it's unique only relative to its parent. +// +// To make sure the id is the same when the MediaSet is re-created, a child key +// is provided to obtainSetId() to make sure the same self id will be used as +// when the parent and key are the same. A sequence of child keys is called a +// path. And it's used to identify a specific media set even if the process is +// killed and re-created, so child keys should be stable identifiers. + +public class DataManager implements StitchingChangeListener { + public static final int INCLUDE_IMAGE = 1; + public static final int INCLUDE_VIDEO = 2; + public static final int INCLUDE_ALL = INCLUDE_IMAGE | INCLUDE_VIDEO; + public static final int INCLUDE_LOCAL_ONLY = 4; + public static final int INCLUDE_LOCAL_IMAGE_ONLY = + INCLUDE_LOCAL_ONLY | INCLUDE_IMAGE; + public static final int INCLUDE_LOCAL_VIDEO_ONLY = + INCLUDE_LOCAL_ONLY | INCLUDE_VIDEO; + public static final int INCLUDE_LOCAL_ALL_ONLY = + INCLUDE_LOCAL_ONLY | INCLUDE_IMAGE | INCLUDE_VIDEO; + + // Any one who would like to access data should require this lock + // to prevent concurrency issue. + public static final Object LOCK = new Object(); + + public static DataManager from(Context context) { + GalleryApp app = (GalleryApp) context.getApplicationContext(); + return app.getDataManager(); + } + + private static final String TAG = "DataManager"; + + // This is the path for the media set seen by the user at top level. + private static final String TOP_SET_PATH = "/combo/{/local/all,/picasa/all}"; + + private static final String TOP_IMAGE_SET_PATH = "/combo/{/local/image,/picasa/image}"; + + private static final String TOP_VIDEO_SET_PATH = + "/combo/{/local/video,/picasa/video}"; + + private static final String TOP_LOCAL_SET_PATH = "/local/all"; + + private static final String TOP_LOCAL_IMAGE_SET_PATH = "/local/image"; + + private static final String TOP_LOCAL_VIDEO_SET_PATH = "/local/video"; + + public static final Comparator<MediaItem> sDateTakenComparator = + new DateTakenComparator(); + + private static class DateTakenComparator implements Comparator<MediaItem> { + @Override + public int compare(MediaItem item1, MediaItem item2) { + return -Utils.compare(item1.getDateInMs(), item2.getDateInMs()); + } + } + + private final Handler mDefaultMainHandler; + + private GalleryApp mApplication; + private int mActiveCount = 0; + + private HashMap<Uri, NotifyBroker> mNotifierMap = + new HashMap<Uri, NotifyBroker>(); + + + private HashMap<String, MediaSource> mSourceMap = + new LinkedHashMap<String, MediaSource>(); + + public DataManager(GalleryApp application) { + mApplication = application; + mDefaultMainHandler = new Handler(application.getMainLooper()); + } + + public synchronized void initializeSourceMap() { + if (!mSourceMap.isEmpty()) return; + + // the order matters, the UriSource must come last + addSource(new LocalSource(mApplication)); + addSource(new PicasaSource(mApplication)); + addSource(new ComboSource(mApplication)); + addSource(new ClusterSource(mApplication)); + addSource(new FilterSource(mApplication)); + addSource(new SecureSource(mApplication)); + addSource(new UriSource(mApplication)); + addSource(new SnailSource(mApplication)); + + if (mActiveCount > 0) { + for (MediaSource source : mSourceMap.values()) { + source.resume(); + } + } + } + + public String getTopSetPath(int typeBits) { + + switch (typeBits) { + case INCLUDE_IMAGE: return TOP_IMAGE_SET_PATH; + case INCLUDE_VIDEO: return TOP_VIDEO_SET_PATH; + case INCLUDE_ALL: return TOP_SET_PATH; + case INCLUDE_LOCAL_IMAGE_ONLY: return TOP_LOCAL_IMAGE_SET_PATH; + case INCLUDE_LOCAL_VIDEO_ONLY: return TOP_LOCAL_VIDEO_SET_PATH; + case INCLUDE_LOCAL_ALL_ONLY: return TOP_LOCAL_SET_PATH; + default: throw new IllegalArgumentException(); + } + } + + // open for debug + void addSource(MediaSource source) { + if (source == null) return; + mSourceMap.put(source.getPrefix(), source); + } + + // A common usage of this method is: + // synchronized (DataManager.LOCK) { + // MediaObject object = peekMediaObject(path); + // if (object == null) { + // object = createMediaObject(...); + // } + // } + public MediaObject peekMediaObject(Path path) { + return path.getObject(); + } + + public MediaObject getMediaObject(Path path) { + synchronized (LOCK) { + MediaObject obj = path.getObject(); + if (obj != null) return obj; + + MediaSource source = mSourceMap.get(path.getPrefix()); + if (source == null) { + Log.w(TAG, "cannot find media source for path: " + path); + return null; + } + + try { + MediaObject object = source.createMediaObject(path); + if (object == null) { + Log.w(TAG, "cannot create media object: " + path); + } + return object; + } catch (Throwable t) { + Log.w(TAG, "exception in creating media object: " + path, t); + return null; + } + } + } + + public MediaObject getMediaObject(String s) { + return getMediaObject(Path.fromString(s)); + } + + public MediaSet getMediaSet(Path path) { + return (MediaSet) getMediaObject(path); + } + + public MediaSet getMediaSet(String s) { + return (MediaSet) getMediaObject(s); + } + + public MediaSet[] getMediaSetsFromString(String segment) { + String[] seq = Path.splitSequence(segment); + int n = seq.length; + MediaSet[] sets = new MediaSet[n]; + for (int i = 0; i < n; i++) { + sets[i] = getMediaSet(seq[i]); + } + return sets; + } + + // Maps a list of Paths to MediaItems, and invoke consumer.consume() + // for each MediaItem (may not be in the same order as the input list). + // An index number is also passed to consumer.consume() to identify + // the original position in the input list of the corresponding Path (plus + // startIndex). + public void mapMediaItems(ArrayList<Path> list, ItemConsumer consumer, + int startIndex) { + HashMap<String, ArrayList<PathId>> map = + new HashMap<String, ArrayList<PathId>>(); + + // Group the path by the prefix. + int n = list.size(); + for (int i = 0; i < n; i++) { + Path path = list.get(i); + String prefix = path.getPrefix(); + ArrayList<PathId> group = map.get(prefix); + if (group == null) { + group = new ArrayList<PathId>(); + map.put(prefix, group); + } + group.add(new PathId(path, i + startIndex)); + } + + // For each group, ask the corresponding media source to map it. + for (Entry<String, ArrayList<PathId>> entry : map.entrySet()) { + String prefix = entry.getKey(); + MediaSource source = mSourceMap.get(prefix); + source.mapMediaItems(entry.getValue(), consumer); + } + } + + // The following methods forward the request to the proper object. + public int getSupportedOperations(Path path) { + return getMediaObject(path).getSupportedOperations(); + } + + public void getPanoramaSupport(Path path, PanoramaSupportCallback callback) { + getMediaObject(path).getPanoramaSupport(callback); + } + + public void delete(Path path) { + getMediaObject(path).delete(); + } + + public void rotate(Path path, int degrees) { + getMediaObject(path).rotate(degrees); + } + + public Uri getContentUri(Path path) { + return getMediaObject(path).getContentUri(); + } + + public int getMediaType(Path path) { + return getMediaObject(path).getMediaType(); + } + + public Path findPathByUri(Uri uri, String type) { + if (uri == null) return null; + for (MediaSource source : mSourceMap.values()) { + Path path = source.findPathByUri(uri, type); + if (path != null) return path; + } + return null; + } + + public Path getDefaultSetOf(Path item) { + MediaSource source = mSourceMap.get(item.getPrefix()); + return source == null ? null : source.getDefaultSetOf(item); + } + + // Returns number of bytes used by cached pictures currently downloaded. + public long getTotalUsedCacheSize() { + long sum = 0; + for (MediaSource source : mSourceMap.values()) { + sum += source.getTotalUsedCacheSize(); + } + return sum; + } + + // Returns number of bytes used by cached pictures if all pending + // downloads and removals are completed. + public long getTotalTargetCacheSize() { + long sum = 0; + for (MediaSource source : mSourceMap.values()) { + sum += source.getTotalTargetCacheSize(); + } + return sum; + } + + public void registerChangeNotifier(Uri uri, ChangeNotifier notifier) { + NotifyBroker broker = null; + synchronized (mNotifierMap) { + broker = mNotifierMap.get(uri); + if (broker == null) { + broker = new NotifyBroker(mDefaultMainHandler); + mApplication.getContentResolver() + .registerContentObserver(uri, true, broker); + mNotifierMap.put(uri, broker); + } + } + broker.registerNotifier(notifier); + } + + public void resume() { + if (++mActiveCount == 1) { + for (MediaSource source : mSourceMap.values()) { + source.resume(); + } + } + } + + public void pause() { + if (--mActiveCount == 0) { + for (MediaSource source : mSourceMap.values()) { + source.pause(); + } + } + } + + private static class NotifyBroker extends ContentObserver { + private WeakHashMap<ChangeNotifier, Object> mNotifiers = + new WeakHashMap<ChangeNotifier, Object>(); + + public NotifyBroker(Handler handler) { + super(handler); + } + + public synchronized void registerNotifier(ChangeNotifier notifier) { + mNotifiers.put(notifier, null); + } + + @Override + public synchronized void onChange(boolean selfChange) { + for(ChangeNotifier notifier : mNotifiers.keySet()) { + notifier.onChange(selfChange); + } + } + } + + @Override + public void onStitchingQueued(Uri uri) { + // Do nothing. + } + + @Override + public void onStitchingResult(Uri uri) { + Path path = findPathByUri(uri, null); + if (path != null) { + MediaObject mediaObject = getMediaObject(path); + if (mediaObject != null) { + mediaObject.clearCachedPanoramaSupport(); + } + } + } + + @Override + public void onStitchingProgress(Uri uri, int progress) { + // Do nothing. + } +} diff --git a/src/com/android/gallery3d/data/DataSourceType.java b/src/com/android/gallery3d/data/DataSourceType.java new file mode 100644 index 000000000..ab534d0c3 --- /dev/null +++ b/src/com/android/gallery3d/data/DataSourceType.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.util.MediaSetUtils; + +public final class DataSourceType { + public static final int TYPE_NOT_CATEGORIZED = 0; + public static final int TYPE_LOCAL = 1; + public static final int TYPE_PICASA = 2; + public static final int TYPE_CAMERA = 3; + + private static final Path PICASA_ROOT = Path.fromString("/picasa"); + private static final Path LOCAL_ROOT = Path.fromString("/local"); + + public static int identifySourceType(MediaSet set) { + if (set == null) { + return TYPE_NOT_CATEGORIZED; + } + + Path path = set.getPath(); + if (MediaSetUtils.isCameraSource(path)) return TYPE_CAMERA; + + Path prefix = path.getPrefixPath(); + + if (prefix == PICASA_ROOT) return TYPE_PICASA; + if (prefix == LOCAL_ROOT) return TYPE_LOCAL; + + return TYPE_NOT_CATEGORIZED; + } +} diff --git a/src/com/android/gallery3d/data/DecodeUtils.java b/src/com/android/gallery3d/data/DecodeUtils.java new file mode 100644 index 000000000..fa709157d --- /dev/null +++ b/src/com/android/gallery3d/data/DecodeUtils.java @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.annotation.TargetApi; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.graphics.BitmapFactory.Options; +import android.graphics.BitmapRegionDecoder; +import android.os.Build; +import android.util.FloatMath; + +import com.android.gallery3d.common.ApiHelper; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.common.Utils; +import com.android.photos.data.GalleryBitmapPool; +import com.android.gallery3d.ui.Log; +import com.android.gallery3d.util.ThreadPool.CancelListener; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.InputStream; + +public class DecodeUtils { + private static final String TAG = "DecodeUtils"; + + private static class DecodeCanceller implements CancelListener { + Options mOptions; + + public DecodeCanceller(Options options) { + mOptions = options; + } + + @Override + public void onCancel() { + mOptions.requestCancelDecode(); + } + } + + @TargetApi(ApiHelper.VERSION_CODES.HONEYCOMB) + public static void setOptionsMutable(Options options) { + if (ApiHelper.HAS_OPTIONS_IN_MUTABLE) options.inMutable = true; + } + + public static Bitmap decode(JobContext jc, FileDescriptor fd, Options options) { + if (options == null) options = new Options(); + jc.setCancelListener(new DecodeCanceller(options)); + setOptionsMutable(options); + return ensureGLCompatibleBitmap( + BitmapFactory.decodeFileDescriptor(fd, null, options)); + } + + public static void decodeBounds(JobContext jc, FileDescriptor fd, + Options options) { + Utils.assertTrue(options != null); + options.inJustDecodeBounds = true; + jc.setCancelListener(new DecodeCanceller(options)); + BitmapFactory.decodeFileDescriptor(fd, null, options); + options.inJustDecodeBounds = false; + } + + public static Bitmap decode(JobContext jc, byte[] bytes, Options options) { + return decode(jc, bytes, 0, bytes.length, options); + } + + public static Bitmap decode(JobContext jc, byte[] bytes, int offset, + int length, Options options) { + if (options == null) options = new Options(); + jc.setCancelListener(new DecodeCanceller(options)); + setOptionsMutable(options); + return ensureGLCompatibleBitmap( + BitmapFactory.decodeByteArray(bytes, offset, length, options)); + } + + public static void decodeBounds(JobContext jc, byte[] bytes, int offset, + int length, Options options) { + Utils.assertTrue(options != null); + options.inJustDecodeBounds = true; + jc.setCancelListener(new DecodeCanceller(options)); + BitmapFactory.decodeByteArray(bytes, offset, length, options); + options.inJustDecodeBounds = false; + } + + public static Bitmap decodeThumbnail( + JobContext jc, String filePath, Options options, int targetSize, int type) { + FileInputStream fis = null; + try { + fis = new FileInputStream(filePath); + FileDescriptor fd = fis.getFD(); + return decodeThumbnail(jc, fd, options, targetSize, type); + } catch (Exception ex) { + Log.w(TAG, ex); + return null; + } finally { + Utils.closeSilently(fis); + } + } + + public static Bitmap decodeThumbnail( + JobContext jc, FileDescriptor fd, Options options, int targetSize, int type) { + if (options == null) options = new Options(); + jc.setCancelListener(new DecodeCanceller(options)); + + options.inJustDecodeBounds = true; + BitmapFactory.decodeFileDescriptor(fd, null, options); + if (jc.isCancelled()) return null; + + int w = options.outWidth; + int h = options.outHeight; + + if (type == MediaItem.TYPE_MICROTHUMBNAIL) { + // We center-crop the original image as it's micro thumbnail. In this case, + // we want to make sure the shorter side >= "targetSize". + float scale = (float) targetSize / Math.min(w, h); + options.inSampleSize = BitmapUtils.computeSampleSizeLarger(scale); + + // For an extremely wide image, e.g. 300x30000, we may got OOM when decoding + // it for TYPE_MICROTHUMBNAIL. So we add a max number of pixels limit here. + final int MAX_PIXEL_COUNT = 640000; // 400 x 1600 + if ((w / options.inSampleSize) * (h / options.inSampleSize) > MAX_PIXEL_COUNT) { + options.inSampleSize = BitmapUtils.computeSampleSize( + FloatMath.sqrt((float) MAX_PIXEL_COUNT / (w * h))); + } + } else { + // For screen nail, we only want to keep the longer side >= targetSize. + float scale = (float) targetSize / Math.max(w, h); + options.inSampleSize = BitmapUtils.computeSampleSizeLarger(scale); + } + + options.inJustDecodeBounds = false; + setOptionsMutable(options); + + Bitmap result = BitmapFactory.decodeFileDescriptor(fd, null, options); + if (result == null) return null; + + // We need to resize down if the decoder does not support inSampleSize + // (For example, GIF images) + float scale = (float) targetSize / (type == MediaItem.TYPE_MICROTHUMBNAIL + ? Math.min(result.getWidth(), result.getHeight()) + : Math.max(result.getWidth(), result.getHeight())); + + if (scale <= 0.5) result = BitmapUtils.resizeBitmapByScale(result, scale, true); + return ensureGLCompatibleBitmap(result); + } + + /** + * Decodes the bitmap from the given byte array if the image size is larger than the given + * requirement. + * + * Note: The returned image may be resized down. However, both width and height must be + * larger than the <code>targetSize</code>. + */ + public static Bitmap decodeIfBigEnough(JobContext jc, byte[] data, + Options options, int targetSize) { + if (options == null) options = new Options(); + jc.setCancelListener(new DecodeCanceller(options)); + + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(data, 0, data.length, options); + if (jc.isCancelled()) return null; + if (options.outWidth < targetSize || options.outHeight < targetSize) { + return null; + } + options.inSampleSize = BitmapUtils.computeSampleSizeLarger( + options.outWidth, options.outHeight, targetSize); + options.inJustDecodeBounds = false; + setOptionsMutable(options); + + return ensureGLCompatibleBitmap( + BitmapFactory.decodeByteArray(data, 0, data.length, options)); + } + + // TODO: This function should not be called directly from + // DecodeUtils.requestDecode(...), since we don't have the knowledge + // if the bitmap will be uploaded to GL. + public static Bitmap ensureGLCompatibleBitmap(Bitmap bitmap) { + if (bitmap == null || bitmap.getConfig() != null) return bitmap; + Bitmap newBitmap = bitmap.copy(Config.ARGB_8888, false); + bitmap.recycle(); + return newBitmap; + } + + public static BitmapRegionDecoder createBitmapRegionDecoder( + JobContext jc, byte[] bytes, int offset, int length, + boolean shareable) { + if (offset < 0 || length <= 0 || offset + length > bytes.length) { + throw new IllegalArgumentException(String.format( + "offset = %s, length = %s, bytes = %s", + offset, length, bytes.length)); + } + + try { + return BitmapRegionDecoder.newInstance( + bytes, offset, length, shareable); + } catch (Throwable t) { + Log.w(TAG, t); + return null; + } + } + + public static BitmapRegionDecoder createBitmapRegionDecoder( + JobContext jc, String filePath, boolean shareable) { + try { + return BitmapRegionDecoder.newInstance(filePath, shareable); + } catch (Throwable t) { + Log.w(TAG, t); + return null; + } + } + + public static BitmapRegionDecoder createBitmapRegionDecoder( + JobContext jc, FileDescriptor fd, boolean shareable) { + try { + return BitmapRegionDecoder.newInstance(fd, shareable); + } catch (Throwable t) { + Log.w(TAG, t); + return null; + } + } + + public static BitmapRegionDecoder createBitmapRegionDecoder( + JobContext jc, InputStream is, boolean shareable) { + try { + return BitmapRegionDecoder.newInstance(is, shareable); + } catch (Throwable t) { + // We often cancel the creating of bitmap region decoder, + // so just log one line. + Log.w(TAG, "requestCreateBitmapRegionDecoder: " + t); + return null; + } + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public static Bitmap decodeUsingPool(JobContext jc, byte[] data, int offset, + int length, BitmapFactory.Options options) { + if (options == null) options = new BitmapFactory.Options(); + if (options.inSampleSize < 1) options.inSampleSize = 1; + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + options.inBitmap = (options.inSampleSize == 1) + ? findCachedBitmap(jc, data, offset, length, options) : null; + try { + Bitmap bitmap = decode(jc, data, offset, length, options); + if (options.inBitmap != null && options.inBitmap != bitmap) { + GalleryBitmapPool.getInstance().put(options.inBitmap); + options.inBitmap = null; + } + return bitmap; + } catch (IllegalArgumentException e) { + if (options.inBitmap == null) throw e; + + Log.w(TAG, "decode fail with a given bitmap, try decode to a new bitmap"); + GalleryBitmapPool.getInstance().put(options.inBitmap); + options.inBitmap = null; + return decode(jc, data, offset, length, options); + } + } + + // This is the same as the method above except the source data comes + // from a file descriptor instead of a byte array. + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + public static Bitmap decodeUsingPool(JobContext jc, + FileDescriptor fileDescriptor, Options options) { + if (options == null) options = new BitmapFactory.Options(); + if (options.inSampleSize < 1) options.inSampleSize = 1; + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + options.inBitmap = (options.inSampleSize == 1) + ? findCachedBitmap(jc, fileDescriptor, options) : null; + try { + Bitmap bitmap = DecodeUtils.decode(jc, fileDescriptor, options); + if (options.inBitmap != null && options.inBitmap != bitmap) { + GalleryBitmapPool.getInstance().put(options.inBitmap); + options.inBitmap = null; + } + return bitmap; + } catch (IllegalArgumentException e) { + if (options.inBitmap == null) throw e; + + Log.w(TAG, "decode fail with a given bitmap, try decode to a new bitmap"); + GalleryBitmapPool.getInstance().put(options.inBitmap); + options.inBitmap = null; + return decode(jc, fileDescriptor, options); + } + } + + private static Bitmap findCachedBitmap(JobContext jc, byte[] data, + int offset, int length, Options options) { + decodeBounds(jc, data, offset, length, options); + return GalleryBitmapPool.getInstance().get(options.outWidth, options.outHeight); + } + + private static Bitmap findCachedBitmap(JobContext jc, FileDescriptor fileDescriptor, + Options options) { + decodeBounds(jc, fileDescriptor, options); + return GalleryBitmapPool.getInstance().get(options.outWidth, options.outHeight); + } +} diff --git a/src/com/android/gallery3d/data/DownloadCache.java b/src/com/android/gallery3d/data/DownloadCache.java new file mode 100644 index 000000000..be7820b01 --- /dev/null +++ b/src/com/android/gallery3d/data/DownloadCache.java @@ -0,0 +1,370 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.LruCache; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.DownloadEntry.Columns; +import com.android.gallery3d.util.Future; +import com.android.gallery3d.util.FutureListener; +import com.android.gallery3d.util.ThreadPool; +import com.android.gallery3d.util.ThreadPool.CancelListener; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import java.io.File; +import java.net.URL; +import java.util.HashMap; +import java.util.HashSet; + +public class DownloadCache { + private static final String TAG = "DownloadCache"; + private static final int MAX_DELETE_COUNT = 16; + private static final int LRU_CAPACITY = 4; + + private static final String TABLE_NAME = DownloadEntry.SCHEMA.getTableName(); + + private static final String QUERY_PROJECTION[] = {Columns.ID, Columns.DATA}; + private static final String WHERE_HASH_AND_URL = String.format( + "%s = ? AND %s = ?", Columns.HASH_CODE, Columns.CONTENT_URL); + private static final int QUERY_INDEX_ID = 0; + private static final int QUERY_INDEX_DATA = 1; + + private static final String FREESPACE_PROJECTION[] = { + Columns.ID, Columns.DATA, Columns.CONTENT_URL, Columns.CONTENT_SIZE}; + private static final String FREESPACE_ORDER_BY = + String.format("%s ASC", Columns.LAST_ACCESS); + private static final int FREESPACE_IDNEX_ID = 0; + private static final int FREESPACE_IDNEX_DATA = 1; + private static final int FREESPACE_INDEX_CONTENT_URL = 2; + private static final int FREESPACE_INDEX_CONTENT_SIZE = 3; + + private static final String ID_WHERE = Columns.ID + " = ?"; + + private static final String SUM_PROJECTION[] = + {String.format("sum(%s)", Columns.CONTENT_SIZE)}; + private static final int SUM_INDEX_SUM = 0; + + private final LruCache<String, Entry> mEntryMap = + new LruCache<String, Entry>(LRU_CAPACITY); + private final HashMap<String, DownloadTask> mTaskMap = + new HashMap<String, DownloadTask>(); + private final File mRoot; + private final GalleryApp mApplication; + private final SQLiteDatabase mDatabase; + private final long mCapacity; + + private long mTotalBytes = 0; + private boolean mInitialized = false; + + public DownloadCache(GalleryApp application, File root, long capacity) { + mRoot = Utils.checkNotNull(root); + mApplication = Utils.checkNotNull(application); + mCapacity = capacity; + mDatabase = new DatabaseHelper(application.getAndroidContext()) + .getWritableDatabase(); + } + + private Entry findEntryInDatabase(String stringUrl) { + long hash = Utils.crc64Long(stringUrl); + String whereArgs[] = {String.valueOf(hash), stringUrl}; + Cursor cursor = mDatabase.query(TABLE_NAME, QUERY_PROJECTION, + WHERE_HASH_AND_URL, whereArgs, null, null, null); + try { + if (cursor.moveToNext()) { + File file = new File(cursor.getString(QUERY_INDEX_DATA)); + long id = cursor.getInt(QUERY_INDEX_ID); + Entry entry = null; + synchronized (mEntryMap) { + entry = mEntryMap.get(stringUrl); + if (entry == null) { + entry = new Entry(id, file); + mEntryMap.put(stringUrl, entry); + } + } + return entry; + } + } finally { + cursor.close(); + } + return null; + } + + public Entry download(JobContext jc, URL url) { + if (!mInitialized) initialize(); + + String stringUrl = url.toString(); + + // First find in the entry-pool + synchronized (mEntryMap) { + Entry entry = mEntryMap.get(stringUrl); + if (entry != null) { + updateLastAccess(entry.mId); + return entry; + } + } + + // Then, find it in database + TaskProxy proxy = new TaskProxy(); + synchronized (mTaskMap) { + Entry entry = findEntryInDatabase(stringUrl); + if (entry != null) { + updateLastAccess(entry.mId); + return entry; + } + + // Finally, we need to download the file .... + // First check if we are downloading it now ... + DownloadTask task = mTaskMap.get(stringUrl); + if (task == null) { // if not, start the download task now + task = new DownloadTask(stringUrl); + mTaskMap.put(stringUrl, task); + task.mFuture = mApplication.getThreadPool().submit(task, task); + } + task.addProxy(proxy); + } + + return proxy.get(jc); + } + + private void updateLastAccess(long id) { + ContentValues values = new ContentValues(); + values.put(Columns.LAST_ACCESS, System.currentTimeMillis()); + mDatabase.update(TABLE_NAME, values, + ID_WHERE, new String[] {String.valueOf(id)}); + } + + private synchronized void freeSomeSpaceIfNeed(int maxDeleteFileCount) { + if (mTotalBytes <= mCapacity) return; + Cursor cursor = mDatabase.query(TABLE_NAME, + FREESPACE_PROJECTION, null, null, null, null, FREESPACE_ORDER_BY); + try { + while (maxDeleteFileCount > 0 + && mTotalBytes > mCapacity && cursor.moveToNext()) { + long id = cursor.getLong(FREESPACE_IDNEX_ID); + String url = cursor.getString(FREESPACE_INDEX_CONTENT_URL); + long size = cursor.getLong(FREESPACE_INDEX_CONTENT_SIZE); + String path = cursor.getString(FREESPACE_IDNEX_DATA); + boolean containsKey; + synchronized (mEntryMap) { + containsKey = mEntryMap.containsKey(url); + } + if (!containsKey) { + --maxDeleteFileCount; + mTotalBytes -= size; + new File(path).delete(); + mDatabase.delete(TABLE_NAME, + ID_WHERE, new String[]{String.valueOf(id)}); + } else { + // skip delete, since it is being used + } + } + } finally { + cursor.close(); + } + } + + private synchronized long insertEntry(String url, File file) { + long size = file.length(); + mTotalBytes += size; + + ContentValues values = new ContentValues(); + String hashCode = String.valueOf(Utils.crc64Long(url)); + values.put(Columns.DATA, file.getAbsolutePath()); + values.put(Columns.HASH_CODE, hashCode); + values.put(Columns.CONTENT_URL, url); + values.put(Columns.CONTENT_SIZE, size); + values.put(Columns.LAST_UPDATED, System.currentTimeMillis()); + return mDatabase.insert(TABLE_NAME, "", values); + } + + private synchronized void initialize() { + if (mInitialized) return; + mInitialized = true; + if (!mRoot.isDirectory()) mRoot.mkdirs(); + if (!mRoot.isDirectory()) { + throw new RuntimeException("cannot create " + mRoot.getAbsolutePath()); + } + + Cursor cursor = mDatabase.query( + TABLE_NAME, SUM_PROJECTION, null, null, null, null, null); + mTotalBytes = 0; + try { + if (cursor.moveToNext()) { + mTotalBytes = cursor.getLong(SUM_INDEX_SUM); + } + } finally { + cursor.close(); + } + if (mTotalBytes > mCapacity) freeSomeSpaceIfNeed(MAX_DELETE_COUNT); + } + + private final class DatabaseHelper extends SQLiteOpenHelper { + public static final String DATABASE_NAME = "download.db"; + public static final int DATABASE_VERSION = 2; + + public DatabaseHelper(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + DownloadEntry.SCHEMA.createTables(db); + // Delete old files + for (File file : mRoot.listFiles()) { + if (!file.delete()) { + Log.w(TAG, "fail to remove: " + file.getAbsolutePath()); + } + } + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + //reset everything + DownloadEntry.SCHEMA.dropTables(db); + onCreate(db); + } + } + + public class Entry { + public File cacheFile; + protected long mId; + + Entry(long id, File cacheFile) { + mId = id; + this.cacheFile = Utils.checkNotNull(cacheFile); + } + } + + private class DownloadTask implements Job<File>, FutureListener<File> { + private HashSet<TaskProxy> mProxySet = new HashSet<TaskProxy>(); + private Future<File> mFuture; + private final String mUrl; + + public DownloadTask(String url) { + mUrl = Utils.checkNotNull(url); + } + + public void removeProxy(TaskProxy proxy) { + synchronized (mTaskMap) { + Utils.assertTrue(mProxySet.remove(proxy)); + if (mProxySet.isEmpty()) { + mFuture.cancel(); + mTaskMap.remove(mUrl); + } + } + } + + // should be used in synchronized block of mDatabase + public void addProxy(TaskProxy proxy) { + proxy.mTask = this; + mProxySet.add(proxy); + } + + @Override + public void onFutureDone(Future<File> future) { + File file = future.get(); + long id = 0; + if (file != null) { // insert to database + id = insertEntry(mUrl, file); + } + + if (future.isCancelled()) { + Utils.assertTrue(mProxySet.isEmpty()); + return; + } + + synchronized (mTaskMap) { + Entry entry = null; + synchronized (mEntryMap) { + if (file != null) { + entry = new Entry(id, file); + Utils.assertTrue(mEntryMap.put(mUrl, entry) == null); + } + } + for (TaskProxy proxy : mProxySet) { + proxy.setResult(entry); + } + mTaskMap.remove(mUrl); + freeSomeSpaceIfNeed(MAX_DELETE_COUNT); + } + } + + @Override + public File run(JobContext jc) { + // TODO: utilize etag + jc.setMode(ThreadPool.MODE_NETWORK); + File tempFile = null; + try { + URL url = new URL(mUrl); + tempFile = File.createTempFile("cache", ".tmp", mRoot); + // download from url to tempFile + jc.setMode(ThreadPool.MODE_NETWORK); + boolean downloaded = DownloadUtils.requestDownload(jc, url, tempFile); + jc.setMode(ThreadPool.MODE_NONE); + if (downloaded) return tempFile; + } catch (Exception e) { + Log.e(TAG, String.format("fail to download %s", mUrl), e); + } finally { + jc.setMode(ThreadPool.MODE_NONE); + } + if (tempFile != null) tempFile.delete(); + return null; + } + } + + public static class TaskProxy { + private DownloadTask mTask; + private boolean mIsCancelled = false; + private Entry mEntry; + + synchronized void setResult(Entry entry) { + if (mIsCancelled) return; + mEntry = entry; + notifyAll(); + } + + public synchronized Entry get(JobContext jc) { + jc.setCancelListener(new CancelListener() { + @Override + public void onCancel() { + mTask.removeProxy(TaskProxy.this); + synchronized (TaskProxy.this) { + mIsCancelled = true; + TaskProxy.this.notifyAll(); + } + } + }); + while (!mIsCancelled && mEntry == null) { + try { + wait(); + } catch (InterruptedException e) { + Log.w(TAG, "ignore interrupt", e); + } + } + jc.setCancelListener(null); + return mEntry; + } + } +} diff --git a/src/com/android/gallery3d/data/DownloadEntry.java b/src/com/android/gallery3d/data/DownloadEntry.java new file mode 100644 index 000000000..578523f73 --- /dev/null +++ b/src/com/android/gallery3d/data/DownloadEntry.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.common.Entry; +import com.android.gallery3d.common.EntrySchema; + + +@Entry.Table("download") +public class DownloadEntry extends Entry { + public static final EntrySchema SCHEMA = new EntrySchema(DownloadEntry.class); + + public static interface Columns extends Entry.Columns { + public static final String HASH_CODE = "hash_code"; + public static final String CONTENT_URL = "content_url"; + public static final String CONTENT_SIZE = "_size"; + public static final String ETAG = "etag"; + public static final String LAST_ACCESS = "last_access"; + public static final String LAST_UPDATED = "last_updated"; + public static final String DATA = "_data"; + } + + @Column(value = "hash_code", indexed = true) + public long hashCode; + + @Column("content_url") + public String contentUrl; + + @Column("_size") + public long contentSize; + + @Column("etag") + public String eTag; + + @Column(value = "last_access", indexed = true) + public long lastAccessTime; + + @Column(value = "last_updated") + public long lastUpdatedTime; + + @Column("_data") + public String path; + + @Override + public String toString() { + // Note: THIS IS REQUIRED. We used all the fields here. Otherwise, + // ProGuard will remove these UNUSED fields. However, these + // fields are needed to generate database. + return new StringBuilder() + .append("hash_code: ").append(hashCode).append(", ") + .append("content_url").append(contentUrl).append(", ") + .append("_size").append(contentSize).append(", ") + .append("etag").append(eTag).append(", ") + .append("last_access").append(lastAccessTime).append(", ") + .append("last_updated").append(lastUpdatedTime).append(",") + .append("_data").append(path) + .toString(); + } +} diff --git a/src/com/android/gallery3d/data/DownloadUtils.java b/src/com/android/gallery3d/data/DownloadUtils.java new file mode 100644 index 000000000..137898e91 --- /dev/null +++ b/src/com/android/gallery3d/data/DownloadUtils.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.ThreadPool.CancelListener; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.net.URL; + +public class DownloadUtils { + private static final String TAG = "DownloadService"; + + public static boolean requestDownload(JobContext jc, URL url, File file) { + FileOutputStream fos = null; + try { + fos = new FileOutputStream(file); + return download(jc, url, fos); + } catch (Throwable t) { + return false; + } finally { + Utils.closeSilently(fos); + } + } + + public static void dump(JobContext jc, InputStream is, OutputStream os) + throws IOException { + byte buffer[] = new byte[4096]; + int rc = is.read(buffer, 0, buffer.length); + final Thread thread = Thread.currentThread(); + jc.setCancelListener(new CancelListener() { + @Override + public void onCancel() { + thread.interrupt(); + } + }); + while (rc > 0) { + if (jc.isCancelled()) throw new InterruptedIOException(); + os.write(buffer, 0, rc); + rc = is.read(buffer, 0, buffer.length); + } + jc.setCancelListener(null); + Thread.interrupted(); // consume the interrupt signal + } + + public static boolean download(JobContext jc, URL url, OutputStream output) { + InputStream input = null; + try { + input = url.openStream(); + dump(jc, input, output); + return true; + } catch (Throwable t) { + Log.w(TAG, "fail to download", t); + return false; + } finally { + Utils.closeSilently(input); + } + } +}
\ No newline at end of file diff --git a/src/com/android/gallery3d/data/EmptyAlbumImage.java b/src/com/android/gallery3d/data/EmptyAlbumImage.java new file mode 100644 index 000000000..6f8c37c6b --- /dev/null +++ b/src/com/android/gallery3d/data/EmptyAlbumImage.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.GalleryApp; + +public class EmptyAlbumImage extends ActionImage { + @SuppressWarnings("unused") + private static final String TAG = "EmptyAlbumImage"; + + public EmptyAlbumImage(Path path, GalleryApp application) { + super(path, application, R.drawable.placeholder_empty); + } + + @Override + public int getSupportedOperations() { + return super.getSupportedOperations() | SUPPORT_BACK; + } +} diff --git a/src/com/android/gallery3d/data/Exif.java b/src/com/android/gallery3d/data/Exif.java new file mode 100644 index 000000000..950e7de18 --- /dev/null +++ b/src/com/android/gallery3d/data/Exif.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2011 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.gallery3d.data; + +import android.util.Log; + +import com.android.gallery3d.exif.ExifInterface; + +import java.io.IOException; +import java.io.InputStream; + +public class Exif { + private static final String TAG = "CameraExif"; + + // Returns the degrees in clockwise. Values are 0, 90, 180, or 270. + public static int getOrientation(InputStream is) { + if (is == null) { + return 0; + } + ExifInterface exif = new ExifInterface(); + try { + exif.readExif(is); + Integer val = exif.getTagIntValue(ExifInterface.TAG_ORIENTATION); + if (val == null) { + return 0; + } else { + return ExifInterface.getRotationForOrientationValue(val.shortValue()); + } + } catch (IOException e) { + Log.w(TAG, "Failed to read EXIF orientation", e); + return 0; + } + } +} diff --git a/src/com/android/gallery3d/data/Face.java b/src/com/android/gallery3d/data/Face.java new file mode 100644 index 000000000..d2dc22bfc --- /dev/null +++ b/src/com/android/gallery3d/data/Face.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2011 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.gallery3d.data; + +import android.graphics.Rect; + +import com.android.gallery3d.common.Utils; + +import java.util.StringTokenizer; + +public class Face implements Comparable<Face> { + private String mName; + private String mPersonId; + private Rect mPosition; + + public Face(String name, String personId, String rect) { + mName = name; + mPersonId = personId; + Utils.assertTrue(mName != null && mPersonId != null && rect != null); + StringTokenizer tokenizer = new StringTokenizer(rect); + mPosition = new Rect(); + while (tokenizer.hasMoreElements()) { + mPosition.left = Integer.parseInt(tokenizer.nextToken()); + mPosition.top = Integer.parseInt(tokenizer.nextToken()); + mPosition.right = Integer.parseInt(tokenizer.nextToken()); + mPosition.bottom = Integer.parseInt(tokenizer.nextToken()); + } + } + + public Rect getPosition() { + return mPosition; + } + + public String getName() { + return mName; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof Face) { + Face face = (Face) obj; + return mPersonId.equals(face.mPersonId); + } + return false; + } + + @Override + public int compareTo(Face another) { + return mName.compareTo(another.mName); + } +} diff --git a/src/com/android/gallery3d/data/FaceClustering.java b/src/com/android/gallery3d/data/FaceClustering.java new file mode 100644 index 000000000..819915edb --- /dev/null +++ b/src/com/android/gallery3d/data/FaceClustering.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2011 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.gallery3d.data; + +import android.content.Context; +import android.graphics.Rect; + +import com.android.gallery3d.R; +import com.android.gallery3d.picasasource.PicasaSource; + +import java.util.ArrayList; +import java.util.TreeMap; + +public class FaceClustering extends Clustering { + @SuppressWarnings("unused") + private static final String TAG = "FaceClustering"; + + private FaceCluster[] mClusters; + private String mUntaggedString; + private Context mContext; + + private class FaceCluster { + ArrayList<Path> mPaths = new ArrayList<Path>(); + String mName; + MediaItem mCoverItem; + Rect mCoverRegion; + int mCoverFaceIndex; + + public FaceCluster(String name) { + mName = name; + } + + public void add(MediaItem item, int faceIndex) { + Path path = item.getPath(); + mPaths.add(path); + Face[] faces = item.getFaces(); + if (faces != null) { + Face face = faces[faceIndex]; + if (mCoverItem == null) { + mCoverItem = item; + mCoverRegion = face.getPosition(); + mCoverFaceIndex = faceIndex; + } else { + Rect region = face.getPosition(); + if (mCoverRegion.width() < region.width() && + mCoverRegion.height() < region.height()) { + mCoverItem = item; + mCoverRegion = face.getPosition(); + mCoverFaceIndex = faceIndex; + } + } + } + } + + public int size() { + return mPaths.size(); + } + + public MediaItem getCover() { + if (mCoverItem != null) { + if (PicasaSource.isPicasaImage(mCoverItem)) { + return PicasaSource.getFaceItem(mContext, mCoverItem, mCoverFaceIndex); + } else { + return mCoverItem; + } + } + return null; + } + } + + public FaceClustering(Context context) { + mUntaggedString = context.getResources().getString(R.string.untagged); + mContext = context; + } + + @Override + public void run(MediaSet baseSet) { + final TreeMap<Face, FaceCluster> map = + new TreeMap<Face, FaceCluster>(); + final FaceCluster untagged = new FaceCluster(mUntaggedString); + + baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + Face[] faces = item.getFaces(); + if (faces == null || faces.length == 0) { + untagged.add(item, -1); + return; + } + for (int j = 0; j < faces.length; j++) { + Face face = faces[j]; + FaceCluster cluster = map.get(face); + if (cluster == null) { + cluster = new FaceCluster(face.getName()); + map.put(face, cluster); + } + cluster.add(item, j); + } + } + }); + + int m = map.size(); + mClusters = map.values().toArray(new FaceCluster[m + ((untagged.size() > 0) ? 1 : 0)]); + if (untagged.size() > 0) { + mClusters[m] = untagged; + } + } + + @Override + public int getNumberOfClusters() { + return mClusters.length; + } + + @Override + public ArrayList<Path> getCluster(int index) { + return mClusters[index].mPaths; + } + + @Override + public String getClusterName(int index) { + return mClusters[index].mName; + } + + @Override + public MediaItem getClusterCover(int index) { + return mClusters[index].getCover(); + } +} diff --git a/src/com/android/gallery3d/data/FilterDeleteSet.java b/src/com/android/gallery3d/data/FilterDeleteSet.java new file mode 100644 index 000000000..c76412ff8 --- /dev/null +++ b/src/com/android/gallery3d/data/FilterDeleteSet.java @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import java.util.ArrayList; + +// FilterDeleteSet filters a base MediaSet to remove some deletion items (we +// expect the number to be small). The user can use the following methods to +// add/remove deletion items: +// +// void addDeletion(Path path, int index); +// void removeDelection(Path path); +// void clearDeletion(); +// int getNumberOfDeletions(); +// +public class FilterDeleteSet extends MediaSet implements ContentListener { + @SuppressWarnings("unused") + private static final String TAG = "FilterDeleteSet"; + + private static final int REQUEST_ADD = 1; + private static final int REQUEST_REMOVE = 2; + private static final int REQUEST_CLEAR = 3; + + private static class Request { + int type; // one of the REQUEST_* constants + Path path; + int indexHint; + public Request(int type, Path path, int indexHint) { + this.type = type; + this.path = path; + this.indexHint = indexHint; + } + } + + private static class Deletion { + Path path; + int index; + public Deletion(Path path, int index) { + this.path = path; + this.index = index; + } + } + + // The underlying MediaSet + private final MediaSet mBaseSet; + + // Pending Requests + private ArrayList<Request> mRequests = new ArrayList<Request>(); + + // Deletions currently in effect, ordered by index + private ArrayList<Deletion> mCurrent = new ArrayList<Deletion>(); + + public FilterDeleteSet(Path path, MediaSet baseSet) { + super(path, INVALID_DATA_VERSION); + mBaseSet = baseSet; + mBaseSet.addContentListener(this); + } + + @Override + public boolean isCameraRoll() { + return mBaseSet.isCameraRoll(); + } + + @Override + public String getName() { + return mBaseSet.getName(); + } + + @Override + public int getMediaItemCount() { + return mBaseSet.getMediaItemCount() - mCurrent.size(); + } + + // Gets the MediaItems whose (post-deletion) index are in the range [start, + // start + count). Because we remove some of the MediaItems, the index need + // to be adjusted. + // + // For example, if there are 12 items in total. The deleted items are 3, 5, + // 10, and the the requested range is [3, 7]: + // + // The original index: 0 1 2 3 4 5 6 7 8 9 A B C + // The deleted items: X X X + // The new index: 0 1 2 3 4 5 6 7 8 9 + // Requested: * * * * * + // + // We need to figure out the [3, 7] actually maps to the original index 4, + // 6, 7, 8, 9. + // + // We can break the MediaItems into segments, each segment other than the + // last one ends in a deleted item. The difference between the new index and + // the original index increases with each segment: + // + // 0 1 2 X (new index = old index) + // 4 X (new index = old index - 1) + // 6 7 8 9 X (new index = old index - 2) + // B C (new index = old index - 3) + // + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + if (count <= 0) return new ArrayList<MediaItem>(); + + int end = start + count - 1; + int n = mCurrent.size(); + // Find the segment that "start" falls into. Count the number of items + // not yet deleted until it reaches "start". + int i = 0; + for (i = 0; i < n; i++) { + Deletion d = mCurrent.get(i); + if (d.index - i > start) break; + } + // Find the segment that "end" falls into. + int j = i; + for (; j < n; j++) { + Deletion d = mCurrent.get(j); + if (d.index - j > end) break; + } + + // Now get enough to cover deleted items in [start, end] + ArrayList<MediaItem> base = mBaseSet.getMediaItem(start + i, count + (j - i)); + + // Remove the deleted items. + for (int m = j - 1; m >= i; m--) { + Deletion d = mCurrent.get(m); + int k = d.index - (start + i); + base.remove(k); + } + return base; + } + + // We apply the pending requests in the mRequests to construct mCurrent in reload(). + @Override + public long reload() { + boolean newData = mBaseSet.reload() > mDataVersion; + synchronized (mRequests) { + if (!newData && mRequests.isEmpty()) { + return mDataVersion; + } + for (int i = 0; i < mRequests.size(); i++) { + Request r = mRequests.get(i); + switch (r.type) { + case REQUEST_ADD: { + // Add the path into mCurrent if there is no duplicate. + int n = mCurrent.size(); + int j; + for (j = 0; j < n; j++) { + if (mCurrent.get(j).path == r.path) break; + } + if (j == n) { + mCurrent.add(new Deletion(r.path, r.indexHint)); + } + break; + } + case REQUEST_REMOVE: { + // Remove the path from mCurrent. + int n = mCurrent.size(); + for (int j = 0; j < n; j++) { + if (mCurrent.get(j).path == r.path) { + mCurrent.remove(j); + break; + } + } + break; + } + case REQUEST_CLEAR: { + mCurrent.clear(); + break; + } + } + } + mRequests.clear(); + } + + if (!mCurrent.isEmpty()) { + // See if the elements in mCurrent can be found in the MediaSet. We + // don't want to search the whole mBaseSet, so we just search a + // small window that contains the index hints (plus some margin). + int minIndex = mCurrent.get(0).index; + int maxIndex = minIndex; + for (int i = 1; i < mCurrent.size(); i++) { + Deletion d = mCurrent.get(i); + minIndex = Math.min(d.index, minIndex); + maxIndex = Math.max(d.index, maxIndex); + } + + int n = mBaseSet.getMediaItemCount(); + int from = Math.max(minIndex - 5, 0); + int to = Math.min(maxIndex + 5, n); + ArrayList<MediaItem> items = mBaseSet.getMediaItem(from, to - from); + ArrayList<Deletion> result = new ArrayList<Deletion>(); + for (int i = 0; i < items.size(); i++) { + MediaItem item = items.get(i); + if (item == null) continue; + Path p = item.getPath(); + // Find the matching path in mCurrent, if found move it to result + for (int j = 0; j < mCurrent.size(); j++) { + Deletion d = mCurrent.get(j); + if (d.path == p) { + d.index = from + i; + result.add(d); + mCurrent.remove(j); + break; + } + } + } + mCurrent = result; + } + + mDataVersion = nextVersionNumber(); + return mDataVersion; + } + + private void sendRequest(int type, Path path, int indexHint) { + Request r = new Request(type, path, indexHint); + synchronized (mRequests) { + mRequests.add(r); + } + notifyContentChanged(); + } + + @Override + public void onContentDirty() { + notifyContentChanged(); + } + + public void addDeletion(Path path, int indexHint) { + sendRequest(REQUEST_ADD, path, indexHint); + } + + public void removeDeletion(Path path) { + sendRequest(REQUEST_REMOVE, path, 0 /* unused */); + } + + public void clearDeletion() { + sendRequest(REQUEST_CLEAR, null /* unused */ , 0 /* unused */); + } + + // Returns number of deletions _in effect_ (the number will only gets + // updated after a reload()). + public int getNumberOfDeletions() { + return mCurrent.size(); + } +} diff --git a/src/com/android/gallery3d/data/FilterEmptyPromptSet.java b/src/com/android/gallery3d/data/FilterEmptyPromptSet.java new file mode 100644 index 000000000..b576e06d4 --- /dev/null +++ b/src/com/android/gallery3d/data/FilterEmptyPromptSet.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import java.util.ArrayList; + +public class FilterEmptyPromptSet extends MediaSet implements ContentListener { + @SuppressWarnings("unused") + private static final String TAG = "FilterEmptyPromptSet"; + + private ArrayList<MediaItem> mEmptyItem; + private MediaSet mBaseSet; + + public FilterEmptyPromptSet(Path path, MediaSet baseSet, MediaItem emptyItem) { + super(path, INVALID_DATA_VERSION); + mEmptyItem = new ArrayList<MediaItem>(1); + mEmptyItem.add(emptyItem); + mBaseSet = baseSet; + mBaseSet.addContentListener(this); + } + + @Override + public int getMediaItemCount() { + int itemCount = mBaseSet.getMediaItemCount(); + if (itemCount > 0) { + return itemCount; + } else { + return 1; + } + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + int itemCount = mBaseSet.getMediaItemCount(); + if (itemCount > 0) { + return mBaseSet.getMediaItem(start, count); + } else if (start == 0 && count == 1) { + return mEmptyItem; + } else { + throw new ArrayIndexOutOfBoundsException(); + } + } + + @Override + public void onContentDirty() { + notifyContentChanged(); + } + + @Override + public boolean isLeafAlbum() { + return true; + } + + @Override + public boolean isCameraRoll() { + return mBaseSet.isCameraRoll(); + } + + @Override + public long reload() { + return mBaseSet.reload(); + } + + @Override + public String getName() { + return mBaseSet.getName(); + } +} diff --git a/src/com/android/gallery3d/data/FilterSource.java b/src/com/android/gallery3d/data/FilterSource.java new file mode 100644 index 000000000..d689fe336 --- /dev/null +++ b/src/com/android/gallery3d/data/FilterSource.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +public class FilterSource extends MediaSource { + @SuppressWarnings("unused") + private static final String TAG = "FilterSource"; + private static final int FILTER_BY_MEDIATYPE = 0; + private static final int FILTER_BY_DELETE = 1; + private static final int FILTER_BY_EMPTY = 2; + private static final int FILTER_BY_EMPTY_ITEM = 3; + private static final int FILTER_BY_CAMERA_SHORTCUT = 4; + private static final int FILTER_BY_CAMERA_SHORTCUT_ITEM = 5; + + public static final String FILTER_EMPTY_ITEM = "/filter/empty_prompt"; + public static final String FILTER_CAMERA_SHORTCUT = "/filter/camera_shortcut"; + private static final String FILTER_CAMERA_SHORTCUT_ITEM = "/filter/camera_shortcut_item"; + + private GalleryApp mApplication; + private PathMatcher mMatcher; + private MediaItem mEmptyItem; + private MediaItem mCameraShortcutItem; + + public FilterSource(GalleryApp application) { + super("filter"); + mApplication = application; + mMatcher = new PathMatcher(); + mMatcher.add("/filter/mediatype/*/*", FILTER_BY_MEDIATYPE); + mMatcher.add("/filter/delete/*", FILTER_BY_DELETE); + mMatcher.add("/filter/empty/*", FILTER_BY_EMPTY); + mMatcher.add(FILTER_EMPTY_ITEM, FILTER_BY_EMPTY_ITEM); + mMatcher.add(FILTER_CAMERA_SHORTCUT, FILTER_BY_CAMERA_SHORTCUT); + mMatcher.add(FILTER_CAMERA_SHORTCUT_ITEM, FILTER_BY_CAMERA_SHORTCUT_ITEM); + + mEmptyItem = new EmptyAlbumImage(Path.fromString(FILTER_EMPTY_ITEM), + mApplication); + mCameraShortcutItem = new CameraShortcutImage( + Path.fromString(FILTER_CAMERA_SHORTCUT_ITEM), mApplication); + } + + // The name we accept are: + // /filter/mediatype/k/{set} where k is the media type we want. + // /filter/delete/{set} + @Override + public MediaObject createMediaObject(Path path) { + int matchType = mMatcher.match(path); + DataManager dataManager = mApplication.getDataManager(); + switch (matchType) { + case FILTER_BY_MEDIATYPE: { + int mediaType = mMatcher.getIntVar(0); + String setsName = mMatcher.getVar(1); + MediaSet[] sets = dataManager.getMediaSetsFromString(setsName); + return new FilterTypeSet(path, dataManager, sets[0], mediaType); + } + case FILTER_BY_DELETE: { + String setsName = mMatcher.getVar(0); + MediaSet[] sets = dataManager.getMediaSetsFromString(setsName); + return new FilterDeleteSet(path, sets[0]); + } + case FILTER_BY_EMPTY: { + String setsName = mMatcher.getVar(0); + MediaSet[] sets = dataManager.getMediaSetsFromString(setsName); + return new FilterEmptyPromptSet(path, sets[0], mEmptyItem); + } + case FILTER_BY_EMPTY_ITEM: { + return mEmptyItem; + } + case FILTER_BY_CAMERA_SHORTCUT: { + return new SingleItemAlbum(path, mCameraShortcutItem); + } + case FILTER_BY_CAMERA_SHORTCUT_ITEM: { + return mCameraShortcutItem; + } + default: + throw new RuntimeException("bad path: " + path); + } + } +} diff --git a/src/com/android/gallery3d/data/FilterTypeSet.java b/src/com/android/gallery3d/data/FilterTypeSet.java new file mode 100644 index 000000000..477ef73ad --- /dev/null +++ b/src/com/android/gallery3d/data/FilterTypeSet.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import java.util.ArrayList; + +// FilterTypeSet filters a base MediaSet according to a matching media type. +public class FilterTypeSet extends MediaSet implements ContentListener { + @SuppressWarnings("unused") + private static final String TAG = "FilterTypeSet"; + + private final DataManager mDataManager; + private final MediaSet mBaseSet; + private final int mMediaType; + private final ArrayList<Path> mPaths = new ArrayList<Path>(); + private final ArrayList<MediaSet> mAlbums = new ArrayList<MediaSet>(); + + public FilterTypeSet(Path path, DataManager dataManager, MediaSet baseSet, + int mediaType) { + super(path, INVALID_DATA_VERSION); + mDataManager = dataManager; + mBaseSet = baseSet; + mMediaType = mediaType; + mBaseSet.addContentListener(this); + } + + @Override + public String getName() { + return mBaseSet.getName(); + } + + @Override + public MediaSet getSubMediaSet(int index) { + return mAlbums.get(index); + } + + @Override + public int getSubMediaSetCount() { + return mAlbums.size(); + } + + @Override + public int getMediaItemCount() { + return mPaths.size(); + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + return ClusterAlbum.getMediaItemFromPath( + mPaths, start, count, mDataManager); + } + + @Override + public long reload() { + if (mBaseSet.reload() > mDataVersion) { + updateData(); + mDataVersion = nextVersionNumber(); + } + return mDataVersion; + } + + @Override + public void onContentDirty() { + notifyContentChanged(); + } + + private void updateData() { + // Albums + mAlbums.clear(); + String basePath = "/filter/mediatype/" + mMediaType; + + for (int i = 0, n = mBaseSet.getSubMediaSetCount(); i < n; i++) { + MediaSet set = mBaseSet.getSubMediaSet(i); + String filteredPath = basePath + "/{" + set.getPath().toString() + "}"; + MediaSet filteredSet = mDataManager.getMediaSet(filteredPath); + filteredSet.reload(); + if (filteredSet.getMediaItemCount() > 0 + || filteredSet.getSubMediaSetCount() > 0) { + mAlbums.add(filteredSet); + } + } + + // Items + mPaths.clear(); + final int total = mBaseSet.getMediaItemCount(); + final Path[] buf = new Path[total]; + + mBaseSet.enumerateMediaItems(new MediaSet.ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + if (item.getMediaType() == mMediaType) { + if (index < 0 || index >= total) return; + Path path = item.getPath(); + buf[index] = path; + } + } + }); + + for (int i = 0; i < total; i++) { + if (buf[i] != null) { + mPaths.add(buf[i]); + } + } + } + + @Override + public int getSupportedOperations() { + return SUPPORT_SHARE | SUPPORT_DELETE; + } + + @Override + public void delete() { + ItemConsumer consumer = new ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + if ((item.getSupportedOperations() & SUPPORT_DELETE) != 0) { + item.delete(); + } + } + }; + mDataManager.mapMediaItems(mPaths, consumer, 0); + } +} diff --git a/src/com/android/gallery3d/data/ImageCacheRequest.java b/src/com/android/gallery3d/data/ImageCacheRequest.java new file mode 100644 index 000000000..6cbc5c5ea --- /dev/null +++ b/src/com/android/gallery3d/data/ImageCacheRequest.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.data.BytesBufferPool.BytesBuffer; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +abstract class ImageCacheRequest implements Job<Bitmap> { + private static final String TAG = "ImageCacheRequest"; + + protected GalleryApp mApplication; + private Path mPath; + private int mType; + private int mTargetSize; + private long mTimeModified; + + public ImageCacheRequest(GalleryApp application, + Path path, long timeModified, int type, int targetSize) { + mApplication = application; + mPath = path; + mType = type; + mTargetSize = targetSize; + mTimeModified = timeModified; + } + + private String debugTag() { + return mPath + "," + mTimeModified + "," + + ((mType == MediaItem.TYPE_THUMBNAIL) ? "THUMB" : + (mType == MediaItem.TYPE_MICROTHUMBNAIL) ? "MICROTHUMB" : "?"); + } + + @Override + public Bitmap run(JobContext jc) { + ImageCacheService cacheService = mApplication.getImageCacheService(); + + BytesBuffer buffer = MediaItem.getBytesBufferPool().get(); + try { + boolean found = cacheService.getImageData(mPath, mTimeModified, mType, buffer); + if (jc.isCancelled()) return null; + if (found) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + Bitmap bitmap; + if (mType == MediaItem.TYPE_MICROTHUMBNAIL) { + bitmap = DecodeUtils.decodeUsingPool(jc, + buffer.data, buffer.offset, buffer.length, options); + } else { + bitmap = DecodeUtils.decodeUsingPool(jc, + buffer.data, buffer.offset, buffer.length, options); + } + if (bitmap == null && !jc.isCancelled()) { + Log.w(TAG, "decode cached failed " + debugTag()); + } + return bitmap; + } + } finally { + MediaItem.getBytesBufferPool().recycle(buffer); + } + Bitmap bitmap = onDecodeOriginal(jc, mType); + if (jc.isCancelled()) return null; + + if (bitmap == null) { + Log.w(TAG, "decode orig failed " + debugTag()); + return null; + } + + if (mType == MediaItem.TYPE_MICROTHUMBNAIL) { + bitmap = BitmapUtils.resizeAndCropCenter(bitmap, mTargetSize, true); + } else { + bitmap = BitmapUtils.resizeDownBySideLength(bitmap, mTargetSize, true); + } + if (jc.isCancelled()) return null; + + byte[] array = BitmapUtils.compressToBytes(bitmap); + if (jc.isCancelled()) return null; + + cacheService.putImageData(mPath, mTimeModified, mType, array); + return bitmap; + } + + public abstract Bitmap onDecodeOriginal(JobContext jc, int targetSize); +} diff --git a/src/com/android/gallery3d/data/ImageCacheService.java b/src/com/android/gallery3d/data/ImageCacheService.java new file mode 100644 index 000000000..1c7cb8c5e --- /dev/null +++ b/src/com/android/gallery3d/data/ImageCacheService.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.Context; + +import com.android.gallery3d.common.BlobCache; +import com.android.gallery3d.common.BlobCache.LookupRequest; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.data.BytesBufferPool.BytesBuffer; +import com.android.gallery3d.util.CacheManager; +import com.android.gallery3d.util.GalleryUtils; + +import java.io.IOException; +import java.nio.ByteBuffer; + +public class ImageCacheService { + @SuppressWarnings("unused") + private static final String TAG = "ImageCacheService"; + + private static final String IMAGE_CACHE_FILE = "imgcache"; + private static final int IMAGE_CACHE_MAX_ENTRIES = 5000; + private static final int IMAGE_CACHE_MAX_BYTES = 200 * 1024 * 1024; + private static final int IMAGE_CACHE_VERSION = 7; + + private BlobCache mCache; + + public ImageCacheService(Context context) { + mCache = CacheManager.getCache(context, IMAGE_CACHE_FILE, + IMAGE_CACHE_MAX_ENTRIES, IMAGE_CACHE_MAX_BYTES, + IMAGE_CACHE_VERSION); + } + + /** + * Gets the cached image data for the given <code>path</code>, + * <code>timeModified</code> and <code>type</code>. + * + * The image data will be stored in <code>buffer.data</code>, started from + * <code>buffer.offset</code> for <code>buffer.length</code> bytes. If the + * buffer.data is not big enough, a new byte array will be allocated and returned. + * + * @return true if the image data is found; false if not found. + */ + public boolean getImageData(Path path, long timeModified, int type, BytesBuffer buffer) { + byte[] key = makeKey(path, timeModified, type); + long cacheKey = Utils.crc64Long(key); + try { + LookupRequest request = new LookupRequest(); + request.key = cacheKey; + request.buffer = buffer.data; + synchronized (mCache) { + if (!mCache.lookup(request)) return false; + } + if (isSameKey(key, request.buffer)) { + buffer.data = request.buffer; + buffer.offset = key.length; + buffer.length = request.length - buffer.offset; + return true; + } + } catch (IOException ex) { + // ignore. + } + return false; + } + + public void putImageData(Path path, long timeModified, int type, byte[] value) { + byte[] key = makeKey(path, timeModified, type); + long cacheKey = Utils.crc64Long(key); + ByteBuffer buffer = ByteBuffer.allocate(key.length + value.length); + buffer.put(key); + buffer.put(value); + synchronized (mCache) { + try { + mCache.insert(cacheKey, buffer.array()); + } catch (IOException ex) { + // ignore. + } + } + } + + public void clearImageData(Path path, long timeModified, int type) { + byte[] key = makeKey(path, timeModified, type); + long cacheKey = Utils.crc64Long(key); + synchronized (mCache) { + try { + mCache.clearEntry(cacheKey); + } catch (IOException ex) { + // ignore. + } + } + } + + private static byte[] makeKey(Path path, long timeModified, int type) { + return GalleryUtils.getBytes(path.toString() + "+" + timeModified + "+" + type); + } + + private static boolean isSameKey(byte[] key, byte[] buffer) { + int n = key.length; + if (buffer.length < n) { + return false; + } + for (int i = 0; i < n; ++i) { + if (key[i] != buffer[i]) { + return false; + } + } + return true; + } +} diff --git a/src/com/android/gallery3d/data/LocalAlbum.java b/src/com/android/gallery3d/data/LocalAlbum.java new file mode 100644 index 000000000..7b7015af6 --- /dev/null +++ b/src/com/android/gallery3d/data/LocalAlbum.java @@ -0,0 +1,325 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.ContentResolver; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.Environment; +import android.provider.MediaStore; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.Images.ImageColumns; +import android.provider.MediaStore.Video; +import android.provider.MediaStore.Video.VideoColumns; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.BucketNames; +import com.android.gallery3d.util.GalleryUtils; +import com.android.gallery3d.util.MediaSetUtils; + +import java.io.File; +import java.util.ArrayList; + +// LocalAlbumSet lists all media items in one bucket on local storage. +// The media items need to be all images or all videos, but not both. +public class LocalAlbum extends MediaSet { + private static final String TAG = "LocalAlbum"; + private static final String[] COUNT_PROJECTION = { "count(*)" }; + + private static final int INVALID_COUNT = -1; + private final String mWhereClause; + private final String mOrderClause; + private final Uri mBaseUri; + private final String[] mProjection; + + private final GalleryApp mApplication; + private final ContentResolver mResolver; + private final int mBucketId; + private final String mName; + private final boolean mIsImage; + private final ChangeNotifier mNotifier; + private final Path mItemPath; + private int mCachedCount = INVALID_COUNT; + + public LocalAlbum(Path path, GalleryApp application, int bucketId, + boolean isImage, String name) { + super(path, nextVersionNumber()); + mApplication = application; + mResolver = application.getContentResolver(); + mBucketId = bucketId; + mName = name; + mIsImage = isImage; + + if (isImage) { + mWhereClause = ImageColumns.BUCKET_ID + " = ?"; + mOrderClause = ImageColumns.DATE_TAKEN + " DESC, " + + ImageColumns._ID + " DESC"; + mBaseUri = Images.Media.EXTERNAL_CONTENT_URI; + mProjection = LocalImage.PROJECTION; + mItemPath = LocalImage.ITEM_PATH; + } else { + mWhereClause = VideoColumns.BUCKET_ID + " = ?"; + mOrderClause = VideoColumns.DATE_TAKEN + " DESC, " + + VideoColumns._ID + " DESC"; + mBaseUri = Video.Media.EXTERNAL_CONTENT_URI; + mProjection = LocalVideo.PROJECTION; + mItemPath = LocalVideo.ITEM_PATH; + } + + mNotifier = new ChangeNotifier(this, mBaseUri, application); + } + + public LocalAlbum(Path path, GalleryApp application, int bucketId, + boolean isImage) { + this(path, application, bucketId, isImage, + BucketHelper.getBucketName( + application.getContentResolver(), bucketId)); + } + + @Override + public boolean isCameraRoll() { + return mBucketId == MediaSetUtils.CAMERA_BUCKET_ID; + } + + @Override + public Uri getContentUri() { + if (mIsImage) { + return MediaStore.Images.Media.EXTERNAL_CONTENT_URI.buildUpon() + .appendQueryParameter(LocalSource.KEY_BUCKET_ID, + String.valueOf(mBucketId)).build(); + } else { + return MediaStore.Video.Media.EXTERNAL_CONTENT_URI.buildUpon() + .appendQueryParameter(LocalSource.KEY_BUCKET_ID, + String.valueOf(mBucketId)).build(); + } + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + DataManager dataManager = mApplication.getDataManager(); + Uri uri = mBaseUri.buildUpon() + .appendQueryParameter("limit", start + "," + count).build(); + ArrayList<MediaItem> list = new ArrayList<MediaItem>(); + GalleryUtils.assertNotInRenderThread(); + Cursor cursor = mResolver.query( + uri, mProjection, mWhereClause, + new String[]{String.valueOf(mBucketId)}, + mOrderClause); + if (cursor == null) { + Log.w(TAG, "query fail: " + uri); + return list; + } + + try { + while (cursor.moveToNext()) { + int id = cursor.getInt(0); // _id must be in the first column + Path childPath = mItemPath.getChild(id); + MediaItem item = loadOrUpdateItem(childPath, cursor, + dataManager, mApplication, mIsImage); + list.add(item); + } + } finally { + cursor.close(); + } + return list; + } + + private static MediaItem loadOrUpdateItem(Path path, Cursor cursor, + DataManager dataManager, GalleryApp app, boolean isImage) { + synchronized (DataManager.LOCK) { + LocalMediaItem item = (LocalMediaItem) dataManager.peekMediaObject(path); + if (item == null) { + if (isImage) { + item = new LocalImage(path, app, cursor); + } else { + item = new LocalVideo(path, app, cursor); + } + } else { + item.updateContent(cursor); + } + return item; + } + } + + // The pids array are sorted by the (path) id. + public static MediaItem[] getMediaItemById( + GalleryApp application, boolean isImage, ArrayList<Integer> ids) { + // get the lower and upper bound of (path) id + MediaItem[] result = new MediaItem[ids.size()]; + if (ids.isEmpty()) return result; + int idLow = ids.get(0); + int idHigh = ids.get(ids.size() - 1); + + // prepare the query parameters + Uri baseUri; + String[] projection; + Path itemPath; + if (isImage) { + baseUri = Images.Media.EXTERNAL_CONTENT_URI; + projection = LocalImage.PROJECTION; + itemPath = LocalImage.ITEM_PATH; + } else { + baseUri = Video.Media.EXTERNAL_CONTENT_URI; + projection = LocalVideo.PROJECTION; + itemPath = LocalVideo.ITEM_PATH; + } + + ContentResolver resolver = application.getContentResolver(); + DataManager dataManager = application.getDataManager(); + Cursor cursor = resolver.query(baseUri, projection, "_id BETWEEN ? AND ?", + new String[]{String.valueOf(idLow), String.valueOf(idHigh)}, + "_id"); + if (cursor == null) { + Log.w(TAG, "query fail" + baseUri); + return result; + } + try { + int n = ids.size(); + int i = 0; + + while (i < n && cursor.moveToNext()) { + int id = cursor.getInt(0); // _id must be in the first column + + // Match id with the one on the ids list. + if (ids.get(i) > id) { + continue; + } + + while (ids.get(i) < id) { + if (++i >= n) { + return result; + } + } + + Path childPath = itemPath.getChild(id); + MediaItem item = loadOrUpdateItem(childPath, cursor, dataManager, + application, isImage); + result[i] = item; + ++i; + } + return result; + } finally { + cursor.close(); + } + } + + public static Cursor getItemCursor(ContentResolver resolver, Uri uri, + String[] projection, int id) { + return resolver.query(uri, projection, "_id=?", + new String[]{String.valueOf(id)}, null); + } + + @Override + public int getMediaItemCount() { + if (mCachedCount == INVALID_COUNT) { + Cursor cursor = mResolver.query( + mBaseUri, COUNT_PROJECTION, mWhereClause, + new String[]{String.valueOf(mBucketId)}, null); + if (cursor == null) { + Log.w(TAG, "query fail"); + return 0; + } + try { + Utils.assertTrue(cursor.moveToNext()); + mCachedCount = cursor.getInt(0); + } finally { + cursor.close(); + } + } + return mCachedCount; + } + + @Override + public String getName() { + return getLocalizedName(mApplication.getResources(), mBucketId, mName); + } + + @Override + public long reload() { + if (mNotifier.isDirty()) { + mDataVersion = nextVersionNumber(); + mCachedCount = INVALID_COUNT; + } + return mDataVersion; + } + + @Override + public int getSupportedOperations() { + return SUPPORT_DELETE | SUPPORT_SHARE | SUPPORT_INFO; + } + + @Override + public void delete() { + GalleryUtils.assertNotInRenderThread(); + mResolver.delete(mBaseUri, mWhereClause, + new String[]{String.valueOf(mBucketId)}); + } + + @Override + public boolean isLeafAlbum() { + return true; + } + + public static String getLocalizedName(Resources res, int bucketId, + String name) { + if (bucketId == MediaSetUtils.CAMERA_BUCKET_ID) { + return res.getString(R.string.folder_camera); + } else if (bucketId == MediaSetUtils.DOWNLOAD_BUCKET_ID) { + return res.getString(R.string.folder_download); + } else if (bucketId == MediaSetUtils.IMPORTED_BUCKET_ID) { + return res.getString(R.string.folder_imported); + } else if (bucketId == MediaSetUtils.SNAPSHOT_BUCKET_ID) { + return res.getString(R.string.folder_screenshot); + } else if (bucketId == MediaSetUtils.EDITED_ONLINE_PHOTOS_BUCKET_ID) { + return res.getString(R.string.folder_edited_online_photos); + } else { + return name; + } + } + + // Relative path is the absolute path minus external storage path + public static String getRelativePath(int bucketId) { + String relativePath = "/"; + if (bucketId == MediaSetUtils.CAMERA_BUCKET_ID) { + relativePath += BucketNames.CAMERA; + } else if (bucketId == MediaSetUtils.DOWNLOAD_BUCKET_ID) { + relativePath += BucketNames.DOWNLOAD; + } else if (bucketId == MediaSetUtils.IMPORTED_BUCKET_ID) { + relativePath += BucketNames.IMPORTED; + } else if (bucketId == MediaSetUtils.SNAPSHOT_BUCKET_ID) { + relativePath += BucketNames.SCREENSHOTS; + } else if (bucketId == MediaSetUtils.EDITED_ONLINE_PHOTOS_BUCKET_ID) { + relativePath += BucketNames.EDITED_ONLINE_PHOTOS; + } else { + // If the first few cases didn't hit the matching path, do a + // thorough search in the local directories. + File extStorage = Environment.getExternalStorageDirectory(); + String path = GalleryUtils.searchDirForPath(extStorage, bucketId); + if (path == null) { + Log.w(TAG, "Relative path for bucket id: " + bucketId + " is not found."); + relativePath = null; + } else { + relativePath = path.substring(extStorage.getAbsolutePath().length()); + } + } + return relativePath; + } + +} diff --git a/src/com/android/gallery3d/data/LocalAlbumSet.java b/src/com/android/gallery3d/data/LocalAlbumSet.java new file mode 100644 index 000000000..b2b4b8c5d --- /dev/null +++ b/src/com/android/gallery3d/data/LocalAlbumSet.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.net.Uri; +import android.os.Handler; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.Video; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.data.BucketHelper.BucketEntry; +import com.android.gallery3d.util.Future; +import com.android.gallery3d.util.FutureListener; +import com.android.gallery3d.util.MediaSetUtils; +import com.android.gallery3d.util.ThreadPool; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import java.util.ArrayList; +import java.util.Comparator; + +// LocalAlbumSet lists all image or video albums in the local storage. +// The path should be "/local/image", "local/video" or "/local/all" +public class LocalAlbumSet extends MediaSet + implements FutureListener<ArrayList<MediaSet>> { + @SuppressWarnings("unused") + private static final String TAG = "LocalAlbumSet"; + + public static final Path PATH_ALL = Path.fromString("/local/all"); + public static final Path PATH_IMAGE = Path.fromString("/local/image"); + public static final Path PATH_VIDEO = Path.fromString("/local/video"); + + private static final Uri[] mWatchUris = + {Images.Media.EXTERNAL_CONTENT_URI, Video.Media.EXTERNAL_CONTENT_URI}; + + private final GalleryApp mApplication; + private final int mType; + private ArrayList<MediaSet> mAlbums = new ArrayList<MediaSet>(); + private final ChangeNotifier mNotifier; + private final String mName; + private final Handler mHandler; + private boolean mIsLoading; + + private Future<ArrayList<MediaSet>> mLoadTask; + private ArrayList<MediaSet> mLoadBuffer; + + public LocalAlbumSet(Path path, GalleryApp application) { + super(path, nextVersionNumber()); + mApplication = application; + mHandler = new Handler(application.getMainLooper()); + mType = getTypeFromPath(path); + mNotifier = new ChangeNotifier(this, mWatchUris, application); + mName = application.getResources().getString( + R.string.set_label_local_albums); + } + + private static int getTypeFromPath(Path path) { + String name[] = path.split(); + if (name.length < 2) { + throw new IllegalArgumentException(path.toString()); + } + return getTypeFromString(name[1]); + } + + @Override + public MediaSet getSubMediaSet(int index) { + return mAlbums.get(index); + } + + @Override + public int getSubMediaSetCount() { + return mAlbums.size(); + } + + @Override + public String getName() { + return mName; + } + + private static int findBucket(BucketEntry entries[], int bucketId) { + for (int i = 0, n = entries.length; i < n; ++i) { + if (entries[i].bucketId == bucketId) return i; + } + return -1; + } + + private class AlbumsLoader implements ThreadPool.Job<ArrayList<MediaSet>> { + + @Override + @SuppressWarnings("unchecked") + public ArrayList<MediaSet> run(JobContext jc) { + // Note: it will be faster if we only select media_type and bucket_id. + // need to test the performance if that is worth + BucketEntry[] entries = BucketHelper.loadBucketEntries( + jc, mApplication.getContentResolver(), mType); + + if (jc.isCancelled()) return null; + + int offset = 0; + // Move camera and download bucket to the front, while keeping the + // order of others. + int index = findBucket(entries, MediaSetUtils.CAMERA_BUCKET_ID); + if (index != -1) { + circularShiftRight(entries, offset++, index); + } + index = findBucket(entries, MediaSetUtils.DOWNLOAD_BUCKET_ID); + if (index != -1) { + circularShiftRight(entries, offset++, index); + } + + ArrayList<MediaSet> albums = new ArrayList<MediaSet>(); + DataManager dataManager = mApplication.getDataManager(); + for (BucketEntry entry : entries) { + MediaSet album = getLocalAlbum(dataManager, + mType, mPath, entry.bucketId, entry.bucketName); + albums.add(album); + } + return albums; + } + } + + private MediaSet getLocalAlbum( + DataManager manager, int type, Path parent, int id, String name) { + synchronized (DataManager.LOCK) { + Path path = parent.getChild(id); + MediaObject object = manager.peekMediaObject(path); + if (object != null) return (MediaSet) object; + switch (type) { + case MEDIA_TYPE_IMAGE: + return new LocalAlbum(path, mApplication, id, true, name); + case MEDIA_TYPE_VIDEO: + return new LocalAlbum(path, mApplication, id, false, name); + case MEDIA_TYPE_ALL: + Comparator<MediaItem> comp = DataManager.sDateTakenComparator; + return new LocalMergeAlbum(path, comp, new MediaSet[] { + getLocalAlbum(manager, MEDIA_TYPE_IMAGE, PATH_IMAGE, id, name), + getLocalAlbum(manager, MEDIA_TYPE_VIDEO, PATH_VIDEO, id, name)}, id); + } + throw new IllegalArgumentException(String.valueOf(type)); + } + } + + @Override + public synchronized boolean isLoading() { + return mIsLoading; + } + + @Override + // synchronized on this function for + // 1. Prevent calling reload() concurrently. + // 2. Prevent calling onFutureDone() and reload() concurrently + public synchronized long reload() { + if (mNotifier.isDirty()) { + if (mLoadTask != null) mLoadTask.cancel(); + mIsLoading = true; + mLoadTask = mApplication.getThreadPool().submit(new AlbumsLoader(), this); + } + if (mLoadBuffer != null) { + mAlbums = mLoadBuffer; + mLoadBuffer = null; + for (MediaSet album : mAlbums) { + album.reload(); + } + mDataVersion = nextVersionNumber(); + } + return mDataVersion; + } + + @Override + public synchronized void onFutureDone(Future<ArrayList<MediaSet>> future) { + if (mLoadTask != future) return; // ignore, wait for the latest task + mLoadBuffer = future.get(); + mIsLoading = false; + if (mLoadBuffer == null) mLoadBuffer = new ArrayList<MediaSet>(); + mHandler.post(new Runnable() { + @Override + public void run() { + notifyContentChanged(); + } + }); + } + + // For debug only. Fake there is a ContentObserver.onChange() event. + void fakeChange() { + mNotifier.fakeChange(); + } + + // Circular shift the array range from a[i] to a[j] (inclusive). That is, + // a[i] -> a[i+1] -> a[i+2] -> ... -> a[j], and a[j] -> a[i] + private static <T> void circularShiftRight(T[] array, int i, int j) { + T temp = array[j]; + for (int k = j; k > i; k--) { + array[k] = array[k - 1]; + } + array[i] = temp; + } +} diff --git a/src/com/android/gallery3d/data/LocalImage.java b/src/com/android/gallery3d/data/LocalImage.java new file mode 100644 index 000000000..cc70dd457 --- /dev/null +++ b/src/com/android/gallery3d/data/LocalImage.java @@ -0,0 +1,355 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapRegionDecoder; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.Images.ImageColumns; +import android.provider.MediaStore.MediaColumns; +import android.util.Log; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.app.PanoramaMetadataSupport; +import com.android.gallery3d.app.StitchingProgressManager; +import com.android.gallery3d.common.ApiHelper; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.exif.ExifInterface; +import com.android.gallery3d.exif.ExifTag; +import com.android.gallery3d.filtershow.tools.SaveImage; +import com.android.gallery3d.util.GalleryUtils; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; +import com.android.gallery3d.util.UpdateHelper; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +// LocalImage represents an image in the local storage. +public class LocalImage extends LocalMediaItem { + private static final String TAG = "LocalImage"; + + static final Path ITEM_PATH = Path.fromString("/local/image/item"); + + // Must preserve order between these indices and the order of the terms in + // the following PROJECTION array. + private static final int INDEX_ID = 0; + private static final int INDEX_CAPTION = 1; + private static final int INDEX_MIME_TYPE = 2; + private static final int INDEX_LATITUDE = 3; + private static final int INDEX_LONGITUDE = 4; + private static final int INDEX_DATE_TAKEN = 5; + private static final int INDEX_DATE_ADDED = 6; + private static final int INDEX_DATE_MODIFIED = 7; + private static final int INDEX_DATA = 8; + private static final int INDEX_ORIENTATION = 9; + private static final int INDEX_BUCKET_ID = 10; + private static final int INDEX_SIZE = 11; + private static final int INDEX_WIDTH = 12; + private static final int INDEX_HEIGHT = 13; + + static final String[] PROJECTION = { + ImageColumns._ID, // 0 + ImageColumns.TITLE, // 1 + ImageColumns.MIME_TYPE, // 2 + ImageColumns.LATITUDE, // 3 + ImageColumns.LONGITUDE, // 4 + ImageColumns.DATE_TAKEN, // 5 + ImageColumns.DATE_ADDED, // 6 + ImageColumns.DATE_MODIFIED, // 7 + ImageColumns.DATA, // 8 + ImageColumns.ORIENTATION, // 9 + ImageColumns.BUCKET_ID, // 10 + ImageColumns.SIZE, // 11 + "0", // 12 + "0" // 13 + }; + + static { + updateWidthAndHeightProjection(); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private static void updateWidthAndHeightProjection() { + if (ApiHelper.HAS_MEDIA_COLUMNS_WIDTH_AND_HEIGHT) { + PROJECTION[INDEX_WIDTH] = MediaColumns.WIDTH; + PROJECTION[INDEX_HEIGHT] = MediaColumns.HEIGHT; + } + } + + private final GalleryApp mApplication; + + public int rotation; + + private PanoramaMetadataSupport mPanoramaMetadata = new PanoramaMetadataSupport(this); + + public LocalImage(Path path, GalleryApp application, Cursor cursor) { + super(path, nextVersionNumber()); + mApplication = application; + loadFromCursor(cursor); + } + + public LocalImage(Path path, GalleryApp application, int id) { + super(path, nextVersionNumber()); + mApplication = application; + ContentResolver resolver = mApplication.getContentResolver(); + Uri uri = Images.Media.EXTERNAL_CONTENT_URI; + Cursor cursor = LocalAlbum.getItemCursor(resolver, uri, PROJECTION, id); + if (cursor == null) { + throw new RuntimeException("cannot get cursor for: " + path); + } + try { + if (cursor.moveToNext()) { + loadFromCursor(cursor); + } else { + throw new RuntimeException("cannot find data for: " + path); + } + } finally { + cursor.close(); + } + } + + private void loadFromCursor(Cursor cursor) { + id = cursor.getInt(INDEX_ID); + caption = cursor.getString(INDEX_CAPTION); + mimeType = cursor.getString(INDEX_MIME_TYPE); + latitude = cursor.getDouble(INDEX_LATITUDE); + longitude = cursor.getDouble(INDEX_LONGITUDE); + dateTakenInMs = cursor.getLong(INDEX_DATE_TAKEN); + dateAddedInSec = cursor.getLong(INDEX_DATE_ADDED); + dateModifiedInSec = cursor.getLong(INDEX_DATE_MODIFIED); + filePath = cursor.getString(INDEX_DATA); + rotation = cursor.getInt(INDEX_ORIENTATION); + bucketId = cursor.getInt(INDEX_BUCKET_ID); + fileSize = cursor.getLong(INDEX_SIZE); + width = cursor.getInt(INDEX_WIDTH); + height = cursor.getInt(INDEX_HEIGHT); + } + + @Override + protected boolean updateFromCursor(Cursor cursor) { + UpdateHelper uh = new UpdateHelper(); + id = uh.update(id, cursor.getInt(INDEX_ID)); + caption = uh.update(caption, cursor.getString(INDEX_CAPTION)); + mimeType = uh.update(mimeType, cursor.getString(INDEX_MIME_TYPE)); + latitude = uh.update(latitude, cursor.getDouble(INDEX_LATITUDE)); + longitude = uh.update(longitude, cursor.getDouble(INDEX_LONGITUDE)); + dateTakenInMs = uh.update( + dateTakenInMs, cursor.getLong(INDEX_DATE_TAKEN)); + dateAddedInSec = uh.update( + dateAddedInSec, cursor.getLong(INDEX_DATE_ADDED)); + dateModifiedInSec = uh.update( + dateModifiedInSec, cursor.getLong(INDEX_DATE_MODIFIED)); + filePath = uh.update(filePath, cursor.getString(INDEX_DATA)); + rotation = uh.update(rotation, cursor.getInt(INDEX_ORIENTATION)); + bucketId = uh.update(bucketId, cursor.getInt(INDEX_BUCKET_ID)); + fileSize = uh.update(fileSize, cursor.getLong(INDEX_SIZE)); + width = uh.update(width, cursor.getInt(INDEX_WIDTH)); + height = uh.update(height, cursor.getInt(INDEX_HEIGHT)); + return uh.isUpdated(); + } + + @Override + public Job<Bitmap> requestImage(int type) { + return new LocalImageRequest(mApplication, mPath, dateModifiedInSec, + type, filePath); + } + + public static class LocalImageRequest extends ImageCacheRequest { + private String mLocalFilePath; + + LocalImageRequest(GalleryApp application, Path path, long timeModified, + int type, String localFilePath) { + super(application, path, timeModified, type, + MediaItem.getTargetSize(type)); + mLocalFilePath = localFilePath; + } + + @Override + public Bitmap onDecodeOriginal(JobContext jc, final int type) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + int targetSize = MediaItem.getTargetSize(type); + + // try to decode from JPEG EXIF + if (type == MediaItem.TYPE_MICROTHUMBNAIL) { + ExifInterface exif = new ExifInterface(); + byte[] thumbData = null; + try { + exif.readExif(mLocalFilePath); + thumbData = exif.getThumbnail(); + } catch (FileNotFoundException e) { + Log.w(TAG, "failed to find file to read thumbnail: " + mLocalFilePath); + } catch (IOException e) { + Log.w(TAG, "failed to get thumbnail from: " + mLocalFilePath); + } + if (thumbData != null) { + Bitmap bitmap = DecodeUtils.decodeIfBigEnough( + jc, thumbData, options, targetSize); + if (bitmap != null) return bitmap; + } + } + + return DecodeUtils.decodeThumbnail(jc, mLocalFilePath, options, targetSize, type); + } + } + + @Override + public Job<BitmapRegionDecoder> requestLargeImage() { + return new LocalLargeImageRequest(filePath); + } + + public static class LocalLargeImageRequest + implements Job<BitmapRegionDecoder> { + String mLocalFilePath; + + public LocalLargeImageRequest(String localFilePath) { + mLocalFilePath = localFilePath; + } + + @Override + public BitmapRegionDecoder run(JobContext jc) { + return DecodeUtils.createBitmapRegionDecoder(jc, mLocalFilePath, false); + } + } + + @Override + public int getSupportedOperations() { + StitchingProgressManager progressManager = mApplication.getStitchingProgressManager(); + if (progressManager != null && progressManager.getProgress(getContentUri()) != null) { + return 0; // doesn't support anything while stitching! + } + int operation = SUPPORT_DELETE | SUPPORT_SHARE | SUPPORT_CROP + | SUPPORT_SETAS | SUPPORT_EDIT | SUPPORT_INFO; + if (BitmapUtils.isSupportedByRegionDecoder(mimeType)) { + operation |= SUPPORT_FULL_IMAGE; + } + + if (BitmapUtils.isRotationSupported(mimeType)) { + operation |= SUPPORT_ROTATE; + } + + if (GalleryUtils.isValidLocation(latitude, longitude)) { + operation |= SUPPORT_SHOW_ON_MAP; + } + return operation; + } + + @Override + public void getPanoramaSupport(PanoramaSupportCallback callback) { + mPanoramaMetadata.getPanoramaSupport(mApplication, callback); + } + + @Override + public void clearCachedPanoramaSupport() { + mPanoramaMetadata.clearCachedValues(); + } + + @Override + public void delete() { + GalleryUtils.assertNotInRenderThread(); + Uri baseUri = Images.Media.EXTERNAL_CONTENT_URI; + ContentResolver contentResolver = mApplication.getContentResolver(); + SaveImage.deleteAuxFiles(contentResolver, getContentUri()); + contentResolver.delete(baseUri, "_id=?", + new String[]{String.valueOf(id)}); + } + + @Override + public void rotate(int degrees) { + GalleryUtils.assertNotInRenderThread(); + Uri baseUri = Images.Media.EXTERNAL_CONTENT_URI; + ContentValues values = new ContentValues(); + int rotation = (this.rotation + degrees) % 360; + if (rotation < 0) rotation += 360; + + if (mimeType.equalsIgnoreCase("image/jpeg")) { + ExifInterface exifInterface = new ExifInterface(); + ExifTag tag = exifInterface.buildTag(ExifInterface.TAG_ORIENTATION, + ExifInterface.getOrientationValueForRotation(rotation)); + if(tag != null) { + exifInterface.setTag(tag); + try { + exifInterface.forceRewriteExif(filePath); + fileSize = new File(filePath).length(); + values.put(Images.Media.SIZE, fileSize); + } catch (FileNotFoundException e) { + Log.w(TAG, "cannot find file to set exif: " + filePath); + } catch (IOException e) { + Log.w(TAG, "cannot set exif data: " + filePath); + } + } else { + Log.w(TAG, "Could not build tag: " + ExifInterface.TAG_ORIENTATION); + } + } + + values.put(Images.Media.ORIENTATION, rotation); + mApplication.getContentResolver().update(baseUri, values, "_id=?", + new String[]{String.valueOf(id)}); + } + + @Override + public Uri getContentUri() { + Uri baseUri = Images.Media.EXTERNAL_CONTENT_URI; + return baseUri.buildUpon().appendPath(String.valueOf(id)).build(); + } + + @Override + public int getMediaType() { + return MEDIA_TYPE_IMAGE; + } + + @Override + public MediaDetails getDetails() { + MediaDetails details = super.getDetails(); + details.addDetail(MediaDetails.INDEX_ORIENTATION, Integer.valueOf(rotation)); + if (MIME_TYPE_JPEG.equals(mimeType)) { + // ExifInterface returns incorrect values for photos in other format. + // For example, the width and height of an webp images is always '0'. + MediaDetails.extractExifInfo(details, filePath); + } + return details; + } + + @Override + public int getRotation() { + return rotation; + } + + @Override + public int getWidth() { + return width; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public String getFilePath() { + return filePath; + } +} diff --git a/src/com/android/gallery3d/data/LocalMediaItem.java b/src/com/android/gallery3d/data/LocalMediaItem.java new file mode 100644 index 000000000..7e003cd3a --- /dev/null +++ b/src/com/android/gallery3d/data/LocalMediaItem.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.database.Cursor; + +import com.android.gallery3d.util.GalleryUtils; + +import java.text.DateFormat; +import java.util.Date; + +// +// LocalMediaItem is an abstract class captures those common fields +// in LocalImage and LocalVideo. +// +public abstract class LocalMediaItem extends MediaItem { + + @SuppressWarnings("unused") + private static final String TAG = "LocalMediaItem"; + + // database fields + public int id; + public String caption; + public String mimeType; + public long fileSize; + public double latitude = INVALID_LATLNG; + public double longitude = INVALID_LATLNG; + public long dateTakenInMs; + public long dateAddedInSec; + public long dateModifiedInSec; + public String filePath; + public int bucketId; + public int width; + public int height; + + public LocalMediaItem(Path path, long version) { + super(path, version); + } + + @Override + public long getDateInMs() { + return dateTakenInMs; + } + + @Override + public String getName() { + return caption; + } + + @Override + public void getLatLong(double[] latLong) { + latLong[0] = latitude; + latLong[1] = longitude; + } + + abstract protected boolean updateFromCursor(Cursor cursor); + + public int getBucketId() { + return bucketId; + } + + protected void updateContent(Cursor cursor) { + if (updateFromCursor(cursor)) { + mDataVersion = nextVersionNumber(); + } + } + + @Override + public MediaDetails getDetails() { + MediaDetails details = super.getDetails(); + details.addDetail(MediaDetails.INDEX_PATH, filePath); + details.addDetail(MediaDetails.INDEX_TITLE, caption); + DateFormat formater = DateFormat.getDateTimeInstance(); + details.addDetail(MediaDetails.INDEX_DATETIME, + formater.format(new Date(dateModifiedInSec * 1000))); + details.addDetail(MediaDetails.INDEX_WIDTH, width); + details.addDetail(MediaDetails.INDEX_HEIGHT, height); + + if (GalleryUtils.isValidLocation(latitude, longitude)) { + details.addDetail(MediaDetails.INDEX_LOCATION, new double[] {latitude, longitude}); + } + if (fileSize > 0) details.addDetail(MediaDetails.INDEX_SIZE, fileSize); + return details; + } + + @Override + public String getMimeType() { + return mimeType; + } + + @Override + public long getSize() { + return fileSize; + } +} diff --git a/src/com/android/gallery3d/data/LocalMergeAlbum.java b/src/com/android/gallery3d/data/LocalMergeAlbum.java new file mode 100644 index 000000000..f0b5e5726 --- /dev/null +++ b/src/com/android/gallery3d/data/LocalMergeAlbum.java @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.net.Uri; +import android.provider.MediaStore; + +import com.android.gallery3d.common.ApiHelper; + +import java.lang.ref.SoftReference; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.NoSuchElementException; +import java.util.SortedMap; +import java.util.TreeMap; + +// MergeAlbum merges items from two or more MediaSets. It uses a Comparator to +// determine the order of items. The items are assumed to be sorted in the input +// media sets (with the same order that the Comparator uses). +// +// This only handles MediaItems, not SubMediaSets. +public class LocalMergeAlbum extends MediaSet implements ContentListener { + @SuppressWarnings("unused") + private static final String TAG = "LocalMergeAlbum"; + private static final int PAGE_SIZE = 64; + + private final Comparator<MediaItem> mComparator; + private final MediaSet[] mSources; + + private FetchCache[] mFetcher; + private int mSupportedOperation; + private int mBucketId; + + // mIndex maps global position to the position of each underlying media sets. + private TreeMap<Integer, int[]> mIndex = new TreeMap<Integer, int[]>(); + + public LocalMergeAlbum( + Path path, Comparator<MediaItem> comparator, MediaSet[] sources, int bucketId) { + super(path, INVALID_DATA_VERSION); + mComparator = comparator; + mSources = sources; + mBucketId = bucketId; + for (MediaSet set : mSources) { + set.addContentListener(this); + } + reload(); + } + + @Override + public boolean isCameraRoll() { + if (mSources.length == 0) return false; + for(MediaSet set : mSources) { + if (!set.isCameraRoll()) return false; + } + return true; + } + + private void updateData() { + ArrayList<MediaSet> matches = new ArrayList<MediaSet>(); + int supported = mSources.length == 0 ? 0 : MediaItem.SUPPORT_ALL; + mFetcher = new FetchCache[mSources.length]; + for (int i = 0, n = mSources.length; i < n; ++i) { + mFetcher[i] = new FetchCache(mSources[i]); + supported &= mSources[i].getSupportedOperations(); + } + mSupportedOperation = supported; + mIndex.clear(); + mIndex.put(0, new int[mSources.length]); + } + + private void invalidateCache() { + for (int i = 0, n = mSources.length; i < n; i++) { + mFetcher[i].invalidate(); + } + mIndex.clear(); + mIndex.put(0, new int[mSources.length]); + } + + @Override + public Uri getContentUri() { + String bucketId = String.valueOf(mBucketId); + if (ApiHelper.HAS_MEDIA_PROVIDER_FILES_TABLE) { + return MediaStore.Files.getContentUri("external").buildUpon() + .appendQueryParameter(LocalSource.KEY_BUCKET_ID, bucketId) + .build(); + } else { + // We don't have a single URL for a merged image before ICS + // So we used the image's URL as a substitute. + return MediaStore.Images.Media.EXTERNAL_CONTENT_URI.buildUpon() + .appendQueryParameter(LocalSource.KEY_BUCKET_ID, bucketId) + .build(); + } + } + + @Override + public String getName() { + return mSources.length == 0 ? "" : mSources[0].getName(); + } + + @Override + public int getMediaItemCount() { + return getTotalMediaItemCount(); + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + + // First find the nearest mark position <= start. + SortedMap<Integer, int[]> head = mIndex.headMap(start + 1); + int markPos = head.lastKey(); + int[] subPos = head.get(markPos).clone(); + MediaItem[] slot = new MediaItem[mSources.length]; + + int size = mSources.length; + + // fill all slots + for (int i = 0; i < size; i++) { + slot[i] = mFetcher[i].getItem(subPos[i]); + } + + ArrayList<MediaItem> result = new ArrayList<MediaItem>(); + + for (int i = markPos; i < start + count; i++) { + int k = -1; // k points to the best slot up to now. + for (int j = 0; j < size; j++) { + if (slot[j] != null) { + if (k == -1 || mComparator.compare(slot[j], slot[k]) < 0) { + k = j; + } + } + } + + // If we don't have anything, all streams are exhausted. + if (k == -1) break; + + // Pick the best slot and refill it. + subPos[k]++; + if (i >= start) { + result.add(slot[k]); + } + slot[k] = mFetcher[k].getItem(subPos[k]); + + // Periodically leave a mark in the index, so we can come back later. + if ((i + 1) % PAGE_SIZE == 0) { + mIndex.put(i + 1, subPos.clone()); + } + } + + return result; + } + + @Override + public int getTotalMediaItemCount() { + int count = 0; + for (MediaSet set : mSources) { + count += set.getTotalMediaItemCount(); + } + return count; + } + + @Override + public long reload() { + boolean changed = false; + for (int i = 0, n = mSources.length; i < n; ++i) { + if (mSources[i].reload() > mDataVersion) changed = true; + } + if (changed) { + mDataVersion = nextVersionNumber(); + updateData(); + invalidateCache(); + } + return mDataVersion; + } + + @Override + public void onContentDirty() { + notifyContentChanged(); + } + + @Override + public int getSupportedOperations() { + return mSupportedOperation; + } + + @Override + public void delete() { + for (MediaSet set : mSources) { + set.delete(); + } + } + + @Override + public void rotate(int degrees) { + for (MediaSet set : mSources) { + set.rotate(degrees); + } + } + + private static class FetchCache { + private MediaSet mBaseSet; + private SoftReference<ArrayList<MediaItem>> mCacheRef; + private int mStartPos; + + public FetchCache(MediaSet baseSet) { + mBaseSet = baseSet; + } + + public void invalidate() { + mCacheRef = null; + } + + public MediaItem getItem(int index) { + boolean needLoading = false; + ArrayList<MediaItem> cache = null; + if (mCacheRef == null + || index < mStartPos || index >= mStartPos + PAGE_SIZE) { + needLoading = true; + } else { + cache = mCacheRef.get(); + if (cache == null) { + needLoading = true; + } + } + + if (needLoading) { + cache = mBaseSet.getMediaItem(index, PAGE_SIZE); + mCacheRef = new SoftReference<ArrayList<MediaItem>>(cache); + mStartPos = index; + } + + if (index < mStartPos || index >= mStartPos + cache.size()) { + return null; + } + + return cache.get(index - mStartPos); + } + } + + @Override + public boolean isLeafAlbum() { + return true; + } +} diff --git a/src/com/android/gallery3d/data/LocalSource.java b/src/com/android/gallery3d/data/LocalSource.java new file mode 100644 index 000000000..a2e3d1443 --- /dev/null +++ b/src/com/android/gallery3d/data/LocalSource.java @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.ContentProviderClient; +import android.content.ContentUris; +import android.content.UriMatcher; +import android.net.Uri; +import android.provider.MediaStore; + +import com.android.gallery3d.app.Gallery; +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.data.MediaSet.ItemConsumer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + +class LocalSource extends MediaSource { + + public static final String KEY_BUCKET_ID = "bucketId"; + + private GalleryApp mApplication; + private PathMatcher mMatcher; + private static final int NO_MATCH = -1; + private final UriMatcher mUriMatcher = new UriMatcher(NO_MATCH); + public static final Comparator<PathId> sIdComparator = new IdComparator(); + + private static final int LOCAL_IMAGE_ALBUMSET = 0; + private static final int LOCAL_VIDEO_ALBUMSET = 1; + private static final int LOCAL_IMAGE_ALBUM = 2; + private static final int LOCAL_VIDEO_ALBUM = 3; + private static final int LOCAL_IMAGE_ITEM = 4; + private static final int LOCAL_VIDEO_ITEM = 5; + private static final int LOCAL_ALL_ALBUMSET = 6; + private static final int LOCAL_ALL_ALBUM = 7; + + private static final String TAG = "LocalSource"; + + private ContentProviderClient mClient; + + public LocalSource(GalleryApp context) { + super("local"); + mApplication = context; + mMatcher = new PathMatcher(); + mMatcher.add("/local/image", LOCAL_IMAGE_ALBUMSET); + mMatcher.add("/local/video", LOCAL_VIDEO_ALBUMSET); + mMatcher.add("/local/all", LOCAL_ALL_ALBUMSET); + + mMatcher.add("/local/image/*", LOCAL_IMAGE_ALBUM); + mMatcher.add("/local/video/*", LOCAL_VIDEO_ALBUM); + mMatcher.add("/local/all/*", LOCAL_ALL_ALBUM); + mMatcher.add("/local/image/item/*", LOCAL_IMAGE_ITEM); + mMatcher.add("/local/video/item/*", LOCAL_VIDEO_ITEM); + + mUriMatcher.addURI(MediaStore.AUTHORITY, + "external/images/media/#", LOCAL_IMAGE_ITEM); + mUriMatcher.addURI(MediaStore.AUTHORITY, + "external/video/media/#", LOCAL_VIDEO_ITEM); + mUriMatcher.addURI(MediaStore.AUTHORITY, + "external/images/media", LOCAL_IMAGE_ALBUM); + mUriMatcher.addURI(MediaStore.AUTHORITY, + "external/video/media", LOCAL_VIDEO_ALBUM); + mUriMatcher.addURI(MediaStore.AUTHORITY, + "external/file", LOCAL_ALL_ALBUM); + } + + @Override + public MediaObject createMediaObject(Path path) { + GalleryApp app = mApplication; + switch (mMatcher.match(path)) { + case LOCAL_ALL_ALBUMSET: + case LOCAL_IMAGE_ALBUMSET: + case LOCAL_VIDEO_ALBUMSET: + return new LocalAlbumSet(path, mApplication); + case LOCAL_IMAGE_ALBUM: + return new LocalAlbum(path, app, mMatcher.getIntVar(0), true); + case LOCAL_VIDEO_ALBUM: + return new LocalAlbum(path, app, mMatcher.getIntVar(0), false); + case LOCAL_ALL_ALBUM: { + int bucketId = mMatcher.getIntVar(0); + DataManager dataManager = app.getDataManager(); + MediaSet imageSet = (MediaSet) dataManager.getMediaObject( + LocalAlbumSet.PATH_IMAGE.getChild(bucketId)); + MediaSet videoSet = (MediaSet) dataManager.getMediaObject( + LocalAlbumSet.PATH_VIDEO.getChild(bucketId)); + Comparator<MediaItem> comp = DataManager.sDateTakenComparator; + return new LocalMergeAlbum( + path, comp, new MediaSet[] {imageSet, videoSet}, bucketId); + } + case LOCAL_IMAGE_ITEM: + return new LocalImage(path, mApplication, mMatcher.getIntVar(0)); + case LOCAL_VIDEO_ITEM: + return new LocalVideo(path, mApplication, mMatcher.getIntVar(0)); + default: + throw new RuntimeException("bad path: " + path); + } + } + + private static int getMediaType(String type, int defaultType) { + if (type == null) return defaultType; + try { + int value = Integer.parseInt(type); + if ((value & (MEDIA_TYPE_IMAGE + | MEDIA_TYPE_VIDEO)) != 0) return value; + } catch (NumberFormatException e) { + Log.w(TAG, "invalid type: " + type, e); + } + return defaultType; + } + + // The media type bit passed by the intent + private static final int MEDIA_TYPE_ALL = 0; + private static final int MEDIA_TYPE_IMAGE = 1; + private static final int MEDIA_TYPE_VIDEO = 4; + + private Path getAlbumPath(Uri uri, int defaultType) { + int mediaType = getMediaType( + uri.getQueryParameter(Gallery.KEY_MEDIA_TYPES), + defaultType); + String bucketId = uri.getQueryParameter(KEY_BUCKET_ID); + int id = 0; + try { + id = Integer.parseInt(bucketId); + } catch (NumberFormatException e) { + Log.w(TAG, "invalid bucket id: " + bucketId, e); + return null; + } + switch (mediaType) { + case MEDIA_TYPE_IMAGE: + return Path.fromString("/local/image").getChild(id); + case MEDIA_TYPE_VIDEO: + return Path.fromString("/local/video").getChild(id); + default: + return Path.fromString("/local/all").getChild(id); + } + } + + @Override + public Path findPathByUri(Uri uri, String type) { + try { + switch (mUriMatcher.match(uri)) { + case LOCAL_IMAGE_ITEM: { + long id = ContentUris.parseId(uri); + return id >= 0 ? LocalImage.ITEM_PATH.getChild(id) : null; + } + case LOCAL_VIDEO_ITEM: { + long id = ContentUris.parseId(uri); + return id >= 0 ? LocalVideo.ITEM_PATH.getChild(id) : null; + } + case LOCAL_IMAGE_ALBUM: { + return getAlbumPath(uri, MEDIA_TYPE_IMAGE); + } + case LOCAL_VIDEO_ALBUM: { + return getAlbumPath(uri, MEDIA_TYPE_VIDEO); + } + case LOCAL_ALL_ALBUM: { + return getAlbumPath(uri, MEDIA_TYPE_ALL); + } + } + } catch (NumberFormatException e) { + Log.w(TAG, "uri: " + uri.toString(), e); + } + return null; + } + + @Override + public Path getDefaultSetOf(Path item) { + MediaObject object = mApplication.getDataManager().getMediaObject(item); + if (object instanceof LocalMediaItem) { + return Path.fromString("/local/all").getChild( + String.valueOf(((LocalMediaItem) object).getBucketId())); + } + return null; + } + + @Override + public void mapMediaItems(ArrayList<PathId> list, ItemConsumer consumer) { + ArrayList<PathId> imageList = new ArrayList<PathId>(); + ArrayList<PathId> videoList = new ArrayList<PathId>(); + int n = list.size(); + for (int i = 0; i < n; i++) { + PathId pid = list.get(i); + // We assume the form is: "/local/{image,video}/item/#" + // We don't use mMatcher for efficiency's reason. + Path parent = pid.path.getParent(); + if (parent == LocalImage.ITEM_PATH) { + imageList.add(pid); + } else if (parent == LocalVideo.ITEM_PATH) { + videoList.add(pid); + } + } + // TODO: use "files" table so we can merge the two cases. + processMapMediaItems(imageList, consumer, true); + processMapMediaItems(videoList, consumer, false); + } + + private void processMapMediaItems(ArrayList<PathId> list, + ItemConsumer consumer, boolean isImage) { + // Sort path by path id + Collections.sort(list, sIdComparator); + int n = list.size(); + for (int i = 0; i < n; ) { + PathId pid = list.get(i); + + // Find a range of items. + ArrayList<Integer> ids = new ArrayList<Integer>(); + int startId = Integer.parseInt(pid.path.getSuffix()); + ids.add(startId); + + int j; + for (j = i + 1; j < n; j++) { + PathId pid2 = list.get(j); + int curId = Integer.parseInt(pid2.path.getSuffix()); + if (curId - startId >= MediaSet.MEDIAITEM_BATCH_FETCH_COUNT) { + break; + } + ids.add(curId); + } + + MediaItem[] items = LocalAlbum.getMediaItemById( + mApplication, isImage, ids); + for(int k = i ; k < j; k++) { + PathId pid2 = list.get(k); + consumer.consume(pid2.id, items[k - i]); + } + + i = j; + } + } + + // This is a comparator which compares the suffix number in two Paths. + private static class IdComparator implements Comparator<PathId> { + @Override + public int compare(PathId p1, PathId p2) { + String s1 = p1.path.getSuffix(); + String s2 = p2.path.getSuffix(); + int len1 = s1.length(); + int len2 = s2.length(); + if (len1 < len2) { + return -1; + } else if (len1 > len2) { + return 1; + } else { + return s1.compareTo(s2); + } + } + } + + @Override + public void resume() { + mClient = mApplication.getContentResolver() + .acquireContentProviderClient(MediaStore.AUTHORITY); + } + + @Override + public void pause() { + mClient.release(); + mClient = null; + } +} diff --git a/src/com/android/gallery3d/data/LocalVideo.java b/src/com/android/gallery3d/data/LocalVideo.java new file mode 100644 index 000000000..4b8774ca4 --- /dev/null +++ b/src/com/android/gallery3d/data/LocalVideo.java @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.ContentResolver; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapRegionDecoder; +import android.net.Uri; +import android.provider.MediaStore.Video; +import android.provider.MediaStore.Video.VideoColumns; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.util.GalleryUtils; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; +import com.android.gallery3d.util.UpdateHelper; + +// LocalVideo represents a video in the local storage. +public class LocalVideo extends LocalMediaItem { + private static final String TAG = "LocalVideo"; + static final Path ITEM_PATH = Path.fromString("/local/video/item"); + + // Must preserve order between these indices and the order of the terms in + // the following PROJECTION array. + private static final int INDEX_ID = 0; + private static final int INDEX_CAPTION = 1; + private static final int INDEX_MIME_TYPE = 2; + private static final int INDEX_LATITUDE = 3; + private static final int INDEX_LONGITUDE = 4; + private static final int INDEX_DATE_TAKEN = 5; + private static final int INDEX_DATE_ADDED = 6; + private static final int INDEX_DATE_MODIFIED = 7; + private static final int INDEX_DATA = 8; + private static final int INDEX_DURATION = 9; + private static final int INDEX_BUCKET_ID = 10; + private static final int INDEX_SIZE = 11; + private static final int INDEX_RESOLUTION = 12; + + static final String[] PROJECTION = new String[] { + VideoColumns._ID, + VideoColumns.TITLE, + VideoColumns.MIME_TYPE, + VideoColumns.LATITUDE, + VideoColumns.LONGITUDE, + VideoColumns.DATE_TAKEN, + VideoColumns.DATE_ADDED, + VideoColumns.DATE_MODIFIED, + VideoColumns.DATA, + VideoColumns.DURATION, + VideoColumns.BUCKET_ID, + VideoColumns.SIZE, + VideoColumns.RESOLUTION, + }; + + private final GalleryApp mApplication; + + public int durationInSec; + + public LocalVideo(Path path, GalleryApp application, Cursor cursor) { + super(path, nextVersionNumber()); + mApplication = application; + loadFromCursor(cursor); + } + + public LocalVideo(Path path, GalleryApp context, int id) { + super(path, nextVersionNumber()); + mApplication = context; + ContentResolver resolver = mApplication.getContentResolver(); + Uri uri = Video.Media.EXTERNAL_CONTENT_URI; + Cursor cursor = LocalAlbum.getItemCursor(resolver, uri, PROJECTION, id); + if (cursor == null) { + throw new RuntimeException("cannot get cursor for: " + path); + } + try { + if (cursor.moveToNext()) { + loadFromCursor(cursor); + } else { + throw new RuntimeException("cannot find data for: " + path); + } + } finally { + cursor.close(); + } + } + + private void loadFromCursor(Cursor cursor) { + id = cursor.getInt(INDEX_ID); + caption = cursor.getString(INDEX_CAPTION); + mimeType = cursor.getString(INDEX_MIME_TYPE); + latitude = cursor.getDouble(INDEX_LATITUDE); + longitude = cursor.getDouble(INDEX_LONGITUDE); + dateTakenInMs = cursor.getLong(INDEX_DATE_TAKEN); + dateAddedInSec = cursor.getLong(INDEX_DATE_ADDED); + dateModifiedInSec = cursor.getLong(INDEX_DATE_MODIFIED); + filePath = cursor.getString(INDEX_DATA); + durationInSec = cursor.getInt(INDEX_DURATION) / 1000; + bucketId = cursor.getInt(INDEX_BUCKET_ID); + fileSize = cursor.getLong(INDEX_SIZE); + parseResolution(cursor.getString(INDEX_RESOLUTION)); + } + + private void parseResolution(String resolution) { + if (resolution == null) return; + int m = resolution.indexOf('x'); + if (m == -1) return; + try { + int w = Integer.parseInt(resolution.substring(0, m)); + int h = Integer.parseInt(resolution.substring(m + 1)); + width = w; + height = h; + } catch (Throwable t) { + Log.w(TAG, t); + } + } + + @Override + protected boolean updateFromCursor(Cursor cursor) { + UpdateHelper uh = new UpdateHelper(); + id = uh.update(id, cursor.getInt(INDEX_ID)); + caption = uh.update(caption, cursor.getString(INDEX_CAPTION)); + mimeType = uh.update(mimeType, cursor.getString(INDEX_MIME_TYPE)); + latitude = uh.update(latitude, cursor.getDouble(INDEX_LATITUDE)); + longitude = uh.update(longitude, cursor.getDouble(INDEX_LONGITUDE)); + dateTakenInMs = uh.update( + dateTakenInMs, cursor.getLong(INDEX_DATE_TAKEN)); + dateAddedInSec = uh.update( + dateAddedInSec, cursor.getLong(INDEX_DATE_ADDED)); + dateModifiedInSec = uh.update( + dateModifiedInSec, cursor.getLong(INDEX_DATE_MODIFIED)); + filePath = uh.update(filePath, cursor.getString(INDEX_DATA)); + durationInSec = uh.update( + durationInSec, cursor.getInt(INDEX_DURATION) / 1000); + bucketId = uh.update(bucketId, cursor.getInt(INDEX_BUCKET_ID)); + fileSize = uh.update(fileSize, cursor.getLong(INDEX_SIZE)); + return uh.isUpdated(); + } + + @Override + public Job<Bitmap> requestImage(int type) { + return new LocalVideoRequest(mApplication, getPath(), dateModifiedInSec, + type, filePath); + } + + public static class LocalVideoRequest extends ImageCacheRequest { + private String mLocalFilePath; + + LocalVideoRequest(GalleryApp application, Path path, long timeModified, + int type, String localFilePath) { + super(application, path, timeModified, type, + MediaItem.getTargetSize(type)); + mLocalFilePath = localFilePath; + } + + @Override + public Bitmap onDecodeOriginal(JobContext jc, int type) { + Bitmap bitmap = BitmapUtils.createVideoThumbnail(mLocalFilePath); + if (bitmap == null || jc.isCancelled()) return null; + return bitmap; + } + } + + @Override + public Job<BitmapRegionDecoder> requestLargeImage() { + throw new UnsupportedOperationException("Cannot regquest a large image" + + " to a local video!"); + } + + @Override + public int getSupportedOperations() { + return SUPPORT_DELETE | SUPPORT_SHARE | SUPPORT_PLAY | SUPPORT_INFO | SUPPORT_TRIM | SUPPORT_MUTE; + } + + @Override + public void delete() { + GalleryUtils.assertNotInRenderThread(); + Uri baseUri = Video.Media.EXTERNAL_CONTENT_URI; + mApplication.getContentResolver().delete(baseUri, "_id=?", + new String[]{String.valueOf(id)}); + } + + @Override + public void rotate(int degrees) { + // TODO + } + + @Override + public Uri getContentUri() { + Uri baseUri = Video.Media.EXTERNAL_CONTENT_URI; + return baseUri.buildUpon().appendPath(String.valueOf(id)).build(); + } + + @Override + public Uri getPlayUri() { + return getContentUri(); + } + + @Override + public int getMediaType() { + return MEDIA_TYPE_VIDEO; + } + + @Override + public MediaDetails getDetails() { + MediaDetails details = super.getDetails(); + int s = durationInSec; + if (s > 0) { + details.addDetail(MediaDetails.INDEX_DURATION, GalleryUtils.formatDuration( + mApplication.getAndroidContext(), durationInSec)); + } + return details; + } + + @Override + public int getWidth() { + return width; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public String getFilePath() { + return filePath; + } +} diff --git a/src/com/android/gallery3d/data/LocationClustering.java b/src/com/android/gallery3d/data/LocationClustering.java new file mode 100644 index 000000000..540322a33 --- /dev/null +++ b/src/com/android/gallery3d/data/LocationClustering.java @@ -0,0 +1,316 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.FloatMath; +import android.widget.Toast; + +import com.android.gallery3d.R; +import com.android.gallery3d.util.GalleryUtils; +import com.android.gallery3d.util.ReverseGeocoder; + +import java.util.ArrayList; + +class LocationClustering extends Clustering { + @SuppressWarnings("unused") + private static final String TAG = "LocationClustering"; + + private static final int MIN_GROUPS = 1; + private static final int MAX_GROUPS = 20; + private static final int MAX_ITERATIONS = 30; + + // If the total distance change is less than this ratio, stop iterating. + private static final float STOP_CHANGE_RATIO = 0.01f; + private Context mContext; + private ArrayList<ArrayList<SmallItem>> mClusters; + private ArrayList<String> mNames; + private String mNoLocationString; + private Handler mHandler; + + private static class Point { + public Point(double lat, double lng) { + latRad = Math.toRadians(lat); + lngRad = Math.toRadians(lng); + } + public Point() {} + public double latRad, lngRad; + } + + private static class SmallItem { + Path path; + double lat, lng; + } + + public LocationClustering(Context context) { + mContext = context; + mNoLocationString = mContext.getResources().getString(R.string.no_location); + mHandler = new Handler(Looper.getMainLooper()); + } + + @Override + public void run(MediaSet baseSet) { + final int total = baseSet.getTotalMediaItemCount(); + final SmallItem[] buf = new SmallItem[total]; + // Separate items to two sets: with or without lat-long. + final double[] latLong = new double[2]; + baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + if (index < 0 || index >= total) return; + SmallItem s = new SmallItem(); + s.path = item.getPath(); + item.getLatLong(latLong); + s.lat = latLong[0]; + s.lng = latLong[1]; + buf[index] = s; + } + }); + + final ArrayList<SmallItem> withLatLong = new ArrayList<SmallItem>(); + final ArrayList<SmallItem> withoutLatLong = new ArrayList<SmallItem>(); + final ArrayList<Point> points = new ArrayList<Point>(); + for (int i = 0; i < total; i++) { + SmallItem s = buf[i]; + if (s == null) continue; + if (GalleryUtils.isValidLocation(s.lat, s.lng)) { + withLatLong.add(s); + points.add(new Point(s.lat, s.lng)); + } else { + withoutLatLong.add(s); + } + } + + ArrayList<ArrayList<SmallItem>> clusters = new ArrayList<ArrayList<SmallItem>>(); + + int m = withLatLong.size(); + if (m > 0) { + // cluster the items with lat-long + Point[] pointsArray = new Point[m]; + pointsArray = points.toArray(pointsArray); + int[] bestK = new int[1]; + int[] index = kMeans(pointsArray, bestK); + + for (int i = 0; i < bestK[0]; i++) { + clusters.add(new ArrayList<SmallItem>()); + } + + for (int i = 0; i < m; i++) { + clusters.get(index[i]).add(withLatLong.get(i)); + } + } + + ReverseGeocoder geocoder = new ReverseGeocoder(mContext); + mNames = new ArrayList<String>(); + boolean hasUnresolvedAddress = false; + mClusters = new ArrayList<ArrayList<SmallItem>>(); + for (ArrayList<SmallItem> cluster : clusters) { + String name = generateName(cluster, geocoder); + if (name != null) { + mNames.add(name); + mClusters.add(cluster); + } else { + // move cluster-i to no location cluster + withoutLatLong.addAll(cluster); + hasUnresolvedAddress = true; + } + } + + if (withoutLatLong.size() > 0) { + mNames.add(mNoLocationString); + mClusters.add(withoutLatLong); + } + + if (hasUnresolvedAddress) { + mHandler.post(new Runnable() { + @Override + public void run() { + Toast.makeText(mContext, R.string.no_connectivity, + Toast.LENGTH_LONG).show(); + } + }); + } + } + + private static String generateName(ArrayList<SmallItem> items, + ReverseGeocoder geocoder) { + ReverseGeocoder.SetLatLong set = new ReverseGeocoder.SetLatLong(); + + int n = items.size(); + for (int i = 0; i < n; i++) { + SmallItem item = items.get(i); + double itemLatitude = item.lat; + double itemLongitude = item.lng; + + if (set.mMinLatLatitude > itemLatitude) { + set.mMinLatLatitude = itemLatitude; + set.mMinLatLongitude = itemLongitude; + } + if (set.mMaxLatLatitude < itemLatitude) { + set.mMaxLatLatitude = itemLatitude; + set.mMaxLatLongitude = itemLongitude; + } + if (set.mMinLonLongitude > itemLongitude) { + set.mMinLonLatitude = itemLatitude; + set.mMinLonLongitude = itemLongitude; + } + if (set.mMaxLonLongitude < itemLongitude) { + set.mMaxLonLatitude = itemLatitude; + set.mMaxLonLongitude = itemLongitude; + } + } + + return geocoder.computeAddress(set); + } + + @Override + public int getNumberOfClusters() { + return mClusters.size(); + } + + @Override + public ArrayList<Path> getCluster(int index) { + ArrayList<SmallItem> items = mClusters.get(index); + ArrayList<Path> result = new ArrayList<Path>(items.size()); + for (int i = 0, n = items.size(); i < n; i++) { + result.add(items.get(i).path); + } + return result; + } + + @Override + public String getClusterName(int index) { + return mNames.get(index); + } + + // Input: n points + // Output: the best k is stored in bestK[0], and the return value is the + // an array which specifies the group that each point belongs (0 to k - 1). + private static int[] kMeans(Point points[], int[] bestK) { + int n = points.length; + + // min and max number of groups wanted + int minK = Math.min(n, MIN_GROUPS); + int maxK = Math.min(n, MAX_GROUPS); + + Point[] center = new Point[maxK]; // center of each group. + Point[] groupSum = new Point[maxK]; // sum of points in each group. + int[] groupCount = new int[maxK]; // number of points in each group. + int[] grouping = new int[n]; // The group assignment for each point. + + for (int i = 0; i < maxK; i++) { + center[i] = new Point(); + groupSum[i] = new Point(); + } + + // The score we want to minimize is: + // (sum of distance from each point to its group center) * sqrt(k). + float bestScore = Float.MAX_VALUE; + // The best group assignment up to now. + int[] bestGrouping = new int[n]; + // The best K up to now. + bestK[0] = 1; + + float lastDistance = 0; + float totalDistance = 0; + + for (int k = minK; k <= maxK; k++) { + // step 1: (arbitrarily) pick k points as the initial centers. + int delta = n / k; + for (int i = 0; i < k; i++) { + Point p = points[i * delta]; + center[i].latRad = p.latRad; + center[i].lngRad = p.lngRad; + } + + for (int iter = 0; iter < MAX_ITERATIONS; iter++) { + // step 2: assign each point to the nearest center. + for (int i = 0; i < k; i++) { + groupSum[i].latRad = 0; + groupSum[i].lngRad = 0; + groupCount[i] = 0; + } + totalDistance = 0; + + for (int i = 0; i < n; i++) { + Point p = points[i]; + float bestDistance = Float.MAX_VALUE; + int bestIndex = 0; + for (int j = 0; j < k; j++) { + float distance = (float) GalleryUtils.fastDistanceMeters( + p.latRad, p.lngRad, center[j].latRad, center[j].lngRad); + // We may have small non-zero distance introduced by + // floating point calculation, so zero out small + // distances less than 1 meter. + if (distance < 1) { + distance = 0; + } + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = j; + } + } + grouping[i] = bestIndex; + groupCount[bestIndex]++; + groupSum[bestIndex].latRad += p.latRad; + groupSum[bestIndex].lngRad += p.lngRad; + totalDistance += bestDistance; + } + + // step 3: calculate new centers + for (int i = 0; i < k; i++) { + if (groupCount[i] > 0) { + center[i].latRad = groupSum[i].latRad / groupCount[i]; + center[i].lngRad = groupSum[i].lngRad / groupCount[i]; + } + } + + if (totalDistance == 0 || (Math.abs(lastDistance - totalDistance) + / totalDistance) < STOP_CHANGE_RATIO) { + break; + } + lastDistance = totalDistance; + } + + // step 4: remove empty groups and reassign group number + int reassign[] = new int[k]; + int realK = 0; + for (int i = 0; i < k; i++) { + if (groupCount[i] > 0) { + reassign[i] = realK++; + } + } + + // step 5: calculate the final score + float score = totalDistance * FloatMath.sqrt(realK); + + if (score < bestScore) { + bestScore = score; + bestK[0] = realK; + for (int i = 0; i < n; i++) { + bestGrouping[i] = reassign[grouping[i]]; + } + if (score == 0) { + break; + } + } + } + return bestGrouping; + } +} diff --git a/src/com/android/gallery3d/data/Log.java b/src/com/android/gallery3d/data/Log.java new file mode 100644 index 000000000..3384eb66c --- /dev/null +++ b/src/com/android/gallery3d/data/Log.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +public class Log { + public static int v(String tag, String msg) { + return android.util.Log.v(tag, msg); + } + public static int v(String tag, String msg, Throwable tr) { + return android.util.Log.v(tag, msg, tr); + } + public static int d(String tag, String msg) { + return android.util.Log.d(tag, msg); + } + public static int d(String tag, String msg, Throwable tr) { + return android.util.Log.d(tag, msg, tr); + } + public static int i(String tag, String msg) { + return android.util.Log.i(tag, msg); + } + public static int i(String tag, String msg, Throwable tr) { + return android.util.Log.i(tag, msg, tr); + } + public static int w(String tag, String msg) { + return android.util.Log.w(tag, msg); + } + public static int w(String tag, String msg, Throwable tr) { + return android.util.Log.w(tag, msg, tr); + } + public static int w(String tag, Throwable tr) { + return android.util.Log.w(tag, tr); + } + public static int e(String tag, String msg) { + return android.util.Log.e(tag, msg); + } + public static int e(String tag, String msg, Throwable tr) { + return android.util.Log.e(tag, msg, tr); + } +} diff --git a/src/com/android/gallery3d/data/MediaDetails.java b/src/com/android/gallery3d/data/MediaDetails.java new file mode 100644 index 000000000..cac524b88 --- /dev/null +++ b/src/com/android/gallery3d/data/MediaDetails.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.R; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.exif.ExifInterface; +import com.android.gallery3d.exif.ExifTag; +import com.android.gallery3d.exif.Rational; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map.Entry; +import java.util.TreeMap; + +public class MediaDetails implements Iterable<Entry<Integer, Object>> { + @SuppressWarnings("unused") + private static final String TAG = "MediaDetails"; + + private TreeMap<Integer, Object> mDetails = new TreeMap<Integer, Object>(); + private HashMap<Integer, Integer> mUnits = new HashMap<Integer, Integer>(); + + public static final int INDEX_TITLE = 1; + public static final int INDEX_DESCRIPTION = 2; + public static final int INDEX_DATETIME = 3; + public static final int INDEX_LOCATION = 4; + public static final int INDEX_WIDTH = 5; + public static final int INDEX_HEIGHT = 6; + public static final int INDEX_ORIENTATION = 7; + public static final int INDEX_DURATION = 8; + public static final int INDEX_MIMETYPE = 9; + public static final int INDEX_SIZE = 10; + + // for EXIF + public static final int INDEX_MAKE = 100; + public static final int INDEX_MODEL = 101; + public static final int INDEX_FLASH = 102; + public static final int INDEX_FOCAL_LENGTH = 103; + public static final int INDEX_WHITE_BALANCE = 104; + public static final int INDEX_APERTURE = 105; + public static final int INDEX_SHUTTER_SPEED = 106; + public static final int INDEX_EXPOSURE_TIME = 107; + public static final int INDEX_ISO = 108; + + // Put this last because it may be long. + public static final int INDEX_PATH = 200; + + public static class FlashState { + private static int FLASH_FIRED_MASK = 1; + private static int FLASH_RETURN_MASK = 2 | 4; + private static int FLASH_MODE_MASK = 8 | 16; + private static int FLASH_FUNCTION_MASK = 32; + private static int FLASH_RED_EYE_MASK = 64; + private int mState; + + public FlashState(int state) { + mState = state; + } + + public boolean isFlashFired() { + return (mState & FLASH_FIRED_MASK) != 0; + } + } + + public void addDetail(int index, Object value) { + mDetails.put(index, value); + } + + public Object getDetail(int index) { + return mDetails.get(index); + } + + public int size() { + return mDetails.size(); + } + + @Override + public Iterator<Entry<Integer, Object>> iterator() { + return mDetails.entrySet().iterator(); + } + + public void setUnit(int index, int unit) { + mUnits.put(index, unit); + } + + public boolean hasUnit(int index) { + return mUnits.containsKey(index); + } + + public int getUnit(int index) { + return mUnits.get(index); + } + + private static void setExifData(MediaDetails details, ExifTag tag, + int key) { + if (tag != null) { + String value = null; + int type = tag.getDataType(); + if (type == ExifTag.TYPE_UNSIGNED_RATIONAL || type == ExifTag.TYPE_RATIONAL) { + value = String.valueOf(tag.getValueAsRational(0).toDouble()); + } else if (type == ExifTag.TYPE_ASCII) { + value = tag.getValueAsString(); + } else { + value = String.valueOf(tag.forceGetValueAsLong(0)); + } + if (key == MediaDetails.INDEX_FLASH) { + MediaDetails.FlashState state = new MediaDetails.FlashState( + Integer.valueOf(value.toString())); + details.addDetail(key, state); + } else { + details.addDetail(key, value); + } + } + } + + public static void extractExifInfo(MediaDetails details, String filePath) { + + ExifInterface exif = new ExifInterface(); + try { + exif.readExif(filePath); + } catch (FileNotFoundException e) { + Log.w(TAG, "Could not find file to read exif: " + filePath, e); + } catch (IOException e) { + Log.w(TAG, "Could not read exif from file: " + filePath, e); + } + + setExifData(details, exif.getTag(ExifInterface.TAG_FLASH), + MediaDetails.INDEX_FLASH); + setExifData(details, exif.getTag(ExifInterface.TAG_IMAGE_WIDTH), + MediaDetails.INDEX_WIDTH); + setExifData(details, exif.getTag(ExifInterface.TAG_IMAGE_LENGTH), + MediaDetails.INDEX_HEIGHT); + setExifData(details, exif.getTag(ExifInterface.TAG_MAKE), + MediaDetails.INDEX_MAKE); + setExifData(details, exif.getTag(ExifInterface.TAG_MODEL), + MediaDetails.INDEX_MODEL); + setExifData(details, exif.getTag(ExifInterface.TAG_APERTURE_VALUE), + MediaDetails.INDEX_APERTURE); + setExifData(details, exif.getTag(ExifInterface.TAG_ISO_SPEED_RATINGS), + MediaDetails.INDEX_ISO); + setExifData(details, exif.getTag(ExifInterface.TAG_WHITE_BALANCE), + MediaDetails.INDEX_WHITE_BALANCE); + setExifData(details, exif.getTag(ExifInterface.TAG_EXPOSURE_TIME), + MediaDetails.INDEX_EXPOSURE_TIME); + ExifTag focalTag = exif.getTag(ExifInterface.TAG_FOCAL_LENGTH); + if (focalTag != null) { + details.addDetail(MediaDetails.INDEX_FOCAL_LENGTH, + focalTag.getValueAsRational(0).toDouble()); + details.setUnit(MediaDetails.INDEX_FOCAL_LENGTH, R.string.unit_mm); + } + } +} diff --git a/src/com/android/gallery3d/data/MediaItem.java b/src/com/android/gallery3d/data/MediaItem.java new file mode 100644 index 000000000..59ea86551 --- /dev/null +++ b/src/com/android/gallery3d/data/MediaItem.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.graphics.Bitmap; +import android.graphics.BitmapRegionDecoder; + +import com.android.gallery3d.common.ApiHelper; +import com.android.gallery3d.ui.ScreenNail; +import com.android.gallery3d.util.ThreadPool.Job; + +// MediaItem represents an image or a video item. +public abstract class MediaItem extends MediaObject { + // NOTE: These type numbers are stored in the image cache, so it should not + // not be changed without resetting the cache. + public static final int TYPE_THUMBNAIL = 1; + public static final int TYPE_MICROTHUMBNAIL = 2; + + public static final int CACHED_IMAGE_QUALITY = 95; + + public static final int IMAGE_READY = 0; + public static final int IMAGE_WAIT = 1; + public static final int IMAGE_ERROR = -1; + + public static final String MIME_TYPE_JPEG = "image/jpeg"; + + private static final int BYTESBUFFE_POOL_SIZE = 4; + private static final int BYTESBUFFER_SIZE = 200 * 1024; + + private static int sMicrothumbnailTargetSize = 200; + private static final BytesBufferPool sMicroThumbBufferPool = + new BytesBufferPool(BYTESBUFFE_POOL_SIZE, BYTESBUFFER_SIZE); + + private static int sThumbnailTargetSize = 640; + + // TODO: fix default value for latlng and change this. + public static final double INVALID_LATLNG = 0f; + + public abstract Job<Bitmap> requestImage(int type); + public abstract Job<BitmapRegionDecoder> requestLargeImage(); + + public MediaItem(Path path, long version) { + super(path, version); + } + + public long getDateInMs() { + return 0; + } + + public String getName() { + return null; + } + + public void getLatLong(double[] latLong) { + latLong[0] = INVALID_LATLNG; + latLong[1] = INVALID_LATLNG; + } + + public String[] getTags() { + return null; + } + + public Face[] getFaces() { + return null; + } + + // The rotation of the full-resolution image. By default, it returns the value of + // getRotation(). + public int getFullImageRotation() { + return getRotation(); + } + + public int getRotation() { + return 0; + } + + public long getSize() { + return 0; + } + + public abstract String getMimeType(); + + public String getFilePath() { + return ""; + } + + // Returns width and height of the media item. + // Returns 0, 0 if the information is not available. + public abstract int getWidth(); + public abstract int getHeight(); + + // This is an alternative for requestImage() in PhotoPage. If this + // is implemented, you don't need to implement requestImage(). + public ScreenNail getScreenNail() { + return null; + } + + public static int getTargetSize(int type) { + switch (type) { + case TYPE_THUMBNAIL: + return sThumbnailTargetSize; + case TYPE_MICROTHUMBNAIL: + return sMicrothumbnailTargetSize; + default: + throw new RuntimeException( + "should only request thumb/microthumb from cache"); + } + } + + public static BytesBufferPool getBytesBufferPool() { + return sMicroThumbBufferPool; + } + + public static void setThumbnailSizes(int size, int microSize) { + sThumbnailTargetSize = size; + if (sMicrothumbnailTargetSize != microSize) { + sMicrothumbnailTargetSize = microSize; + } + } +} diff --git a/src/com/android/gallery3d/data/MediaObject.java b/src/com/android/gallery3d/data/MediaObject.java new file mode 100644 index 000000000..270d4cf0b --- /dev/null +++ b/src/com/android/gallery3d/data/MediaObject.java @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.net.Uri; + +public abstract class MediaObject { + @SuppressWarnings("unused") + private static final String TAG = "MediaObject"; + public static final long INVALID_DATA_VERSION = -1; + + // These are the bits returned from getSupportedOperations(): + public static final int SUPPORT_DELETE = 1 << 0; + public static final int SUPPORT_ROTATE = 1 << 1; + public static final int SUPPORT_SHARE = 1 << 2; + public static final int SUPPORT_CROP = 1 << 3; + public static final int SUPPORT_SHOW_ON_MAP = 1 << 4; + public static final int SUPPORT_SETAS = 1 << 5; + public static final int SUPPORT_FULL_IMAGE = 1 << 6; + public static final int SUPPORT_PLAY = 1 << 7; + public static final int SUPPORT_CACHE = 1 << 8; + public static final int SUPPORT_EDIT = 1 << 9; + public static final int SUPPORT_INFO = 1 << 10; + public static final int SUPPORT_TRIM = 1 << 11; + public static final int SUPPORT_UNLOCK = 1 << 12; + public static final int SUPPORT_BACK = 1 << 13; + public static final int SUPPORT_ACTION = 1 << 14; + public static final int SUPPORT_CAMERA_SHORTCUT = 1 << 15; + public static final int SUPPORT_MUTE = 1 << 16; + public static final int SUPPORT_ALL = 0xffffffff; + + // These are the bits returned from getMediaType(): + public static final int MEDIA_TYPE_UNKNOWN = 1; + public static final int MEDIA_TYPE_IMAGE = 2; + public static final int MEDIA_TYPE_VIDEO = 4; + public static final int MEDIA_TYPE_ALL = MEDIA_TYPE_IMAGE | MEDIA_TYPE_VIDEO; + + public static final String MEDIA_TYPE_IMAGE_STRING = "image"; + public static final String MEDIA_TYPE_VIDEO_STRING = "video"; + public static final String MEDIA_TYPE_ALL_STRING = "all"; + + // These are flags for cache() and return values for getCacheFlag(): + public static final int CACHE_FLAG_NO = 0; + public static final int CACHE_FLAG_SCREENNAIL = 1; + public static final int CACHE_FLAG_FULL = 2; + + // These are return values for getCacheStatus(): + public static final int CACHE_STATUS_NOT_CACHED = 0; + public static final int CACHE_STATUS_CACHING = 1; + public static final int CACHE_STATUS_CACHED_SCREENNAIL = 2; + public static final int CACHE_STATUS_CACHED_FULL = 3; + + private static long sVersionSerial = 0; + + protected long mDataVersion; + + protected final Path mPath; + + public interface PanoramaSupportCallback { + void panoramaInfoAvailable(MediaObject mediaObject, boolean isPanorama, + boolean isPanorama360); + } + + public MediaObject(Path path, long version) { + path.setObject(this); + mPath = path; + mDataVersion = version; + } + + public Path getPath() { + return mPath; + } + + public int getSupportedOperations() { + return 0; + } + + public void getPanoramaSupport(PanoramaSupportCallback callback) { + callback.panoramaInfoAvailable(this, false, false); + } + + public void clearCachedPanoramaSupport() { + } + + public void delete() { + throw new UnsupportedOperationException(); + } + + public void rotate(int degrees) { + throw new UnsupportedOperationException(); + } + + public Uri getContentUri() { + String className = getClass().getName(); + Log.e(TAG, "Class " + className + "should implement getContentUri."); + Log.e(TAG, "The object was created from path: " + getPath()); + throw new UnsupportedOperationException(); + } + + public Uri getPlayUri() { + throw new UnsupportedOperationException(); + } + + public int getMediaType() { + return MEDIA_TYPE_UNKNOWN; + } + + public MediaDetails getDetails() { + MediaDetails details = new MediaDetails(); + return details; + } + + public long getDataVersion() { + return mDataVersion; + } + + public int getCacheFlag() { + return CACHE_FLAG_NO; + } + + public int getCacheStatus() { + throw new UnsupportedOperationException(); + } + + public long getCacheSize() { + throw new UnsupportedOperationException(); + } + + public void cache(int flag) { + throw new UnsupportedOperationException(); + } + + public static synchronized long nextVersionNumber() { + return ++MediaObject.sVersionSerial; + } + + public static int getTypeFromString(String s) { + if (MEDIA_TYPE_ALL_STRING.equals(s)) return MediaObject.MEDIA_TYPE_ALL; + if (MEDIA_TYPE_IMAGE_STRING.equals(s)) return MediaObject.MEDIA_TYPE_IMAGE; + if (MEDIA_TYPE_VIDEO_STRING.equals(s)) return MediaObject.MEDIA_TYPE_VIDEO; + throw new IllegalArgumentException(s); + } + + public static String getTypeString(int type) { + switch (type) { + case MEDIA_TYPE_IMAGE: return MEDIA_TYPE_IMAGE_STRING; + case MEDIA_TYPE_VIDEO: return MEDIA_TYPE_VIDEO_STRING; + case MEDIA_TYPE_ALL: return MEDIA_TYPE_ALL_STRING; + } + throw new IllegalArgumentException(); + } +} diff --git a/src/com/android/gallery3d/data/MediaSet.java b/src/com/android/gallery3d/data/MediaSet.java new file mode 100644 index 000000000..683aa6b32 --- /dev/null +++ b/src/com/android/gallery3d/data/MediaSet.java @@ -0,0 +1,348 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.Future; + +import java.util.ArrayList; +import java.util.WeakHashMap; + +// MediaSet is a directory-like data structure. +// It contains MediaItems and sub-MediaSets. +// +// The primary interface are: +// getMediaItemCount(), getMediaItem() and +// getSubMediaSetCount(), getSubMediaSet(). +// +// getTotalMediaItemCount() returns the number of all MediaItems, including +// those in sub-MediaSets. +public abstract class MediaSet extends MediaObject { + @SuppressWarnings("unused") + private static final String TAG = "MediaSet"; + + public static final int MEDIAITEM_BATCH_FETCH_COUNT = 500; + public static final int INDEX_NOT_FOUND = -1; + + public static final int SYNC_RESULT_SUCCESS = 0; + public static final int SYNC_RESULT_CANCELLED = 1; + public static final int SYNC_RESULT_ERROR = 2; + + /** Listener to be used with requestSync(SyncListener). */ + public static interface SyncListener { + /** + * Called when the sync task completed. Completion may be due to normal termination, + * an exception, or cancellation. + * + * @param mediaSet the MediaSet that's done with sync + * @param resultCode one of the SYNC_RESULT_* constants + */ + void onSyncDone(MediaSet mediaSet, int resultCode); + } + + public MediaSet(Path path, long version) { + super(path, version); + } + + public int getMediaItemCount() { + return 0; + } + + // Returns the media items in the range [start, start + count). + // + // The number of media items returned may be less than the specified count + // if there are not enough media items available. The number of + // media items available may not be consistent with the return value of + // getMediaItemCount() because the contents of database may have already + // changed. + public ArrayList<MediaItem> getMediaItem(int start, int count) { + return new ArrayList<MediaItem>(); + } + + public MediaItem getCoverMediaItem() { + ArrayList<MediaItem> items = getMediaItem(0, 1); + if (items.size() > 0) return items.get(0); + for (int i = 0, n = getSubMediaSetCount(); i < n; i++) { + MediaItem cover = getSubMediaSet(i).getCoverMediaItem(); + if (cover != null) return cover; + } + return null; + } + + public int getSubMediaSetCount() { + return 0; + } + + public MediaSet getSubMediaSet(int index) { + throw new IndexOutOfBoundsException(); + } + + public boolean isLeafAlbum() { + return false; + } + + public boolean isCameraRoll() { + return false; + } + + /** + * Method {@link #reload()} may process the loading task in background, this method tells + * its client whether the loading is still in process or not. + */ + public boolean isLoading() { + return false; + } + + public int getTotalMediaItemCount() { + int total = getMediaItemCount(); + for (int i = 0, n = getSubMediaSetCount(); i < n; i++) { + total += getSubMediaSet(i).getTotalMediaItemCount(); + } + return total; + } + + // TODO: we should have better implementation of sub classes + public int getIndexOfItem(Path path, int hint) { + // hint < 0 is handled below + // first, try to find it around the hint + int start = Math.max(0, + hint - MEDIAITEM_BATCH_FETCH_COUNT / 2); + ArrayList<MediaItem> list = getMediaItem( + start, MEDIAITEM_BATCH_FETCH_COUNT); + int index = getIndexOf(path, list); + if (index != INDEX_NOT_FOUND) return start + index; + + // try to find it globally + start = start == 0 ? MEDIAITEM_BATCH_FETCH_COUNT : 0; + list = getMediaItem(start, MEDIAITEM_BATCH_FETCH_COUNT); + while (true) { + index = getIndexOf(path, list); + if (index != INDEX_NOT_FOUND) return start + index; + if (list.size() < MEDIAITEM_BATCH_FETCH_COUNT) return INDEX_NOT_FOUND; + start += MEDIAITEM_BATCH_FETCH_COUNT; + list = getMediaItem(start, MEDIAITEM_BATCH_FETCH_COUNT); + } + } + + protected int getIndexOf(Path path, ArrayList<MediaItem> list) { + for (int i = 0, n = list.size(); i < n; ++i) { + // item could be null only in ClusterAlbum + MediaObject item = list.get(i); + if (item != null && item.mPath == path) return i; + } + return INDEX_NOT_FOUND; + } + + public abstract String getName(); + + private WeakHashMap<ContentListener, Object> mListeners = + new WeakHashMap<ContentListener, Object>(); + + // NOTE: The MediaSet only keeps a weak reference to the listener. The + // listener is automatically removed when there is no other reference to + // the listener. + public void addContentListener(ContentListener listener) { + mListeners.put(listener, null); + } + + public void removeContentListener(ContentListener listener) { + mListeners.remove(listener); + } + + // This should be called by subclasses when the content is changed. + public void notifyContentChanged() { + for (ContentListener listener : mListeners.keySet()) { + listener.onContentDirty(); + } + } + + // Reload the content. Return the current data version. reload() should be called + // in the same thread as getMediaItem(int, int) and getSubMediaSet(int). + public abstract long reload(); + + @Override + public MediaDetails getDetails() { + MediaDetails details = super.getDetails(); + details.addDetail(MediaDetails.INDEX_TITLE, getName()); + return details; + } + + // Enumerate all media items in this media set (including the ones in sub + // media sets), in an efficient order. ItemConsumer.consumer() will be + // called for each media item with its index. + public void enumerateMediaItems(ItemConsumer consumer) { + enumerateMediaItems(consumer, 0); + } + + public void enumerateTotalMediaItems(ItemConsumer consumer) { + enumerateTotalMediaItems(consumer, 0); + } + + public static interface ItemConsumer { + void consume(int index, MediaItem item); + } + + // The default implementation uses getMediaItem() for enumerateMediaItems(). + // Subclasses may override this and use more efficient implementations. + // Returns the number of items enumerated. + protected int enumerateMediaItems(ItemConsumer consumer, int startIndex) { + int total = getMediaItemCount(); + int start = 0; + while (start < total) { + int count = Math.min(MEDIAITEM_BATCH_FETCH_COUNT, total - start); + ArrayList<MediaItem> items = getMediaItem(start, count); + for (int i = 0, n = items.size(); i < n; i++) { + MediaItem item = items.get(i); + consumer.consume(startIndex + start + i, item); + } + start += count; + } + return total; + } + + // Recursively enumerate all media items under this set. + // Returns the number of items enumerated. + protected int enumerateTotalMediaItems( + ItemConsumer consumer, int startIndex) { + int start = 0; + start += enumerateMediaItems(consumer, startIndex); + int m = getSubMediaSetCount(); + for (int i = 0; i < m; i++) { + start += getSubMediaSet(i).enumerateTotalMediaItems( + consumer, startIndex + start); + } + return start; + } + + /** + * Requests sync on this MediaSet. It returns a Future object that can be used by the caller + * to query the status of the sync. The sync result code is one of the SYNC_RESULT_* constants + * defined in this class and can be obtained by Future.get(). + * + * Subclasses should perform sync on a different thread. + * + * The default implementation here returns a Future stub that does nothing and returns + * SYNC_RESULT_SUCCESS by get(). + */ + public Future<Integer> requestSync(SyncListener listener) { + listener.onSyncDone(this, SYNC_RESULT_SUCCESS); + return FUTURE_STUB; + } + + private static final Future<Integer> FUTURE_STUB = new Future<Integer>() { + @Override + public void cancel() {} + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public boolean isDone() { + return true; + } + + @Override + public Integer get() { + return SYNC_RESULT_SUCCESS; + } + + @Override + public void waitDone() {} + }; + + protected Future<Integer> requestSyncOnMultipleSets(MediaSet[] sets, SyncListener listener) { + return new MultiSetSyncFuture(sets, listener); + } + + private class MultiSetSyncFuture implements Future<Integer>, SyncListener { + @SuppressWarnings("hiding") + private static final String TAG = "Gallery.MultiSetSync"; + + private final SyncListener mListener; + private final Future<Integer> mFutures[]; + + private boolean mIsCancelled = false; + private int mResult = -1; + private int mPendingCount; + + @SuppressWarnings("unchecked") + MultiSetSyncFuture(MediaSet[] sets, SyncListener listener) { + mListener = listener; + mPendingCount = sets.length; + mFutures = new Future[sets.length]; + + synchronized (this) { + for (int i = 0, n = sets.length; i < n; ++i) { + mFutures[i] = sets[i].requestSync(this); + Log.d(TAG, " request sync: " + Utils.maskDebugInfo(sets[i].getName())); + } + } + } + + @Override + public synchronized void cancel() { + if (mIsCancelled) return; + mIsCancelled = true; + for (Future<Integer> future : mFutures) future.cancel(); + if (mResult < 0) mResult = SYNC_RESULT_CANCELLED; + } + + @Override + public synchronized boolean isCancelled() { + return mIsCancelled; + } + + @Override + public synchronized boolean isDone() { + return mPendingCount == 0; + } + + @Override + public synchronized Integer get() { + waitDone(); + return mResult; + } + + @Override + public synchronized void waitDone() { + try { + while (!isDone()) wait(); + } catch (InterruptedException e) { + Log.d(TAG, "waitDone() interrupted"); + } + } + + // SyncListener callback + @Override + public void onSyncDone(MediaSet mediaSet, int resultCode) { + SyncListener listener = null; + synchronized (this) { + if (resultCode == SYNC_RESULT_ERROR) mResult = SYNC_RESULT_ERROR; + --mPendingCount; + if (mPendingCount == 0) { + listener = mListener; + notifyAll(); + } + Log.d(TAG, "onSyncDone: " + Utils.maskDebugInfo(mediaSet.getName()) + + " #pending=" + mPendingCount); + } + if (listener != null) listener.onSyncDone(MediaSet.this, mResult); + } + } +} diff --git a/src/com/android/gallery3d/data/MediaSource.java b/src/com/android/gallery3d/data/MediaSource.java new file mode 100644 index 000000000..95901283b --- /dev/null +++ b/src/com/android/gallery3d/data/MediaSource.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.net.Uri; + +import com.android.gallery3d.data.MediaSet.ItemConsumer; + +import java.util.ArrayList; + +public abstract class MediaSource { + private static final String TAG = "MediaSource"; + private String mPrefix; + + protected MediaSource(String prefix) { + mPrefix = prefix; + } + + public String getPrefix() { + return mPrefix; + } + + public Path findPathByUri(Uri uri, String type) { + return null; + } + + public abstract MediaObject createMediaObject(Path path); + + public void pause() { + } + + public void resume() { + } + + public Path getDefaultSetOf(Path item) { + return null; + } + + public long getTotalUsedCacheSize() { + return 0; + } + + public long getTotalTargetCacheSize() { + return 0; + } + + public static class PathId { + public PathId(Path path, int id) { + this.path = path; + this.id = id; + } + public Path path; + public int id; + } + + // Maps a list of Paths (all belong to this MediaSource) to MediaItems, + // and invoke consumer.consume() for each MediaItem with the given id. + // + // This default implementation uses getMediaObject for each Path. Subclasses + // may override this and provide more efficient implementation (like + // batching the database query). + public void mapMediaItems(ArrayList<PathId> list, ItemConsumer consumer) { + int n = list.size(); + for (int i = 0; i < n; i++) { + PathId pid = list.get(i); + MediaObject obj; + synchronized (DataManager.LOCK) { + obj = pid.path.getObject(); + if (obj == null) { + try { + obj = createMediaObject(pid.path); + } catch (Throwable th) { + Log.w(TAG, "cannot create media object: " + pid.path, th); + } + } + } + if (obj != null) { + consumer.consume(pid.id, (MediaItem) obj); + } + } + } +} diff --git a/src/com/android/gallery3d/data/MtpClient.java b/src/com/android/gallery3d/data/MtpClient.java new file mode 100644 index 000000000..737b5b60d --- /dev/null +++ b/src/com/android/gallery3d/data/MtpClient.java @@ -0,0 +1,443 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.annotation.TargetApi; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbInterface; +import android.hardware.usb.UsbManager; +import android.mtp.MtpDevice; +import android.mtp.MtpObjectInfo; +import android.mtp.MtpStorageInfo; +import android.util.Log; + +import com.android.gallery3d.common.ApiHelper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * This class helps an application manage a list of connected MTP or PTP devices. + * It listens for MTP devices being attached and removed from the USB host bus + * and notifies the application when the MTP device list changes. + */ +@TargetApi(ApiHelper.VERSION_CODES.HONEYCOMB_MR1) +public class MtpClient { + + private static final String TAG = "MtpClient"; + + private static final String ACTION_USB_PERMISSION = + "android.mtp.MtpClient.action.USB_PERMISSION"; + + private final Context mContext; + private final UsbManager mUsbManager; + private final ArrayList<Listener> mListeners = new ArrayList<Listener>(); + // mDevices contains all MtpDevices that have been seen by our client, + // so we can inform when the device has been detached. + // mDevices is also used for synchronization in this class. + private final HashMap<String, MtpDevice> mDevices = new HashMap<String, MtpDevice>(); + // List of MTP devices we should not try to open for which we are currently + // asking for permission to open. + private final ArrayList<String> mRequestPermissionDevices = new ArrayList<String>(); + // List of MTP devices we should not try to open. + // We add devices to this list if the user canceled a permission request or we were + // unable to open the device. + private final ArrayList<String> mIgnoredDevices = new ArrayList<String>(); + + private final PendingIntent mPermissionIntent; + + private final BroadcastReceiver mUsbReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + UsbDevice usbDevice = (UsbDevice)intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + String deviceName = usbDevice.getDeviceName(); + + synchronized (mDevices) { + MtpDevice mtpDevice = mDevices.get(deviceName); + + if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(action)) { + if (mtpDevice == null) { + mtpDevice = openDeviceLocked(usbDevice); + } + if (mtpDevice != null) { + for (Listener listener : mListeners) { + listener.deviceAdded(mtpDevice); + } + } + } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { + if (mtpDevice != null) { + mDevices.remove(deviceName); + mRequestPermissionDevices.remove(deviceName); + mIgnoredDevices.remove(deviceName); + for (Listener listener : mListeners) { + listener.deviceRemoved(mtpDevice); + } + } + } else if (ACTION_USB_PERMISSION.equals(action)) { + mRequestPermissionDevices.remove(deviceName); + boolean permission = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, + false); + Log.d(TAG, "ACTION_USB_PERMISSION: " + permission); + if (permission) { + if (mtpDevice == null) { + mtpDevice = openDeviceLocked(usbDevice); + } + if (mtpDevice != null) { + for (Listener listener : mListeners) { + listener.deviceAdded(mtpDevice); + } + } + } else { + // so we don't ask for permission again + mIgnoredDevices.add(deviceName); + } + } + } + } + }; + + /** + * An interface for being notified when MTP or PTP devices are attached + * or removed. In the current implementation, only PTP devices are supported. + */ + public interface Listener { + /** + * Called when a new device has been added + * + * @param device the new device that was added + */ + public void deviceAdded(MtpDevice device); + + /** + * Called when a new device has been removed + * + * @param device the device that was removed + */ + public void deviceRemoved(MtpDevice device); + } + + /** + * Tests to see if a {@link android.hardware.usb.UsbDevice} + * supports the PTP protocol (typically used by digital cameras) + * + * @param device the device to test + * @return true if the device is a PTP device. + */ + static public boolean isCamera(UsbDevice device) { + int count = device.getInterfaceCount(); + for (int i = 0; i < count; i++) { + UsbInterface intf = device.getInterface(i); + if (intf.getInterfaceClass() == UsbConstants.USB_CLASS_STILL_IMAGE && + intf.getInterfaceSubclass() == 1 && + intf.getInterfaceProtocol() == 1) { + return true; + } + } + return false; + } + + /** + * MtpClient constructor + * + * @param context the {@link android.content.Context} to use for the MtpClient + */ + public MtpClient(Context context) { + mContext = context; + mUsbManager = (UsbManager)context.getSystemService(Context.USB_SERVICE); + mPermissionIntent = PendingIntent.getBroadcast(mContext, 0, new Intent(ACTION_USB_PERMISSION), 0); + IntentFilter filter = new IntentFilter(); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + filter.addAction(ACTION_USB_PERMISSION); + context.registerReceiver(mUsbReceiver, filter); + } + + /** + * Opens the {@link android.hardware.usb.UsbDevice} for an MTP or PTP + * device and return an {@link android.mtp.MtpDevice} for it. + * + * @param usbDevice the device to open + * @return an MtpDevice for the device. + */ + private MtpDevice openDeviceLocked(UsbDevice usbDevice) { + String deviceName = usbDevice.getDeviceName(); + + // don't try to open devices that we have decided to ignore + // or are currently asking permission for + if (isCamera(usbDevice) && !mIgnoredDevices.contains(deviceName) + && !mRequestPermissionDevices.contains(deviceName)) { + if (!mUsbManager.hasPermission(usbDevice)) { + mUsbManager.requestPermission(usbDevice, mPermissionIntent); + mRequestPermissionDevices.add(deviceName); + } else { + UsbDeviceConnection connection = mUsbManager.openDevice(usbDevice); + if (connection != null) { + MtpDevice mtpDevice = new MtpDevice(usbDevice); + if (mtpDevice.open(connection)) { + mDevices.put(usbDevice.getDeviceName(), mtpDevice); + return mtpDevice; + } else { + // so we don't try to open it again + mIgnoredDevices.add(deviceName); + } + } else { + // so we don't try to open it again + mIgnoredDevices.add(deviceName); + } + } + } + return null; + } + + /** + * Closes all resources related to the MtpClient object + */ + public void close() { + mContext.unregisterReceiver(mUsbReceiver); + } + + /** + * Registers a {@link com.android.gallery3d.data.MtpClient.Listener} interface to receive + * notifications when MTP or PTP devices are added or removed. + * + * @param listener the listener to register + */ + public void addListener(Listener listener) { + synchronized (mDevices) { + if (!mListeners.contains(listener)) { + mListeners.add(listener); + } + } + } + + /** + * Unregisters a {@link com.android.gallery3d.data.MtpClient.Listener} interface. + * + * @param listener the listener to unregister + */ + public void removeListener(Listener listener) { + synchronized (mDevices) { + mListeners.remove(listener); + } + } + + /** + * Retrieves an {@link android.mtp.MtpDevice} object for the USB device + * with the given name. + * + * @param deviceName the name of the USB device + * @return the MtpDevice, or null if it does not exist + */ + public MtpDevice getDevice(String deviceName) { + synchronized (mDevices) { + return mDevices.get(deviceName); + } + } + + /** + * Retrieves an {@link android.mtp.MtpDevice} object for the USB device + * with the given ID. + * + * @param id the ID of the USB device + * @return the MtpDevice, or null if it does not exist + */ + public MtpDevice getDevice(int id) { + synchronized (mDevices) { + return mDevices.get(UsbDevice.getDeviceName(id)); + } + } + + /** + * Retrieves a list of all currently connected {@link android.mtp.MtpDevice}. + * + * @return the list of MtpDevices + */ + public List<MtpDevice> getDeviceList() { + synchronized (mDevices) { + // Query the USB manager since devices might have attached + // before we added our listener. + for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { + if (mDevices.get(usbDevice.getDeviceName()) == null) { + openDeviceLocked(usbDevice); + } + } + + return new ArrayList<MtpDevice>(mDevices.values()); + } + } + + /** + * Retrieves a list of all {@link android.mtp.MtpStorageInfo} + * for the MTP or PTP device with the given USB device name + * + * @param deviceName the name of the USB device + * @return the list of MtpStorageInfo + */ + public List<MtpStorageInfo> getStorageList(String deviceName) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return null; + } + int[] storageIds = device.getStorageIds(); + if (storageIds == null) { + return null; + } + + int length = storageIds.length; + ArrayList<MtpStorageInfo> storageList = new ArrayList<MtpStorageInfo>(length); + for (int i = 0; i < length; i++) { + MtpStorageInfo info = device.getStorageInfo(storageIds[i]); + if (info == null) { + Log.w(TAG, "getStorageInfo failed"); + } else { + storageList.add(info); + } + } + return storageList; + } + + /** + * Retrieves the {@link android.mtp.MtpObjectInfo} for an object on + * the MTP or PTP device with the given USB device name with the given + * object handle + * + * @param deviceName the name of the USB device + * @param objectHandle handle of the object to query + * @return the MtpObjectInfo + */ + public MtpObjectInfo getObjectInfo(String deviceName, int objectHandle) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return null; + } + return device.getObjectInfo(objectHandle); + } + + /** + * Deletes an object on the MTP or PTP device with the given USB device name. + * + * @param deviceName the name of the USB device + * @param objectHandle handle of the object to delete + * @return true if the deletion succeeds + */ + public boolean deleteObject(String deviceName, int objectHandle) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return false; + } + return device.deleteObject(objectHandle); + } + + /** + * Retrieves a list of {@link android.mtp.MtpObjectInfo} for all objects + * on the MTP or PTP device with the given USB device name and given storage ID + * and/or object handle. + * If the object handle is zero, then all objects in the root of the storage unit + * will be returned. Otherwise, all immediate children of the object will be returned. + * If the storage ID is also zero, then all objects on all storage units will be returned. + * + * @param deviceName the name of the USB device + * @param storageId the ID of the storage unit to query, or zero for all + * @param objectHandle the handle of the parent object to query, or zero for the storage root + * @return the list of MtpObjectInfo + */ + public List<MtpObjectInfo> getObjectList(String deviceName, int storageId, int objectHandle) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return null; + } + if (objectHandle == 0) { + // all objects in root of storage + objectHandle = 0xFFFFFFFF; + } + int[] handles = device.getObjectHandles(storageId, 0, objectHandle); + if (handles == null) { + return null; + } + + int length = handles.length; + ArrayList<MtpObjectInfo> objectList = new ArrayList<MtpObjectInfo>(length); + for (int i = 0; i < length; i++) { + MtpObjectInfo info = device.getObjectInfo(handles[i]); + if (info == null) { + Log.w(TAG, "getObjectInfo failed"); + } else { + objectList.add(info); + } + } + return objectList; + } + + /** + * Returns the data for an object as a byte array. + * + * @param deviceName the name of the USB device containing the object + * @param objectHandle handle of the object to read + * @param objectSize the size of the object (this should match + * {@link android.mtp.MtpObjectInfo#getCompressedSize} + * @return the object's data, or null if reading fails + */ + public byte[] getObject(String deviceName, int objectHandle, int objectSize) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return null; + } + return device.getObject(objectHandle, objectSize); + } + + /** + * Returns the thumbnail data for an object as a byte array. + * + * @param deviceName the name of the USB device containing the object + * @param objectHandle handle of the object to read + * @return the object's thumbnail, or null if reading fails + */ + public byte[] getThumbnail(String deviceName, int objectHandle) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return null; + } + return device.getThumbnail(objectHandle); + } + + /** + * Copies the data for an object to a file in external storage. + * + * @param deviceName the name of the USB device containing the object + * @param objectHandle handle of the object to read + * @param destPath path to destination for the file transfer. + * This path should be in the external storage as defined by + * {@link android.os.Environment#getExternalStorageDirectory} + * @return true if the file transfer succeeds + */ + public boolean importFile(String deviceName, int objectHandle, String destPath) { + MtpDevice device = getDevice(deviceName); + if (device == null) { + return false; + } + return device.importFile(objectHandle, destPath); + } +} diff --git a/src/com/android/gallery3d/data/PanoramaMetadataJob.java b/src/com/android/gallery3d/data/PanoramaMetadataJob.java new file mode 100644 index 000000000..ab99d6a81 --- /dev/null +++ b/src/com/android/gallery3d/data/PanoramaMetadataJob.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import android.content.Context; +import android.net.Uri; + +import com.android.gallery3d.util.LightCycleHelper; +import com.android.gallery3d.util.LightCycleHelper.PanoramaMetadata; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +public class PanoramaMetadataJob implements Job<PanoramaMetadata> { + Context mContext; + Uri mUri; + + public PanoramaMetadataJob(Context context, Uri uri) { + mContext = context; + mUri = uri; + } + + @Override + public PanoramaMetadata run(JobContext jc) { + return LightCycleHelper.getPanoramaMetadata(mContext, mUri); + } +} diff --git a/src/com/android/gallery3d/data/Path.java b/src/com/android/gallery3d/data/Path.java new file mode 100644 index 000000000..fcae65e66 --- /dev/null +++ b/src/com/android/gallery3d/data/Path.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.IdentityCache; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; + +public class Path { + private static final String TAG = "Path"; + private static Path sRoot = new Path(null, "ROOT"); + + private final Path mParent; + private final String mSegment; + private WeakReference<MediaObject> mObject; + private IdentityCache<String, Path> mChildren; + + private Path(Path parent, String segment) { + mParent = parent; + mSegment = segment; + } + + public Path getChild(String segment) { + synchronized (Path.class) { + if (mChildren == null) { + mChildren = new IdentityCache<String, Path>(); + } else { + Path p = mChildren.get(segment); + if (p != null) return p; + } + + Path p = new Path(this, segment); + mChildren.put(segment, p); + return p; + } + } + + public Path getParent() { + synchronized (Path.class) { + return mParent; + } + } + + public Path getChild(int segment) { + return getChild(String.valueOf(segment)); + } + + public Path getChild(long segment) { + return getChild(String.valueOf(segment)); + } + + public void setObject(MediaObject object) { + synchronized (Path.class) { + Utils.assertTrue(mObject == null || mObject.get() == null); + mObject = new WeakReference<MediaObject>(object); + } + } + + MediaObject getObject() { + synchronized (Path.class) { + return (mObject == null) ? null : mObject.get(); + } + } + + @Override + // TODO: toString() should be more efficient, will fix it later + public String toString() { + synchronized (Path.class) { + StringBuilder sb = new StringBuilder(); + String[] segments = split(); + for (int i = 0; i < segments.length; i++) { + sb.append("/"); + sb.append(segments[i]); + } + return sb.toString(); + } + } + + public boolean equalsIgnoreCase (String p) { + String path = toString(); + return path.equalsIgnoreCase(p); + } + + public static Path fromString(String s) { + synchronized (Path.class) { + String[] segments = split(s); + Path current = sRoot; + for (int i = 0; i < segments.length; i++) { + current = current.getChild(segments[i]); + } + return current; + } + } + + public String[] split() { + synchronized (Path.class) { + int n = 0; + for (Path p = this; p != sRoot; p = p.mParent) { + n++; + } + String[] segments = new String[n]; + int i = n - 1; + for (Path p = this; p != sRoot; p = p.mParent) { + segments[i--] = p.mSegment; + } + return segments; + } + } + + public static String[] split(String s) { + int n = s.length(); + if (n == 0) return new String[0]; + if (s.charAt(0) != '/') { + throw new RuntimeException("malformed path:" + s); + } + ArrayList<String> segments = new ArrayList<String>(); + int i = 1; + while (i < n) { + int brace = 0; + int j; + for (j = i; j < n; j++) { + char c = s.charAt(j); + if (c == '{') ++brace; + else if (c == '}') --brace; + else if (brace == 0 && c == '/') break; + } + if (brace != 0) { + throw new RuntimeException("unbalanced brace in path:" + s); + } + segments.add(s.substring(i, j)); + i = j + 1; + } + String[] result = new String[segments.size()]; + segments.toArray(result); + return result; + } + + // Splits a string to an array of strings. + // For example, "{foo,bar,baz}" -> {"foo","bar","baz"}. + public static String[] splitSequence(String s) { + int n = s.length(); + if (s.charAt(0) != '{' || s.charAt(n-1) != '}') { + throw new RuntimeException("bad sequence: " + s); + } + ArrayList<String> segments = new ArrayList<String>(); + int i = 1; + while (i < n - 1) { + int brace = 0; + int j; + for (j = i; j < n - 1; j++) { + char c = s.charAt(j); + if (c == '{') ++brace; + else if (c == '}') --brace; + else if (brace == 0 && c == ',') break; + } + if (brace != 0) { + throw new RuntimeException("unbalanced brace in path:" + s); + } + segments.add(s.substring(i, j)); + i = j + 1; + } + String[] result = new String[segments.size()]; + segments.toArray(result); + return result; + } + + public String getPrefix() { + if (this == sRoot) return ""; + return getPrefixPath().mSegment; + } + + public Path getPrefixPath() { + synchronized (Path.class) { + Path current = this; + if (current == sRoot) { + throw new IllegalStateException(); + } + while (current.mParent != sRoot) { + current = current.mParent; + } + return current; + } + } + + public String getSuffix() { + // We don't need lock because mSegment is final. + return mSegment; + } + + // Below are for testing/debugging only + static void clearAll() { + synchronized (Path.class) { + sRoot = new Path(null, ""); + } + } + + static void dumpAll() { + dumpAll(sRoot, "", ""); + } + + static void dumpAll(Path p, String prefix1, String prefix2) { + synchronized (Path.class) { + MediaObject obj = p.getObject(); + Log.d(TAG, prefix1 + p.mSegment + ":" + + (obj == null ? "null" : obj.getClass().getSimpleName())); + if (p.mChildren != null) { + ArrayList<String> childrenKeys = p.mChildren.keys(); + int i = 0, n = childrenKeys.size(); + for (String key : childrenKeys) { + Path child = p.mChildren.get(key); + if (child == null) { + ++i; + continue; + } + Log.d(TAG, prefix2 + "|"); + if (++i < n) { + dumpAll(child, prefix2 + "+-- ", prefix2 + "| "); + } else { + dumpAll(child, prefix2 + "+-- ", prefix2 + " "); + } + } + } + } + } +} diff --git a/src/com/android/gallery3d/data/PathMatcher.java b/src/com/android/gallery3d/data/PathMatcher.java new file mode 100644 index 000000000..9c6b840d5 --- /dev/null +++ b/src/com/android/gallery3d/data/PathMatcher.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import java.util.ArrayList; +import java.util.HashMap; + +public class PathMatcher { + public static final int NOT_FOUND = -1; + + private ArrayList<String> mVariables = new ArrayList<String>(); + private Node mRoot = new Node(); + + public PathMatcher() { + mRoot = new Node(); + } + + public void add(String pattern, int kind) { + String[] segments = Path.split(pattern); + Node current = mRoot; + for (int i = 0; i < segments.length; i++) { + current = current.addChild(segments[i]); + } + current.setKind(kind); + } + + public int match(Path path) { + String[] segments = path.split(); + mVariables.clear(); + Node current = mRoot; + for (int i = 0; i < segments.length; i++) { + Node next = current.getChild(segments[i]); + if (next == null) { + next = current.getChild("*"); + if (next != null) { + mVariables.add(segments[i]); + } else { + return NOT_FOUND; + } + } + current = next; + } + return current.getKind(); + } + + public String getVar(int index) { + return mVariables.get(index); + } + + public int getIntVar(int index) { + return Integer.parseInt(mVariables.get(index)); + } + + public long getLongVar(int index) { + return Long.parseLong(mVariables.get(index)); + } + + private static class Node { + private HashMap<String, Node> mMap; + private int mKind = NOT_FOUND; + + Node addChild(String segment) { + if (mMap == null) { + mMap = new HashMap<String, Node>(); + } else { + Node node = mMap.get(segment); + if (node != null) return node; + } + + Node n = new Node(); + mMap.put(segment, n); + return n; + } + + Node getChild(String segment) { + if (mMap == null) return null; + return mMap.get(segment); + } + + void setKind(int kind) { + mKind = kind; + } + + int getKind() { + return mKind; + } + } +} diff --git a/src/com/android/gallery3d/data/SecureAlbum.java b/src/com/android/gallery3d/data/SecureAlbum.java new file mode 100644 index 000000000..204f848f8 --- /dev/null +++ b/src/com/android/gallery3d/data/SecureAlbum.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.MediaColumns; +import android.provider.MediaStore.Video; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.app.StitchingChangeListener; +import com.android.gallery3d.util.MediaSetUtils; + +import java.util.ArrayList; + +// This class lists all media items added by the client. +public class SecureAlbum extends MediaSet implements StitchingChangeListener { + @SuppressWarnings("unused") + private static final String TAG = "SecureAlbum"; + private static final String[] PROJECTION = {MediaColumns._ID}; + private int mMinImageId = Integer.MAX_VALUE; // the smallest id of images + private int mMaxImageId = Integer.MIN_VALUE; // the biggest id in images + private int mMinVideoId = Integer.MAX_VALUE; // the smallest id of videos + private int mMaxVideoId = Integer.MIN_VALUE; // the biggest id of videos + // All the media items added by the client. + private ArrayList<Path> mAllItems = new ArrayList<Path>(); + // The types of items in mAllItems. True is video and false is image. + private ArrayList<Boolean> mAllItemTypes = new ArrayList<Boolean>(); + private ArrayList<Path> mExistingItems = new ArrayList<Path>(); + private Context mContext; + private DataManager mDataManager; + private static final Uri[] mWatchUris = + {Images.Media.EXTERNAL_CONTENT_URI, Video.Media.EXTERNAL_CONTENT_URI}; + private final ChangeNotifier mNotifier; + // A placeholder image in the end of secure album. When it is tapped, it + // will take the user to the lock screen. + private MediaItem mUnlockItem; + private boolean mShowUnlockItem; + + public SecureAlbum(Path path, GalleryApp application, MediaItem unlock) { + super(path, nextVersionNumber()); + mContext = application.getAndroidContext(); + mDataManager = application.getDataManager(); + mNotifier = new ChangeNotifier(this, mWatchUris, application); + mUnlockItem = unlock; + mShowUnlockItem = (!isCameraBucketEmpty(Images.Media.EXTERNAL_CONTENT_URI) + || !isCameraBucketEmpty(Video.Media.EXTERNAL_CONTENT_URI)); + } + + public void addMediaItem(boolean isVideo, int id) { + Path pathBase; + if (isVideo) { + pathBase = LocalVideo.ITEM_PATH; + mMinVideoId = Math.min(mMinVideoId, id); + mMaxVideoId = Math.max(mMaxVideoId, id); + } else { + pathBase = LocalImage.ITEM_PATH; + mMinImageId = Math.min(mMinImageId, id); + mMaxImageId = Math.max(mMaxImageId, id); + } + Path path = pathBase.getChild(id); + if (!mAllItems.contains(path)) { + mAllItems.add(path); + mAllItemTypes.add(isVideo); + mNotifier.fakeChange(); + } + } + + // The sequence is stitching items, local media items, and unlock image. + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + int existingCount = mExistingItems.size(); + if (start >= existingCount + 1) { + return new ArrayList<MediaItem>(); + } + + // Add paths of requested stitching items. + int end = Math.min(start + count, existingCount); + ArrayList<Path> subset = new ArrayList<Path>(mExistingItems.subList(start, end)); + + // Convert paths to media items. + final MediaItem[] buf = new MediaItem[end - start]; + ItemConsumer consumer = new ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + buf[index] = item; + } + }; + mDataManager.mapMediaItems(subset, consumer, 0); + ArrayList<MediaItem> result = new ArrayList<MediaItem>(end - start); + for (int i = 0; i < buf.length; i++) { + result.add(buf[i]); + } + if (mShowUnlockItem) result.add(mUnlockItem); + return result; + } + + @Override + public int getMediaItemCount() { + return (mExistingItems.size() + (mShowUnlockItem ? 1 : 0)); + } + + @Override + public String getName() { + return "secure"; + } + + @Override + public long reload() { + if (mNotifier.isDirty()) { + mDataVersion = nextVersionNumber(); + updateExistingItems(); + } + return mDataVersion; + } + + private ArrayList<Integer> queryExistingIds(Uri uri, int minId, int maxId) { + ArrayList<Integer> ids = new ArrayList<Integer>(); + if (minId == Integer.MAX_VALUE || maxId == Integer.MIN_VALUE) return ids; + + String[] selectionArgs = {String.valueOf(minId), String.valueOf(maxId)}; + Cursor cursor = mContext.getContentResolver().query(uri, PROJECTION, + "_id BETWEEN ? AND ?", selectionArgs, null); + if (cursor == null) return ids; + try { + while (cursor.moveToNext()) { + ids.add(cursor.getInt(0)); + } + } finally { + cursor.close(); + } + return ids; + } + + private boolean isCameraBucketEmpty(Uri baseUri) { + Uri uri = baseUri.buildUpon() + .appendQueryParameter("limit", "1").build(); + String[] selection = {String.valueOf(MediaSetUtils.CAMERA_BUCKET_ID)}; + Cursor cursor = mContext.getContentResolver().query(uri, PROJECTION, + "bucket_id = ?", selection, null); + if (cursor == null) return true; + try { + return (cursor.getCount() == 0); + } finally { + cursor.close(); + } + } + + private void updateExistingItems() { + if (mAllItems.size() == 0) return; + + // Query existing ids. + ArrayList<Integer> imageIds = queryExistingIds( + Images.Media.EXTERNAL_CONTENT_URI, mMinImageId, mMaxImageId); + ArrayList<Integer> videoIds = queryExistingIds( + Video.Media.EXTERNAL_CONTENT_URI, mMinVideoId, mMaxVideoId); + + // Construct the existing items list. + mExistingItems.clear(); + for (int i = mAllItems.size() - 1; i >= 0; i--) { + Path path = mAllItems.get(i); + boolean isVideo = mAllItemTypes.get(i); + int id = Integer.parseInt(path.getSuffix()); + if (isVideo) { + if (videoIds.contains(id)) mExistingItems.add(path); + } else { + if (imageIds.contains(id)) mExistingItems.add(path); + } + } + } + + @Override + public boolean isLeafAlbum() { + return true; + } + + @Override + public void onStitchingQueued(Uri uri) { + int id = Integer.parseInt(uri.getLastPathSegment()); + addMediaItem(false, id); + } + + @Override + public void onStitchingResult(Uri uri) { + } + + @Override + public void onStitchingProgress(Uri uri, final int progress) { + } +} diff --git a/src/com/android/gallery3d/data/SecureSource.java b/src/com/android/gallery3d/data/SecureSource.java new file mode 100644 index 000000000..6bc8cc295 --- /dev/null +++ b/src/com/android/gallery3d/data/SecureSource.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +public class SecureSource extends MediaSource { + private GalleryApp mApplication; + private static PathMatcher mMatcher = new PathMatcher(); + private static final int SECURE_ALBUM = 0; + private static final int SECURE_UNLOCK = 1; + + static { + mMatcher.add("/secure/all/*", SECURE_ALBUM); + mMatcher.add("/secure/unlock", SECURE_UNLOCK); + } + + public SecureSource(GalleryApp context) { + super("secure"); + mApplication = context; + } + + public static boolean isSecurePath(String path) { + return (SECURE_ALBUM == mMatcher.match(Path.fromString(path))); + } + + @Override + public MediaObject createMediaObject(Path path) { + switch (mMatcher.match(path)) { + case SECURE_ALBUM: { + DataManager dataManager = mApplication.getDataManager(); + MediaItem unlock = (MediaItem) dataManager.getMediaObject( + "/secure/unlock"); + return new SecureAlbum(path, mApplication, unlock); + } + case SECURE_UNLOCK: + return new UnlockImage(path, mApplication); + default: + throw new RuntimeException("bad path: " + path); + } + } +} diff --git a/src/com/android/gallery3d/data/SingleItemAlbum.java b/src/com/android/gallery3d/data/SingleItemAlbum.java new file mode 100644 index 000000000..a0093e0c3 --- /dev/null +++ b/src/com/android/gallery3d/data/SingleItemAlbum.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import java.util.ArrayList; + +public class SingleItemAlbum extends MediaSet { + @SuppressWarnings("unused") + private static final String TAG = "SingleItemAlbum"; + private final MediaItem mItem; + private final String mName; + + public SingleItemAlbum(Path path, MediaItem item) { + super(path, nextVersionNumber()); + mItem = item; + mName = "SingleItemAlbum("+mItem.getClass().getSimpleName()+")"; + } + + @Override + public int getMediaItemCount() { + return 1; + } + + @Override + public ArrayList<MediaItem> getMediaItem(int start, int count) { + ArrayList<MediaItem> result = new ArrayList<MediaItem>(); + + // If [start, start+count) contains the index 0, return the item. + if (start <= 0 && start + count > 0) { + result.add(mItem); + } + + return result; + } + + public MediaItem getItem() { + return mItem; + } + + @Override + public boolean isLeafAlbum() { + return true; + } + + @Override + public String getName() { + return mName; + } + + @Override + public long reload() { + return mDataVersion; + } +} diff --git a/src/com/android/gallery3d/data/SizeClustering.java b/src/com/android/gallery3d/data/SizeClustering.java new file mode 100644 index 000000000..b809c841b --- /dev/null +++ b/src/com/android/gallery3d/data/SizeClustering.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.Context; +import android.content.res.Resources; + +import com.android.gallery3d.R; + +import java.util.ArrayList; + +public class SizeClustering extends Clustering { + @SuppressWarnings("unused") + private static final String TAG = "SizeClustering"; + + private Context mContext; + private ArrayList<Path>[] mClusters; + private String[] mNames; + private long mMinSizes[]; + + private static final long MEGA_BYTES = 1024L*1024; + private static final long GIGA_BYTES = 1024L*1024*1024; + + private static final long[] SIZE_LEVELS = { + 0, + 1 * MEGA_BYTES, + 10 * MEGA_BYTES, + 100 * MEGA_BYTES, + 1 * GIGA_BYTES, + 2 * GIGA_BYTES, + 4 * GIGA_BYTES, + }; + + public SizeClustering(Context context) { + mContext = context; + } + + @SuppressWarnings("unchecked") + @Override + public void run(MediaSet baseSet) { + @SuppressWarnings("unchecked") + final ArrayList<Path>[] group = new ArrayList[SIZE_LEVELS.length]; + baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + // Find the cluster this item belongs to. + long size = item.getSize(); + int i; + for (i = 0; i < SIZE_LEVELS.length - 1; i++) { + if (size < SIZE_LEVELS[i + 1]) { + break; + } + } + + ArrayList<Path> list = group[i]; + if (list == null) { + list = new ArrayList<Path>(); + group[i] = list; + } + list.add(item.getPath()); + } + }); + + int count = 0; + for (int i = 0; i < group.length; i++) { + if (group[i] != null) { + count++; + } + } + + mClusters = new ArrayList[count]; + mNames = new String[count]; + mMinSizes = new long[count]; + + Resources res = mContext.getResources(); + int k = 0; + // Go through group in the reverse order, so the group with the largest + // size will show first. + for (int i = group.length - 1; i >= 0; i--) { + if (group[i] == null) continue; + + mClusters[k] = group[i]; + if (i == 0) { + mNames[k] = String.format( + res.getString(R.string.size_below), getSizeString(i + 1)); + } else if (i == group.length - 1) { + mNames[k] = String.format( + res.getString(R.string.size_above), getSizeString(i)); + } else { + String minSize = getSizeString(i); + String maxSize = getSizeString(i + 1); + mNames[k] = String.format( + res.getString(R.string.size_between), minSize, maxSize); + } + mMinSizes[k] = SIZE_LEVELS[i]; + k++; + } + } + + private String getSizeString(int index) { + long bytes = SIZE_LEVELS[index]; + if (bytes >= GIGA_BYTES) { + return (bytes / GIGA_BYTES) + "GB"; + } else { + return (bytes / MEGA_BYTES) + "MB"; + } + } + + @Override + public int getNumberOfClusters() { + return mClusters.length; + } + + @Override + public ArrayList<Path> getCluster(int index) { + return mClusters[index]; + } + + @Override + public String getClusterName(int index) { + return mNames[index]; + } + + public long getMinSize(int index) { + return mMinSizes[index]; + } +} diff --git a/src/com/android/gallery3d/data/SnailAlbum.java b/src/com/android/gallery3d/data/SnailAlbum.java new file mode 100644 index 000000000..7bce7a695 --- /dev/null +++ b/src/com/android/gallery3d/data/SnailAlbum.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import java.util.concurrent.atomic.AtomicBoolean; + +// This is a simple MediaSet which contains only one MediaItem -- a SnailItem. +public class SnailAlbum extends SingleItemAlbum { + @SuppressWarnings("unused") + private static final String TAG = "SnailAlbum"; + private AtomicBoolean mDirty = new AtomicBoolean(false); + + public SnailAlbum(Path path, SnailItem item) { + super(path, item); + } + + @Override + public long reload() { + if (mDirty.compareAndSet(true, false)) { + ((SnailItem) getItem()).updateVersion(); + mDataVersion = nextVersionNumber(); + } + return mDataVersion; + } + + public void notifyChange() { + mDirty.set(true); + notifyContentChanged(); + } +} diff --git a/src/com/android/gallery3d/data/SnailItem.java b/src/com/android/gallery3d/data/SnailItem.java new file mode 100644 index 000000000..3586d2cab --- /dev/null +++ b/src/com/android/gallery3d/data/SnailItem.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import android.graphics.Bitmap; +import android.graphics.BitmapRegionDecoder; + +import com.android.gallery3d.ui.ScreenNail; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +// SnailItem is a MediaItem which can provide a ScreenNail. This is +// used so we can show an foreign component (like an +// android.view.View) instead of a Bitmap. +public class SnailItem extends MediaItem { + @SuppressWarnings("unused") + private static final String TAG = "SnailItem"; + private ScreenNail mScreenNail; + + public SnailItem(Path path) { + super(path, nextVersionNumber()); + } + + @Override + public Job<Bitmap> requestImage(int type) { + // nothing to return + return new Job<Bitmap>() { + @Override + public Bitmap run(JobContext jc) { + return null; + } + }; + } + + @Override + public Job<BitmapRegionDecoder> requestLargeImage() { + // nothing to return + return new Job<BitmapRegionDecoder>() { + @Override + public BitmapRegionDecoder run(JobContext jc) { + return null; + } + }; + } + + // We do not provide requestImage or requestLargeImage, instead we + // provide a ScreenNail. + @Override + public ScreenNail getScreenNail() { + return mScreenNail; + } + + @Override + public String getMimeType() { + return ""; + } + + // Returns width and height of the media item. + // Returns 0, 0 if the information is not available. + @Override + public int getWidth() { + return 0; + } + + @Override + public int getHeight() { + return 0; + } + + ////////////////////////////////////////////////////////////////////////// + // Extra methods for SnailItem + ////////////////////////////////////////////////////////////////////////// + + public void setScreenNail(ScreenNail screenNail) { + mScreenNail = screenNail; + } + + public void updateVersion() { + mDataVersion = nextVersionNumber(); + } +} diff --git a/src/com/android/gallery3d/data/SnailSource.java b/src/com/android/gallery3d/data/SnailSource.java new file mode 100644 index 000000000..5c690ccdb --- /dev/null +++ b/src/com/android/gallery3d/data/SnailSource.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import com.android.gallery3d.app.GalleryApp; + +public class SnailSource extends MediaSource { + @SuppressWarnings("unused") + private static final String TAG = "SnailSource"; + private static final int SNAIL_ALBUM = 0; + private static final int SNAIL_ITEM = 1; + + private GalleryApp mApplication; + private PathMatcher mMatcher; + private static int sNextId; + + public SnailSource(GalleryApp application) { + super("snail"); + mApplication = application; + mMatcher = new PathMatcher(); + mMatcher.add("/snail/set/*", SNAIL_ALBUM); + mMatcher.add("/snail/item/*", SNAIL_ITEM); + } + + // The only path we accept is "/snail/set/id" and "/snail/item/id" + @Override + public MediaObject createMediaObject(Path path) { + DataManager dataManager = mApplication.getDataManager(); + switch (mMatcher.match(path)) { + case SNAIL_ALBUM: + String itemPath = "/snail/item/" + mMatcher.getVar(0); + SnailItem item = + (SnailItem) dataManager.getMediaObject(itemPath); + return new SnailAlbum(path, item); + case SNAIL_ITEM: { + int id = mMatcher.getIntVar(0); + return new SnailItem(path); + } + } + return null; + } + + // Registers a new SnailAlbum containing a SnailItem and returns the id of + // them. You can obtain the Path of the SnailAlbum and SnailItem associated + // with the id by getSetPath and getItemPath(). + public static synchronized int newId() { + return sNextId++; + } + + public static Path getSetPath(int id) { + return Path.fromString("/snail/set").getChild(id); + } + + public static Path getItemPath(int id) { + return Path.fromString("/snail/item").getChild(id); + } +} diff --git a/src/com/android/gallery3d/data/TagClustering.java b/src/com/android/gallery3d/data/TagClustering.java new file mode 100644 index 000000000..407ca84c4 --- /dev/null +++ b/src/com/android/gallery3d/data/TagClustering.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.Context; + +import com.android.gallery3d.R; + +import java.util.ArrayList; +import java.util.Map; +import java.util.TreeMap; + +public class TagClustering extends Clustering { + @SuppressWarnings("unused") + private static final String TAG = "TagClustering"; + + private ArrayList<ArrayList<Path>> mClusters; + private String[] mNames; + private String mUntaggedString; + + public TagClustering(Context context) { + mUntaggedString = context.getResources().getString(R.string.untagged); + } + + @Override + public void run(MediaSet baseSet) { + final TreeMap<String, ArrayList<Path>> map = + new TreeMap<String, ArrayList<Path>>(); + final ArrayList<Path> untagged = new ArrayList<Path>(); + + baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + Path path = item.getPath(); + + String[] tags = item.getTags(); + if (tags == null || tags.length == 0) { + untagged.add(path); + return; + } + for (int j = 0; j < tags.length; j++) { + String key = tags[j]; + ArrayList<Path> list = map.get(key); + if (list == null) { + list = new ArrayList<Path>(); + map.put(key, list); + } + list.add(path); + } + } + }); + + int m = map.size(); + mClusters = new ArrayList<ArrayList<Path>>(); + mNames = new String[m + ((untagged.size() > 0) ? 1 : 0)]; + int i = 0; + for (Map.Entry<String, ArrayList<Path>> entry : map.entrySet()) { + mNames[i++] = entry.getKey(); + mClusters.add(entry.getValue()); + } + if (untagged.size() > 0) { + mNames[i++] = mUntaggedString; + mClusters.add(untagged); + } + } + + @Override + public int getNumberOfClusters() { + return mClusters.size(); + } + + @Override + public ArrayList<Path> getCluster(int index) { + return mClusters.get(index); + } + + @Override + public String getClusterName(int index) { + return mNames[index]; + } +} diff --git a/src/com/android/gallery3d/data/TimeClustering.java b/src/com/android/gallery3d/data/TimeClustering.java new file mode 100644 index 000000000..35cbab1ee --- /dev/null +++ b/src/com/android/gallery3d/data/TimeClustering.java @@ -0,0 +1,439 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.Context; +import android.text.format.DateFormat; +import android.text.format.DateUtils; + +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.GalleryUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + +public class TimeClustering extends Clustering { + @SuppressWarnings("unused") + private static final String TAG = "TimeClustering"; + + // If 2 items are greater than 25 miles apart, they will be in different + // clusters. + private static final int GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES = 20; + + // Do not want to split based on anything under 1 min. + private static final long MIN_CLUSTER_SPLIT_TIME_IN_MS = 60000L; + + // Disregard a cluster split time of anything over 2 hours. + private static final long MAX_CLUSTER_SPLIT_TIME_IN_MS = 7200000L; + + // Try and get around 9 clusters (best-effort for the common case). + private static final int NUM_CLUSTERS_TARGETED = 9; + + // Try and merge 2 clusters if they are both smaller than min cluster size. + // The min cluster size can range from 8 to 15. + private static final int MIN_MIN_CLUSTER_SIZE = 8; + private static final int MAX_MIN_CLUSTER_SIZE = 15; + + // Try and split a cluster if it is bigger than max cluster size. + // The max cluster size can range from 20 to 50. + private static final int MIN_MAX_CLUSTER_SIZE = 20; + private static final int MAX_MAX_CLUSTER_SIZE = 50; + + // Initially put 2 items in the same cluster as long as they are within + // 3 cluster frequencies of each other. + private static int CLUSTER_SPLIT_MULTIPLIER = 3; + + // The minimum change factor in the time between items to consider a + // partition. + // Example: (Item 3 - Item 2) / (Item 2 - Item 1). + private static final int MIN_PARTITION_CHANGE_FACTOR = 2; + + // Make the cluster split time of a large cluster half that of a regular + // cluster. + private static final int PARTITION_CLUSTER_SPLIT_TIME_FACTOR = 2; + + private Context mContext; + private ArrayList<Cluster> mClusters; + private String[] mNames; + private Cluster mCurrCluster; + + private long mClusterSplitTime = + (MIN_CLUSTER_SPLIT_TIME_IN_MS + MAX_CLUSTER_SPLIT_TIME_IN_MS) / 2; + private long mLargeClusterSplitTime = + mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR; + private int mMinClusterSize = (MIN_MIN_CLUSTER_SIZE + MAX_MIN_CLUSTER_SIZE) / 2; + private int mMaxClusterSize = (MIN_MAX_CLUSTER_SIZE + MAX_MAX_CLUSTER_SIZE) / 2; + + + private static final Comparator<SmallItem> sDateComparator = + new DateComparator(); + + private static class DateComparator implements Comparator<SmallItem> { + @Override + public int compare(SmallItem item1, SmallItem item2) { + return -Utils.compare(item1.dateInMs, item2.dateInMs); + } + } + + public TimeClustering(Context context) { + mContext = context; + mClusters = new ArrayList<Cluster>(); + mCurrCluster = new Cluster(); + } + + @Override + public void run(MediaSet baseSet) { + final int total = baseSet.getTotalMediaItemCount(); + final SmallItem[] buf = new SmallItem[total]; + final double[] latLng = new double[2]; + + baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() { + @Override + public void consume(int index, MediaItem item) { + if (index < 0 || index >= total) return; + SmallItem s = new SmallItem(); + s.path = item.getPath(); + s.dateInMs = item.getDateInMs(); + item.getLatLong(latLng); + s.lat = latLng[0]; + s.lng = latLng[1]; + buf[index] = s; + } + }); + + ArrayList<SmallItem> items = new ArrayList<SmallItem>(total); + for (int i = 0; i < total; i++) { + if (buf[i] != null) { + items.add(buf[i]); + } + } + + Collections.sort(items, sDateComparator); + + int n = items.size(); + long minTime = 0; + long maxTime = 0; + for (int i = 0; i < n; i++) { + long t = items.get(i).dateInMs; + if (t == 0) continue; + if (minTime == 0) { + minTime = maxTime = t; + } else { + minTime = Math.min(minTime, t); + maxTime = Math.max(maxTime, t); + } + } + + setTimeRange(maxTime - minTime, n); + + for (int i = 0; i < n; i++) { + compute(items.get(i)); + } + + compute(null); + + int m = mClusters.size(); + mNames = new String[m]; + for (int i = 0; i < m; i++) { + mNames[i] = mClusters.get(i).generateCaption(mContext); + } + } + + @Override + public int getNumberOfClusters() { + return mClusters.size(); + } + + @Override + public ArrayList<Path> getCluster(int index) { + ArrayList<SmallItem> items = mClusters.get(index).getItems(); + ArrayList<Path> result = new ArrayList<Path>(items.size()); + for (int i = 0, n = items.size(); i < n; i++) { + result.add(items.get(i).path); + } + return result; + } + + @Override + public String getClusterName(int index) { + return mNames[index]; + } + + private void setTimeRange(long timeRange, int numItems) { + if (numItems != 0) { + int meanItemsPerCluster = numItems / NUM_CLUSTERS_TARGETED; + // Heuristic to get min and max cluster size - half and double the + // desired items per cluster. + mMinClusterSize = meanItemsPerCluster / 2; + mMaxClusterSize = meanItemsPerCluster * 2; + mClusterSplitTime = timeRange / numItems * CLUSTER_SPLIT_MULTIPLIER; + } + mClusterSplitTime = Utils.clamp(mClusterSplitTime, MIN_CLUSTER_SPLIT_TIME_IN_MS, MAX_CLUSTER_SPLIT_TIME_IN_MS); + mLargeClusterSplitTime = mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR; + mMinClusterSize = Utils.clamp(mMinClusterSize, MIN_MIN_CLUSTER_SIZE, MAX_MIN_CLUSTER_SIZE); + mMaxClusterSize = Utils.clamp(mMaxClusterSize, MIN_MAX_CLUSTER_SIZE, MAX_MAX_CLUSTER_SIZE); + } + + private void compute(SmallItem currentItem) { + if (currentItem != null) { + int numClusters = mClusters.size(); + int numCurrClusterItems = mCurrCluster.size(); + boolean geographicallySeparateItem = false; + boolean itemAddedToCurrentCluster = false; + + // Determine if this item should go in the current cluster or be the + // start of a new cluster. + if (numCurrClusterItems == 0) { + mCurrCluster.addItem(currentItem); + } else { + SmallItem prevItem = mCurrCluster.getLastItem(); + if (isGeographicallySeparated(prevItem, currentItem)) { + mClusters.add(mCurrCluster); + geographicallySeparateItem = true; + } else if (numCurrClusterItems > mMaxClusterSize) { + splitAndAddCurrentCluster(); + } else if (timeDistance(prevItem, currentItem) < mClusterSplitTime) { + mCurrCluster.addItem(currentItem); + itemAddedToCurrentCluster = true; + } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize + && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) { + mergeAndAddCurrentCluster(); + } else { + mClusters.add(mCurrCluster); + } + + // Creating a new cluster and adding the current item to it. + if (!itemAddedToCurrentCluster) { + mCurrCluster = new Cluster(); + if (geographicallySeparateItem) { + mCurrCluster.mGeographicallySeparatedFromPrevCluster = true; + } + mCurrCluster.addItem(currentItem); + } + } + } else { + if (mCurrCluster.size() > 0) { + int numClusters = mClusters.size(); + int numCurrClusterItems = mCurrCluster.size(); + + // The last cluster may potentially be too big or too small. + if (numCurrClusterItems > mMaxClusterSize) { + splitAndAddCurrentCluster(); + } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize + && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) { + mergeAndAddCurrentCluster(); + } else { + mClusters.add(mCurrCluster); + } + mCurrCluster = new Cluster(); + } + } + } + + private void splitAndAddCurrentCluster() { + ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems(); + int numCurrClusterItems = mCurrCluster.size(); + int secondPartitionStartIndex = getPartitionIndexForCurrentCluster(); + if (secondPartitionStartIndex != -1) { + Cluster partitionedCluster = new Cluster(); + for (int j = 0; j < secondPartitionStartIndex; j++) { + partitionedCluster.addItem(currClusterItems.get(j)); + } + mClusters.add(partitionedCluster); + partitionedCluster = new Cluster(); + for (int j = secondPartitionStartIndex; j < numCurrClusterItems; j++) { + partitionedCluster.addItem(currClusterItems.get(j)); + } + mClusters.add(partitionedCluster); + } else { + mClusters.add(mCurrCluster); + } + } + + private int getPartitionIndexForCurrentCluster() { + int partitionIndex = -1; + float largestChange = MIN_PARTITION_CHANGE_FACTOR; + ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems(); + int numCurrClusterItems = mCurrCluster.size(); + int minClusterSize = mMinClusterSize; + + // Could be slightly more efficient here but this code seems cleaner. + if (numCurrClusterItems > minClusterSize + 1) { + for (int i = minClusterSize; i < numCurrClusterItems - minClusterSize; i++) { + SmallItem prevItem = currClusterItems.get(i - 1); + SmallItem currItem = currClusterItems.get(i); + SmallItem nextItem = currClusterItems.get(i + 1); + + long timeNext = nextItem.dateInMs; + long timeCurr = currItem.dateInMs; + long timePrev = prevItem.dateInMs; + + if (timeNext == 0 || timeCurr == 0 || timePrev == 0) continue; + + long diff1 = Math.abs(timeNext - timeCurr); + long diff2 = Math.abs(timeCurr - timePrev); + + float change = Math.max(diff1 / (diff2 + 0.01f), diff2 / (diff1 + 0.01f)); + if (change > largestChange) { + if (timeDistance(currItem, prevItem) > mLargeClusterSplitTime) { + partitionIndex = i; + largestChange = change; + } else if (timeDistance(nextItem, currItem) > mLargeClusterSplitTime) { + partitionIndex = i + 1; + largestChange = change; + } + } + } + } + return partitionIndex; + } + + private void mergeAndAddCurrentCluster() { + int numClusters = mClusters.size(); + Cluster prevCluster = mClusters.get(numClusters - 1); + ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems(); + int numCurrClusterItems = mCurrCluster.size(); + if (prevCluster.size() < mMinClusterSize) { + for (int i = 0; i < numCurrClusterItems; i++) { + prevCluster.addItem(currClusterItems.get(i)); + } + mClusters.set(numClusters - 1, prevCluster); + } else { + mClusters.add(mCurrCluster); + } + } + + // Returns true if a, b are sufficiently geographically separated. + private static boolean isGeographicallySeparated(SmallItem itemA, SmallItem itemB) { + if (!GalleryUtils.isValidLocation(itemA.lat, itemA.lng) + || !GalleryUtils.isValidLocation(itemB.lat, itemB.lng)) { + return false; + } + + double distance = GalleryUtils.fastDistanceMeters( + Math.toRadians(itemA.lat), + Math.toRadians(itemA.lng), + Math.toRadians(itemB.lat), + Math.toRadians(itemB.lng)); + return (GalleryUtils.toMile(distance) > GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES); + } + + // Returns the time interval between the two items in milliseconds. + private static long timeDistance(SmallItem a, SmallItem b) { + return Math.abs(a.dateInMs - b.dateInMs); + } +} + +class SmallItem { + Path path; + long dateInMs; + double lat, lng; +} + +class Cluster { + @SuppressWarnings("unused") + private static final String TAG = "Cluster"; + private static final String MMDDYY_FORMAT = "MMddyy"; + + // This is for TimeClustering only. + public boolean mGeographicallySeparatedFromPrevCluster = false; + + private ArrayList<SmallItem> mItems = new ArrayList<SmallItem>(); + + public Cluster() { + } + + public void addItem(SmallItem item) { + mItems.add(item); + } + + public int size() { + return mItems.size(); + } + + public SmallItem getLastItem() { + int n = mItems.size(); + return (n == 0) ? null : mItems.get(n - 1); + } + + public ArrayList<SmallItem> getItems() { + return mItems; + } + + public String generateCaption(Context context) { + int n = mItems.size(); + long minTimestamp = 0; + long maxTimestamp = 0; + + for (int i = 0; i < n; i++) { + long t = mItems.get(i).dateInMs; + if (t == 0) continue; + if (minTimestamp == 0) { + minTimestamp = maxTimestamp = t; + } else { + minTimestamp = Math.min(minTimestamp, t); + maxTimestamp = Math.max(maxTimestamp, t); + } + } + if (minTimestamp == 0) return ""; + + String caption; + String minDay = DateFormat.format(MMDDYY_FORMAT, minTimestamp) + .toString(); + String maxDay = DateFormat.format(MMDDYY_FORMAT, maxTimestamp) + .toString(); + + if (minDay.substring(4).equals(maxDay.substring(4))) { + // The items are from the same year - show at least as + // much granularity as abbrev_all allows. + caption = DateUtils.formatDateRange(context, minTimestamp, + maxTimestamp, DateUtils.FORMAT_ABBREV_ALL); + + // Get a more granular date range string if the min and + // max timestamp are on the same day and from the + // current year. + if (minDay.equals(maxDay)) { + int flags = DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE; + // Contains the year only if the date does not + // correspond to the current year. + String dateRangeWithOptionalYear = DateUtils.formatDateTime( + context, minTimestamp, flags); + String dateRangeWithYear = DateUtils.formatDateTime( + context, minTimestamp, flags | DateUtils.FORMAT_SHOW_YEAR); + if (!dateRangeWithOptionalYear.equals(dateRangeWithYear)) { + // This means both dates are from the same year + // - show the time. + // Not enough room to display the time range. + // Pick the mid-point. + long midTimestamp = (minTimestamp + maxTimestamp) / 2; + caption = DateUtils.formatDateRange(context, midTimestamp, + midTimestamp, DateUtils.FORMAT_SHOW_TIME | flags); + } + } + } else { + // The items are not from the same year - only show + // month and year. + int flags = DateUtils.FORMAT_NO_MONTH_DAY + | DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE; + caption = DateUtils.formatDateRange(context, minTimestamp, + maxTimestamp, flags); + } + + return caption; + } +} diff --git a/src/com/android/gallery3d/data/UnlockImage.java b/src/com/android/gallery3d/data/UnlockImage.java new file mode 100644 index 000000000..ed3b485c4 --- /dev/null +++ b/src/com/android/gallery3d/data/UnlockImage.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2012 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.gallery3d.data; + +import com.android.gallery3d.R; +import com.android.gallery3d.app.GalleryApp; + +public class UnlockImage extends ActionImage { + @SuppressWarnings("unused") + private static final String TAG = "UnlockImage"; + + public UnlockImage(Path path, GalleryApp application) { + super(path, application, R.drawable.placeholder_locked); + } + + @Override + public int getSupportedOperations() { + return super.getSupportedOperations() | SUPPORT_UNLOCK; + } +} diff --git a/src/com/android/gallery3d/data/UriImage.java b/src/com/android/gallery3d/data/UriImage.java new file mode 100644 index 000000000..e8875b572 --- /dev/null +++ b/src/com/android/gallery3d/data/UriImage.java @@ -0,0 +1,298 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.ContentResolver; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory.Options; +import android.graphics.BitmapRegionDecoder; +import android.net.Uri; +import android.os.ParcelFileDescriptor; + +import com.android.gallery3d.app.GalleryApp; +import com.android.gallery3d.app.PanoramaMetadataSupport; +import com.android.gallery3d.common.BitmapUtils; +import com.android.gallery3d.common.Utils; +import com.android.gallery3d.util.ThreadPool.CancelListener; +import com.android.gallery3d.util.ThreadPool.Job; +import com.android.gallery3d.util.ThreadPool.JobContext; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; + +public class UriImage extends MediaItem { + private static final String TAG = "UriImage"; + + private static final int STATE_INIT = 0; + private static final int STATE_DOWNLOADING = 1; + private static final int STATE_DOWNLOADED = 2; + private static final int STATE_ERROR = -1; + + private final Uri mUri; + private final String mContentType; + + private DownloadCache.Entry mCacheEntry; + private ParcelFileDescriptor mFileDescriptor; + private int mState = STATE_INIT; + private int mWidth; + private int mHeight; + private int mRotation; + private PanoramaMetadataSupport mPanoramaMetadata = new PanoramaMetadataSupport(this); + + private GalleryApp mApplication; + + public UriImage(GalleryApp application, Path path, Uri uri, String contentType) { + super(path, nextVersionNumber()); + mUri = uri; + mApplication = Utils.checkNotNull(application); + mContentType = contentType; + } + + @Override + public Job<Bitmap> requestImage(int type) { + return new BitmapJob(type); + } + + @Override + public Job<BitmapRegionDecoder> requestLargeImage() { + return new RegionDecoderJob(); + } + + private void openFileOrDownloadTempFile(JobContext jc) { + int state = openOrDownloadInner(jc); + synchronized (this) { + mState = state; + if (mState != STATE_DOWNLOADED) { + if (mFileDescriptor != null) { + Utils.closeSilently(mFileDescriptor); + mFileDescriptor = null; + } + } + notifyAll(); + } + } + + private int openOrDownloadInner(JobContext jc) { + String scheme = mUri.getScheme(); + if (ContentResolver.SCHEME_CONTENT.equals(scheme) + || ContentResolver.SCHEME_ANDROID_RESOURCE.equals(scheme) + || ContentResolver.SCHEME_FILE.equals(scheme)) { + try { + if (MIME_TYPE_JPEG.equalsIgnoreCase(mContentType)) { + InputStream is = mApplication.getContentResolver() + .openInputStream(mUri); + mRotation = Exif.getOrientation(is); + Utils.closeSilently(is); + } + mFileDescriptor = mApplication.getContentResolver() + .openFileDescriptor(mUri, "r"); + if (jc.isCancelled()) return STATE_INIT; + return STATE_DOWNLOADED; + } catch (FileNotFoundException e) { + Log.w(TAG, "fail to open: " + mUri, e); + return STATE_ERROR; + } + } else { + try { + URL url = new URI(mUri.toString()).toURL(); + mCacheEntry = mApplication.getDownloadCache().download(jc, url); + if (jc.isCancelled()) return STATE_INIT; + if (mCacheEntry == null) { + Log.w(TAG, "download failed " + url); + return STATE_ERROR; + } + if (MIME_TYPE_JPEG.equalsIgnoreCase(mContentType)) { + InputStream is = new FileInputStream(mCacheEntry.cacheFile); + mRotation = Exif.getOrientation(is); + Utils.closeSilently(is); + } + mFileDescriptor = ParcelFileDescriptor.open( + mCacheEntry.cacheFile, ParcelFileDescriptor.MODE_READ_ONLY); + return STATE_DOWNLOADED; + } catch (Throwable t) { + Log.w(TAG, "download error", t); + return STATE_ERROR; + } + } + } + + private boolean prepareInputFile(JobContext jc) { + jc.setCancelListener(new CancelListener() { + @Override + public void onCancel() { + synchronized (this) { + notifyAll(); + } + } + }); + + while (true) { + synchronized (this) { + if (jc.isCancelled()) return false; + if (mState == STATE_INIT) { + mState = STATE_DOWNLOADING; + // Then leave the synchronized block and continue. + } else if (mState == STATE_ERROR) { + return false; + } else if (mState == STATE_DOWNLOADED) { + return true; + } else /* if (mState == STATE_DOWNLOADING) */ { + try { + wait(); + } catch (InterruptedException ex) { + // ignored. + } + continue; + } + } + // This is only reached for STATE_INIT->STATE_DOWNLOADING + openFileOrDownloadTempFile(jc); + } + } + + private class RegionDecoderJob implements Job<BitmapRegionDecoder> { + @Override + public BitmapRegionDecoder run(JobContext jc) { + if (!prepareInputFile(jc)) return null; + BitmapRegionDecoder decoder = DecodeUtils.createBitmapRegionDecoder( + jc, mFileDescriptor.getFileDescriptor(), false); + mWidth = decoder.getWidth(); + mHeight = decoder.getHeight(); + return decoder; + } + } + + private class BitmapJob implements Job<Bitmap> { + private int mType; + + protected BitmapJob(int type) { + mType = type; + } + + @Override + public Bitmap run(JobContext jc) { + if (!prepareInputFile(jc)) return null; + int targetSize = MediaItem.getTargetSize(mType); + Options options = new Options(); + options.inPreferredConfig = Config.ARGB_8888; + Bitmap bitmap = DecodeUtils.decodeThumbnail(jc, + mFileDescriptor.getFileDescriptor(), options, targetSize, mType); + + if (jc.isCancelled() || bitmap == null) { + return null; + } + + if (mType == MediaItem.TYPE_MICROTHUMBNAIL) { + bitmap = BitmapUtils.resizeAndCropCenter(bitmap, targetSize, true); + } else { + bitmap = BitmapUtils.resizeDownBySideLength(bitmap, targetSize, true); + } + return bitmap; + } + } + + @Override + public int getSupportedOperations() { + int supported = SUPPORT_EDIT | SUPPORT_SETAS; + if (isSharable()) supported |= SUPPORT_SHARE; + if (BitmapUtils.isSupportedByRegionDecoder(mContentType)) { + supported |= SUPPORT_FULL_IMAGE; + } + return supported; + } + + @Override + public void getPanoramaSupport(PanoramaSupportCallback callback) { + mPanoramaMetadata.getPanoramaSupport(mApplication, callback); + } + + @Override + public void clearCachedPanoramaSupport() { + mPanoramaMetadata.clearCachedValues(); + } + + private boolean isSharable() { + // We cannot grant read permission to the receiver since we put + // the data URI in EXTRA_STREAM instead of the data part of an intent + // And there are issues in MediaUploader and Bluetooth file sender to + // share a general image data. So, we only share for local file. + return ContentResolver.SCHEME_FILE.equals(mUri.getScheme()); + } + + @Override + public int getMediaType() { + return MEDIA_TYPE_IMAGE; + } + + @Override + public Uri getContentUri() { + return mUri; + } + + @Override + public MediaDetails getDetails() { + MediaDetails details = super.getDetails(); + if (mWidth != 0 && mHeight != 0) { + details.addDetail(MediaDetails.INDEX_WIDTH, mWidth); + details.addDetail(MediaDetails.INDEX_HEIGHT, mHeight); + } + if (mContentType != null) { + details.addDetail(MediaDetails.INDEX_MIMETYPE, mContentType); + } + if (ContentResolver.SCHEME_FILE.equals(mUri.getScheme())) { + String filePath = mUri.getPath(); + details.addDetail(MediaDetails.INDEX_PATH, filePath); + MediaDetails.extractExifInfo(details, filePath); + } + return details; + } + + @Override + public String getMimeType() { + return mContentType; + } + + @Override + protected void finalize() throws Throwable { + try { + if (mFileDescriptor != null) { + Utils.closeSilently(mFileDescriptor); + } + } finally { + super.finalize(); + } + } + + @Override + public int getWidth() { + return 0; + } + + @Override + public int getHeight() { + return 0; + } + + @Override + public int getRotation() { + return mRotation; + } +} diff --git a/src/com/android/gallery3d/data/UriSource.java b/src/com/android/gallery3d/data/UriSource.java new file mode 100644 index 000000000..f66bacd7b --- /dev/null +++ b/src/com/android/gallery3d/data/UriSource.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2010 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.gallery3d.data; + +import android.content.ContentResolver; +import android.net.Uri; +import android.webkit.MimeTypeMap; + +import com.android.gallery3d.app.GalleryApp; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.net.URLEncoder; + +class UriSource extends MediaSource { + @SuppressWarnings("unused") + private static final String TAG = "UriSource"; + private static final String IMAGE_TYPE_PREFIX = "image/"; + private static final String IMAGE_TYPE_ANY = "image/*"; + private static final String CHARSET_UTF_8 = "utf-8"; + + private GalleryApp mApplication; + + public UriSource(GalleryApp context) { + super("uri"); + mApplication = context; + } + + @Override + public MediaObject createMediaObject(Path path) { + String segment[] = path.split(); + if (segment.length != 3) { + throw new RuntimeException("bad path: " + path); + } + try { + String uri = URLDecoder.decode(segment[1], CHARSET_UTF_8); + String type = URLDecoder.decode(segment[2], CHARSET_UTF_8); + return new UriImage(mApplication, path, Uri.parse(uri), type); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } + + private String getMimeType(Uri uri) { + if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) { + String extension = + MimeTypeMap.getFileExtensionFromUrl(uri.toString()); + String type = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(extension.toLowerCase()); + if (type != null) return type; + } + // Assume the type is image if the type cannot be resolved + // This could happen for "http" URI. + String type = mApplication.getContentResolver().getType(uri); + if (type == null) type = "image/*"; + return type; + } + + @Override + public Path findPathByUri(Uri uri, String type) { + String mimeType = getMimeType(uri); + + // Try to find a most specific type but it has to be started with "image/" + if ((type == null) || (IMAGE_TYPE_ANY.equals(type) + && mimeType.startsWith(IMAGE_TYPE_PREFIX))) { + type = mimeType; + } + + if (type.startsWith(IMAGE_TYPE_PREFIX)) { + try { + return Path.fromString("/uri/" + + URLEncoder.encode(uri.toString(), CHARSET_UTF_8) + + "/" +URLEncoder.encode(type, CHARSET_UTF_8)); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } + // We have no clues that it is an image + return null; + } +} |