/* * 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.app; import com.android.gallery3d.common.Utils; import com.android.gallery3d.data.ContentListener; import com.android.gallery3d.data.DataManager; import com.android.gallery3d.data.MediaItem; import com.android.gallery3d.data.MediaObject; import com.android.gallery3d.data.MediaSet; import com.android.gallery3d.data.Path; import com.android.gallery3d.ui.PhotoView; import com.android.gallery3d.ui.PhotoView.ImageData; import com.android.gallery3d.ui.SynchronizedHandler; import com.android.gallery3d.ui.TileImageViewAdapter; import com.android.gallery3d.util.Future; import com.android.gallery3d.util.FutureListener; import com.android.gallery3d.util.ThreadPool; import android.graphics.Bitmap; import android.graphics.BitmapRegionDecoder; import android.os.Handler; import android.os.Message; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; public class PhotoDataAdapter implements PhotoPage.Model { @SuppressWarnings("unused") private static final String TAG = "PhotoDataAdapter"; private static final int MSG_LOAD_START = 1; private static final int MSG_LOAD_FINISH = 2; private static final int MSG_RUN_OBJECT = 3; private static final int MIN_LOAD_COUNT = 8; private static final int DATA_CACHE_SIZE = 32; private static final int IMAGE_CACHE_SIZE = 5; private static final int BIT_SCREEN_NAIL = 1; private static final int BIT_FULL_IMAGE = 2; private static final long VERSION_OUT_OF_RANGE = MediaObject.nextVersionNumber(); // sImageFetchSeq is the fetching sequence for images. // We want to fetch the current screennail first (offset = 0), the next // screennail (offset = +1), then the previous screennail (offset = -1) etc. // After all the screennail are fetched, we fetch the full images (only some // of them because of we don't want to use too much memory). private static ImageFetch[] sImageFetchSeq; private static class ImageFetch { int indexOffset; int imageBit; public ImageFetch(int offset, int bit) { indexOffset = offset; imageBit = bit; } } static { int k = 0; sImageFetchSeq = new ImageFetch[1 + (IMAGE_CACHE_SIZE - 1) * 2 + 3]; sImageFetchSeq[k++] = new ImageFetch(0, BIT_SCREEN_NAIL); for (int i = 1; i < IMAGE_CACHE_SIZE; ++i) { sImageFetchSeq[k++] = new ImageFetch(i, BIT_SCREEN_NAIL); sImageFetchSeq[k++] = new ImageFetch(-i, BIT_SCREEN_NAIL); } sImageFetchSeq[k++] = new ImageFetch(0, BIT_FULL_IMAGE); sImageFetchSeq[k++] = new ImageFetch(1, BIT_FULL_IMAGE); sImageFetchSeq[k++] = new ImageFetch(-1, BIT_FULL_IMAGE); } private final TileImageViewAdapter mTileProvider = new TileImageViewAdapter(); // PhotoDataAdapter caches MediaItems (data) and ImageEntries (image). // // The MediaItems are stored in the mData array, which has DATA_CACHE_SIZE // entries. The valid index range are [mContentStart, mContentEnd). We keep // mContentEnd - mContentStart <= DATA_CACHE_SIZE, so we can use // (i % DATA_CACHE_SIZE) as index to the array. // // The valid MediaItem window size (mContentEnd - mContentStart) may be // smaller than DATA_CACHE_SIZE because we only update the window and reload // the MediaItems when there are significant changes to the window position // (>= MIN_LOAD_COUNT). private final MediaItem mData[] = new MediaItem[DATA_CACHE_SIZE]; private int mContentStart = 0; private int mContentEnd = 0; /* * The ImageCache is a version-to-ImageEntry map. It only holds * the ImageEntries in the range of [mActiveStart, mActiveEnd). * We also keep mActiveEnd - mActiveStart <= IMAGE_CACHE_SIZE. * Besides, the [mActiveStart, mActiveEnd) range must be contained * within the[mContentStart, mContentEnd) range. */ private HashMap mImageCache = new HashMap(); private int mActiveStart = 0; private int mActiveEnd = 0; // mCurrentIndex is the "center" image the user is viewing. The change of // mCurrentIndex triggers the data loading and image loading. private int mCurrentIndex; // mChanges keeps the version number (of MediaItem) about the previous, // current, and next image. If the version number changes, we invalidate // the model. This is used after a database reload or mCurrentIndex changes. private final long mChanges[] = new long[3]; private final Handler mMainHandler; private final ThreadPool mThreadPool; private final PhotoView mPhotoView; private final MediaSet mSource; private ReloadTask mReloadTask; private long mSourceVersion = MediaObject.INVALID_DATA_VERSION; private int mSize = 0; private Path mItemPath; private boolean mIsActive; public interface DataListener extends LoadingListener { public void onPhotoChanged(int index, Path item); } private DataListener mDataListener; private final SourceListener mSourceListener = new SourceListener(); // The path of the current viewing item will be stored in mItemPath. // If mItemPath is not null, mCurrentIndex is only a hint for where we // can find the item. If mItemPath is null, then we use the mCurrentIndex to // find the image being viewed. public PhotoDataAdapter(GalleryActivity activity, PhotoView view, MediaSet mediaSet, Path itemPath, int indexHint) { mSource = Utils.checkNotNull(mediaSet); mPhotoView = Utils.checkNotNull(view); mItemPath = Utils.checkNotNull(itemPath); mCurrentIndex = indexHint; mThreadPool = activity.getThreadPool(); Arrays.fill(mChanges, MediaObject.INVALID_DATA_VERSION); mMainHandler = new SynchronizedHandler(activity.getGLRoot()) { @SuppressWarnings("unchecked") @Override public void handleMessage(Message message) { switch (message.what) { case MSG_RUN_OBJECT: ((Runnable) message.obj).run(); return; case MSG_LOAD_START: { if (mDataListener != null) mDataListener.onLoadingStarted(); return; } case MSG_LOAD_FINISH: { if (mDataListener != null) mDataListener.onLoadingFinished(); return; } default: throw new AssertionError(); } } }; updateSlidingWindow(); } private long getVersion(int index) { if (index < 0 || index >= mSize) return VERSION_OUT_OF_RANGE; if (index >= mContentStart && index < mContentEnd) { MediaItem item = mData[index % DATA_CACHE_SIZE]; if (item != null) return item.getDataVersion(); } return MediaObject.INVALID_DATA_VERSION; } private void fireModelInvalidated() { for (int i = -1; i <= 1; ++i) { long current = getVersion(mCurrentIndex + i); long change = mChanges[i + 1]; if (current != change) { mPhotoView.notifyImageInvalidated(i); mChanges[i + 1] = current; } } } public void setDataListener(DataListener listener) { mDataListener = listener; } private void updateScreenNail(long version, Future future) { ImageEntry entry = mImageCache.get(version); if (entry == null || entry.screenNailTask == null) { Bitmap screenNail = future.get(); if (screenNail != null) screenNail.recycle(); return; } entry.screenNailTask = null; entry.screenNail = future.get(); if (entry.screenNail == null) { entry.failToLoad = true; } else { for (int i = -1; i <=1; ++i) { if (version == getVersion(mCurrentIndex + i)) { if (i == 0) updateTileProvider(entry); mPhotoView.notifyImageInvalidated(i); } } } updateImageRequests(); } private void updateFullImage(long version, Future future) { ImageEntry entry = mImageCache.get(version); if (entry == null || entry.fullImageTask == null) { BitmapRegionDecoder fullImage = future.get(); if (fullImage != null) fullImage.recycle(); return; } entry.fullImageTask = null; entry.fullImage = future.get(); if (entry.fullImage != null) { if (version == getVersion(mCurrentIndex)) { updateTileProvider(entry); mPhotoView.notifyImageInvalidated(0); } } updateImageRequests(); } public void resume() { mIsActive = true; mSource.addContentListener(mSourceListener); updateImageCache(); updateImageRequests(); mReloadTask = new ReloadTask(); mReloadTask.start(); mPhotoView.notifyModelInvalidated(); } public void pause() { mIsActive = false; mReloadTask.terminate(); mReloadTask = null; mSource.removeContentListener(mSourceListener); for (ImageEntry entry : mImageCache.values()) { if (entry.fullImageTask != null) entry.fullImageTask.cancel(); if (entry.screenNailTask != null) entry.screenNailTask.cancel(); } mImageCache.clear(); mTileProvider.clear(); } private ImageData getImage(int index) { if (index < 0 || index >= mSize || !mIsActive) return null; Utils.assertTrue(index >= mActiveStart && index < mActiveEnd); ImageEntry entry = mImageCache.get(getVersion(index)); Bitmap screennail = entry == null ? null : entry.screenNail; if (screennail != null) { return new ImageData(screennail, entry.rotation); } else { return new ImageData(null, 0); } } public ImageData getPreviousImage() { return getImage(mCurrentIndex - 1); } public ImageData getNextImage() { return getImage(mCurrentIndex + 1); } private void updateCurrentIndex(int index) { mCurrentIndex = index; updateSlidingWindow(); MediaItem item = mData[index % DATA_CACHE_SIZE]; mItemPath = item == null ? null : item.getPath(); updateImageCache(); updateImageRequests(); updateTileProvider(); mPhotoView.notifyOnNewImage(); if (mDataListener != null) { mDataListener.onPhotoChanged(index, mItemPath); } fireModelInvalidated(); } public void next() { updateCurrentIndex(mCurrentIndex + 1); } public void previous() { updateCurrentIndex(mCurrentIndex - 1); } public void jumpTo(int index) { if (mCurrentIndex == index) return; updateCurrentIndex(index); } public Bitmap getBackupImage() { return mTileProvider.getBackupImage(); } public int getImageHeight() { return mTileProvider.getImageHeight(); } public int getImageWidth() { return mTileProvider.getImageWidth(); } public int getImageRotation() { ImageEntry entry = mImageCache.get(getVersion(mCurrentIndex)); return entry == null ? 0 : entry.rotation; } public int getLevelCount() { return mTileProvider.getLevelCount(); } public Bitmap getTile(int level, int x, int y, int tileSize) { return mTileProvider.getTile(level, x, y, tileSize); } public boolean isFailedToLoad() { return mTileProvider.isFailedToLoad(); } public boolean isEmpty() { return mSize == 0; } public int getCurrentIndex() { return mCurrentIndex; } public MediaItem getCurrentMediaItem() { return mData[mCurrentIndex % DATA_CACHE_SIZE]; } public void setCurrentPhoto(Path path, int indexHint) { if (mItemPath == path) return; mItemPath = path; mCurrentIndex = indexHint; updateSlidingWindow(); updateImageCache(); fireModelInvalidated(); // We need to reload content if the path doesn't match. MediaItem item = getCurrentMediaItem(); if (item != null && item.getPath() != path) { if (mReloadTask != null) mReloadTask.notifyDirty(); } } private void updateTileProvider() { ImageEntry entry = mImageCache.get(getVersion(mCurrentIndex)); if (entry == null) { // in loading mTileProvider.clear(); } else { updateTileProvider(entry); } } private void updateTileProvider(ImageEntry entry) { Bitmap screenNail = entry.screenNail; BitmapRegionDecoder fullImage = entry.fullImage; if (screenNail != null) { if (fullImage != null) { mTileProvider.setBackupImage(screenNail, fullImage.getWidth(), fullImage.getHeight()); mTileProvider.setRegionDecoder(fullImage); } else { int width = screenNail.getWidth(); int height = screenNail.getHeight(); mTileProvider.setBackupImage(screenNail, width, height); } } else { mTileProvider.clear(); if (entry.failToLoad) mTileProvider.setFailedToLoad(); } } private void updateSlidingWindow() { // 1. Update the image window int start = Utils.clamp(mCurrentIndex - IMAGE_CACHE_SIZE / 2, 0, Math.max(0, mSize - IMAGE_CACHE_SIZE)); int end = Math.min(mSize, start + IMAGE_CACHE_SIZE); if (mActiveStart == start && mActiveEnd == end) return; mActiveStart = start; mActiveEnd = end; // 2. Update the data window start = Utils.clamp(mCurrentIndex - DATA_CACHE_SIZE / 2, 0, Math.max(0, mSize - DATA_CACHE_SIZE)); end = Math.min(mSize, start + DATA_CACHE_SIZE); if (mContentStart > mActiveStart || mContentEnd < mActiveEnd || Math.abs(start - mContentStart) > MIN_LOAD_COUNT) { for (int i = mContentStart; i < mContentEnd; ++i) { if (i < start || i >= end) { mData[i % DATA_CACHE_SIZE] = null; } } mContentStart = start; mContentEnd = end; if (mReloadTask != null) mReloadTask.notifyDirty(); } } private void updateImageRequests() { if (!mIsActive) return; int currentIndex = mCurrentIndex; MediaItem item = mData[currentIndex % DATA_CACHE_SIZE]; if (item == null || item.getPath() != mItemPath) { // current item mismatch - don't request image return; } // 1. Find the most wanted request and start it (if not already started). Future task = null; for (int i = 0; i < sImageFetchSeq.length; i++) { int offset = sImageFetchSeq[i].indexOffset; int bit = sImageFetchSeq[i].imageBit; task = startTaskIfNeeded(currentIndex + offset, bit); if (task != null) break; } // 2. Cancel everything else. for (ImageEntry entry : mImageCache.values()) { if (entry.screenNailTask != null && entry.screenNailTask != task) { entry.screenNailTask.cancel(); entry.screenNailTask = null; entry.requestedBits &= ~BIT_SCREEN_NAIL; } if (entry.fullImageTask != null && entry.fullImageTask != task) { entry.fullImageTask.cancel(); entry.fullImageTask = null; entry.requestedBits &= ~BIT_FULL_IMAGE; } } } // Returns the task if we started the task or the task is already started. private Future startTaskIfNeeded(int index, int which) { if (index < mActiveStart || index >= mActiveEnd) return null; ImageEntry entry = mImageCache.get(getVersion(index)); if (entry == null) return null; if (which == BIT_SCREEN_NAIL && entry.screenNailTask != null) { return entry.screenNailTask; } else if (which == BIT_FULL_IMAGE && entry.fullImageTask != null) { return entry.fullImageTask; } MediaItem item = mData[index % DATA_CACHE_SIZE]; Utils.assertTrue(item != null); if (which == BIT_SCREEN_NAIL && (entry.requestedBits & BIT_SCREEN_NAIL) == 0) { entry.requestedBits |= BIT_SCREEN_NAIL; entry.screenNailTask = mThreadPool.submit( item.requestImage(MediaItem.TYPE_THUMBNAIL), new ScreenNailListener(item.getDataVersion())); // request screen nail return entry.screenNailTask; } if (which == BIT_FULL_IMAGE && (entry.requestedBits & BIT_FULL_IMAGE) == 0 && (item.getSupportedOperations() & MediaItem.SUPPORT_FULL_IMAGE) != 0) { entry.requestedBits |= BIT_FULL_IMAGE; entry.fullImageTask = mThreadPool.submit( item.requestLargeImage(), new FullImageListener(item.getDataVersion())); // request full image return entry.fullImageTask; } return null; } private void updateImageCache() { HashSet toBeRemoved = new HashSet(mImageCache.keySet()); for (int i = mActiveStart; i < mActiveEnd; ++i) { MediaItem item = mData[i % DATA_CACHE_SIZE]; long version = item == null ? MediaObject.INVALID_DATA_VERSION : item.getDataVersion(); if (version == MediaObject.INVALID_DATA_VERSION) continue; ImageEntry entry = mImageCache.get(version); toBeRemoved.remove(version); if (entry != null) { if (Math.abs(i - mCurrentIndex) > 1) { if (entry.fullImageTask != null) { entry.fullImageTask.cancel(); entry.fullImageTask = null; } entry.fullImage = null; entry.requestedBits &= ~BIT_FULL_IMAGE; } } else { entry = new ImageEntry(); entry.rotation = item.getRotation(); mImageCache.put(version, entry); } } // Clear the data and requests for ImageEntries outside the new window. for (Long version : toBeRemoved) { ImageEntry entry = mImageCache.remove(version); if (entry.fullImageTask != null) entry.fullImageTask.cancel(); if (entry.screenNailTask != null) entry.screenNailTask.cancel(); } } private class FullImageListener implements Runnable, FutureListener { private final long mVersion; private Future mFuture; public FullImageListener(long version) { mVersion = version; } public void onFutureDone(Future future) { mFuture = future; mMainHandler.sendMessage( mMainHandler.obtainMessage(MSG_RUN_OBJECT, this)); } public void run() { updateFullImage(mVersion, mFuture); } } private class ScreenNailListener implements Runnable, FutureListener { private final long mVersion; private Future mFuture; public ScreenNailListener(long version) { mVersion = version; } public void onFutureDone(Future future) { mFuture = future; mMainHandler.sendMessage( mMainHandler.obtainMessage(MSG_RUN_OBJECT, this)); } public void run() { updateScreenNail(mVersion, mFuture); } } private static class ImageEntry { public int requestedBits = 0; public int rotation; public BitmapRegionDecoder fullImage; public Bitmap screenNail; public Future screenNailTask; public Future fullImageTask; public boolean failToLoad = false; } private class SourceListener implements ContentListener { public void onContentDirty() { if (mReloadTask != null) mReloadTask.notifyDirty(); } } private T executeAndWait(Callable callable) { FutureTask task = new FutureTask(callable); mMainHandler.sendMessage( mMainHandler.obtainMessage(MSG_RUN_OBJECT, task)); try { return task.get(); } catch (InterruptedException e) { return null; } catch (ExecutionException e) { throw new RuntimeException(e); } } private static class UpdateInfo { public long version; public boolean reloadContent; public Path target; public int indexHint; public int contentStart; public int contentEnd; public int size; public ArrayList items; } private class GetUpdateInfo implements Callable { private boolean needContentReload() { for (int i = mContentStart, n = mContentEnd; i < n; ++i) { if (mData[i % DATA_CACHE_SIZE] == null) return true; } MediaItem current = mData[mCurrentIndex % DATA_CACHE_SIZE]; return current == null || current.getPath() != mItemPath; } @Override public UpdateInfo call() throws Exception { UpdateInfo info = new UpdateInfo(); info.version = mSourceVersion; info.reloadContent = needContentReload(); info.target = mItemPath; info.indexHint = mCurrentIndex; info.contentStart = mContentStart; info.contentEnd = mContentEnd; info.size = mSize; return info; } } private class UpdateContent implements Callable { UpdateInfo mUpdateInfo; public UpdateContent(UpdateInfo updateInfo) { mUpdateInfo = updateInfo; } @Override public Void call() throws Exception { UpdateInfo info = mUpdateInfo; mSourceVersion = info.version; if (info.size != mSize) { mSize = info.size; if (mContentEnd > mSize) mContentEnd = mSize; if (mActiveEnd > mSize) mActiveEnd = mSize; } if (info.indexHint == MediaSet.INDEX_NOT_FOUND) { // The image has been deleted, clear mItemPath, the // mCurrentIndex will be updated in the updateCurrentItem(). mItemPath = null; updateCurrentItem(); } else { mCurrentIndex = info.indexHint; } updateSlidingWindow(); if (info.items != null) { int start = Math.max(info.contentStart, mContentStart); int end = Math.min(info.contentStart + info.items.size(), mContentEnd); int dataIndex = start % DATA_CACHE_SIZE; for (int i = start; i < end; ++i) { mData[dataIndex] = info.items.get(i - info.contentStart); if (++dataIndex == DATA_CACHE_SIZE) dataIndex = 0; } } if (mItemPath == null) { MediaItem current = mData[mCurrentIndex % DATA_CACHE_SIZE]; mItemPath = current == null ? null : current.getPath(); } updateImageCache(); updateTileProvider(); updateImageRequests(); fireModelInvalidated(); return null; } private void updateCurrentItem() { if (mSize == 0) return; if (mCurrentIndex >= mSize) { mCurrentIndex = mSize - 1; mPhotoView.notifyOnNewImage(); mPhotoView.startSlideInAnimation(PhotoView.TRANS_SLIDE_IN_LEFT); } else { mPhotoView.notifyOnNewImage(); mPhotoView.startSlideInAnimation(PhotoView.TRANS_SLIDE_IN_RIGHT); } } } private class ReloadTask extends Thread { private volatile boolean mActive = true; private volatile boolean mDirty = true; private boolean mIsLoading = false; private void updateLoading(boolean loading) { if (mIsLoading == loading) return; mIsLoading = loading; mMainHandler.sendEmptyMessage(loading ? MSG_LOAD_START : MSG_LOAD_FINISH); } @Override public void run() { while (mActive) { synchronized (this) { if (!mDirty && mActive) { updateLoading(false); Utils.waitWithoutInterrupt(this); continue; } } mDirty = false; UpdateInfo info = executeAndWait(new GetUpdateInfo()); synchronized (DataManager.LOCK) { updateLoading(true); long version = mSource.reload(); if (info.version != version) { info.reloadContent = true; info.size = mSource.getMediaItemCount(); } if (!info.reloadContent) continue; info.items = mSource.getMediaItem(info.contentStart, info.contentEnd); MediaItem item = findCurrentMediaItem(info); if (item == null || item.getPath() != info.target) { info.indexHint = findIndexOfTarget(info); } } executeAndWait(new UpdateContent(info)); } } public synchronized void notifyDirty() { mDirty = true; notifyAll(); } public synchronized void terminate() { mActive = false; notifyAll(); } private MediaItem findCurrentMediaItem(UpdateInfo info) { ArrayList items = info.items; int index = info.indexHint - info.contentStart; return index < 0 || index >= items.size() ? null : items.get(index); } private int findIndexOfTarget(UpdateInfo info) { if (info.target == null) return info.indexHint; ArrayList items = info.items; // First, try to find the item in the data just loaded if (items != null) { for (int i = 0, n = items.size(); i < n; ++i) { if (items.get(i).getPath() == info.target) return i + info.contentStart; } } // Not found, find it in mSource. return mSource.getIndexOfItem(info.target, info.indexHint); } } }