Initial code for Gallery2.

fix: 5176434

Change-Id: I041e282b9c7b34ceb1db8b033be2b853bb3a992c
diff --git a/src/com/android/gallery3d/anim/AlphaAnimation.java b/src/com/android/gallery3d/anim/AlphaAnimation.java
new file mode 100644
index 0000000..cb17527
--- /dev/null
+++ b/src/com/android/gallery3d/anim/AlphaAnimation.java
@@ -0,0 +1,48 @@
+/*
+ * 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.anim;
+
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.ui.GLCanvas;
+
+public class AlphaAnimation extends CanvasAnimation {
+    private final float mStartAlpha;
+    private final float mEndAlpha;
+    private float mCurrentAlpha;
+
+    public AlphaAnimation(float from, float to) {
+        mStartAlpha = from;
+        mEndAlpha = to;
+        mCurrentAlpha = from;
+    }
+
+    @Override
+    public void apply(GLCanvas canvas) {
+        canvas.multiplyAlpha(mCurrentAlpha);
+    }
+
+    @Override
+    public int getCanvasSaveFlags() {
+        return GLCanvas.SAVE_FLAG_ALPHA;
+    }
+
+    @Override
+    protected void onCalculate(float progress) {
+        mCurrentAlpha = Utils.clamp(mStartAlpha
+                + (mEndAlpha - mStartAlpha) * progress, 0f, 1f);
+    }
+}
diff --git a/src/com/android/gallery3d/anim/Animation.java b/src/com/android/gallery3d/anim/Animation.java
new file mode 100644
index 0000000..bd5a6cd
--- /dev/null
+++ b/src/com/android/gallery3d/anim/Animation.java
@@ -0,0 +1,92 @@
+/*
+ * 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.anim;
+
+import com.android.gallery3d.common.Utils;
+
+import android.view.animation.Interpolator;
+
+// Animation calculates a value according to the current input time.
+//
+// 1. First we need to use setDuration(int) to set the duration of the
+//    animation. The duration is in milliseconds.
+// 2. Then we should call start(). The actual start time is the first value
+//    passed to calculate(long).
+// 3. Each time we want to get an animation value, we call
+//    calculate(long currentTimeMillis) to ask the Animation to calculate it.
+//    The parameter passed to calculate(long) should be nonnegative.
+// 4. Use get() to get that value.
+//
+// In step 3, onCalculate(float progress) is called so subclasses can calculate
+// the value according to progress (progress is a value in [0,1]).
+//
+// Before onCalculate(float) is called, There is an optional interpolator which
+// can change the progress value. The interpolator can be set by
+// setInterpolator(Interpolator). If the interpolator is used, the value passed
+// to onCalculate may be (for example, the overshoot effect).
+//
+// The isActive() method returns true after the animation start() is called and
+// before calculate is passed a value which reaches the duration of the
+// animation.
+//
+// The start() method can be called again to restart the Animation.
+//
+abstract public class Animation {
+    private static final long ANIMATION_START = -1;
+    private static final long NO_ANIMATION = -2;
+
+    private long mStartTime = NO_ANIMATION;
+    private int mDuration;
+    private Interpolator mInterpolator;
+
+    public void setInterpolator(Interpolator interpolator) {
+        mInterpolator = interpolator;
+    }
+
+    public void setDuration(int duration) {
+        mDuration = duration;
+    }
+
+    public void start() {
+        mStartTime = ANIMATION_START;
+    }
+
+    public void setStartTime(long time) {
+        mStartTime = time;
+    }
+
+    public boolean isActive() {
+        return mStartTime != NO_ANIMATION;
+    }
+
+    public void forceStop() {
+        mStartTime = NO_ANIMATION;
+    }
+
+    public boolean calculate(long currentTimeMillis) {
+        if (mStartTime == NO_ANIMATION) return false;
+        if (mStartTime == ANIMATION_START) mStartTime = currentTimeMillis;
+        int elapse = (int) (currentTimeMillis - mStartTime);
+        float x = Utils.clamp((float) elapse / mDuration, 0f, 1f);
+        Interpolator i = mInterpolator;
+        onCalculate(i != null ? i.getInterpolation(x) : x);
+        if (elapse >= mDuration) mStartTime = NO_ANIMATION;
+        return mStartTime != NO_ANIMATION;
+    }
+
+    abstract protected void onCalculate(float progress);
+}
diff --git a/src/com/android/gallery3d/anim/AnimationSet.java b/src/com/android/gallery3d/anim/AnimationSet.java
new file mode 100644
index 0000000..773cb43
--- /dev/null
+++ b/src/com/android/gallery3d/anim/AnimationSet.java
@@ -0,0 +1,76 @@
+/*
+ * 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.anim;
+
+import com.android.gallery3d.ui.GLCanvas;
+
+import java.util.ArrayList;
+
+public class AnimationSet extends CanvasAnimation {
+
+    private final ArrayList<CanvasAnimation> mAnimations =
+            new ArrayList<CanvasAnimation>();
+    private int mSaveFlags = 0;
+
+
+    public void addAnimation(CanvasAnimation anim) {
+        mAnimations.add(anim);
+        mSaveFlags |= anim.getCanvasSaveFlags();
+    }
+
+    @Override
+    public void apply(GLCanvas canvas) {
+        for (int i = 0, n = mAnimations.size(); i < n; i++) {
+            mAnimations.get(i).apply(canvas);
+        }
+    }
+
+    @Override
+    public int getCanvasSaveFlags() {
+        return mSaveFlags;
+    }
+
+    @Override
+    protected void onCalculate(float progress) {
+        // DO NOTHING
+    }
+
+    @Override
+    public boolean calculate(long currentTimeMillis) {
+        boolean more = false;
+        for (CanvasAnimation anim : mAnimations) {
+            more |= anim.calculate(currentTimeMillis);
+        }
+        return more;
+    }
+
+    @Override
+    public void start() {
+        for (CanvasAnimation anim : mAnimations) {
+            anim.start();
+        }
+    }
+
+    @Override
+    public boolean isActive() {
+        for (CanvasAnimation anim : mAnimations) {
+            if (anim.isActive()) return true;
+        }
+        return false;
+    }
+
+}
diff --git a/src/com/android/gallery3d/anim/CanvasAnimation.java b/src/com/android/gallery3d/anim/CanvasAnimation.java
new file mode 100644
index 0000000..4c8bcc8
--- /dev/null
+++ b/src/com/android/gallery3d/anim/CanvasAnimation.java
@@ -0,0 +1,25 @@
+/*
+ * 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.anim;
+
+import com.android.gallery3d.ui.GLCanvas;
+
+public abstract class CanvasAnimation extends Animation {
+
+    public abstract int getCanvasSaveFlags();
+    public abstract void apply(GLCanvas canvas);
+}
diff --git a/src/com/android/gallery3d/anim/FloatAnimation.java b/src/com/android/gallery3d/anim/FloatAnimation.java
new file mode 100644
index 0000000..1294ec2
--- /dev/null
+++ b/src/com/android/gallery3d/anim/FloatAnimation.java
@@ -0,0 +1,40 @@
+/*
+ * 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.anim;
+
+public class FloatAnimation extends Animation {
+
+    private final float mFrom;
+    private final float mTo;
+    private float mCurrent;
+
+    public FloatAnimation(float from, float to, int duration) {
+        mFrom = from;
+        mTo = to;
+        mCurrent = from;
+        setDuration(duration);
+    }
+
+    @Override
+    protected void onCalculate(float progress) {
+        mCurrent = mFrom + (mTo - mFrom) * progress;
+    }
+
+    public float get() {
+        return mCurrent;
+    }
+}
diff --git a/src/com/android/gallery3d/app/AbstractGalleryActivity.java b/src/com/android/gallery3d/app/AbstractGalleryActivity.java
new file mode 100644
index 0000000..d0d7b0f
--- /dev/null
+++ b/src/com/android/gallery3d/app/AbstractGalleryActivity.java
@@ -0,0 +1,198 @@
+/*
+ * 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.R;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.ImageCacheService;
+import com.android.gallery3d.ui.GLRoot;
+import com.android.gallery3d.ui.GLRootView;
+import com.android.gallery3d.ui.PositionRepository;
+import com.android.gallery3d.util.ThreadPool;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnCancelListener;
+import android.content.DialogInterface.OnClickListener;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+
+public class AbstractGalleryActivity extends Activity implements GalleryActivity {
+    @SuppressWarnings("unused")
+    private static final String TAG = "AbstractGalleryActivity";
+    private GLRootView mGLRootView;
+    private StateManager mStateManager;
+    private PositionRepository mPositionRepository = new PositionRepository();
+
+    private AlertDialog mAlertDialog = null;
+    private BroadcastReceiver mMountReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (getExternalCacheDir() != null) onStorageReady();
+        }
+    };
+    private IntentFilter mMountFilter = new IntentFilter(Intent.ACTION_MEDIA_MOUNTED);
+
+    @Override
+    protected void onSaveInstanceState(Bundle outState) {
+        mGLRootView.lockRenderThread();
+        try {
+            super.onSaveInstanceState(outState);
+            getStateManager().saveState(outState);
+        } finally {
+            mGLRootView.unlockRenderThread();
+        }
+    }
+
+    public Context getAndroidContext() {
+        return this;
+    }
+
+    public ImageCacheService getImageCacheService() {
+        return ((GalleryApp) getApplication()).getImageCacheService();
+    }
+
+    public DataManager getDataManager() {
+        return ((GalleryApp) getApplication()).getDataManager();
+    }
+
+    public ThreadPool getThreadPool() {
+        return ((GalleryApp) getApplication()).getThreadPool();
+    }
+
+    public GalleryApp getGalleryApplication() {
+        return (GalleryApp) getApplication();
+    }
+
+    public synchronized StateManager getStateManager() {
+        if (mStateManager == null) {
+            mStateManager = new StateManager(this);
+        }
+        return mStateManager;
+    }
+
+    public GLRoot getGLRoot() {
+        return mGLRootView;
+    }
+
+    public PositionRepository getPositionRepository() {
+        return mPositionRepository;
+    }
+
+    @Override
+    public void setContentView(int resId) {
+        super.setContentView(resId);
+        mGLRootView = (GLRootView) findViewById(R.id.gl_root_view);
+    }
+
+    public int getActionBarHeight() {
+        ActionBar actionBar = getActionBar();
+        return actionBar != null ? actionBar.getHeight() : 0;
+    }
+
+    protected void onStorageReady() {
+        if (mAlertDialog != null) {
+            mAlertDialog.dismiss();
+            mAlertDialog = null;
+            unregisterReceiver(mMountReceiver);
+        }
+    }
+
+    @Override
+    protected void onStart() {
+        super.onStart();
+        if (getExternalCacheDir() == null) {
+            OnCancelListener onCancel = new OnCancelListener() {
+                @Override
+                public void onCancel(DialogInterface dialog) {
+                    finish();
+                }
+            };
+            OnClickListener onClick = new OnClickListener() {
+                @Override
+                public void onClick(DialogInterface dialog, int which) {
+                    dialog.cancel();
+                }
+            };
+            mAlertDialog = new AlertDialog.Builder(this)
+                    .setIcon(android.R.drawable.ic_dialog_alert)
+                    .setTitle("No Storage")
+                    .setMessage("No external storage available.")
+                    .setNegativeButton(android.R.string.cancel, onClick)
+                    .setOnCancelListener(onCancel)
+                    .show();
+            registerReceiver(mMountReceiver, mMountFilter);
+        }
+    }
+
+    @Override
+    protected void onStop() {
+        super.onStop();
+        if (mAlertDialog != null) {
+            unregisterReceiver(mMountReceiver);
+            mAlertDialog.dismiss();
+            mAlertDialog = null;
+        }
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        mGLRootView.lockRenderThread();
+        try {
+            getStateManager().resume();
+            getDataManager().resume();
+        } finally {
+            mGLRootView.unlockRenderThread();
+        }
+        mGLRootView.onResume();
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+        mGLRootView.onPause();
+        mGLRootView.lockRenderThread();
+        try {
+            getStateManager().pause();
+            getDataManager().pause();
+        } finally {
+            mGLRootView.unlockRenderThread();
+        }
+    }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+        mGLRootView.lockRenderThread();
+        try {
+            getStateManager().notifyActivityResult(
+                    requestCode, resultCode, data);
+        } finally {
+            mGLRootView.unlockRenderThread();
+        }
+    }
+
+    @Override
+    public GalleryActionBar getGalleryActionBar() {
+        return null;
+    }
+}
diff --git a/src/com/android/gallery3d/app/ActivityState.java b/src/com/android/gallery3d/app/ActivityState.java
new file mode 100644
index 0000000..bfacc54
--- /dev/null
+++ b/src/com/android/gallery3d/app/ActivityState.java
@@ -0,0 +1,136 @@
+/*
+ * 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.ui.GLView;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.WindowManager;
+
+abstract public class ActivityState {
+    public static final int FLAG_HIDE_ACTION_BAR = 1;
+    public static final int FLAG_HIDE_STATUS_BAR = 2;
+
+    protected GalleryActivity mActivity;
+    protected Bundle mData;
+    protected int mFlags;
+
+    protected ResultEntry mReceivedResults;
+    protected ResultEntry mResult;
+
+    protected static class ResultEntry {
+        public int requestCode;
+        public int resultCode = Activity.RESULT_CANCELED;
+        public Intent resultData;
+        ResultEntry next;
+    }
+
+    protected ActivityState() {
+    }
+
+    protected void setContentPane(GLView content) {
+        mActivity.getGLRoot().setContentPane(content);
+    }
+
+    void initialize(GalleryActivity activity, Bundle data) {
+        mActivity = activity;
+        mData = data;
+    }
+
+    public Bundle getData() {
+        return mData;
+    }
+
+    protected void onBackPressed() {
+        mActivity.getStateManager().finishState(this);
+    }
+
+    protected void setStateResult(int resultCode, Intent data) {
+        if (mResult == null) return;
+        mResult.resultCode = resultCode;
+        mResult.resultData = data;
+    }
+
+    protected void onSaveState(Bundle outState) {
+    }
+
+    protected void onStateResult(int requestCode, int resultCode, Intent data) {
+    }
+
+    protected void onCreate(Bundle data, Bundle storedState) {
+    }
+
+    protected void onPause() {
+    }
+
+    // should only be called by StateManager
+    void resume() {
+        Activity activity = (Activity) mActivity;
+        ActionBar actionBar = activity.getActionBar();
+        if (actionBar != null) {
+            if ((mFlags & FLAG_HIDE_ACTION_BAR) != 0) {
+                actionBar.hide();
+            } else {
+                actionBar.show();
+            }
+            int stateCount = mActivity.getStateManager().getStateCount();
+            actionBar.setDisplayOptions(
+                    stateCount == 1 ? 0 : ActionBar.DISPLAY_HOME_AS_UP,
+                    ActionBar.DISPLAY_HOME_AS_UP);
+        }
+
+        activity.invalidateOptionsMenu();
+
+        if ((mFlags & FLAG_HIDE_STATUS_BAR) != 0) {
+            WindowManager.LayoutParams params = ((Activity) mActivity).getWindow().getAttributes();
+            params.systemUiVisibility = View.STATUS_BAR_HIDDEN;
+            ((Activity) mActivity).getWindow().setAttributes(params);
+        } else {
+            WindowManager.LayoutParams params = ((Activity) mActivity).getWindow().getAttributes();
+            params.systemUiVisibility = View.STATUS_BAR_VISIBLE;
+            ((Activity) mActivity).getWindow().setAttributes(params);
+        }
+
+        ResultEntry entry = mReceivedResults;
+        if (entry != null) {
+            mReceivedResults = null;
+            onStateResult(entry.requestCode, entry.resultCode, entry.resultData);
+        }
+        onResume();
+    }
+
+    // a subclass of ActivityState should override the method to resume itself
+    protected void onResume() {
+    }
+
+    protected boolean onCreateActionBar(Menu menu) {
+        return false;
+    }
+
+    protected boolean onItemSelected(MenuItem item) {
+        return false;
+    }
+
+    protected void onDestroy() {
+    }
+}
diff --git a/src/com/android/gallery3d/app/AlbumDataAdapter.java b/src/com/android/gallery3d/app/AlbumDataAdapter.java
new file mode 100644
index 0000000..9934cf8
--- /dev/null
+++ b/src/com/android/gallery3d/app/AlbumDataAdapter.java
@@ -0,0 +1,367 @@
+/*
+ * 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.ui.AlbumView;
+import com.android.gallery3d.ui.SynchronizedHandler;
+
+import android.os.Handler;
+import android.os.Message;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+
+public class AlbumDataAdapter implements AlbumView.Model {
+    @SuppressWarnings("unused")
+    private static final String TAG = "AlbumDataAdapter";
+    private static final int DATA_CACHE_SIZE = 1000;
+
+    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 = 32;
+    private static final int MAX_LOAD_COUNT = 64;
+
+    private final MediaItem[] mData;
+    private final long[] mItemVersion;
+    private final long[] mSetVersion;
+
+    private int mActiveStart = 0;
+    private int mActiveEnd = 0;
+
+    private int mContentStart = 0;
+    private int mContentEnd = 0;
+
+    private final MediaSet mSource;
+    private long mSourceVersion = MediaObject.INVALID_DATA_VERSION;
+
+    private final Handler mMainHandler;
+    private int mSize = 0;
+
+    private AlbumView.ModelListener mModelListener;
+    private MySourceListener mSourceListener = new MySourceListener();
+    private LoadingListener mLoadingListener;
+
+    private ReloadTask mReloadTask;
+
+    public AlbumDataAdapter(GalleryActivity context, MediaSet mediaSet) {
+        mSource = mediaSet;
+
+        mData = new MediaItem[DATA_CACHE_SIZE];
+        mItemVersion = new long[DATA_CACHE_SIZE];
+        mSetVersion = new long[DATA_CACHE_SIZE];
+        Arrays.fill(mItemVersion, MediaObject.INVALID_DATA_VERSION);
+        Arrays.fill(mSetVersion, MediaObject.INVALID_DATA_VERSION);
+
+        mMainHandler = new SynchronizedHandler(context.getGLRoot()) {
+            @Override
+            public void handleMessage(Message message) {
+                switch (message.what) {
+                    case MSG_RUN_OBJECT:
+                        ((Runnable) message.obj).run();
+                        return;
+                    case MSG_LOAD_START:
+                        if (mLoadingListener != null) mLoadingListener.onLoadingStarted();
+                        return;
+                    case MSG_LOAD_FINISH:
+                        if (mLoadingListener != null) mLoadingListener.onLoadingFinished();
+                        return;
+                }
+            }
+        };
+    }
+
+    public void resume() {
+        mSource.addContentListener(mSourceListener);
+        mReloadTask = new ReloadTask();
+        mReloadTask.start();
+    }
+
+    public void pause() {
+        mReloadTask.terminate();
+        mReloadTask = null;
+        mSource.removeContentListener(mSourceListener);
+    }
+
+    public MediaItem get(int index) {
+        if (!isActive(index)) {
+            throw new IllegalArgumentException(String.format(
+                    "%s not in (%s, %s)", index, mActiveStart, mActiveEnd));
+        }
+        return mData[index % mData.length];
+    }
+
+    public int getActiveStart() {
+        return mActiveStart;
+    }
+
+    public int getActiveEnd() {
+        return mActiveEnd;
+    }
+
+    public boolean isActive(int index) {
+        return index >= mActiveStart && index < mActiveEnd;
+    }
+
+    public int size() {
+        return mSize;
+    }
+
+    private void clearSlot(int slotIndex) {
+        mData[slotIndex] = null;
+        mItemVersion[slotIndex] = MediaObject.INVALID_DATA_VERSION;
+        mSetVersion[slotIndex] = MediaObject.INVALID_DATA_VERSION;
+    }
+
+    private void setContentWindow(int contentStart, int contentEnd) {
+        if (contentStart == mContentStart && contentEnd == mContentEnd) return;
+        int end = mContentEnd;
+        int start = mContentStart;
+
+        // We need change the content window before calling reloadData(...)
+        synchronized (this) {
+            mContentStart = contentStart;
+            mContentEnd = contentEnd;
+        }
+        MediaItem[] data = mData;
+        long[] itemVersion = mItemVersion;
+        long[] setVersion = mSetVersion;
+        if (contentStart >= end || start >= contentEnd) {
+            for (int i = start, n = end; i < n; ++i) {
+                clearSlot(i % DATA_CACHE_SIZE);
+            }
+        } else {
+            for (int i = start; i < contentStart; ++i) {
+                clearSlot(i % DATA_CACHE_SIZE);
+            }
+            for (int i = contentEnd, n = end; i < n; ++i) {
+                clearSlot(i % DATA_CACHE_SIZE);
+            }
+        }
+        if (mReloadTask != null) mReloadTask.notifyDirty();
+    }
+
+    public void setActiveWindow(int start, int end) {
+        if (start == mActiveStart && end == mActiveEnd) return;
+
+        mActiveStart = start;
+        mActiveEnd = end;
+
+        Utils.assertTrue(start <= end
+                && end - start <= mData.length && end <= mSize);
+
+        int length = mData.length;
+        mActiveStart = start;
+        mActiveEnd = end;
+
+        // If no data is visible, keep the cache content
+        if (start == end) return;
+
+        int contentStart = Utils.clamp((start + end) / 2 - length / 2,
+                0, Math.max(0, mSize - length));
+        int contentEnd = Math.min(contentStart + length, mSize);
+        if (mContentStart > start || mContentEnd < end
+                || Math.abs(contentStart - mContentStart) > MIN_LOAD_COUNT) {
+            setContentWindow(contentStart, contentEnd);
+        }
+    }
+
+    private class MySourceListener implements ContentListener {
+        public void onContentDirty() {
+            if (mReloadTask != null) mReloadTask.notifyDirty();
+        }
+    }
+
+    public void setModelListener(AlbumView.ModelListener listener) {
+        mModelListener = listener;
+    }
+
+    public void setLoadingListener(LoadingListener listener) {
+        mLoadingListener = listener;
+    }
+
+    private <T> T executeAndWait(Callable<T> callable) {
+        FutureTask<T> task = new FutureTask<T>(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 int reloadStart;
+        public int reloadCount;
+
+        public int size;
+        public ArrayList<MediaItem> items;
+    }
+
+    private class GetUpdateInfo implements Callable<UpdateInfo> {
+        private final long mVersion;
+
+        public GetUpdateInfo(long version) {
+            mVersion = version;
+        }
+
+        public UpdateInfo call() throws Exception {
+            UpdateInfo info = new UpdateInfo();
+            long version = mVersion;
+            info.version = mSourceVersion;
+            info.size = mSize;
+            long setVersion[] = mSetVersion;
+            for (int i = mContentStart, n = mContentEnd; i < n; ++i) {
+                int index = i % DATA_CACHE_SIZE;
+                if (setVersion[index] != version) {
+                    info.reloadStart = i;
+                    info.reloadCount = Math.min(MAX_LOAD_COUNT, n - i);
+                    return info;
+                }
+            }
+            return mSourceVersion == mVersion ? null : info;
+        }
+    }
+
+    private class UpdateContent implements Callable<Void> {
+
+        private UpdateInfo mUpdateInfo;
+
+        public UpdateContent(UpdateInfo info) {
+            mUpdateInfo = info;
+        }
+
+        @Override
+        public Void call() throws Exception {
+            UpdateInfo info = mUpdateInfo;
+            mSourceVersion = info.version;
+            if (mSize != info.size) {
+                mSize = info.size;
+                if (mModelListener != null) mModelListener.onSizeChanged(mSize);
+                if (mContentEnd > mSize) mContentEnd = mSize;
+                if (mActiveEnd > mSize) mActiveEnd = mSize;
+            }
+
+            ArrayList<MediaItem> items = info.items;
+
+            if (items == null) return null;
+            int start = Math.max(info.reloadStart, mContentStart);
+            int end = Math.min(info.reloadStart + items.size(), mContentEnd);
+
+            for (int i = start; i < end; ++i) {
+                int index = i % DATA_CACHE_SIZE;
+                mSetVersion[index] = info.version;
+                MediaItem updateItem = items.get(i - info.reloadStart);
+                long itemVersion = updateItem.getDataVersion();
+                if (mItemVersion[index] != itemVersion) {
+                    mItemVersion[index] = itemVersion;
+                    mData[index] = updateItem;
+                    if (mModelListener != null && i >= mActiveStart && i < mActiveEnd) {
+                        mModelListener.onWindowContentChanged(i);
+                    }
+                }
+            }
+            return null;
+        }
+    }
+
+    /*
+     * The thread model of ReloadTask
+     *      *
+     * [Reload Task]       [Main Thread]
+     *       |                   |
+     * getUpdateInfo() -->       |           (synchronous call)
+     *     (wait) <----    getUpdateInfo()
+     *       |                   |
+     *   Load Data               |
+     *       |                   |
+     * updateContent() -->       |           (synchronous call)
+     *     (wait)          updateContent()
+     *       |                   |
+     *       |                   |
+     */
+    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() {
+            boolean updateComplete = false;
+            while (mActive) {
+                synchronized (this) {
+                    if (mActive && !mDirty && updateComplete) {
+                        updateLoading(false);
+                        Utils.waitWithoutInterrupt(this);
+                        continue;
+                    }
+                }
+                mDirty = false;
+                updateLoading(true);
+                long version;
+                synchronized (DataManager.LOCK) {
+                    version = mSource.reload();
+                }
+                UpdateInfo info = executeAndWait(new GetUpdateInfo(version));
+                updateComplete = info == null;
+                if (updateComplete) continue;
+                synchronized (DataManager.LOCK) {
+                    if (info.version != version) {
+                        info.size = mSource.getMediaItemCount();
+                        info.version = version;
+                    }
+                    if (info.reloadCount > 0) {
+                        info.items = mSource.getMediaItem(info.reloadStart, info.reloadCount);
+                    }
+                }
+                executeAndWait(new UpdateContent(info));
+            }
+            updateLoading(false);
+        }
+
+        public synchronized void notifyDirty() {
+            mDirty = true;
+            notifyAll();
+        }
+
+        public synchronized void terminate() {
+            mActive = false;
+            notifyAll();
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/app/AlbumPage.java b/src/com/android/gallery3d/app/AlbumPage.java
new file mode 100644
index 0000000..5c09ce2
--- /dev/null
+++ b/src/com/android/gallery3d/app/AlbumPage.java
@@ -0,0 +1,602 @@
+/*
+ * 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.R;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.MediaDetails;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.data.MediaSet;
+import com.android.gallery3d.data.MtpDevice;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.ui.ActionModeHandler;
+import com.android.gallery3d.ui.ActionModeHandler.ActionModeListener;
+import com.android.gallery3d.ui.AlbumView;
+import com.android.gallery3d.ui.DetailsWindow;
+import com.android.gallery3d.ui.DetailsWindow.CloseListener;
+import com.android.gallery3d.ui.GLCanvas;
+import com.android.gallery3d.ui.GLView;
+import com.android.gallery3d.ui.GridDrawer;
+import com.android.gallery3d.ui.HighlightDrawer;
+import com.android.gallery3d.ui.PositionProvider;
+import com.android.gallery3d.ui.PositionRepository;
+import com.android.gallery3d.ui.PositionRepository.Position;
+import com.android.gallery3d.ui.SelectionManager;
+import com.android.gallery3d.ui.SlotView;
+import com.android.gallery3d.ui.StaticBackground;
+import com.android.gallery3d.util.Future;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.app.Activity;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.MediaStore;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View.MeasureSpec;
+import android.widget.Toast;
+
+import java.util.Random;
+
+public class AlbumPage extends ActivityState implements GalleryActionBar.ClusterRunner,
+        SelectionManager.SelectionListener {
+    @SuppressWarnings("unused")
+    private static final String TAG = "AlbumPage";
+
+    public static final String KEY_MEDIA_PATH = "media-path";
+    public static final String KEY_SET_CENTER = "set-center";
+    public static final String KEY_AUTO_SELECT_ALL = "auto-select-all";
+    public static final String KEY_SHOW_CLUSTER_MENU = "cluster-menu";
+
+    private static final int REQUEST_SLIDESHOW = 1;
+    private static final int REQUEST_PHOTO = 2;
+    private static final int REQUEST_DO_ANIMATION = 3;
+
+    private static final float USER_DISTANCE_METER = 0.3f;
+
+    private boolean mIsActive = false;
+    private StaticBackground mStaticBackground;
+    private AlbumView mAlbumView;
+    private Path mMediaSetPath;
+
+    private AlbumDataAdapter mAlbumDataAdapter;
+
+    protected SelectionManager mSelectionManager;
+    private GridDrawer mGridDrawer;
+    private HighlightDrawer mHighlightDrawer;
+
+    private boolean mGetContent;
+    private boolean mShowClusterMenu;
+
+    private ActionMode mActionMode;
+    private ActionModeHandler mActionModeHandler;
+    private int mFocusIndex = 0;
+    private DetailsWindow mDetailsWindow;
+    private MediaSet mMediaSet;
+    private boolean mShowDetails;
+    private float mUserDistance; // in pixel
+
+    private ProgressDialog mProgressDialog;
+    private Future<?> mPendingTask;
+
+    private Future<Void> mSyncTask = null;
+
+    private GLView mRootPane = new GLView() {
+        private float mMatrix[] = new float[16];
+
+        @Override
+        protected void onLayout(
+                boolean changed, int left, int top, int right, int bottom) {
+            mStaticBackground.layout(0, 0, right - left, bottom - top);
+
+            int slotViewTop = GalleryActionBar.getHeight((Activity) mActivity);
+            int slotViewBottom = bottom - top;
+            int slotViewRight = right - left;
+
+            if (mShowDetails) {
+                mDetailsWindow.measure(
+                        MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+                int width = mDetailsWindow.getMeasuredWidth();
+                int detailLeft = right - left - width;
+                slotViewRight = detailLeft;
+                mDetailsWindow.layout(detailLeft, slotViewTop, detailLeft + width,
+                        bottom - top);
+            } else {
+                mAlbumView.setSelectionDrawer(mGridDrawer);
+            }
+
+            mAlbumView.layout(0, slotViewTop, slotViewRight, slotViewBottom);
+            GalleryUtils.setViewPointMatrix(mMatrix,
+                    (right - left) / 2, (bottom - top) / 2, -mUserDistance);
+            PositionRepository.getInstance(mActivity).setOffset(
+                    0, slotViewTop);
+        }
+
+        @Override
+        protected void render(GLCanvas canvas) {
+            canvas.save(GLCanvas.SAVE_FLAG_MATRIX);
+            canvas.multiplyMatrix(mMatrix, 0);
+            super.render(canvas);
+            canvas.restore();
+        }
+    };
+
+    @Override
+    protected void onBackPressed() {
+        if (mShowDetails) {
+            hideDetails();
+        } else if (mSelectionManager.inSelectionMode()) {
+            mSelectionManager.leaveSelectionMode();
+        } else {
+            mAlbumView.savePositions(PositionRepository.getInstance(mActivity));
+            super.onBackPressed();
+        }
+    }
+
+    public void onSingleTapUp(int slotIndex) {
+        MediaItem item = mAlbumDataAdapter.get(slotIndex);
+        if (item == null) {
+            Log.w(TAG, "item not ready yet, ignore the click");
+            return;
+        }
+        if (mShowDetails) {
+            mHighlightDrawer.setHighlightItem(item.getPath());
+            mDetailsWindow.reloadDetails(slotIndex);
+        } else if (!mSelectionManager.inSelectionMode()) {
+            if (mGetContent) {
+                onGetContent(item);
+            } else {
+                boolean playVideo =
+                    (item.getSupportedOperations() & MediaItem.SUPPORT_PLAY) != 0;
+                if (playVideo) {
+                    // Play the video.
+                    PhotoPage.playVideo((Activity) mActivity, item.getPlayUri(), item.getName());
+                } else {
+                    // Get into the PhotoPage.
+                    Bundle data = new Bundle();
+                    mAlbumView.savePositions(PositionRepository.getInstance(mActivity));
+                    data.putInt(PhotoPage.KEY_INDEX_HINT, slotIndex);
+                    data.putString(PhotoPage.KEY_MEDIA_SET_PATH,
+                            mMediaSetPath.toString());
+                    data.putString(PhotoPage.KEY_MEDIA_ITEM_PATH,
+                            item.getPath().toString());
+                    mActivity.getStateManager().startStateForResult(
+                            PhotoPage.class, REQUEST_PHOTO, data);
+                }
+            }
+        } else {
+            mSelectionManager.toggle(item.getPath());
+            mAlbumView.invalidate();
+        }
+    }
+
+    private void onGetContent(final MediaItem item) {
+        DataManager dm = mActivity.getDataManager();
+        Activity activity = (Activity) mActivity;
+        if (mData.getString(Gallery.EXTRA_CROP) != null) {
+            // TODO: Handle MtpImagew
+            Uri uri = dm.getContentUri(item.getPath());
+            Intent intent = new Intent(CropImage.ACTION_CROP, uri)
+                    .addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT)
+                    .putExtras(getData());
+            if (mData.getParcelable(MediaStore.EXTRA_OUTPUT) == null) {
+                intent.putExtra(CropImage.KEY_RETURN_DATA, true);
+            }
+            activity.startActivity(intent);
+            activity.finish();
+        } else {
+            activity.setResult(Activity.RESULT_OK,
+                    new Intent(null, item.getContentUri()));
+            activity.finish();
+        }
+    }
+
+    public void onLongTap(int slotIndex) {
+        if (mGetContent) return;
+        if (mShowDetails) {
+            onSingleTapUp(slotIndex);
+        } else {
+            MediaItem item = mAlbumDataAdapter.get(slotIndex);
+            if (item == null) return;
+            mSelectionManager.setAutoLeaveSelectionMode(true);
+            mSelectionManager.toggle(item.getPath());
+            mAlbumView.invalidate();
+        }
+    }
+
+    public void doCluster(int clusterType) {
+        String basePath = mMediaSet.getPath().toString();
+        String newPath = FilterUtils.newClusterPath(basePath, clusterType);
+        Bundle data = new Bundle(getData());
+        data.putString(AlbumSetPage.KEY_MEDIA_PATH, newPath);
+        if (mShowClusterMenu) {
+            Context context = mActivity.getAndroidContext();
+            data.putString(AlbumSetPage.KEY_SET_TITLE, mMediaSet.getName());
+            data.putString(AlbumSetPage.KEY_SET_SUBTITLE,
+                    GalleryActionBar.getClusterByTypeString(context, clusterType));
+        }
+
+        mAlbumView.savePositions(PositionRepository.getInstance(mActivity));
+        mActivity.getStateManager().startStateForResult(
+                AlbumSetPage.class, REQUEST_DO_ANIMATION, data);
+    }
+
+    public void doFilter(int filterType) {
+        String basePath = mMediaSet.getPath().toString();
+        String newPath = FilterUtils.switchFilterPath(basePath, filterType);
+        Bundle data = new Bundle(getData());
+        data.putString(AlbumPage.KEY_MEDIA_PATH, newPath);
+        mAlbumView.savePositions(PositionRepository.getInstance(mActivity));
+        mActivity.getStateManager().switchState(this, AlbumPage.class, data);
+    }
+
+    public void onOperationComplete() {
+        mAlbumView.invalidate();
+        // TODO: enable animation
+    }
+
+    @Override
+    protected void onCreate(Bundle data, Bundle restoreState) {
+        mUserDistance = GalleryUtils.meterToPixel(USER_DISTANCE_METER);
+        initializeViews();
+        initializeData(data);
+        mGetContent = data.getBoolean(Gallery.KEY_GET_CONTENT, false);
+        mShowClusterMenu = data.getBoolean(KEY_SHOW_CLUSTER_MENU, false);
+
+        startTransition(data);
+
+        // Enable auto-select-all for mtp album
+        if (data.getBoolean(KEY_AUTO_SELECT_ALL)) {
+            mSelectionManager.selectAll();
+        }
+    }
+
+    private void startTransition() {
+        final PositionRepository repository =
+                PositionRepository.getInstance(mActivity);
+        mAlbumView.startTransition(new PositionProvider() {
+            private Position mTempPosition = new Position();
+            public Position getPosition(long identity, Position target) {
+                Position p = repository.get(identity);
+                if (p != null) return p;
+                mTempPosition.set(target);
+                mTempPosition.z = 128;
+                return mTempPosition;
+            }
+        });
+    }
+
+    private void startTransition(Bundle data) {
+        final PositionRepository repository =
+                PositionRepository.getInstance(mActivity);
+        final int[] center = data == null
+                ? null
+                : data.getIntArray(KEY_SET_CENTER);
+        final Random random = new Random();
+        mAlbumView.startTransition(new PositionProvider() {
+            private Position mTempPosition = new Position();
+            public Position getPosition(long identity, Position target) {
+                Position p = repository.get(identity);
+                if (p != null) return p;
+                if (center != null) {
+                    random.setSeed(identity);
+                    mTempPosition.set(center[0], center[1],
+                            0, random.nextInt(60) - 30, 0);
+                } else {
+                    mTempPosition.set(target);
+                    mTempPosition.z = 128;
+                }
+                return mTempPosition;
+            }
+        });
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        mIsActive = true;
+        setContentPane(mRootPane);
+        mAlbumDataAdapter.resume();
+        mAlbumView.resume();
+        mActionModeHandler.resume();
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+        mIsActive = false;
+        mAlbumDataAdapter.pause();
+        mAlbumView.pause();
+        if (mDetailsWindow != null) {
+            mDetailsWindow.pause();
+        }
+        Future<?> task = mPendingTask;
+        if (task != null) {
+            // cancel on going task
+            task.cancel();
+            task.waitDone();
+            if (mProgressDialog != null) {
+                mProgressDialog.dismiss();
+                mProgressDialog = null;
+            }
+        }
+        if (mSyncTask != null) {
+            mSyncTask.cancel();
+            mSyncTask = null;
+        }
+        mActionModeHandler.pause();
+    }
+
+    @Override
+    protected void onDestroy() {
+        if (mAlbumDataAdapter != null) {
+            mAlbumDataAdapter.setLoadingListener(null);
+        }
+    }
+
+    private void initializeViews() {
+        mStaticBackground = new StaticBackground((Context) mActivity);
+        mRootPane.addComponent(mStaticBackground);
+
+        mSelectionManager = new SelectionManager(mActivity, false);
+        mSelectionManager.setSelectionListener(this);
+        mGridDrawer = new GridDrawer((Context) mActivity, mSelectionManager);
+        Config.AlbumPage config = Config.AlbumPage.get((Context) mActivity);
+        mAlbumView = new AlbumView(mActivity,
+                config.slotWidth, config.slotHeight, config.displayItemSize);
+        mAlbumView.setSelectionDrawer(mGridDrawer);
+        mRootPane.addComponent(mAlbumView);
+        mAlbumView.setListener(new SlotView.SimpleListener() {
+            @Override
+            public void onSingleTapUp(int slotIndex) {
+                AlbumPage.this.onSingleTapUp(slotIndex);
+            }
+            @Override
+            public void onLongTap(int slotIndex) {
+                AlbumPage.this.onLongTap(slotIndex);
+            }
+        });
+        mActionModeHandler = new ActionModeHandler(mActivity, mSelectionManager);
+        mActionModeHandler.setActionModeListener(new ActionModeListener() {
+            public boolean onActionItemClicked(MenuItem item) {
+                return onItemSelected(item);
+            }
+        });
+        mStaticBackground.setImage(R.drawable.background,
+                R.drawable.background_portrait);
+    }
+
+    private void initializeData(Bundle data) {
+        mMediaSetPath = Path.fromString(data.getString(KEY_MEDIA_PATH));
+        mMediaSet = mActivity.getDataManager().getMediaSet(mMediaSetPath);
+        Utils.assertTrue(mMediaSet != null,
+                "MediaSet is null. Path = %s", mMediaSetPath);
+        mSelectionManager.setSourceMediaSet(mMediaSet);
+        mAlbumDataAdapter = new AlbumDataAdapter(mActivity, mMediaSet);
+        mAlbumDataAdapter.setLoadingListener(new MyLoadingListener());
+        mAlbumView.setModel(mAlbumDataAdapter);
+    }
+
+    private void showDetails() {
+        mShowDetails = true;
+        if (mDetailsWindow == null) {
+            mHighlightDrawer = new HighlightDrawer(mActivity.getAndroidContext());
+            mDetailsWindow = new DetailsWindow(mActivity, new MyDetailsSource());
+            mDetailsWindow.setCloseListener(new CloseListener() {
+                public void onClose() {
+                    hideDetails();
+                }
+            });
+            mRootPane.addComponent(mDetailsWindow);
+        }
+        mAlbumView.setSelectionDrawer(mHighlightDrawer);
+        mDetailsWindow.show();
+    }
+
+    private void hideDetails() {
+        mShowDetails = false;
+        mAlbumView.setSelectionDrawer(mGridDrawer);
+        mDetailsWindow.hide();
+    }
+
+    @Override
+    protected boolean onCreateActionBar(Menu menu) {
+        Activity activity = (Activity) mActivity;
+        GalleryActionBar actionBar = mActivity.getGalleryActionBar();
+        MenuInflater inflater = activity.getMenuInflater();
+
+        if (mGetContent) {
+            inflater.inflate(R.menu.pickup, menu);
+            int typeBits = mData.getInt(Gallery.KEY_TYPE_BITS,
+                    DataManager.INCLUDE_IMAGE);
+
+            actionBar.setTitle(GalleryUtils.getSelectionModePrompt(typeBits));
+        } else {
+            inflater.inflate(R.menu.album, menu);
+            actionBar.setTitle(mMediaSet.getName());
+            if (mMediaSet instanceof MtpDevice) {
+                menu.findItem(R.id.action_slideshow).setVisible(false);
+            } else {
+                menu.findItem(R.id.action_slideshow).setVisible(true);
+            }
+
+            MenuItem groupBy = menu.findItem(R.id.action_group_by);
+            FilterUtils.setupMenuItems(actionBar, mMediaSetPath, true);
+
+            if (groupBy != null) {
+                groupBy.setVisible(mShowClusterMenu);
+            }
+
+            actionBar.setTitle(mMediaSet.getName());
+        }
+        actionBar.setSubtitle(null);
+
+        return true;
+    }
+
+    @Override
+    protected boolean onItemSelected(MenuItem item) {
+        switch (item.getItemId()) {
+            case R.id.action_select:
+                mSelectionManager.setAutoLeaveSelectionMode(false);
+                mSelectionManager.enterSelectionMode();
+                return true;
+            case R.id.action_group_by: {
+                mActivity.getGalleryActionBar().showClusterDialog(this);
+                return true;
+            }
+            case R.id.action_slideshow: {
+                Bundle data = new Bundle();
+                data.putString(SlideshowPage.KEY_SET_PATH,
+                        mMediaSetPath.toString());
+                data.putBoolean(SlideshowPage.KEY_REPEAT, true);
+                mActivity.getStateManager().startStateForResult(
+                        SlideshowPage.class, REQUEST_SLIDESHOW, data);
+                return true;
+            }
+            case R.id.action_details: {
+                if (mShowDetails) {
+                    hideDetails();
+                } else {
+                    showDetails();
+                }
+                return true;
+            }
+            default:
+                return false;
+        }
+    }
+
+    @Override
+    protected void onStateResult(int request, int result, Intent data) {
+        switch (request) {
+            case REQUEST_SLIDESHOW: {
+                // data could be null, if there is no images in the album
+                if (data == null) return;
+                mFocusIndex = data.getIntExtra(SlideshowPage.KEY_PHOTO_INDEX, 0);
+                mAlbumView.setCenterIndex(mFocusIndex);
+                break;
+            }
+            case REQUEST_PHOTO: {
+                if (data == null) return;
+                mFocusIndex = data.getIntExtra(PhotoPage.KEY_INDEX_HINT, 0);
+                mAlbumView.setCenterIndex(mFocusIndex);
+                startTransition();
+                break;
+            }
+            case REQUEST_DO_ANIMATION: {
+                startTransition(null);
+                break;
+            }
+        }
+    }
+
+    public void onSelectionModeChange(int mode) {
+        switch (mode) {
+            case SelectionManager.ENTER_SELECTION_MODE: {
+                mActionMode = mActionModeHandler.startActionMode();
+                break;
+            }
+            case SelectionManager.LEAVE_SELECTION_MODE: {
+                mActionMode.finish();
+                mRootPane.invalidate();
+                break;
+            }
+            case SelectionManager.SELECT_ALL_MODE: {
+                int count = mSelectionManager.getSelectedCount();
+                String format = mActivity.getResources().getQuantityString(
+                        R.plurals.number_of_items_selected, count);
+                mActionModeHandler.setTitle(String.format(format, count));
+                mActionModeHandler.updateSupportedOperation();
+                mRootPane.invalidate();
+                break;
+            }
+        }
+    }
+
+    public void onSelectionChange(Path path, boolean selected) {
+        Utils.assertTrue(mActionMode != null);
+        int count = mSelectionManager.getSelectedCount();
+        String format = mActivity.getResources().getQuantityString(
+                R.plurals.number_of_items_selected, count);
+        mActionModeHandler.setTitle(String.format(format, count));
+        mActionModeHandler.updateSupportedOperation(path, selected);
+    }
+
+    private class MyLoadingListener implements LoadingListener {
+        @Override
+        public void onLoadingStarted() {
+            GalleryUtils.setSpinnerVisibility((Activity) mActivity, true);
+        }
+
+        @Override
+        public void onLoadingFinished() {
+            if (!mIsActive) return;
+            if (mAlbumDataAdapter.size() == 0) {
+                if (mSyncTask == null) {
+                    mSyncTask = mMediaSet.requestSync();
+                }
+                if (mSyncTask.isDone()){
+                    Toast.makeText((Context) mActivity,
+                            R.string.empty_album, Toast.LENGTH_LONG).show();
+                    mActivity.getStateManager().finishState(AlbumPage.this);
+                }
+            }
+            if (mSyncTask == null || mSyncTask.isDone()) {
+                GalleryUtils.setSpinnerVisibility((Activity) mActivity, false);
+            }
+        }
+    }
+
+    private class MyDetailsSource implements DetailsWindow.DetailsSource {
+        private int mIndex;
+        public int size() {
+            return mAlbumDataAdapter.size();
+        }
+
+        // If requested index is out of active window, suggest a valid index.
+        // If there is no valid index available, return -1.
+        public int findIndex(int indexHint) {
+            if (mAlbumDataAdapter.isActive(indexHint)) {
+                mIndex = indexHint;
+            } else {
+                mIndex = mAlbumDataAdapter.getActiveStart();
+                if (!mAlbumDataAdapter.isActive(mIndex)) {
+                    return -1;
+                }
+            }
+            return mIndex;
+        }
+
+        public MediaDetails getDetails() {
+            MediaObject item = mAlbumDataAdapter.get(mIndex);
+            if (item != null) {
+                mHighlightDrawer.setHighlightItem(item.getPath());
+                return item.getDetails();
+            } else {
+                return null;
+            }
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/app/AlbumPicker.java b/src/com/android/gallery3d/app/AlbumPicker.java
new file mode 100644
index 0000000..b86aee8
--- /dev/null
+++ b/src/com/android/gallery3d/app/AlbumPicker.java
@@ -0,0 +1,68 @@
+/*
+ * 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.app;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.ui.GLRoot;
+import com.android.gallery3d.ui.GLRootView;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.view.View.OnClickListener;
+
+public class AlbumPicker extends AbstractGalleryActivity
+        implements OnClickListener {
+
+    public static final String KEY_ALBUM_PATH = "album-path";
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        setContentView(R.layout.dialog_picker);
+        ((GLRootView) findViewById(R.id.gl_root_view)).setZOrderOnTop(true);
+        findViewById(R.id.cancel).setOnClickListener(this);
+        setTitle(R.string.select_album);
+        Intent intent = getIntent();
+        Bundle extras = intent.getExtras();
+        Bundle data = extras == null ? new Bundle() : new Bundle(extras);
+
+        data.putBoolean(Gallery.KEY_GET_ALBUM, true);
+        data.putString(AlbumSetPage.KEY_MEDIA_PATH,
+                getDataManager().getTopSetPath(DataManager.INCLUDE_IMAGE));
+        getStateManager().startState(AlbumSetPage.class, data);
+    }
+
+    @Override
+    public void onBackPressed() {
+        // send the back event to the top sub-state
+        GLRoot root = getGLRoot();
+        root.lockRenderThread();
+        try {
+            getStateManager().getTopState().onBackPressed();
+        } finally {
+            root.unlockRenderThread();
+        }
+    }
+
+    @Override
+    public void onClick(View v) {
+        if (v.getId() == R.id.cancel) finish();
+    }
+}
diff --git a/src/com/android/gallery3d/app/AlbumSetDataAdapter.java b/src/com/android/gallery3d/app/AlbumSetDataAdapter.java
new file mode 100644
index 0000000..9086ddb
--- /dev/null
+++ b/src/com/android/gallery3d/app/AlbumSetDataAdapter.java
@@ -0,0 +1,384 @@
+/*
+ * 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.ui.AlbumSetView;
+import com.android.gallery3d.ui.SynchronizedHandler;
+
+import android.os.Handler;
+import android.os.Message;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+
+public class AlbumSetDataAdapter implements AlbumSetView.Model {
+    @SuppressWarnings("unused")
+    private static final String TAG = "AlbumSetDataAdapter";
+
+    private static final int INDEX_NONE = -1;
+
+    private static final int MIN_LOAD_COUNT = 4;
+    private static final int MAX_COVER_COUNT = 4;
+
+    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 MediaItem[] EMPTY_MEDIA_ITEMS = new MediaItem[0];
+
+    private final MediaSet[] mData;
+    private final MediaItem[][] mCoverData;
+    private final long[] mItemVersion;
+    private final long[] mSetVersion;
+
+    private int mActiveStart = 0;
+    private int mActiveEnd = 0;
+
+    private int mContentStart = 0;
+    private int mContentEnd = 0;
+
+    private final MediaSet mSource;
+    private long mSourceVersion = MediaObject.INVALID_DATA_VERSION;
+    private int mSize;
+
+    private AlbumSetView.ModelListener mModelListener;
+    private LoadingListener mLoadingListener;
+    private ReloadTask mReloadTask;
+
+    private final Handler mMainHandler;
+
+    private MySourceListener mSourceListener = new MySourceListener();
+
+    public AlbumSetDataAdapter(GalleryActivity activity, MediaSet albumSet, int cacheSize) {
+        mSource = Utils.checkNotNull(albumSet);
+        mCoverData = new MediaItem[cacheSize][];
+        mData = new MediaSet[cacheSize];
+        mItemVersion = new long[cacheSize];
+        mSetVersion = new long[cacheSize];
+        Arrays.fill(mItemVersion, MediaObject.INVALID_DATA_VERSION);
+        Arrays.fill(mSetVersion, MediaObject.INVALID_DATA_VERSION);
+
+        mMainHandler = new SynchronizedHandler(activity.getGLRoot()) {
+            @Override
+            public void handleMessage(Message message) {
+                switch (message.what) {
+                    case MSG_RUN_OBJECT:
+                        ((Runnable) message.obj).run();
+                        return;
+                    case MSG_LOAD_START:
+                        if (mLoadingListener != null) mLoadingListener.onLoadingStarted();
+                        return;
+                    case MSG_LOAD_FINISH:
+                        if (mLoadingListener != null) mLoadingListener.onLoadingFinished();
+                        return;
+                }
+            }
+        };
+    }
+
+    public void pause() {
+        mReloadTask.terminate();
+        mReloadTask = null;
+        mSource.removeContentListener(mSourceListener);
+    }
+
+    public void resume() {
+        mSource.addContentListener(mSourceListener);
+        mReloadTask = new ReloadTask();
+        mReloadTask.start();
+    }
+
+    public MediaSet getMediaSet(int index) {
+        if (index < mActiveStart && index >= mActiveEnd) {
+            throw new IllegalArgumentException(String.format(
+                    "%s not in (%s, %s)", index, mActiveStart, mActiveEnd));
+        }
+        return mData[index % mData.length];
+    }
+
+    public MediaItem[] getCoverItems(int index) {
+        if (index < mActiveStart && index >= mActiveEnd) {
+            throw new IllegalArgumentException(String.format(
+                    "%s not in (%s, %s)", index, mActiveStart, mActiveEnd));
+        }
+        MediaItem[] result = mCoverData[index % mCoverData.length];
+
+        // If the result is not ready yet, return an empty array
+        return result == null ? EMPTY_MEDIA_ITEMS : result;
+    }
+
+    public int getActiveStart() {
+        return mActiveStart;
+    }
+
+    public int getActiveEnd() {
+        return mActiveEnd;
+    }
+
+    public boolean isActive(int index) {
+        return index >= mActiveStart && index < mActiveEnd;
+    }
+
+    public int size() {
+        return mSize;
+    }
+
+    private void clearSlot(int slotIndex) {
+        mData[slotIndex] = null;
+        mCoverData[slotIndex] = null;
+        mItemVersion[slotIndex] = MediaObject.INVALID_DATA_VERSION;
+        mSetVersion[slotIndex] = MediaObject.INVALID_DATA_VERSION;
+    }
+
+    private void setContentWindow(int contentStart, int contentEnd) {
+        if (contentStart == mContentStart && contentEnd == mContentEnd) return;
+        MediaItem[][] data = mCoverData;
+        int length = data.length;
+
+        int start = this.mContentStart;
+        int end = this.mContentEnd;
+
+        mContentStart = contentStart;
+        mContentEnd = contentEnd;
+
+        if (contentStart >= end || start >= contentEnd) {
+            for (int i = start, n = end; i < n; ++i) {
+                clearSlot(i % length);
+            }
+        } else {
+            for (int i = start; i < contentStart; ++i) {
+                clearSlot(i % length);
+            }
+            for (int i = contentEnd, n = end; i < n; ++i) {
+                clearSlot(i % length);
+            }
+        }
+        mReloadTask.notifyDirty();
+    }
+
+    public void setActiveWindow(int start, int end) {
+        if (start == mActiveStart && end == mActiveEnd) return;
+
+        Utils.assertTrue(start <= end
+                && end - start <= mCoverData.length && end <= mSize);
+
+        mActiveStart = start;
+        mActiveEnd = end;
+
+        int length = mCoverData.length;
+        // If no data is visible, keep the cache content
+        if (start == end) return;
+
+        int contentStart = Utils.clamp((start + end) / 2 - length / 2,
+                0, Math.max(0, mSize - length));
+        int contentEnd = Math.min(contentStart + length, mSize);
+        if (mContentStart > start || mContentEnd < end
+                || Math.abs(contentStart - mContentStart) > MIN_LOAD_COUNT) {
+            setContentWindow(contentStart, contentEnd);
+        }
+    }
+
+    private class MySourceListener implements ContentListener {
+        public void onContentDirty() {
+            mReloadTask.notifyDirty();
+        }
+    }
+
+    public void setModelListener(AlbumSetView.ModelListener listener) {
+        mModelListener = listener;
+    }
+
+    public void setLoadingListener(LoadingListener listener) {
+        mLoadingListener = listener;
+    }
+
+    private static void getRepresentativeItems(MediaSet set, int wanted,
+            ArrayList<MediaItem> result) {
+        if (set.getMediaItemCount() > 0) {
+            result.addAll(set.getMediaItem(0, wanted));
+        }
+
+        int n = set.getSubMediaSetCount();
+        for (int i = 0; i < n && wanted > result.size(); i++) {
+            MediaSet subset = set.getSubMediaSet(i);
+            double perSet = (double) (wanted - result.size()) / (n - i);
+            int m = (int) Math.ceil(perSet);
+            getRepresentativeItems(subset, m, result);
+        }
+    }
+
+    private static class UpdateInfo {
+        public long version;
+        public int index;
+
+        public int size;
+        public MediaSet item;
+        public MediaItem covers[];
+    }
+
+    private class GetUpdateInfo implements Callable<UpdateInfo> {
+
+        private final long mVersion;
+
+        public GetUpdateInfo(long version) {
+            mVersion = version;
+        }
+
+        private int getInvalidIndex(long version) {
+            long setVersion[] = mSetVersion;
+            int length = setVersion.length;
+            for (int i = mContentStart, n = mContentEnd; i < n; ++i) {
+                int index = i % length;
+                if (setVersion[i % length] != version) return i;
+            }
+            return INDEX_NONE;
+        }
+
+        @Override
+        public UpdateInfo call() throws Exception {
+            int index = getInvalidIndex(mVersion);
+            if (index == INDEX_NONE
+                    && mSourceVersion == mVersion) return null;
+            UpdateInfo info = new UpdateInfo();
+            info.version = mSourceVersion;
+            info.index = index;
+            info.size = mSize;
+            return info;
+        }
+    }
+
+    private class UpdateContent implements Callable<Void> {
+        private UpdateInfo mUpdateInfo;
+
+        public UpdateContent(UpdateInfo info) {
+            mUpdateInfo = info;
+        }
+
+        public Void call() {
+            UpdateInfo info = mUpdateInfo;
+            mSourceVersion = info.version;
+            if (mSize != info.size) {
+                mSize = info.size;
+                if (mModelListener != null) mModelListener.onSizeChanged(mSize);
+                if (mContentEnd > mSize) mContentEnd = mSize;
+                if (mActiveEnd > mSize) mActiveEnd = mSize;
+            }
+            // Note: info.index could be INDEX_NONE, i.e., -1
+            if (info.index >= mContentStart && info.index < mContentEnd) {
+                int pos = info.index % mCoverData.length;
+                mSetVersion[pos] = info.version;
+                long itemVersion = info.item.getDataVersion();
+                if (mItemVersion[pos] == itemVersion) return null;
+                mItemVersion[pos] = itemVersion;
+                mData[pos] = info.item;
+                mCoverData[pos] = info.covers;
+                if (mModelListener != null
+                        && info.index >= mActiveStart && info.index < mActiveEnd) {
+                    mModelListener.onWindowContentChanged(info.index);
+                }
+            }
+            return null;
+        }
+    }
+
+    private <T> T executeAndWait(Callable<T> callable) {
+        FutureTask<T> task = new FutureTask<T>(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);
+        }
+    }
+
+    // TODO: load active range first
+    private class ReloadTask extends Thread {
+        private volatile boolean mActive = true;
+        private volatile boolean mDirty = true;
+        private volatile 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() {
+            boolean updateComplete = false;
+            while (mActive) {
+                synchronized (this) {
+                    if (mActive && !mDirty && updateComplete) {
+                        updateLoading(false);
+                        Utils.waitWithoutInterrupt(this);
+                        continue;
+                    }
+                }
+                mDirty = false;
+                updateLoading(true);
+
+                long version;
+                synchronized (DataManager.LOCK) {
+                    version = mSource.reload();
+                }
+                UpdateInfo info = executeAndWait(new GetUpdateInfo(version));
+                updateComplete = info == null;
+                if (updateComplete) continue;
+
+                synchronized (DataManager.LOCK) {
+                    if (info.version != version) {
+                        info.version = version;
+                        info.size = mSource.getSubMediaSetCount();
+                    }
+                    if (info.index != INDEX_NONE) {
+                        info.item = mSource.getSubMediaSet(info.index);
+                        if (info.item == null) continue;
+                        ArrayList<MediaItem> covers = new ArrayList<MediaItem>();
+                        getRepresentativeItems(info.item, MAX_COVER_COUNT, covers);
+                        info.covers = covers.toArray(new MediaItem[covers.size()]);
+                    }
+                }
+                executeAndWait(new UpdateContent(info));
+            }
+            updateLoading(false);
+        }
+
+        public synchronized void notifyDirty() {
+            mDirty = true;
+            notifyAll();
+        }
+
+        public synchronized void terminate() {
+            mActive = false;
+            notifyAll();
+        }
+    }
+}
+
+
diff --git a/src/com/android/gallery3d/app/AlbumSetPage.java b/src/com/android/gallery3d/app/AlbumSetPage.java
new file mode 100644
index 0000000..688ff81
--- /dev/null
+++ b/src/com/android/gallery3d/app/AlbumSetPage.java
@@ -0,0 +1,586 @@
+/*
+ * 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.R;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.MediaDetails;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.data.MediaSet;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.picasasource.PicasaSource;
+import com.android.gallery3d.settings.GallerySettings;
+import com.android.gallery3d.ui.ActionModeHandler;
+import com.android.gallery3d.ui.ActionModeHandler.ActionModeListener;
+import com.android.gallery3d.ui.AlbumSetView;
+import com.android.gallery3d.ui.DetailsWindow;
+import com.android.gallery3d.ui.DetailsWindow.CloseListener;
+import com.android.gallery3d.ui.GLCanvas;
+import com.android.gallery3d.ui.GLView;
+import com.android.gallery3d.ui.GridDrawer;
+import com.android.gallery3d.ui.HighlightDrawer;
+import com.android.gallery3d.ui.PositionProvider;
+import com.android.gallery3d.ui.PositionRepository;
+import com.android.gallery3d.ui.PositionRepository.Position;
+import com.android.gallery3d.ui.SelectionManager;
+import com.android.gallery3d.ui.SlotView;
+import com.android.gallery3d.ui.StaticBackground;
+import com.android.gallery3d.ui.SynchronizedHandler;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.os.Message;
+import android.provider.MediaStore;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View.MeasureSpec;
+import android.widget.Toast;
+
+public class AlbumSetPage extends ActivityState implements
+        SelectionManager.SelectionListener, GalleryActionBar.ClusterRunner,
+        EyePosition.EyePositionListener {
+    @SuppressWarnings("unused")
+    private static final String TAG = "AlbumSetPage";
+
+    public static final String KEY_MEDIA_PATH = "media-path";
+    public static final String KEY_SET_TITLE = "set-title";
+    public static final String KEY_SET_SUBTITLE = "set-subtitle";
+    private static final int DATA_CACHE_SIZE = 256;
+    private static final int REQUEST_DO_ANIMATION = 1;
+    private static final int MSG_GOTO_MANAGE_CACHE_PAGE = 1;
+
+    private boolean mIsActive = false;
+    private StaticBackground mStaticBackground;
+    private AlbumSetView mAlbumSetView;
+
+    private MediaSet mMediaSet;
+    private String mTitle;
+    private String mSubtitle;
+    private boolean mShowClusterTabs;
+
+    protected SelectionManager mSelectionManager;
+    private AlbumSetDataAdapter mAlbumSetDataAdapter;
+    private GridDrawer mGridDrawer;
+    private HighlightDrawer mHighlightDrawer;
+
+    private boolean mGetContent;
+    private boolean mGetAlbum;
+    private ActionMode mActionMode;
+    private ActionModeHandler mActionModeHandler;
+    private DetailsWindow mDetailsWindow;
+    private boolean mShowDetails;
+    private EyePosition mEyePosition;
+
+    // The eyes' position of the user, the origin is at the center of the
+    // device and the unit is in pixels.
+    private float mX;
+    private float mY;
+    private float mZ;
+
+    private SynchronizedHandler mHandler;
+
+    private GLView mRootPane = new GLView() {
+        private float mMatrix[] = new float[16];
+
+        @Override
+        protected void onLayout(
+                boolean changed, int left, int top, int right, int bottom) {
+            mStaticBackground.layout(0, 0, right - left, bottom - top);
+            mEyePosition.resetPosition();
+
+            int slotViewTop = GalleryActionBar.getHeight((Activity) mActivity);
+            int slotViewBottom = bottom - top;
+            int slotViewRight = right - left;
+
+            if (mShowDetails) {
+                mDetailsWindow.measure(
+                        MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+                int width = mDetailsWindow.getMeasuredWidth();
+                int detailLeft = right - left - width;
+                slotViewRight = detailLeft;
+                mDetailsWindow.layout(detailLeft, slotViewTop, detailLeft + width,
+                        bottom - top);
+            } else {
+                mAlbumSetView.setSelectionDrawer(mGridDrawer);
+            }
+
+            mAlbumSetView.layout(0, slotViewTop, slotViewRight, slotViewBottom);
+            PositionRepository.getInstance(mActivity).setOffset(
+                    0, slotViewTop);
+        }
+
+        @Override
+        protected void render(GLCanvas canvas) {
+            canvas.save(GLCanvas.SAVE_FLAG_MATRIX);
+            GalleryUtils.setViewPointMatrix(mMatrix,
+                    getWidth() / 2 + mX, getHeight() / 2 + mY, mZ);
+            canvas.multiplyMatrix(mMatrix, 0);
+            super.render(canvas);
+            canvas.restore();
+        }
+    };
+
+    @Override
+    public void onEyePositionChanged(float x, float y, float z) {
+        mRootPane.lockRendering();
+        mX = x;
+        mY = y;
+        mZ = z;
+        mRootPane.unlockRendering();
+        mRootPane.invalidate();
+    }
+
+    @Override
+    public void onBackPressed() {
+        if (mShowDetails) {
+            hideDetails();
+        } else if (mSelectionManager.inSelectionMode()) {
+            mSelectionManager.leaveSelectionMode();
+        } else {
+            mAlbumSetView.savePositions(
+                    PositionRepository.getInstance(mActivity));
+            super.onBackPressed();
+        }
+    }
+
+    private void savePositions(int slotIndex, int center[]) {
+        Rect offset = new Rect();
+        mRootPane.getBoundsOf(mAlbumSetView, offset);
+        mAlbumSetView.savePositions(PositionRepository.getInstance(mActivity));
+        Rect r = mAlbumSetView.getSlotRect(slotIndex);
+        int scrollX = mAlbumSetView.getScrollX();
+        int scrollY = mAlbumSetView.getScrollY();
+        center[0] = offset.left + (r.left + r.right) / 2 - scrollX;
+        center[1] = offset.top + (r.top + r.bottom) / 2 - scrollY;
+    }
+
+    public void onSingleTapUp(int slotIndex) {
+        MediaSet targetSet = mAlbumSetDataAdapter.getMediaSet(slotIndex);
+        if (targetSet == null) return; // Content is dirty, we shall reload soon
+
+        if (mShowDetails) {
+            Path path = targetSet.getPath();
+            mHighlightDrawer.setHighlightItem(path);
+            mDetailsWindow.reloadDetails(slotIndex);
+        } else if (!mSelectionManager.inSelectionMode()) {
+            Bundle data = new Bundle(getData());
+            String mediaPath = targetSet.getPath().toString();
+            int[] center = new int[2];
+            savePositions(slotIndex, center);
+            data.putIntArray(AlbumPage.KEY_SET_CENTER, center);
+            if (mGetAlbum && targetSet.isLeafAlbum()) {
+                Activity activity = (Activity) mActivity;
+                Intent result = new Intent()
+                        .putExtra(AlbumPicker.KEY_ALBUM_PATH, targetSet.getPath().toString());
+                activity.setResult(Activity.RESULT_OK, result);
+                activity.finish();
+            } else if (targetSet.getSubMediaSetCount() > 0) {
+                data.putString(AlbumSetPage.KEY_MEDIA_PATH, mediaPath);
+                mActivity.getStateManager().startStateForResult(
+                        AlbumSetPage.class, REQUEST_DO_ANIMATION, data);
+            } else {
+                if (!mGetContent && (targetSet.getSupportedOperations()
+                        & MediaObject.SUPPORT_IMPORT) != 0) {
+                    data.putBoolean(AlbumPage.KEY_AUTO_SELECT_ALL, true);
+                }
+                data.putString(AlbumPage.KEY_MEDIA_PATH, mediaPath);
+                boolean inAlbum = mActivity.getStateManager().hasStateClass(AlbumPage.class);
+                // We only show cluster menu in the first AlbumPage in stack
+                data.putBoolean(AlbumPage.KEY_SHOW_CLUSTER_MENU, !inAlbum);
+                mActivity.getStateManager().startStateForResult(
+                        AlbumPage.class, REQUEST_DO_ANIMATION, data);
+            }
+        } else {
+            mSelectionManager.toggle(targetSet.getPath());
+            mAlbumSetView.invalidate();
+        }
+    }
+
+    public void onLongTap(int slotIndex) {
+        if (mGetContent || mGetAlbum) return;
+        if (mShowDetails) {
+            onSingleTapUp(slotIndex);
+        } else {
+            MediaSet set = mAlbumSetDataAdapter.getMediaSet(slotIndex);
+            if (set == null) return;
+            mSelectionManager.setAutoLeaveSelectionMode(true);
+            mSelectionManager.toggle(set.getPath());
+            mAlbumSetView.invalidate();
+        }
+    }
+
+    public void doCluster(int clusterType) {
+        String basePath = mMediaSet.getPath().toString();
+        String newPath = FilterUtils.switchClusterPath(basePath, clusterType);
+        Bundle data = new Bundle(getData());
+        data.putString(AlbumSetPage.KEY_MEDIA_PATH, newPath);
+        mAlbumSetView.savePositions(PositionRepository.getInstance(mActivity));
+        mActivity.getStateManager().switchState(this, AlbumSetPage.class, data);
+    }
+
+    public void doFilter(int filterType) {
+        String basePath = mMediaSet.getPath().toString();
+        String newPath = FilterUtils.switchFilterPath(basePath, filterType);
+        Bundle data = new Bundle(getData());
+        data.putString(AlbumSetPage.KEY_MEDIA_PATH, newPath);
+        mAlbumSetView.savePositions(PositionRepository.getInstance(mActivity));
+        mActivity.getStateManager().switchState(this, AlbumSetPage.class, data);
+    }
+
+    public void onOperationComplete() {
+        mAlbumSetView.invalidate();
+        // TODO: enable animation
+    }
+
+    @Override
+    public void onCreate(Bundle data, Bundle restoreState) {
+        mHandler = new SynchronizedHandler(mActivity.getGLRoot()) {
+            @Override
+            public void handleMessage(Message message) {
+                Utils.assertTrue(message.what == MSG_GOTO_MANAGE_CACHE_PAGE);
+                Bundle data = new Bundle();
+                String mediaPath = mActivity.getDataManager().getTopSetPath(
+                    DataManager.INCLUDE_ALL);
+                data.putString(AlbumSetPage.KEY_MEDIA_PATH, mediaPath);
+                mActivity.getStateManager().startState(ManageCachePage.class, data);
+            }
+        };
+
+        initializeViews();
+        initializeData(data);
+        mGetContent = data.getBoolean(Gallery.KEY_GET_CONTENT, false);
+        mGetAlbum = data.getBoolean(Gallery.KEY_GET_ALBUM, false);
+        mTitle = data.getString(AlbumSetPage.KEY_SET_TITLE);
+        mSubtitle = data.getString(AlbumSetPage.KEY_SET_SUBTITLE);
+        mEyePosition = new EyePosition(mActivity.getAndroidContext(), this);
+
+        startTransition();
+    }
+
+    @Override
+    public void onPause() {
+        super.onPause();
+        mIsActive = false;
+        mActionModeHandler.pause();
+        mAlbumSetDataAdapter.pause();
+        mAlbumSetView.pause();
+        mEyePosition.pause();
+        if (mDetailsWindow != null) {
+            mDetailsWindow.pause();
+        }
+        GalleryActionBar actionBar = mActivity.getGalleryActionBar();
+        if (actionBar != null) actionBar.hideClusterTabs();
+    }
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        mIsActive = true;
+        setContentPane(mRootPane);
+        mAlbumSetDataAdapter.resume();
+        mAlbumSetView.resume();
+        mEyePosition.resume();
+        mActionModeHandler.resume();
+        GalleryActionBar actionBar = mActivity.getGalleryActionBar();
+        if (mShowClusterTabs && actionBar != null) actionBar.showClusterTabs(this);
+    }
+
+    private void initializeData(Bundle data) {
+        String mediaPath = data.getString(AlbumSetPage.KEY_MEDIA_PATH);
+        mMediaSet = mActivity.getDataManager().getMediaSet(mediaPath);
+        mSelectionManager.setSourceMediaSet(mMediaSet);
+        mAlbumSetDataAdapter = new AlbumSetDataAdapter(
+                mActivity, mMediaSet, DATA_CACHE_SIZE);
+        mAlbumSetDataAdapter.setLoadingListener(new MyLoadingListener());
+        mAlbumSetView.setModel(mAlbumSetDataAdapter);
+    }
+
+    private void initializeViews() {
+        mSelectionManager = new SelectionManager(mActivity, true);
+        mSelectionManager.setSelectionListener(this);
+        mStaticBackground = new StaticBackground(mActivity.getAndroidContext());
+        mRootPane.addComponent(mStaticBackground);
+
+        mGridDrawer = new GridDrawer((Context) mActivity, mSelectionManager);
+        Config.AlbumSetPage config = Config.AlbumSetPage.get((Context) mActivity);
+        mAlbumSetView = new AlbumSetView(mActivity, mGridDrawer,
+                config.slotWidth, config.slotHeight,
+                config.displayItemSize, config.labelFontSize,
+                config.labelOffsetY, config.labelMargin);
+        mAlbumSetView.setListener(new SlotView.SimpleListener() {
+            @Override
+            public void onSingleTapUp(int slotIndex) {
+                AlbumSetPage.this.onSingleTapUp(slotIndex);
+            }
+            @Override
+            public void onLongTap(int slotIndex) {
+                AlbumSetPage.this.onLongTap(slotIndex);
+            }
+        });
+
+        mActionModeHandler = new ActionModeHandler(mActivity, mSelectionManager);
+        mActionModeHandler.setActionModeListener(new ActionModeListener() {
+            public boolean onActionItemClicked(MenuItem item) {
+                return onItemSelected(item);
+            }
+        });
+        mRootPane.addComponent(mAlbumSetView);
+
+        mStaticBackground.setImage(R.drawable.background,
+                R.drawable.background_portrait);
+    }
+
+    @Override
+    protected boolean onCreateActionBar(Menu menu) {
+        Activity activity = (Activity) mActivity;
+        GalleryActionBar actionBar = mActivity.getGalleryActionBar();
+        MenuInflater inflater = activity.getMenuInflater();
+
+        final boolean inAlbum = mActivity.getStateManager().hasStateClass(
+                AlbumPage.class);
+
+        if (mGetContent) {
+            inflater.inflate(R.menu.pickup, menu);
+            int typeBits = mData.getInt(
+                    Gallery.KEY_TYPE_BITS, DataManager.INCLUDE_IMAGE);
+            int id = R.string.select_image;
+            if ((typeBits & DataManager.INCLUDE_VIDEO) != 0) {
+                id = (typeBits & DataManager.INCLUDE_IMAGE) == 0
+                        ? R.string.select_video
+                        : R.string.select_item;
+            }
+            actionBar.setTitle(id);
+        } else  if (mGetAlbum) {
+            inflater.inflate(R.menu.pickup, menu);
+            actionBar.setTitle(R.string.select_album);
+        } else {
+            mShowClusterTabs = !inAlbum;
+            inflater.inflate(R.menu.albumset, menu);
+            if (mTitle != null) {
+                actionBar.setTitle(mTitle);
+            } else {
+                actionBar.setTitle(activity.getApplicationInfo().labelRes);
+            }
+            MenuItem selectItem = menu.findItem(R.id.action_select);
+
+            if (selectItem != null) {
+                boolean selectAlbums = !inAlbum &&
+                        actionBar.getClusterTypeAction() == FilterUtils.CLUSTER_BY_ALBUM;
+                if (selectAlbums) {
+                    selectItem.setTitle(R.string.select_album);
+                } else {
+                    selectItem.setTitle(R.string.select_group);
+                }
+            }
+
+            MenuItem switchCamera = menu.findItem(R.id.action_camera);
+            if (switchCamera != null) {
+                switchCamera.setVisible(GalleryUtils.isCameraAvailable(activity));
+            }
+
+            actionBar.setSubtitle(mSubtitle);
+        }
+        return true;
+    }
+
+    @Override
+    protected boolean onItemSelected(MenuItem item) {
+        Activity activity = (Activity) mActivity;
+        switch (item.getItemId()) {
+            case R.id.action_select:
+                mSelectionManager.setAutoLeaveSelectionMode(false);
+                mSelectionManager.enterSelectionMode();
+                return true;
+            case R.id.action_details:
+                if (mAlbumSetDataAdapter.size() != 0) {
+                    if (mShowDetails) {
+                        hideDetails();
+                    } else {
+                        showDetails();
+                    }
+                } else {
+                    Toast.makeText(activity,
+                            activity.getText(R.string.no_albums_alert),
+                            Toast.LENGTH_SHORT).show();
+                }
+                return true;
+            case R.id.action_camera: {
+                Intent intent = new Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA)
+                        .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
+                        | Intent.FLAG_ACTIVITY_NEW_TASK);
+                activity.startActivity(intent);
+                return true;
+            }
+            case R.id.action_manage_offline: {
+                mHandler.sendEmptyMessage(MSG_GOTO_MANAGE_CACHE_PAGE);
+                return true;
+            }
+            case R.id.action_sync_picasa_albums: {
+                PicasaSource.requestSync(activity);
+                return true;
+            }
+            case R.id.action_settings: {
+                activity.startActivity(new Intent(activity, GallerySettings.class));
+                return true;
+            }
+            default:
+                return false;
+        }
+    }
+
+    @Override
+    protected void onStateResult(int requestCode, int resultCode, Intent data) {
+        switch (requestCode) {
+            case REQUEST_DO_ANIMATION: {
+                startTransition();
+            }
+        }
+    }
+
+    private void startTransition() {
+        final PositionRepository repository =
+                PositionRepository.getInstance(mActivity);
+        mAlbumSetView.startTransition(new PositionProvider() {
+            private Position mTempPosition = new Position();
+            public Position getPosition(long identity, Position target) {
+                Position p = repository.get(identity);
+                if (p == null) {
+                    p = mTempPosition;
+                    p.set(target.x, target.y, 128, target.theta, 1);
+                }
+                return p;
+            }
+        });
+    }
+
+    private String getSelectedString() {
+        GalleryActionBar actionBar = mActivity.getGalleryActionBar();
+        int count = mSelectionManager.getSelectedCount();
+        int action = actionBar.getClusterTypeAction();
+        int string = action == FilterUtils.CLUSTER_BY_ALBUM
+                ? R.plurals.number_of_albums_selected
+                : R.plurals.number_of_groups_selected;
+        String format = mActivity.getResources().getQuantityString(string, count);
+        return String.format(format, count);
+    }
+
+    public void onSelectionModeChange(int mode) {
+
+        switch (mode) {
+            case SelectionManager.ENTER_SELECTION_MODE: {
+                mActivity.getGalleryActionBar().hideClusterTabs();
+                mActionMode = mActionModeHandler.startActionMode();
+                break;
+            }
+            case SelectionManager.LEAVE_SELECTION_MODE: {
+                mActionMode.finish();
+                mActivity.getGalleryActionBar().showClusterTabs(this);
+                mRootPane.invalidate();
+                break;
+            }
+            case SelectionManager.SELECT_ALL_MODE: {
+                mActionModeHandler.setTitle(getSelectedString());
+                mRootPane.invalidate();
+                break;
+            }
+        }
+    }
+
+    public void onSelectionChange(Path path, boolean selected) {
+        Utils.assertTrue(mActionMode != null);
+        mActionModeHandler.setTitle(getSelectedString());
+        mActionModeHandler.updateSupportedOperation(path, selected);
+    }
+
+    private void hideDetails() {
+        mShowDetails = false;
+        mAlbumSetView.setSelectionDrawer(mGridDrawer);
+        mDetailsWindow.hide();
+    }
+
+    private void showDetails() {
+        mShowDetails = true;
+        if (mDetailsWindow == null) {
+            mHighlightDrawer = new HighlightDrawer(mActivity.getAndroidContext());
+            mDetailsWindow = new DetailsWindow(mActivity, new MyDetailsSource());
+            mDetailsWindow.setCloseListener(new CloseListener() {
+                public void onClose() {
+                    hideDetails();
+                }
+            });
+            mRootPane.addComponent(mDetailsWindow);
+        }
+        mAlbumSetView.setSelectionDrawer(mHighlightDrawer);
+        mDetailsWindow.show();
+    }
+
+    private class MyLoadingListener implements LoadingListener {
+        public void onLoadingStarted() {
+            GalleryUtils.setSpinnerVisibility((Activity) mActivity, true);
+        }
+
+        public void onLoadingFinished() {
+            if (!mIsActive) return;
+            GalleryUtils.setSpinnerVisibility((Activity) mActivity, false);
+            if (mAlbumSetDataAdapter.size() == 0) {
+                Toast.makeText((Context) mActivity,
+                        R.string.empty_album, Toast.LENGTH_LONG).show();
+                if (mActivity.getStateManager().getStateCount() > 1) {
+                    mActivity.getStateManager().finishState(AlbumSetPage.this);
+                }
+            }
+        }
+    }
+
+    private class MyDetailsSource implements DetailsWindow.DetailsSource {
+        private int mIndex;
+        public int size() {
+            return mAlbumSetDataAdapter.size();
+        }
+
+        // If requested index is out of active window, suggest a valid index.
+        // If there is no valid index available, return -1.
+        public int findIndex(int indexHint) {
+            if (mAlbumSetDataAdapter.isActive(indexHint)) {
+                mIndex = indexHint;
+            } else {
+                mIndex = mAlbumSetDataAdapter.getActiveStart();
+                if (!mAlbumSetDataAdapter.isActive(mIndex)) {
+                    return -1;
+                }
+            }
+            return mIndex;
+        }
+
+        public MediaDetails getDetails() {
+            MediaObject item = mAlbumSetDataAdapter.getMediaSet(mIndex);
+            if (item != null) {
+                mHighlightDrawer.setHighlightItem(item.getPath());
+                return item.getDetails();
+            } else {
+                return null;
+            }
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/app/Config.java b/src/com/android/gallery3d/app/Config.java
new file mode 100644
index 0000000..4586235
--- /dev/null
+++ b/src/com/android/gallery3d/app/Config.java
@@ -0,0 +1,140 @@
+/*
+ * 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.R;
+
+import android.content.Context;
+import android.content.res.Resources;
+
+final class Config {
+    public static class AlbumSetPage {
+        private static AlbumSetPage sInstance;
+
+        public final int slotWidth;
+        public final int slotHeight;
+        public final int displayItemSize;
+        public final int labelFontSize;
+        public final int labelOffsetY;
+        public final int labelMargin;
+
+        public static synchronized AlbumSetPage get(Context context) {
+            if (sInstance == null) {
+                sInstance = new AlbumSetPage(context);
+            }
+            return sInstance;
+        }
+
+        private AlbumSetPage(Context context) {
+            Resources r = context.getResources();
+            slotWidth = r.getDimensionPixelSize(R.dimen.albumset_slot_width);
+            slotHeight = r.getDimensionPixelSize(R.dimen.albumset_slot_height);
+            displayItemSize = r.getDimensionPixelSize(R.dimen.albumset_display_item_size);
+            labelFontSize = r.getDimensionPixelSize(R.dimen.albumset_label_font_size);
+            labelOffsetY = r.getDimensionPixelSize(R.dimen.albumset_label_offset_y);
+            labelMargin = r.getDimensionPixelSize(R.dimen.albumset_label_margin);
+        }
+    }
+
+    public static class AlbumPage {
+        private static AlbumPage sInstance;
+
+        public final int slotWidth;
+        public final int slotHeight;
+        public final int displayItemSize;
+
+        public static synchronized AlbumPage get(Context context) {
+            if (sInstance == null) {
+                sInstance = new AlbumPage(context);
+            }
+            return sInstance;
+        }
+
+        private AlbumPage(Context context) {
+            Resources r = context.getResources();
+            slotWidth = r.getDimensionPixelSize(R.dimen.album_slot_width);
+            slotHeight = r.getDimensionPixelSize(R.dimen.album_slot_height);
+            displayItemSize = r.getDimensionPixelSize(R.dimen.album_display_item_size);
+        }
+    }
+
+    public static class ManageCachePage extends AlbumSetPage {
+        private static ManageCachePage sInstance;
+
+        public final int cacheBarHeight;
+        public final int cacheBarPinLeftMargin;
+        public final int cacheBarPinRightMargin;
+        public final int cacheBarButtonRightMargin;
+        public final int cacheBarFontSize;
+
+        public static synchronized ManageCachePage get(Context context) {
+            if (sInstance == null) {
+                sInstance = new ManageCachePage(context);
+            }
+            return sInstance;
+        }
+
+        public ManageCachePage(Context context) {
+            super(context);
+            Resources r = context.getResources();
+            cacheBarHeight = r.getDimensionPixelSize(R.dimen.cache_bar_height);
+            cacheBarPinLeftMargin = r.getDimensionPixelSize(R.dimen.cache_bar_pin_left_margin);
+            cacheBarPinRightMargin = r.getDimensionPixelSize(
+                    R.dimen.cache_bar_pin_right_margin);
+            cacheBarButtonRightMargin = r.getDimensionPixelSize(
+                    R.dimen.cache_bar_button_right_margin);
+            cacheBarFontSize = r.getDimensionPixelSize(R.dimen.cache_bar_font_size);
+        }
+    }
+
+    public static class PhotoPage {
+        private static PhotoPage sInstance;
+
+        // These are all height values. See the comment in FilmStripView for
+        // the meaning of these values.
+        public final int filmstripTopMargin;
+        public final int filmstripMidMargin;
+        public final int filmstripBottomMargin;
+        public final int filmstripThumbSize;
+        public final int filmstripContentSize;
+        public final int filmstripGripSize;
+        public final int filmstripBarSize;
+
+        // These are width values.
+        public final int filmstripGripWidth;
+
+        public static synchronized PhotoPage get(Context context) {
+            if (sInstance == null) {
+                sInstance = new PhotoPage(context);
+            }
+            return sInstance;
+        }
+
+        public PhotoPage(Context context) {
+            Resources r = context.getResources();
+            filmstripTopMargin = r.getDimensionPixelSize(R.dimen.filmstrip_top_margin);
+            filmstripMidMargin = r.getDimensionPixelSize(R.dimen.filmstrip_mid_margin);
+            filmstripBottomMargin = r.getDimensionPixelSize(R.dimen.filmstrip_bottom_margin);
+            filmstripThumbSize = r.getDimensionPixelSize(R.dimen.filmstrip_thumb_size);
+            filmstripContentSize = r.getDimensionPixelSize(R.dimen.filmstrip_content_size);
+            filmstripGripSize = r.getDimensionPixelSize(R.dimen.filmstrip_grip_size);
+            filmstripBarSize = r.getDimensionPixelSize(R.dimen.filmstrip_bar_size);
+            filmstripGripWidth = r.getDimensionPixelSize(R.dimen.filmstrip_grip_width);
+        }
+    }
+}
+
diff --git a/src/com/android/gallery3d/app/CropImage.java b/src/com/android/gallery3d/app/CropImage.java
new file mode 100644
index 0000000..6c0a0c7
--- /dev/null
+++ b/src/com/android/gallery3d/app/CropImage.java
@@ -0,0 +1,850 @@
+/*
+ * 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.R;
+import com.android.gallery3d.common.BitmapUtils;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.LocalImage;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.picasasource.PicasaSource;
+import com.android.gallery3d.ui.BitmapTileProvider;
+import com.android.gallery3d.ui.CropView;
+import com.android.gallery3d.ui.GLRoot;
+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.GalleryUtils;
+import com.android.gallery3d.util.InterruptableOutputStream;
+import com.android.gallery3d.util.ThreadPool.CancelListener;
+import com.android.gallery3d.util.ThreadPool.Job;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.app.ProgressDialog;
+import android.app.WallpaperManager;
+import android.content.ContentValues;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.CompressFormat;
+import android.graphics.Bitmap.Config;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapRegionDecoder;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.Message;
+import android.provider.MediaStore;
+import android.provider.MediaStore.Images;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.Window;
+import android.widget.Toast;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * The activity can crop specific region of interest from an image.
+ */
+public class CropImage extends AbstractGalleryActivity {
+    private static final String TAG = "CropImage";
+    public static final String ACTION_CROP = "com.android.camera.action.CROP";
+
+    private static final int MAX_PIXEL_COUNT = 5 * 1000000; // 5M pixels
+    private static final int MAX_FILE_INDEX = 1000;
+    private static final int TILE_SIZE = 512;
+    private static final int BACKUP_PIXEL_COUNT = 480000; // around 800x600
+
+    private static final int MSG_LARGE_BITMAP = 1;
+    private static final int MSG_BITMAP = 2;
+    private static final int MSG_SAVE_COMPLETE = 3;
+
+    private static final int MAX_BACKUP_IMAGE_SIZE = 320;
+    private static final int DEFAULT_COMPRESS_QUALITY = 90;
+
+    public static final String KEY_RETURN_DATA = "return-data";
+    public static final String KEY_CROPPED_RECT = "cropped-rect";
+    public static final String KEY_ASPECT_X = "aspectX";
+    public static final String KEY_ASPECT_Y = "aspectY";
+    public static final String KEY_SPOTLIGHT_X = "spotlightX";
+    public static final String KEY_SPOTLIGHT_Y = "spotlightY";
+    public static final String KEY_OUTPUT_X = "outputX";
+    public static final String KEY_OUTPUT_Y = "outputY";
+    public static final String KEY_SCALE = "scale";
+    public static final String KEY_DATA = "data";
+    public static final String KEY_SCALE_UP_IF_NEEDED = "scaleUpIfNeeded";
+    public static final String KEY_OUTPUT_FORMAT = "outputFormat";
+    public static final String KEY_SET_AS_WALLPAPER = "set-as-wallpaper";
+    public static final String KEY_NO_FACE_DETECTION = "noFaceDetection";
+
+    private static final String KEY_STATE = "state";
+
+    private static final int STATE_INIT = 0;
+    private static final int STATE_LOADED = 1;
+    private static final int STATE_SAVING = 2;
+
+    public static final String DOWNLOAD_STRING = "download";
+    public static final File DOWNLOAD_BUCKET = new File(
+            Environment.getExternalStorageDirectory(), DOWNLOAD_STRING);
+
+    public static final String CROP_ACTION = "com.android.camera.action.CROP";
+
+    private int mState = STATE_INIT;
+
+    private CropView mCropView;
+
+    private boolean mDoFaceDetection = true;
+
+    private Handler mMainHandler;
+
+    // We keep the following members so that we can free them
+
+    // mBitmap is the unrotated bitmap we pass in to mCropView for detect faces.
+    // mCropView is responsible for rotating it to the way that it is viewed by users.
+    private Bitmap mBitmap;
+    private BitmapTileProvider mBitmapTileProvider;
+    private BitmapRegionDecoder mRegionDecoder;
+    private Bitmap mBitmapInIntent;
+    private boolean mUseRegionDecoder = false;
+
+    private ProgressDialog mProgressDialog;
+    private Future<BitmapRegionDecoder> mLoadTask;
+    private Future<Bitmap> mLoadBitmapTask;
+    private Future<Intent> mSaveTask;
+
+    private MediaItem mMediaItem;
+
+    @Override
+    public void onCreate(Bundle bundle) {
+        super.onCreate(bundle);
+        requestWindowFeature(Window.FEATURE_ACTION_BAR);
+        requestWindowFeature(Window.FEATURE_ACTION_BAR_OVERLAY);
+
+        // Initialize UI
+        setContentView(R.layout.cropimage);
+        mCropView = new CropView(this);
+        getGLRoot().setContentPane(mCropView);
+
+        mMainHandler = new SynchronizedHandler(getGLRoot()) {
+            @Override
+            public void handleMessage(Message message) {
+                switch (message.what) {
+                    case MSG_LARGE_BITMAP: {
+                        mProgressDialog.dismiss();
+                        onBitmapRegionDecoderAvailable((BitmapRegionDecoder) message.obj);
+                        break;
+                    }
+                    case MSG_BITMAP: {
+                        mProgressDialog.dismiss();
+                        onBitmapAvailable((Bitmap) message.obj);
+                        break;
+                    }
+                    case MSG_SAVE_COMPLETE: {
+                        mProgressDialog.dismiss();
+                        setResult(RESULT_OK, (Intent) message.obj);
+                        finish();
+                        break;
+                    }
+                }
+            }
+        };
+
+        setCropParameters();
+    }
+
+    @Override
+    protected void onSaveInstanceState(Bundle saveState) {
+        saveState.putInt(KEY_STATE, mState);
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        super.onCreateOptionsMenu(menu);
+        getMenuInflater().inflate(R.menu.crop, menu);
+        return true;
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        switch (item.getItemId()) {
+            case R.id.cancel: {
+                setResult(RESULT_CANCELED);
+                finish();
+                break;
+            }
+            case R.id.save: {
+                onSaveClicked();
+                break;
+            }
+        }
+        return true;
+    }
+
+    private class SaveOutput implements Job<Intent> {
+        private RectF mCropRect;
+
+        public SaveOutput(RectF cropRect) {
+            mCropRect = cropRect;
+        }
+
+        public Intent run(JobContext jc) {
+            RectF cropRect = mCropRect;
+            Bundle extra = getIntent().getExtras();
+
+            Rect rect = new Rect(
+                    Math.round(cropRect.left), Math.round(cropRect.top),
+                    Math.round(cropRect.right), Math.round(cropRect.bottom));
+
+            Intent result = new Intent();
+            result.putExtra(KEY_CROPPED_RECT, rect);
+            Bitmap cropped = null;
+            boolean outputted = false;
+            if (extra != null) {
+                Uri uri = (Uri) extra.getParcelable(MediaStore.EXTRA_OUTPUT);
+                if (uri != null) {
+                    if (jc.isCancelled()) return null;
+                    outputted = true;
+                    cropped = getCroppedImage(rect);
+                    if (!saveBitmapToUri(jc, cropped, uri)) return null;
+                }
+                if (extra.getBoolean(KEY_RETURN_DATA, false)) {
+                    if (jc.isCancelled()) return null;
+                    outputted = true;
+                    if (cropped == null) cropped = getCroppedImage(rect);
+                    result.putExtra(KEY_DATA, cropped);
+                }
+                if (extra.getBoolean(KEY_SET_AS_WALLPAPER, false)) {
+                    if (jc.isCancelled()) return null;
+                    outputted = true;
+                    if (cropped == null) cropped = getCroppedImage(rect);
+                    if (!setAsWallpaper(jc, cropped)) return null;
+                }
+            }
+            if (!outputted) {
+                if (jc.isCancelled()) return null;
+                if (cropped == null) cropped = getCroppedImage(rect);
+                Uri data = saveToMediaProvider(jc, cropped);
+                if (data != null) result.setData(data);
+            }
+            return result;
+        }
+    }
+
+    public static String determineCompressFormat(MediaObject obj) {
+        String compressFormat = "JPEG";
+        if (obj instanceof MediaItem) {
+            String mime = ((MediaItem) obj).getMimeType();
+            if (mime.contains("png") || mime.contains("gif")) {
+              // Set the compress format to PNG for png and gif images
+              // because they may contain alpha values.
+              compressFormat = "PNG";
+            }
+        }
+        return compressFormat;
+    }
+
+    private boolean setAsWallpaper(JobContext jc, Bitmap wallpaper) {
+        try {
+            WallpaperManager.getInstance(this).setBitmap(wallpaper);
+        } catch (IOException e) {
+            Log.w(TAG, "fail to set wall paper", e);
+        }
+        return true;
+    }
+
+    private File saveMedia(
+            JobContext jc, Bitmap cropped, File directory, String filename) {
+        // Try file-1.jpg, file-2.jpg, ... until we find a filename
+        // which does not exist yet.
+        File candidate = null;
+        String fileExtension = getFileExtension();
+        for (int i = 1; i < MAX_FILE_INDEX; ++i) {
+            candidate = new File(directory, filename + "-" + i + "."
+                    + fileExtension);
+            try {
+                if (candidate.createNewFile()) break;
+            } catch (IOException e) {
+                Log.e(TAG, "fail to create new file: "
+                        + candidate.getAbsolutePath(), e);
+                return null;
+            }
+        }
+        if (!candidate.exists() || !candidate.isFile()) {
+            throw new RuntimeException("cannot create file: " + filename);
+        }
+
+        candidate.setReadable(true, false);
+        candidate.setWritable(true, false);
+
+        try {
+            FileOutputStream fos = new FileOutputStream(candidate);
+            try {
+                saveBitmapToOutputStream(jc, cropped,
+                        convertExtensionToCompressFormat(fileExtension), fos);
+            } finally {
+                fos.close();
+            }
+        } catch (IOException e) {
+            Log.e(TAG, "fail to save image: "
+                    + candidate.getAbsolutePath(), e);
+            candidate.delete();
+            return null;
+        }
+
+        if (jc.isCancelled()) {
+            candidate.delete();
+            return null;
+        }
+
+        return candidate;
+    }
+
+    private Uri saveToMediaProvider(JobContext jc, Bitmap cropped) {
+        if (PicasaSource.isPicasaImage(mMediaItem)) {
+            return savePicasaImage(jc, cropped);
+        } else if (mMediaItem instanceof LocalImage) {
+            return saveLocalImage(jc, cropped);
+        } else {
+            Log.w(TAG, "no output for crop image " + mMediaItem);
+            return null;
+        }
+    }
+
+    private Uri savePicasaImage(JobContext jc, Bitmap cropped) {
+        if (!DOWNLOAD_BUCKET.isDirectory() && !DOWNLOAD_BUCKET.mkdirs()) {
+            throw new RuntimeException("cannot create download folder");
+        }
+
+        String filename = PicasaSource.getImageTitle(mMediaItem);
+        int pos = filename.lastIndexOf('.');
+        if (pos >= 0) filename = filename.substring(0, pos);
+        File output = saveMedia(jc, cropped, DOWNLOAD_BUCKET, filename);
+        if (output == null) return null;
+
+        long now = System.currentTimeMillis() / 1000;
+        ContentValues values = new ContentValues();
+        values.put(Images.Media.TITLE, PicasaSource.getImageTitle(mMediaItem));
+        values.put(Images.Media.DISPLAY_NAME, output.getName());
+        values.put(Images.Media.DATE_TAKEN, PicasaSource.getDateTaken(mMediaItem));
+        values.put(Images.Media.DATE_MODIFIED, now);
+        values.put(Images.Media.DATE_ADDED, now);
+        values.put(Images.Media.MIME_TYPE, "image/jpeg");
+        values.put(Images.Media.ORIENTATION, 0);
+        values.put(Images.Media.DATA, output.getAbsolutePath());
+        values.put(Images.Media.SIZE, output.length());
+
+        double latitude = PicasaSource.getLatitude(mMediaItem);
+        double longitude = PicasaSource.getLongitude(mMediaItem);
+        if (GalleryUtils.isValidLocation(latitude, longitude)) {
+            values.put(Images.Media.LATITUDE, latitude);
+            values.put(Images.Media.LONGITUDE, longitude);
+        }
+        return getContentResolver().insert(
+                Images.Media.EXTERNAL_CONTENT_URI, values);
+    }
+
+    private Uri saveLocalImage(JobContext jc, Bitmap cropped) {
+        LocalImage localImage = (LocalImage) mMediaItem;
+
+        File oldPath = new File(localImage.filePath);
+        File directory = new File(oldPath.getParent());
+
+        String filename = oldPath.getName();
+        int pos = filename.lastIndexOf('.');
+        if (pos >= 0) filename = filename.substring(0, pos);
+        File output = saveMedia(jc, cropped, directory, filename);
+        if (output == null) return null;
+
+        long now = System.currentTimeMillis() / 1000;
+        ContentValues values = new ContentValues();
+        values.put(Images.Media.TITLE, localImage.caption);
+        values.put(Images.Media.DISPLAY_NAME, output.getName());
+        values.put(Images.Media.DATE_TAKEN, localImage.dateTakenInMs);
+        values.put(Images.Media.DATE_MODIFIED, now);
+        values.put(Images.Media.DATE_ADDED, now);
+        values.put(Images.Media.MIME_TYPE, "image/jpeg");
+        values.put(Images.Media.ORIENTATION, 0);
+        values.put(Images.Media.DATA, output.getAbsolutePath());
+        values.put(Images.Media.SIZE, output.length());
+
+        if (GalleryUtils.isValidLocation(localImage.latitude, localImage.longitude)) {
+            values.put(Images.Media.LATITUDE, localImage.latitude);
+            values.put(Images.Media.LONGITUDE, localImage.longitude);
+        }
+        return getContentResolver().insert(
+                Images.Media.EXTERNAL_CONTENT_URI, values);
+    }
+
+    private boolean saveBitmapToOutputStream(
+            JobContext jc, Bitmap bitmap, CompressFormat format, OutputStream os) {
+        // We wrap the OutputStream so that it can be interrupted.
+        final InterruptableOutputStream ios = new InterruptableOutputStream(os);
+        jc.setCancelListener(new CancelListener() {
+                public void onCancel() {
+                    ios.interrupt();
+                }
+            });
+        try {
+            bitmap.compress(format, DEFAULT_COMPRESS_QUALITY, os);
+            if (!jc.isCancelled()) return false;
+        } finally {
+            jc.setCancelListener(null);
+            Utils.closeSilently(os);
+        }
+        return false;
+    }
+
+    private boolean saveBitmapToUri(JobContext jc, Bitmap bitmap, Uri uri) {
+        try {
+            return saveBitmapToOutputStream(jc, bitmap,
+                    convertExtensionToCompressFormat(getFileExtension()),
+                    getContentResolver().openOutputStream(uri));
+        } catch (FileNotFoundException e) {
+            Log.w(TAG, "cannot write output", e);
+        }
+        return true;
+    }
+
+    private CompressFormat convertExtensionToCompressFormat(String extension) {
+        return extension.equals("png")
+                ? CompressFormat.PNG
+                : CompressFormat.JPEG;
+    }
+
+    private String getFileExtension() {
+        String requestFormat = getIntent().getStringExtra(KEY_OUTPUT_FORMAT);
+        String outputFormat = (requestFormat == null)
+                ? determineCompressFormat(mMediaItem)
+                : requestFormat;
+
+        outputFormat = outputFormat.toLowerCase();
+        return (outputFormat.equals("png") || outputFormat.equals("gif"))
+                ? "png" // We don't support gif compression.
+                : "jpg";
+    }
+
+    private void onSaveClicked() {
+        Bundle extra = getIntent().getExtras();
+        RectF cropRect = mCropView.getCropRectangle();
+        if (cropRect == null) return;
+        mState = STATE_SAVING;
+        int messageId = extra != null && extra.getBoolean(KEY_SET_AS_WALLPAPER)
+                ? R.string.wallpaper
+                : R.string.saving_image;
+        mProgressDialog = ProgressDialog.show(
+                this, null, getString(messageId), true, false);
+        mSaveTask = getThreadPool().submit(new SaveOutput(cropRect),
+                new FutureListener<Intent>() {
+            public void onFutureDone(Future<Intent> future) {
+                mSaveTask = null;
+                if (future.get() == null) return;
+                mMainHandler.sendMessage(mMainHandler.obtainMessage(
+                        MSG_SAVE_COMPLETE, future.get()));
+            }
+        });
+    }
+
+    private Bitmap getCroppedImage(Rect rect) {
+        Utils.assertTrue(rect.width() > 0 && rect.height() > 0);
+
+        Bundle extras = getIntent().getExtras();
+        // (outputX, outputY) = the width and height of the returning bitmap.
+        int outputX = rect.width();
+        int outputY = rect.height();
+        if (extras != null) {
+            outputX = extras.getInt(KEY_OUTPUT_X, outputX);
+            outputY = extras.getInt(KEY_OUTPUT_Y, outputY);
+        }
+
+        if (outputX * outputY > MAX_PIXEL_COUNT) {
+            float scale = (float) Math.sqrt(
+                    (double) MAX_PIXEL_COUNT / outputX / outputY);
+            Log.w(TAG, "scale down the cropped image: " + scale);
+            outputX = Math.round(scale * outputX);
+            outputY = Math.round(scale * outputY);
+        }
+
+        // (rect.width() * scaleX, rect.height() * scaleY) =
+        // the size of drawing area in output bitmap
+        float scaleX = 1;
+        float scaleY = 1;
+        Rect dest = new Rect(0, 0, outputX, outputY);
+        if (extras == null || extras.getBoolean(KEY_SCALE, true)) {
+            scaleX = (float) outputX / rect.width();
+            scaleY = (float) outputY / rect.height();
+            if (extras == null || !extras.getBoolean(
+                    KEY_SCALE_UP_IF_NEEDED, false)) {
+                if (scaleX > 1f) scaleX = 1;
+                if (scaleY > 1f) scaleY = 1;
+            }
+        }
+
+        // Keep the content in the center (or crop the content)
+        int rectWidth = Math.round(rect.width() * scaleX);
+        int rectHeight = Math.round(rect.height() * scaleY);
+        dest.set(Math.round((outputX - rectWidth) / 2f),
+                Math.round((outputY - rectHeight) / 2f),
+                Math.round((outputX + rectWidth) / 2f),
+                Math.round((outputY + rectHeight) / 2f));
+
+        if (mBitmapInIntent != null) {
+            Bitmap source = mBitmapInIntent;
+            Bitmap result = Bitmap.createBitmap(
+                    outputX, outputY, Config.ARGB_8888);
+            Canvas canvas = new Canvas(result);
+            canvas.drawBitmap(source, rect, dest, null);
+            return result;
+        }
+
+        int rotation = mMediaItem.getRotation();
+        rotateRectangle(rect, mCropView.getImageWidth(),
+                mCropView.getImageHeight(), 360 - rotation);
+        rotateRectangle(dest, outputX, outputY, 360 - rotation);
+        if (mUseRegionDecoder) {
+            BitmapFactory.Options options = new BitmapFactory.Options();
+            int sample = BitmapUtils.computeSampleSizeLarger(
+                    Math.max(scaleX, scaleY));
+            options.inSampleSize = sample;
+            if ((rect.width() / sample) == dest.width()
+                    && (rect.height() / sample) == dest.height()
+                    && rotation == 0) {
+                // To prevent concurrent access in GLThread
+                synchronized (mRegionDecoder) {
+                    return mRegionDecoder.decodeRegion(rect, options);
+                }
+            }
+            Bitmap result = Bitmap.createBitmap(
+                    outputX, outputY, Config.ARGB_8888);
+            Canvas canvas = new Canvas(result);
+            rotateCanvas(canvas, outputX, outputY, rotation);
+            drawInTiles(canvas, mRegionDecoder, rect, dest, sample);
+            return result;
+        } else {
+            Bitmap result = Bitmap.createBitmap(outputX, outputY, Config.ARGB_8888);
+            Canvas canvas = new Canvas(result);
+            rotateCanvas(canvas, outputX, outputY, rotation);
+            canvas.drawBitmap(mBitmap,
+                    rect, dest, new Paint(Paint.FILTER_BITMAP_FLAG));
+            return result;
+        }
+    }
+
+    private static void rotateCanvas(
+            Canvas canvas, int width, int height, int rotation) {
+        canvas.translate(width / 2, height / 2);
+        canvas.rotate(rotation);
+        if (((rotation / 90) & 0x01) == 0) {
+            canvas.translate(-width / 2, -height / 2);
+        } else {
+            canvas.translate(-height / 2, -width / 2);
+        }
+    }
+
+    private static void rotateRectangle(
+            Rect rect, int width, int height, int rotation) {
+        if (rotation == 0 || rotation == 360) return;
+
+        int w = rect.width();
+        int h = rect.height();
+        switch (rotation) {
+            case 90: {
+                rect.top = rect.left;
+                rect.left = height - rect.bottom;
+                rect.right = rect.left + h;
+                rect.bottom = rect.top + w;
+                return;
+            }
+            case 180: {
+                rect.left = width - rect.right;
+                rect.top = height - rect.bottom;
+                rect.right = rect.left + w;
+                rect.bottom = rect.top + h;
+                return;
+            }
+            case 270: {
+                rect.left = rect.top;
+                rect.top = width - rect.right;
+                rect.right = rect.left + h;
+                rect.bottom = rect.top + w;
+                return;
+            }
+            default: throw new AssertionError();
+        }
+    }
+
+    private void drawInTiles(Canvas canvas,
+            BitmapRegionDecoder decoder, Rect rect, Rect dest, int sample) {
+        int tileSize = TILE_SIZE * sample;
+        Rect tileRect = new Rect();
+        BitmapFactory.Options options = new BitmapFactory.Options();
+        options.inPreferredConfig = Config.ARGB_8888;
+        options.inSampleSize = sample;
+        canvas.translate(dest.left, dest.top);
+        canvas.scale((float) sample * dest.width() / rect.width(),
+                (float) sample * dest.height() / rect.height());
+        Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG);
+        for (int tx = rect.left, x = 0;
+                tx < rect.right; tx += tileSize, x += TILE_SIZE) {
+            for (int ty = rect.top, y = 0;
+                    ty < rect.bottom; ty += tileSize, y += TILE_SIZE) {
+                tileRect.set(tx, ty, tx + tileSize, ty + tileSize);
+                if (tileRect.intersect(rect)) {
+                    Bitmap bitmap;
+
+                    // To prevent concurrent access in GLThread
+                    synchronized (decoder) {
+                        bitmap = decoder.decodeRegion(tileRect, options);
+                    }
+                    canvas.drawBitmap(bitmap, x, y, paint);
+                    bitmap.recycle();
+                }
+            }
+        }
+    }
+
+    private void onBitmapRegionDecoderAvailable(
+            BitmapRegionDecoder regionDecoder) {
+
+        if (regionDecoder == null) {
+            Toast.makeText(this, "fail to load image", Toast.LENGTH_SHORT).show();
+            finish();
+            return;
+        }
+        mRegionDecoder = regionDecoder;
+        mUseRegionDecoder = true;
+        mState = STATE_LOADED;
+
+        BitmapFactory.Options options = new BitmapFactory.Options();
+        int width = regionDecoder.getWidth();
+        int height = regionDecoder.getHeight();
+        options.inSampleSize = BitmapUtils.computeSampleSize(width, height,
+                BitmapUtils.UNCONSTRAINED, BACKUP_PIXEL_COUNT);
+        mBitmap = regionDecoder.decodeRegion(
+                new Rect(0, 0, width, height), options);
+        mCropView.setDataModel(new TileImageViewAdapter(
+                mBitmap, regionDecoder), mMediaItem.getRotation());
+        if (mDoFaceDetection) {
+            mCropView.detectFaces(mBitmap);
+        } else {
+            mCropView.initializeHighlightRectangle();
+        }
+    }
+
+    private void onBitmapAvailable(Bitmap bitmap) {
+        if (bitmap == null) {
+            Toast.makeText(this, "fail to load image", Toast.LENGTH_SHORT).show();
+            finish();
+            return;
+        }
+        mUseRegionDecoder = false;
+        mState = STATE_LOADED;
+
+        mBitmap = bitmap;
+        BitmapFactory.Options options = new BitmapFactory.Options();
+        mCropView.setDataModel(new BitmapTileProvider(bitmap, 512),
+                mMediaItem.getRotation());
+        if (mDoFaceDetection) {
+            mCropView.detectFaces(bitmap);
+        } else {
+            mCropView.initializeHighlightRectangle();
+        }
+    }
+
+    private void setCropParameters() {
+        Bundle extras = getIntent().getExtras();
+        if (extras == null)
+            return;
+        int aspectX = extras.getInt(KEY_ASPECT_X, 0);
+        int aspectY = extras.getInt(KEY_ASPECT_Y, 0);
+        if (aspectX != 0 && aspectY != 0) {
+            mCropView.setAspectRatio((float) aspectX / aspectY);
+        }
+
+        float spotlightX = extras.getFloat(KEY_SPOTLIGHT_X, 0);
+        float spotlightY = extras.getFloat(KEY_SPOTLIGHT_Y, 0);
+        if (spotlightX != 0 && spotlightY != 0) {
+            mCropView.setSpotlightRatio(spotlightX, spotlightY);
+        }
+    }
+
+    private void initializeData() {
+        Bundle extras = getIntent().getExtras();
+
+        if (extras != null) {
+            if (extras.containsKey(KEY_NO_FACE_DETECTION)) {
+                mDoFaceDetection = !extras.getBoolean(KEY_NO_FACE_DETECTION);
+            }
+
+            mBitmapInIntent = extras.getParcelable(KEY_DATA);
+
+            if (mBitmapInIntent != null) {
+                mBitmapTileProvider =
+                        new BitmapTileProvider(mBitmapInIntent, MAX_BACKUP_IMAGE_SIZE);
+                mCropView.setDataModel(mBitmapTileProvider, 0);
+                if (mDoFaceDetection) {
+                    mCropView.detectFaces(mBitmapInIntent);
+                } else {
+                    mCropView.initializeHighlightRectangle();
+                }
+                mState = STATE_LOADED;
+                return;
+            }
+        }
+
+        mProgressDialog = ProgressDialog.show(
+                this, null, getString(R.string.loading_image), true, false);
+
+        mMediaItem = getMediaItemFromIntentData();
+        if (mMediaItem == null) return;
+
+        boolean supportedByBitmapRegionDecoder =
+            (mMediaItem.getSupportedOperations() & MediaItem.SUPPORT_FULL_IMAGE) != 0;
+        if (supportedByBitmapRegionDecoder) {
+            mLoadTask = getThreadPool().submit(new LoadDataTask(mMediaItem),
+                    new FutureListener<BitmapRegionDecoder>() {
+                public void onFutureDone(Future<BitmapRegionDecoder> future) {
+                    mLoadTask = null;
+                    BitmapRegionDecoder decoder = future.get();
+                    if (future.isCancelled()) {
+                        if (decoder != null) decoder.recycle();
+                        return;
+                    }
+                    mMainHandler.sendMessage(mMainHandler.obtainMessage(
+                            MSG_LARGE_BITMAP, decoder));
+                }
+            });
+        } else {
+            mLoadBitmapTask = getThreadPool().submit(new LoadBitmapDataTask(mMediaItem),
+                    new FutureListener<Bitmap>() {
+                public void onFutureDone(Future<Bitmap> future) {
+                    mLoadBitmapTask = null;
+                    Bitmap bitmap = future.get();
+                    if (future.isCancelled()) {
+                        if (bitmap != null) bitmap.recycle();
+                        return;
+                    }
+                    mMainHandler.sendMessage(mMainHandler.obtainMessage(
+                            MSG_BITMAP, bitmap));
+                }
+            });
+        }
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        if (mState == STATE_INIT) initializeData();
+        if (mState == STATE_SAVING) onSaveClicked();
+
+        // TODO: consider to do it in GLView system
+        GLRoot root = getGLRoot();
+        root.lockRenderThread();
+        try {
+            mCropView.resume();
+        } finally {
+            root.unlockRenderThread();
+        }
+    }
+
+    @Override
+    protected void onPause() {
+        super.onPause();
+
+        Future<BitmapRegionDecoder> loadTask = mLoadTask;
+        if (loadTask != null && !loadTask.isDone()) {
+            // load in progress, try to cancel it
+            loadTask.cancel();
+            loadTask.waitDone();
+            mProgressDialog.dismiss();
+        }
+
+        Future<Bitmap> loadBitmapTask = mLoadBitmapTask;
+        if (loadBitmapTask != null && !loadBitmapTask.isDone()) {
+            // load in progress, try to cancel it
+            loadBitmapTask.cancel();
+            loadBitmapTask.waitDone();
+            mProgressDialog.dismiss();
+        }
+
+        Future<Intent> saveTask = mSaveTask;
+        if (saveTask != null && !saveTask.isDone()) {
+            // save in progress, try to cancel it
+            saveTask.cancel();
+            saveTask.waitDone();
+            mProgressDialog.dismiss();
+        }
+        GLRoot root = getGLRoot();
+        root.lockRenderThread();
+        try {
+            mCropView.pause();
+        } finally {
+            root.unlockRenderThread();
+        }
+    }
+
+    private MediaItem getMediaItemFromIntentData() {
+        Uri uri = getIntent().getData();
+        DataManager manager = getDataManager();
+        if (uri == null) {
+            Log.w(TAG, "no data given");
+            return null;
+        }
+        Path path = manager.findPathByUri(uri);
+        if (path == null) {
+            Log.w(TAG, "cannot get path for: " + uri);
+            return null;
+        }
+        return (MediaItem) manager.getMediaObject(path);
+    }
+
+    private class LoadDataTask implements Job<BitmapRegionDecoder> {
+        MediaItem mItem;
+
+        public LoadDataTask(MediaItem item) {
+            mItem = item;
+        }
+
+        public BitmapRegionDecoder run(JobContext jc) {
+            return mItem == null ? null : mItem.requestLargeImage().run(jc);
+        }
+    }
+
+    private class LoadBitmapDataTask implements Job<Bitmap> {
+        MediaItem mItem;
+
+        public LoadBitmapDataTask(MediaItem item) {
+            mItem = item;
+        }
+        public Bitmap run(JobContext jc) {
+            return mItem == null
+                    ? null
+                    : mItem.requestImage(MediaItem.TYPE_THUMBNAIL).run(jc);
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/app/DialogPicker.java b/src/com/android/gallery3d/app/DialogPicker.java
new file mode 100644
index 0000000..ebfc521
--- /dev/null
+++ b/src/com/android/gallery3d/app/DialogPicker.java
@@ -0,0 +1,68 @@
+/*
+ * 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.R;
+import com.android.gallery3d.ui.GLRoot;
+import com.android.gallery3d.ui.GLRootView;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.view.View.OnClickListener;
+
+public class DialogPicker extends AbstractGalleryActivity
+        implements OnClickListener {
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        setContentView(R.layout.dialog_picker);
+        ((GLRootView) findViewById(R.id.gl_root_view)).setZOrderOnTop(true);
+        findViewById(R.id.cancel).setOnClickListener(this);
+
+        int typeBits = GalleryUtils.determineTypeBits(this, getIntent());
+        setTitle(GalleryUtils.getSelectionModePrompt(typeBits));
+        Intent intent = getIntent();
+        Bundle extras = intent.getExtras();
+        Bundle data = extras == null ? new Bundle() : new Bundle(extras);
+
+        data.putBoolean(Gallery.KEY_GET_CONTENT, true);
+        data.putString(AlbumSetPage.KEY_MEDIA_PATH,
+                getDataManager().getTopSetPath(typeBits));
+        getStateManager().startState(AlbumSetPage.class, data);
+    }
+
+    @Override
+    public void onBackPressed() {
+        // send the back event to the top sub-state
+        GLRoot root = getGLRoot();
+        root.lockRenderThread();
+        try {
+            getStateManager().getTopState().onBackPressed();
+        } finally {
+            root.unlockRenderThread();
+        }
+    }
+
+    @Override
+    public void onClick(View v) {
+        if (v.getId() == R.id.cancel) finish();
+    }
+}
diff --git a/src/com/android/gallery3d/app/EyePosition.java b/src/com/android/gallery3d/app/EyePosition.java
new file mode 100644
index 0000000..1c3aa60
--- /dev/null
+++ b/src/com/android/gallery3d/app/EyePosition.java
@@ -0,0 +1,218 @@
+/*
+ * 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.util.GalleryUtils;
+
+import android.content.Context;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.os.SystemClock;
+import android.view.Display;
+import android.view.Surface;
+import android.view.WindowManager;
+
+public class EyePosition {
+    private static final String TAG = "EyePosition";
+
+    public interface EyePositionListener {
+        public void onEyePositionChanged(float x, float y, float z);
+    }
+
+    private static final float GYROSCOPE_THRESHOLD = 0.15f;
+    private static final float GYROSCOPE_LIMIT = 10f;
+    private static final int GYROSCOPE_SETTLE_DOWN = 15;
+    private static final float GYROSCOPE_RESTORE_FACTOR = 0.995f;
+
+    private static final double USER_ANGEL = Math.toRadians(10);
+    private static final float USER_ANGEL_COS = (float) Math.cos(USER_ANGEL);
+    private static final float USER_ANGEL_SIN = (float) Math.sin(USER_ANGEL);
+    private static final float MAX_VIEW_RANGE = (float) 0.5;
+    private static final int NOT_STARTED = -1;
+
+    private static final float USER_DISTANCE_METER = 0.3f;
+
+    private Context mContext;
+    private EyePositionListener mListener;
+    private Display mDisplay;
+    // The eyes' position of the user, the origin is at the center of the
+    // device and the unit is in pixels.
+    private float mX;
+    private float mY;
+    private float mZ;
+
+    private final float mUserDistance; // in pixel
+    private final float mLimit;
+    private long mStartTime = NOT_STARTED;
+    private Sensor mSensor;
+    private PositionListener mPositionListener = new PositionListener();
+
+    private int mGyroscopeCountdown = 0;
+
+    public EyePosition(Context context, EyePositionListener listener) {
+        mContext = context;
+        mListener = listener;
+        mUserDistance = GalleryUtils.meterToPixel(USER_DISTANCE_METER);
+        mLimit = mUserDistance * MAX_VIEW_RANGE;
+
+        WindowManager wManager = (WindowManager) mContext
+                .getSystemService(Context.WINDOW_SERVICE);
+        mDisplay = wManager.getDefaultDisplay();
+
+        SensorManager sManager = (SensorManager) mContext
+                .getSystemService(Context.SENSOR_SERVICE);
+        mSensor = sManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
+        if (mSensor == null) {
+            Log.w(TAG, "no gyroscope, use accelerometer instead");
+            mSensor = sManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
+        }
+        if (mSensor == null) {
+            Log.w(TAG, "no sensor available");
+        }
+    }
+
+    public void resetPosition() {
+        mStartTime = NOT_STARTED;
+        mX = mY = 0;
+        mZ = -mUserDistance;
+        mListener.onEyePositionChanged(mX, mY, mZ);
+    }
+
+    /*
+     * We assume the user is at the following position
+     *
+     *              /|\  user's eye
+     *               |   /
+     *   -G(gravity) |  /
+     *               |_/
+     *             / |/_____\ -Y (-y direction of device)
+     *     user angel
+     */
+    private void onAccelerometerChanged(float gx, float gy, float gz) {
+
+        float x = gx, y = gy, z = gz;
+
+        switch (mDisplay.getRotation()) {
+            case Surface.ROTATION_90: x = -gy; y= gx; break;
+            case Surface.ROTATION_180: x = -gx; y = -gy; break;
+            case Surface.ROTATION_270: x = gy; y = -gx; break;
+        }
+
+        float temp = x * x + y * y + z * z;
+        float t = -y /temp;
+
+        float tx = t * x;
+        float ty = -1 + t * y;
+        float tz = t * z;
+
+        float length = (float) Math.sqrt(tx * tx + ty * ty + tz * tz);
+        float glength = (float) Math.sqrt(temp);
+
+        mX = Utils.clamp((x * USER_ANGEL_COS / glength
+                + tx * USER_ANGEL_SIN / length) * mUserDistance,
+                -mLimit, mLimit);
+        mY = -Utils.clamp((y * USER_ANGEL_COS / glength
+                + ty * USER_ANGEL_SIN / length) * mUserDistance,
+                -mLimit, mLimit);
+        mZ = (float) -Math.sqrt(
+                mUserDistance * mUserDistance - mX * mX - mY * mY);
+        mListener.onEyePositionChanged(mX, mY, mZ);
+    }
+
+    private void onGyroscopeChanged(float gx, float gy, float gz) {
+        long now = SystemClock.elapsedRealtime();
+        float distance = (gx > 0 ? gx : -gx) + (gy > 0 ? gy : - gy);
+        if (distance < GYROSCOPE_THRESHOLD
+                || distance > GYROSCOPE_LIMIT || mGyroscopeCountdown > 0) {
+            --mGyroscopeCountdown;
+            mStartTime = now;
+            float limit = mUserDistance / 20f;
+            if (mX > limit || mX < -limit || mY > limit || mY < -limit) {
+                mX *= GYROSCOPE_RESTORE_FACTOR;
+                mY *= GYROSCOPE_RESTORE_FACTOR;
+                mZ = (float) -Math.sqrt(
+                        mUserDistance * mUserDistance - mX * mX - mY * mY);
+                mListener.onEyePositionChanged(mX, mY, mZ);
+            }
+            return;
+        }
+
+        float t = (now - mStartTime) / 1000f * mUserDistance * (-mZ);
+        mStartTime = now;
+
+        float x = -gy, y = -gx;
+        switch (mDisplay.getRotation()) {
+            case Surface.ROTATION_90: x = -gx; y= gy; break;
+            case Surface.ROTATION_180: x = gy; y = gx; break;
+            case Surface.ROTATION_270: x = gx; y = -gy; break;
+        }
+
+        mX = Utils.clamp((float) (mX + x * t / Math.hypot(mZ, mX)),
+                -mLimit, mLimit) * GYROSCOPE_RESTORE_FACTOR;
+        mY = Utils.clamp((float) (mY + y * t / Math.hypot(mZ, mY)),
+                -mLimit, mLimit) * GYROSCOPE_RESTORE_FACTOR;
+
+        mZ = (float) -Math.sqrt(
+                mUserDistance * mUserDistance - mX * mX - mY * mY);
+        mListener.onEyePositionChanged(mX, mY, mZ);
+    }
+
+    private class PositionListener implements SensorEventListener {
+        public void onAccuracyChanged(Sensor sensor, int accuracy) {
+        }
+
+        public void onSensorChanged(SensorEvent event) {
+            switch (event.sensor.getType()) {
+                case Sensor.TYPE_GYROSCOPE: {
+                    onGyroscopeChanged(
+                            event.values[0], event.values[1], event.values[2]);
+                    break;
+                }
+                case Sensor.TYPE_ACCELEROMETER: {
+                    onAccelerometerChanged(
+                            event.values[0], event.values[1], event.values[2]);
+                }
+            }
+        }
+    }
+
+    public void pause() {
+        if (mSensor != null) {
+            SensorManager sManager = (SensorManager) mContext
+                    .getSystemService(Context.SENSOR_SERVICE);
+            sManager.unregisterListener(mPositionListener);
+        }
+    }
+
+    public void resume() {
+        if (mSensor != null) {
+            SensorManager sManager = (SensorManager) mContext
+                    .getSystemService(Context.SENSOR_SERVICE);
+            sManager.registerListener(mPositionListener,
+                    mSensor, SensorManager.SENSOR_DELAY_GAME);
+        }
+
+        mStartTime = NOT_STARTED;
+        mGyroscopeCountdown = GYROSCOPE_SETTLE_DOWN;
+        mX = mY = 0;
+        mZ = -mUserDistance;
+        mListener.onEyePositionChanged(mX, mY, mZ);
+    }
+}
diff --git a/src/com/android/gallery3d/app/FilterUtils.java b/src/com/android/gallery3d/app/FilterUtils.java
new file mode 100644
index 0000000..9b8ea2d
--- /dev/null
+++ b/src/com/android/gallery3d/app/FilterUtils.java
@@ -0,0 +1,296 @@
+/*
+ * 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.R;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.data.Path;
+
+// This class handles filtering and clustering.
+//
+// We allow at most only one filter operation at a time (Currently it
+// doesn't make sense to use more than one). Also each clustering operation
+// can be applied at most once. In addition, there is one more constraint
+// ("fixed set constraint") described below.
+//
+// A clustered album (not including album set) and its base sets are fixed.
+// For example,
+//
+// /cluster/{base_set}/time/7
+//
+// This set and all sets inside base_set (recursively) are fixed because
+// 1. We can not change this set to use another clustering condition (like
+//    changing "time" to "location").
+// 2. Neither can we change any set in the base_set.
+// The reason is in both cases the 7th set may not exist in the new clustering.
+// ---------------------
+// newPath operation: create a new path based on a source path and put an extra
+// condition on top of it:
+//
+// T = newFilterPath(S, filterType);
+// T = newClusterPath(S, clusterType);
+//
+// Similar functions can be used to replace the current condition (if there is one).
+//
+// T = switchFilterPath(S, filterType);
+// T = switchClusterPath(S, clusterType);
+//
+// For all fixed set in the path defined above, if some clusterType and
+// filterType are already used, they cannot not be used as parameter for these
+// functions. setupMenuItems() makes sure those types cannot be selected.
+//
+public class FilterUtils {
+    private static final String TAG = "FilterUtils";
+
+    public static final int CLUSTER_BY_ALBUM = 1;
+    public static final int CLUSTER_BY_TIME = 2;
+    public static final int CLUSTER_BY_LOCATION = 4;
+    public static final int CLUSTER_BY_TAG = 8;
+    public static final int CLUSTER_BY_SIZE = 16;
+    public static final int CLUSTER_BY_FACE = 32;
+
+    public static final int FILTER_IMAGE_ONLY = 1;
+    public static final int FILTER_VIDEO_ONLY = 2;
+    public static final int FILTER_ALL = 4;
+
+    // These are indices of the return values of getAppliedFilters().
+    // The _F suffix means "fixed".
+    private static final int CLUSTER_TYPE = 0;
+    private static final int FILTER_TYPE = 1;
+    private static final int CLUSTER_TYPE_F = 2;
+    private static final int FILTER_TYPE_F = 3;
+    private static final int CLUSTER_CURRENT_TYPE = 4;
+    private static final int FILTER_CURRENT_TYPE = 5;
+
+    public static void setupMenuItems(GalleryActionBar model, Path path, boolean inAlbum) {
+        int[] result = new int[6];
+        getAppliedFilters(path, result);
+        int ctype = result[CLUSTER_TYPE];
+        int ftype = result[FILTER_TYPE];
+        int ftypef = result[FILTER_TYPE_F];
+        int ccurrent = result[CLUSTER_CURRENT_TYPE];
+        int fcurrent = result[FILTER_CURRENT_TYPE];
+
+        setMenuItemApplied(model, CLUSTER_BY_TIME,
+                (ctype & CLUSTER_BY_TIME) != 0, (ccurrent & CLUSTER_BY_TIME) != 0);
+        setMenuItemApplied(model, CLUSTER_BY_LOCATION,
+                (ctype & CLUSTER_BY_LOCATION) != 0, (ccurrent & CLUSTER_BY_LOCATION) != 0);
+        setMenuItemApplied(model, CLUSTER_BY_TAG,
+                (ctype & CLUSTER_BY_TAG) != 0, (ccurrent & CLUSTER_BY_TAG) != 0);
+        setMenuItemApplied(model, CLUSTER_BY_FACE,
+                (ctype & CLUSTER_BY_FACE) != 0, (ccurrent & CLUSTER_BY_FACE) != 0);
+
+        model.setClusterItemVisibility(CLUSTER_BY_ALBUM, !inAlbum || ctype == 0);
+
+        setMenuItemApplied(model, R.id.action_cluster_album, ctype == 0,
+                ccurrent == 0);
+
+        // A filtering is available if it's not applied, and the old filtering
+        // (if any) is not fixed.
+        setMenuItemAppliedEnabled(model, R.string.show_images_only,
+                (ftype & FILTER_IMAGE_ONLY) != 0,
+                (ftype & FILTER_IMAGE_ONLY) == 0 && ftypef == 0,
+                (fcurrent & FILTER_IMAGE_ONLY) != 0);
+        setMenuItemAppliedEnabled(model, R.string.show_videos_only,
+                (ftype & FILTER_VIDEO_ONLY) != 0,
+                (ftype & FILTER_VIDEO_ONLY) == 0 && ftypef == 0,
+                (fcurrent & FILTER_VIDEO_ONLY) != 0);
+        setMenuItemAppliedEnabled(model, R.string.show_all,
+                ftype == 0, ftype != 0 && ftypef == 0, fcurrent == 0);
+    }
+
+    // Gets the filters applied in the path.
+    private static void getAppliedFilters(Path path, int[] result) {
+        getAppliedFilters(path, result, false);
+    }
+
+    private static void getAppliedFilters(Path path, int[] result, boolean underCluster) {
+        String[] segments = path.split();
+        // Recurse into sub media sets.
+        for (int i = 0; i < segments.length; i++) {
+            if (segments[i].startsWith("{")) {
+                String[] sets = Path.splitSequence(segments[i]);
+                for (int j = 0; j < sets.length; j++) {
+                    Path sub = Path.fromString(sets[j]);
+                    getAppliedFilters(sub, result, underCluster);
+                }
+            }
+        }
+
+        // update current selection
+        if (segments[0].equals("cluster")) {
+            // if this is a clustered album, set underCluster to true.
+            if (segments.length == 4) {
+                underCluster = true;
+            }
+
+            int ctype = toClusterType(segments[2]);
+            result[CLUSTER_TYPE] |= ctype;
+            result[CLUSTER_CURRENT_TYPE] = ctype;
+            if (underCluster) {
+                result[CLUSTER_TYPE_F] |= ctype;
+            }
+        }
+    }
+
+    private static int toClusterType(String s) {
+        if (s.equals("time")) {
+            return CLUSTER_BY_TIME;
+        } else if (s.equals("location")) {
+            return CLUSTER_BY_LOCATION;
+        } else if (s.equals("tag")) {
+            return CLUSTER_BY_TAG;
+        } else if (s.equals("size")) {
+            return CLUSTER_BY_SIZE;
+        } else if (s.equals("face")) {
+            return CLUSTER_BY_FACE;
+        }
+        return 0;
+    }
+
+    private static void setMenuItemApplied(
+            GalleryActionBar model, int id, boolean applied, boolean updateTitle) {
+        model.setClusterItemEnabled(id, !applied);
+    }
+
+    private static void setMenuItemAppliedEnabled(GalleryActionBar model, int id, boolean applied, boolean enabled, boolean updateTitle) {
+        model.setClusterItemEnabled(id, enabled);
+    }
+
+    // Add a specified filter to the path.
+    public static String newFilterPath(String base, int filterType) {
+        int mediaType;
+        switch (filterType) {
+            case FILTER_IMAGE_ONLY:
+                mediaType = MediaObject.MEDIA_TYPE_IMAGE;
+                break;
+            case FILTER_VIDEO_ONLY:
+                mediaType = MediaObject.MEDIA_TYPE_VIDEO;
+                break;
+            default:  /* FILTER_ALL */
+                return base;
+        }
+
+        return "/filter/mediatype/" + mediaType + "/{" + base + "}";
+    }
+
+    // Add a specified clustering to the path.
+    public static String newClusterPath(String base, int clusterType) {
+        String kind;
+        switch (clusterType) {
+            case CLUSTER_BY_TIME:
+                kind = "time";
+                break;
+            case CLUSTER_BY_LOCATION:
+                kind = "location";
+                break;
+            case CLUSTER_BY_TAG:
+                kind = "tag";
+                break;
+            case CLUSTER_BY_SIZE:
+                kind = "size";
+                break;
+            case CLUSTER_BY_FACE:
+                kind = "face";
+                break;
+            default: /* CLUSTER_BY_ALBUM */
+                return base;
+        }
+
+        return "/cluster/{" + base + "}/" + kind;
+    }
+
+    // Change the topmost filter to the specified type.
+    public static String switchFilterPath(String base, int filterType) {
+        return newFilterPath(removeOneFilterFromPath(base), filterType);
+    }
+
+    // Change the topmost clustering to the specified type.
+    public static String switchClusterPath(String base, int clusterType) {
+        return newClusterPath(removeOneClusterFromPath(base), clusterType);
+    }
+
+    // Remove the topmost clustering (if any) from the path.
+    private static String removeOneClusterFromPath(String base) {
+        boolean[] done = new boolean[1];
+        return removeOneClusterFromPath(base, done);
+    }
+
+    private static String removeOneClusterFromPath(String base, boolean[] done) {
+        if (done[0]) return base;
+
+        String[] segments = Path.split(base);
+        if (segments[0].equals("cluster")) {
+            done[0] = true;
+            return Path.splitSequence(segments[1])[0];
+        }
+
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < segments.length; i++) {
+            sb.append("/");
+            if (segments[i].startsWith("{")) {
+                sb.append("{");
+                String[] sets = Path.splitSequence(segments[i]);
+                for (int j = 0; j < sets.length; j++) {
+                    if (j > 0) {
+                        sb.append(",");
+                    }
+                    sb.append(removeOneClusterFromPath(sets[j], done));
+                }
+                sb.append("}");
+            } else {
+                sb.append(segments[i]);
+            }
+        }
+        return sb.toString();
+    }
+
+    // Remove the topmost filter (if any) from the path.
+    private static String removeOneFilterFromPath(String base) {
+        boolean[] done = new boolean[1];
+        return removeOneFilterFromPath(base, done);
+    }
+
+    private static String removeOneFilterFromPath(String base, boolean[] done) {
+        if (done[0]) return base;
+
+        String[] segments = Path.split(base);
+        if (segments[0].equals("filter") && segments[1].equals("mediatype")) {
+            done[0] = true;
+            return Path.splitSequence(segments[3])[0];
+        }
+
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < segments.length; i++) {
+            sb.append("/");
+            if (segments[i].startsWith("{")) {
+                sb.append("{");
+                String[] sets = Path.splitSequence(segments[i]);
+                for (int j = 0; j < sets.length; j++) {
+                    if (j > 0) {
+                        sb.append(",");
+                    }
+                    sb.append(removeOneFilterFromPath(sets[j], done));
+                }
+                sb.append("}");
+            } else {
+                sb.append(segments[i]);
+            }
+        }
+        return sb.toString();
+    }
+}
diff --git a/src/com/android/gallery3d/app/Gallery.java b/src/com/android/gallery3d/app/Gallery.java
new file mode 100644
index 0000000..2c5263b
--- /dev/null
+++ b/src/com/android/gallery3d/app/Gallery.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.app;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.MediaSet;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.ui.GLRoot;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.app.ActionBar;
+import android.content.ContentResolver;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.Window;
+import android.widget.Toast;
+
+public final class Gallery extends AbstractGalleryActivity {
+    public static final String EXTRA_SLIDESHOW = "slideshow";
+    public static final String EXTRA_CROP = "crop";
+
+    public static final String ACTION_REVIEW = "com.android.camera.action.REVIEW";
+    public static final String KEY_GET_CONTENT = "get-content";
+    public static final String KEY_GET_ALBUM = "get-album";
+    public static final String KEY_TYPE_BITS = "type-bits";
+    public static final String KEY_MEDIA_TYPES = "mediaTypes";
+
+    private static final String TAG = "Gallery";
+    private GalleryActionBar mActionBar;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        requestWindowFeature(Window.FEATURE_ACTION_BAR);
+        requestWindowFeature(Window.FEATURE_ACTION_BAR_OVERLAY);
+        requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
+
+        setContentView(R.layout.main);
+        mActionBar = new GalleryActionBar(this);
+
+        if (savedInstanceState != null) {
+            getStateManager().restoreFromState(savedInstanceState);
+        } else {
+            initializeByIntent();
+        }
+    }
+
+    private void initializeByIntent() {
+        Intent intent = getIntent();
+        String action = intent.getAction();
+
+        if (Intent.ACTION_GET_CONTENT.equalsIgnoreCase(action)) {
+            startGetContent(intent);
+        } else if (Intent.ACTION_PICK.equalsIgnoreCase(action)) {
+            // We do NOT really support the PICK intent. Handle it as
+            // the GET_CONTENT. However, we need to translate the type
+            // in the intent here.
+            Log.w(TAG, "action PICK is not supported");
+            String type = Utils.ensureNotNull(intent.getType());
+            if (type.startsWith("vnd.android.cursor.dir/")) {
+                if (type.endsWith("/image")) intent.setType("image/*");
+                if (type.endsWith("/video")) intent.setType("video/*");
+            }
+            startGetContent(intent);
+        } else if (Intent.ACTION_VIEW.equalsIgnoreCase(action)
+                || ACTION_REVIEW.equalsIgnoreCase(action)){
+            startViewAction(intent);
+        } else {
+            startDefaultPage();
+        }
+    }
+
+    public void startDefaultPage() {
+        Bundle data = new Bundle();
+        data.putString(AlbumSetPage.KEY_MEDIA_PATH,
+                getDataManager().getTopSetPath(DataManager.INCLUDE_ALL));
+        getStateManager().startState(AlbumSetPage.class, data);
+    }
+
+    private void startGetContent(Intent intent) {
+        Bundle data = intent.getExtras() != null
+                ? new Bundle(intent.getExtras())
+                : new Bundle();
+        data.putBoolean(KEY_GET_CONTENT, true);
+        int typeBits = GalleryUtils.determineTypeBits(this, intent);
+        data.putInt(KEY_TYPE_BITS, typeBits);
+        data.putString(AlbumSetPage.KEY_MEDIA_PATH,
+                getDataManager().getTopSetPath(typeBits));
+        getStateManager().startState(AlbumSetPage.class, data);
+    }
+
+    private String getContentType(Intent intent) {
+        String type = intent.getType();
+        if (type != null) return type;
+
+        Uri uri = intent.getData();
+        try {
+            return getContentResolver().getType(uri);
+        } catch (Throwable t) {
+            Log.w(TAG, "get type fail", t);
+            return null;
+        }
+    }
+
+    private void startViewAction(Intent intent) {
+        Boolean slideshow = intent.getBooleanExtra(EXTRA_SLIDESHOW, false);
+        if (slideshow) {
+            getActionBar().hide();
+            DataManager manager = getDataManager();
+            Path path = manager.findPathByUri(intent.getData());
+            if (path == null || manager.getMediaObject(path)
+                    instanceof MediaItem) {
+                path = Path.fromString(
+                        manager.getTopSetPath(DataManager.INCLUDE_IMAGE));
+            }
+            Bundle data = new Bundle();
+            data.putString(SlideshowPage.KEY_SET_PATH, path.toString());
+            data.putBoolean(SlideshowPage.KEY_RANDOM_ORDER, true);
+            data.putBoolean(SlideshowPage.KEY_REPEAT, true);
+            getStateManager().startState(SlideshowPage.class, data);
+        } else {
+            Bundle data = new Bundle();
+            DataManager dm = getDataManager();
+            Uri uri = intent.getData();
+            String contentType = getContentType(intent);
+            if (contentType == null) {
+                Toast.makeText(this,
+                        R.string.no_such_item, Toast.LENGTH_LONG).show();
+                finish();
+                return;
+            }
+            if (contentType.startsWith(
+                    ContentResolver.CURSOR_DIR_BASE_TYPE)) {
+                int mediaType = intent.getIntExtra(KEY_MEDIA_TYPES, 0);
+                if (mediaType != 0) {
+                    uri = uri.buildUpon().appendQueryParameter(
+                            KEY_MEDIA_TYPES, String.valueOf(mediaType))
+                            .build();
+                }
+                Path albumPath = dm.findPathByUri(uri);
+                if (albumPath != null) {
+                    MediaSet mediaSet = (MediaSet) dm.getMediaObject(albumPath);
+                    data.putString(AlbumPage.KEY_MEDIA_PATH, albumPath.toString());
+                    getStateManager().startState(AlbumPage.class, data);
+                } else {
+                    startDefaultPage();
+                }
+            } else {
+                Path itemPath = dm.findPathByUri(uri);
+                Path albumPath = dm.getDefaultSetOf(itemPath);
+                if (albumPath != null) {
+                    data.putString(PhotoPage.KEY_MEDIA_SET_PATH,
+                            albumPath.toString());
+                }
+                data.putString(PhotoPage.KEY_MEDIA_ITEM_PATH, itemPath.toString());
+                getStateManager().startState(PhotoPage.class, data);
+            }
+        }
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        super.onCreateOptionsMenu(menu);
+        return getStateManager().createOptionsMenu(menu);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        GLRoot root = getGLRoot();
+        root.lockRenderThread();
+        try {
+            return getStateManager().itemSelected(item);
+        } finally {
+            root.unlockRenderThread();
+        }
+    }
+
+    @Override
+    public void onBackPressed() {
+        // send the back event to the top sub-state
+        GLRoot root = getGLRoot();
+        root.lockRenderThread();
+        try {
+            getStateManager().onBackPressed();
+        } finally {
+            root.unlockRenderThread();
+        }
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        GLRoot root = getGLRoot();
+        root.lockRenderThread();
+        try {
+            getStateManager().destroy();
+        } finally {
+            root.unlockRenderThread();
+        }
+    }
+
+    @Override
+    protected void onResume() {
+        Utils.assertTrue(getStateManager().getStateCount() > 0);
+        super.onResume();
+    }
+
+    @Override
+    public GalleryActionBar getGalleryActionBar() {
+        return mActionBar;
+    }
+}
diff --git a/src/com/android/gallery3d/app/GalleryActionBar.java b/src/com/android/gallery3d/app/GalleryActionBar.java
new file mode 100644
index 0000000..b9b59ee
--- /dev/null
+++ b/src/com/android/gallery3d/app/GalleryActionBar.java
@@ -0,0 +1,218 @@
+/*
+ * 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.app;
+
+import java.util.ArrayList;
+
+import com.android.gallery3d.R;
+
+import android.app.ActionBar;
+import android.app.ActionBar.Tab;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.FragmentTransaction;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.ShareActionProvider;
+
+public class GalleryActionBar implements ActionBar.TabListener {
+    private static final String TAG = "GalleryActionBar";
+
+    public interface ClusterRunner {
+        public void doCluster(int id);
+    }
+
+    private static class ActionItem {
+        public int action;
+        public boolean enabled;
+        public boolean visible;
+        public int tabTitle;
+        public int dialogTitle;
+        public int clusterBy;
+
+        public ActionItem(int action, boolean applied, boolean enabled, int title,
+                int clusterBy) {
+            this(action, applied, enabled, title, title, clusterBy);
+        }
+
+        public ActionItem(int action, boolean applied, boolean enabled, int tabTitle,
+                int dialogTitle, int clusterBy) {
+            this.action = action;
+            this.enabled = enabled;
+            this.tabTitle = tabTitle;
+            this.dialogTitle = dialogTitle;
+            this.clusterBy = clusterBy;
+            this.visible = true;
+        }
+    }
+
+    private static final ActionItem[] sClusterItems = new ActionItem[] {
+        new ActionItem(FilterUtils.CLUSTER_BY_ALBUM, true, false, R.string.albums,
+                R.string.group_by_album),
+        new ActionItem(FilterUtils.CLUSTER_BY_LOCATION, true, false,
+                R.string.locations, R.string.location, R.string.group_by_location),
+        new ActionItem(FilterUtils.CLUSTER_BY_TIME, true, false, R.string.times,
+                R.string.time, R.string.group_by_time),
+        new ActionItem(FilterUtils.CLUSTER_BY_FACE, true, false, R.string.people,
+                R.string.group_by_faces),
+        new ActionItem(FilterUtils.CLUSTER_BY_TAG, true, false, R.string.tags,
+                R.string.group_by_tags)
+    };
+
+    private ClusterRunner mClusterRunner;
+    private CharSequence[] mTitles;
+    private ArrayList<Integer> mActions;
+    private Context mContext;
+    private ActionBar mActionBar;
+    // We need this because ActionBar.getSelectedTab() doesn't work when
+    // ActionBar is hidden.
+    private Tab mCurrentTab;
+
+    public GalleryActionBar(Activity activity) {
+        mActionBar = activity.getActionBar();
+        mContext = activity;
+
+        for (ActionItem item : sClusterItems) {
+            mActionBar.addTab(mActionBar.newTab().setText(item.tabTitle).
+                    setTag(item).setTabListener(this));
+        }
+    }
+
+    public static int getHeight(Activity activity) {
+        ActionBar actionBar = activity.getActionBar();
+        return actionBar != null ? actionBar.getHeight() : 0;
+    }
+
+    private void createDialogData() {
+        ArrayList<CharSequence> titles = new ArrayList<CharSequence>();
+        mActions = new ArrayList<Integer>();
+        for (ActionItem item : sClusterItems) {
+            if (item.enabled && item.visible) {
+                titles.add(mContext.getString(item.dialogTitle));
+                mActions.add(item.action);
+            }
+        }
+        mTitles = new CharSequence[titles.size()];
+        titles.toArray(mTitles);
+    }
+
+    public void setClusterItemEnabled(int id, boolean enabled) {
+        for (ActionItem item : sClusterItems) {
+            if (item.action == id) {
+                item.enabled = enabled;
+                return;
+            }
+        }
+    }
+
+    public void setClusterItemVisibility(int id, boolean visible) {
+        for (ActionItem item : sClusterItems) {
+            if (item.action == id) {
+                item.visible = visible;
+                return;
+            }
+        }
+    }
+
+    public int getClusterTypeAction() {
+        if (mCurrentTab != null) {
+            ActionItem item = (ActionItem) mCurrentTab.getTag();
+            return item.action;
+        }
+        // By default, it's group-by-album
+        return FilterUtils.CLUSTER_BY_ALBUM;
+    }
+
+    public static String getClusterByTypeString(Context context, int type) {
+        for (ActionItem item : sClusterItems) {
+            if (item.action == type) {
+                return context.getString(item.clusterBy);
+            }
+        }
+        return null;
+    }
+
+    public static ShareActionProvider initializeShareActionProvider(Menu menu) {
+        MenuItem item = menu.findItem(R.id.action_share);
+        ShareActionProvider shareActionProvider = null;
+        if (item != null) {
+            shareActionProvider = (ShareActionProvider) item.getActionProvider();
+            shareActionProvider.setShareHistoryFileName(
+                    ShareActionProvider.DEFAULT_SHARE_HISTORY_FILE_NAME);
+        }
+        return shareActionProvider;
+    }
+
+    public void showClusterTabs(ClusterRunner runner) {
+        mActionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS);
+        mClusterRunner = runner;
+    }
+
+    public void hideClusterTabs() {
+        mActionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
+        mClusterRunner = null;
+    }
+
+    public void showClusterDialog(final ClusterRunner clusterRunner) {
+        createDialogData();
+        final ArrayList<Integer> actions = mActions;
+        new AlertDialog.Builder(mContext).setTitle(R.string.group_by).setItems(
+                mTitles, new DialogInterface.OnClickListener() {
+            public void onClick(DialogInterface dialog, int which) {
+                clusterRunner.doCluster(actions.get(which).intValue());
+            }
+        }).create().show();
+    }
+
+    public void setTitle(String title) {
+        if (mActionBar != null) mActionBar.setTitle(title);
+    }
+
+    public void setTitle(int titleId) {
+        if (mActionBar != null) mActionBar.setTitle(titleId);
+    }
+
+    public void setSubtitle(String title) {
+        if (mActionBar != null) mActionBar.setSubtitle(title);
+    }
+
+    public void setNavigationMode(int mode) {
+        if (mActionBar != null) mActionBar.setNavigationMode(mode);
+    }
+
+    public int getHeight() {
+        return mActionBar == null ? 0 : mActionBar.getHeight();
+    }
+
+    @Override
+    public void onTabSelected(Tab tab, FragmentTransaction ft) {
+        if (mCurrentTab == tab) return;
+        mCurrentTab = tab;
+        ActionItem item = (ActionItem) tab.getTag();
+        if (mClusterRunner != null) mClusterRunner.doCluster(item.action);
+    }
+
+    @Override
+    public void onTabUnselected(Tab tab, FragmentTransaction ft) {
+    }
+
+    @Override
+    public void onTabReselected(Tab tab, FragmentTransaction ft) {
+    }
+}
diff --git a/src/com/android/gallery3d/app/GalleryActivity.java b/src/com/android/gallery3d/app/GalleryActivity.java
new file mode 100644
index 0000000..02f2f72
--- /dev/null
+++ b/src/com/android/gallery3d/app/GalleryActivity.java
@@ -0,0 +1,28 @@
+/*
+ * 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.ui.GLRoot;
+import com.android.gallery3d.ui.PositionRepository;
+
+public interface GalleryActivity extends GalleryContext {
+    public StateManager getStateManager();
+    public GLRoot getGLRoot();
+    public PositionRepository getPositionRepository();
+    public GalleryApp getGalleryApplication();
+    public GalleryActionBar getGalleryActionBar();
+}
diff --git a/src/com/android/gallery3d/app/GalleryApp.java b/src/com/android/gallery3d/app/GalleryApp.java
new file mode 100644
index 0000000..b3a305e
--- /dev/null
+++ b/src/com/android/gallery3d/app/GalleryApp.java
@@ -0,0 +1,39 @@
+/*
+ * 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.data.DataManager;
+import com.android.gallery3d.data.DownloadCache;
+import com.android.gallery3d.data.ImageCacheService;
+import com.android.gallery3d.util.ThreadPool;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.res.Resources;
+import android.os.Looper;
+
+public interface GalleryApp {
+    public DataManager getDataManager();
+    public ImageCacheService getImageCacheService();
+    public DownloadCache getDownloadCache();
+    public ThreadPool getThreadPool();
+
+    public Context getAndroidContext();
+    public Looper getMainLooper();
+    public ContentResolver getContentResolver();
+    public Resources getResources();
+}
diff --git a/src/com/android/gallery3d/app/GalleryAppImpl.java b/src/com/android/gallery3d/app/GalleryAppImpl.java
new file mode 100644
index 0000000..a11d920
--- /dev/null
+++ b/src/com/android/gallery3d/app/GalleryAppImpl.java
@@ -0,0 +1,90 @@
+/*
+ * 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.data.DataManager;
+import com.android.gallery3d.data.DownloadCache;
+import com.android.gallery3d.data.ImageCacheService;
+import com.android.gallery3d.picasasource.PicasaSource;
+import com.android.gallery3d.util.GalleryUtils;
+import com.android.gallery3d.util.ThreadPool;
+import com.android.gallery3d.widget.WidgetUtils;
+
+import android.app.Application;
+import android.content.Context;
+
+import java.io.File;
+
+public class GalleryAppImpl extends Application implements GalleryApp {
+
+    private static final String DOWNLOAD_FOLDER = "download";
+    private static final long DOWNLOAD_CAPACITY = 64 * 1024 * 1024; // 64M
+
+    private ImageCacheService mImageCacheService;
+    private DataManager mDataManager;
+    private ThreadPool mThreadPool;
+    private DownloadCache mDownloadCache;
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        GalleryUtils.initialize(this);
+        WidgetUtils.initialize(this);
+        PicasaSource.initialize(this);
+    }
+
+    public Context getAndroidContext() {
+        return this;
+    }
+
+    public synchronized DataManager getDataManager() {
+        if (mDataManager == null) {
+            mDataManager = new DataManager(this);
+            mDataManager.initializeSourceMap();
+        }
+        return mDataManager;
+    }
+
+    public synchronized ImageCacheService getImageCacheService() {
+        if (mImageCacheService == null) {
+            mImageCacheService = new ImageCacheService(getAndroidContext());
+        }
+        return mImageCacheService;
+    }
+
+    public synchronized ThreadPool getThreadPool() {
+        if (mThreadPool == null) {
+            mThreadPool = new ThreadPool();
+        }
+        return mThreadPool;
+    }
+
+    public synchronized DownloadCache getDownloadCache() {
+        if (mDownloadCache == null) {
+            File cacheDir = new File(getExternalCacheDir(), DOWNLOAD_FOLDER);
+
+            if (!cacheDir.isDirectory()) cacheDir.mkdirs();
+
+            if (!cacheDir.isDirectory()) {
+                throw new RuntimeException(
+                        "fail to create: " + cacheDir.getAbsolutePath());
+            }
+            mDownloadCache = new DownloadCache(this, cacheDir, DOWNLOAD_CAPACITY);
+        }
+        return mDownloadCache;
+    }
+}
diff --git a/src/com/android/gallery3d/app/GalleryContext.java b/src/com/android/gallery3d/app/GalleryContext.java
new file mode 100644
index 0000000..022b4a7
--- /dev/null
+++ b/src/com/android/gallery3d/app/GalleryContext.java
@@ -0,0 +1,38 @@
+/*
+ * 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.data.DataManager;
+import com.android.gallery3d.data.ImageCacheService;
+import com.android.gallery3d.util.ThreadPool;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.res.Resources;
+import android.os.Looper;
+
+public interface GalleryContext {
+    public ImageCacheService getImageCacheService();
+    public DataManager getDataManager();
+
+    public Context getAndroidContext();
+
+    public Looper getMainLooper();
+    public Resources getResources();
+    public ContentResolver getContentResolver();
+    public ThreadPool getThreadPool();
+}
diff --git a/src/com/android/gallery3d/app/LoadingListener.java b/src/com/android/gallery3d/app/LoadingListener.java
new file mode 100644
index 0000000..ecbd798
--- /dev/null
+++ b/src/com/android/gallery3d/app/LoadingListener.java
@@ -0,0 +1,22 @@
+/*
+ * 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;
+
+public interface LoadingListener {
+    public void onLoadingStarted();
+    public void onLoadingFinished();
+}
diff --git a/src/com/android/gallery3d/app/Log.java b/src/com/android/gallery3d/app/Log.java
new file mode 100644
index 0000000..07a8ea5
--- /dev/null
+++ b/src/com/android/gallery3d/app/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.app;
+
+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/app/ManageCachePage.java b/src/com/android/gallery3d/app/ManageCachePage.java
new file mode 100644
index 0000000..a0190db
--- /dev/null
+++ b/src/com/android/gallery3d/app/ManageCachePage.java
@@ -0,0 +1,271 @@
+/*
+ * 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.R;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.data.MediaSet;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.ui.AlbumSetView;
+import com.android.gallery3d.ui.CacheBarView;
+import com.android.gallery3d.ui.GLCanvas;
+import com.android.gallery3d.ui.GLView;
+import com.android.gallery3d.ui.ManageCacheDrawer;
+import com.android.gallery3d.ui.MenuExecutor;
+import com.android.gallery3d.ui.SelectionDrawer;
+import com.android.gallery3d.ui.SelectionManager;
+import com.android.gallery3d.ui.SlotView;
+import com.android.gallery3d.ui.StaticBackground;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.content.Context;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+
+public class ManageCachePage extends ActivityState implements
+        SelectionManager.SelectionListener, CacheBarView.Listener,
+        MenuExecutor.ProgressListener, EyePosition.EyePositionListener {
+    public static final String KEY_MEDIA_PATH = "media-path";
+    private static final String TAG = "ManageCachePage";
+
+    private static final float USER_DISTANCE_METER = 0.3f;
+    private static final int DATA_CACHE_SIZE = 256;
+
+    private StaticBackground mStaticBackground;
+    private AlbumSetView mAlbumSetView;
+
+    private MediaSet mMediaSet;
+
+    protected SelectionManager mSelectionManager;
+    protected SelectionDrawer mSelectionDrawer;
+    private AlbumSetDataAdapter mAlbumSetDataAdapter;
+    private float mUserDistance; // in pixel
+
+    private CacheBarView mCacheBar;
+
+    private EyePosition mEyePosition;
+
+    // The eyes' position of the user, the origin is at the center of the
+    // device and the unit is in pixels.
+    private float mX;
+    private float mY;
+    private float mZ;
+
+    private int mAlbumCountToMakeAvailableOffline;
+
+    private GLView mRootPane = new GLView() {
+        private float mMatrix[] = new float[16];
+
+        @Override
+        protected void onLayout(
+                boolean changed, int left, int top, int right, int bottom) {
+            mStaticBackground.layout(0, 0, right - left, bottom - top);
+            mEyePosition.resetPosition();
+
+            Config.ManageCachePage config = Config.ManageCachePage.get((Context) mActivity);
+
+            ActionBar actionBar = ((Activity) mActivity).getActionBar();
+            int slotViewTop = GalleryActionBar.getHeight((Activity) mActivity);
+            int slotViewBottom = bottom - top - config.cacheBarHeight;
+
+            mAlbumSetView.layout(0, slotViewTop, right - left, slotViewBottom);
+            mCacheBar.layout(0, bottom - top - config.cacheBarHeight,
+                    right - left, bottom - top);
+        }
+
+        @Override
+        protected void render(GLCanvas canvas) {
+            canvas.save(GLCanvas.SAVE_FLAG_MATRIX);
+            GalleryUtils.setViewPointMatrix(mMatrix,
+                        getWidth() / 2 + mX, getHeight() / 2 + mY, mZ);
+            canvas.multiplyMatrix(mMatrix, 0);
+            super.render(canvas);
+            canvas.restore();
+        }
+    };
+
+    public void onEyePositionChanged(float x, float y, float z) {
+        mRootPane.lockRendering();
+        mX = x;
+        mY = y;
+        mZ = z;
+        mRootPane.unlockRendering();
+        mRootPane.invalidate();
+    }
+
+    public void onSingleTapUp(int slotIndex) {
+        MediaSet targetSet = mAlbumSetDataAdapter.getMediaSet(slotIndex);
+        if (targetSet == null) return; // Content is dirty, we shall reload soon
+
+        // ignore selection action if the target set does not support cache
+        // operation (like a local album).
+        if ((targetSet.getSupportedOperations()
+                & MediaSet.SUPPORT_CACHE) == 0) {
+            showToastForLocalAlbum();
+            return;
+        }
+
+        Path path = targetSet.getPath();
+        boolean isFullyCached =
+                (targetSet.getCacheFlag() == MediaObject.CACHE_FLAG_FULL);
+        boolean isSelected = mSelectionManager.isItemSelected(path);
+
+        if (!isFullyCached) {
+            // We only count the media sets that will be made available offline
+            // in this session.
+            if (isSelected) {
+                --mAlbumCountToMakeAvailableOffline;
+            } else {
+                ++mAlbumCountToMakeAvailableOffline;
+            }
+        }
+
+        long sizeOfTarget = targetSet.getCacheSize();
+        if (isFullyCached ^ isSelected) {
+            mCacheBar.increaseTargetCacheSize(-sizeOfTarget);
+        } else {
+            mCacheBar.increaseTargetCacheSize(sizeOfTarget);
+        }
+
+        mSelectionManager.toggle(path);
+        mAlbumSetView.invalidate();
+    }
+
+    @Override
+    public void onCreate(Bundle data, Bundle restoreState) {
+        initializeViews();
+        initializeData(data);
+        mEyePosition = new EyePosition(mActivity.getAndroidContext(), this);
+    }
+
+    @Override
+    public void onPause() {
+        super.onPause();
+        mAlbumSetDataAdapter.pause();
+        mAlbumSetView.pause();
+        mCacheBar.pause();
+        mEyePosition.pause();
+    }
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        setContentPane(mRootPane);
+        mAlbumSetDataAdapter.resume();
+        mAlbumSetView.resume();
+        mCacheBar.resume();
+        mEyePosition.resume();
+    }
+
+    private void initializeData(Bundle data) {
+        mUserDistance = GalleryUtils.meterToPixel(USER_DISTANCE_METER);
+        String mediaPath = data.getString(ManageCachePage.KEY_MEDIA_PATH);
+        mMediaSet = mActivity.getDataManager().getMediaSet(mediaPath);
+        mSelectionManager.setSourceMediaSet(mMediaSet);
+
+        // We will always be in selection mode in this page.
+        mSelectionManager.setAutoLeaveSelectionMode(false);
+        mSelectionManager.enterSelectionMode();
+
+        mAlbumSetDataAdapter = new AlbumSetDataAdapter(
+                mActivity, mMediaSet, DATA_CACHE_SIZE);
+        mAlbumSetView.setModel(mAlbumSetDataAdapter);
+    }
+
+    private void initializeViews() {
+        mSelectionManager = new SelectionManager(mActivity, true);
+        mSelectionManager.setSelectionListener(this);
+        mStaticBackground = new StaticBackground(mActivity.getAndroidContext());
+        mRootPane.addComponent(mStaticBackground);
+
+        mSelectionDrawer = new ManageCacheDrawer(
+                (Context) mActivity, mSelectionManager);
+        Config.ManageCachePage config = Config.ManageCachePage.get((Context) mActivity);
+        mAlbumSetView = new AlbumSetView(mActivity, mSelectionDrawer,
+                config.slotWidth, config.slotHeight,
+                config.displayItemSize, config.labelFontSize,
+                config.labelOffsetY, config.labelMargin);
+        mAlbumSetView.setListener(new SlotView.SimpleListener() {
+            @Override
+            public void onSingleTapUp(int slotIndex) {
+                ManageCachePage.this.onSingleTapUp(slotIndex);
+            }
+        });
+        mRootPane.addComponent(mAlbumSetView);
+
+        mCacheBar = new CacheBarView(mActivity, R.drawable.manage_bar,
+                config.cacheBarHeight,
+                config.cacheBarPinLeftMargin,
+                config.cacheBarPinRightMargin,
+                config.cacheBarButtonRightMargin,
+                config.cacheBarFontSize);
+
+        mCacheBar.setListener(this);
+        mRootPane.addComponent(mCacheBar);
+
+        mStaticBackground.setImage(R.drawable.background,
+                R.drawable.background_portrait);
+    }
+
+    public void onDoneClicked() {
+        ArrayList<Path> ids = mSelectionManager.getSelected(false);
+        if (ids.size() == 0) {
+            onBackPressed();
+            return;
+        }
+        showToast();
+
+        MenuExecutor menuExecutor = new MenuExecutor(mActivity,
+                mSelectionManager);
+        menuExecutor.startAction(R.id.action_toggle_full_caching,
+                R.string.process_caching_requests, this);
+    }
+
+    private void showToast() {
+        if (mAlbumCountToMakeAvailableOffline > 0) {
+            Activity activity = (Activity) mActivity;
+            Toast.makeText(activity, activity.getResources().getQuantityString(
+                    R.plurals.make_albums_available_offline,
+                    mAlbumCountToMakeAvailableOffline),
+                    Toast.LENGTH_SHORT).show();
+        }
+    }
+
+    private void showToastForLocalAlbum() {
+        Activity activity = (Activity) mActivity;
+        Toast.makeText(activity, activity.getResources().getString(
+            R.string.try_to_set_local_album_available_offline),
+            Toast.LENGTH_SHORT).show();
+    }
+
+    public void onProgressComplete(int result) {
+        onBackPressed();
+    }
+
+    public void onProgressUpdate(int index) {
+    }
+
+    public void onSelectionModeChange(int mode) {
+    }
+
+    public void onSelectionChange(Path path, boolean selected) {
+    }
+}
diff --git a/src/com/android/gallery3d/app/MovieActivity.java b/src/com/android/gallery3d/app/MovieActivity.java
new file mode 100644
index 0000000..fea364e
--- /dev/null
+++ b/src/com/android/gallery3d/app/MovieActivity.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2007 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.R;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.database.Cursor;
+import android.media.AudioManager;
+import android.os.Bundle;
+import android.provider.MediaStore;
+import android.provider.MediaStore.Video.VideoColumns;
+import android.view.View;
+import android.view.Window;
+import android.view.WindowManager;
+
+/**
+ * This activity plays a video from a specified URI.
+ */
+public class MovieActivity extends Activity {
+    @SuppressWarnings("unused")
+    private static final String TAG = "MovieActivity";
+
+    private MoviePlayer mPlayer;
+    private boolean mFinishOnCompletion;
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        requestWindowFeature(Window.FEATURE_ACTION_BAR);
+        requestWindowFeature(Window.FEATURE_ACTION_BAR_OVERLAY);
+
+        setContentView(R.layout.movie_view);
+        View rootView = findViewById(R.id.root);
+        Intent intent = getIntent();
+        setVideoTitle(intent);
+        mPlayer = new MoviePlayer(rootView, this, intent.getData()) {
+            @Override
+            public void onCompletion() {
+                if (mFinishOnCompletion) {
+                    finish();
+                }
+            }
+        };
+        if (intent.hasExtra(MediaStore.EXTRA_SCREEN_ORIENTATION)) {
+            int orientation = intent.getIntExtra(
+                    MediaStore.EXTRA_SCREEN_ORIENTATION,
+                    ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
+            if (orientation != getRequestedOrientation()) {
+                setRequestedOrientation(orientation);
+            }
+        }
+        mFinishOnCompletion = intent.getBooleanExtra(MediaStore.EXTRA_FINISH_ON_COMPLETION, true);
+        Window win = getWindow();
+        WindowManager.LayoutParams winParams = win.getAttributes();
+        winParams.buttonBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_OFF;
+        win.setAttributes(winParams);
+
+    }
+
+    private void setVideoTitle(Intent intent) {
+        String title = intent.getStringExtra(Intent.EXTRA_TITLE);
+        if (title == null) {
+            Cursor cursor = null;
+            try {
+                cursor = getContentResolver().query(intent.getData(),
+                        new String[] {VideoColumns.TITLE}, null, null, null);
+                if (cursor != null && cursor.moveToNext()) {
+                    title = cursor.getString(0);
+                }
+            } catch (Throwable t) {
+                Log.w(TAG, "cannot get title from: " + intent.getDataString(), t);
+            } finally {
+                if (cursor != null) cursor.close();
+            }
+        }
+        if (title != null) getActionBar().setTitle(title);
+    }
+
+    @Override
+    public void onStart() {
+        ((AudioManager) getSystemService(AUDIO_SERVICE))
+                .requestAudioFocus(null, AudioManager.STREAM_MUSIC,
+                AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
+        super.onStart();
+    }
+
+    @Override
+    protected void onStop() {
+        ((AudioManager) getSystemService(AUDIO_SERVICE))
+                .abandonAudioFocus(null);
+        super.onStop();
+    }
+
+    @Override
+    public void onPause() {
+        mPlayer.onPause();
+        super.onPause();
+    }
+
+    @Override
+    public void onResume() {
+        mPlayer.onResume();
+        super.onResume();
+    }
+
+    @Override
+    public void onDestroy() {
+        mPlayer.onDestroy();
+        super.onDestroy();
+    }
+}
diff --git a/src/com/android/gallery3d/app/MoviePlayer.java b/src/com/android/gallery3d/app/MoviePlayer.java
new file mode 100644
index 0000000..4239944
--- /dev/null
+++ b/src/com/android/gallery3d/app/MoviePlayer.java
@@ -0,0 +1,291 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.app;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.common.BlobCache;
+import com.android.gallery3d.util.CacheManager;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.app.ActionBar;
+import android.app.AlertDialog;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnCancelListener;
+import android.content.DialogInterface.OnClickListener;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.media.AudioManager;
+import android.media.MediaPlayer;
+import android.net.Uri;
+import android.os.Handler;
+import android.view.KeyEvent;
+import android.view.View;
+import android.widget.MediaController;
+import android.widget.VideoView;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+
+public class MoviePlayer implements
+        MediaPlayer.OnErrorListener, MediaPlayer.OnCompletionListener {
+    @SuppressWarnings("unused")
+    private static final String TAG = "MoviePlayer";
+
+    // Copied from MediaPlaybackService in the Music Player app.
+    private static final String SERVICECMD = "com.android.music.musicservicecommand";
+    private static final String CMDNAME = "command";
+    private static final String CMDPAUSE = "pause";
+
+    private Context mContext;
+    private final VideoView mVideoView;
+    private final View mProgressView;
+    private final Bookmarker mBookmarker;
+    private final Uri mUri;
+    private final Handler mHandler = new Handler();
+    private final AudioBecomingNoisyReceiver mAudioBecomingNoisyReceiver;
+    private final ActionBar mActionBar;
+
+    private boolean mHasPaused;
+
+    private final Runnable mPlayingChecker = new Runnable() {
+        public void run() {
+            if (mVideoView.isPlaying()) {
+                mProgressView.setVisibility(View.GONE);
+            } else {
+                mHandler.postDelayed(mPlayingChecker, 250);
+            }
+        }
+    };
+
+    public MoviePlayer(View rootView, final MovieActivity movieActivity, Uri videoUri) {
+        mContext = movieActivity.getApplicationContext();
+        mVideoView = (VideoView) rootView.findViewById(R.id.surface_view);
+        mProgressView = rootView.findViewById(R.id.progress_indicator);
+        mBookmarker = new Bookmarker(movieActivity);
+        mActionBar = movieActivity.getActionBar();
+        mUri = videoUri;
+
+        // For streams that we expect to be slow to start up, show a
+        // progress spinner until playback starts.
+        String scheme = mUri.getScheme();
+        if ("http".equalsIgnoreCase(scheme) || "rtsp".equalsIgnoreCase(scheme)) {
+            mHandler.postDelayed(mPlayingChecker, 250);
+        } else {
+            mProgressView.setVisibility(View.GONE);
+        }
+
+        mVideoView.setOnErrorListener(this);
+        mVideoView.setOnCompletionListener(this);
+        mVideoView.setVideoURI(mUri);
+
+        MediaController mediaController = new MediaController(movieActivity) {
+            @Override
+            public void show() {
+                super.show();
+                mActionBar.show();
+            }
+
+            @Override
+            public void hide() {
+                super.hide();
+                mActionBar.hide();
+            }
+        };
+        mVideoView.setMediaController(mediaController);
+        mediaController.setOnKeyListener(new View.OnKeyListener() {
+            public boolean onKey(View v, int keyCode, KeyEvent event) {
+                if (keyCode == KeyEvent.KEYCODE_BACK) {
+                    if (event.getAction() == KeyEvent.ACTION_UP) {
+                        movieActivity.onBackPressed();
+                    }
+                    return true;
+                }
+                return false;
+            }
+        });
+
+        mAudioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver();
+        mAudioBecomingNoisyReceiver.register();
+
+        // make the video view handle keys for seeking and pausing
+        mVideoView.requestFocus();
+
+        Intent i = new Intent(SERVICECMD);
+        i.putExtra(CMDNAME, CMDPAUSE);
+        movieActivity.sendBroadcast(i);
+
+        final Integer bookmark = mBookmarker.getBookmark(mUri);
+        if (bookmark != null) {
+            showResumeDialog(movieActivity, bookmark);
+        } else {
+            mVideoView.start();
+        }
+    }
+
+    private void showResumeDialog(Context context, final int bookmark) {
+        AlertDialog.Builder builder = new AlertDialog.Builder(context);
+        builder.setTitle(R.string.resume_playing_title);
+        builder.setMessage(String.format(
+                context.getString(R.string.resume_playing_message),
+                GalleryUtils.formatDuration(context, bookmark / 1000)));
+        builder.setOnCancelListener(new OnCancelListener() {
+            public void onCancel(DialogInterface dialog) {
+                onCompletion();
+            }
+        });
+        builder.setPositiveButton(
+                R.string.resume_playing_resume, new OnClickListener() {
+            public void onClick(DialogInterface dialog, int which) {
+                mVideoView.seekTo(bookmark);
+                mVideoView.start();
+            }
+        });
+        builder.setNegativeButton(
+                R.string.resume_playing_restart, new OnClickListener() {
+            public void onClick(DialogInterface dialog, int which) {
+                mVideoView.start();
+            }
+        });
+        builder.show();
+    }
+
+    public void onPause() {
+        mHandler.removeCallbacksAndMessages(null);
+        mBookmarker.setBookmark(mUri, mVideoView.getCurrentPosition(),
+                mVideoView.getDuration());
+        mVideoView.suspend();
+        mHasPaused = true;
+    }
+
+    public void onResume() {
+        if (mHasPaused) {
+            Integer bookmark = mBookmarker.getBookmark(mUri);
+            if (bookmark != null) {
+                mVideoView.seekTo(bookmark);
+            }
+        }
+        mVideoView.resume();
+    }
+
+    public void onDestroy() {
+        mVideoView.stopPlayback();
+        mAudioBecomingNoisyReceiver.unregister();
+    }
+
+    public boolean onError(MediaPlayer player, int arg1, int arg2) {
+        mHandler.removeCallbacksAndMessages(null);
+        mProgressView.setVisibility(View.GONE);
+        return false;
+    }
+
+    public void onCompletion(MediaPlayer mp) {
+        onCompletion();
+    }
+
+    public void onCompletion() {
+    }
+
+    private class AudioBecomingNoisyReceiver extends BroadcastReceiver {
+
+        public void register() {
+            mContext.registerReceiver(this,
+                    new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
+        }
+
+        public void unregister() {
+            mContext.unregisterReceiver(this);
+        }
+
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (mVideoView.isPlaying()) {
+                mVideoView.pause();
+          }
+        }
+    }
+}
+
+class Bookmarker {
+    private static final String TAG = "Bookmarker";
+
+    private static final String BOOKMARK_CACHE_FILE = "bookmark";
+    private static final int BOOKMARK_CACHE_MAX_ENTRIES = 100;
+    private static final int BOOKMARK_CACHE_MAX_BYTES = 10 * 1024;
+    private static final int BOOKMARK_CACHE_VERSION = 1;
+
+    private static final int HALF_MINUTE = 30 * 1000;
+    private static final int TWO_MINUTES = 4 * HALF_MINUTE;
+
+    private final Context mContext;
+
+    public Bookmarker(Context context) {
+        mContext = context;
+    }
+
+    public void setBookmark(Uri uri, int bookmark, int duration) {
+        try {
+            BlobCache cache = CacheManager.getCache(mContext,
+                    BOOKMARK_CACHE_FILE, BOOKMARK_CACHE_MAX_ENTRIES,
+                    BOOKMARK_CACHE_MAX_BYTES, BOOKMARK_CACHE_VERSION);
+
+            ByteArrayOutputStream bos = new ByteArrayOutputStream();
+            DataOutputStream dos = new DataOutputStream(bos);
+            dos.writeUTF(uri.toString());
+            dos.writeInt(bookmark);
+            dos.writeInt(duration);
+            dos.flush();
+            cache.insert(uri.hashCode(), bos.toByteArray());
+        } catch (Throwable t) {
+            Log.w(TAG, "setBookmark failed", t);
+        }
+    }
+
+    public Integer getBookmark(Uri uri) {
+        try {
+            BlobCache cache = CacheManager.getCache(mContext,
+                    BOOKMARK_CACHE_FILE, BOOKMARK_CACHE_MAX_ENTRIES,
+                    BOOKMARK_CACHE_MAX_BYTES, BOOKMARK_CACHE_VERSION);
+
+            byte[] data = cache.lookup(uri.hashCode());
+            if (data == null) return null;
+
+            DataInputStream dis = new DataInputStream(
+                    new ByteArrayInputStream(data));
+
+            String uriString = dis.readUTF(dis);
+            int bookmark = dis.readInt();
+            int duration = dis.readInt();
+
+            if (!uriString.equals(uri.toString())) {
+                return null;
+            }
+
+            if ((bookmark < HALF_MINUTE) || (duration < TWO_MINUTES)
+                    || (bookmark > (duration - HALF_MINUTE))) {
+                return null;
+            }
+            return Integer.valueOf(bookmark);
+        } catch (Throwable t) {
+            Log.w(TAG, "getBookmark failed", t);
+        }
+        return null;
+    }
+}
diff --git a/src/com/android/gallery3d/app/PackagesMonitor.java b/src/com/android/gallery3d/app/PackagesMonitor.java
new file mode 100644
index 0000000..cb202a3
--- /dev/null
+++ b/src/com/android/gallery3d/app/PackagesMonitor.java
@@ -0,0 +1,50 @@
+/*
+ * 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.picasasource.PicasaSource;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+
+public class PackagesMonitor extends BroadcastReceiver {
+    public static final String KEY_PACKAGES_VERSION  = "packages-version";
+
+    public synchronized static int getPackagesVersion(Context context) {
+        SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
+        return prefs.getInt(KEY_PACKAGES_VERSION, 1);
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
+
+        int version = prefs.getInt(KEY_PACKAGES_VERSION, 1);
+        prefs.edit().putInt(KEY_PACKAGES_VERSION, version + 1).commit();
+
+        String action = intent.getAction();
+        String packageName = intent.getData().getSchemeSpecificPart();
+        if (Intent.ACTION_PACKAGE_ADDED.equals(action)) {
+            PicasaSource.onPackageAdded(context, packageName);
+        } else if (Intent.ACTION_PACKAGE_REMOVED.equals(action)) {
+            PicasaSource.onPackageRemoved(context, packageName);
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/app/PhotoDataAdapter.java b/src/com/android/gallery3d/app/PhotoDataAdapter.java
new file mode 100644
index 0000000..c05c89a
--- /dev/null
+++ b/src/com/android/gallery3d/app/PhotoDataAdapter.java
@@ -0,0 +1,794 @@
+/*
+ * 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<Long, ImageEntry> mImageCache = new HashMap<Long, ImageEntry>();
+    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<Bitmap> 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<BitmapRegionDecoder> 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<Long> toBeRemoved = new HashSet<Long>(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<BitmapRegionDecoder> {
+        private final long mVersion;
+        private Future<BitmapRegionDecoder> mFuture;
+
+        public FullImageListener(long version) {
+            mVersion = version;
+        }
+
+        public void onFutureDone(Future<BitmapRegionDecoder> future) {
+            mFuture = future;
+            mMainHandler.sendMessage(
+                    mMainHandler.obtainMessage(MSG_RUN_OBJECT, this));
+        }
+
+        public void run() {
+            updateFullImage(mVersion, mFuture);
+        }
+    }
+
+    private class ScreenNailListener
+            implements Runnable, FutureListener<Bitmap> {
+        private final long mVersion;
+        private Future<Bitmap> mFuture;
+
+        public ScreenNailListener(long version) {
+            mVersion = version;
+        }
+
+        public void onFutureDone(Future<Bitmap> 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<Bitmap> screenNailTask;
+        public Future<BitmapRegionDecoder> fullImageTask;
+        public boolean failToLoad = false;
+    }
+
+    private class SourceListener implements ContentListener {
+        public void onContentDirty() {
+            if (mReloadTask != null) mReloadTask.notifyDirty();
+        }
+    }
+
+    private <T> T executeAndWait(Callable<T> callable) {
+        FutureTask<T> task = new FutureTask<T>(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<MediaItem> items;
+    }
+
+    private class GetUpdateInfo implements Callable<UpdateInfo> {
+
+        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<Void> {
+        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<MediaItem> 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<MediaItem> 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);
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/app/PhotoPage.java b/src/com/android/gallery3d/app/PhotoPage.java
new file mode 100644
index 0000000..f28eb22
--- /dev/null
+++ b/src/com/android/gallery3d/app/PhotoPage.java
@@ -0,0 +1,581 @@
+/*
+ * 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.R;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.MediaDetails;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.data.MediaSet;
+import com.android.gallery3d.data.MtpDevice;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.picasasource.PicasaSource;
+import com.android.gallery3d.ui.DetailsWindow;
+import com.android.gallery3d.ui.DetailsWindow.CloseListener;
+import com.android.gallery3d.ui.DetailsWindow.DetailsSource;
+import com.android.gallery3d.ui.FilmStripView;
+import com.android.gallery3d.ui.GLCanvas;
+import com.android.gallery3d.ui.GLView;
+import com.android.gallery3d.ui.ImportCompleteListener;
+import com.android.gallery3d.ui.MenuExecutor;
+import com.android.gallery3d.ui.PhotoView;
+import com.android.gallery3d.ui.PositionRepository;
+import com.android.gallery3d.ui.PositionRepository.Position;
+import com.android.gallery3d.ui.SelectionManager;
+import com.android.gallery3d.ui.SynchronizedHandler;
+import com.android.gallery3d.ui.UserInteractionListener;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.app.ActionBar;
+import android.app.ActionBar.OnMenuVisibilityListener;
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.MeasureSpec;
+import android.view.WindowManager;
+import android.widget.ShareActionProvider;
+import android.widget.Toast;
+
+public class PhotoPage extends ActivityState
+        implements PhotoView.PhotoTapListener, FilmStripView.Listener,
+        UserInteractionListener {
+    private static final String TAG = "PhotoPage";
+
+    private static final int MSG_HIDE_BARS = 1;
+    private static final int HIDE_BARS_TIMEOUT = 3500;
+
+    private static final int REQUEST_SLIDESHOW = 1;
+    private static final int REQUEST_CROP = 2;
+    private static final int REQUEST_CROP_PICASA = 3;
+
+    public static final String KEY_MEDIA_SET_PATH = "media-set-path";
+    public static final String KEY_MEDIA_ITEM_PATH = "media-item-path";
+    public static final String KEY_INDEX_HINT = "index-hint";
+
+    private GalleryApp mApplication;
+    private SelectionManager mSelectionManager;
+
+    private PhotoView mPhotoView;
+    private PhotoPage.Model mModel;
+    private FilmStripView mFilmStripView;
+    private DetailsWindow mDetailsWindow;
+    private boolean mShowDetails;
+
+    // mMediaSet could be null if there is no KEY_MEDIA_SET_PATH supplied.
+    // E.g., viewing a photo in gmail attachment
+    private MediaSet mMediaSet;
+    private Menu mMenu;
+
+    private Intent mResultIntent = new Intent();
+    private int mCurrentIndex = 0;
+    private Handler mHandler;
+    private boolean mShowBars;
+    private ActionBar mActionBar;
+    private MyMenuVisibilityListener mMenuVisibilityListener;
+    private boolean mIsMenuVisible;
+    private boolean mIsInteracting;
+    private MediaItem mCurrentPhoto = null;
+    private MenuExecutor mMenuExecutor;
+    private boolean mIsActive;
+    private ShareActionProvider mShareActionProvider;
+
+    public static interface Model extends PhotoView.Model {
+        public void resume();
+        public void pause();
+        public boolean isEmpty();
+        public MediaItem getCurrentMediaItem();
+        public int getCurrentIndex();
+        public void setCurrentPhoto(Path path, int indexHint);
+    }
+
+    private class MyMenuVisibilityListener implements OnMenuVisibilityListener {
+        public void onMenuVisibilityChanged(boolean isVisible) {
+            mIsMenuVisible = isVisible;
+            refreshHidingMessage();
+        }
+    }
+
+    private GLView mRootPane = new GLView() {
+
+        @Override
+        protected void renderBackground(GLCanvas view) {
+            view.clearBuffer();
+        }
+
+        @Override
+        protected void onLayout(
+                boolean changed, int left, int top, int right, int bottom) {
+            mPhotoView.layout(0, 0, right - left, bottom - top);
+            PositionRepository.getInstance(mActivity).setOffset(0, 0);
+            int filmStripHeight = 0;
+            if (mFilmStripView != null) {
+                mFilmStripView.measure(
+                        MeasureSpec.makeMeasureSpec(right - left, MeasureSpec.EXACTLY),
+                        MeasureSpec.UNSPECIFIED);
+                filmStripHeight = mFilmStripView.getMeasuredHeight();
+                mFilmStripView.layout(0, bottom - top - filmStripHeight,
+                        right - left, bottom - top);
+            }
+            if (mShowDetails) {
+                mDetailsWindow.measure(
+                        MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+                int width = mDetailsWindow.getMeasuredWidth();
+                int viewTop = GalleryActionBar.getHeight((Activity) mActivity);
+                mDetailsWindow.layout(
+                        0, viewTop, width, bottom - top - filmStripHeight);
+            }
+        }
+    };
+
+    @Override
+    public void onCreate(Bundle data, Bundle restoreState) {
+        mActionBar = ((Activity) mActivity).getActionBar();
+        mSelectionManager = new SelectionManager(mActivity, false);
+        mMenuExecutor = new MenuExecutor(mActivity, mSelectionManager);
+
+        mPhotoView = new PhotoView(mActivity);
+        mPhotoView.setPhotoTapListener(this);
+        mRootPane.addComponent(mPhotoView);
+        mApplication = (GalleryApp)((Activity) mActivity).getApplication();
+
+        String setPathString = data.getString(KEY_MEDIA_SET_PATH);
+        Path itemPath = Path.fromString(data.getString(KEY_MEDIA_ITEM_PATH));
+
+        if (setPathString != null) {
+            mMediaSet = mActivity.getDataManager().getMediaSet(setPathString);
+            mCurrentIndex = data.getInt(KEY_INDEX_HINT, 0);
+            mMediaSet = (MediaSet)
+                    mActivity.getDataManager().getMediaObject(setPathString);
+            if (mMediaSet == null) {
+                Log.w(TAG, "failed to restore " + setPathString);
+            }
+            PhotoDataAdapter pda = new PhotoDataAdapter(
+                    mActivity, mPhotoView, mMediaSet, itemPath, mCurrentIndex);
+            mModel = pda;
+            mPhotoView.setModel(mModel);
+
+            Config.PhotoPage config = Config.PhotoPage.get((Context) mActivity);
+
+            mFilmStripView = new FilmStripView(mActivity, mMediaSet,
+                    config.filmstripTopMargin, config.filmstripMidMargin, config.filmstripBottomMargin,
+                    config.filmstripContentSize, config.filmstripThumbSize, config.filmstripBarSize,
+                    config.filmstripGripSize, config.filmstripGripWidth);
+            mRootPane.addComponent(mFilmStripView);
+            mFilmStripView.setListener(this);
+            mFilmStripView.setUserInteractionListener(this);
+            mFilmStripView.setFocusIndex(mCurrentIndex);
+            mFilmStripView.setStartIndex(mCurrentIndex);
+
+            mResultIntent.putExtra(KEY_INDEX_HINT, mCurrentIndex);
+            setStateResult(Activity.RESULT_OK, mResultIntent);
+
+            pda.setDataListener(new PhotoDataAdapter.DataListener() {
+
+                public void onPhotoChanged(int index, Path item) {
+                    mFilmStripView.setFocusIndex(index);
+                    mCurrentIndex = index;
+                    mResultIntent.putExtra(KEY_INDEX_HINT, index);
+                    if (item != null) {
+                        mResultIntent.putExtra(KEY_MEDIA_ITEM_PATH, item.toString());
+                        MediaItem photo = mModel.getCurrentMediaItem();
+                        if (photo != null) updateCurrentPhoto(photo);
+                    } else {
+                        mResultIntent.removeExtra(KEY_MEDIA_ITEM_PATH);
+                    }
+                    setStateResult(Activity.RESULT_OK, mResultIntent);
+                }
+
+                @Override
+                public void onLoadingFinished() {
+                    GalleryUtils.setSpinnerVisibility((Activity) mActivity, false);
+                    if (!mModel.isEmpty()) {
+                        MediaItem photo = mModel.getCurrentMediaItem();
+                        if (photo != null) updateCurrentPhoto(photo);
+                    } else if (mIsActive) {
+                        mActivity.getStateManager().finishState(PhotoPage.this);
+                    }
+                }
+
+
+                @Override
+                public void onLoadingStarted() {
+                    GalleryUtils.setSpinnerVisibility((Activity) mActivity, true);
+                }
+            });
+        } else {
+            // Get default media set by the URI
+            MediaItem mediaItem = (MediaItem)
+                    mActivity.getDataManager().getMediaObject(itemPath);
+            mModel = new SinglePhotoDataAdapter(mActivity, mPhotoView, mediaItem);
+            mPhotoView.setModel(mModel);
+            updateCurrentPhoto(mediaItem);
+        }
+        mHandler = new SynchronizedHandler(mActivity.getGLRoot()) {
+            @Override
+            public void handleMessage(Message message) {
+                switch (message.what) {
+                    case MSG_HIDE_BARS: {
+                        hideBars();
+                        break;
+                    }
+                    default: throw new AssertionError(message.what);
+                }
+            }
+        };
+
+        // start the opening animation
+        mPhotoView.setOpenedItem(itemPath);
+    }
+
+    private void updateCurrentPhoto(MediaItem photo) {
+        if (mCurrentPhoto == photo) return;
+        mCurrentPhoto = photo;
+        if (mCurrentPhoto == null) return;
+        updateMenuOperations();
+        if (mShowDetails) {
+            mDetailsWindow.reloadDetails(mModel.getCurrentIndex());
+        }
+        String title = photo.getName();
+        if (title != null) mActionBar.setTitle(title);
+        mPhotoView.showVideoPlayIcon(photo.getMediaType()
+                == MediaObject.MEDIA_TYPE_VIDEO);
+
+        // If we have an ActionBar then we update the share intent
+        if (mShareActionProvider != null) {
+            Path path = photo.getPath();
+            DataManager manager = mActivity.getDataManager();
+            int type = manager.getMediaType(path);
+            Intent intent = new Intent(Intent.ACTION_SEND);
+            intent.setType(MenuExecutor.getMimeType(type));
+            intent.putExtra(Intent.EXTRA_STREAM, manager.getContentUri(path));
+            mShareActionProvider.setShareIntent(intent);
+        }
+    }
+
+    private void updateMenuOperations() {
+        if (mCurrentPhoto == null || mMenu == null) return;
+        int supportedOperations = mCurrentPhoto.getSupportedOperations();
+        if (!GalleryUtils.isEditorAvailable((Context) mActivity, "image/*")) {
+            supportedOperations &= ~MediaObject.SUPPORT_EDIT;
+        }
+        MenuExecutor.updateMenuOperation(mMenu, supportedOperations);
+    }
+
+    private void showBars() {
+        if (mShowBars) return;
+        mShowBars = true;
+        mActionBar.show();
+        WindowManager.LayoutParams params = ((Activity) mActivity).getWindow().getAttributes();
+        params.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE;
+        ((Activity) mActivity).getWindow().setAttributes(params);
+        if (mFilmStripView != null) {
+            mFilmStripView.show();
+        }
+    }
+
+    private void hideBars() {
+        if (!mShowBars) return;
+        mShowBars = false;
+        mActionBar.hide();
+        WindowManager.LayoutParams params = ((Activity) mActivity).getWindow().getAttributes();
+        params.systemUiVisibility = View. SYSTEM_UI_FLAG_LOW_PROFILE;
+        ((Activity) mActivity).getWindow().setAttributes(params);
+        if (mFilmStripView != null) {
+            mFilmStripView.hide();
+        }
+    }
+
+    private void refreshHidingMessage() {
+        mHandler.removeMessages(MSG_HIDE_BARS);
+        if (!mIsMenuVisible && !mIsInteracting) {
+            mHandler.sendEmptyMessageDelayed(MSG_HIDE_BARS, HIDE_BARS_TIMEOUT);
+        }
+    }
+
+    public void onUserInteraction() {
+        showBars();
+        refreshHidingMessage();
+    }
+
+    public void onUserInteractionTap() {
+        if (mShowBars) {
+            hideBars();
+            mHandler.removeMessages(MSG_HIDE_BARS);
+        } else {
+            showBars();
+            refreshHidingMessage();
+        }
+    }
+
+    public void onUserInteractionBegin() {
+        showBars();
+        mIsInteracting = true;
+        refreshHidingMessage();
+    }
+
+    public void onUserInteractionEnd() {
+        mIsInteracting = false;
+        refreshHidingMessage();
+    }
+
+    @Override
+    protected void onBackPressed() {
+        if (mShowDetails) {
+            hideDetails();
+        } else {
+            PositionRepository repository = PositionRepository.getInstance(mActivity);
+            repository.clear();
+            if (mCurrentPhoto != null) {
+                Position position = new Position();
+                position.x = mRootPane.getWidth() / 2;
+                position.y = mRootPane.getHeight() / 2;
+                position.z = -1000;
+                repository.putPosition(
+                        Long.valueOf(System.identityHashCode(mCurrentPhoto.getPath())),
+                        position);
+            }
+            super.onBackPressed();
+        }
+    }
+
+    @Override
+    protected boolean onCreateActionBar(Menu menu) {
+        MenuInflater inflater = ((Activity) mActivity).getMenuInflater();
+        inflater.inflate(R.menu.photo, menu);
+        menu.findItem(R.id.action_slideshow).setVisible(
+                mMediaSet != null && !(mMediaSet instanceof MtpDevice));
+        mShareActionProvider = GalleryActionBar.initializeShareActionProvider(menu);
+        mMenu = menu;
+        mShowBars = true;
+        updateMenuOperations();
+        return true;
+    }
+
+    @Override
+    protected boolean onItemSelected(MenuItem item) {
+        MediaItem current = mModel.getCurrentMediaItem();
+
+        if (current == null) {
+            // item is not ready, ignore
+            return true;
+        }
+
+        int currentIndex = mModel.getCurrentIndex();
+        Path path = current.getPath();
+
+        DataManager manager = mActivity.getDataManager();
+        int action = item.getItemId();
+        switch (action) {
+            case R.id.action_slideshow: {
+                Bundle data = new Bundle();
+                data.putString(SlideshowPage.KEY_SET_PATH,
+                        mMediaSet.getPath().toString());
+                data.putInt(SlideshowPage.KEY_PHOTO_INDEX, currentIndex);
+                data.putBoolean(SlideshowPage.KEY_REPEAT, true);
+                mActivity.getStateManager().startStateForResult(
+                        SlideshowPage.class, REQUEST_SLIDESHOW, data);
+                return true;
+            }
+            case R.id.action_crop: {
+                Activity activity = (Activity) mActivity;
+                Intent intent = new Intent(CropImage.CROP_ACTION);
+                intent.setClass(activity, CropImage.class);
+                intent.setData(manager.getContentUri(path));
+                activity.startActivityForResult(intent, PicasaSource.isPicasaImage(current)
+                        ? REQUEST_CROP_PICASA
+                        : REQUEST_CROP);
+                return true;
+            }
+            case R.id.action_details: {
+                if (mShowDetails) {
+                    hideDetails();
+                } else {
+                    showDetails(currentIndex);
+                }
+                return true;
+            }
+            case R.id.action_setas:
+            case R.id.action_confirm_delete:
+            case R.id.action_rotate_ccw:
+            case R.id.action_rotate_cw:
+            case R.id.action_show_on_map:
+            case R.id.action_edit:
+                mSelectionManager.deSelectAll();
+                mSelectionManager.toggle(path);
+                mMenuExecutor.onMenuClicked(item, null);
+                return true;
+            case R.id.action_import:
+                mSelectionManager.deSelectAll();
+                mSelectionManager.toggle(path);
+                mMenuExecutor.onMenuClicked(item,
+                        new ImportCompleteListener(mActivity));
+                return true;
+            default :
+                return false;
+        }
+    }
+
+    private void hideDetails() {
+        mShowDetails = false;
+        mDetailsWindow.hide();
+    }
+
+    private void showDetails(int index) {
+        mShowDetails = true;
+        if (mDetailsWindow == null) {
+            mDetailsWindow = new DetailsWindow(mActivity, new MyDetailsSource());
+            mDetailsWindow.setCloseListener(new CloseListener() {
+                public void onClose() {
+                    hideDetails();
+                }
+            });
+            mRootPane.addComponent(mDetailsWindow);
+        }
+        mDetailsWindow.reloadDetails(index);
+        mDetailsWindow.show();
+    }
+
+    public void onSingleTapUp(int x, int y) {
+        MediaItem item = mModel.getCurrentMediaItem();
+        if (item == null) {
+            // item is not ready, ignore
+            return;
+        }
+
+        boolean playVideo =
+                (item.getSupportedOperations() & MediaItem.SUPPORT_PLAY) != 0;
+
+        if (playVideo) {
+            // determine if the point is at center (1/6) of the photo view.
+            // (The position of the "play" icon is at center (1/6) of the photo)
+            int w = mPhotoView.getWidth();
+            int h = mPhotoView.getHeight();
+            playVideo = (Math.abs(x - w / 2) * 12 <= w)
+                && (Math.abs(y - h / 2) * 12 <= h);
+        }
+
+        if (playVideo) {
+            playVideo((Activity) mActivity, item.getPlayUri(), item.getName());
+        } else {
+            onUserInteractionTap();
+        }
+    }
+
+    public static void playVideo(Activity activity, Uri uri, String title) {
+        try {
+            Intent intent = new Intent(Intent.ACTION_VIEW)
+                    .setDataAndType(uri, "video/*");
+            intent.putExtra(Intent.EXTRA_TITLE, title);
+            activity.startActivity(intent);
+        } catch (ActivityNotFoundException e) {
+            Toast.makeText(activity, activity.getString(R.string.video_err),
+                    Toast.LENGTH_SHORT).show();
+        }
+    }
+
+    // Called by FileStripView
+    public void onSlotSelected(int slotIndex) {
+        ((PhotoDataAdapter) mModel).jumpTo(slotIndex);
+    }
+
+    @Override
+    protected void onStateResult(int requestCode, int resultCode, Intent data) {
+        switch (requestCode) {
+            case REQUEST_CROP:
+                if (resultCode == Activity.RESULT_OK) {
+                    if (data == null) break;
+                    Path path = mApplication
+                            .getDataManager().findPathByUri(data.getData());
+                    if (path != null) {
+                        mModel.setCurrentPhoto(path, mCurrentIndex);
+                    }
+                }
+                break;
+            case REQUEST_CROP_PICASA: {
+                int message = resultCode == Activity.RESULT_OK
+                        ? R.string.crop_saved
+                        : R.string.crop_not_saved;
+                Toast.makeText(mActivity.getAndroidContext(),
+                        message, Toast.LENGTH_SHORT).show();
+                break;
+            }
+            case REQUEST_SLIDESHOW: {
+                if (data == null) break;
+                String path = data.getStringExtra(SlideshowPage.KEY_ITEM_PATH);
+                int index = data.getIntExtra(SlideshowPage.KEY_PHOTO_INDEX, 0);
+                if (path != null) {
+                    mModel.setCurrentPhoto(Path.fromString(path), index);
+                }
+            }
+        }
+    }
+
+    @Override
+    public void onPause() {
+        super.onPause();
+        mIsActive = false;
+        if (mFilmStripView != null) {
+            mFilmStripView.pause();
+        }
+        if (mDetailsWindow != null) {
+            mDetailsWindow.pause();
+        }
+        mPhotoView.pause();
+        mModel.pause();
+        mHandler.removeMessages(MSG_HIDE_BARS);
+        mActionBar.removeOnMenuVisibilityListener(mMenuVisibilityListener);
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        mIsActive = true;
+        setContentPane(mRootPane);
+        mModel.resume();
+        mPhotoView.resume();
+        if (mFilmStripView != null) {
+            mFilmStripView.resume();
+        }
+        if (mMenuVisibilityListener == null) {
+            mMenuVisibilityListener = new MyMenuVisibilityListener();
+        }
+        mActionBar.addOnMenuVisibilityListener(mMenuVisibilityListener);
+        onUserInteraction();
+    }
+
+    private class MyDetailsSource implements DetailsSource {
+        public MediaDetails getDetails() {
+            return mModel.getCurrentMediaItem().getDetails();
+        }
+        public int size() {
+            return mMediaSet != null ? mMediaSet.getMediaItemCount() : 1;
+        }
+        public int findIndex(int indexHint) {
+            return indexHint;
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/app/SinglePhotoDataAdapter.java b/src/com/android/gallery3d/app/SinglePhotoDataAdapter.java
new file mode 100644
index 0000000..11e0013
--- /dev/null
+++ b/src/com/android/gallery3d/app/SinglePhotoDataAdapter.java
@@ -0,0 +1,181 @@
+/*
+ * 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.BitmapUtils;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.MediaItem;
+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.BitmapFactory;
+import android.graphics.BitmapRegionDecoder;
+import android.graphics.Rect;
+import android.os.Handler;
+import android.os.Message;
+
+public class SinglePhotoDataAdapter extends TileImageViewAdapter
+        implements PhotoPage.Model {
+
+    private static final String TAG = "SinglePhotoDataAdapter";
+    private static final int SIZE_BACKUP = 640;
+    private static final int MSG_UPDATE_IMAGE = 1;
+
+    private MediaItem mItem;
+    private boolean mHasFullImage;
+    private Future<?> mTask;
+    private BitmapRegionDecoder mDecoder;
+    private Bitmap mBackup;
+    private Handler mHandler;
+
+    private PhotoView mPhotoView;
+    private ThreadPool mThreadPool;
+
+    public SinglePhotoDataAdapter(
+            GalleryActivity activity, PhotoView view, MediaItem item) {
+        mItem = Utils.checkNotNull(item);
+        mHasFullImage = (item.getSupportedOperations() &
+                MediaItem.SUPPORT_FULL_IMAGE) != 0;
+        mPhotoView = Utils.checkNotNull(view);
+        mHandler = new SynchronizedHandler(activity.getGLRoot()) {
+            @Override
+            @SuppressWarnings("unchecked")
+            public void handleMessage(Message message) {
+                Utils.assertTrue(message.what == MSG_UPDATE_IMAGE);
+                if (mHasFullImage) {
+                    onDecodeLargeComplete((Future<BitmapRegionDecoder>)
+                            message.obj);
+                } else {
+                    onDecodeThumbComplete((Future<Bitmap>) message.obj);
+                }
+            }
+        };
+        mThreadPool = activity.getThreadPool();
+    }
+
+    private FutureListener<BitmapRegionDecoder> mLargeListener =
+            new FutureListener<BitmapRegionDecoder>() {
+        public void onFutureDone(Future<BitmapRegionDecoder> future) {
+            mHandler.sendMessage(
+                    mHandler.obtainMessage(MSG_UPDATE_IMAGE, future));
+        }
+    };
+
+    private FutureListener<Bitmap> mThumbListener =
+            new FutureListener<Bitmap>() {
+        public void onFutureDone(Future<Bitmap> future) {
+            mHandler.sendMessage(
+                    mHandler.obtainMessage(MSG_UPDATE_IMAGE, future));
+        }
+    };
+
+    public boolean isEmpty() {
+        return false;
+    }
+
+    public int getImageRotation() {
+        return mItem.getRotation();
+    }
+
+    private void onDecodeLargeComplete(Future<BitmapRegionDecoder> future) {
+        try {
+            mDecoder = future.get();
+            if (mDecoder == null) return;
+            int width = mDecoder.getWidth();
+            int height = mDecoder.getHeight();
+            BitmapFactory.Options options = new BitmapFactory.Options();
+            options.inSampleSize = BitmapUtils.computeSampleSizeLarger(
+                    width, height, SIZE_BACKUP);
+            mBackup = mDecoder.decodeRegion(
+                    new Rect(0, 0, width, height), options);
+            setBackupImage(mBackup, width, height);
+            setRegionDecoder(mDecoder);
+            mPhotoView.notifyImageInvalidated(0);
+        } catch (Throwable t) {
+            Log.w(TAG, "fail to decode large", t);
+        }
+    }
+
+    private void onDecodeThumbComplete(Future<Bitmap> future) {
+        try {
+            mBackup = future.get();
+            if (mBackup == null) return;
+            setBackupImage(mBackup, mBackup.getWidth(), mBackup.getHeight());
+            mPhotoView.notifyOnNewImage();
+            mPhotoView.notifyImageInvalidated(0); // the current image
+        } catch (Throwable t) {
+            Log.w(TAG, "fail to decode thumb", t);
+        }
+    }
+
+    public void resume() {
+        if (mTask == null) {
+            if (mHasFullImage) {
+                mTask = mThreadPool.submit(
+                        mItem.requestLargeImage(), mLargeListener);
+            } else {
+                mTask = mThreadPool.submit(
+                        mItem.requestImage(MediaItem.TYPE_THUMBNAIL),
+                        mThumbListener);
+            }
+        }
+    }
+
+    public void pause() {
+        Future<?> task = mTask;
+        task.cancel();
+        task.waitDone();
+        if (task.get() == null) {
+            mTask = null;
+        }
+    }
+
+    public ImageData getNextImage() {
+        return null;
+    }
+
+    public ImageData getPreviousImage() {
+        return null;
+    }
+
+    public void next() {
+        throw new UnsupportedOperationException();
+    }
+
+    public void previous() {
+        throw new UnsupportedOperationException();
+    }
+
+    public MediaItem getCurrentMediaItem() {
+        return mItem;
+    }
+
+    public int getCurrentIndex() {
+        return 0;
+    }
+
+    public void setCurrentPhoto(Path path, int indexHint) {
+        // ignore
+    }
+}
diff --git a/src/com/android/gallery3d/app/SlideshowDataAdapter.java b/src/com/android/gallery3d/app/SlideshowDataAdapter.java
new file mode 100644
index 0000000..6f9b98e
--- /dev/null
+++ b/src/com/android/gallery3d/app/SlideshowDataAdapter.java
@@ -0,0 +1,187 @@
+/*
+ * 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.app.SlideshowPage.Slide;
+import com.android.gallery3d.data.ContentListener;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.util.Future;
+import com.android.gallery3d.util.FutureListener;
+import com.android.gallery3d.util.ThreadPool;
+import com.android.gallery3d.util.ThreadPool.Job;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.graphics.Bitmap;
+
+import java.util.LinkedList;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class SlideshowDataAdapter implements SlideshowPage.Model {
+    @SuppressWarnings("unused")
+    private static final String TAG = "SlideshowDataAdapter";
+
+    private static final int IMAGE_QUEUE_CAPACITY = 3;
+
+    public interface SlideshowSource {
+        public void addContentListener(ContentListener listener);
+        public void removeContentListener(ContentListener listener);
+        public long reload();
+        public MediaItem getMediaItem(int index);
+    }
+
+    private final SlideshowSource mSource;
+
+    private int mLoadIndex = 0;
+    private int mNextOutput = 0;
+    private boolean mIsActive = false;
+    private boolean mNeedReset;
+    private boolean mDataReady;
+
+    private final LinkedList<Slide> mImageQueue = new LinkedList<Slide>();
+
+    private Future<Void> mReloadTask;
+    private final ThreadPool mThreadPool;
+
+    private long mDataVersion = MediaObject.INVALID_DATA_VERSION;
+    private final AtomicBoolean mNeedReload = new AtomicBoolean(false);
+    private final SourceListener mSourceListener = new SourceListener();
+
+    public SlideshowDataAdapter(GalleryContext context, SlideshowSource source, int index) {
+        mSource = source;
+        mLoadIndex = index;
+        mNextOutput = index;
+        mThreadPool = context.getThreadPool();
+    }
+
+    public MediaItem loadItem() {
+        if (mNeedReload.compareAndSet(true, false)) {
+            long v = mSource.reload();
+            if (v != mDataVersion) {
+                mDataVersion = v;
+                mNeedReset = true;
+                return null;
+            }
+        }
+        return mSource.getMediaItem(mLoadIndex);
+    }
+
+    private class ReloadTask implements Job<Void> {
+        public Void run(JobContext jc) {
+            while (true) {
+                synchronized (SlideshowDataAdapter.this) {
+                    while (mIsActive && (!mDataReady
+                            || mImageQueue.size() >= IMAGE_QUEUE_CAPACITY)) {
+                        try {
+                            SlideshowDataAdapter.this.wait();
+                        } catch (InterruptedException ex) {
+                            // ignored.
+                        }
+                        continue;
+                    }
+                }
+                if (!mIsActive) return null;
+                mNeedReset = false;
+
+                MediaItem item = loadItem();
+
+                if (mNeedReset) {
+                    synchronized (SlideshowDataAdapter.this) {
+                        mImageQueue.clear();
+                        mLoadIndex = mNextOutput;
+                    }
+                    continue;
+                }
+
+                if (item == null) {
+                    synchronized (SlideshowDataAdapter.this) {
+                        if (!mNeedReload.get()) mDataReady = false;
+                        SlideshowDataAdapter.this.notifyAll();
+                    }
+                    continue;
+                }
+
+                Bitmap bitmap = item
+                        .requestImage(MediaItem.TYPE_THUMBNAIL)
+                        .run(jc);
+
+                if (bitmap != null) {
+                    synchronized (SlideshowDataAdapter.this) {
+                        mImageQueue.addLast(
+                                new Slide(item, mLoadIndex, bitmap));
+                        if (mImageQueue.size() == 1) {
+                            SlideshowDataAdapter.this.notifyAll();
+                        }
+                    }
+                }
+                ++mLoadIndex;
+            }
+        }
+    }
+
+    private class SourceListener implements ContentListener {
+        public void onContentDirty() {
+            synchronized (SlideshowDataAdapter.this) {
+                mNeedReload.set(true);
+                mDataReady = true;
+                SlideshowDataAdapter.this.notifyAll();
+            }
+        }
+    }
+
+    private synchronized Slide innerNextBitmap() {
+        while (mIsActive && mDataReady && mImageQueue.isEmpty()) {
+            try {
+                wait();
+            } catch (InterruptedException t) {
+                throw new AssertionError();
+            }
+        }
+        if (mImageQueue.isEmpty()) return null;
+        mNextOutput++;
+        this.notifyAll();
+        return mImageQueue.removeFirst();
+    }
+
+    public Future<Slide> nextSlide(FutureListener<Slide> listener) {
+        return mThreadPool.submit(new Job<Slide>() {
+            public Slide run(JobContext jc) {
+                jc.setMode(ThreadPool.MODE_NONE);
+                return innerNextBitmap();
+            }
+        }, listener);
+    }
+
+    public void pause() {
+        synchronized (this) {
+            mIsActive = false;
+            notifyAll();
+        }
+        mSource.removeContentListener(mSourceListener);
+        mReloadTask.cancel();
+        mReloadTask.waitDone();
+        mReloadTask = null;
+    }
+
+    public synchronized void resume() {
+        mIsActive = true;
+        mSource.addContentListener(mSourceListener);
+        mNeedReload.set(true);
+        mDataReady = true;
+        mReloadTask = mThreadPool.submit(new ReloadTask());
+    }
+}
diff --git a/src/com/android/gallery3d/app/SlideshowDream.java b/src/com/android/gallery3d/app/SlideshowDream.java
new file mode 100644
index 0000000..f4abe86
--- /dev/null
+++ b/src/com/android/gallery3d/app/SlideshowDream.java
@@ -0,0 +1,28 @@
+package com.android.gallery3d.app;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.support.v13.dreams.BasicDream;
+import android.graphics.Canvas;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.ViewFlipper;
+
+public class SlideshowDream extends BasicDream {
+    @Override
+    public void onCreate(Bundle bndl) {
+        super.onCreate(bndl);
+        Intent i = new Intent(
+            Intent.ACTION_VIEW,
+            android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
+//            Uri.fromFile(Environment.getExternalStoragePublicDirectory(
+//                        Environment.DIRECTORY_PICTURES)))
+                .putExtra(Gallery.EXTRA_SLIDESHOW, true)
+                .setFlags(getIntent().getFlags());
+        startActivity(i);
+        finish();
+    }
+}
diff --git a/src/com/android/gallery3d/app/SlideshowPage.java b/src/com/android/gallery3d/app/SlideshowPage.java
new file mode 100644
index 0000000..cdf9308
--- /dev/null
+++ b/src/com/android/gallery3d/app/SlideshowPage.java
@@ -0,0 +1,338 @@
+/*
+ * 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.MediaItem;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.data.MediaSet;
+import com.android.gallery3d.ui.GLCanvas;
+import com.android.gallery3d.ui.GLView;
+import com.android.gallery3d.ui.SlideshowView;
+import com.android.gallery3d.ui.SynchronizedHandler;
+import com.android.gallery3d.util.Future;
+import com.android.gallery3d.util.FutureListener;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.os.PowerManager;
+import android.os.PowerManager.WakeLock;
+import android.view.MotionEvent;
+
+import java.util.ArrayList;
+import java.util.Random;
+
+public class SlideshowPage extends ActivityState {
+    private static final String TAG = "SlideshowPage";
+
+    public static final String KEY_SET_PATH = "media-set-path";
+    public static final String KEY_ITEM_PATH = "media-item-path";
+    public static final String KEY_PHOTO_INDEX = "photo-index";
+    public static final String KEY_RANDOM_ORDER = "random-order";
+    public static final String KEY_REPEAT = "repeat";
+
+    private static final long SLIDESHOW_DELAY = 3000; // 3 seconds
+
+    private static final int MSG_LOAD_NEXT_BITMAP = 1;
+    private static final int MSG_SHOW_PENDING_BITMAP = 2;
+
+    public static interface Model {
+        public void pause();
+        public void resume();
+        public Future<Slide> nextSlide(FutureListener<Slide> listener);
+    }
+
+    public static class Slide {
+        public Bitmap bitmap;
+        public MediaItem item;
+        public int index;
+
+        public Slide(MediaItem item, int index, Bitmap bitmap) {
+            this.bitmap = bitmap;
+            this.item = item;
+            this.index = index;
+        }
+    }
+
+    private Handler mHandler;
+    private Model mModel;
+    private SlideshowView mSlideshowView;
+
+    private Slide mPendingSlide = null;
+    private boolean mIsActive = false;
+    private WakeLock mWakeLock;
+    private Intent mResultIntent = new Intent();
+
+    private GLView mRootPane = new GLView() {
+        @Override
+        protected void onLayout(
+                boolean changed, int left, int top, int right, int bottom) {
+            mSlideshowView.layout(0, 0, right - left, bottom - top);
+        }
+
+        @Override
+        protected boolean onTouch(MotionEvent event) {
+            if (event.getAction() == MotionEvent.ACTION_UP) {
+                onBackPressed();
+            }
+            return true;
+        }
+
+        @Override
+        protected void renderBackground(GLCanvas canvas) {
+            canvas.clearBuffer();
+        }
+    };
+
+    @Override
+    public void onCreate(Bundle data, Bundle restoreState) {
+        mFlags |= (FLAG_HIDE_ACTION_BAR | FLAG_HIDE_STATUS_BAR);
+
+        PowerManager pm = (PowerManager) mActivity.getAndroidContext()
+                .getSystemService(Context.POWER_SERVICE);
+        mWakeLock = pm.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK
+                | PowerManager.ON_AFTER_RELEASE, TAG);
+
+        mHandler = new SynchronizedHandler(mActivity.getGLRoot()) {
+            @Override
+            public void handleMessage(Message message) {
+                switch (message.what) {
+                    case MSG_SHOW_PENDING_BITMAP:
+                        showPendingBitmap();
+                        break;
+                    case MSG_LOAD_NEXT_BITMAP:
+                        loadNextBitmap();
+                        break;
+                    default: throw new AssertionError();
+                }
+            }
+        };
+        initializeViews();
+        initializeData(data);
+    }
+
+    private void loadNextBitmap() {
+        mModel.nextSlide(new FutureListener<Slide>() {
+            public void onFutureDone(Future<Slide> future) {
+                mPendingSlide = future.get();
+                mHandler.sendEmptyMessage(MSG_SHOW_PENDING_BITMAP);
+            }
+        });
+    }
+
+    private void showPendingBitmap() {
+        // mPendingBitmap could be null, if
+        //    1.) there is no more items
+        //    2.) mModel is paused
+        Slide slide = mPendingSlide;
+        if (slide == null) {
+            if (mIsActive) {
+                mActivity.getStateManager().finishState(SlideshowPage.this);
+            }
+            return;
+        }
+
+        mSlideshowView.next(slide.bitmap, slide.item.getRotation());
+
+        setStateResult(Activity.RESULT_OK, mResultIntent
+                .putExtra(KEY_ITEM_PATH, slide.item.getPath().toString())
+                .putExtra(KEY_PHOTO_INDEX, slide.index));
+        mHandler.sendEmptyMessageDelayed(MSG_LOAD_NEXT_BITMAP,
+                SLIDESHOW_DELAY);
+    }
+
+    @Override
+    public void onPause() {
+        super.onPause();
+        mWakeLock.release();
+        mIsActive = false;
+        mModel.pause();
+        mSlideshowView.release();
+
+        mHandler.removeMessages(MSG_LOAD_NEXT_BITMAP);
+        mHandler.removeMessages(MSG_SHOW_PENDING_BITMAP);
+    }
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        mWakeLock.acquire();
+        mIsActive = true;
+        mModel.resume();
+
+        if (mPendingSlide != null) {
+            showPendingBitmap();
+        } else {
+            loadNextBitmap();
+        }
+    }
+
+    private void initializeData(Bundle data) {
+        String mediaPath = data.getString(KEY_SET_PATH);
+        boolean random = data.getBoolean(KEY_RANDOM_ORDER, false);
+        MediaSet mediaSet = mActivity.getDataManager().getMediaSet(mediaPath);
+
+        if (random) {
+            boolean repeat = data.getBoolean(KEY_REPEAT);
+            mModel = new SlideshowDataAdapter(
+                    mActivity, new ShuffleSource(mediaSet, repeat), 0);
+            setStateResult(Activity.RESULT_OK,
+                    mResultIntent.putExtra(KEY_PHOTO_INDEX, 0));
+        } else {
+            int index = data.getInt(KEY_PHOTO_INDEX);
+            boolean repeat = data.getBoolean(KEY_REPEAT);
+            mModel = new SlideshowDataAdapter(mActivity,
+                    new SequentialSource(mediaSet, repeat), index);
+            setStateResult(Activity.RESULT_OK,
+                    mResultIntent.putExtra(KEY_PHOTO_INDEX, index));
+        }
+    }
+
+    private void initializeViews() {
+        mSlideshowView = new SlideshowView();
+        mRootPane.addComponent(mSlideshowView);
+        setContentPane(mRootPane);
+    }
+
+    private static MediaItem findMediaItem(MediaSet mediaSet, int index) {
+        for (int i = 0, n = mediaSet.getSubMediaSetCount(); i < n; ++i) {
+            MediaSet subset = mediaSet.getSubMediaSet(i);
+            int count = subset.getTotalMediaItemCount();
+            if (index < count) {
+                return findMediaItem(subset, index);
+            }
+            index -= count;
+        }
+        ArrayList<MediaItem> list = mediaSet.getMediaItem(index, 1);
+        return list.isEmpty() ? null : list.get(0);
+    }
+
+    private static class ShuffleSource implements SlideshowDataAdapter.SlideshowSource {
+        private static final int RETRY_COUNT = 5;
+        private final MediaSet mMediaSet;
+        private final Random mRandom = new Random();
+        private int mOrder[] = new int[0];
+        private boolean mRepeat;
+        private long mSourceVersion = MediaSet.INVALID_DATA_VERSION;
+        private int mLastIndex = -1;
+
+        public ShuffleSource(MediaSet mediaSet, boolean repeat) {
+            mMediaSet = Utils.checkNotNull(mediaSet);
+            mRepeat = repeat;
+        }
+
+        public MediaItem getMediaItem(int index) {
+            if (!mRepeat && index >= mOrder.length) return null;
+            mLastIndex = mOrder[index % mOrder.length];
+            MediaItem item = findMediaItem(mMediaSet, mLastIndex);
+            for (int i = 0; i < RETRY_COUNT && item == null; ++i) {
+                Log.w(TAG, "fail to find image: " + mLastIndex);
+                mLastIndex = mRandom.nextInt(mOrder.length);
+                item = findMediaItem(mMediaSet, mLastIndex);
+            }
+            return item;
+        }
+
+        public long reload() {
+            long version = mMediaSet.reload();
+            if (version != mSourceVersion) {
+                mSourceVersion = version;
+                int count = mMediaSet.getTotalMediaItemCount();
+                if (count != mOrder.length) generateOrderArray(count);
+            }
+            return version;
+        }
+
+        private void generateOrderArray(int totalCount) {
+            if (mOrder.length != totalCount) {
+                mOrder = new int[totalCount];
+                for (int i = 0; i < totalCount; ++i) {
+                    mOrder[i] = i;
+                }
+            }
+            for (int i = totalCount - 1; i > 0; --i) {
+                Utils.swap(mOrder, i, mRandom.nextInt(i + 1));
+            }
+            if (mOrder[0] == mLastIndex && totalCount > 1) {
+                Utils.swap(mOrder, 0, mRandom.nextInt(totalCount - 1) + 1);
+            }
+        }
+
+        public void addContentListener(ContentListener listener) {
+            mMediaSet.addContentListener(listener);
+        }
+
+        public void removeContentListener(ContentListener listener) {
+            mMediaSet.removeContentListener(listener);
+        }
+    }
+
+    private static class SequentialSource implements SlideshowDataAdapter.SlideshowSource {
+        private static final int DATA_SIZE = 32;
+
+        private ArrayList<MediaItem> mData = new ArrayList<MediaItem>();
+        private int mDataStart = 0;
+        private long mDataVersion = MediaObject.INVALID_DATA_VERSION;
+        private final MediaSet mMediaSet;
+        private final boolean mRepeat;
+
+        public SequentialSource(MediaSet mediaSet, boolean repeat) {
+            mMediaSet = mediaSet;
+            mRepeat = repeat;
+        }
+
+        public MediaItem getMediaItem(int index) {
+            int dataEnd = mDataStart + mData.size();
+
+            if (mRepeat) {
+                index = index % mMediaSet.getMediaItemCount();
+            }
+            if (index < mDataStart || index >= dataEnd) {
+                mData = mMediaSet.getMediaItem(index, DATA_SIZE);
+                mDataStart = index;
+                dataEnd = index + mData.size();
+            }
+
+            return (index < mDataStart || index >= dataEnd)
+                    ? null
+                    : mData.get(index - mDataStart);
+        }
+
+        public long reload() {
+            long version = mMediaSet.reload();
+            if (version != mDataVersion) {
+                mDataVersion = version;
+                mData.clear();
+            }
+            return mDataVersion;
+        }
+
+        public void addContentListener(ContentListener listener) {
+            mMediaSet.addContentListener(listener);
+        }
+
+        public void removeContentListener(ContentListener listener) {
+            mMediaSet.removeContentListener(listener);
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/app/StateManager.java b/src/com/android/gallery3d/app/StateManager.java
new file mode 100644
index 0000000..b551f69
--- /dev/null
+++ b/src/com/android/gallery3d/app/StateManager.java
@@ -0,0 +1,277 @@
+/*
+ * 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 android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import java.util.Stack;
+
+public class StateManager {
+    @SuppressWarnings("unused")
+    private static final String TAG = "StateManager";
+    private boolean mIsResumed = false;
+
+    private static final String KEY_MAIN = "activity-state";
+    private static final String KEY_DATA = "data";
+    private static final String KEY_STATE = "bundle";
+    private static final String KEY_CLASS = "class";
+
+    private GalleryActivity mContext;
+    private Stack<StateEntry> mStack = new Stack<StateEntry>();
+    private ActivityState.ResultEntry mResult;
+
+    public StateManager(GalleryActivity context) {
+        mContext = context;
+    }
+
+    public void startState(Class<? extends ActivityState> klass,
+            Bundle data) {
+        Log.v(TAG, "startState " + klass);
+        ActivityState state = null;
+        try {
+            state = klass.newInstance();
+        } catch (Exception e) {
+            throw new AssertionError(e);
+        }
+        if (!mStack.isEmpty()) {
+            ActivityState top = getTopState();
+            if (mIsResumed) top.onPause();
+        }
+        state.initialize(mContext, data);
+
+        mStack.push(new StateEntry(data, state));
+        state.onCreate(data, null);
+        if (mIsResumed) state.resume();
+    }
+
+    public void startStateForResult(Class<? extends ActivityState> klass,
+            int requestCode, Bundle data) {
+        Log.v(TAG, "startStateForResult " + klass + ", " + requestCode);
+        ActivityState state = null;
+        try {
+            state = klass.newInstance();
+        } catch (Exception e) {
+            throw new AssertionError(e);
+        }
+        state.initialize(mContext, data);
+        state.mResult = new ActivityState.ResultEntry();
+        state.mResult.requestCode = requestCode;
+
+        if (!mStack.isEmpty()) {
+            ActivityState as = getTopState();
+            as.mReceivedResults = state.mResult;
+            if (mIsResumed) as.onPause();
+        } else {
+            mResult = state.mResult;
+        }
+
+        mStack.push(new StateEntry(data, state));
+        state.onCreate(data, null);
+        if (mIsResumed) state.resume();
+    }
+
+    public boolean createOptionsMenu(Menu menu) {
+        if (!mStack.isEmpty()) {
+            ((Activity) mContext).setProgressBarIndeterminateVisibility(false);
+            return getTopState().onCreateActionBar(menu);
+        } else {
+            return false;
+        }
+    }
+
+    public void resume() {
+        if (mIsResumed) return;
+        mIsResumed = true;
+        if (!mStack.isEmpty()) getTopState().resume();
+    }
+
+    public void pause() {
+        if (!mIsResumed) return;
+        mIsResumed = false;
+        if (!mStack.isEmpty()) getTopState().onPause();
+    }
+
+    public void notifyActivityResult(int requestCode, int resultCode, Intent data) {
+        getTopState().onStateResult(requestCode, resultCode, data);
+    }
+
+    public int getStateCount() {
+        return mStack.size();
+    }
+
+    public boolean itemSelected(MenuItem item) {
+        if (!mStack.isEmpty()) {
+            if (mStack.size() > 1 && item.getItemId() == android.R.id.home) {
+                getTopState().onBackPressed();
+                return true;
+            } else {
+                return getTopState().onItemSelected(item);
+            }
+        }
+        return false;
+    }
+
+    public void onBackPressed() {
+        if (!mStack.isEmpty()) {
+            getTopState().onBackPressed();
+        }
+    }
+
+    void finishState(ActivityState state) {
+        Log.v(TAG, "finishState " + state.getClass());
+        if (state != mStack.peek().activityState) {
+            throw new IllegalArgumentException("The stateview to be finished"
+                    + " is not at the top of the stack: " + state + ", "
+                    + mStack.peek().activityState);
+        }
+
+        // Remove the top state.
+        mStack.pop();
+        if (mIsResumed) state.onPause();
+        mContext.getGLRoot().setContentPane(null);
+        state.onDestroy();
+
+        if (mStack.isEmpty()) {
+            Log.v(TAG, "no more state, finish activity");
+            Activity activity = (Activity) mContext.getAndroidContext();
+            if (mResult != null) {
+                activity.setResult(mResult.resultCode, mResult.resultData);
+            }
+            activity.finish();
+
+            // The finish() request is rejected (only happens under Monkey),
+            // so we start the default page instead.
+            if (!activity.isFinishing()) {
+                Log.v(TAG, "finish() failed, start default page");
+                ((Gallery) mContext).startDefaultPage();
+            }
+        } else {
+            // Restore the immediately previous state
+            ActivityState top = mStack.peek().activityState;
+            if (mIsResumed) top.resume();
+        }
+    }
+
+    void switchState(ActivityState oldState,
+            Class<? extends ActivityState> klass, Bundle data) {
+        Log.v(TAG, "switchState " + oldState + ", " + klass);
+        if (oldState != mStack.peek().activityState) {
+            throw new IllegalArgumentException("The stateview to be finished"
+                    + " is not at the top of the stack: " + oldState + ", "
+                    + mStack.peek().activityState);
+        }
+        // Remove the top state.
+        mStack.pop();
+        if (mIsResumed) oldState.onPause();
+        oldState.onDestroy();
+
+        // Create new state.
+        ActivityState state = null;
+        try {
+            state = klass.newInstance();
+        } catch (Exception e) {
+            throw new AssertionError(e);
+        }
+        state.initialize(mContext, data);
+        mStack.push(new StateEntry(data, state));
+        state.onCreate(data, null);
+        if (mIsResumed) state.resume();
+    }
+
+    public void destroy() {
+        Log.v(TAG, "destroy");
+        while (!mStack.isEmpty()) {
+            mStack.pop().activityState.onDestroy();
+        }
+        mStack.clear();
+    }
+
+    @SuppressWarnings("unchecked")
+    public void restoreFromState(Bundle inState) {
+        Log.v(TAG, "restoreFromState");
+        Parcelable list[] = inState.getParcelableArray(KEY_MAIN);
+
+        for (Parcelable parcelable : list) {
+            Bundle bundle = (Bundle) parcelable;
+            Class<? extends ActivityState> klass =
+                    (Class<? extends ActivityState>) bundle.getSerializable(KEY_CLASS);
+
+            Bundle data = bundle.getBundle(KEY_DATA);
+            Bundle state = bundle.getBundle(KEY_STATE);
+
+            ActivityState activityState;
+            try {
+                Log.v(TAG, "restoreFromState " + klass);
+                activityState = klass.newInstance();
+            } catch (Exception e) {
+                throw new AssertionError(e);
+            }
+            activityState.initialize(mContext, data);
+            activityState.onCreate(data, state);
+            mStack.push(new StateEntry(data, activityState));
+        }
+    }
+
+    public void saveState(Bundle outState) {
+        Log.v(TAG, "saveState");
+        Parcelable list[] = new Parcelable[mStack.size()];
+
+        int i = 0;
+        for (StateEntry entry : mStack) {
+            Bundle bundle = new Bundle();
+            bundle.putSerializable(KEY_CLASS, entry.activityState.getClass());
+            bundle.putBundle(KEY_DATA, entry.data);
+            Bundle state = new Bundle();
+            entry.activityState.onSaveState(state);
+            bundle.putBundle(KEY_STATE, state);
+            Log.v(TAG, "saveState " + entry.activityState.getClass());
+            list[i++] = bundle;
+        }
+        outState.putParcelableArray(KEY_MAIN, list);
+    }
+
+    public boolean hasStateClass(Class<? extends ActivityState> klass) {
+        for (StateEntry entry : mStack) {
+            if (klass.isInstance(entry.activityState)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public ActivityState getTopState() {
+        Utils.assertTrue(!mStack.isEmpty());
+        return mStack.peek().activityState;
+    }
+
+    private static class StateEntry {
+        public Bundle data;
+        public ActivityState activityState;
+
+        public StateEntry(Bundle data, ActivityState state) {
+            this.data = data;
+            this.activityState = state;
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/app/UsbDeviceActivity.java b/src/com/android/gallery3d/app/UsbDeviceActivity.java
new file mode 100644
index 0000000..28bd667
--- /dev/null
+++ b/src/com/android/gallery3d/app/UsbDeviceActivity.java
@@ -0,0 +1,47 @@
+/*
+ * 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.app;
+
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+
+/* This Activity does nothing but receive USB_DEVICE_ATTACHED events from the
+ * USB service and springboards to the main Gallery activity
+ */
+public final class UsbDeviceActivity extends Activity {
+
+    static final String TAG = "UsbDeviceActivity";
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        //
+        Intent intent = new Intent(this, Gallery.class);
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+        try {
+            startActivity(intent);
+        } catch (ActivityNotFoundException e) {
+            Log.e(TAG, "unable to start Gallery activity", e);
+        }
+        finish();
+    }
+}
diff --git a/src/com/android/gallery3d/app/Wallpaper.java b/src/com/android/gallery3d/app/Wallpaper.java
new file mode 100644
index 0000000..07a3d53
--- /dev/null
+++ b/src/com/android/gallery3d/app/Wallpaper.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2007 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 android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Display;
+
+/**
+ * Wallpaper picker for the gallery application. This just redirects to the
+ * standard pick action.
+ */
+public class Wallpaper extends Activity {
+    @SuppressWarnings("unused")
+    private static final String TAG = "Wallpaper";
+
+    private static final String IMAGE_TYPE = "image/*";
+    private static final String KEY_STATE = "activity-state";
+    private static final String KEY_PICKED_ITEM = "picked-item";
+
+    private static final int STATE_INIT = 0;
+    private static final int STATE_PHOTO_PICKED = 1;
+
+    private int mState = STATE_INIT;
+    private Uri mPickedItem;
+
+    @Override
+    protected void onCreate(Bundle bundle) {
+        super.onCreate(bundle);
+        if (bundle != null) {
+            mState = bundle.getInt(KEY_STATE);
+            mPickedItem = (Uri) bundle.getParcelable(KEY_PICKED_ITEM);
+        }
+    }
+
+    @Override
+    protected void onSaveInstanceState(Bundle saveState) {
+        saveState.putInt(KEY_STATE, mState);
+        if (mPickedItem != null) {
+            saveState.putParcelable(KEY_PICKED_ITEM, mPickedItem);
+        }
+    }
+
+    @SuppressWarnings("fallthrough")
+    @Override
+    protected void onResume() {
+        super.onResume();
+        Intent intent = getIntent();
+        switch (mState) {
+            case STATE_INIT: {
+                mPickedItem = intent.getData();
+                if (mPickedItem == null) {
+                    Intent request = new Intent(Intent.ACTION_GET_CONTENT)
+                            .setClass(this, DialogPicker.class)
+                            .setType(IMAGE_TYPE);
+                    startActivityForResult(request, STATE_PHOTO_PICKED);
+                    return;
+                }
+                mState = STATE_PHOTO_PICKED;
+                // fall-through
+            }
+            case STATE_PHOTO_PICKED: {
+                int width = getWallpaperDesiredMinimumWidth();
+                int height = getWallpaperDesiredMinimumHeight();
+                Display display = getWindowManager().getDefaultDisplay();
+                float spotlightX = (float) display.getWidth() / width;
+                float spotlightY = (float) display.getHeight() / height;
+                Intent request = new Intent(CropImage.ACTION_CROP)
+                        .setDataAndType(mPickedItem, IMAGE_TYPE)
+                        .addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT)
+                        .putExtra(CropImage.KEY_OUTPUT_X, width)
+                        .putExtra(CropImage.KEY_OUTPUT_Y, height)
+                        .putExtra(CropImage.KEY_ASPECT_X, width)
+                        .putExtra(CropImage.KEY_ASPECT_Y, height)
+                        .putExtra(CropImage.KEY_SPOTLIGHT_X, spotlightX)
+                        .putExtra(CropImage.KEY_SPOTLIGHT_Y, spotlightY)
+                        .putExtra(CropImage.KEY_SCALE, true)
+                        .putExtra(CropImage.KEY_NO_FACE_DETECTION, true)
+                        .putExtra(CropImage.KEY_SET_AS_WALLPAPER, true);
+                startActivity(request);
+                finish();
+            }
+        }
+    }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+        if (resultCode != RESULT_OK) {
+            setResult(resultCode);
+            finish();
+            return;
+        }
+        mState = requestCode;
+        if (mState == STATE_PHOTO_PICKED) {
+            mPickedItem = data.getData();
+        }
+
+        // onResume() would be called next
+    }
+}
diff --git a/src/com/android/gallery3d/data/ChangeNotifier.java b/src/com/android/gallery3d/data/ChangeNotifier.java
new file mode 100644
index 0000000..e1e601d
--- /dev/null
+++ b/src/com/android/gallery3d/data/ChangeNotifier.java
@@ -0,0 +1,54 @@
+/*
+ * 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;
+
+import android.net.Uri;
+
+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);
+    }
+
+    // Returns the dirty flag and clear it.
+    public boolean isDirty() {
+        return mContentDirty.compareAndSet(true, false);
+    }
+
+    public void fakeChange() {
+        onChange(false);
+    }
+
+    public void clearDirty() {
+        mContentDirty.set(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 0000000..32f9023
--- /dev/null
+++ b/src/com/android/gallery3d/data/ClusterAlbum.java
@@ -0,0 +1,129 @@
+/*
+ * 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 {
+    private static final String TAG = "ClusterAlbum";
+    private ArrayList<Path> mPaths = new ArrayList<Path>();
+    private String mName = "";
+    private DataManager mDataManager;
+    private MediaSet mClusterAlbumSet;
+
+    public ClusterAlbum(Path path, DataManager dataManager,
+            MediaSet clusterAlbumSet) {
+        super(path, nextVersionNumber());
+        mDataManager = dataManager;
+        mClusterAlbumSet = clusterAlbumSet;
+        mClusterAlbumSet.addContentListener(this);
+    }
+
+    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() {
+            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;
+    }
+
+    public void onContentDirty() {
+        notifyContentChanged();
+    }
+
+    @Override
+    public int getSupportedOperations() {
+        return SUPPORT_SHARE | SUPPORT_DELETE | SUPPORT_INFO;
+    }
+
+    @Override
+    public void delete() {
+        ItemConsumer consumer = new ItemConsumer() {
+            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 0000000..5b0569a
--- /dev/null
+++ b/src/com/android/gallery3d/data/ClusterAlbumSet.java
@@ -0,0 +1,152 @@
+/*
+ * 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;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+
+public class ClusterAlbumSet extends MediaSet implements ContentListener {
+    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;
+    }
+
+    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 = (ClusterAlbum) dataManager.peekMediaObject(
+                        childPath);
+            if (album == null) {
+                album = new ClusterAlbum(childPath, dataManager, this);
+            }
+            album.setMediaItems(clustering.getCluster(i));
+            album.setName(childName);
+            mAlbums.add(album);
+        }
+    }
+
+    private void updateClustersContents() {
+        final HashSet<Path> existing = new HashSet<Path>();
+        mBaseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() {
+            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 0000000..a1f22e5
--- /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 0000000..542dda2
--- /dev/null
+++ b/src/com/android/gallery3d/data/Clustering.java
@@ -0,0 +1,26 @@
+/*
+ * 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);
+}
diff --git a/src/com/android/gallery3d/data/ComboAlbum.java b/src/com/android/gallery3d/data/ComboAlbum.java
new file mode 100644
index 0000000..8ca2077
--- /dev/null
+++ b/src/com/android/gallery3d/data/ComboAlbum.java
@@ -0,0 +1,87 @@
+/*
+ * 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.app.GalleryApp;
+
+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 {
+    private static final String TAG = "ComboAlbum";
+    private final MediaSet[] mSets;
+    private final 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 String getName() {
+        return mName;
+    }
+
+    @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;
+    }
+
+    public void onContentDirty() {
+        notifyContentChanged();
+    }
+}
diff --git a/src/com/android/gallery3d/data/ComboAlbumSet.java b/src/com/android/gallery3d/data/ComboAlbumSet.java
new file mode 100644
index 0000000..aa19603
--- /dev/null
+++ b/src/com/android/gallery3d/data/ComboAlbumSet.java
@@ -0,0 +1,80 @@
+/*
+ * 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;
+
+// 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 {
+    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 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;
+    }
+
+    public void onContentDirty() {
+        notifyContentChanged();
+    }
+}
diff --git a/src/com/android/gallery3d/data/ComboSource.java b/src/com/android/gallery3d/data/ComboSource.java
new file mode 100644
index 0000000..867d47e
--- /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 0000000..5e29526
--- /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 0000000..f7dac5e
--- /dev/null
+++ b/src/com/android/gallery3d/data/DataManager.java
@@ -0,0 +1,333 @@
+/*
+ * 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;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.MediaSet.ItemConsumer;
+import com.android.gallery3d.data.MediaSource.PathId;
+import com.android.gallery3d.picasasource.PicasaSource;
+
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Handler;
+
+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 {
+    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();
+
+    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/{/mtp,/local/all,/picasa/all}";
+    private static final String TOP_IMAGE_SET_PATH =
+            "/combo/{/mtp,/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> {
+        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 MtpSource(mApplication));
+        addSource(new ComboSource(mApplication));
+        addSource(new ClusterSource(mApplication));
+        addSource(new FilterSource(mApplication));
+        addSource(new UriSource(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) {
+        mSourceMap.put(source.getPrefix(), source);
+    }
+
+    public MediaObject peekMediaObject(Path path) {
+        return path.getObject();
+    }
+
+    public MediaSet peekMediaSet(Path path) {
+        return (MediaSet) path.getObject();
+    }
+
+    public MediaObject getMediaObject(Path path) {
+        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;
+        }
+
+        MediaObject object = source.createMediaObject(path);
+        if (object == null) {
+            Log.w(TAG, "cannot create media object: " + path);
+        }
+        return object;
+    }
+
+    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 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 MediaDetails getDetails(Path path) {
+        return getMediaObject(path).getDetails();
+    }
+
+    public void cache(Path path, int flag) {
+        getMediaObject(path).cache(flag);
+    }
+
+    public Path findPathByUri(Uri uri) {
+        if (uri == null) return null;
+        for (MediaSource source : mSourceMap.values()) {
+            Path path = source.findPathByUri(uri);
+            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);
+            }
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/data/DecodeUtils.java b/src/com/android/gallery3d/data/DecodeUtils.java
new file mode 100644
index 0000000..e7ae638
--- /dev/null
+++ b/src/com/android/gallery3d/data/DecodeUtils.java
@@ -0,0 +1,173 @@
+/*
+ * 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.BitmapUtils;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.util.ThreadPool.CancelListener;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.content.ContentResolver;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapFactory.Options;
+import android.graphics.BitmapRegionDecoder;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+
+public class DecodeUtils {
+    private static final String TAG = "DecodeService";
+
+    private static class DecodeCanceller implements CancelListener {
+        Options mOptions;
+        public DecodeCanceller(Options options) {
+            mOptions = options;
+        }
+        public void onCancel() {
+            mOptions.requestCancelDecode();
+        }
+    }
+
+    public static Bitmap requestDecode(JobContext jc, final String filePath,
+            Options options) {
+        if (options == null) options = new Options();
+        jc.setCancelListener(new DecodeCanceller(options));
+        return ensureGLCompatibleBitmap(
+                BitmapFactory.decodeFile(filePath, options));
+    }
+
+    public static Bitmap requestDecode(JobContext jc, byte[] bytes,
+            Options options) {
+        return requestDecode(jc, bytes, 0, bytes.length, options);
+    }
+
+    public static Bitmap requestDecode(JobContext jc, byte[] bytes, int offset,
+            int length, Options options) {
+        if (options == null) options = new Options();
+        jc.setCancelListener(new DecodeCanceller(options));
+        return ensureGLCompatibleBitmap(
+                BitmapFactory.decodeByteArray(bytes, offset, length, options));
+    }
+
+    public static Bitmap requestDecode(JobContext jc, final String filePath,
+            Options options, int targetSize) {
+        FileInputStream fis = null;
+        try {
+            fis = new FileInputStream(filePath);
+            FileDescriptor fd = fis.getFD();
+            return requestDecode(jc, fd, options, targetSize);
+        } catch (Exception ex) {
+            Log.w(TAG, ex);
+            return null;
+        } finally {
+            Utils.closeSilently(fis);
+        }
+    }
+
+    public static Bitmap requestDecode(JobContext jc, FileDescriptor fd,
+            Options options, int targetSize) {
+        if (options == null) options = new Options();
+        jc.setCancelListener(new DecodeCanceller(options));
+
+        options.inJustDecodeBounds = true;
+        BitmapFactory.decodeFileDescriptor(fd, null, options);
+        if (jc.isCancelled()) return null;
+
+        options.inSampleSize = BitmapUtils.computeSampleSizeLarger(
+                options.outWidth, options.outHeight, targetSize);
+        options.inJustDecodeBounds = false;
+        return ensureGLCompatibleBitmap(
+                BitmapFactory.decodeFileDescriptor(fd, null, options));
+    }
+
+    public static Bitmap requestDecode(JobContext jc,
+            FileDescriptor fileDescriptor, Rect paddings, Options options) {
+        if (options == null) options = new Options();
+        jc.setCancelListener(new DecodeCanceller(options));
+        return ensureGLCompatibleBitmap(BitmapFactory.decodeFileDescriptor
+                (fileDescriptor, paddings, 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 requestCreateBitmapRegionDecoder(
+            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 requestCreateBitmapRegionDecoder(
+            JobContext jc, String filePath, boolean shareable) {
+        try {
+            return BitmapRegionDecoder.newInstance(filePath, shareable);
+        } catch (Throwable t)  {
+            Log.w(TAG, t);
+            return null;
+        }
+    }
+
+    public static BitmapRegionDecoder requestCreateBitmapRegionDecoder(
+            JobContext jc, FileDescriptor fd, boolean shareable) {
+        try {
+            return BitmapRegionDecoder.newInstance(fd, shareable);
+        } catch (Throwable t)  {
+            Log.w(TAG, t);
+            return null;
+        }
+    }
+
+    public static BitmapRegionDecoder requestCreateBitmapRegionDecoder(
+            JobContext jc, Uri uri, ContentResolver resolver,
+            boolean shareable) {
+        ParcelFileDescriptor pfd = null;
+        try {
+            pfd = resolver.openFileDescriptor(uri, "r");
+            return BitmapRegionDecoder.newInstance(
+                    pfd.getFileDescriptor(), shareable);
+        } catch (Throwable t) {
+            Log.w(TAG, t);
+            return null;
+        } finally {
+            Utils.closeSilently(pfd);
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/data/DownloadCache.java b/src/com/android/gallery3d/data/DownloadCache.java
new file mode 100644
index 0000000..30ba668
--- /dev/null
+++ b/src/com/android/gallery3d/data/DownloadCache.java
@@ -0,0 +1,398 @@
+/*
+ * 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;
+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 android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import java.io.File;
+import java.net.URL;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.WeakHashMap;
+
+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;
+    private WeakHashMap<Object, Entry> mAssociateMap = new WeakHashMap<Object, Entry>();
+
+    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 lookup(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;
+            }
+        }
+        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);
+        }
+
+        public void associateWith(Object object) {
+            mAssociateMap.put(Utils.checkNotNull(object), this);
+        }
+    }
+
+    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);
+        }
+
+        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);
+            }
+        }
+
+        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() {
+                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 0000000..578523f
--- /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 0000000..9632db9
--- /dev/null
+++ b/src/com/android/gallery3d/data/DownloadUtils.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 com.android.gallery3d.common.Utils;
+import com.android.gallery3d.util.ThreadPool.CancelListener;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import java.io.ByteArrayOutputStream;
+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 byte[] requestDownload(JobContext jc, URL url) {
+        ByteArrayOutputStream baos = null;
+        try {
+            baos = new ByteArrayOutputStream();
+            if (!download(jc, url, baos)) {
+                return null;
+            }
+            return baos.toByteArray();
+        } catch (Throwable t) {
+            Log.w(TAG, t);
+            return null;
+        } finally {
+            Utils.closeSilently(baos);
+        }
+    }
+
+    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() {
+            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/Face.java b/src/com/android/gallery3d/data/Face.java
new file mode 100644
index 0000000..cc1a2d3
--- /dev/null
+++ b/src/com/android/gallery3d/data/Face.java
@@ -0,0 +1,56 @@
+/*
+ * 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.common.Utils;
+
+public class Face implements Comparable<Face> {
+    private String mName;
+    private String mPersonId;
+
+    public Face(String name, String personId) {
+        mName = name;
+        mPersonId = personId;
+        Utils.assertTrue(mName != null && mPersonId != null);
+    }
+
+    public String getName() {
+        return mName;
+    }
+
+    public String getPersonId() {
+        return mPersonId;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj instanceof Face) {
+            Face face = (Face) obj;
+            return mPersonId.equals(face.mPersonId);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return mPersonId.hashCode();
+    }
+
+    public int compareTo(Face another) {
+        return mPersonId.compareTo(another.mPersonId);
+    }
+}
diff --git a/src/com/android/gallery3d/data/FaceClustering.java b/src/com/android/gallery3d/data/FaceClustering.java
new file mode 100644
index 0000000..6ed73b5
--- /dev/null
+++ b/src/com/android/gallery3d/data/FaceClustering.java
@@ -0,0 +1,94 @@
+/*
+ * 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.R;
+
+import android.content.Context;
+
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.TreeMap;
+
+public class FaceClustering extends Clustering {
+    @SuppressWarnings("unused")
+    private static final String TAG = "FaceClustering";
+
+    private ArrayList<ArrayList<Path>> mClusters;
+    private String[] mNames;
+    private String mUntaggedString;
+
+    public FaceClustering(Context context) {
+        mUntaggedString = context.getResources().getString(R.string.untagged);
+    }
+
+    @Override
+    public void run(MediaSet baseSet) {
+        final TreeMap<Face, ArrayList<Path>> map =
+                new TreeMap<Face, ArrayList<Path>>();
+        final ArrayList<Path> untagged = new ArrayList<Path>();
+
+        baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() {
+            public void consume(int index, MediaItem item) {
+                Path path = item.getPath();
+
+                Face[] faces = item.getFaces();
+                if (faces == null || faces.length == 0) {
+                    untagged.add(path);
+                    return;
+                }
+                for (int j = 0; j < faces.length; j++) {
+                    Face key = faces[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<Face, ArrayList<Path>> entry : map.entrySet()) {
+            mNames[i++] = entry.getKey().getName();
+            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/FilterSet.java b/src/com/android/gallery3d/data/FilterSet.java
new file mode 100644
index 0000000..9cb7e02
--- /dev/null
+++ b/src/com/android/gallery3d/data/FilterSet.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;
+
+// FilterSet filters a base MediaSet according to a condition. Currently the
+// condition is a matching media type. It can be extended to other conditions
+// if needed.
+public class FilterSet extends MediaSet implements ContentListener {
+    @SuppressWarnings("unused")
+    private static final String TAG = "FilterSet";
+
+    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 FilterSet(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() {
+            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() {
+            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/FilterSource.java b/src/com/android/gallery3d/data/FilterSource.java
new file mode 100644
index 0000000..d1a04c9
--- /dev/null
+++ b/src/com/android/gallery3d/data/FilterSource.java
@@ -0,0 +1,52 @@
+/*
+ * 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 FilterSource extends MediaSource {
+    private static final String TAG = "FilterSource";
+    private static final int FILTER_BY_MEDIATYPE = 0;
+
+    private GalleryApp mApplication;
+    private PathMatcher mMatcher;
+
+    public FilterSource(GalleryApp application) {
+        super("filter");
+        mApplication = application;
+        mMatcher = new PathMatcher();
+        mMatcher.add("/filter/mediatype/*/*", FILTER_BY_MEDIATYPE);
+    }
+
+    // The name we accept is:
+    // /filter/mediatype/k/{set}
+    // where k is the media type we want.
+    @Override
+    public MediaObject createMediaObject(Path path) {
+        int matchType = mMatcher.match(path);
+        int mediaType = mMatcher.getIntVar(0);
+        String setsName = mMatcher.getVar(1);
+        DataManager dataManager = mApplication.getDataManager();
+        MediaSet[] sets = dataManager.getMediaSetsFromString(setsName);
+        switch (matchType) {
+            case FILTER_BY_MEDIATYPE:
+                return new FilterSet(path, dataManager, sets[0], mediaType);
+            default:
+                throw new RuntimeException("bad path: " + path);
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/data/ImageCacheRequest.java b/src/com/android/gallery3d/data/ImageCacheRequest.java
new file mode 100644
index 0000000..104ff48
--- /dev/null
+++ b/src/com/android/gallery3d/data/ImageCacheRequest.java
@@ -0,0 +1,89 @@
+/*
+ * 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;
+import com.android.gallery3d.common.BitmapUtils;
+import com.android.gallery3d.data.ImageCacheService.ImageData;
+import com.android.gallery3d.util.ThreadPool.Job;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+abstract class ImageCacheRequest implements Job<Bitmap> {
+    private static final String TAG = "ImageCacheRequest";
+
+    protected GalleryApp mApplication;
+    private Path mPath;
+    private int mType;
+    private int mTargetSize;
+
+    public ImageCacheRequest(GalleryApp application,
+            Path path, int type, int targetSize) {
+        mApplication = application;
+        mPath = path;
+        mType = type;
+        mTargetSize = targetSize;
+    }
+
+    public Bitmap run(JobContext jc) {
+        String debugTag = mPath + "," +
+                 ((mType == MediaItem.TYPE_THUMBNAIL) ? "THUMB" :
+                 (mType == MediaItem.TYPE_MICROTHUMBNAIL) ? "MICROTHUMB" : "?");
+        ImageCacheService cacheService = mApplication.getImageCacheService();
+
+        ImageData data = cacheService.getImageData(mPath, mType);
+        if (jc.isCancelled()) return null;
+
+        if (data != null) {
+            BitmapFactory.Options options = new BitmapFactory.Options();
+            options.inPreferredConfig = Bitmap.Config.ARGB_8888;
+            Bitmap bitmap = DecodeUtils.requestDecode(jc, data.mData,
+                    data.mOffset, data.mData.length - data.mOffset, options);
+            if (bitmap == null && !jc.isCancelled()) {
+                Log.w(TAG, "decode cached failed " + debugTag);
+            }
+            return bitmap;
+        } else {
+            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.resizeDownAndCropCenter(bitmap,
+                        mTargetSize, true);
+            } else {
+                bitmap = BitmapUtils.resizeDownBySideLength(bitmap,
+                        mTargetSize, true);
+            }
+            if (jc.isCancelled()) return null;
+
+            byte[] array = BitmapUtils.compressBitmap(bitmap);
+            if (jc.isCancelled()) return null;
+
+            cacheService.putImageData(mPath, 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 0000000..3adce13
--- /dev/null
+++ b/src/com/android/gallery3d/data/ImageCacheService.java
@@ -0,0 +1,105 @@
+/*
+ * 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.BlobCache;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.util.CacheManager;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.content.Context;
+
+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 = 3;
+
+    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);
+    }
+
+    public static class ImageData {
+        public ImageData(byte[] data, int offset) {
+            mData = data;
+            mOffset = offset;
+        }
+        public byte[] mData;
+        public int mOffset;
+    }
+
+    public ImageData getImageData(Path path, int type) {
+        byte[] key = makeKey(path, type);
+        long cacheKey = Utils.crc64Long(key);
+        try {
+            byte[] value = null;
+            synchronized (mCache) {
+                value = mCache.lookup(cacheKey);
+            }
+            if (value == null) return null;
+            if (isSameKey(key, value)) {
+                int offset = key.length;
+                return new ImageData(value, offset);
+            }
+        } catch (IOException ex) {
+            // ignore.
+        }
+        return null;
+    }
+
+    public void putImageData(Path path, int type, byte[] value) {
+        byte[] key = makeKey(path, 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.
+            }
+        }
+    }
+
+    private static byte[] makeKey(Path path, int type) {
+        return GalleryUtils.getBytes(path.toString() + "+" + 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 0000000..5bd4398
--- /dev/null
+++ b/src/com/android/gallery3d/data/LocalAlbum.java
@@ -0,0 +1,252 @@
+/*
+ * 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;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.MediaStore.Images;
+import android.provider.MediaStore.Images.ImageColumns;
+import android.provider.MediaStore.Video;
+import android.provider.MediaStore.Video.VideoColumns;
+
+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 mBucketName;
+    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;
+        mBucketName = 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,
+                LocalAlbumSet.getBucketName(application.getContentResolver(),
+                bucketId));
+    }
+
+    @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) {
+        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 mBucketName;
+    }
+
+    @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;
+    }
+}
diff --git a/src/com/android/gallery3d/data/LocalAlbumSet.java b/src/com/android/gallery3d/data/LocalAlbumSet.java
new file mode 100644
index 0000000..60bef9a
--- /dev/null
+++ b/src/com/android/gallery3d/data/LocalAlbumSet.java
@@ -0,0 +1,263 @@
+/*
+ * 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.common.Utils;
+import com.android.gallery3d.util.GalleryUtils;
+import com.android.gallery3d.util.MediaSetUtils;
+
+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 java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
+
+// 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 {
+    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 String TAG = "LocalAlbumSet";
+    private static final String EXTERNAL_MEDIA = "external";
+
+    // The indices should match the following 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;
+
+    private static final Uri mBaseUri = Files.getContentUri(EXTERNAL_MEDIA);
+    private static final Uri mWatchUriImage = Images.Media.EXTERNAL_CONTENT_URI;
+    private static final Uri mWatchUriVideo = Video.Media.EXTERNAL_CONTENT_URI;
+
+    // The order is import it must match to the index in MediaStore.
+    private static final String[] PROJECTION_BUCKET = {
+            ImageColumns.BUCKET_ID,
+            FileColumns.MEDIA_TYPE,
+            ImageColumns.BUCKET_DISPLAY_NAME };
+
+    private final GalleryApp mApplication;
+    private final int mType;
+    private ArrayList<MediaSet> mAlbums = new ArrayList<MediaSet>();
+    private final ChangeNotifier mNotifierImage;
+    private final ChangeNotifier mNotifierVideo;
+    private final String mName;
+
+    public LocalAlbumSet(Path path, GalleryApp application) {
+        super(path, nextVersionNumber());
+        mApplication = application;
+        mType = getTypeFromPath(path);
+        mNotifierImage = new ChangeNotifier(this, mWatchUriImage, application);
+        mNotifierVideo = new ChangeNotifier(this, mWatchUriVideo, 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());
+        }
+        if ("all".equals(name[1])) return MEDIA_TYPE_ALL;
+        if ("image".equals(name[1])) return MEDIA_TYPE_IMAGE;
+        if ("video".equals(name[1])) return MEDIA_TYPE_VIDEO;
+        throw new IllegalArgumentException(path.toString());
+    }
+
+    @Override
+    public MediaSet getSubMediaSet(int index) {
+        return mAlbums.get(index);
+    }
+
+    @Override
+    public int getSubMediaSetCount() {
+        return mAlbums.size();
+    }
+
+    @Override
+    public String getName() {
+        return mName;
+    }
+
+    private BucketEntry[] loadBucketEntries(Cursor cursor) {
+        HashSet<BucketEntry> buffer = new HashSet<BucketEntry>();
+        int typeBits = 0;
+        if ((mType & MEDIA_TYPE_IMAGE) != 0) {
+            typeBits |= (1 << FileColumns.MEDIA_TYPE_IMAGE);
+        }
+        if ((mType & MEDIA_TYPE_VIDEO) != 0) {
+            typeBits |= (1 << FileColumns.MEDIA_TYPE_VIDEO);
+        }
+        try {
+            while (cursor.moveToNext()) {
+                if ((typeBits & (1 << cursor.getInt(INDEX_MEDIA_TYPE))) != 0) {
+                    buffer.add(new BucketEntry(
+                            cursor.getInt(INDEX_BUCKET_ID),
+                            cursor.getString(INDEX_BUCKET_NAME)));
+                }
+            }
+        } finally {
+            cursor.close();
+        }
+        return buffer.toArray(new BucketEntry[buffer.size()]);
+    }
+
+
+    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;
+    }
+
+    @SuppressWarnings("unchecked")
+    protected ArrayList<MediaSet> loadSubMediaSets() {
+        // Note: it will be faster if we only select media_type and bucket_id.
+        //       need to test the performance if that is worth
+
+        Uri uri = mBaseUri.buildUpon().
+                appendQueryParameter("distinct", "true").build();
+        GalleryUtils.assertNotInRenderThread();
+        Cursor cursor = mApplication.getContentResolver().query(
+                uri, PROJECTION_BUCKET, null, null, null);
+        if (cursor == null) {
+            Log.w(TAG, "cannot open local database: " + uri);
+            return new ArrayList<MediaSet>();
+        }
+        BucketEntry[] entries = loadBucketEntries(cursor);
+        int offset = 0;
+
+        int index = findBucket(entries, MediaSetUtils.CAMERA_BUCKET_ID);
+        if (index != -1) {
+            Utils.swap(entries, index, offset++);
+        }
+        index = findBucket(entries, MediaSetUtils.DOWNLOAD_BUCKET_ID);
+        if (index != -1) {
+            Utils.swap(entries, index, offset++);
+        }
+
+        Arrays.sort(entries, offset, entries.length, new Comparator<BucketEntry>() {
+            @Override
+            public int compare(BucketEntry a, BucketEntry b) {
+                int result = a.bucketName.compareTo(b.bucketName);
+                return result != 0
+                        ? result
+                        : Utils.compare(a.bucketId, b.bucketId);
+            }
+        });
+        ArrayList<MediaSet> albums = new ArrayList<MediaSet>();
+        DataManager dataManager = mApplication.getDataManager();
+        for (BucketEntry entry : entries) {
+            albums.add(getLocalAlbum(dataManager,
+                    mType, mPath, entry.bucketId, entry.bucketName));
+        }
+        for (int i = 0, n = albums.size(); i < n; ++i) {
+            albums.get(i).reload();
+        }
+        return albums;
+    }
+
+    private MediaSet getLocalAlbum(
+            DataManager manager, int type, Path parent, int id, String name) {
+        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)});
+        }
+        throw new IllegalArgumentException(String.valueOf(type));
+    }
+
+    public static String getBucketName(ContentResolver resolver, int bucketId) {
+        Uri uri = mBaseUri.buildUpon()
+                .appendQueryParameter("limit", "1")
+                .build();
+
+        Cursor cursor = resolver.query(
+                uri, PROJECTION_BUCKET, "bucket_id = ?",
+                new String[]{String.valueOf(bucketId)}, null);
+
+        if (cursor == null) {
+            Log.w(TAG, "query fail: " + uri);
+            return "";
+        }
+        try {
+            return cursor.moveToNext()
+                    ? cursor.getString(INDEX_BUCKET_NAME)
+                    : "";
+        } finally {
+            cursor.close();
+        }
+    }
+
+    @Override
+    public long reload() {
+        // "|" is used instead of "||" because we want to clear both flags.
+        if (mNotifierImage.isDirty() | mNotifierVideo.isDirty()) {
+            mDataVersion = nextVersionNumber();
+            mAlbums = loadSubMediaSets();
+        }
+        return mDataVersion;
+    }
+
+    // For debug only. Fake there is a ContentObserver.onChange() event.
+    void fakeChange() {
+        mNotifierImage.fakeChange();
+        mNotifierVideo.fakeChange();
+    }
+
+    private static class BucketEntry {
+        public String bucketName;
+        public int bucketId;
+
+        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/LocalImage.java b/src/com/android/gallery3d/data/LocalImage.java
new file mode 100644
index 0000000..f3dedf0
--- /dev/null
+++ b/src/com/android/gallery3d/data/LocalImage.java
@@ -0,0 +1,289 @@
+/*
+ * 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;
+import com.android.gallery3d.common.BitmapUtils;
+import com.android.gallery3d.util.UpdateHelper;
+import com.android.gallery3d.util.GalleryUtils;
+import com.android.gallery3d.util.ThreadPool.Job;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+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.media.ExifInterface;
+import android.net.Uri;
+import android.provider.MediaStore.Images;
+import android.provider.MediaStore.Images.ImageColumns;
+
+import java.io.File;
+import java.io.IOException;
+
+// LocalImage represents an image in the local storage.
+public class LocalImage extends LocalMediaItem {
+    private static final int THUMBNAIL_TARGET_SIZE = 640;
+    private static final int MICROTHUMBNAIL_TARGET_SIZE = 200;
+
+    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_ID = 11;
+
+    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
+    };
+
+    private final GalleryApp mApplication;
+
+    public int rotation;
+
+    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);
+        filePath = cursor.getString(INDEX_DATA);
+        rotation = cursor.getInt(INDEX_ORIENTATION);
+        bucketId = cursor.getInt(INDEX_BUCKET_ID);
+        fileSize = cursor.getLong(INDEX_SIZE_ID);
+    }
+
+    @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_ID));
+        return uh.isUpdated();
+    }
+
+    @Override
+    public Job<Bitmap> requestImage(int type) {
+        return new LocalImageRequest(mApplication, mPath, type, filePath);
+    }
+
+    public static class LocalImageRequest extends ImageCacheRequest {
+        private String mLocalFilePath;
+
+        LocalImageRequest(GalleryApp application, Path path, int type,
+                String localFilePath) {
+            super(application, path, type, getTargetSize(type));
+            mLocalFilePath = localFilePath;
+        }
+
+        @Override
+        public Bitmap onDecodeOriginal(JobContext jc, int type) {
+            BitmapFactory.Options options = new BitmapFactory.Options();
+            options.inPreferredConfig = Bitmap.Config.ARGB_8888;
+            return DecodeUtils.requestDecode(
+                    jc, mLocalFilePath, options, getTargetSize(type));
+        }
+    }
+
+    static int getTargetSize(int type) {
+        switch (type) {
+            case TYPE_THUMBNAIL:
+                return THUMBNAIL_TARGET_SIZE;
+            case TYPE_MICROTHUMBNAIL:
+                return MICROTHUMBNAIL_TARGET_SIZE;
+            default:
+                throw new RuntimeException(
+                    "should only request thumb/microthumb from cache");
+        }
+    }
+
+    @Override
+    public Job<BitmapRegionDecoder> requestLargeImage() {
+        return new LocalLargeImageRequest(filePath);
+    }
+
+    public static class LocalLargeImageRequest
+            implements Job<BitmapRegionDecoder> {
+        String mLocalFilePath;
+
+        public LocalLargeImageRequest(String localFilePath) {
+            mLocalFilePath = localFilePath;
+        }
+
+        public BitmapRegionDecoder run(JobContext jc) {
+            return DecodeUtils.requestCreateBitmapRegionDecoder(
+                    jc, mLocalFilePath, false);
+        }
+    }
+
+    @Override
+    public int getSupportedOperations() {
+        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 delete() {
+        GalleryUtils.assertNotInRenderThread();
+        Uri baseUri = Images.Media.EXTERNAL_CONTENT_URI;
+        mApplication.getContentResolver().delete(baseUri, "_id=?",
+                new String[]{String.valueOf(id)});
+    }
+
+    private static String getExifOrientation(int orientation) {
+        switch (orientation) {
+            case 0:
+                return String.valueOf(ExifInterface.ORIENTATION_NORMAL);
+            case 90:
+                return String.valueOf(ExifInterface.ORIENTATION_ROTATE_90);
+            case 180:
+                return String.valueOf(ExifInterface.ORIENTATION_ROTATE_180);
+            case 270:
+                return String.valueOf(ExifInterface.ORIENTATION_ROTATE_270);
+            default:
+                throw new AssertionError("invalid: " + orientation);
+        }
+    }
+
+    @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")) {
+            try {
+                ExifInterface exif = new ExifInterface(filePath);
+                exif.setAttribute(ExifInterface.TAG_ORIENTATION,
+                        getExifOrientation(rotation));
+                exif.saveAttributes();
+            } catch (IOException e) {
+                Log.w(TAG, "cannot set exif data: " + filePath);
+            }
+
+            // We need to update the filesize as well
+            fileSize = new File(filePath).length();
+            values.put(Images.Media.SIZE, fileSize);
+        }
+
+        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));
+        MediaDetails.extractExifInfo(details, filePath);
+        return details;
+    }
+
+    @Override
+    public int getRotation() {
+        return rotation;
+    }
+}
diff --git a/src/com/android/gallery3d/data/LocalMediaItem.java b/src/com/android/gallery3d/data/LocalMediaItem.java
new file mode 100644
index 0000000..a76fedf
--- /dev/null
+++ b/src/com/android/gallery3d/data/LocalMediaItem.java
@@ -0,0 +1,103 @@
+/*
+ * 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.GalleryUtils;
+
+import android.database.Cursor;
+
+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 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(dateTakenInMs)));
+
+        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;
+    }
+
+    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 0000000..bb796d5
--- /dev/null
+++ b/src/com/android/gallery3d/data/LocalMergeAlbum.java
@@ -0,0 +1,226 @@
+/*
+ * 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.lang.ref.SoftReference;
+import java.util.ArrayList;
+import java.util.Comparator;
+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 String mName;
+    private FetchCache[] mFetcher;
+    private int mSupportedOperation;
+
+    // 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) {
+        super(path, INVALID_DATA_VERSION);
+        mComparator = comparator;
+        mSources = sources;
+        mName = sources.length == 0 ? "" : sources[0].getName();
+        for (MediaSet set : mSources) {
+            set.addContentListener(this);
+        }
+    }
+
+    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]);
+        mName = mSources.length == 0 ? "" : mSources[0].getName();
+    }
+
+    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 String getName() {
+        return mName;
+    }
+
+    @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 0000000..58ac224
--- /dev/null
+++ b/src/com/android/gallery3d/data/LocalSource.java
@@ -0,0 +1,272 @@
+/*
+ * 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.Gallery;
+import com.android.gallery3d.app.GalleryApp;
+import com.android.gallery3d.data.MediaSet.ItemConsumer;
+
+import android.content.ContentProviderClient;
+import android.content.ContentUris;
+import android.content.UriMatcher;
+import android.net.Uri;
+import android.provider.MediaStore;
+
+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);
+    }
+
+    @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});
+            }
+            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_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("/merge/{/local/image,/local/video}")
+                        .getChild(id);
+        }
+    }
+
+    @Override
+    public Path findPathByUri(Uri uri) {
+        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);
+                }
+            }
+        } 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 LocalImage) {
+            return Path.fromString("/local/image/").getChild(
+                    String.valueOf(((LocalImage) object).getBucketId()));
+        } else if (object instanceof LocalVideo) {
+            return Path.fromString("/local/video/").getChild(
+                    String.valueOf(((LocalVideo) 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> {
+        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 0000000..d1498e8
--- /dev/null
+++ b/src/com/android/gallery3d/data/LocalVideo.java
@@ -0,0 +1,213 @@
+/*
+ * 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.common.BitmapUtils;
+import com.android.gallery3d.util.UpdateHelper;
+import com.android.gallery3d.util.GalleryUtils;
+import com.android.gallery3d.util.ThreadPool.Job;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapRegionDecoder;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.net.Uri;
+import android.provider.MediaStore.Video;
+import android.provider.MediaStore.Video.VideoColumns;
+
+import java.io.File;
+
+// LocalVideo represents a video in the local storage.
+public class LocalVideo extends LocalMediaItem {
+
+    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_ID = 11;
+
+    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
+    };
+
+    private final GalleryApp mApplication;
+    private static Bitmap sOverlay;
+
+    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);
+        filePath = cursor.getString(INDEX_DATA);
+        durationInSec = cursor.getInt(INDEX_DURATION) / 1000;
+        bucketId = cursor.getInt(INDEX_BUCKET_ID);
+        fileSize = cursor.getLong(INDEX_SIZE_ID);
+    }
+
+    @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_ID));
+        return uh.isUpdated();
+    }
+
+    @Override
+    public Job<Bitmap> requestImage(int type) {
+        return new LocalVideoRequest(mApplication, getPath(), type, filePath);
+    }
+
+    public static class LocalVideoRequest extends ImageCacheRequest {
+        private String mLocalFilePath;
+
+        LocalVideoRequest(GalleryApp application, Path path, int type,
+                String localFilePath) {
+            super(application, path, type, LocalImage.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;
+    }
+
+    @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 Uri.fromFile(new File(filePath));
+    }
+
+    @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;
+    }
+}
diff --git a/src/com/android/gallery3d/data/LocationClustering.java b/src/com/android/gallery3d/data/LocationClustering.java
new file mode 100644
index 0000000..3cb1399
--- /dev/null
+++ b/src/com/android/gallery3d/data/LocationClustering.java
@@ -0,0 +1,304 @@
+/*
+ * 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.util.ReverseGeocoder;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.content.Context;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+
+class LocationClustering extends Clustering {
+    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 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);
+    }
+
+    @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() {
+            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) {
+            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 * (float) Math.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 0000000..3384eb6
--- /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 0000000..1b56ac4
--- /dev/null
+++ b/src/com/android/gallery3d/data/MediaDetails.java
@@ -0,0 +1,162 @@
+/*
+ * 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 android.media.ExifInterface;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.TreeMap;
+import java.util.Map.Entry;
+
+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 int getFlashReturn() {
+            return (mState & FLASH_RETURN_MASK) >> 1;
+        }
+
+        public int getFlashMode() {
+            return (mState & FLASH_MODE_MASK) >> 3;
+        }
+
+        public boolean isFlashPresent() {
+            return (mState & FLASH_FUNCTION_MASK) != 0;
+        }
+
+        public boolean isRedEyeModePresent() {
+            return (mState & FLASH_RED_EYE_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();
+    }
+
+    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, ExifInterface exif, String tag,
+            int key) {
+        String value = exif.getAttribute(tag);
+        if (value != null) {
+            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) {
+        try {
+            ExifInterface exif = new ExifInterface(filePath);
+            setExifData(details, exif, ExifInterface.TAG_FLASH, MediaDetails.INDEX_FLASH);
+            setExifData(details, exif, ExifInterface.TAG_IMAGE_WIDTH, MediaDetails.INDEX_WIDTH);
+            setExifData(details, exif, ExifInterface.TAG_IMAGE_LENGTH,
+                    MediaDetails.INDEX_HEIGHT);
+            setExifData(details, exif, ExifInterface.TAG_MAKE, MediaDetails.INDEX_MAKE);
+            setExifData(details, exif, ExifInterface.TAG_MODEL, MediaDetails.INDEX_MODEL);
+            setExifData(details, exif, ExifInterface.TAG_APERTURE, MediaDetails.INDEX_APERTURE);
+            setExifData(details, exif, ExifInterface.TAG_ISO, MediaDetails.INDEX_ISO);
+            setExifData(details, exif, ExifInterface.TAG_WHITE_BALANCE,
+                    MediaDetails.INDEX_WHITE_BALANCE);
+            setExifData(details, exif, ExifInterface.TAG_EXPOSURE_TIME,
+                    MediaDetails.INDEX_EXPOSURE_TIME);
+
+            double data = exif.getAttributeDouble(ExifInterface.TAG_FOCAL_LENGTH, 0);
+            if (data != 0f) {
+                details.addDetail(MediaDetails.INDEX_FOCAL_LENGTH, data);
+                details.setUnit(MediaDetails.INDEX_FOCAL_LENGTH, R.string.unit_mm);
+            }
+        } catch (IOException ex) {
+            // ignore it.
+            Log.w(TAG, "", ex);
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/data/MediaItem.java b/src/com/android/gallery3d/data/MediaItem.java
new file mode 100644
index 0000000..430d832
--- /dev/null
+++ b/src/com/android/gallery3d/data/MediaItem.java
@@ -0,0 +1,75 @@
+/*
+ * 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.ThreadPool.Job;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapRegionDecoder;
+
+// 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 IMAGE_READY = 0;
+    public static final int IMAGE_WAIT = 1;
+    public static final int IMAGE_ERROR = -1;
+
+    // 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;
+    }
+
+    public int getRotation() {
+        return 0;
+    }
+
+    public long getSize() {
+        return 0;
+    }
+
+    public abstract String getMimeType();
+}
diff --git a/src/com/android/gallery3d/data/MediaObject.java b/src/com/android/gallery3d/data/MediaObject.java
new file mode 100644
index 0000000..d0f1672
--- /dev/null
+++ b/src/com/android/gallery3d/data/MediaObject.java
@@ -0,0 +1,130 @@
+/*
+ * 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_IMPORT = 1 << 11;
+    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;
+
+    // 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 MediaObject(Path path, long version) {
+        path.setObject(this);
+        mPath = path;
+        mDataVersion = version;
+    }
+
+    public Path getPath() {
+        return mPath;
+    }
+
+    public int getSupportedOperations() {
+        return 0;
+    }
+
+    public void delete() {
+        throw new UnsupportedOperationException();
+    }
+
+    public void rotate(int degrees) {
+        throw new UnsupportedOperationException();
+    }
+
+    public Uri getContentUri() {
+        throw new UnsupportedOperationException();
+    }
+
+    public Uri getPlayUri() {
+        throw new UnsupportedOperationException();
+    }
+
+    public int getMediaType() {
+        return MEDIA_TYPE_UNKNOWN;
+    }
+
+    public boolean Import() {
+        throw new UnsupportedOperationException();
+    }
+
+    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;
+    }
+}
diff --git a/src/com/android/gallery3d/data/MediaSet.java b/src/com/android/gallery3d/data/MediaSet.java
new file mode 100644
index 0000000..99f00a0
--- /dev/null
+++ b/src/com/android/gallery3d/data/MediaSet.java
@@ -0,0 +1,219 @@
+/*
+ * 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.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 {
+    public static final int MEDIAITEM_BATCH_FETCH_COUNT = 500;
+    public static final int INDEX_NOT_FOUND = -1;
+
+    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 int getSubMediaSetCount() {
+        return 0;
+    }
+
+    public MediaSet getSubMediaSet(int index) {
+        throw new IndexOutOfBoundsException();
+    }
+
+    public boolean isLeafAlbum() {
+        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) {
+            if (list.get(i).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) {
+        if (mListeners.containsKey(listener)) {
+            throw new IllegalArgumentException();
+        }
+        mListeners.put(listener, null);
+    }
+
+    public void removeContentListener(ContentListener listener) {
+        if (!mListeners.containsKey(listener)) {
+            throw new IllegalArgumentException();
+        }
+        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;
+    }
+
+    public Future<Void> requestSync() {
+        return FUTURE_STUB;
+    }
+
+    private static final Future<Void> FUTURE_STUB = new Future<Void>() {
+        @Override
+        public void cancel() {}
+
+        @Override
+        public boolean isCancelled() {
+            return false;
+        }
+
+        @Override
+        public boolean isDone() {
+            return true;
+        }
+
+        @Override
+        public Void get() {
+            return null;
+        }
+
+        @Override
+        public void waitDone() {}
+    };
+}
diff --git a/src/com/android/gallery3d/data/MediaSource.java b/src/com/android/gallery3d/data/MediaSource.java
new file mode 100644
index 0000000..ae98e0f
--- /dev/null
+++ b/src/com/android/gallery3d/data/MediaSource.java
@@ -0,0 +1,93 @@
+/*
+ * 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.data.MediaSet.ItemConsumer;
+
+import android.net.Uri;
+
+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) {
+        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 = 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 0000000..6991c16
--- /dev/null
+++ b/src/com/android/gallery3d/data/MtpClient.java
@@ -0,0 +1,442 @@
+/*
+ * 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.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.MtpDeviceInfo;
+import android.mtp.MtpObjectInfo;
+import android.mtp.MtpStorageInfo;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+
+import java.io.IOException;
+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.
+ */
+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 device 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 android.mtp.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 android.mtp.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/MtpContext.java b/src/com/android/gallery3d/data/MtpContext.java
new file mode 100644
index 0000000..6528494
--- /dev/null
+++ b/src/com/android/gallery3d/data/MtpContext.java
@@ -0,0 +1,141 @@
+package com.android.gallery3d.data;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.content.Context;
+import android.hardware.usb.UsbDevice;
+import android.media.MediaScannerConnection;
+import android.media.MediaScannerConnection.MediaScannerConnectionClient;
+import android.mtp.MtpObjectInfo;
+import android.net.Uri;
+import android.os.Environment;
+import android.util.Log;
+import android.widget.Toast;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+public class MtpContext implements MtpClient.Listener {
+    private static final String TAG = "MtpContext";
+
+    public static final String NAME_IMPORTED_FOLDER = "Imported";
+
+    private ScannerClient mScannerClient;
+    private Context mContext;
+    private MtpClient mClient;
+
+    private static final class ScannerClient implements MediaScannerConnectionClient {
+        ArrayList<String> mPaths = new ArrayList<String>();
+        MediaScannerConnection mScannerConnection;
+        boolean mConnected;
+        Object mLock = new Object();
+
+        public ScannerClient(Context context) {
+            mScannerConnection = new MediaScannerConnection(context, this);
+        }
+
+        public void scanPath(String path) {
+            synchronized (mLock) {
+                if (mConnected) {
+                    mScannerConnection.scanFile(path, null);
+                } else {
+                    mPaths.add(path);
+                    mScannerConnection.connect();
+                }
+            }
+        }
+
+        @Override
+        public void onMediaScannerConnected() {
+            synchronized (mLock) {
+                mConnected = true;
+                if (!mPaths.isEmpty()) {
+                    for (String path : mPaths) {
+                        mScannerConnection.scanFile(path, null);
+                    }
+                    mPaths.clear();
+                }
+            }
+        }
+
+        @Override
+        public void onScanCompleted(String path, Uri uri) {
+        }
+    }
+
+    public MtpContext(Context context) {
+        mContext = context;
+        mScannerClient = new ScannerClient(context);
+        mClient = new MtpClient(mContext);
+    }
+
+    public void pause() {
+        mClient.removeListener(this);
+    }
+
+    public void resume() {
+        mClient.addListener(this);
+        notifyDirty();
+    }
+
+    public void deviceAdded(android.mtp.MtpDevice device) {
+        notifyDirty();
+        showToast(R.string.camera_connected);
+    }
+
+    public void deviceRemoved(android.mtp.MtpDevice device) {
+        notifyDirty();
+        showToast(R.string.camera_disconnected);
+    }
+
+    private void notifyDirty() {
+        mContext.getContentResolver().notifyChange(Uri.parse("mtp://"), null);
+    }
+
+    private void showToast(final int msg) {
+        Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show();
+    }
+
+    public MtpClient getMtpClient() {
+        return mClient;
+    }
+
+    public boolean copyFile(String deviceName, MtpObjectInfo objInfo) {
+        if (GalleryUtils.hasSpaceForSize(objInfo.getCompressedSize())) {
+            File dest = Environment.getExternalStorageDirectory();
+            dest = new File(dest, NAME_IMPORTED_FOLDER);
+            dest.mkdirs();
+            String destPath = new File(dest, objInfo.getName()).getAbsolutePath();
+            int objectId = objInfo.getObjectHandle();
+            if (mClient.importFile(deviceName, objectId, destPath)) {
+                mScannerClient.scanPath(destPath);
+                return true;
+            }
+        } else {
+            Log.w(TAG, "No space to import " + objInfo.getName() +
+                    " whose size = " + objInfo.getCompressedSize());
+        }
+        return false;
+    }
+
+    public boolean copyAlbum(String deviceName, String albumName,
+            List<MtpObjectInfo> children) {
+        File dest = Environment.getExternalStorageDirectory();
+        dest = new File(dest, albumName);
+        dest.mkdirs();
+        int success = 0;
+        for (MtpObjectInfo child : children) {
+            if (!GalleryUtils.hasSpaceForSize(child.getCompressedSize())) continue;
+
+            File importedFile = new File(dest, child.getName());
+            String path = importedFile.getAbsolutePath();
+            if (mClient.importFile(deviceName, child.getObjectHandle(), path)) {
+                mScannerClient.scanPath(path);
+                success++;
+            }
+        }
+        return success == children.size();
+    }
+}
diff --git a/src/com/android/gallery3d/data/MtpDevice.java b/src/com/android/gallery3d/data/MtpDevice.java
new file mode 100644
index 0000000..e654583
--- /dev/null
+++ b/src/com/android/gallery3d/data/MtpDevice.java
@@ -0,0 +1,174 @@
+/*
+ * 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;
+
+import android.hardware.usb.UsbDevice;
+import android.mtp.MtpConstants;
+import android.mtp.MtpObjectInfo;
+import android.mtp.MtpStorageInfo;
+import android.net.Uri;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class MtpDevice extends MediaSet {
+    private static final String TAG = "MtpDevice";
+
+    private final GalleryApp mApplication;
+    private final int mDeviceId;
+    private final String mDeviceName;
+    private final DataManager mDataManager;
+    private final MtpContext mMtpContext;
+    private final String mName;
+    private final ChangeNotifier mNotifier;
+    private final Path mItemPath;
+    private List<MtpObjectInfo> mJpegChildren;
+
+    public MtpDevice(Path path, GalleryApp application, int deviceId,
+            String name, MtpContext mtpContext) {
+        super(path, nextVersionNumber());
+        mApplication = application;
+        mDeviceId = deviceId;
+        mDeviceName = UsbDevice.getDeviceName(deviceId);
+        mDataManager = application.getDataManager();
+        mMtpContext = mtpContext;
+        mName = name;
+        mNotifier = new ChangeNotifier(this, Uri.parse("mtp://"), application);
+        mItemPath = Path.fromString("/mtp/item/" + String.valueOf(deviceId));
+        mJpegChildren = new ArrayList<MtpObjectInfo>();
+    }
+
+    public MtpDevice(Path path, GalleryApp application, int deviceId,
+            MtpContext mtpContext) {
+        this(path, application, deviceId,
+                MtpDeviceSet.getDeviceName(mtpContext, deviceId), mtpContext);
+    }
+
+    private List<MtpObjectInfo> loadItems() {
+        ArrayList<MtpObjectInfo> result = new ArrayList<MtpObjectInfo>();
+
+        List<MtpStorageInfo> storageList = mMtpContext.getMtpClient()
+                 .getStorageList(mDeviceName);
+        if (storageList == null) return result;
+
+        for (MtpStorageInfo info : storageList) {
+            collectJpegChildren(info.getStorageId(), 0, result);
+        }
+
+        return result;
+    }
+
+    private void collectJpegChildren(int storageId, int objectId,
+            ArrayList<MtpObjectInfo> result) {
+        ArrayList<MtpObjectInfo> dirChildren = new ArrayList<MtpObjectInfo>();
+
+        queryChildren(storageId, objectId, result, dirChildren);
+
+        for (int i = 0, n = dirChildren.size(); i < n; i++) {
+            MtpObjectInfo info = dirChildren.get(i);
+            collectJpegChildren(storageId, info.getObjectHandle(), result);
+        }
+    }
+
+    private void queryChildren(int storageId, int objectId,
+            ArrayList<MtpObjectInfo> jpeg, ArrayList<MtpObjectInfo> dir) {
+        List<MtpObjectInfo> children = mMtpContext.getMtpClient().getObjectList(
+                mDeviceName, storageId, objectId);
+        if (children == null) return;
+
+        for (MtpObjectInfo obj : children) {
+            int format = obj.getFormat();
+            switch (format) {
+                case MtpConstants.FORMAT_JFIF:
+                case MtpConstants.FORMAT_EXIF_JPEG:
+                    jpeg.add(obj);
+                    break;
+                case MtpConstants.FORMAT_ASSOCIATION:
+                    dir.add(obj);
+                    break;
+                default:
+                    Log.w(TAG, "other type: name = " + obj.getName()
+                            + ", format = " + format);
+            }
+        }
+    }
+
+    public static MtpObjectInfo getObjectInfo(MtpContext mtpContext, int deviceId,
+            int objectId) {
+        String deviceName = UsbDevice.getDeviceName(deviceId);
+        return mtpContext.getMtpClient().getObjectInfo(deviceName, objectId);
+    }
+
+    @Override
+    public ArrayList<MediaItem> getMediaItem(int start, int count) {
+        ArrayList<MediaItem> result = new ArrayList<MediaItem>();
+        int begin = start;
+        int end = Math.min(start + count, mJpegChildren.size());
+
+        DataManager dataManager = mApplication.getDataManager();
+        for (int i = begin; i < end; i++) {
+            MtpObjectInfo child = mJpegChildren.get(i);
+            Path childPath = mItemPath.getChild(child.getObjectHandle());
+            MtpImage image = (MtpImage) dataManager.peekMediaObject(childPath);
+            if (image == null) {
+                image = new MtpImage(
+                        childPath, mApplication, mDeviceId, child, mMtpContext);
+            } else {
+                image.updateContent(child);
+            }
+            result.add(image);
+        }
+        return result;
+    }
+
+    @Override
+    public int getMediaItemCount() {
+        return mJpegChildren.size();
+    }
+
+    @Override
+    public String getName() {
+        return mName;
+    }
+
+    @Override
+    public long reload() {
+        if (mNotifier.isDirty()) {
+            mDataVersion = nextVersionNumber();
+            mJpegChildren = loadItems();
+        }
+        return mDataVersion;
+    }
+
+    @Override
+    public int getSupportedOperations() {
+        return SUPPORT_IMPORT;
+    }
+
+    @Override
+    public boolean Import() {
+        return mMtpContext.copyAlbum(mDeviceName, mName, mJpegChildren);
+    }
+
+    @Override
+    public boolean isLeafAlbum() {
+        return true;
+    }
+}
diff --git a/src/com/android/gallery3d/data/MtpDeviceSet.java b/src/com/android/gallery3d/data/MtpDeviceSet.java
new file mode 100644
index 0000000..6521623
--- /dev/null
+++ b/src/com/android/gallery3d/data/MtpDeviceSet.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 com.android.gallery3d.R;
+import com.android.gallery3d.app.GalleryApp;
+import com.android.gallery3d.util.MediaSetUtils;
+
+import android.mtp.MtpDeviceInfo;
+import android.net.Uri;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+// MtpDeviceSet -- MtpDevice -- MtpImage
+public class MtpDeviceSet extends MediaSet {
+    private static final String TAG = "MtpDeviceSet";
+
+    private GalleryApp mApplication;
+    private final ArrayList<MediaSet> mDeviceSet = new ArrayList<MediaSet>();
+    private final ChangeNotifier mNotifier;
+    private final MtpContext mMtpContext;
+    private final String mName;
+
+    public MtpDeviceSet(Path path, GalleryApp application, MtpContext mtpContext) {
+        super(path, nextVersionNumber());
+        mApplication = application;
+        mNotifier = new ChangeNotifier(this, Uri.parse("mtp://"), application);
+        mMtpContext = mtpContext;
+        mName = application.getResources().getString(R.string.set_label_mtp_devices);
+    }
+
+    private void loadDevices() {
+        DataManager dataManager = mApplication.getDataManager();
+        // Enumerate all devices
+        mDeviceSet.clear();
+        List<android.mtp.MtpDevice> devices = mMtpContext.getMtpClient().getDeviceList();
+        Log.v(TAG, "loadDevices: " + devices + ", size=" + devices.size());
+        for (android.mtp.MtpDevice mtpDevice : devices) {
+            int deviceId = mtpDevice.getDeviceId();
+            Path childPath = mPath.getChild(deviceId);
+            MtpDevice device = (MtpDevice) dataManager.peekMediaObject(childPath);
+            if (device == null) {
+                device = new MtpDevice(childPath, mApplication, deviceId, mMtpContext);
+            }
+            Log.d(TAG, "add device " + device);
+            mDeviceSet.add(device);
+        }
+
+        Collections.sort(mDeviceSet, MediaSetUtils.NAME_COMPARATOR);
+        for (int i = 0, n = mDeviceSet.size(); i < n; i++) {
+            mDeviceSet.get(i).reload();
+        }
+    }
+
+    public static String getDeviceName(MtpContext mtpContext, int deviceId) {
+        android.mtp.MtpDevice device = mtpContext.getMtpClient().getDevice(deviceId);
+        if (device == null) {
+            return "";
+        }
+        MtpDeviceInfo info = device.getDeviceInfo();
+        if (info == null) {
+            return "";
+        }
+        String manufacturer = info.getManufacturer().trim();
+        String model = info.getModel().trim();
+        return manufacturer + " " + model;
+    }
+
+    @Override
+    public MediaSet getSubMediaSet(int index) {
+        return index < mDeviceSet.size() ? mDeviceSet.get(index) : null;
+    }
+
+    @Override
+    public int getSubMediaSetCount() {
+        return mDeviceSet.size();
+    }
+
+    @Override
+    public String getName() {
+        return mName;
+    }
+
+    @Override
+    public long reload() {
+        if (mNotifier.isDirty()) {
+            mDataVersion = nextVersionNumber();
+            loadDevices();
+        }
+        return mDataVersion;
+    }
+}
diff --git a/src/com/android/gallery3d/data/MtpImage.java b/src/com/android/gallery3d/data/MtpImage.java
new file mode 100644
index 0000000..4766d88
--- /dev/null
+++ b/src/com/android/gallery3d/data/MtpImage.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 com.android.gallery3d.app.GalleryApp;
+import com.android.gallery3d.provider.GalleryProvider;
+import com.android.gallery3d.util.ThreadPool;
+import com.android.gallery3d.util.ThreadPool.Job;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapRegionDecoder;
+import android.hardware.usb.UsbDevice;
+import android.mtp.MtpObjectInfo;
+import android.net.Uri;
+import android.util.Log;
+
+import java.text.DateFormat;
+import java.util.Date;
+
+public class MtpImage extends MediaItem {
+    private static final String TAG = "MtpImage";
+
+    private final int mDeviceId;
+    private int mObjectId;
+    private int mObjectSize;
+    private long mDateTaken;
+    private String mFileName;
+    private final ThreadPool mThreadPool;
+    private final MtpContext mMtpContext;
+    private final MtpObjectInfo mObjInfo;
+    private final int mImageWidth;
+    private final int mImageHeight;
+
+    MtpImage(Path path, GalleryApp application, int deviceId,
+            MtpObjectInfo objInfo, MtpContext mtpContext) {
+        super(path, nextVersionNumber());
+        mDeviceId = deviceId;
+        mObjInfo = objInfo;
+        mObjectId = objInfo.getObjectHandle();
+        mObjectSize = objInfo.getCompressedSize();
+        mDateTaken = objInfo.getDateCreated();
+        mFileName = objInfo.getName();
+        mImageWidth = objInfo.getImagePixWidth();
+        mImageHeight = objInfo.getImagePixHeight();
+        mThreadPool = application.getThreadPool();
+        mMtpContext = mtpContext;
+    }
+
+    MtpImage(Path path, GalleryApp app, int deviceId, int objectId, MtpContext mtpContext) {
+        this(path, app, deviceId, MtpDevice.getObjectInfo(mtpContext, deviceId, objectId),
+                mtpContext);
+    }
+
+    @Override
+    public long getDateInMs() {
+        return mDateTaken;
+    }
+
+    @Override
+    public Job<Bitmap> requestImage(int type) {
+        return new Job<Bitmap>() {
+            public Bitmap run(JobContext jc) {
+                GetThumbnailBytes job = new GetThumbnailBytes();
+                byte[] thumbnail = mThreadPool.submit(job).get();
+                if (thumbnail == null) {
+                    Log.w(TAG, "decoding thumbnail failed");
+                    return null;
+                }
+                return DecodeUtils.requestDecode(jc, thumbnail, null);
+            }
+        };
+    }
+
+    @Override
+    public Job<BitmapRegionDecoder> requestLargeImage() {
+        return new Job<BitmapRegionDecoder>() {
+            public BitmapRegionDecoder run(JobContext jc) {
+                byte[] bytes = mMtpContext.getMtpClient().getObject(
+                        UsbDevice.getDeviceName(mDeviceId), mObjectId, mObjectSize);
+                return DecodeUtils.requestCreateBitmapRegionDecoder(
+                        jc, bytes, 0, bytes.length, false);
+            }
+        };
+    }
+
+    public byte[] getImageData() {
+        return mMtpContext.getMtpClient().getObject(
+                UsbDevice.getDeviceName(mDeviceId), mObjectId, mObjectSize);
+    }
+
+    @Override
+    public boolean Import() {
+        return mMtpContext.copyFile(UsbDevice.getDeviceName(mDeviceId), mObjInfo);
+    }
+
+    @Override
+    public int getSupportedOperations() {
+        return SUPPORT_FULL_IMAGE | SUPPORT_IMPORT;
+    }
+
+    private class GetThumbnailBytes implements Job<byte[]> {
+        public byte[] run(JobContext jc) {
+            return mMtpContext.getMtpClient().getThumbnail(
+                    UsbDevice.getDeviceName(mDeviceId), mObjectId);
+        }
+    }
+
+    public void updateContent(MtpObjectInfo info) {
+        if (mObjectId != info.getObjectHandle() || mDateTaken != info.getDateCreated()) {
+            mObjectId = info.getObjectHandle();
+            mDateTaken = info.getDateCreated();
+            mDataVersion = nextVersionNumber();
+        }
+    }
+
+    @Override
+    public String getMimeType() {
+        // Currently only JPEG is supported in MTP.
+        return "image/jpeg";
+    }
+
+    @Override
+    public int getMediaType() {
+        return MEDIA_TYPE_IMAGE;
+    }
+
+    @Override
+    public long getSize() {
+        return mObjectSize;
+    }
+
+    @Override
+    public Uri getContentUri() {
+        return GalleryProvider.BASE_URI.buildUpon()
+                .appendEncodedPath(mPath.toString().substring(1))
+                .build();
+    }
+
+    @Override
+    public MediaDetails getDetails() {
+        MediaDetails details = super.getDetails();
+        DateFormat formater = DateFormat.getDateTimeInstance();
+        details.addDetail(MediaDetails.INDEX_TITLE, mFileName);
+        details.addDetail(MediaDetails.INDEX_DATETIME, formater.format(new Date(mDateTaken)));
+        details.addDetail(MediaDetails.INDEX_WIDTH, mImageWidth);
+        details.addDetail(MediaDetails.INDEX_HEIGHT, mImageHeight);
+        details.addDetail(MediaDetails.INDEX_SIZE, Long.valueOf(mObjectSize));
+        return details;
+    }
+
+}
diff --git a/src/com/android/gallery3d/data/MtpSource.java b/src/com/android/gallery3d/data/MtpSource.java
new file mode 100644
index 0000000..683a402
--- /dev/null
+++ b/src/com/android/gallery3d/data/MtpSource.java
@@ -0,0 +1,71 @@
+/*
+ * 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 MtpSource extends MediaSource {
+    private static final String TAG = "MtpSource";
+
+    private static final int MTP_DEVICESET = 0;
+    private static final int MTP_DEVICE = 1;
+    private static final int MTP_ITEM = 2;
+
+    GalleryApp mApplication;
+    PathMatcher mMatcher;
+    MtpContext mMtpContext;
+
+    public MtpSource(GalleryApp application) {
+        super("mtp");
+        mApplication = application;
+        mMatcher = new PathMatcher();
+        mMatcher.add("/mtp", MTP_DEVICESET);
+        mMatcher.add("/mtp/*", MTP_DEVICE);
+        mMatcher.add("/mtp/item/*/*", MTP_ITEM);
+        mMtpContext = new MtpContext(mApplication.getAndroidContext());
+    }
+
+    @Override
+    public MediaObject createMediaObject(Path path) {
+        switch (mMatcher.match(path)) {
+            case MTP_DEVICESET: {
+                return new MtpDeviceSet(path, mApplication, mMtpContext);
+            }
+            case MTP_DEVICE: {
+                int deviceId = mMatcher.getIntVar(0);
+                return new MtpDevice(path, mApplication, deviceId, mMtpContext);
+            }
+            case MTP_ITEM: {
+                int deviceId = mMatcher.getIntVar(0);
+                int objectId = mMatcher.getIntVar(1);
+                return new MtpImage(path, mApplication, deviceId, objectId, mMtpContext);
+            }
+            default:
+                throw new RuntimeException("bad path: " + path);
+        }
+    }
+
+    @Override
+    public void pause() {
+        mMtpContext.pause();
+    }
+
+    @Override
+    public void resume() {
+        mMtpContext.resume();
+    }
+}
diff --git a/src/com/android/gallery3d/data/Path.java b/src/com/android/gallery3d/data/Path.java
new file mode 100644
index 0000000..3de1c7c
--- /dev/null
+++ b/src/com/android/gallery3d/data/Path.java
@@ -0,0 +1,237 @@
+/*
+ * 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);
+        }
+    }
+
+    public MediaObject getObject() {
+        synchronized (Path.class) {
+            return (mObject == null) ? null : mObject.get();
+        }
+    }
+
+    @Override
+    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 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() {
+        synchronized (Path.class) {
+            Path current = this;
+            if (current == sRoot) return "";
+            while (current.mParent != sRoot) {
+                current = current.mParent;
+            }
+            return current.mSegment;
+        }
+    }
+
+    public String getSuffix() {
+        // We don't need lock because mSegment is final.
+        return mSegment;
+    }
+
+    public String getSuffix(int level) {
+        // We don't need lock because mSegment and mParent are final.
+        Path p = this;
+        while (level-- != 0) {
+            p = p.mParent;
+        }
+        return p.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 0000000..9c6b840
--- /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/SizeClustering.java b/src/com/android/gallery3d/data/SizeClustering.java
new file mode 100644
index 0000000..7e24b33
--- /dev/null
+++ b/src/com/android/gallery3d/data/SizeClustering.java
@@ -0,0 +1,138 @@
+/*
+ * 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 android.content.Context;
+import android.content.res.Resources;
+
+import java.util.ArrayList;
+
+public class SizeClustering extends Clustering {
+    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;
+    }
+
+    @Override
+    public void run(MediaSet baseSet) {
+        final ArrayList<Path>[] group =
+                (ArrayList<Path>[]) new ArrayList[SIZE_LEVELS.length];
+        baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() {
+            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 = (ArrayList<Path>[]) 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/TagClustering.java b/src/com/android/gallery3d/data/TagClustering.java
new file mode 100644
index 0000000..c873051
--- /dev/null
+++ b/src/com/android/gallery3d/data/TagClustering.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.R;
+
+import android.content.Context;
+
+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() {
+            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 0000000..1ccf14c
--- /dev/null
+++ b/src/com/android/gallery3d/data/TimeClustering.java
@@ -0,0 +1,436 @@
+/*
+ * 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.GalleryUtils;
+
+import android.content.Context;
+import android.text.format.DateFormat;
+import android.text.format.DateUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+
+public class TimeClustering extends Clustering {
+    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> {
+        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() {
+            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/UriImage.java b/src/com/android/gallery3d/data/UriImage.java
new file mode 100644
index 0000000..3a7ed7c
--- /dev/null
+++ b/src/com/android/gallery3d/data/UriImage.java
@@ -0,0 +1,266 @@
+/*
+ * 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;
+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 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 android.webkit.MimeTypeMap;
+
+import java.io.FileNotFoundException;
+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 GalleryApp mApplication;
+
+    public UriImage(GalleryApp application, Path path, Uri uri) {
+        super(path, nextVersionNumber());
+        mUri = uri;
+        mApplication = Utils.checkNotNull(application);
+        mContentType = getMimeType(uri);
+    }
+
+    private String getMimeType(Uri uri) {
+        if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
+            String extension =
+                    MimeTypeMap.getFileExtensionFromUrl(uri.toString());
+            String type = MimeTypeMap.getSingleton()
+                    .getMimeTypeFromExtension(extension);
+            if (type != null) return type;
+        }
+        return mApplication.getContentResolver().getType(uri);
+    }
+
+    @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 {
+                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;
+                }
+                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() {
+            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> {
+        public BitmapRegionDecoder run(JobContext jc) {
+            if (!prepareInputFile(jc)) return null;
+            BitmapRegionDecoder decoder = DecodeUtils.requestCreateBitmapRegionDecoder(
+                    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;
+        }
+
+        public Bitmap run(JobContext jc) {
+            if (!prepareInputFile(jc)) return null;
+            int targetSize = LocalImage.getTargetSize(mType);
+            Options options = new Options();
+            options.inPreferredConfig = Config.ARGB_8888;
+            Bitmap bitmap = DecodeUtils.requestDecode(jc,
+                    mFileDescriptor.getFileDescriptor(), options, targetSize);
+            if (jc.isCancelled() || bitmap == null) {
+                return null;
+            }
+
+            if (mType == MediaItem.TYPE_MICROTHUMBNAIL) {
+                bitmap = BitmapUtils.resizeDownAndCropCenter(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;
+    }
+
+    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);
+        }
+        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();
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/data/UriSource.java b/src/com/android/gallery3d/data/UriSource.java
new file mode 100644
index 0000000..ac62b93
--- /dev/null
+++ b/src/com/android/gallery3d/data/UriSource.java
@@ -0,0 +1,58 @@
+/*
+ * 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;
+
+import android.net.Uri;
+
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+
+class UriSource extends MediaSource {
+    @SuppressWarnings("unused")
+    private static final String TAG = "UriSource";
+
+    private GalleryApp mApplication;
+
+    public UriSource(GalleryApp context) {
+        super("uri");
+        mApplication = context;
+    }
+
+    @Override
+    public MediaObject createMediaObject(Path path) {
+        String segment[] = path.split();
+        if (segment.length != 2) {
+            throw new RuntimeException("bad path: " + path);
+        }
+
+        String decoded = URLDecoder.decode(segment[1]);
+        return new UriImage(mApplication, path, Uri.parse(decoded));
+    }
+
+    @Override
+    public Path findPathByUri(Uri uri) {
+        String type = mApplication.getContentResolver().getType(uri);
+        // Assume the type is image if the type cannot be resolved
+        // This could happen for "http" URI.
+        if (type == null || type.startsWith("image/")) {
+            return Path.fromString("/uri/" + URLEncoder.encode(uri.toString()));
+        }
+        return null;
+    }
+}
diff --git a/src/com/android/gallery3d/provider/GalleryProvider.java b/src/com/android/gallery3d/provider/GalleryProvider.java
new file mode 100644
index 0000000..f5f0f1b
--- /dev/null
+++ b/src/com/android/gallery3d/provider/GalleryProvider.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.provider;
+
+import com.android.gallery3d.app.GalleryApp;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.DownloadCache;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.data.MtpImage;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.picasasource.PicasaSource;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.ParcelFileDescriptor;
+import android.provider.MediaStore.Images.ImageColumns;
+import android.util.Log;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.OutputStream;
+
+public class GalleryProvider extends ContentProvider {
+    private static final String TAG = "GalleryProvider";
+
+    public static final String AUTHORITY = "com.android.gallery3d.provider";
+    public static final Uri BASE_URI = Uri.parse("content://" + AUTHORITY);
+
+    private DataManager mDataManager;
+    private DownloadCache mDownloadCache;
+
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        throw new UnsupportedOperationException();
+    }
+
+    // TODO: consider concurrent access
+    @Override
+    public String getType(Uri uri) {
+        long token = Binder.clearCallingIdentity();
+        try {
+            Path path = Path.fromString(uri.getPath());
+            MediaItem item = (MediaItem) mDataManager.getMediaObject(path);
+            return item != null ? item.getMimeType() : null;
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean onCreate() {
+        GalleryApp app = (GalleryApp) getContext().getApplicationContext();
+        mDataManager = app.getDataManager();
+        return true;
+    }
+
+    private DownloadCache getDownloadCache() {
+        if (mDownloadCache == null) {
+            GalleryApp app = (GalleryApp) getContext().getApplicationContext();
+            mDownloadCache = app.getDownloadCache();
+        }
+        return mDownloadCache;
+    }
+
+    // TODO: consider concurrent access
+    @Override
+    public Cursor query(Uri uri, String[] projection,
+            String selection, String[] selectionArgs, String sortOrder) {
+        long token = Binder.clearCallingIdentity();
+        try {
+            Path path = Path.fromString(uri.getPath());
+            MediaObject object = mDataManager.getMediaObject(path);
+            if (object == null) {
+                Log.w(TAG, "cannot find: " + uri);
+                return null;
+            }
+            if (PicasaSource.isPicasaImage(object)) {
+                return queryPicasaItem(object,
+                        projection, selection, selectionArgs, sortOrder);
+            } else if (object instanceof MtpImage) {
+                return queryMtpItem((MtpImage) object,
+                        projection, selection, selectionArgs, sortOrder);
+            } else {
+                    return null;
+            }
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    private Cursor queryMtpItem(MtpImage image, String[] projection,
+            String selection, String[] selectionArgs, String sortOrder) {
+        Object[] columnValues = new Object[projection.length];
+        for (int i = 0, n = projection.length; i < n; ++i) {
+            String column = projection[i];
+            if (ImageColumns.DISPLAY_NAME.equals(column)) {
+                columnValues[i] = image.getName();
+            } else if (ImageColumns.SIZE.equals(column)){
+                columnValues[i] = image.getSize();
+            } else if (ImageColumns.MIME_TYPE.equals(column)) {
+                columnValues[i] = image.getMimeType();
+            } else if (ImageColumns.DATE_TAKEN.equals(column)) {
+                columnValues[i] = image.getDateInMs();
+            } else {
+                Log.w(TAG, "unsupported column: " + column);
+            }
+        }
+        MatrixCursor cursor = new MatrixCursor(projection);
+        cursor.addRow(columnValues);
+        return cursor;
+    }
+
+    private Cursor queryPicasaItem(MediaObject image, String[] projection,
+            String selection, String[] selectionArgs, String sortOrder) {
+        Object[] columnValues = new Object[projection.length];
+        double latitude = PicasaSource.getLatitude(image);
+        double longitude = PicasaSource.getLongitude(image);
+        boolean isValidLatlong = GalleryUtils.isValidLocation(latitude, longitude);
+
+        for (int i = 0, n = projection.length; i < n; ++i) {
+            String column = projection[i];
+            if (ImageColumns.DISPLAY_NAME.equals(column)) {
+                columnValues[i] = PicasaSource.getImageTitle(image);
+            } else if (ImageColumns.SIZE.equals(column)){
+                columnValues[i] = PicasaSource.getImageSize(image);
+            } else if (ImageColumns.MIME_TYPE.equals(column)) {
+                columnValues[i] = PicasaSource.getContentType(image);
+            } else if (ImageColumns.DATE_TAKEN.equals(column)) {
+                columnValues[i] = PicasaSource.getDateTaken(image);
+            } else if (ImageColumns.LATITUDE.equals(column)) {
+                columnValues[i] = isValidLatlong ? latitude : null;
+            } else if (ImageColumns.LONGITUDE.equals(column)) {
+                columnValues[i] = isValidLatlong ? longitude : null;
+            } else if (ImageColumns.ORIENTATION.equals(column)) {
+                columnValues[i] = PicasaSource.getRotation(image);
+            } else {
+                Log.w(TAG, "unsupported column: " + column);
+            }
+        }
+        MatrixCursor cursor = new MatrixCursor(projection);
+        cursor.addRow(columnValues);
+        return cursor;
+    }
+
+    @Override
+    public ParcelFileDescriptor openFile(Uri uri, String mode)
+            throws FileNotFoundException {
+        long token = Binder.clearCallingIdentity();
+        try {
+            if (mode.contains("w")) {
+                throw new FileNotFoundException("cannot open file for write");
+            }
+            Path path = Path.fromString(uri.getPath());
+            MediaObject object = mDataManager.getMediaObject(path);
+            if (object == null) {
+                throw new FileNotFoundException(uri.toString());
+            }
+            if (PicasaSource.isPicasaImage(object)) {
+                return PicasaSource.openFile(getContext(), object, mode);
+            } else if (object instanceof MtpImage) {
+                return openPipeHelper(uri, null, null, null,
+                        new MtpPipeDataWriter((MtpImage) object));
+            } else {
+                throw new FileNotFoundException("unspported type: " + object);
+            }
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    @Override
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        throw new UnsupportedOperationException();
+    }
+
+    private final class MtpPipeDataWriter implements PipeDataWriter<Object> {
+        private final MtpImage mImage;
+
+        private MtpPipeDataWriter(MtpImage image) {
+            mImage = image;
+        }
+
+        @Override
+        public void writeDataToPipe(ParcelFileDescriptor output,
+                Uri uri, String mimeType, Bundle opts, Object args) {
+            OutputStream os = null;
+            try {
+                os = new ParcelFileDescriptor.AutoCloseOutputStream(output);
+                os.write(mImage.getImageData());
+            } catch (IOException e) {
+                Log.w(TAG, "fail to download: " + uri, e);
+            } finally {
+                Utils.closeSilently(os);
+            }
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/AbstractDisplayItem.java b/src/com/android/gallery3d/ui/AbstractDisplayItem.java
new file mode 100644
index 0000000..aad3919
--- /dev/null
+++ b/src/com/android/gallery3d/ui/AbstractDisplayItem.java
@@ -0,0 +1,114 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.data.MediaItem;
+
+import android.graphics.Bitmap;
+
+public abstract class AbstractDisplayItem extends DisplayItem {
+
+    private static final String TAG = "AbstractDisplayItem";
+
+    private static final int STATE_INVALID = 0x01;
+    private static final int STATE_VALID = 0x02;
+    private static final int STATE_UPDATING = 0x04;
+    private static final int STATE_CANCELING = 0x08;
+    private static final int STATE_ERROR = 0x10;
+
+    private int mState = STATE_INVALID;
+    private boolean mImageRequested = false;
+    private boolean mRecycling = false;
+    private Bitmap mBitmap;
+
+    protected final MediaItem mMediaItem;
+    private int mRotation;
+
+    public AbstractDisplayItem(MediaItem item) {
+        mMediaItem = item;
+        if (item == null) mState = STATE_ERROR;
+        if (item != null) mRotation = mMediaItem.getRotation();
+    }
+
+    protected void updateImage(Bitmap bitmap, boolean isCancelled) {
+        if (mRecycling) {
+            return;
+        }
+
+        if (isCancelled && bitmap == null) {
+            mState = STATE_INVALID;
+            if (mImageRequested) {
+                // request image again.
+                requestImage();
+            }
+            return;
+        }
+
+        mBitmap = bitmap;
+        mState = bitmap == null ? STATE_ERROR : STATE_VALID ;
+        onBitmapAvailable(mBitmap);
+    }
+
+    @Override
+    public int getRotation() {
+        return mRotation;
+    }
+
+    @Override
+    public long getIdentity() {
+        return mMediaItem != null
+                ? System.identityHashCode(mMediaItem.getPath())
+                : System.identityHashCode(this);
+    }
+
+    public void requestImage() {
+        mImageRequested = true;
+        if (mState == STATE_INVALID) {
+            mState = STATE_UPDATING;
+            startLoadBitmap();
+        }
+    }
+
+    public void cancelImageRequest() {
+        mImageRequested = false;
+        if (mState == STATE_UPDATING) {
+            mState = STATE_CANCELING;
+            cancelLoadBitmap();
+        }
+    }
+
+    private boolean inState(int states) {
+        return (mState & states) != 0;
+    }
+
+    public void recycle() {
+        if (!inState(STATE_UPDATING | STATE_CANCELING)) {
+            if (mBitmap != null) mBitmap = null;
+        } else {
+            mRecycling = true;
+            cancelImageRequest();
+        }
+    }
+
+    public boolean isRequestInProgress() {
+        return mImageRequested && inState(STATE_UPDATING | STATE_CANCELING);
+    }
+
+    abstract protected void startLoadBitmap();
+    abstract protected void cancelLoadBitmap();
+    abstract protected void onBitmapAvailable(Bitmap bitmap);
+}
diff --git a/src/com/android/gallery3d/ui/ActionModeHandler.java b/src/com/android/gallery3d/ui/ActionModeHandler.java
new file mode 100644
index 0000000..6c81a3f
--- /dev/null
+++ b/src/com/android/gallery3d/ui/ActionModeHandler.java
@@ -0,0 +1,246 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.GalleryActionBar;
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.ui.CustomMenu.DropDownMenu;
+import com.android.gallery3d.ui.MenuExecutor.ProgressListener;
+import com.android.gallery3d.util.Future;
+import com.android.gallery3d.util.GalleryUtils;
+import com.android.gallery3d.util.ThreadPool.Job;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Handler;
+import android.view.ActionMode;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Button;
+import android.widget.PopupMenu.OnMenuItemClickListener;
+import android.widget.ShareActionProvider;
+
+import java.util.ArrayList;
+
+public class ActionModeHandler implements ActionMode.Callback {
+    private static final String TAG = "ActionModeHandler";
+    private static final int SUPPORT_MULTIPLE_MASK = MediaObject.SUPPORT_DELETE
+            | MediaObject.SUPPORT_ROTATE | MediaObject.SUPPORT_SHARE
+            | MediaObject.SUPPORT_CACHE | MediaObject.SUPPORT_IMPORT;
+
+    public interface ActionModeListener {
+        public boolean onActionItemClicked(MenuItem item);
+    }
+
+    private final GalleryActivity mActivity;
+    private final MenuExecutor mMenuExecutor;
+    private final SelectionManager mSelectionManager;
+    private Menu mMenu;
+    private DropDownMenu mSelectionMenu;
+    private ActionModeListener mListener;
+    private Future<?> mMenuTask;
+    private Handler mMainHandler;
+    private ShareActionProvider mShareActionProvider;
+
+    public ActionModeHandler(
+            GalleryActivity activity, SelectionManager selectionManager) {
+        mActivity = Utils.checkNotNull(activity);
+        mSelectionManager = Utils.checkNotNull(selectionManager);
+        mMenuExecutor = new MenuExecutor(activity, selectionManager);
+        mMainHandler = new Handler(activity.getMainLooper());
+    }
+
+    public ActionMode startActionMode() {
+        Activity a = (Activity) mActivity;
+        final ActionMode actionMode = a.startActionMode(this);
+        CustomMenu customMenu = new CustomMenu(a);
+        View customView = LayoutInflater.from(a).inflate(
+                R.layout.action_mode, null);
+        actionMode.setCustomView(customView);
+        mSelectionMenu = customMenu.addDropDownMenu(
+                (Button) customView.findViewById(R.id.selection_menu),
+                R.menu.selection);
+        customMenu.setOnMenuItemClickListener(new OnMenuItemClickListener() {
+            public boolean onMenuItemClick(MenuItem item) {
+                return onActionItemClicked(actionMode, item);
+            }
+        });
+        return actionMode;
+    }
+
+    public void setTitle(String title) {
+        mSelectionMenu.setTitle(title);
+    }
+
+    public void setActionModeListener(ActionModeListener listener) {
+        mListener = listener;
+    }
+
+    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+        boolean result;
+        if (mListener != null) {
+            result = mListener.onActionItemClicked(item);
+            if (result) {
+                mSelectionManager.leaveSelectionMode();
+                return result;
+            }
+        }
+        ProgressListener listener = null;
+        if (item.getItemId() == R.id.action_import) {
+            listener = new ImportCompleteListener(mActivity);
+        }
+        result = mMenuExecutor.onMenuClicked(item, listener);
+        if (item.getItemId() == R.id.action_select_all) {
+            updateSupportedOperation();
+
+            // For clients who call SelectionManager.selectAll() directly, we need to ensure the
+            // menu status is consistent with selection manager.
+            item = mSelectionMenu.findItem(R.id.action_select_all);
+            if (item != null) {
+                if (mSelectionManager.inSelectAllMode()) {
+                    item.setChecked(true);
+                    item.setTitle(R.string.deselect_all);
+                } else {
+                    item.setChecked(false);
+                    item.setTitle(R.string.select_all);
+                }
+            }
+        }
+        return result;
+    }
+
+    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+        MenuInflater inflater = mode.getMenuInflater();
+        inflater.inflate(R.menu.operation, menu);
+
+        mShareActionProvider = GalleryActionBar.initializeShareActionProvider(menu);
+
+        mMenu = menu;
+        return true;
+    }
+
+    public void onDestroyActionMode(ActionMode mode) {
+        mSelectionManager.leaveSelectionMode();
+    }
+
+    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+        return true;
+    }
+
+    private void updateMenuOptionsAndSharingIntent(JobContext jc) {
+        ArrayList<Path> paths = mSelectionManager.getSelected(true);
+        if (paths.size() == 0) return;
+
+        int operation = MediaObject.SUPPORT_ALL;
+        DataManager manager = mActivity.getDataManager();
+        final ArrayList<Uri> uris = new ArrayList<Uri>();
+        int type = 0;
+        for (Path path : paths) {
+            if (jc.isCancelled()) return;
+            int support = manager.getSupportedOperations(path);
+            type |= manager.getMediaType(path);
+            operation &= support;
+            if ((support & MediaObject.SUPPORT_SHARE) != 0) {
+                uris.add(manager.getContentUri(path));
+            }
+        }
+        final Intent intent = new Intent();
+        final String mimeType = MenuExecutor.getMimeType(type);
+
+        if (paths.size() == 1) {
+            if (!GalleryUtils.isEditorAvailable((Context) mActivity, mimeType)) {
+                operation &= ~MediaObject.SUPPORT_EDIT;
+            }
+        } else {
+            operation &= SUPPORT_MULTIPLE_MASK;
+        }
+
+
+        Log.v(TAG, "Sharing intent MIME type=" + mimeType + ", uri size = "+ uris.size());
+        if (uris.size() > 1) {
+            intent.setAction(Intent.ACTION_SEND_MULTIPLE).setType(mimeType);
+            intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
+        } else {
+            intent.setAction(Intent.ACTION_SEND).setType(mimeType);
+            intent.putExtra(Intent.EXTRA_STREAM, uris.get(0));
+        }
+        intent.setType(mimeType);
+
+        final int supportedOperation = operation;
+
+        mMainHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                mMenuTask = null;
+                MenuExecutor.updateMenuOperation(mMenu, supportedOperation);
+
+                if (mShareActionProvider != null) {
+                    Log.v(TAG, "Sharing intent is ready: action = " + intent.getAction());
+                    mShareActionProvider.setShareIntent(intent);
+                }
+            }
+        });
+    }
+
+    public void updateSupportedOperation(Path path, boolean selected) {
+        // TODO: We need to improve the performance
+        updateSupportedOperation();
+    }
+
+    public void updateSupportedOperation() {
+        if (mMenuTask != null) {
+            mMenuTask.cancel();
+        }
+
+        // Disable share action until share intent is in good shape
+        if (mShareActionProvider != null) {
+            Log.v(TAG, "Disable sharing until intent is ready");
+            mShareActionProvider.setShareIntent(null);
+        }
+
+        // Generate sharing intent and update supported operations in the background
+        mMenuTask = mActivity.getThreadPool().submit(new Job<Void>() {
+            public Void run(JobContext jc) {
+                updateMenuOptionsAndSharingIntent(jc);
+                return null;
+            }
+        });
+    }
+
+    public void pause() {
+        if (mMenuTask != null) {
+            mMenuTask.cancel();
+            mMenuTask = null;
+        }
+        mMenuExecutor.pause();
+    }
+
+    public void resume() {
+        updateSupportedOperation();
+    }
+}
diff --git a/src/com/android/gallery3d/ui/AdaptiveBackground.java b/src/com/android/gallery3d/ui/AdaptiveBackground.java
new file mode 100644
index 0000000..42cb2cc
--- /dev/null
+++ b/src/com/android/gallery3d/ui/AdaptiveBackground.java
@@ -0,0 +1,128 @@
+/*
+ * 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.ui;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.LightingColorFilter;
+import android.graphics.Paint;
+
+import com.android.gallery3d.anim.FloatAnimation;
+
+public class AdaptiveBackground extends GLView {
+
+    private static final int BACKGROUND_WIDTH = 128;
+    private static final int BACKGROUND_HEIGHT = 64;
+    private static final int FILTERED_COLOR = 0xffaaaaaa;
+    private static final int ANIMATION_DURATION = 500;
+
+    private BasicTexture mOldBackground;
+    private BasicTexture mBackground;
+
+    private final Paint mPaint;
+    private Bitmap mPendingBitmap;
+    private final FloatAnimation mAnimation =
+            new FloatAnimation(0, 1, ANIMATION_DURATION);
+
+    public AdaptiveBackground() {
+        Paint paint = new Paint();
+        paint.setFilterBitmap(true);
+        paint.setColorFilter(new LightingColorFilter(FILTERED_COLOR, 0));
+        mPaint = paint;
+    }
+
+    public Bitmap getAdaptiveBitmap(Bitmap bitmap) {
+        Bitmap target = Bitmap.createBitmap(
+                BACKGROUND_WIDTH, BACKGROUND_HEIGHT, Bitmap.Config.ARGB_8888);
+        Canvas canvas = new Canvas(target);
+        int width = bitmap.getWidth();
+        int height = bitmap.getHeight();
+        int left = 0;
+        int top = 0;
+        if (width * BACKGROUND_HEIGHT > height * BACKGROUND_WIDTH) {
+            float scale = (float) BACKGROUND_HEIGHT / height;
+            canvas.scale(scale, scale);
+            left = (BACKGROUND_WIDTH - (int) (width * scale + 0.5)) / 2;
+        } else {
+            float scale = (float) BACKGROUND_WIDTH / width;
+            canvas.scale(scale, scale);
+            top = (BACKGROUND_HEIGHT - (int) (height * scale + 0.5)) / 2;
+        }
+        canvas.drawBitmap(bitmap, left, top, mPaint);
+        BoxBlurFilter.apply(target,
+                BoxBlurFilter.MODE_REPEAT, BoxBlurFilter.MODE_CLAMP);
+        return target;
+    }
+
+    private void startTransition(Bitmap bitmap) {
+        BitmapTexture texture = new BitmapTexture(bitmap);
+        if (mBackground == null) {
+            mBackground = texture;
+        } else {
+            if (mOldBackground != null) mOldBackground.recycle();
+            mOldBackground = mBackground;
+            mBackground = texture;
+            mAnimation.start();
+        }
+        invalidate();
+    }
+
+    public void setImage(Bitmap bitmap) {
+        if (mAnimation.isActive()) {
+            mPendingBitmap = bitmap;
+        } else {
+            startTransition(bitmap);
+        }
+    }
+
+    public void setScrollPosition(int position) {
+        if (mScrollX == position) return;
+        mScrollX = position;
+        invalidate();
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        if (mBackground == null) return;
+
+        int height = getHeight();
+        float scale = (float) height / BACKGROUND_HEIGHT;
+        int width = (int) (BACKGROUND_WIDTH * scale + 0.5f);
+        int scroll = mScrollX;
+        int start = (scroll / width) * width;
+
+        if (mOldBackground == null) {
+            for (int i = start, n = scroll + getWidth(); i < n; i += width) {
+                mBackground.draw(canvas, i - scroll, 0, width, height);
+            }
+        } else {
+            boolean moreAnimation =
+                    mAnimation.calculate(canvas.currentAnimationTimeMillis());
+            float ratio = mAnimation.get();
+            for (int i = start, n = scroll + getWidth(); i < n; i += width) {
+                canvas.drawMixed(mOldBackground,
+                        mBackground, ratio, i - scroll, 0, width, height);
+            }
+            if (moreAnimation) {
+                invalidate();
+            } else if (mPendingBitmap != null) {
+                startTransition(mPendingBitmap);
+                mPendingBitmap = null;
+            }
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/AlbumSetSlidingWindow.java b/src/com/android/gallery3d/ui/AlbumSetSlidingWindow.java
new file mode 100644
index 0000000..92d8b41
--- /dev/null
+++ b/src/com/android/gallery3d/ui/AlbumSetSlidingWindow.java
@@ -0,0 +1,543 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.MediaSet;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.ui.AlbumSetView.AlbumSetItem;
+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 android.graphics.Bitmap;
+import android.graphics.Color;
+import android.os.Message;
+
+public class AlbumSetSlidingWindow implements AlbumSetView.ModelListener {
+    private static final String TAG = "GallerySlidingWindow";
+    private static final int MSG_LOAD_BITMAP_DONE = 0;
+
+    public static interface Listener {
+        public void onSizeChanged(int size);
+        public void onContentInvalidated();
+        public void onWindowContentChanged(
+                int slot, AlbumSetItem old, AlbumSetItem update);
+    }
+
+    private final AlbumSetView.Model mSource;
+    private int mSize;
+    private int mLabelWidth;
+    private int mDisplayItemSize;
+    private int mLabelFontSize;
+
+    private int mContentStart = 0;
+    private int mContentEnd = 0;
+
+    private int mActiveStart = 0;
+    private int mActiveEnd = 0;
+
+    private Listener mListener;
+
+    private final MyAlbumSetItem mData[];
+    private SelectionDrawer mSelectionDrawer;
+    private final ColorTexture mWaitLoadingTexture;
+
+    private SynchronizedHandler mHandler;
+    private ThreadPool mThreadPool;
+
+    private int mActiveRequestCount = 0;
+    private String mLoadingLabel;
+    private boolean mIsActive = false;
+
+    private static class MyAlbumSetItem extends AlbumSetItem {
+        public Path setPath;
+        public int sourceType;
+        public int cacheFlag;
+        public int cacheStatus;
+    }
+
+    public AlbumSetSlidingWindow(GalleryActivity activity, int labelWidth,
+            int displayItemSize, int labelFontSize, SelectionDrawer drawer,
+            AlbumSetView.Model source, int cacheSize) {
+        source.setModelListener(this);
+        mLabelWidth = labelWidth;
+        mDisplayItemSize = displayItemSize;
+        mLabelFontSize = labelFontSize;
+        mLoadingLabel = activity.getAndroidContext().getString(R.string.loading);
+        mSource = source;
+        mSelectionDrawer = drawer;
+        mData = new MyAlbumSetItem[cacheSize];
+        mSize = source.size();
+
+        mWaitLoadingTexture = new ColorTexture(Color.TRANSPARENT);
+        mWaitLoadingTexture.setSize(1, 1);
+
+        mHandler = new SynchronizedHandler(activity.getGLRoot()) {
+            @Override
+            public void handleMessage(Message message) {
+                Utils.assertTrue(message.what == MSG_LOAD_BITMAP_DONE);
+                ((GalleryDisplayItem) message.obj).onLoadBitmapDone();
+            }
+        };
+
+        mThreadPool = activity.getThreadPool();
+    }
+
+    public void setSelectionDrawer(SelectionDrawer drawer) {
+        mSelectionDrawer = drawer;
+    }
+
+    public void setListener(Listener listener) {
+        mListener = listener;
+    }
+
+    public AlbumSetItem get(int slotIndex) {
+        Utils.assertTrue(isActiveSlot(slotIndex),
+                "invalid slot: %s outsides (%s, %s)",
+                slotIndex, mActiveStart, mActiveEnd);
+        return mData[slotIndex % mData.length];
+    }
+
+    public int size() {
+        return mSize;
+    }
+
+    public boolean isActiveSlot(int slotIndex) {
+        return slotIndex >= mActiveStart && slotIndex < mActiveEnd;
+    }
+
+    private void setContentWindow(int contentStart, int contentEnd) {
+        if (contentStart == mContentStart && contentEnd == mContentEnd) return;
+
+        if (contentStart >= mContentEnd || mContentStart >= contentEnd) {
+            for (int i = mContentStart, n = mContentEnd; i < n; ++i) {
+                freeSlotContent(i);
+            }
+            mSource.setActiveWindow(contentStart, contentEnd);
+            for (int i = contentStart; i < contentEnd; ++i) {
+                prepareSlotContent(i);
+            }
+        } else {
+            for (int i = mContentStart; i < contentStart; ++i) {
+                freeSlotContent(i);
+            }
+            for (int i = contentEnd, n = mContentEnd; i < n; ++i) {
+                freeSlotContent(i);
+            }
+            mSource.setActiveWindow(contentStart, contentEnd);
+            for (int i = contentStart, n = mContentStart; i < n; ++i) {
+                prepareSlotContent(i);
+            }
+            for (int i = mContentEnd; i < contentEnd; ++i) {
+                prepareSlotContent(i);
+            }
+        }
+
+        mContentStart = contentStart;
+        mContentEnd = contentEnd;
+    }
+
+    public void setActiveWindow(int start, int end) {
+        Utils.assertTrue(
+                start <= end && end - start <= mData.length && end <= mSize,
+                "start = %s, end = %s, length = %s, size = %s",
+                start, end, mData.length, mSize);
+
+        AlbumSetItem data[] = mData;
+
+        mActiveStart = start;
+        mActiveEnd = end;
+
+        // If no data is visible, keep the cache content
+        if (start == end) return;
+
+        int contentStart = Utils.clamp((start + end) / 2 - data.length / 2,
+                0, Math.max(0, mSize - data.length));
+        int contentEnd = Math.min(contentStart + data.length, mSize);
+        setContentWindow(contentStart, contentEnd);
+        if (mIsActive) updateAllImageRequests();
+    }
+
+    // We would like to request non active slots in the following order:
+    // Order:    8 6 4 2                   1 3 5 7
+    //         |---------|---------------|---------|
+    //                   |<-  active  ->|
+    //         |<-------- cached range ----------->|
+    private void requestNonactiveImages() {
+        int range = Math.max(
+                mContentEnd - mActiveEnd, mActiveStart - mContentStart);
+        for (int i = 0 ;i < range; ++i) {
+            requestImagesInSlot(mActiveEnd + i);
+            requestImagesInSlot(mActiveStart - 1 - i);
+        }
+    }
+
+    private void cancelNonactiveImages() {
+        int range = Math.max(
+                mContentEnd - mActiveEnd, mActiveStart - mContentStart);
+        for (int i = 0 ;i < range; ++i) {
+            cancelImagesInSlot(mActiveEnd + i);
+            cancelImagesInSlot(mActiveStart - 1 - i);
+        }
+    }
+
+    private void requestImagesInSlot(int slotIndex) {
+        if (slotIndex < mContentStart || slotIndex >= mContentEnd) return;
+        AlbumSetItem items = mData[slotIndex % mData.length];
+        for (DisplayItem item : items.covers) {
+            ((GalleryDisplayItem) item).requestImage();
+        }
+    }
+
+    private void cancelImagesInSlot(int slotIndex) {
+        if (slotIndex < mContentStart || slotIndex >= mContentEnd) return;
+        AlbumSetItem items = mData[slotIndex % mData.length];
+        for (DisplayItem item : items.covers) {
+            ((GalleryDisplayItem) item).cancelImageRequest();
+        }
+    }
+
+    private void freeSlotContent(int slotIndex) {
+        AlbumSetItem data[] = mData;
+        int index = slotIndex % data.length;
+        AlbumSetItem original = data[index];
+        if (original != null) {
+            data[index] = null;
+            for (DisplayItem item : original.covers) {
+                ((GalleryDisplayItem) item).recycle();
+            }
+        }
+    }
+
+    private long getMediaSetDataVersion(MediaSet set) {
+        return set == null
+                ? MediaSet.INVALID_DATA_VERSION
+                : set.getDataVersion();
+    }
+
+    private void prepareSlotContent(int slotIndex) {
+        MediaSet set = mSource.getMediaSet(slotIndex);
+
+        MyAlbumSetItem item = new MyAlbumSetItem();
+        MediaItem[] coverItems = mSource.getCoverItems(slotIndex);
+        item.covers = new GalleryDisplayItem[coverItems.length];
+        item.sourceType = identifySourceType(set);
+        item.cacheFlag = identifyCacheFlag(set);
+        item.cacheStatus = identifyCacheStatus(set);
+        item.setPath = set == null ? null : set.getPath();
+
+        for (int i = 0; i < coverItems.length; ++i) {
+            item.covers[i] = new GalleryDisplayItem(slotIndex, i, coverItems[i]);
+        }
+        item.labelItem = new LabelDisplayItem(slotIndex);
+        item.setDataVersion = getMediaSetDataVersion(set);
+        mData[slotIndex % mData.length] = item;
+    }
+
+    private boolean isCoverItemsChanged(int slotIndex) {
+        AlbumSetItem original = mData[slotIndex % mData.length];
+        if (original == null) return true;
+        MediaItem[] coverItems = mSource.getCoverItems(slotIndex);
+
+        if (original.covers.length != coverItems.length) return true;
+        for (int i = 0, n = coverItems.length; i < n; ++i) {
+            GalleryDisplayItem g = (GalleryDisplayItem) original.covers[i];
+            if (g.mDataVersion != coverItems[i].getDataVersion()) return true;
+        }
+        return false;
+    }
+
+    private void updateSlotContent(final int slotIndex) {
+
+        MyAlbumSetItem data[] = mData;
+        int pos = slotIndex % data.length;
+        MyAlbumSetItem original = data[pos];
+
+        if (!isCoverItemsChanged(slotIndex)) {
+            MediaSet set = mSource.getMediaSet(slotIndex);
+            original.sourceType = identifySourceType(set);
+            original.cacheFlag = identifyCacheFlag(set);
+            original.cacheStatus = identifyCacheStatus(set);
+            original.setPath = set == null ? null : set.getPath();
+            ((LabelDisplayItem) original.labelItem).updateContent();
+            if (mListener != null) mListener.onContentInvalidated();
+            return;
+        }
+
+        prepareSlotContent(slotIndex);
+        AlbumSetItem update = data[pos];
+
+        if (mListener != null && isActiveSlot(slotIndex)) {
+            mListener.onWindowContentChanged(slotIndex, original, update);
+        }
+        if (original != null) {
+            for (DisplayItem item : original.covers) {
+                ((GalleryDisplayItem) item).recycle();
+            }
+        }
+    }
+
+    private void notifySlotChanged(int slotIndex) {
+        // If the updated content is not cached, ignore it
+        if (slotIndex < mContentStart || slotIndex >= mContentEnd) {
+            Log.w(TAG, String.format(
+                    "invalid update: %s is outside (%s, %s)",
+                    slotIndex, mContentStart, mContentEnd) );
+            return;
+        }
+        updateSlotContent(slotIndex);
+        boolean isActiveSlot = isActiveSlot(slotIndex);
+        if (mActiveRequestCount == 0 || isActiveSlot) {
+            for (DisplayItem item : mData[slotIndex % mData.length].covers) {
+                GalleryDisplayItem galleryItem = (GalleryDisplayItem) item;
+                galleryItem.requestImage();
+                if (isActiveSlot && galleryItem.isRequestInProgress()) {
+                    ++mActiveRequestCount;
+                }
+            }
+        }
+    }
+
+    private void updateAllImageRequests() {
+        mActiveRequestCount = 0;
+        for (int i = mActiveStart, n = mActiveEnd; i < n; ++i) {
+            for (DisplayItem item : mData[i % mData.length].covers) {
+                GalleryDisplayItem coverItem = (GalleryDisplayItem) item;
+                coverItem.requestImage();
+                if (coverItem.isRequestInProgress()) ++mActiveRequestCount;
+            }
+        }
+        if (mActiveRequestCount == 0) {
+            requestNonactiveImages();
+        } else {
+            cancelNonactiveImages();
+        }
+    }
+
+    private class GalleryDisplayItem extends AbstractDisplayItem
+            implements FutureListener<Bitmap> {
+        private Future<Bitmap> mFuture;
+        private final int mSlotIndex;
+        private final int mCoverIndex;
+        private final int mMediaType;
+        private Texture mContent;
+        private final long mDataVersion;
+
+        public GalleryDisplayItem(int slotIndex, int coverIndex, MediaItem item) {
+            super(item);
+            mSlotIndex = slotIndex;
+            mCoverIndex = coverIndex;
+            mMediaType = item.getMediaType();
+            mDataVersion = item.getDataVersion();
+            updateContent(mWaitLoadingTexture);
+        }
+
+        @Override
+        protected void onBitmapAvailable(Bitmap bitmap) {
+            if (isActiveSlot(mSlotIndex)) {
+                --mActiveRequestCount;
+                if (mActiveRequestCount == 0) requestNonactiveImages();
+            }
+            if (bitmap != null) {
+                BitmapTexture texture = new BitmapTexture(bitmap);
+                texture.setThrottled(true);
+                updateContent(texture);
+                if (mListener != null) mListener.onContentInvalidated();
+            }
+        }
+
+        private void updateContent(Texture content) {
+            mContent = content;
+
+            int width = content.getWidth();
+            int height = content.getHeight();
+
+            float scale = (float) mDisplayItemSize / Math.max(width, height);
+
+            width = (int) Math.floor(width * scale);
+            height = (int) Math.floor(height * scale);
+
+            setSize(width, height);
+        }
+
+        @Override
+        public boolean render(GLCanvas canvas, int pass) {
+            int sourceType = SelectionDrawer.DATASOURCE_TYPE_NOT_CATEGORIZED;
+            int cacheFlag = MediaSet.CACHE_FLAG_NO;
+            int cacheStatus = MediaSet.CACHE_STATUS_NOT_CACHED;
+            MyAlbumSetItem set = mData[mSlotIndex % mData.length];
+            Path path = set.setPath;
+            if (mCoverIndex == 0) {
+                sourceType = set.sourceType;
+                cacheFlag = set.cacheFlag;
+                cacheStatus = set.cacheStatus;
+            }
+
+            mSelectionDrawer.draw(canvas, mContent, mWidth, mHeight,
+                    getRotation(), path, mCoverIndex, sourceType, mMediaType,
+                    cacheFlag == MediaSet.CACHE_FLAG_FULL,
+                    (cacheFlag == MediaSet.CACHE_FLAG_FULL)
+                    && (cacheStatus != MediaSet.CACHE_STATUS_CACHED_FULL));
+            return false;
+        }
+
+        @Override
+        public void startLoadBitmap() {
+            mFuture = mThreadPool.submit(mMediaItem.requestImage(
+                    MediaItem.TYPE_MICROTHUMBNAIL), this);
+        }
+
+        @Override
+        public void cancelLoadBitmap() {
+            mFuture.cancel();
+        }
+
+        @Override
+        public void onFutureDone(Future<Bitmap> future) {
+            mHandler.sendMessage(mHandler.obtainMessage(MSG_LOAD_BITMAP_DONE, this));
+        }
+
+        private void onLoadBitmapDone() {
+            Future<Bitmap> future = mFuture;
+            mFuture = null;
+            updateImage(future.get(), future.isCancelled());
+        }
+
+        @Override
+        public String toString() {
+            return String.format("GalleryDisplayItem(%s, %s)", mSlotIndex, mCoverIndex);
+        }
+    }
+
+    private static int identifySourceType(MediaSet set) {
+        if (set == null) {
+            return SelectionDrawer.DATASOURCE_TYPE_NOT_CATEGORIZED;
+        }
+
+        Path path = set.getPath();
+        if (MediaSetUtils.isCameraSource(path)) {
+            return SelectionDrawer.DATASOURCE_TYPE_CAMERA;
+        }
+
+        int type = SelectionDrawer.DATASOURCE_TYPE_NOT_CATEGORIZED;
+        String prefix = path.getPrefix();
+
+        if (prefix.equals("picasa")) {
+            type = SelectionDrawer.DATASOURCE_TYPE_PICASA;
+        } else if (prefix.equals("local") || prefix.equals("merge")) {
+            type = SelectionDrawer.DATASOURCE_TYPE_LOCAL;
+        } else if (prefix.equals("mtp")) {
+            type = SelectionDrawer.DATASOURCE_TYPE_MTP;
+        }
+
+        return type;
+    }
+
+    private static int identifyCacheFlag(MediaSet set) {
+        if (set == null || (set.getSupportedOperations()
+                & MediaSet.SUPPORT_CACHE) == 0) {
+            return MediaSet.CACHE_FLAG_NO;
+        }
+
+        return set.getCacheFlag();
+    }
+
+    private static int identifyCacheStatus(MediaSet set) {
+        if (set == null || (set.getSupportedOperations()
+                & MediaSet.SUPPORT_CACHE) == 0) {
+            return MediaSet.CACHE_STATUS_NOT_CACHED;
+        }
+
+        return set.getCacheStatus();
+    }
+
+    private class LabelDisplayItem extends DisplayItem {
+        private static final int FONT_COLOR = Color.WHITE;
+
+        private StringTexture mTexture;
+        private String mLabel;
+        private String mPostfix;
+        private final int mSlotIndex;
+
+        public LabelDisplayItem(int slotIndex) {
+            mSlotIndex = slotIndex;
+            updateContent();
+        }
+
+        public boolean updateContent() {
+            String label = mLoadingLabel;
+            String postfix = null;
+            MediaSet set = mSource.getMediaSet(mSlotIndex);
+            if (set != null) {
+                label = Utils.ensureNotNull(set.getName());
+                postfix = " (" + set.getTotalMediaItemCount() + ")";
+            }
+            if (Utils.equals(label, mLabel)
+                    && Utils.equals(postfix, mPostfix)) return false;
+            mTexture = StringTexture.newInstance(
+                    label, postfix, mLabelFontSize, FONT_COLOR, mLabelWidth, true);
+            setSize(mTexture.getWidth(), mTexture.getHeight());
+            return true;
+        }
+
+        @Override
+        public boolean render(GLCanvas canvas, int pass) {
+            mTexture.draw(canvas, -mWidth / 2, -mHeight / 2);
+            return false;
+        }
+
+        @Override
+        public long getIdentity() {
+            return System.identityHashCode(this);
+        }
+    }
+
+    public void onSizeChanged(int size) {
+        if (mSize != size) {
+            mSize = size;
+            if (mListener != null && mIsActive) mListener.onSizeChanged(mSize);
+        }
+    }
+
+    public void onWindowContentChanged(int index) {
+        if (!mIsActive) {
+            // paused, ignore slot changed event
+            return;
+        }
+        notifySlotChanged(index);
+    }
+
+    public void pause() {
+        mIsActive = false;
+        for (int i = mContentStart, n = mContentEnd; i < n; ++i) {
+            freeSlotContent(i);
+        }
+    }
+
+    public void resume() {
+        mIsActive = true;
+        for (int i = mContentStart, n = mContentEnd; i < n; ++i) {
+            prepareSlotContent(i);
+        }
+        updateAllImageRequests();
+    }
+}
diff --git a/src/com/android/gallery3d/ui/AlbumSetView.java b/src/com/android/gallery3d/ui/AlbumSetView.java
new file mode 100644
index 0000000..ef066b3
--- /dev/null
+++ b/src/com/android/gallery3d/ui/AlbumSetView.java
@@ -0,0 +1,240 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.MediaSet;
+import com.android.gallery3d.ui.PositionRepository.Position;
+
+import android.graphics.Rect;
+
+import java.util.Random;
+
+public class AlbumSetView extends SlotView {
+    @SuppressWarnings("unused")
+    private static final String TAG = "AlbumSetView";
+    private static final int CACHE_SIZE = 32;
+    private static final float PHOTO_DISTANCE = 35f;
+
+    private int mVisibleStart;
+    private int mVisibleEnd;
+
+    private Random mRandom = new Random();
+    private long mSeed = mRandom.nextLong();
+
+    private AlbumSetSlidingWindow mDataWindow;
+    private final GalleryActivity mActivity;
+    private final int mSlotWidth;
+    private final int mDisplayItemSize;
+    private final int mLabelFontSize;
+    private final int mLabelOffsetY;
+    private final int mLabelMargin;
+
+    private SelectionDrawer mSelectionDrawer;
+
+    public static interface Model {
+        public MediaItem[] getCoverItems(int index);
+        public MediaSet getMediaSet(int index);
+        public int size();
+        public void setActiveWindow(int start, int end);
+        public void setModelListener(ModelListener listener);
+    }
+
+    public static interface ModelListener {
+        public void onWindowContentChanged(int index);
+        public void onSizeChanged(int size);
+    }
+
+    public static class AlbumSetItem {
+        public DisplayItem[] covers;
+        public DisplayItem labelItem;
+        public long setDataVersion;
+    }
+
+    public AlbumSetView(GalleryActivity activity, SelectionDrawer drawer,
+            int slotWidth, int slotHeight, int displayItemSize,
+            int labelFontSize, int labelOffsetY, int labelMargin) {
+        super(activity.getAndroidContext());
+        mActivity = activity;
+        setSelectionDrawer(drawer);
+        setSlotSize(slotWidth, slotHeight);
+        mSlotWidth = slotWidth;
+        mDisplayItemSize = displayItemSize;
+        mLabelFontSize = labelFontSize;
+        mLabelOffsetY = labelOffsetY;
+        mLabelMargin = labelMargin;
+    }
+
+    public void setSelectionDrawer(SelectionDrawer drawer) {
+        mSelectionDrawer = drawer;
+        if (mDataWindow != null) {
+            mDataWindow.setSelectionDrawer(drawer);
+        }
+    }
+
+    public void setModel(AlbumSetView.Model model) {
+        if (mDataWindow != null) {
+            mDataWindow.setListener(null);
+            setSlotCount(0);
+            mDataWindow = null;
+        }
+        if (model != null) {
+            mDataWindow = new AlbumSetSlidingWindow(mActivity,
+                    mSlotWidth - mLabelMargin * 2, mDisplayItemSize, mLabelFontSize,
+                    mSelectionDrawer, model, CACHE_SIZE);
+            mDataWindow.setListener(new MyCacheListener());
+            setSlotCount(mDataWindow.size());
+            updateVisibleRange(getVisibleStart(), getVisibleEnd());
+        }
+    }
+
+    private void putSlotContent(int slotIndex, AlbumSetItem entry) {
+        // Get displayItems from mItemsetMap or create them from MediaSet.
+        Utils.assertTrue(entry != null);
+        Rect rect = getSlotRect(slotIndex);
+
+        DisplayItem[] items = entry.covers;
+        mRandom.setSeed(slotIndex ^ mSeed);
+
+        int x = (rect.left + rect.right) / 2;
+        int y = (rect.top + rect.bottom) / 2;
+
+        Position basePosition = new Position(x, y, 0);
+
+        // Put the cover items in reverse order, so that the first item is on
+        // top of the rest.
+        int labelY = y + mLabelOffsetY - entry.labelItem.getHeight() / 2;
+        Position position = new Position(x, labelY, 0f);
+        putDisplayItem(position, position, entry.labelItem);
+
+        for (int i = 0, n = items.length; i < n; ++i) {
+            DisplayItem item = items[i];
+            float dx = 0;
+            float dy = 0;
+            float dz = 0f;
+            float theta = 0;
+            if (i != 0) {
+                dz = i * PHOTO_DISTANCE;
+            }
+            position = new Position(x + dx, y + dy, dz);
+            position.theta = theta;
+            putDisplayItem(position, basePosition, item);
+        }
+
+    }
+
+    private void freeSlotContent(int index, AlbumSetItem entry) {
+        if (entry == null) return;
+        for (DisplayItem item : entry.covers) {
+            removeDisplayItem(item);
+        }
+        removeDisplayItem(entry.labelItem);
+    }
+
+    public int size() {
+        return mDataWindow.size();
+    }
+
+    @Override
+    public void onLayoutChanged(int width, int height) {
+        updateVisibleRange(0, 0);
+        updateVisibleRange(getVisibleStart(), getVisibleEnd());
+    }
+
+    @Override
+    public void onScrollPositionChanged(int position) {
+        super.onScrollPositionChanged(position);
+        updateVisibleRange(getVisibleStart(), getVisibleEnd());
+    }
+
+    private void updateVisibleRange(int start, int end) {
+        if (start == mVisibleStart && end == mVisibleEnd) {
+            // we need to set the mDataWindow active range in any case.
+            mDataWindow.setActiveWindow(start, end);
+            return;
+        }
+        if (start >= mVisibleEnd || mVisibleStart >= end) {
+            for (int i = mVisibleStart, n = mVisibleEnd; i < n; ++i) {
+                freeSlotContent(i, mDataWindow.get(i));
+            }
+            mDataWindow.setActiveWindow(start, end);
+            for (int i = start; i < end; ++i) {
+                putSlotContent(i, mDataWindow.get(i));
+            }
+        } else {
+            for (int i = mVisibleStart; i < start; ++i) {
+                freeSlotContent(i, mDataWindow.get(i));
+            }
+            for (int i = end, n = mVisibleEnd; i < n; ++i) {
+                freeSlotContent(i, mDataWindow.get(i));
+            }
+            mDataWindow.setActiveWindow(start, end);
+            for (int i = start, n = mVisibleStart; i < n; ++i) {
+                putSlotContent(i, mDataWindow.get(i));
+            }
+            for (int i = mVisibleEnd; i < end; ++i) {
+                putSlotContent(i, mDataWindow.get(i));
+            }
+        }
+        mVisibleStart = start;
+        mVisibleEnd = end;
+
+        invalidate();
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        mSelectionDrawer.prepareDrawing();
+        super.render(canvas);
+    }
+
+    private class MyCacheListener implements AlbumSetSlidingWindow.Listener {
+
+        public void onSizeChanged(int size) {
+            // If the layout parameters are changed, we need reput all items.
+            if (setSlotCount(size)) updateVisibleRange(0, 0);
+            updateVisibleRange(getVisibleStart(), getVisibleEnd());
+            invalidate();
+        }
+
+        public void onWindowContentChanged(int slot, AlbumSetItem old, AlbumSetItem update) {
+            freeSlotContent(slot, old);
+            putSlotContent(slot, update);
+            invalidate();
+        }
+
+        public void onContentInvalidated() {
+            invalidate();
+        }
+    }
+
+    public void pause() {
+        for (int i = mVisibleStart, n = mVisibleEnd; i < n; ++i) {
+            freeSlotContent(i, mDataWindow.get(i));
+        }
+        mDataWindow.pause();
+    }
+
+    public void resume() {
+        mDataWindow.resume();
+        for (int i = mVisibleStart, n = mVisibleEnd; i < n; ++i) {
+            putSlotContent(i, mDataWindow.get(i));
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/AlbumSlidingWindow.java b/src/com/android/gallery3d/ui/AlbumSlidingWindow.java
new file mode 100644
index 0000000..9e44bd1
--- /dev/null
+++ b/src/com/android/gallery3d/ui/AlbumSlidingWindow.java
@@ -0,0 +1,433 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.common.BitmapUtils;
+import com.android.gallery3d.common.LruCache;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.util.Future;
+import com.android.gallery3d.util.FutureListener;
+import com.android.gallery3d.util.ThreadPool;
+import com.android.gallery3d.util.ThreadPool.Job;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.os.Message;
+
+public class AlbumSlidingWindow implements AlbumView.ModelListener {
+    @SuppressWarnings("unused")
+    private static final String TAG = "AlbumSlidingWindow";
+
+    private static final int MSG_LOAD_BITMAP_DONE = 0;
+    private static final int MSG_UPDATE_SLOT = 1;
+    private static final int MIN_THUMB_SIZE = 100;
+
+    public static interface Listener {
+        public void onSizeChanged(int size);
+        public void onContentInvalidated();
+        public void onWindowContentChanged(
+                int slot, DisplayItem old, DisplayItem update);
+    }
+
+    private final AlbumView.Model mSource;
+    private int mSize;
+
+    private int mContentStart = 0;
+    private int mContentEnd = 0;
+
+    private int mActiveStart = 0;
+    private int mActiveEnd = 0;
+
+    private Listener mListener;
+    private int mFocusIndex = -1;
+
+    private final AlbumDisplayItem mData[];
+    private final ColorTexture mWaitLoadingTexture;
+    private SelectionDrawer mSelectionDrawer;
+
+    private SynchronizedHandler mHandler;
+    private ThreadPool mThreadPool;
+    private int mSlotWidth, mSlotHeight;
+
+    private int mActiveRequestCount = 0;
+    private boolean mIsActive = false;
+
+    private int mDisplayItemSize;  // 0: disabled
+    private LruCache<Path, Bitmap> mImageCache = new LruCache<Path, Bitmap>(1000);
+
+    public AlbumSlidingWindow(GalleryActivity activity,
+            AlbumView.Model source, int cacheSize,
+            int slotWidth, int slotHeight, int displayItemSize) {
+        source.setModelListener(this);
+        mSource = source;
+        mData = new AlbumDisplayItem[cacheSize];
+        mSize = source.size();
+        mSlotWidth = slotWidth;
+        mSlotHeight = slotHeight;
+        mDisplayItemSize = displayItemSize;
+
+        mWaitLoadingTexture = new ColorTexture(Color.TRANSPARENT);
+        mWaitLoadingTexture.setSize(1, 1);
+
+        mHandler = new SynchronizedHandler(activity.getGLRoot()) {
+            @Override
+            public void handleMessage(Message message) {
+                switch (message.what) {
+                    case MSG_LOAD_BITMAP_DONE: {
+                        ((AlbumDisplayItem) message.obj).onLoadBitmapDone();
+                        break;
+                    }
+                    case MSG_UPDATE_SLOT: {
+                        updateSlotContent(message.arg1);
+                        break;
+                    }
+                }
+            }
+        };
+
+        mThreadPool = activity.getThreadPool();
+    }
+
+    public void setSelectionDrawer(SelectionDrawer drawer) {
+        mSelectionDrawer = drawer;
+    }
+
+    public void setListener(Listener listener) {
+        mListener = listener;
+    }
+
+    public void setFocusIndex(int slotIndex) {
+        mFocusIndex = slotIndex;
+    }
+
+    public DisplayItem get(int slotIndex) {
+        Utils.assertTrue(isActiveSlot(slotIndex),
+                "invalid slot: %s outsides (%s, %s)",
+                slotIndex, mActiveStart, mActiveEnd);
+        return mData[slotIndex % mData.length];
+    }
+
+    public int size() {
+        return mSize;
+    }
+
+    public boolean isActiveSlot(int slotIndex) {
+        return slotIndex >= mActiveStart && slotIndex < mActiveEnd;
+    }
+
+    private void setContentWindow(int contentStart, int contentEnd) {
+        if (contentStart == mContentStart && contentEnd == mContentEnd) return;
+
+        if (!mIsActive) {
+            mContentStart = contentStart;
+            mContentEnd = contentEnd;
+            mSource.setActiveWindow(contentStart, contentEnd);
+            return;
+        }
+
+        if (contentStart >= mContentEnd || mContentStart >= contentEnd) {
+            for (int i = mContentStart, n = mContentEnd; i < n; ++i) {
+                freeSlotContent(i);
+            }
+            mSource.setActiveWindow(contentStart, contentEnd);
+            for (int i = contentStart; i < contentEnd; ++i) {
+                prepareSlotContent(i);
+            }
+        } else {
+            for (int i = mContentStart; i < contentStart; ++i) {
+                freeSlotContent(i);
+            }
+            for (int i = contentEnd, n = mContentEnd; i < n; ++i) {
+                freeSlotContent(i);
+            }
+            mSource.setActiveWindow(contentStart, contentEnd);
+            for (int i = contentStart, n = mContentStart; i < n; ++i) {
+                prepareSlotContent(i);
+            }
+            for (int i = mContentEnd; i < contentEnd; ++i) {
+                prepareSlotContent(i);
+            }
+        }
+
+        mContentStart = contentStart;
+        mContentEnd = contentEnd;
+    }
+
+    public void setActiveWindow(int start, int end) {
+        Utils.assertTrue(start <= end
+                && end - start <= mData.length && end <= mSize,
+                "%s, %s, %s, %s", start, end, mData.length, mSize);
+        DisplayItem data[] = mData;
+
+        mActiveStart = start;
+        mActiveEnd = end;
+
+        // If no data is visible, keep the cache content
+        if (start == end) return;
+
+        int contentStart = Utils.clamp((start + end) / 2 - data.length / 2,
+                0, Math.max(0, mSize - data.length));
+        int contentEnd = Math.min(contentStart + data.length, mSize);
+        setContentWindow(contentStart, contentEnd);
+        if (mIsActive) updateAllImageRequests();
+    }
+
+    // We would like to request non active slots in the following order:
+    // Order:    8 6 4 2                   1 3 5 7
+    //         |---------|---------------|---------|
+    //                   |<-  active  ->|
+    //         |<-------- cached range ----------->|
+    private void requestNonactiveImages() {
+        int range = Math.max(
+                (mContentEnd - mActiveEnd), (mActiveStart - mContentStart));
+        for (int i = 0 ;i < range; ++i) {
+            requestSlotImage(mActiveEnd + i, false);
+            requestSlotImage(mActiveStart - 1 - i, false);
+        }
+    }
+
+    private void requestSlotImage(int slotIndex, boolean isActive) {
+        if (slotIndex < mContentStart || slotIndex >= mContentEnd) return;
+        AlbumDisplayItem item = mData[slotIndex % mData.length];
+        item.requestImage();
+    }
+
+    private void cancelNonactiveImages() {
+        int range = Math.max(
+                (mContentEnd - mActiveEnd), (mActiveStart - mContentStart));
+        for (int i = 0 ;i < range; ++i) {
+            cancelSlotImage(mActiveEnd + i, false);
+            cancelSlotImage(mActiveStart - 1 - i, false);
+        }
+    }
+
+    private void cancelSlotImage(int slotIndex, boolean isActive) {
+        if (slotIndex < mContentStart || slotIndex >= mContentEnd) return;
+        AlbumDisplayItem item = mData[slotIndex % mData.length];
+        item.cancelImageRequest();
+    }
+
+    private void freeSlotContent(int slotIndex) {
+        AlbumDisplayItem data[] = mData;
+        int index = slotIndex % data.length;
+        AlbumDisplayItem original = data[index];
+        if (original != null) {
+            original.recycle();
+            data[index] = null;
+        }
+    }
+
+    private void prepareSlotContent(final int slotIndex) {
+        mData[slotIndex % mData.length] = new AlbumDisplayItem(
+                slotIndex, mSource.get(slotIndex));
+    }
+
+    private void updateSlotContent(final int slotIndex) {
+        MediaItem item = mSource.get(slotIndex);
+        AlbumDisplayItem data[] = mData;
+        int index = slotIndex % data.length;
+        AlbumDisplayItem original = data[index];
+        AlbumDisplayItem update = new AlbumDisplayItem(slotIndex, item);
+        data[index] = update;
+        boolean isActive = isActiveSlot(slotIndex);
+        if (mListener != null && isActive) {
+            mListener.onWindowContentChanged(slotIndex, original, update);
+        }
+        if (original != null) {
+            if (isActive && original.isRequestInProgress()) {
+                --mActiveRequestCount;
+            }
+            original.recycle();
+        }
+        if (isActive) {
+            if (mActiveRequestCount == 0) cancelNonactiveImages();
+            ++mActiveRequestCount;
+            update.requestImage();
+        } else {
+            if (mActiveRequestCount == 0) update.requestImage();
+        }
+    }
+
+    private void updateAllImageRequests() {
+        mActiveRequestCount = 0;
+        AlbumDisplayItem data[] = mData;
+        for (int i = mActiveStart, n = mActiveEnd; i < n; ++i) {
+            AlbumDisplayItem item = data[i % data.length];
+            item.requestImage();
+            if (item.isRequestInProgress()) ++mActiveRequestCount;
+        }
+        if (mActiveRequestCount == 0) {
+            requestNonactiveImages();
+        } else {
+            cancelNonactiveImages();
+        }
+    }
+
+    private class AlbumDisplayItem extends AbstractDisplayItem
+            implements FutureListener<Bitmap>, Job<Bitmap> {
+        private Future<Bitmap> mFuture;
+        private final int mSlotIndex;
+        private final int mMediaType;
+        private Texture mContent;
+
+        public AlbumDisplayItem(int slotIndex, MediaItem item) {
+            super(item);
+            mMediaType = (item == null)
+                    ? MediaItem.MEDIA_TYPE_UNKNOWN
+                    : item.getMediaType();
+            mSlotIndex = slotIndex;
+            updateContent(mWaitLoadingTexture);
+        }
+
+        @Override
+        protected void onBitmapAvailable(Bitmap bitmap) {
+            boolean isActiveSlot = isActiveSlot(mSlotIndex);
+            if (isActiveSlot) {
+                --mActiveRequestCount;
+                if (mActiveRequestCount == 0) requestNonactiveImages();
+            }
+            if (bitmap != null) {
+                BitmapTexture texture = new BitmapTexture(bitmap);
+                texture.setThrottled(true);
+                updateContent(texture);
+                if (mListener != null && isActiveSlot) {
+                    mListener.onContentInvalidated();
+                }
+            }
+        }
+
+        private void updateContent(Texture content) {
+            mContent = content;
+
+            int width = mContent.getWidth();
+            int height = mContent.getHeight();
+
+            float scalex = mDisplayItemSize / (float) width;
+            float scaley = mDisplayItemSize / (float) height;
+            float scale = Math.min(scalex, scaley);
+
+            width = (int) Math.floor(width * scale);
+            height = (int) Math.floor(height * scale);
+
+            setSize(width, height);
+        }
+
+        @Override
+        public boolean render(GLCanvas canvas, int pass) {
+            if (pass == 0) {
+                Path path = null;
+                if (mMediaItem != null) path = mMediaItem.getPath();
+                mSelectionDrawer.draw(canvas, mContent, mWidth, mHeight,
+                        getRotation(), path, mMediaType);
+                return (mFocusIndex == mSlotIndex);
+            } else if (pass == 1) {
+                mSelectionDrawer.drawFocus(canvas, mWidth, mHeight);
+            }
+            return false;
+        }
+
+        @Override
+        public void startLoadBitmap() {
+            if (mDisplayItemSize < MIN_THUMB_SIZE) {
+                Path path = mMediaItem.getPath();
+                if (mImageCache.containsKey(path)) {
+                    Bitmap bitmap = mImageCache.get(path);
+                    updateImage(bitmap, false);
+                    return;
+                }
+                mFuture = mThreadPool.submit(this, this);
+            } else {
+                mFuture = mThreadPool.submit(mMediaItem.requestImage(
+                        MediaItem.TYPE_MICROTHUMBNAIL), this);
+            }
+        }
+
+        // This gets the bitmap and scale it down.
+        public Bitmap run(JobContext jc) {
+            Job<Bitmap> job = mMediaItem.requestImage(
+                    MediaItem.TYPE_MICROTHUMBNAIL);
+            Bitmap bitmap = job.run(jc);
+            if (bitmap != null) {
+                bitmap = BitmapUtils.resizeDownBySideLength(
+                        bitmap, mDisplayItemSize, true);
+            }
+            return bitmap;
+        }
+
+        @Override
+        public void cancelLoadBitmap() {
+            if (mFuture != null) {
+                mFuture.cancel();
+            }
+        }
+
+        @Override
+        public void onFutureDone(Future<Bitmap> bitmap) {
+            mHandler.sendMessage(mHandler.obtainMessage(MSG_LOAD_BITMAP_DONE, this));
+        }
+
+        private void onLoadBitmapDone() {
+            Future<Bitmap> future = mFuture;
+            mFuture = null;
+            Bitmap bitmap = future.get();
+            boolean isCancelled = future.isCancelled();
+            if (mDisplayItemSize < MIN_THUMB_SIZE && (bitmap != null || !isCancelled)) {
+                Path path = mMediaItem.getPath();
+                mImageCache.put(path, bitmap);
+            }
+            updateImage(bitmap, isCancelled);
+        }
+
+        @Override
+        public String toString() {
+            return String.format("AlbumDisplayItem[%s]", mSlotIndex);
+        }
+    }
+
+    public void onSizeChanged(int size) {
+        if (mSize != size) {
+            mSize = size;
+            if (mListener != null) mListener.onSizeChanged(mSize);
+        }
+    }
+
+    public void onWindowContentChanged(int index) {
+        if (index >= mContentStart && index < mContentEnd && mIsActive) {
+            updateSlotContent(index);
+        }
+    }
+
+    public void resume() {
+        mIsActive = true;
+        for (int i = mContentStart, n = mContentEnd; i < n; ++i) {
+            prepareSlotContent(i);
+        }
+        updateAllImageRequests();
+    }
+
+    public void pause() {
+        mIsActive = false;
+        for (int i = mContentStart, n = mContentEnd; i < n; ++i) {
+            freeSlotContent(i);
+        }
+        mImageCache.clear();
+    }
+}
diff --git a/src/com/android/gallery3d/ui/AlbumView.java b/src/com/android/gallery3d/ui/AlbumView.java
new file mode 100644
index 0000000..417611a
--- /dev/null
+++ b/src/com/android/gallery3d/ui/AlbumView.java
@@ -0,0 +1,197 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.ui.PositionRepository.Position;
+
+import android.graphics.Rect;
+
+public class AlbumView extends SlotView {
+    @SuppressWarnings("unused")
+    private static final String TAG = "AlbumView";
+    private static final int CACHE_SIZE = 64;
+
+    private int mVisibleStart = 0;
+    private int mVisibleEnd = 0;
+
+    private AlbumSlidingWindow mDataWindow;
+    private final GalleryActivity mActivity;
+    private SelectionDrawer mSelectionDrawer;
+    private int mSlotWidth, mSlotHeight;
+    private int mDisplayItemSize;
+
+    private boolean mIsActive = false;
+
+    public static interface Model {
+        public int size();
+        public MediaItem get(int index);
+        public void setActiveWindow(int start, int end);
+        public void setModelListener(ModelListener listener);
+    }
+
+    public static interface ModelListener {
+        public void onWindowContentChanged(int index);
+        public void onSizeChanged(int size);
+    }
+
+    public AlbumView(GalleryActivity activity,
+            int slotWidth, int slotHeight, int displayItemSize) {
+        super(activity.getAndroidContext());
+        mSlotWidth = slotWidth;
+        mSlotHeight = slotHeight;
+        mDisplayItemSize = displayItemSize;
+        setSlotSize(slotWidth, slotHeight);
+        mActivity = activity;
+    }
+
+    public void setSelectionDrawer(SelectionDrawer drawer) {
+        mSelectionDrawer = drawer;
+        if (mDataWindow != null) mDataWindow.setSelectionDrawer(drawer);
+    }
+
+    public void setModel(Model model) {
+        if (mDataWindow != null) {
+            mDataWindow.setListener(null);
+            setSlotCount(0);
+            mDataWindow = null;
+        }
+        if (model != null) {
+            mDataWindow = new AlbumSlidingWindow(
+                    mActivity, model, CACHE_SIZE,
+                    mSlotWidth, mSlotHeight, mDisplayItemSize);
+            mDataWindow.setSelectionDrawer(mSelectionDrawer);
+            mDataWindow.setListener(new MyDataModelListener());
+            setSlotCount(model.size());
+            updateVisibleRange(getVisibleStart(), getVisibleEnd());
+        }
+    }
+
+    public void setFocusIndex(int slotIndex) {
+        if (mDataWindow != null) {
+            mDataWindow.setFocusIndex(slotIndex);
+        }
+    }
+
+    private void putSlotContent(int slotIndex, DisplayItem item) {
+        Rect rect = getSlotRect(slotIndex);
+        Position position = new Position(
+                (rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2, 0);
+        putDisplayItem(position, position, item);
+    }
+
+    private void updateVisibleRange(int start, int end) {
+        if (start == mVisibleStart && end == mVisibleEnd) {
+            // we need to set the mDataWindow active range in any case.
+            mDataWindow.setActiveWindow(start, end);
+            return;
+        }
+
+        if (!mIsActive) {
+            mVisibleStart = start;
+            mVisibleEnd = end;
+            mDataWindow.setActiveWindow(start, end);
+            return;
+        }
+
+        if (start >= mVisibleEnd || mVisibleStart >= end) {
+            for (int i = mVisibleStart, n = mVisibleEnd; i < n; ++i) {
+                DisplayItem item = mDataWindow.get(i);
+                if (item != null) removeDisplayItem(item);
+            }
+            mDataWindow.setActiveWindow(start, end);
+            for (int i = start; i < end; ++i) {
+                putSlotContent(i, mDataWindow.get(i));
+            }
+        } else {
+            for (int i = mVisibleStart; i < start; ++i) {
+                DisplayItem item = mDataWindow.get(i);
+                if (item != null) removeDisplayItem(item);
+            }
+            for (int i = end, n = mVisibleEnd; i < n; ++i) {
+                DisplayItem item = mDataWindow.get(i);
+                if (item != null) removeDisplayItem(item);
+            }
+            mDataWindow.setActiveWindow(start, end);
+            for (int i = start, n = mVisibleStart; i < n; ++i) {
+                putSlotContent(i, mDataWindow.get(i));
+            }
+            for (int i = mVisibleEnd; i < end; ++i) {
+                putSlotContent(i, mDataWindow.get(i));
+            }
+        }
+
+        mVisibleStart = start;
+        mVisibleEnd = end;
+    }
+
+    @Override
+    protected void onLayoutChanged(int width, int height) {
+        // Reput all the items
+        updateVisibleRange(0, 0);
+        updateVisibleRange(getVisibleStart(), getVisibleEnd());
+    }
+
+    @Override
+    protected void onScrollPositionChanged(int position) {
+        super.onScrollPositionChanged(position);
+        updateVisibleRange(getVisibleStart(), getVisibleEnd());
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        mSelectionDrawer.prepareDrawing();
+        super.render(canvas);
+    }
+
+    private class MyDataModelListener implements AlbumSlidingWindow.Listener {
+
+        public void onContentInvalidated() {
+            invalidate();
+        }
+
+        public void onSizeChanged(int size) {
+            // If the layout parameters are changed, we need reput all items.
+            if (setSlotCount(size)) updateVisibleRange(0, 0);
+            updateVisibleRange(getVisibleStart(), getVisibleEnd());
+            invalidate();
+        }
+
+        public void onWindowContentChanged(
+                int slotIndex, DisplayItem old, DisplayItem update) {
+            removeDisplayItem(old);
+            putSlotContent(slotIndex, update);
+        }
+    }
+
+    public void resume() {
+        mIsActive = true;
+        mDataWindow.resume();
+        for (int i = mVisibleStart, n = mVisibleEnd; i < n; ++i) {
+            putSlotContent(i, mDataWindow.get(i));
+        }
+    }
+
+    public void pause() {
+        mIsActive = false;
+        for (int i = mVisibleStart, n = mVisibleEnd; i < n; ++i) {
+            removeDisplayItem(mDataWindow.get(i));
+        }
+        mDataWindow.pause();
+    }
+}
diff --git a/src/com/android/gallery3d/ui/BasicTexture.java b/src/com/android/gallery3d/ui/BasicTexture.java
new file mode 100644
index 0000000..e930063
--- /dev/null
+++ b/src/com/android/gallery3d/ui/BasicTexture.java
@@ -0,0 +1,164 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+
+import java.lang.ref.WeakReference;
+import java.util.WeakHashMap;
+
+// BasicTexture is a Texture corresponds to a real GL texture.
+// The state of a BasicTexture indicates whether its data is loaded to GL memory.
+// If a BasicTexture is loaded into GL memory, it has a GL texture id.
+abstract class BasicTexture implements Texture {
+
+    @SuppressWarnings("unused")
+    private static final String TAG = "BasicTexture";
+    protected static final int UNSPECIFIED = -1;
+
+    protected static final int STATE_UNLOADED = 0;
+    protected static final int STATE_LOADED = 1;
+    protected static final int STATE_ERROR = -1;
+
+    protected int mId;
+    protected int mState;
+
+    protected int mWidth = UNSPECIFIED;
+    protected int mHeight = UNSPECIFIED;
+
+    private int mTextureWidth;
+    private int mTextureHeight;
+
+    protected WeakReference<GLCanvas> mCanvasRef = null;
+    private static WeakHashMap<BasicTexture, Object> sAllTextures
+            = new WeakHashMap<BasicTexture, Object>();
+    private static ThreadLocal sInFinalizer = new ThreadLocal();
+
+    protected BasicTexture(GLCanvas canvas, int id, int state) {
+        setAssociatedCanvas(canvas);
+        mId = id;
+        mState = state;
+        synchronized (sAllTextures) {
+            sAllTextures.put(this, null);
+        }
+    }
+
+    protected BasicTexture() {
+        this(null, 0, STATE_UNLOADED);
+    }
+
+    protected void setAssociatedCanvas(GLCanvas canvas) {
+        mCanvasRef = canvas == null
+                ? null
+                : new WeakReference<GLCanvas>(canvas);
+    }
+
+    /**
+     * Sets the content size of this texture. In OpenGL, the actual texture
+     * size must be of power of 2, the size of the content may be smaller.
+     */
+    protected void setSize(int width, int height) {
+        mWidth = width;
+        mHeight = height;
+        mTextureWidth = Utils.nextPowerOf2(width);
+        mTextureHeight = Utils.nextPowerOf2(height);
+    }
+
+    public int getId() {
+        return mId;
+    }
+
+    public int getWidth() {
+        return mWidth;
+    }
+
+    public int getHeight() {
+        return mHeight;
+    }
+
+    // Returns the width rounded to the next power of 2.
+    public int getTextureWidth() {
+        return mTextureWidth;
+    }
+
+    // Returns the height rounded to the next power of 2.
+    public int getTextureHeight() {
+        return mTextureHeight;
+    }
+
+    public void draw(GLCanvas canvas, int x, int y) {
+        canvas.drawTexture(this, x, y, getWidth(), getHeight());
+    }
+
+    public void draw(GLCanvas canvas, int x, int y, int w, int h) {
+        canvas.drawTexture(this, x, y, w, h);
+    }
+
+    // onBind is called before GLCanvas binds this texture.
+    // It should make sure the data is uploaded to GL memory.
+    abstract protected boolean onBind(GLCanvas canvas);
+
+    public boolean isLoaded(GLCanvas canvas) {
+        return mState == STATE_LOADED && mCanvasRef.get() == canvas;
+    }
+
+    // recycle() is called when the texture will never be used again,
+    // so it can free all resources.
+    public void recycle() {
+        freeResource();
+    }
+
+    // yield() is called when the texture will not be used temporarily,
+    // so it can free some resources.
+    // The default implementation unloads the texture from GL memory, so
+    // the subclass should make sure it can reload the texture to GL memory
+    // later, or it will have to override this method.
+    public void yield() {
+        freeResource();
+    }
+
+    private void freeResource() {
+        GLCanvas canvas = mCanvasRef == null ? null : mCanvasRef.get();
+        if (canvas != null && isLoaded(canvas)) {
+            canvas.unloadTexture(this);
+        }
+        mState = BasicTexture.STATE_UNLOADED;
+        setAssociatedCanvas(null);
+    }
+
+    @Override
+    protected void finalize() {
+        sInFinalizer.set(BasicTexture.class);
+        recycle();
+        sInFinalizer.set(null);
+    }
+
+    // This is for deciding if we can call Bitmap's recycle().
+    // We cannot call Bitmap's recycle() in finalizer because at that point
+    // the finalizer of Bitmap may already be called so recycle() will crash.
+    public static boolean inFinalizer() {
+        return sInFinalizer.get() != null;
+    }
+
+    public static void yieldAllTextures() {
+        synchronized (sAllTextures) {
+            for (BasicTexture t : sAllTextures.keySet()) {
+                t.yield();
+            }
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/BitmapTexture.java b/src/com/android/gallery3d/ui/BitmapTexture.java
new file mode 100644
index 0000000..046bda9
--- /dev/null
+++ b/src/com/android/gallery3d/ui/BitmapTexture.java
@@ -0,0 +1,49 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+
+import android.graphics.Bitmap;
+
+// BitmapTexture is a texture whose content is specified by a fixed Bitmap.
+//
+// The texture does not own the Bitmap. The user should make sure the Bitmap
+// is valid during the texture's lifetime. When the texture is recycled, it
+// does not free the Bitmap.
+public class BitmapTexture extends UploadedTexture {
+    protected Bitmap mContentBitmap;
+
+    public BitmapTexture(Bitmap bitmap) {
+        Utils.assertTrue(bitmap != null && !bitmap.isRecycled());
+        mContentBitmap = bitmap;
+    }
+
+    @Override
+    protected void onFreeBitmap(Bitmap bitmap) {
+        // Do nothing.
+    }
+
+    @Override
+    protected Bitmap onGetBitmap() {
+        return mContentBitmap;
+    }
+
+    public Bitmap getBitmap() {
+        return mContentBitmap;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/BitmapTileProvider.java b/src/com/android/gallery3d/ui/BitmapTileProvider.java
new file mode 100644
index 0000000..a47337f
--- /dev/null
+++ b/src/com/android/gallery3d/ui/BitmapTileProvider.java
@@ -0,0 +1,91 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.BitmapUtils;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Bitmap.Config;
+
+import java.util.ArrayList;
+
+public class BitmapTileProvider implements TileImageView.Model {
+    private final Bitmap mBackup;
+    private final Bitmap[] mMipmaps;
+    private final Config mConfig;
+    private final int mImageWidth;
+    private final int mImageHeight;
+
+    private boolean mRecycled = false;
+
+    public BitmapTileProvider(Bitmap bitmap, int maxBackupSize) {
+        mImageWidth = bitmap.getWidth();
+        mImageHeight = bitmap.getHeight();
+        ArrayList<Bitmap> list = new ArrayList<Bitmap>();
+        list.add(bitmap);
+        while (bitmap.getWidth() > maxBackupSize
+                || bitmap.getHeight() > maxBackupSize) {
+            bitmap = BitmapUtils.resizeBitmapByScale(bitmap, 0.5f, false);
+            list.add(bitmap);
+        }
+
+        mBackup = list.remove(list.size() - 1);
+        mMipmaps = list.toArray(new Bitmap[list.size()]);
+        mConfig = Config.ARGB_8888;
+    }
+
+    public Bitmap getBackupImage() {
+        return mBackup;
+    }
+
+    public int getImageHeight() {
+        return mImageHeight;
+    }
+
+    public int getImageWidth() {
+        return mImageWidth;
+    }
+
+    public int getLevelCount() {
+        return mMipmaps.length;
+    }
+
+    public Bitmap getTile(int level, int x, int y, int tileSize) {
+        Bitmap result = Bitmap.createBitmap(tileSize, tileSize, mConfig);
+        Canvas canvas = new Canvas(result);
+        canvas.drawBitmap(mMipmaps[level], -(x >> level), -(y >> level), null);
+        return result;
+    }
+
+    public void recycle() {
+        if (mRecycled) return;
+        mRecycled = true;
+        for (Bitmap bitmap : mMipmaps) {
+            BitmapUtils.recycleSilently(bitmap);
+        }
+        BitmapUtils.recycleSilently(mBackup);
+    }
+
+    public int getRotation() {
+        return 0;
+    }
+
+    public boolean isFailedToLoad() {
+        return false;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/BoxBlurFilter.java b/src/com/android/gallery3d/ui/BoxBlurFilter.java
new file mode 100644
index 0000000..0497a61
--- /dev/null
+++ b/src/com/android/gallery3d/ui/BoxBlurFilter.java
@@ -0,0 +1,100 @@
+/*
+ * 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.ui;
+
+import android.graphics.Bitmap;
+
+
+public class BoxBlurFilter {
+    private static final int RED_MASK = 0xff0000;
+    private static final int RED_MASK_SHIFT = 16;
+    private static final int GREEN_MASK = 0x00ff00;
+    private static final int GREEN_MASK_SHIFT = 8;
+    private static final int BLUE_MASK = 0x0000ff;
+    private static final int RADIUS = 4;
+    private static final int KERNEL_SIZE = RADIUS * 2 + 1;
+    private static final int NUM_COLORS = 256;
+    private static final int[] KERNEL_NORM = new int[KERNEL_SIZE * NUM_COLORS];
+
+    public static final int MODE_REPEAT = 1;
+    public static final int MODE_CLAMP = 2;
+
+    static {
+        int index = 0;
+        // Build a lookup table from summed to normalized kernel values.
+        // The formula: KERNAL_NORM[value] = value / KERNEL_SIZE
+        for (int i = 0; i < NUM_COLORS; ++i) {
+            for (int j = 0; j < KERNEL_SIZE; ++j) {
+                KERNEL_NORM[index++] = i;
+            }
+        }
+    }
+
+    private BoxBlurFilter() {
+    }
+
+    private static int sample(int x, int width, int mode) {
+        if (x >= 0 && x < width) return x;
+        return mode == MODE_REPEAT
+                ? x < 0 ? x + width : x - width
+                : x < 0 ? 0 : width - 1;
+    }
+
+    public static void apply(
+            Bitmap bitmap, int horizontalMode, int verticalMode) {
+
+        int width = bitmap.getWidth();
+        int height = bitmap.getHeight();
+        int data[] = new int[width * height];
+        bitmap.getPixels(data, 0, width, 0, 0, width, height);
+        int temp[] = new int[width * height];
+        applyOneDimension(data, temp, width, height, horizontalMode);
+        applyOneDimension(temp, data, height, width, verticalMode);
+        bitmap.setPixels(data, 0, width, 0, 0, width, height);
+    }
+
+    private static void applyOneDimension(
+            int[] in, int[] out, int width, int height, int mode) {
+        for (int y = 0, read = 0; y < height; ++y, read += width) {
+            // Evaluate the kernel for the first pixel in the row.
+            int red = 0;
+            int green = 0;
+            int blue = 0;
+            for (int i = -RADIUS; i <= RADIUS; ++i) {
+                int argb = in[read + sample(i, width, mode)];
+                red += (argb & RED_MASK) >> RED_MASK_SHIFT;
+                green += (argb & GREEN_MASK) >> GREEN_MASK_SHIFT;
+                blue += argb & BLUE_MASK;
+            }
+            for (int x = 0, write = y; x < width; ++x, write += height) {
+                // Output the current pixel.
+                out[write] = 0xFF000000
+                        | (KERNEL_NORM[red] << RED_MASK_SHIFT)
+                        | (KERNEL_NORM[green] << GREEN_MASK_SHIFT)
+                        | KERNEL_NORM[blue];
+
+                // Slide to the next pixel, adding the new rightmost pixel and
+                // subtracting the former leftmost.
+                int prev = in[read + sample(x - RADIUS, width, mode)];
+                int next = in[read + sample(x + RADIUS + 1, width, mode)];
+                red += ((next & RED_MASK) - (prev & RED_MASK)) >> RED_MASK_SHIFT;
+                green += ((next & GREEN_MASK) - (prev & GREEN_MASK)) >> GREEN_MASK_SHIFT;
+                blue += (next & BLUE_MASK) - (prev & BLUE_MASK);
+            }
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/CacheBarView.java b/src/com/android/gallery3d/ui/CacheBarView.java
new file mode 100644
index 0000000..40f84d8
--- /dev/null
+++ b/src/com/android/gallery3d/ui/CacheBarView.java
@@ -0,0 +1,270 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.util.Future;
+import com.android.gallery3d.util.FutureListener;
+import com.android.gallery3d.util.ThreadPool.Job;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.os.Handler;
+import android.os.Message;
+import android.os.StatFs;
+import android.text.format.Formatter;
+import android.view.View.MeasureSpec;
+
+import java.io.File;
+
+public class CacheBarView extends GLView implements TextButton.OnClickedListener {
+    private static final String TAG = "CacheBarView";
+    private static final int FONT_COLOR = 0xffffffff;
+    private static final int MSG_REFRESH_STORAGE = 1;
+    private static final int PIN_SIZE = 36;
+
+    public interface Listener {
+        void onDoneClicked();
+    }
+
+    private GalleryActivity mActivity;
+    private Context mContext;
+
+    private StorageInfo mStorageInfo;
+    private long mUserChangeDelta;
+    private Future<StorageInfo> mStorageInfoFuture;
+    private Handler mHandler;
+
+    private int mTotalHeight;
+    private int mPinLeftMargin;
+    private int mPinRightMargin;
+    private int mButtonRightMargin;
+
+    private NinePatchTexture mBackground;
+    private GLView mLeftPin;            // The pin icon.
+    private GLView mLeftLabel;          // "Make available offline"
+    private ProgressBar mStorageBar;
+    private Label mStorageLabel;        // "27.26 GB free"
+    private TextButton mDoneButton;     // "Done"
+
+    private Listener mListener;
+
+    public CacheBarView(GalleryActivity activity, int resBackground, int height,
+            int pinLeftMargin, int pinRightMargin, int buttonRightMargin,
+            int fontSize) {
+        mActivity = activity;
+        mContext = activity.getAndroidContext();
+
+        mPinLeftMargin = pinLeftMargin;
+        mPinRightMargin = pinRightMargin;
+        mButtonRightMargin = buttonRightMargin;
+
+        mBackground = new NinePatchTexture(mContext, resBackground);
+        Rect paddings = mBackground.getPaddings();
+
+        // The total height of the strip that includes the bar containing Pin,
+        // Label, DoneButton, ..., ect. and the extended fading bar.
+        mTotalHeight = height + paddings.top;
+
+        mLeftPin = new Icon(mContext, R.drawable.ic_manage_pin, PIN_SIZE, PIN_SIZE);
+        mLeftLabel = new Label(mContext, R.string.make_available_offline,
+                fontSize, FONT_COLOR);
+        addComponent(mLeftPin);
+        addComponent(mLeftLabel);
+
+        mDoneButton = new TextButton(mContext, R.string.done);
+        mDoneButton.setOnClickListener(this);
+        NinePatchTexture normal = new NinePatchTexture(
+                mContext, R.drawable.btn_default_normal_holo_dark);
+        NinePatchTexture pressed = new NinePatchTexture(
+                mContext, R.drawable.btn_default_pressed_holo_dark);
+        mDoneButton.setNormalBackground(normal);
+        mDoneButton.setPressedBackground(pressed);
+        addComponent(mDoneButton);
+
+        // Initially the progress bar and label are invisible.
+        // It will be made visible after we have the storage information.
+        mStorageBar = new ProgressBar(mContext,
+                R.drawable.progress_primary_holo_dark,
+                R.drawable.progress_secondary_holo_dark,
+                R.drawable.progress_bg_holo_dark);
+        mStorageLabel = new Label(mContext, "", 14, Color.WHITE);
+        addComponent(mStorageBar);
+        addComponent(mStorageLabel);
+        mStorageBar.setVisibility(GLView.INVISIBLE);
+        mStorageLabel.setVisibility(GLView.INVISIBLE);
+
+        mHandler = new SynchronizedHandler(activity.getGLRoot()) {
+            @Override
+            public void handleMessage(Message msg) {
+                switch(msg.what) {
+                    case MSG_REFRESH_STORAGE:
+                        mStorageInfo = (StorageInfo) msg.obj;
+                        refreshStorageInfo();
+                        break;
+                }
+            }
+        };
+    }
+
+    public void setListener(Listener listener) {
+        mListener = listener;
+    }
+
+    // Called by mDoneButton
+    public void onClicked(GLView source) {
+        if (mListener != null) {
+            mListener.onDoneClicked();
+        }
+    }
+
+    @Override
+    protected void onLayout(
+            boolean changed, int left, int top, int right, int bottom) {
+        // The size of mStorageLabel can change, so we need to layout
+        // even if the size of CacheBarView does not change.
+        int w = right - left;
+        int h = bottom - top;
+
+        mLeftPin.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+        int pinH = mLeftPin.getMeasuredHeight();
+        int pinW = mLeftPin.getMeasuredWidth();
+        int pinT = (h - pinH) / 2;
+        int pinL = mPinLeftMargin;
+        mLeftPin.layout(pinL, pinT, pinL + pinW, pinT + pinH);
+
+        mLeftLabel.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+        int labelH = mLeftLabel.getMeasuredHeight();
+        int labelW = mLeftLabel.getMeasuredWidth();
+        int labelT = (h - labelH) / 2;
+        int labelL = pinL + pinW + mPinRightMargin;
+        mLeftLabel.layout(labelL, labelT, labelL + labelW, labelT + labelH);
+
+        mDoneButton.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+        int doneH = mDoneButton.getMeasuredHeight();
+        int doneW = mDoneButton.getMeasuredWidth();
+        int doneT = (h - doneH) / 2;
+        int doneR = w - mButtonRightMargin;
+        mDoneButton.layout(doneR - doneW, doneT, doneR, doneT + doneH);
+
+        int centerX = w / 2;
+        int centerY = h / 2;
+
+        int capBarH = 20;
+        int capBarW = 200;
+        int capBarT = centerY - capBarH / 2;
+        int capBarL = centerX - capBarW / 2;
+        mStorageBar.layout(capBarL, capBarT, capBarL + capBarW,
+                capBarT + capBarH);
+
+        mStorageLabel.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+        int capLabelH = mStorageLabel.getMeasuredHeight();
+        int capLabelW = mStorageLabel.getMeasuredWidth();
+        int capLabelT = centerY - capLabelH / 2;
+        int capLabelL = centerX + capBarW / 2 + 8;
+        mStorageLabel.layout(capLabelL , capLabelT, capLabelL + capLabelW,
+                capLabelT + capLabelH);
+    }
+
+    public void refreshStorageInfo() {
+        long used = mStorageInfo.usedBytes;
+        long total = mStorageInfo.totalBytes;
+        long cached = mStorageInfo.usedCacheBytes;
+        long target = mStorageInfo.targetCacheBytes;
+
+        double primary = (double) used / total;
+        double secondary =
+                (double) (used - cached + target + mUserChangeDelta) / total;
+
+        mStorageBar.setProgress((int) (primary * 10000));
+        mStorageBar.setSecondaryProgress((int) (secondary * 10000));
+
+        long freeBytes = mStorageInfo.totalBytes - mStorageInfo.usedBytes;
+        String sizeString = Formatter.formatFileSize(mContext, freeBytes);
+        String label = mContext.getString(R.string.free_space_format, sizeString);
+        mStorageLabel.setText(label);
+        mStorageBar.setVisibility(GLView.VISIBLE);
+        mStorageLabel.setVisibility(GLView.VISIBLE);
+        requestLayout(); // because the size of the label may have changed.
+    }
+
+    public void increaseTargetCacheSize(long delta) {
+        mUserChangeDelta += delta;
+        refreshStorageInfo();
+    }
+
+    @Override
+    protected void renderBackground(GLCanvas canvas) {
+        Rect paddings = mBackground.getPaddings();
+        mBackground.draw(canvas, 0, -paddings.top, getWidth(), mTotalHeight);
+    }
+
+    public void resume() {
+        mStorageInfoFuture = mActivity.getThreadPool().submit(
+            new StorageInfoJob(),
+            new FutureListener<StorageInfo>() {
+                    public void onFutureDone(Future<StorageInfo> future) {
+                        mStorageInfoFuture = null;
+                        if (!future.isCancelled()) {
+                            mHandler.sendMessage(mHandler.obtainMessage(
+                                    MSG_REFRESH_STORAGE, future.get()));
+                        }
+                    }
+                });
+    }
+
+    public void pause() {
+        if (mStorageInfoFuture != null) {
+            mStorageInfoFuture.cancel();
+            mStorageInfoFuture = null;
+        }
+        mStorageBar.setVisibility(GLView.INVISIBLE);
+        mStorageLabel.setVisibility(GLView.INVISIBLE);
+    }
+
+    public static class StorageInfo {
+        long totalBytes;      // number of bytes the storage has.
+        long usedBytes;       // number of bytes already used.
+        long usedCacheBytes;  // number of bytes used for the cache (should be less
+                              // then usedBytes).
+        long targetCacheBytes;// number of bytes used for the cache
+                              // if all pending downloads (and removals) are completed.
+    }
+
+    private class StorageInfoJob implements Job<StorageInfo> {
+        public StorageInfo run(JobContext jc) {
+            File cacheDir = mContext.getExternalCacheDir();
+            if (cacheDir == null) {
+                cacheDir = mContext.getCacheDir();
+            }
+            String path = cacheDir.getAbsolutePath();
+            StatFs stat = new StatFs(path);
+            long blockSize = stat.getBlockSize();
+            long availableBlocks = stat.getAvailableBlocks();
+            long totalBlocks = stat.getBlockCount();
+            StorageInfo si = new StorageInfo();
+            si.totalBytes = blockSize * totalBlocks;
+            si.usedBytes = blockSize * (totalBlocks - availableBlocks);
+            si.usedCacheBytes = mActivity.getDataManager().getTotalUsedCacheSize();
+            si.targetCacheBytes = mActivity.getDataManager().getTotalTargetCacheSize();
+            return si;
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/CanvasTexture.java b/src/com/android/gallery3d/ui/CanvasTexture.java
new file mode 100644
index 0000000..679a4bc
--- /dev/null
+++ b/src/com/android/gallery3d/ui/CanvasTexture.java
@@ -0,0 +1,52 @@
+/*
+ * 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.ui;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Bitmap.Config;
+
+// CanvasTexture is a texture whose content is the drawing on a Canvas.
+// The subclasses should override onDraw() to draw on the bitmap.
+// By default CanvasTexture is not opaque.
+abstract class CanvasTexture extends UploadedTexture {
+    protected Canvas mCanvas;
+    private final Config mConfig;
+
+    public CanvasTexture(int width, int height) {
+        mConfig = Config.ARGB_8888;
+        setSize(width, height);
+        setOpaque(false);
+    }
+
+    @Override
+    protected Bitmap onGetBitmap() {
+        Bitmap bitmap = Bitmap.createBitmap(mWidth, mHeight, mConfig);
+        mCanvas = new Canvas(bitmap);
+        onDraw(mCanvas, bitmap);
+        return bitmap;
+    }
+
+    @Override
+    protected void onFreeBitmap(Bitmap bitmap) {
+        if (!inFinalizer()) {
+            bitmap.recycle();
+        }
+    }
+
+    abstract protected void onDraw(Canvas canvas, Bitmap backing);
+}
diff --git a/src/com/android/gallery3d/ui/ColorTexture.java b/src/com/android/gallery3d/ui/ColorTexture.java
new file mode 100644
index 0000000..24e8914
--- /dev/null
+++ b/src/com/android/gallery3d/ui/ColorTexture.java
@@ -0,0 +1,58 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+
+// ColorTexture is a texture which fills the rectangle with the specified color.
+public class ColorTexture implements Texture {
+
+    private final int mColor;
+    private int mWidth;
+    private int mHeight;
+
+    public ColorTexture(int color) {
+        mColor = color;
+        mWidth = 1;
+        mHeight = 1;
+    }
+
+    public void draw(GLCanvas canvas, int x, int y) {
+        draw(canvas, x, y, mWidth, mHeight);
+    }
+
+    public void draw(GLCanvas canvas, int x, int y, int w, int h) {
+        canvas.fillRect(x, y, w, h, mColor);
+    }
+
+    public boolean isOpaque() {
+        return Utils.isOpaque(mColor);
+    }
+
+    public void setSize(int width, int height) {
+        mWidth = width;
+        mHeight = height;
+    }
+
+    public int getWidth() {
+        return mWidth;
+    }
+
+    public int getHeight() {
+        return mHeight;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/Config.java b/src/com/android/gallery3d/ui/Config.java
new file mode 100644
index 0000000..5c5b621
--- /dev/null
+++ b/src/com/android/gallery3d/ui/Config.java
@@ -0,0 +1,31 @@
+/*
+ * 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.ui;
+
+interface DetailsWindowConfig {
+    public static final int FONT_SIZE = 18;
+    public static final int PREFERRED_WIDTH = 400;
+    public static final int LEFT_RIGHT_EXTRA_PADDING = 9;
+    public static final int TOP_BOTTOM_EXTRA_PADDING = 9;
+    public static final int LINE_SPACING = 5;
+    public static final int FIRST_LINE_SPACING = 18;
+}
+
+interface TextButtonConfig {
+    public static final int HORIZONTAL_PADDINGS = 16;
+    public static final int VERTICAL_PADDINGS = 5;
+}
diff --git a/src/com/android/gallery3d/ui/CropView.java b/src/com/android/gallery3d/ui/CropView.java
new file mode 100644
index 0000000..9c59c9a
--- /dev/null
+++ b/src/com/android/gallery3d/ui/CropView.java
@@ -0,0 +1,801 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.anim.Animation;
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.common.Utils;
+
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.PointF;
+import android.graphics.RectF;
+import android.media.FaceDetector;
+import android.os.Handler;
+import android.os.Message;
+import android.view.MotionEvent;
+import android.view.animation.DecelerateInterpolator;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+import javax.microedition.khronos.opengles.GL11;
+
+/**
+ * The activity can crop specific region of interest from an image.
+ */
+public class CropView extends GLView {
+    private static final String TAG = "CropView";
+
+    private static final int FACE_PIXEL_COUNT = 120000; // around 400x300
+
+    private static final int COLOR_OUTLINE = 0xFF008AFF;
+    private static final int COLOR_FACE_OUTLINE = 0xFF000000;
+
+    private static final float OUTLINE_WIDTH = 3f;
+
+    private static final int SIZE_UNKNOWN = -1;
+    private static final int TOUCH_TOLERANCE = 30;
+
+    private static final float MIN_SELECTION_LENGTH = 16f;
+    public static final float UNSPECIFIED = -1f;
+
+    private static final int MAX_FACE_COUNT = 3;
+    private static final float FACE_EYE_RATIO = 2f;
+
+    private static final int ANIMATION_DURATION = 1250;
+
+    private static final int MOVE_LEFT = 1;
+    private static final int MOVE_TOP = 2;
+    private static final int MOVE_RIGHT = 4;
+    private static final int MOVE_BOTTOM = 8;
+    private static final int MOVE_BLOCK = 16;
+
+    private static final float MAX_SELECTION_RATIO = 0.8f;
+    private static final float MIN_SELECTION_RATIO = 0.4f;
+    private static final float SELECTION_RATIO = 0.60f;
+    private static final int ANIMATION_TRIGGER = 64;
+
+    private static final int MSG_UPDATE_FACES = 1;
+
+    private float mAspectRatio = UNSPECIFIED;
+    private float mSpotlightRatioX = 0;
+    private float mSpotlightRatioY = 0;
+
+    private Handler mMainHandler;
+
+    private FaceHighlightView mFaceDetectionView;
+    private HighlightRectangle mHighlightRectangle;
+    private TileImageView mImageView;
+    private AnimationController mAnimation = new AnimationController();
+
+    private int mImageWidth = SIZE_UNKNOWN;
+    private int mImageHeight = SIZE_UNKNOWN;
+
+    private GalleryActivity mActivity;
+
+    private GLPaint mPaint = new GLPaint();
+    private GLPaint mFacePaint = new GLPaint();
+
+    private int mImageRotation;
+
+    public CropView(GalleryActivity activity) {
+        mActivity = activity;
+        mImageView = new TileImageView(activity);
+        mFaceDetectionView = new FaceHighlightView();
+        mHighlightRectangle = new HighlightRectangle();
+
+        addComponent(mImageView);
+        addComponent(mFaceDetectionView);
+        addComponent(mHighlightRectangle);
+
+        mHighlightRectangle.setVisibility(GLView.INVISIBLE);
+
+        mPaint.setColor(COLOR_OUTLINE);
+        mPaint.setLineWidth(OUTLINE_WIDTH);
+
+        mFacePaint.setColor(COLOR_FACE_OUTLINE);
+        mFacePaint.setLineWidth(OUTLINE_WIDTH);
+
+        mMainHandler = new SynchronizedHandler(activity.getGLRoot()) {
+            @Override
+            public void handleMessage(Message message) {
+                Utils.assertTrue(message.what == MSG_UPDATE_FACES);
+                ((DetectFaceTask) message.obj).updateFaces();
+            }
+        };
+    }
+
+    public void setAspectRatio(float ratio) {
+        mAspectRatio = ratio;
+    }
+
+    public void setSpotlightRatio(float ratioX, float ratioY) {
+        mSpotlightRatioX = ratioX;
+        mSpotlightRatioY = ratioY;
+    }
+
+    @Override
+    public void onLayout(boolean changed, int l, int t, int r, int b) {
+        int width = r - l;
+        int height = b - t;
+
+        mFaceDetectionView.layout(0, 0, width, height);
+        mHighlightRectangle.layout(0, 0, width, height);
+        mImageView.layout(0, 0, width, height);
+        if (mImageHeight != SIZE_UNKNOWN) {
+            mAnimation.initialize();
+            if (mHighlightRectangle.getVisibility() == GLView.VISIBLE) {
+                mAnimation.parkNow(
+                        mHighlightRectangle.mHighlightRect);
+            }
+        }
+    }
+
+    private boolean setImageViewPosition(int centerX, int centerY, float scale) {
+        int inverseX = mImageWidth - centerX;
+        int inverseY = mImageHeight - centerY;
+        TileImageView t = mImageView;
+        int rotation = mImageRotation;
+        switch (rotation) {
+            case 0: return t.setPosition(centerX, centerY, scale, 0);
+            case 90: return t.setPosition(centerY, inverseX, scale, 90);
+            case 180: return t.setPosition(inverseX, inverseY, scale, 180);
+            case 270: return t.setPosition(inverseY, centerX, scale, 270);
+            default: throw new IllegalArgumentException(String.valueOf(rotation));
+        }
+    }
+
+    @Override
+    public void render(GLCanvas canvas) {
+        AnimationController a = mAnimation;
+        if (a.calculate(canvas.currentAnimationTimeMillis())) invalidate();
+        setImageViewPosition(a.getCenterX(), a.getCenterY(), a.getScale());
+        super.render(canvas);
+    }
+
+    @Override
+    public void renderBackground(GLCanvas canvas) {
+        canvas.clearBuffer();
+    }
+
+    public RectF getCropRectangle() {
+        if (mHighlightRectangle.getVisibility() == GLView.INVISIBLE) return null;
+        RectF rect = mHighlightRectangle.mHighlightRect;
+        RectF result = new RectF(rect.left * mImageWidth, rect.top * mImageHeight,
+                rect.right * mImageWidth, rect.bottom * mImageHeight);
+        return result;
+    }
+
+    public int getImageWidth() {
+        return mImageWidth;
+    }
+
+    public int getImageHeight() {
+        return mImageHeight;
+    }
+
+    private class FaceHighlightView extends GLView {
+        private static final int INDEX_NONE = -1;
+        private ArrayList<RectF> mFaces = new ArrayList<RectF>();
+        private RectF mRect = new RectF();
+        private int mPressedFaceIndex = INDEX_NONE;
+
+        public void addFace(RectF faceRect) {
+            mFaces.add(faceRect);
+            invalidate();
+        }
+
+        private void renderFace(GLCanvas canvas, RectF face, boolean pressed) {
+            GL11 gl = canvas.getGLInstance();
+            if (pressed) {
+                gl.glEnable(GL11.GL_STENCIL_TEST);
+                gl.glClear(GL11.GL_STENCIL_BUFFER_BIT);
+                gl.glStencilOp(GL11.GL_KEEP, GL11.GL_KEEP, GL11.GL_REPLACE);
+                gl.glStencilFunc(GL11.GL_ALWAYS, 1, 1);
+            }
+
+            RectF r = mAnimation.mapRect(face, mRect);
+            canvas.fillRect(r.left, r.top, r.width(), r.height(), Color.TRANSPARENT);
+            canvas.drawRect(r.left, r.top, r.width(), r.height(), mFacePaint);
+
+            if (pressed) {
+                gl.glStencilOp(GL11.GL_KEEP, GL11.GL_KEEP, GL11.GL_KEEP);
+            }
+        }
+
+        @Override
+        protected void renderBackground(GLCanvas canvas) {
+            ArrayList<RectF> faces = mFaces;
+            for (int i = 0, n = faces.size(); i < n; ++i) {
+                renderFace(canvas, faces.get(i), i == mPressedFaceIndex);
+            }
+
+            GL11 gl = canvas.getGLInstance();
+            if (mPressedFaceIndex != INDEX_NONE) {
+                gl.glStencilFunc(GL11.GL_NOTEQUAL, 1, 1);
+                canvas.fillRect(0, 0, getWidth(), getHeight(), 0x66000000);
+                gl.glDisable(GL11.GL_STENCIL_TEST);
+            }
+        }
+
+        private void setPressedFace(int index) {
+            if (mPressedFaceIndex == index) return;
+            mPressedFaceIndex = index;
+            invalidate();
+        }
+
+        private int getFaceIndexByPosition(float x, float y) {
+            ArrayList<RectF> faces = mFaces;
+            for (int i = 0, n = faces.size(); i < n; ++i) {
+                RectF r = mAnimation.mapRect(faces.get(i), mRect);
+                if (r.contains(x, y)) return i;
+            }
+            return INDEX_NONE;
+        }
+
+        @Override
+        protected boolean onTouch(MotionEvent event) {
+            float x = event.getX();
+            float y = event.getY();
+            switch (event.getAction()) {
+                case MotionEvent.ACTION_DOWN:
+                case MotionEvent.ACTION_MOVE: {
+                    setPressedFace(getFaceIndexByPosition(x, y));
+                    break;
+                }
+                case MotionEvent.ACTION_CANCEL:
+                case MotionEvent.ACTION_UP: {
+                    int index = mPressedFaceIndex;
+                    setPressedFace(INDEX_NONE);
+                    if (index != INDEX_NONE) {
+                        mHighlightRectangle.setRectangle(mFaces.get(index));
+                        mHighlightRectangle.setVisibility(GLView.VISIBLE);
+                        setVisibility(GLView.INVISIBLE);
+                    }
+                }
+            }
+            return true;
+        }
+    }
+
+    private class AnimationController extends Animation {
+        private int mCurrentX;
+        private int mCurrentY;
+        private float mCurrentScale;
+        private int mStartX;
+        private int mStartY;
+        private float mStartScale;
+        private int mTargetX;
+        private int mTargetY;
+        private float mTargetScale;
+
+        public AnimationController() {
+            setDuration(ANIMATION_DURATION);
+            setInterpolator(new DecelerateInterpolator(4));
+        }
+
+        public void initialize() {
+            mCurrentX = mImageWidth / 2;
+            mCurrentY = mImageHeight / 2;
+            mCurrentScale = Math.min(2, Math.min(
+                    (float) getWidth() / mImageWidth,
+                    (float) getHeight() / mImageHeight));
+        }
+
+        public void startParkingAnimation(RectF highlight) {
+            RectF r = mAnimation.mapRect(highlight, new RectF());
+            int width = getWidth();
+            int height = getHeight();
+
+            float wr = r.width() / width;
+            float hr = r.height() / height;
+            final int d = ANIMATION_TRIGGER;
+            if (wr >= MIN_SELECTION_RATIO && wr < MAX_SELECTION_RATIO
+                    && hr >= MIN_SELECTION_RATIO && hr < MAX_SELECTION_RATIO
+                    && r.left >= d && r.right < width - d
+                    && r.top >= d && r.bottom < height - d) return;
+
+            mStartX = mCurrentX;
+            mStartY = mCurrentY;
+            mStartScale = mCurrentScale;
+            calculateTarget(highlight);
+            start();
+        }
+
+        public void parkNow(RectF highlight) {
+            calculateTarget(highlight);
+            forceStop();
+            mStartX = mCurrentX = mTargetX;
+            mStartY = mCurrentY = mTargetY;
+            mStartScale = mCurrentScale = mTargetScale;
+        }
+
+        public void inverseMapPoint(PointF point) {
+            float s = mCurrentScale;
+            point.x = Utils.clamp(((point.x - getWidth() * 0.5f) / s
+                    + mCurrentX) / mImageWidth, 0, 1);
+            point.y = Utils.clamp(((point.y - getHeight() * 0.5f) / s
+                    + mCurrentY) / mImageHeight, 0, 1);
+        }
+
+        public RectF mapRect(RectF input, RectF output) {
+            float offsetX = getWidth() * 0.5f;
+            float offsetY = getHeight() * 0.5f;
+            int x = mCurrentX;
+            int y = mCurrentY;
+            float s = mCurrentScale;
+            output.set(
+                    offsetX + (input.left * mImageWidth - x) * s,
+                    offsetY + (input.top * mImageHeight - y) * s,
+                    offsetX + (input.right * mImageWidth - x) * s,
+                    offsetY + (input.bottom * mImageHeight - y) * s);
+            return output;
+        }
+
+        @Override
+        protected void onCalculate(float progress) {
+            mCurrentX = Math.round(mStartX + (mTargetX - mStartX) * progress);
+            mCurrentY = Math.round(mStartY + (mTargetY - mStartY) * progress);
+            mCurrentScale = mStartScale + (mTargetScale - mStartScale) * progress;
+
+            if (mCurrentX == mTargetX && mCurrentY == mTargetY
+                    && mCurrentScale == mTargetScale) forceStop();
+        }
+
+        public int getCenterX() {
+            return mCurrentX;
+        }
+
+        public int getCenterY() {
+            return mCurrentY;
+        }
+
+        public float getScale() {
+            return mCurrentScale;
+        }
+
+        private void calculateTarget(RectF highlight) {
+            float width = getWidth();
+            float height = getHeight();
+
+            if (mImageWidth != SIZE_UNKNOWN) {
+                float minScale = Math.min(width / mImageWidth, height / mImageHeight);
+                float scale = Utils.clamp(SELECTION_RATIO * Math.min(
+                        width / (highlight.width() * mImageWidth),
+                        height / (highlight.height() * mImageHeight)), minScale, 2f);
+                int centerX = Math.round(
+                        mImageWidth * (highlight.left + highlight.right) * 0.5f);
+                int centerY = Math.round(
+                        mImageHeight * (highlight.top + highlight.bottom) * 0.5f);
+
+                if (Math.round(mImageWidth * scale) > width) {
+                    int limitX = Math.round(width * 0.5f / scale);
+                    centerX = Math.round(
+                            (highlight.left + highlight.right) * mImageWidth / 2);
+                    centerX = Utils.clamp(centerX, limitX, mImageWidth - limitX);
+                } else {
+                    centerX = mImageWidth / 2;
+                }
+                if (Math.round(mImageHeight * scale) > height) {
+                    int limitY = Math.round(height * 0.5f / scale);
+                    centerY = Math.round(
+                            (highlight.top + highlight.bottom) * mImageHeight / 2);
+                    centerY = Utils.clamp(centerY, limitY, mImageHeight - limitY);
+                } else {
+                    centerY = mImageHeight / 2;
+                }
+                mTargetX = centerX;
+                mTargetY = centerY;
+                mTargetScale = scale;
+            }
+        }
+
+    }
+
+    private class HighlightRectangle extends GLView {
+        private RectF mHighlightRect = new RectF(0.25f, 0.25f, 0.75f, 0.75f);
+        private RectF mTempRect = new RectF();
+        private PointF mTempPoint = new PointF();
+
+        private ResourceTexture mArrowX;
+        private ResourceTexture mArrowY;
+
+        private int mMovingEdges = 0;
+        private float mReferenceX;
+        private float mReferenceY;
+
+        public HighlightRectangle() {
+            mArrowX = new ResourceTexture(mActivity.getAndroidContext(),
+                    R.drawable.camera_crop_width_holo);
+            mArrowY = new ResourceTexture(mActivity.getAndroidContext(),
+                    R.drawable.camera_crop_height_holo);
+        }
+
+        public void setInitRectangle() {
+            float targetRatio = mAspectRatio == UNSPECIFIED
+                    ? 1f
+                    : mAspectRatio * mImageHeight / mImageWidth;
+            float w = SELECTION_RATIO / 2f;
+            float h = SELECTION_RATIO / 2f;
+            if (targetRatio > 1) {
+                h = w / targetRatio;
+            } else {
+                w = h * targetRatio;
+            }
+            mHighlightRect.set(0.5f - w, 0.5f - h, 0.5f + w, 0.5f + h);
+        }
+
+        public void setRectangle(RectF faceRect) {
+            mHighlightRect.set(faceRect);
+            mAnimation.startParkingAnimation(faceRect);
+            invalidate();
+        }
+
+        private void moveEdges(MotionEvent event) {
+            float scale = mAnimation.getScale();
+            float dx = (event.getX() - mReferenceX) / scale / mImageWidth;
+            float dy = (event.getY() - mReferenceY) / scale / mImageHeight;
+            mReferenceX = event.getX();
+            mReferenceY = event.getY();
+            RectF r = mHighlightRect;
+
+            if ((mMovingEdges & MOVE_BLOCK) != 0) {
+                dx = Utils.clamp(dx, -r.left,  1 - r.right);
+                dy = Utils.clamp(dy, -r.top , 1 - r.bottom);
+                r.top += dy;
+                r.bottom += dy;
+                r.left += dx;
+                r.right += dx;
+            } else {
+                PointF point = mTempPoint;
+                point.set(mReferenceX, mReferenceY);
+                mAnimation.inverseMapPoint(point);
+                float left = r.left + MIN_SELECTION_LENGTH / mImageWidth;
+                float right = r.right - MIN_SELECTION_LENGTH / mImageWidth;
+                float top = r.top + MIN_SELECTION_LENGTH / mImageHeight;
+                float bottom = r.bottom - MIN_SELECTION_LENGTH / mImageHeight;
+                if ((mMovingEdges & MOVE_RIGHT) != 0) {
+                    r.right = Utils.clamp(point.x, left, 1f);
+                }
+                if ((mMovingEdges & MOVE_LEFT) != 0) {
+                    r.left = Utils.clamp(point.x, 0, right);
+                }
+                if ((mMovingEdges & MOVE_TOP) != 0) {
+                    r.top = Utils.clamp(point.y, 0, bottom);
+                }
+                if ((mMovingEdges & MOVE_BOTTOM) != 0) {
+                    r.bottom = Utils.clamp(point.y, top, 1f);
+                }
+                if (mAspectRatio != UNSPECIFIED) {
+                    float targetRatio = mAspectRatio * mImageHeight / mImageWidth;
+                    if (r.width() / r.height() > targetRatio) {
+                        float height = r.width() / targetRatio;
+                        if ((mMovingEdges & MOVE_BOTTOM) != 0) {
+                            r.bottom = Utils.clamp(r.top + height, top, 1f);
+                        } else {
+                            r.top = Utils.clamp(r.bottom - height, 0, bottom);
+                        }
+                    } else {
+                        float width = r.height() * targetRatio;
+                        if ((mMovingEdges & MOVE_LEFT) != 0) {
+                            r.left = Utils.clamp(r.right - width, 0, right);
+                        } else {
+                            r.right = Utils.clamp(r.left + width, left, 1f);
+                        }
+                    }
+                    if (r.width() / r.height() > targetRatio) {
+                        float width = r.height() * targetRatio;
+                        if ((mMovingEdges & MOVE_LEFT) != 0) {
+                            r.left = Utils.clamp(r.right - width, 0, right);
+                        } else {
+                            r.right = Utils.clamp(r.left + width, left, 1f);
+                        }
+                    } else {
+                        float height = r.width() / targetRatio;
+                        if ((mMovingEdges & MOVE_BOTTOM) != 0) {
+                            r.bottom = Utils.clamp(r.top + height, top, 1f);
+                        } else {
+                            r.top = Utils.clamp(r.bottom - height, 0, bottom);
+                        }
+                    }
+                }
+            }
+            invalidate();
+        }
+
+        private void setMovingEdges(MotionEvent event) {
+            RectF r = mAnimation.mapRect(mHighlightRect, mTempRect);
+            float x = event.getX();
+            float y = event.getY();
+
+            if (x > r.left + TOUCH_TOLERANCE && x < r.right - TOUCH_TOLERANCE
+                    && y > r.top + TOUCH_TOLERANCE && y < r.bottom - TOUCH_TOLERANCE) {
+                mMovingEdges = MOVE_BLOCK;
+                return;
+            }
+
+            boolean inVerticalRange = (r.top - TOUCH_TOLERANCE) <= y
+                    && y <= (r.bottom + TOUCH_TOLERANCE);
+            boolean inHorizontalRange = (r.left - TOUCH_TOLERANCE) <= x
+                    && x <= (r.right + TOUCH_TOLERANCE);
+
+            if (inVerticalRange) {
+                boolean left = Math.abs(x - r.left) <= TOUCH_TOLERANCE;
+                boolean right = Math.abs(x - r.right) <= TOUCH_TOLERANCE;
+                if (left && right) {
+                    left = Math.abs(x - r.left) < Math.abs(x - r.right);
+                    right = !left;
+                }
+                if (left) mMovingEdges |= MOVE_LEFT;
+                if (right) mMovingEdges |= MOVE_RIGHT;
+                if (mAspectRatio != UNSPECIFIED && inHorizontalRange) {
+                    mMovingEdges |= (y >
+                            (r.top + r.bottom) / 2) ? MOVE_BOTTOM : MOVE_TOP;
+                }
+            }
+            if (inHorizontalRange) {
+                boolean top = Math.abs(y - r.top) <= TOUCH_TOLERANCE;
+                boolean bottom = Math.abs(y - r.bottom) <= TOUCH_TOLERANCE;
+                if (top && bottom) {
+                    top = Math.abs(y - r.top) < Math.abs(y - r.bottom);
+                    bottom = !top;
+                }
+                if (top) mMovingEdges |= MOVE_TOP;
+                if (bottom) mMovingEdges |= MOVE_BOTTOM;
+                if (mAspectRatio != UNSPECIFIED && inVerticalRange) {
+                    mMovingEdges |= (x >
+                            (r.left + r.right) / 2) ? MOVE_RIGHT : MOVE_LEFT;
+                }
+            }
+        }
+
+        @Override
+        protected boolean onTouch(MotionEvent event) {
+            switch (event.getAction()) {
+                case MotionEvent.ACTION_DOWN: {
+                    mReferenceX = event.getX();
+                    mReferenceY = event.getY();
+                    setMovingEdges(event);
+                    invalidate();
+                    return true;
+                }
+                case MotionEvent.ACTION_MOVE:
+                    moveEdges(event);
+                    break;
+                case MotionEvent.ACTION_CANCEL:
+                case MotionEvent.ACTION_UP: {
+                    mMovingEdges = 0;
+                    mAnimation.startParkingAnimation(mHighlightRect);
+                    invalidate();
+                    return true;
+                }
+            }
+            return true;
+        }
+
+        @Override
+        protected void renderBackground(GLCanvas canvas) {
+            RectF r = mAnimation.mapRect(mHighlightRect, mTempRect);
+            drawHighlightRectangle(canvas, r);
+
+            float centerY = (r.top + r.bottom) / 2;
+            float centerX = (r.left + r.right) / 2;
+            if ((mMovingEdges & (MOVE_RIGHT | MOVE_BLOCK)) != 0) {
+                mArrowX.draw(canvas,
+                        Math.round(r.right - mArrowX.getWidth() / 2),
+                        Math.round(centerY - mArrowX.getHeight() / 2));
+            }
+            if ((mMovingEdges & (MOVE_LEFT | MOVE_BLOCK)) != 0) {
+                mArrowX.draw(canvas,
+                        Math.round(r.left - mArrowX.getWidth() / 2),
+                        Math.round(centerY - mArrowX.getHeight() / 2));
+            }
+            if ((mMovingEdges & (MOVE_TOP | MOVE_BLOCK)) != 0) {
+                mArrowY.draw(canvas,
+                        Math.round(centerX - mArrowY.getWidth() / 2),
+                        Math.round(r.top - mArrowY.getHeight() / 2));
+            }
+            if ((mMovingEdges & (MOVE_BOTTOM | MOVE_BLOCK)) != 0) {
+                mArrowY.draw(canvas,
+                        Math.round(centerX - mArrowY.getWidth() / 2),
+                        Math.round(r.bottom - mArrowY.getHeight() / 2));
+            }
+        }
+
+        private void drawHighlightRectangle(GLCanvas canvas, RectF r) {
+            GL11 gl = canvas.getGLInstance();
+            gl.glLineWidth(3.0f);
+            gl.glEnable(GL11.GL_LINE_SMOOTH);
+
+            gl.glEnable(GL11.GL_STENCIL_TEST);
+            gl.glClear(GL11.GL_STENCIL_BUFFER_BIT);
+            gl.glStencilOp(GL11.GL_KEEP, GL11.GL_KEEP, GL11.GL_REPLACE);
+            gl.glStencilFunc(GL11.GL_ALWAYS, 1, 1);
+
+            if (mSpotlightRatioX == 0 || mSpotlightRatioY == 0) {
+                canvas.fillRect(r.left, r.top, r.width(), r.height(), Color.TRANSPARENT);
+                canvas.drawRect(r.left, r.top, r.width(), r.height(), mPaint);
+            } else {
+                float sx = r.width() * mSpotlightRatioX;
+                float sy = r.height() * mSpotlightRatioY;
+                float cx = r.centerX();
+                float cy = r.centerY();
+
+                canvas.fillRect(cx - sx / 2, cy - sy / 2, sx, sy, Color.TRANSPARENT);
+                canvas.drawRect(cx - sx / 2, cy - sy / 2, sx, sy, mPaint);
+                canvas.drawRect(r.left, r.top, r.width(), r.height(), mPaint);
+
+                gl.glStencilFunc(GL11.GL_NOTEQUAL, 1, 1);
+                gl.glStencilOp(GL11.GL_KEEP, GL11.GL_KEEP, GL11.GL_REPLACE);
+
+                canvas.drawRect(cx - sy / 2, cy - sx / 2, sy, sx, mPaint);
+                canvas.fillRect(cx - sy / 2, cy - sx / 2, sy, sx, Color.TRANSPARENT);
+                canvas.fillRect(r.left, r.top, r.width(), r.height(), 0x80000000);
+            }
+
+            gl.glStencilFunc(GL11.GL_NOTEQUAL, 1, 1);
+            gl.glStencilOp(GL11.GL_KEEP, GL11.GL_KEEP, GL11.GL_KEEP);
+
+            canvas.fillRect(0, 0, getWidth(), getHeight(), 0xA0000000);
+
+            gl.glDisable(GL11.GL_STENCIL_TEST);
+        }
+    }
+
+    private class DetectFaceTask extends Thread {
+        private final FaceDetector.Face[] mFaces = new FaceDetector.Face[MAX_FACE_COUNT];
+        private final Bitmap mFaceBitmap;
+        private int mFaceCount;
+
+        public DetectFaceTask(Bitmap bitmap) {
+            mFaceBitmap = bitmap;
+            setName("face-detect");
+        }
+
+        @Override
+        public void run() {
+            Bitmap bitmap = mFaceBitmap;
+            FaceDetector detector = new FaceDetector(
+                    bitmap.getWidth(), bitmap.getHeight(), MAX_FACE_COUNT);
+            mFaceCount = detector.findFaces(bitmap, mFaces);
+            mMainHandler.sendMessage(
+                    mMainHandler.obtainMessage(MSG_UPDATE_FACES, this));
+        }
+
+        private RectF getFaceRect(FaceDetector.Face face) {
+            PointF point = new PointF();
+            face.getMidPoint(point);
+
+            int width = mFaceBitmap.getWidth();
+            int height = mFaceBitmap.getHeight();
+            float rx = face.eyesDistance() * FACE_EYE_RATIO;
+            float ry = rx;
+            float aspect = mAspectRatio;
+            if (aspect != UNSPECIFIED) {
+                if (aspect > 1) {
+                    rx = ry * aspect;
+                } else {
+                    ry = rx / aspect;
+                }
+            }
+
+            RectF r = new RectF(
+                    point.x - rx, point.y - ry, point.x + rx, point.y + ry);
+            r.intersect(0, 0, width, height);
+
+            if (aspect != UNSPECIFIED) {
+                if (r.width() / r.height() > aspect) {
+                    float w = r.height() * aspect;
+                    r.left = (r.left + r.right - w) * 0.5f;
+                    r.right = r.left + w;
+                } else {
+                    float h = r.width() / aspect;
+                    r.top =  (r.top + r.bottom - h) * 0.5f;
+                    r.bottom = r.top + h;
+                }
+            }
+
+            r.left /= width;
+            r.right /= width;
+            r.top /= height;
+            r.bottom /= height;
+            return r;
+        }
+
+        public void updateFaces() {
+            if (mFaceCount > 1) {
+                for (int i = 0, n = mFaceCount; i < n; ++i) {
+                    mFaceDetectionView.addFace(getFaceRect(mFaces[i]));
+                }
+                mFaceDetectionView.setVisibility(GLView.VISIBLE);
+                Toast.makeText(mActivity.getAndroidContext(),
+                        R.string.multiface_crop_help, Toast.LENGTH_SHORT).show();
+            } else if (mFaceCount == 1) {
+                mFaceDetectionView.setVisibility(GLView.INVISIBLE);
+                mHighlightRectangle.setRectangle(getFaceRect(mFaces[0]));
+                mHighlightRectangle.setVisibility(GLView.VISIBLE);
+            } else /*mFaceCount == 0*/ {
+                mHighlightRectangle.setInitRectangle();
+                mHighlightRectangle.setVisibility(GLView.VISIBLE);
+            }
+        }
+    }
+
+    public void setDataModel(TileImageView.Model dataModel, int rotation) {
+        if (((rotation / 90) & 0x01) != 0) {
+            mImageWidth = dataModel.getImageHeight();
+            mImageHeight = dataModel.getImageWidth();
+        } else {
+            mImageWidth = dataModel.getImageWidth();
+            mImageHeight = dataModel.getImageHeight();
+        }
+
+        mImageRotation = rotation;
+
+        mImageView.setModel(dataModel);
+        mAnimation.initialize();
+    }
+
+    public void detectFaces(Bitmap bitmap) {
+        int rotation = mImageRotation;
+        int width = bitmap.getWidth();
+        int height = bitmap.getHeight();
+        float scale = (float) Math.sqrt(
+                (double) FACE_PIXEL_COUNT / (width * height));
+
+        // faceBitmap is a correctly rotated bitmap, as viewed by a user.
+        Bitmap faceBitmap;
+        if (((rotation / 90) & 1) == 0) {
+            int w = (Math.round(width * scale) & ~1); // must be even
+            int h = Math.round(height * scale);
+            faceBitmap = Bitmap.createBitmap(w, h, Config.RGB_565);
+            Canvas canvas = new Canvas(faceBitmap);
+            canvas.rotate(rotation, w / 2, h / 2);
+            canvas.scale((float) w / width, (float) h / height);
+            canvas.drawBitmap(bitmap, 0, 0, new Paint(Paint.FILTER_BITMAP_FLAG));
+        } else {
+            int w = (Math.round(height * scale) & ~1); // must be even
+            int h = Math.round(width * scale);
+            faceBitmap = Bitmap.createBitmap(w, h, Config.RGB_565);
+            Canvas canvas = new Canvas(faceBitmap);
+            canvas.translate(w / 2, h / 2);
+            canvas.rotate(rotation);
+            canvas.translate(-h / 2, -w / 2);
+            canvas.scale((float) w / height, (float) h / width);
+            canvas.drawBitmap(bitmap, 0, 0, new Paint(Paint.FILTER_BITMAP_FLAG));
+        }
+        new DetectFaceTask(faceBitmap).start();
+    }
+
+    public void initializeHighlightRectangle() {
+        mHighlightRectangle.setInitRectangle();
+        mHighlightRectangle.setVisibility(GLView.VISIBLE);
+    }
+
+    public void resume() {
+        mImageView.prepareTextures();
+    }
+
+    public void pause() {
+        mImageView.freeTextures();
+    }
+}
+
diff --git a/src/com/android/gallery3d/ui/CustomMenu.java b/src/com/android/gallery3d/ui/CustomMenu.java
new file mode 100644
index 0000000..de2367e
--- /dev/null
+++ b/src/com/android/gallery3d/ui/CustomMenu.java
@@ -0,0 +1,126 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+
+import android.app.ActionBar;
+import android.content.Context;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.PopupMenu;
+import android.widget.PopupMenu.OnMenuItemClickListener;
+
+import java.util.ArrayList;
+
+public class CustomMenu implements OnMenuItemClickListener {
+    @SuppressWarnings("unused")
+    private static final String TAG = "FilterMenu";
+
+    public static class DropDownMenu {
+        private Button mButton;
+        private PopupMenu mPopupMenu;
+        private Menu mMenu;
+
+        public DropDownMenu(Context context, Button button, int menuId,
+                OnMenuItemClickListener listener) {
+            mButton = button;
+            mButton.setBackgroundDrawable(context.getResources().getDrawable(
+                    R.drawable.dropdown_normal_holo_dark));
+            mPopupMenu = new PopupMenu(context, mButton);
+            mMenu = mPopupMenu.getMenu();
+            mPopupMenu.getMenuInflater().inflate(menuId, mMenu);
+            mPopupMenu.setOnMenuItemClickListener(listener);
+            mButton.setOnClickListener(new OnClickListener() {
+                public void onClick(View v) {
+                    mPopupMenu.show();
+                }
+            });
+        }
+
+        public MenuItem findItem(int id) {
+            return mMenu.findItem(id);
+        }
+
+        public void setTitle(CharSequence title) {
+            mButton.setText(title);
+        }
+    }
+
+
+
+    private Context mContext;
+    private ArrayList<DropDownMenu> mMenus;
+    private OnMenuItemClickListener mListener;
+
+    public CustomMenu(Context context) {
+        mContext = context;
+        mMenus = new ArrayList<DropDownMenu>();
+    }
+
+    public DropDownMenu addDropDownMenu(Button button, int menuId) {
+        DropDownMenu menu = new DropDownMenu(mContext, button, menuId, this);
+        mMenus.add(menu);
+        return menu;
+    }
+
+    public void setOnMenuItemClickListener(OnMenuItemClickListener listener) {
+        mListener = listener;
+    }
+
+    public MenuItem findMenuItem(int id) {
+        MenuItem item = null;
+        for (DropDownMenu menu : mMenus) {
+            item = menu.findItem(id);
+            if (item != null) return item;
+        }
+        return item;
+    }
+
+    public void setMenuItemAppliedEnabled(int id, boolean applied, boolean enabled,
+            boolean updateTitle) {
+        MenuItem item = null;
+        for (DropDownMenu menu : mMenus) {
+            item = menu.findItem(id);
+            if (item != null) {
+                item.setCheckable(true);
+                item.setChecked(applied);
+                item.setEnabled(enabled);
+                if (updateTitle) {
+                    menu.setTitle(item.getTitle());
+                }
+            }
+        }
+    }
+
+    public void setMenuItemVisibility(int id, boolean visibility) {
+        MenuItem item = findMenuItem(id);
+        if (item != null) {
+            item.setVisible(visibility);
+        }
+    }
+
+    public boolean onMenuItemClick(MenuItem item) {
+        if (mListener != null) {
+            return mListener.onMenuItemClick(item);
+        }
+        return false;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/DetailsWindow.java b/src/com/android/gallery3d/ui/DetailsWindow.java
new file mode 100644
index 0000000..03e2169
--- /dev/null
+++ b/src/com/android/gallery3d/ui/DetailsWindow.java
@@ -0,0 +1,451 @@
+/*
+ * 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.ui;
+
+import static com.android.gallery3d.ui.DetailsWindowConfig.FONT_SIZE;
+import static com.android.gallery3d.ui.DetailsWindowConfig.LEFT_RIGHT_EXTRA_PADDING;
+import static com.android.gallery3d.ui.DetailsWindowConfig.LINE_SPACING;
+import static com.android.gallery3d.ui.DetailsWindowConfig.PREFERRED_WIDTH;
+import static com.android.gallery3d.ui.DetailsWindowConfig.TOP_BOTTOM_EXTRA_PADDING;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.MediaDetails;
+import com.android.gallery3d.util.Future;
+import com.android.gallery3d.util.FutureListener;
+import com.android.gallery3d.util.ReverseGeocoder;
+import com.android.gallery3d.util.ThreadPool.Job;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.location.Address;
+import android.os.Handler;
+import android.os.Message;
+import android.text.format.Formatter;
+import android.view.MotionEvent;
+import android.view.View.MeasureSpec;
+
+import java.util.ArrayList;
+import java.util.Map.Entry;
+
+// TODO: Add scroll bar to this window.
+public class DetailsWindow extends GLView {
+    @SuppressWarnings("unused")
+    private static final String TAG = "DetailsWindow";
+    private static final int MSG_REFRESH_LOCATION = 1;
+    private static final int FONT_COLOR = Color.WHITE;
+    private static final int CLOSE_BUTTON_SIZE = 32;
+
+    private GalleryActivity mContext;
+    protected Texture mBackground;
+    private StringTexture mTitle;
+    private MyDataModel mModel;
+    private MediaDetails mDetails;
+    private DetailsSource mSource;
+    private int mIndex;
+    private int mLocationIndex;
+    private Future<Address> mAddressLookupJob;
+    private Handler mHandler;
+    private Icon mCloseButton;
+    private int mMaxDetailLength;
+    private CloseListener mListener;
+
+    private ScrollView mScrollView;
+    private DetailsPanel mDetailPanel = new DetailsPanel();
+
+    public interface DetailsSource {
+        public int size();
+        public int findIndex(int indexHint);
+        public MediaDetails getDetails();
+    }
+
+    public interface CloseListener {
+        public void onClose();
+    }
+
+    public DetailsWindow(GalleryActivity activity, DetailsSource source) {
+        mContext = activity;
+        mSource = source;
+        mHandler = new SynchronizedHandler(activity.getGLRoot()) {
+            @Override
+            public void handleMessage(Message msg) {
+                switch(msg.what) {
+                    case MSG_REFRESH_LOCATION:
+                        mModel.updateLocation((Address) msg.obj);
+                        invalidate();
+                        break;
+                }
+            }
+        };
+        Context context = activity.getAndroidContext();
+        ResourceTexture icon = new ResourceTexture(context, R.drawable.ic_menu_cancel_holo_light);
+        setBackground(new NinePatchTexture(context, R.drawable.popup_full_dark));
+
+        mCloseButton = new Icon(context, icon, CLOSE_BUTTON_SIZE, CLOSE_BUTTON_SIZE) {
+            @Override
+            protected boolean onTouch(MotionEvent event) {
+                switch (event.getActionMasked()) {
+                    case MotionEvent.ACTION_UP:
+                        if (mListener != null) mListener.onClose();
+                }
+                return true;
+            }
+        };
+        mScrollView = new ScrollView(context);
+        mScrollView.addComponent(mDetailPanel);
+
+        super.addComponent(mScrollView);
+        super.addComponent(mCloseButton);
+
+        reloadDetails(0);
+    }
+
+    public void setCloseListener(CloseListener listener) {
+        mListener = listener;
+    }
+
+    public void setBackground(Texture background) {
+        if (background == mBackground) return;
+        mBackground = background;
+        if (background != null && background instanceof NinePatchTexture) {
+            Rect p = ((NinePatchTexture) mBackground).getPaddings();
+            p.left += LEFT_RIGHT_EXTRA_PADDING;
+            p.right += LEFT_RIGHT_EXTRA_PADDING;
+            p.top += TOP_BOTTOM_EXTRA_PADDING;
+            p.bottom += TOP_BOTTOM_EXTRA_PADDING;
+            setPaddings(p);
+        } else {
+            setPaddings(0, 0, 0, 0);
+        }
+        Rect p = getPaddings();
+        mMaxDetailLength = PREFERRED_WIDTH - p.left - p.right;
+        invalidate();
+    }
+
+    public void setTitle(String title) {
+        mTitle = StringTexture.newInstance(title, FONT_SIZE, FONT_COLOR);
+    }
+
+    @Override
+    protected void renderBackground(GLCanvas canvas) {
+        if (mBackground == null) return;
+        int width = getWidth();
+        int height = getHeight();
+
+        //TODO: change alpha in the background image.
+        canvas.save(GLCanvas.SAVE_FLAG_ALPHA);
+        canvas.setAlpha(0.7f);
+        mBackground.draw(canvas, 0, 0, width, height);
+        canvas.restore();
+
+        Rect p = getPaddings();
+        if (mTitle != null) mTitle.draw(canvas, p.left, p.top);
+    }
+
+    @Override
+    protected void onMeasure(int widthSpec, int heightSpec) {
+        int height = MeasureSpec.getSize(heightSpec);
+        MeasureHelper.getInstance(this)
+                .setPreferredContentSize(PREFERRED_WIDTH, height)
+                .measure(widthSpec, heightSpec);
+    }
+
+    @Override
+    protected void onLayout(boolean sizeChange, int l, int t, int r, int b) {
+        mCloseButton.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
+        int bWidth = mCloseButton.getMeasuredWidth();
+        int bHeight = mCloseButton.getMeasuredHeight();
+        int width = getWidth();
+        int height = getHeight();
+
+        Rect p = getPaddings();
+        mCloseButton.layout(width - p.right - bWidth, p.top,
+                width - p.right, p.top + bHeight);
+        mScrollView.layout(p.left, p.top + bHeight, width - p.right,
+                height - p.bottom);
+    }
+
+    public void show() {
+        setVisibility(GLView.VISIBLE);
+        requestLayout();
+    }
+
+    public void hide() {
+        setVisibility(GLView.INVISIBLE);
+        requestLayout();
+    }
+
+    public void pause() {
+        Future<Address> lookupJob = mAddressLookupJob;
+        if (lookupJob != null) {
+            lookupJob.cancel();
+            lookupJob.waitDone();
+        }
+    }
+
+    public void reloadDetails(int indexHint) {
+        int index = mSource.findIndex(indexHint);
+        if (index == -1) return;
+        MediaDetails details = mSource.getDetails();
+        if (details != null) {
+            if (mIndex == index && mDetails == details) return;
+            mIndex = index;
+            mDetails = details;
+            setDetails(details);
+        }
+        mDetailPanel.requestLayout();
+    }
+
+    private void setDetails(MediaDetails details) {
+        mModel = new MyDataModel(details);
+        invalidate();
+    }
+
+    private class AddressLookupJob implements Job<Address> {
+        double[] mLatlng;
+        protected AddressLookupJob(double[] latlng) {
+            mLatlng = latlng;
+        }
+
+        public Address run(JobContext jc) {
+            ReverseGeocoder geocoder = new ReverseGeocoder(mContext.getAndroidContext());
+            return geocoder.lookupAddress(mLatlng[0], mLatlng[1], true);
+        }
+    }
+
+    private class MyDataModel {
+        ArrayList<Texture> mItems;
+
+        public MyDataModel(MediaDetails details) {
+            Context context = mContext.getAndroidContext();
+            mLocationIndex = -1;
+            mItems = new ArrayList<Texture>(details.size());
+            setTitle(String.format(context.getString(R.string.sequence_in_set),
+                    mIndex + 1, mSource.size()));
+            setDetails(context, details);
+        }
+
+        private void setDetails(Context context, MediaDetails details) {
+            for (Entry<Integer, Object> detail : details) {
+                String value;
+                switch (detail.getKey()) {
+                    case MediaDetails.INDEX_LOCATION: {
+                        value = getLocationText((double[]) detail.getValue());
+                        break;
+                    }
+                    case MediaDetails.INDEX_SIZE: {
+                        value = Formatter.formatFileSize(
+                                context, (Long) detail.getValue());
+                        break;
+                    }
+                    case MediaDetails.INDEX_WHITE_BALANCE: {
+                        value = "1".equals(detail.getValue())
+                                ? context.getString(R.string.manual)
+                                : context.getString(R.string.auto);
+                        break;
+                    }
+                    case MediaDetails.INDEX_FLASH: {
+                        MediaDetails.FlashState flash =
+                                (MediaDetails.FlashState) detail.getValue();
+                        // TODO: camera doesn't fill in the complete values, show more information
+                        // when it is fixed.
+                        if (flash.isFlashFired()) {
+                            value = context.getString(R.string.flash_on);
+                        } else {
+                            value = context.getString(R.string.flash_off);
+                        }
+                        break;
+                    }
+                    case MediaDetails.INDEX_EXPOSURE_TIME: {
+                        value = (String) detail.getValue();
+                        double time = Double.valueOf(value);
+                        if (time < 1.0f) {
+                            value = String.format("1/%d", (int) (0.5f + 1 / time));
+                        } else {
+                            int integer = (int) time;
+                            time -= integer;
+                            value = String.valueOf(integer) + "''";
+                            if (time > 0.0001) {
+                                value += String.format(" 1/%d", (int) (0.5f + 1 / time));
+                            }
+                        }
+                        break;
+                    }
+                    default: {
+                        Object valueObj = detail.getValue();
+                        // This shouldn't happen, log its key to help us diagnose the problem.
+                        Utils.assertTrue(valueObj != null, "%s's value is Null",
+                                getName(context, detail.getKey()));
+                        value = valueObj.toString();
+                    }
+                }
+                int key = detail.getKey();
+                if (details.hasUnit(key)) {
+                    value = String.format("%s : %s %s", getName(context, key), value,
+                            context.getString(details.getUnit(key)));
+                } else {
+                    value = String.format("%s : %s", getName(context, key), value);
+                }
+                Texture label = MultiLineTexture.newInstance(
+                        value, mMaxDetailLength, FONT_SIZE, FONT_COLOR);
+                mItems.add(label);
+            }
+        }
+
+        private String getLocationText(double[] latlng) {
+            String text = String.format("(%f, %f)", latlng[0], latlng[1]);
+            mAddressLookupJob = mContext.getThreadPool().submit(
+                    new AddressLookupJob(latlng),
+                    new FutureListener<Address>() {
+                        public void onFutureDone(Future<Address> future) {
+                            mAddressLookupJob = null;
+                            if (!future.isCancelled()) {
+                                mHandler.sendMessage(mHandler.obtainMessage(
+                                        MSG_REFRESH_LOCATION, future.get()));
+                            }
+                        }
+                    });
+            mLocationIndex = mItems.size();
+            return text;
+        }
+
+        public void updateLocation(Address address) {
+            int index = mLocationIndex;
+            if (address != null && index >=0 && index < mItems.size()) {
+                Context context = mContext.getAndroidContext();
+                String parts[] = {
+                    address.getAdminArea(),
+                    address.getSubAdminArea(),
+                    address.getLocality(),
+                    address.getSubLocality(),
+                    address.getThoroughfare(),
+                    address.getSubThoroughfare(),
+                    address.getPremises(),
+                    address.getPostalCode(),
+                    address.getCountryName()
+                };
+
+                String addressText = "";
+                for (int i = 0; i < parts.length; i++) {
+                    if (parts[i] == null || parts[i].isEmpty()) continue;
+                    if (!addressText.isEmpty()) {
+                        addressText += ", ";
+                    }
+                    addressText += parts[i];
+                }
+                String text = String.format("%s : %s", getName(context,
+                        MediaDetails.INDEX_LOCATION), addressText);
+                mItems.set(index, MultiLineTexture.newInstance(
+                        text, mMaxDetailLength, FONT_SIZE, FONT_COLOR));
+            }
+        }
+
+        public Texture getView(int index) {
+            return mItems.get(index);
+        }
+
+        public int size() {
+            return mItems.size();
+        }
+    }
+
+    private static String getName(Context context, int key) {
+        switch (key) {
+            case MediaDetails.INDEX_TITLE:
+                return context.getString(R.string.title);
+            case MediaDetails.INDEX_DESCRIPTION:
+                return context.getString(R.string.description);
+            case MediaDetails.INDEX_DATETIME:
+                return context.getString(R.string.time);
+            case MediaDetails.INDEX_LOCATION:
+                return context.getString(R.string.location);
+            case MediaDetails.INDEX_PATH:
+                return context.getString(R.string.path);
+            case MediaDetails.INDEX_WIDTH:
+                return context.getString(R.string.width);
+            case MediaDetails.INDEX_HEIGHT:
+                return context.getString(R.string.height);
+            case MediaDetails.INDEX_ORIENTATION:
+                return context.getString(R.string.orientation);
+            case MediaDetails.INDEX_DURATION:
+                return context.getString(R.string.duration);
+            case MediaDetails.INDEX_MIMETYPE:
+                return context.getString(R.string.mimetype);
+            case MediaDetails.INDEX_SIZE:
+                return context.getString(R.string.file_size);
+            case MediaDetails.INDEX_MAKE:
+                return context.getString(R.string.maker);
+            case MediaDetails.INDEX_MODEL:
+                return context.getString(R.string.model);
+            case MediaDetails.INDEX_FLASH:
+                return context.getString(R.string.flash);
+            case MediaDetails.INDEX_APERTURE:
+                return context.getString(R.string.aperture);
+            case MediaDetails.INDEX_FOCAL_LENGTH:
+                return context.getString(R.string.focal_length);
+            case MediaDetails.INDEX_WHITE_BALANCE:
+                return context.getString(R.string.white_balance);
+            case MediaDetails.INDEX_EXPOSURE_TIME:
+                return context.getString(R.string.exposure_time);
+            case MediaDetails.INDEX_ISO:
+                return context.getString(R.string.iso);
+            default:
+                return "Unknown key" + key;
+        }
+    }
+
+    private class DetailsPanel extends GLView {
+
+        @Override
+        public void onMeasure(int widthSpec, int heightSpec) {
+            if (mTitle == null || mModel == null) {
+                MeasureHelper.getInstance(this)
+                        .setPreferredContentSize(PREFERRED_WIDTH, 0)
+                        .measure(widthSpec, heightSpec);
+                return;
+            }
+
+            int h = getPaddings().top + LINE_SPACING;
+            for (int i = 0, n = mModel.size(); i < n; ++i) {
+                h += mModel.getView(i).getHeight() + LINE_SPACING;
+            }
+
+            MeasureHelper.getInstance(this)
+                    .setPreferredContentSize(PREFERRED_WIDTH, h)
+                    .measure(widthSpec, heightSpec);
+        }
+
+        @Override
+        protected void render(GLCanvas canvas) {
+            super.render(canvas);
+
+            if (mTitle == null || mModel == null) {
+                return;
+            }
+            Rect p = getPaddings();
+            int x = p.left, y = p.top + LINE_SPACING;
+            for (int i = 0, n = mModel.size(); i < n ; i++) {
+                Texture t = mModel.getView(i);
+                t.draw(canvas, x, y);
+                y += t.getHeight() + LINE_SPACING;
+            }
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/DisplayItem.java b/src/com/android/gallery3d/ui/DisplayItem.java
new file mode 100644
index 0000000..3038232
--- /dev/null
+++ b/src/com/android/gallery3d/ui/DisplayItem.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.ui;
+
+public abstract class DisplayItem {
+
+    protected int mWidth;
+    protected int mHeight;
+
+    protected void setSize(int width, int height) {
+        mWidth = width;
+        mHeight = height;
+    }
+
+    // returns true if more pass is needed
+    public abstract boolean render(GLCanvas canvas, int pass);
+
+    public abstract long getIdentity();
+
+    public int getWidth() {
+        return mWidth;
+    }
+
+    public int getHeight() {
+        return mHeight;
+    }
+
+    public int getRotation() {
+        return 0;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/DownUpDetector.java b/src/com/android/gallery3d/ui/DownUpDetector.java
new file mode 100644
index 0000000..19db772
--- /dev/null
+++ b/src/com/android/gallery3d/ui/DownUpDetector.java
@@ -0,0 +1,61 @@
+/*
+ * 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.ui;
+
+import android.view.MotionEvent;
+
+public class DownUpDetector {
+    public interface DownUpListener {
+        void onDown(MotionEvent e);
+        void onUp(MotionEvent e);
+    }
+
+    private boolean mStillDown;
+    private DownUpListener mListener;
+
+    public DownUpDetector(DownUpListener listener) {
+        mListener = listener;
+    }
+
+    private void setState(boolean down, MotionEvent e) {
+        if (down == mStillDown) return;
+        mStillDown = down;
+        if (down) {
+            mListener.onDown(e);
+        } else {
+            mListener.onUp(e);
+        }
+    }
+
+    public void onTouchEvent(MotionEvent ev) {
+        switch (ev.getAction() & MotionEvent.ACTION_MASK) {
+        case MotionEvent.ACTION_DOWN:
+            setState(true, ev);
+            break;
+
+        case MotionEvent.ACTION_UP:
+        case MotionEvent.ACTION_CANCEL:
+        case MotionEvent.ACTION_POINTER_DOWN:  // Multitouch event - abort.
+            setState(false, ev);
+            break;
+        }
+    }
+
+    public boolean isDown() {
+        return mStillDown;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/DrawableTexture.java b/src/com/android/gallery3d/ui/DrawableTexture.java
new file mode 100644
index 0000000..5c3964d
--- /dev/null
+++ b/src/com/android/gallery3d/ui/DrawableTexture.java
@@ -0,0 +1,38 @@
+/*
+ * 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.ui;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.Drawable;
+
+// DrawableTexture is a texture whose content is from a Drawable.
+public class DrawableTexture extends CanvasTexture {
+
+    private final Drawable mDrawable;
+
+    public DrawableTexture(Drawable drawable, int width, int height) {
+        super(width, height);
+        mDrawable = drawable;
+    }
+
+    @Override
+    protected void onDraw(Canvas canvas, Bitmap backing) {
+        mDrawable.setBounds(0, 0, mWidth, mHeight);
+        mDrawable.draw(canvas);
+    }
+}
diff --git a/src/com/android/gallery3d/ui/FilmStripView.java b/src/com/android/gallery3d/ui/FilmStripView.java
new file mode 100644
index 0000000..8d28f2c
--- /dev/null
+++ b/src/com/android/gallery3d/ui/FilmStripView.java
@@ -0,0 +1,261 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.anim.AlphaAnimation;
+import com.android.gallery3d.anim.CanvasAnimation;
+import com.android.gallery3d.app.AlbumDataAdapter;
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.data.MediaSet;
+
+import android.content.Context;
+import android.view.MotionEvent;
+import android.view.View.MeasureSpec;
+
+public class FilmStripView extends GLView implements SlotView.Listener,
+        ScrollBarView.Listener, UserInteractionListener {
+    @SuppressWarnings("unused")
+    private static final String TAG = "FilmStripView";
+
+    private static final int HIDE_ANIMATION_DURATION = 300;  // 0.3 sec
+
+    public interface Listener {
+        void onSlotSelected(int slotIndex);
+    }
+
+    private int mTopMargin, mMidMargin, mBottomMargin;
+    private int mContentSize, mBarSize, mGripSize;
+    private AlbumView mAlbumView;
+    private ScrollBarView mScrollBarView;
+    private AlbumDataAdapter mAlbumDataAdapter;
+    private StripDrawer mStripDrawer;
+    private Listener mListener;
+    private UserInteractionListener mUIListener;
+    private boolean mFilmStripVisible;
+    private CanvasAnimation mFilmStripAnimation;
+    private NinePatchTexture mBackgroundTexture;
+
+    // The layout of FileStripView is
+    // topMargin
+    //             ----+----+
+    //            /    +----+--\
+    // contentSize     |    |   thumbSize
+    //            \    +----+--/
+    //             ----+----+
+    // midMargin
+    //             ----+----+
+    //            /    +----+--\
+    //     barSize     |    |   gripSize
+    //            \    +----+--/
+    //             ----+----+
+    // bottomMargin
+    public FilmStripView(GalleryActivity activity, MediaSet mediaSet,
+            int topMargin, int midMargin, int bottomMargin, int contentSize,
+            int thumbSize, int barSize, int gripSize, int gripWidth) {
+        mTopMargin = topMargin;
+        mMidMargin = midMargin;
+        mBottomMargin = bottomMargin;
+        mContentSize = contentSize;
+        mBarSize = barSize;
+        mGripSize = gripSize;
+
+        mStripDrawer = new StripDrawer((Context) activity);
+        mAlbumView = new AlbumView(activity, thumbSize, thumbSize, thumbSize);
+        mAlbumView.setOverscrollEffect(SlotView.OVERSCROLL_SYSTEM);
+        mAlbumView.setSelectionDrawer(mStripDrawer);
+        mAlbumView.setListener(this);
+        mAlbumView.setUserInteractionListener(this);
+        mAlbumDataAdapter = new AlbumDataAdapter(activity, mediaSet);
+        addComponent(mAlbumView);
+        mScrollBarView = new ScrollBarView(activity.getAndroidContext(),
+                mGripSize, gripWidth);
+        mScrollBarView.setListener(this);
+        addComponent(mScrollBarView);
+
+        mAlbumView.setModel(mAlbumDataAdapter);
+        mBackgroundTexture = new NinePatchTexture(activity.getAndroidContext(),
+                R.drawable.navstrip_translucent);
+        mFilmStripVisible = true;
+    }
+
+    public void setListener(Listener listener) {
+        mListener = listener;
+    }
+
+    public void setUserInteractionListener(UserInteractionListener listener) {
+        mUIListener = listener;
+    }
+
+    private void setFilmStripVisible(boolean visible) {
+        if (mFilmStripVisible == visible) return;
+        mFilmStripVisible = visible;
+        if (!visible) {
+            mFilmStripAnimation = new AlphaAnimation(1, 0);
+            mFilmStripAnimation.setDuration(HIDE_ANIMATION_DURATION);
+            mFilmStripAnimation.start();
+        } else {
+            mFilmStripAnimation = null;
+        }
+        invalidate();
+    }
+
+    public void show() {
+        setFilmStripVisible(true);
+    }
+
+    public void hide() {
+        setFilmStripVisible(false);
+    }
+
+    @Override
+    protected void onVisibilityChanged(int visibility) {
+        super.onVisibilityChanged(visibility);
+        if (visibility == GLView.VISIBLE) {
+            onUserInteraction();
+        }
+    }
+
+    @Override
+    protected void onMeasure(int widthSpec, int heightSpec) {
+        int height = mTopMargin + mContentSize + mMidMargin + mBarSize + mBottomMargin;
+        MeasureHelper.getInstance(this)
+                .setPreferredContentSize(MeasureSpec.getSize(widthSpec), height)
+                .measure(widthSpec, heightSpec);
+    }
+
+    @Override
+    protected void onLayout(
+            boolean changed, int left, int top, int right, int bottom) {
+        if (!changed) return;
+        mAlbumView.layout(0, mTopMargin, right - left, mTopMargin + mContentSize);
+        int barStart = mTopMargin + mContentSize + mMidMargin;
+        mScrollBarView.layout(0, barStart, right - left, barStart + mBarSize);
+        int width = right - left;
+        int height = bottom - top;
+    }
+
+    @Override
+    protected boolean onTouch(MotionEvent event) {
+        // consume all touch events on the "gray area", so they don't go to
+        // the photo view below. (otherwise you can scroll the picture through
+        // it).
+        return true;
+    }
+
+    @Override
+    protected boolean dispatchTouchEvent(MotionEvent event) {
+        if (!mFilmStripVisible && mFilmStripAnimation == null) {
+            return false;
+        }
+
+        switch (event.getAction()) {
+            case MotionEvent.ACTION_DOWN:
+            case MotionEvent.ACTION_MOVE:
+                onUserInteractionBegin();
+                break;
+            case MotionEvent.ACTION_UP:
+            case MotionEvent.ACTION_CANCEL:
+                onUserInteractionEnd();
+                break;
+        }
+
+        return super.dispatchTouchEvent(event);
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        CanvasAnimation anim = mFilmStripAnimation;
+        if (anim == null && !mFilmStripVisible) return;
+
+        boolean needRestore = false;
+        if (anim != null) {
+            needRestore = true;
+            canvas.save(anim.getCanvasSaveFlags());
+            long now = canvas.currentAnimationTimeMillis();
+            boolean more = anim.calculate(now);
+            anim.apply(canvas);
+            if (more) {
+                invalidate();
+            } else {
+                mFilmStripAnimation = null;
+            }
+        }
+
+        mBackgroundTexture.draw(canvas, 0, 0, getWidth(), getHeight());
+        super.render(canvas);
+
+        if (needRestore) {
+            canvas.restore();
+        }
+    }
+
+    // Called by AlbumView
+    public void onSingleTapUp(int slotIndex) {
+        mAlbumView.setFocusIndex(slotIndex);
+        mListener.onSlotSelected(slotIndex);
+    }
+
+    // Called by AlbumView
+    public void onLongTap(int slotIndex) {
+        onSingleTapUp(slotIndex);
+    }
+
+    // Called by AlbumView
+    public void onUserInteractionBegin() {
+        mUIListener.onUserInteractionBegin();
+    }
+
+    // Called by AlbumView
+    public void onUserInteractionEnd() {
+        mUIListener.onUserInteractionEnd();
+    }
+
+    // Called by AlbumView
+    public void onUserInteraction() {
+        mUIListener.onUserInteraction();
+    }
+
+    // Called by AlbumView
+    public void onScrollPositionChanged(int position, int total) {
+        mScrollBarView.setContentPosition(position, total);
+    }
+
+    // Called by ScrollBarView
+    public void onScrollBarPositionChanged(int position) {
+        mAlbumView.setScrollPosition(position);
+    }
+
+    public void setFocusIndex(int slotIndex) {
+        mAlbumView.setFocusIndex(slotIndex);
+        mAlbumView.makeSlotVisible(slotIndex);
+    }
+
+    public void setStartIndex(int slotIndex) {
+        mAlbumView.setStartIndex(slotIndex);
+    }
+
+    public void pause() {
+        mAlbumView.pause();
+        mAlbumDataAdapter.pause();
+    }
+
+    public void resume() {
+        mAlbumView.resume();
+        mAlbumDataAdapter.resume();
+    }
+}
diff --git a/src/com/android/gallery3d/ui/GLCanvas.java b/src/com/android/gallery3d/ui/GLCanvas.java
new file mode 100644
index 0000000..88c02f3
--- /dev/null
+++ b/src/com/android/gallery3d/ui/GLCanvas.java
@@ -0,0 +1,138 @@
+/*
+ * 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.ui;
+
+import android.graphics.RectF;
+
+import javax.microedition.khronos.opengles.GL11;
+
+//
+// GLCanvas gives a convenient interface to draw using OpenGL.
+//
+// When a rectangle is specified in this interface, it means the region
+// [x, x+width) * [y, y+height)
+//
+public interface GLCanvas {
+    // Tells GLCanvas the size of the underlying GL surface. This should be
+    // called before first drawing and when the size of GL surface is changed.
+    // This is called by GLRoot and should not be called by the clients
+    // who only want to draw on the GLCanvas. Both width and height must be
+    // nonnegative.
+    public void setSize(int width, int height);
+
+    // Clear the drawing buffers. This should only be used by GLRoot.
+    public void clearBuffer();
+
+    // This is the time value used to calculate the animation in the current
+    // frame. The "set" function should only called by GLRoot, and the
+    // "time" parameter must be nonnegative.
+    public void setCurrentAnimationTimeMillis(long time);
+    public long currentAnimationTimeMillis();
+
+    public void setBlendEnabled(boolean enabled);
+
+    // Sets and gets the current alpha, alpha must be in [0, 1].
+    public void setAlpha(float alpha);
+    public float getAlpha();
+
+    // (current alpha) = (current alpha) * alpha
+    public void multiplyAlpha(float alpha);
+
+    // Change the current transform matrix.
+    public void translate(float x, float y, float z);
+    public void scale(float sx, float sy, float sz);
+    public void rotate(float angle, float x, float y, float z);
+    public void multiplyMatrix(float[] mMatrix, int offset);
+
+    // Modifies the current clip with the specified rectangle.
+    // (current clip) = (current clip) intersect (specified rectangle).
+    // Returns true if the result clip is non-empty.
+    public boolean clipRect(int left, int top, int right, int bottom);
+
+    // Pushes the configuration state (matrix, alpha, and clip) onto
+    // a private stack.
+    public int save();
+
+    // Same as save(), but only save those specified in saveFlags.
+    public int save(int saveFlags);
+
+    public static final int SAVE_FLAG_ALL = 0xFFFFFFFF;
+    public static final int SAVE_FLAG_CLIP = 0x01;
+    public static final int SAVE_FLAG_ALPHA = 0x02;
+    public static final int SAVE_FLAG_MATRIX = 0x04;
+
+    // Pops from the top of the stack as current configuration state (matrix,
+    // alpha, and clip). This call balances a previous call to save(), and is
+    // used to remove all modifications to the configuration state since the
+    // last save call.
+    public void restore();
+
+    // Draws a line using the specified paint from (x1, y1) to (x2, y2).
+    // (Both end points are included).
+    public void drawLine(float x1, float y1, float x2, float y2, GLPaint paint);
+
+    // Draws a rectangle using the specified paint from (x1, y1) to (x2, y2).
+    // (Both end points are included).
+    public void drawRect(float x1, float y1, float x2, float y2, GLPaint paint);
+
+    // Fills the specified rectangle with the specified color.
+    public void fillRect(float x, float y, float width, float height, int color);
+
+    // Draws a texture to the specified rectangle.
+    public void drawTexture(
+            BasicTexture texture, int x, int y, int width, int height);
+    public void drawMesh(BasicTexture tex, int x, int y, int xyBuffer,
+            int uvBuffer, int indexBuffer, int indexCount);
+
+    // Draws a texture to the specified rectangle. The "alpha" parameter
+    // overrides the current drawing alpha value.
+    public void drawTexture(BasicTexture texture,
+            int x, int y, int width, int height, float alpha);
+
+    // Draws a the source rectangle part of the texture to the target rectangle.
+    public void drawTexture(BasicTexture texture, RectF source, RectF target);
+
+    // Draw two textures to the specified rectangle. The actual texture used is
+    // from * (1 - ratio) + to * ratio
+    // The two textures must have the same size.
+    public void drawMixed(BasicTexture from, BasicTexture to,
+            float ratio, int x, int y, int w, int h);
+
+    public void drawMixed(BasicTexture from, int toColor,
+            float ratio, int x, int y, int w, int h);
+
+    // Return a texture copied from the specified rectangle.
+    public BasicTexture copyTexture(int x, int y, int width, int height);
+
+    // Gets the underlying GL instance. This is used only when direct access to
+    // GL is needed.
+    public GL11 getGLInstance();
+
+    // Unloads the specified texture from the canvas. The resource allocated
+    // to draw the texture will be released. The specified texture will return
+    // to the unloaded state. This function should be called only from
+    // BasicTexture or its descendant
+    public boolean unloadTexture(BasicTexture texture);
+
+    // Delete the specified buffer object, similar to unloadTexture.
+    public void deleteBuffer(int bufferId);
+
+    // Delete the textures and buffers in GL side. This function should only be
+    // called in the GL thread.
+    public void deleteRecycledResources();
+
+}
diff --git a/src/com/android/gallery3d/ui/GLCanvasImpl.java b/src/com/android/gallery3d/ui/GLCanvasImpl.java
new file mode 100644
index 0000000..387743f
--- /dev/null
+++ b/src/com/android/gallery3d/ui/GLCanvasImpl.java
@@ -0,0 +1,913 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.util.IntArray;
+
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.opengl.GLU;
+import android.opengl.Matrix;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.FloatBuffer;
+import java.util.Stack;
+import javax.microedition.khronos.opengles.GL10;
+import javax.microedition.khronos.opengles.GL11;
+import javax.microedition.khronos.opengles.GL11Ext;
+
+public class GLCanvasImpl implements GLCanvas {
+    @SuppressWarnings("unused")
+    private static final String TAG = "GLCanvasImp";
+
+    private static final float OPAQUE_ALPHA = 0.95f;
+
+    private static final int OFFSET_FILL_RECT = 0;
+    private static final int OFFSET_DRAW_LINE = 4;
+    private static final int OFFSET_DRAW_RECT = 6;
+    private static final float[] BOX_COORDINATES = {
+            0, 0, 1, 0, 0, 1, 1, 1,  // used for filling a rectangle
+            0, 0, 1, 1,              // used for drawing a line
+            0, 0, 0, 1, 1, 1, 1, 0}; // used for drawing the outline of a rectangle
+
+    private final GL11 mGL;
+
+    private final float mMatrixValues[] = new float[16];
+    private final float mTextureMatrixValues[] = new float[16];
+
+    // mapPoints needs 10 input and output numbers.
+    private final float mMapPointsBuffer[] = new float[10];
+
+    private final float mTextureColor[] = new float[4];
+
+    private int mBoxCoords;
+
+    private final GLState mGLState;
+
+    private long mAnimationTime;
+
+    private float mAlpha;
+    private final Rect mClipRect = new Rect();
+    private final Stack<ConfigState> mRestoreStack =
+            new Stack<ConfigState>();
+    private ConfigState mRecycledRestoreAction;
+
+    private final RectF mDrawTextureSourceRect = new RectF();
+    private final RectF mDrawTextureTargetRect = new RectF();
+    private final float[] mTempMatrix = new float[32];
+    private final IntArray mUnboundTextures = new IntArray();
+    private final IntArray mDeleteBuffers = new IntArray();
+    private int mHeight;
+    private boolean mBlendEnabled = true;
+
+    // Drawing statistics
+    int mCountDrawLine;
+    int mCountFillRect;
+    int mCountDrawMesh;
+    int mCountTextureRect;
+    int mCountTextureOES;
+
+    GLCanvasImpl(GL11 gl) {
+        mGL = gl;
+        mGLState = new GLState(gl);
+        initialize();
+    }
+
+    public void setSize(int width, int height) {
+        Utils.assertTrue(width >= 0 && height >= 0);
+        mHeight = height;
+
+        GL11 gl = mGL;
+        gl.glViewport(0, 0, width, height);
+        gl.glMatrixMode(GL11.GL_PROJECTION);
+        gl.glLoadIdentity();
+        GLU.gluOrtho2D(gl, 0, width, 0, height);
+
+        gl.glMatrixMode(GL11.GL_MODELVIEW);
+        gl.glLoadIdentity();
+        float matrix[] = mMatrixValues;
+
+        Matrix.setIdentityM(matrix, 0);
+        Matrix.translateM(matrix, 0, 0, mHeight, 0);
+        Matrix.scaleM(matrix, 0, 1, -1, 1);
+
+        mClipRect.set(0, 0, width, height);
+        gl.glScissor(0, 0, width, height);
+    }
+
+    public long currentAnimationTimeMillis() {
+        return mAnimationTime;
+    }
+
+    public void setAlpha(float alpha) {
+        Utils.assertTrue(alpha >= 0 && alpha <= 1);
+        mAlpha = alpha;
+    }
+
+    public void multiplyAlpha(float alpha) {
+        Utils.assertTrue(alpha >= 0 && alpha <= 1);
+        mAlpha *= alpha;
+    }
+
+    public float getAlpha() {
+        return mAlpha;
+    }
+
+    private static ByteBuffer allocateDirectNativeOrderBuffer(int size) {
+        return ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder());
+    }
+
+    private void initialize() {
+        GL11 gl = mGL;
+
+        // First create an nio buffer, then create a VBO from it.
+        int size = BOX_COORDINATES.length * Float.SIZE / Byte.SIZE;
+        FloatBuffer xyBuffer = allocateDirectNativeOrderBuffer(size).asFloatBuffer();
+        xyBuffer.put(BOX_COORDINATES, 0, BOX_COORDINATES.length).position(0);
+
+        int[] name = new int[1];
+        gl.glGenBuffers(1, name, 0);
+        mBoxCoords = name[0];
+
+        gl.glBindBuffer(GL11.GL_ARRAY_BUFFER, mBoxCoords);
+        gl.glBufferData(GL11.GL_ARRAY_BUFFER,
+                xyBuffer.capacity() * (Float.SIZE / Byte.SIZE),
+                xyBuffer, GL11.GL_STATIC_DRAW);
+
+        gl.glVertexPointer(2, GL11.GL_FLOAT, 0, 0);
+        gl.glTexCoordPointer(2, GL11.GL_FLOAT, 0, 0);
+
+        // Enable the texture coordinate array for Texture 1
+        gl.glClientActiveTexture(GL11.GL_TEXTURE1);
+        gl.glTexCoordPointer(2, GL11.GL_FLOAT, 0, 0);
+        gl.glClientActiveTexture(GL11.GL_TEXTURE0);
+        gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
+
+        // mMatrixValues will be initialized in setSize()
+        mAlpha = 1.0f;
+    }
+
+    public void drawRect(float x, float y, float width, float height, GLPaint paint) {
+        GL11 gl = mGL;
+
+        mGLState.setColorMode(paint.getColor(), mAlpha);
+        mGLState.setLineWidth(paint.getLineWidth());
+        mGLState.setLineSmooth(paint.getAntiAlias());
+
+        saveTransform();
+        translate(x, y, 0);
+        scale(width, height, 1);
+
+        gl.glLoadMatrixf(mMatrixValues, 0);
+        gl.glDrawArrays(GL11.GL_LINE_LOOP, OFFSET_DRAW_RECT, 4);
+
+        restoreTransform();
+        mCountDrawLine++;
+    }
+
+    public void drawLine(float x1, float y1, float x2, float y2, GLPaint paint) {
+        GL11 gl = mGL;
+
+        mGLState.setColorMode(paint.getColor(), mAlpha);
+        mGLState.setLineWidth(paint.getLineWidth());
+        mGLState.setLineSmooth(paint.getAntiAlias());
+
+        saveTransform();
+        translate(x1, y1, 0);
+        scale(x2 - x1, y2 - y1, 1);
+
+        gl.glLoadMatrixf(mMatrixValues, 0);
+        gl.glDrawArrays(GL11.GL_LINE_STRIP, OFFSET_DRAW_LINE, 2);
+
+        restoreTransform();
+        mCountDrawLine++;
+    }
+
+    public void fillRect(float x, float y, float width, float height, int color) {
+        mGLState.setColorMode(color, mAlpha);
+        GL11 gl = mGL;
+
+        saveTransform();
+        translate(x, y, 0);
+        scale(width, height, 1);
+
+        gl.glLoadMatrixf(mMatrixValues, 0);
+        gl.glDrawArrays(GL11.GL_TRIANGLE_STRIP, OFFSET_FILL_RECT, 4);
+
+        restoreTransform();
+        mCountFillRect++;
+    }
+
+    public void translate(float x, float y, float z) {
+        Matrix.translateM(mMatrixValues, 0, x, y, z);
+    }
+
+    public void scale(float sx, float sy, float sz) {
+        Matrix.scaleM(mMatrixValues, 0, sx, sy, sz);
+    }
+
+    public void rotate(float angle, float x, float y, float z) {
+        float[] temp = mTempMatrix;
+        Matrix.setRotateM(temp, 0, angle, x, y, z);
+        Matrix.multiplyMM(temp, 16, mMatrixValues, 0, temp, 0);
+        System.arraycopy(temp, 16, mMatrixValues, 0, 16);
+    }
+
+    public void multiplyMatrix(float matrix[], int offset) {
+        float[] temp = mTempMatrix;
+        Matrix.multiplyMM(temp, 0, mMatrixValues , 0, matrix, 0);
+        System.arraycopy(temp, 0, mMatrixValues, 0, 16);
+    }
+
+    private void textureRect(float x, float y, float width, float height) {
+        GL11 gl = mGL;
+
+        saveTransform();
+        translate(x, y, 0);
+        scale(width, height, 1);
+
+        gl.glLoadMatrixf(mMatrixValues, 0);
+        gl.glDrawArrays(GL11.GL_TRIANGLE_STRIP, OFFSET_FILL_RECT, 4);
+
+        restoreTransform();
+        mCountTextureRect++;
+    }
+
+    public void drawMesh(BasicTexture tex, int x, int y, int xyBuffer,
+            int uvBuffer, int indexBuffer, int indexCount) {
+        float alpha = mAlpha;
+        if (!bindTexture(tex)) return;
+
+        mGLState.setBlendEnabled(mBlendEnabled
+                && (!tex.isOpaque() || alpha < OPAQUE_ALPHA));
+        mGLState.setTextureAlpha(alpha);
+
+        // Reset the texture matrix. We will set our own texture coordinates
+        // below.
+        setTextureCoords(0, 0, 1, 1);
+
+        saveTransform();
+        translate(x, y, 0);
+
+        mGL.glLoadMatrixf(mMatrixValues, 0);
+
+        mGL.glBindBuffer(GL11.GL_ARRAY_BUFFER, xyBuffer);
+        mGL.glVertexPointer(2, GL11.GL_FLOAT, 0, 0);
+
+        mGL.glBindBuffer(GL11.GL_ARRAY_BUFFER, uvBuffer);
+        mGL.glTexCoordPointer(2, GL11.GL_FLOAT, 0, 0);
+
+        mGL.glBindBuffer(GL11.GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
+        mGL.glDrawElements(GL11.GL_TRIANGLE_STRIP,
+                indexCount, GL11.GL_UNSIGNED_BYTE, 0);
+
+        mGL.glBindBuffer(GL11.GL_ARRAY_BUFFER, mBoxCoords);
+        mGL.glVertexPointer(2, GL11.GL_FLOAT, 0, 0);
+        mGL.glTexCoordPointer(2, GL11.GL_FLOAT, 0, 0);
+
+        restoreTransform();
+        mCountDrawMesh++;
+    }
+
+    private float[] mapPoints(float matrix[], int x1, int y1, int x2, int y2) {
+        float[] point = mMapPointsBuffer;
+        int srcOffset = 6;
+        point[srcOffset] = x1;
+        point[srcOffset + 1] = y1;
+        point[srcOffset + 2] = 0;
+        point[srcOffset + 3] = 1;
+
+        int resultOffset = 0;
+        Matrix.multiplyMV(point, resultOffset, matrix, 0, point, srcOffset);
+        point[resultOffset] /= point[resultOffset + 3];
+        point[resultOffset + 1] /= point[resultOffset + 3];
+
+        // map the second point
+        point[srcOffset] = x2;
+        point[srcOffset + 1] = y2;
+        resultOffset = 2;
+        Matrix.multiplyMV(point, resultOffset, matrix, 0, point, srcOffset);
+        point[resultOffset] /= point[resultOffset + 3];
+        point[resultOffset + 1] /= point[resultOffset + 3];
+
+        return point;
+    }
+
+    public boolean clipRect(int left, int top, int right, int bottom) {
+        float point[] = mapPoints(mMatrixValues, left, top, right, bottom);
+
+        // mMatrix could be a rotation matrix. In this case, we need to find
+        // the boundaries after rotation. (only handle 90 * n degrees)
+        if (point[0] > point[2]) {
+            left = (int) point[2];
+            right = (int) point[0];
+        } else {
+            left = (int) point[0];
+            right = (int) point[2];
+        }
+        if (point[1] > point[3]) {
+            top = (int) point[3];
+            bottom = (int) point[1];
+        } else {
+            top = (int) point[1];
+            bottom = (int) point[3];
+        }
+        Rect clip = mClipRect;
+
+        boolean intersect = clip.intersect(left, top, right, bottom);
+        if (!intersect) clip.set(0, 0, 0, 0);
+        mGL.glScissor(clip.left, clip.top, clip.width(), clip.height());
+        return intersect;
+    }
+
+    private void drawBoundTexture(
+            BasicTexture texture, int x, int y, int width, int height) {
+        // Test whether it has been rotated or flipped, if so, glDrawTexiOES
+        // won't work
+        if (isMatrixRotatedOrFlipped(mMatrixValues)) {
+            setTextureCoords(0, 0,
+                    (float) texture.getWidth() / texture.getTextureWidth(),
+                    (float) texture.getHeight() / texture.getTextureHeight());
+            textureRect(x, y, width, height);
+        } else {
+            // draw the rect from bottom-left to top-right
+            float points[] = mapPoints(
+                    mMatrixValues, x, y + height, x + width, y);
+            x = Math.round(points[0]);
+            y = Math.round(points[1]);
+            width = Math.round(points[2]) - x;
+            height = Math.round(points[3]) - y;
+            if (width > 0 && height > 0) {
+                ((GL11Ext) mGL).glDrawTexiOES(x, y, 0, width, height);
+                mCountTextureOES++;
+            }
+        }
+    }
+
+    public void drawTexture(
+            BasicTexture texture, int x, int y, int width, int height) {
+        drawTexture(texture, x, y, width, height, mAlpha);
+    }
+
+    public void setBlendEnabled(boolean enabled) {
+        mBlendEnabled = enabled;
+    }
+
+    public void drawTexture(BasicTexture texture,
+            int x, int y, int width, int height, float alpha) {
+        if (width <= 0 || height <= 0) return;
+
+        mGLState.setBlendEnabled(mBlendEnabled
+                && (!texture.isOpaque() || alpha < OPAQUE_ALPHA));
+        if (!bindTexture(texture)) return;
+        mGLState.setTextureAlpha(alpha);
+        drawBoundTexture(texture, x, y, width, height);
+    }
+
+    public void drawTexture(BasicTexture texture, RectF source, RectF target) {
+        if (target.width() <= 0 || target.height() <= 0) return;
+
+        // Copy the input to avoid changing it.
+        mDrawTextureSourceRect.set(source);
+        mDrawTextureTargetRect.set(target);
+        source = mDrawTextureSourceRect;
+        target = mDrawTextureTargetRect;
+
+        mGLState.setBlendEnabled(mBlendEnabled
+                && (!texture.isOpaque() || mAlpha < OPAQUE_ALPHA));
+        if (!bindTexture(texture)) return;
+        convertCoordinate(source, target, texture);
+        setTextureCoords(source);
+        mGLState.setTextureAlpha(mAlpha);
+        textureRect(target.left, target.top, target.width(), target.height());
+    }
+
+    // This function changes the source coordinate to the texture coordinates.
+    // It also clips the source and target coordinates if it is beyond the
+    // bound of the texture.
+    private void convertCoordinate(RectF source, RectF target,
+            BasicTexture texture) {
+
+        int width = texture.getWidth();
+        int height = texture.getHeight();
+        int texWidth = texture.getTextureWidth();
+        int texHeight = texture.getTextureHeight();
+        // Convert to texture coordinates
+        source.left /= texWidth;
+        source.right /= texWidth;
+        source.top /= texHeight;
+        source.bottom /= texHeight;
+
+        // Clip if the rendering range is beyond the bound of the texture.
+        float xBound = (float) width / texWidth;
+        if (source.right > xBound) {
+            target.right = target.left + target.width() *
+                    (xBound - source.left) / source.width();
+            source.right = xBound;
+        }
+        float yBound = (float) height / texHeight;
+        if (source.bottom > yBound) {
+            target.bottom = target.top + target.height() *
+                    (yBound - source.top) / source.height();
+            source.bottom = yBound;
+        }
+    }
+
+    public void drawMixed(BasicTexture from,
+            int toColor, float ratio, int x, int y, int w, int h) {
+        drawMixed(from, toColor, ratio, x, y, w, h, mAlpha);
+    }
+
+    public void drawMixed(BasicTexture from, BasicTexture to,
+            float ratio, int x, int y, int w, int h) {
+        drawMixed(from, to, ratio, x, y, w, h, mAlpha);
+    }
+
+    private boolean bindTexture(BasicTexture texture) {
+        if (!texture.onBind(this)) return false;
+        mGLState.setTexture2DEnabled(true);
+        mGL.glBindTexture(GL11.GL_TEXTURE_2D, texture.getId());
+        return true;
+    }
+
+    private void setTextureColor(float r, float g, float b, float alpha) {
+        float[] color = mTextureColor;
+        color[0] = r;
+        color[1] = g;
+        color[2] = b;
+        color[3] = alpha;
+    }
+
+    private void drawMixed(BasicTexture from, int toColor,
+            float ratio, int x, int y, int width, int height, float alpha) {
+
+        if (ratio <= 0) {
+            drawTexture(from, x, y, width, height, alpha);
+            return;
+        } else if (ratio >= 1) {
+            fillRect(x, y, width, height, toColor);
+            return;
+        }
+
+        mGLState.setBlendEnabled(mBlendEnabled && (!from.isOpaque()
+                || !Utils.isOpaque(toColor) || alpha < OPAQUE_ALPHA));
+
+        final GL11 gl = mGL;
+        if (!bindTexture(from)) return;
+
+        //
+        // The formula we want:
+        //     alpha * ((1 - ratio) * from + ratio * to)
+        // The formula that GL supports is in the form of:
+        //     combo * (modulate * from) + (1 - combo) * to
+        //
+        // So, we have combo = 1 - alpha * ratio
+        //     and     modulate = alpha * (1f - ratio) / combo
+        //
+        float comboRatio = 1 - alpha * ratio;
+
+        // handle the case that (1 - comboRatio) == 0
+        if (alpha < OPAQUE_ALPHA) {
+            mGLState.setTextureAlpha(alpha * (1f - ratio) / comboRatio);
+        } else {
+            mGLState.setTextureAlpha(1f);
+        }
+
+        // Interpolate the RGB and alpha values between both textures.
+        mGLState.setTexEnvMode(GL11.GL_COMBINE);
+        // Specify the interpolation factor via the alpha component of
+        // GL_TEXTURE_ENV_COLORs.
+        // RGB component are get from toColor and will used as SRC1
+        float colorAlpha = (float) (toColor >>> 24) / (0xff * 0xff);
+        setTextureColor(((toColor >>> 16) & 0xff) * colorAlpha,
+                ((toColor >>> 8) & 0xff) * colorAlpha,
+                (toColor & 0xff) * colorAlpha, comboRatio);
+        gl.glTexEnvfv(GL11.GL_TEXTURE_ENV, GL11.GL_TEXTURE_ENV_COLOR, mTextureColor, 0);
+
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_COMBINE_RGB, GL11.GL_INTERPOLATE);
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_COMBINE_ALPHA, GL11.GL_INTERPOLATE);
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_SRC1_RGB, GL11.GL_CONSTANT);
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_OPERAND1_RGB, GL11.GL_SRC_COLOR);
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_SRC1_ALPHA, GL11.GL_CONSTANT);
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_OPERAND1_ALPHA, GL11.GL_SRC_ALPHA);
+
+        // Wire up the interpolation factor for RGB.
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_SRC2_RGB, GL11.GL_CONSTANT);
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_OPERAND2_RGB, GL11.GL_SRC_ALPHA);
+
+        // Wire up the interpolation factor for alpha.
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_SRC2_ALPHA, GL11.GL_CONSTANT);
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_OPERAND2_ALPHA, GL11.GL_SRC_ALPHA);
+
+        drawBoundTexture(from, x, y, width, height);
+        mGLState.setTexEnvMode(GL11.GL_REPLACE);
+    }
+
+    private void drawMixed(BasicTexture from, BasicTexture to,
+            float ratio, int x, int y, int width, int height, float alpha) {
+
+        if (ratio <= 0) {
+            drawTexture(from, x, y, width, height, alpha);
+            return;
+        } else if (ratio >= 1) {
+            drawTexture(to, x, y, width, height, alpha);
+            return;
+        }
+
+        // In the current implementation the two textures must have the
+        // same size.
+        Utils.assertTrue(from.getWidth() == to.getWidth()
+                && from.getHeight() == to.getHeight());
+
+        mGLState.setBlendEnabled(mBlendEnabled && (!from.isOpaque()
+                || !to.isOpaque() || alpha < OPAQUE_ALPHA));
+
+        final GL11 gl = mGL;
+        if (!bindTexture(from)) return;
+
+        //
+        // The formula we want:
+        //     alpha * ((1 - ratio) * from + ratio * to)
+        // The formula that GL supports is in the form of:
+        //     combo * (modulate * from) + (1 - combo) * to
+        //
+        // So, we have combo = 1 - alpha * ratio
+        //     and     modulate = alpha * (1f - ratio) / combo
+        //
+        float comboRatio = 1 - alpha * ratio;
+
+        // handle the case that (1 - comboRatio) == 0
+        if (alpha < OPAQUE_ALPHA) {
+            mGLState.setTextureAlpha(alpha * (1f - ratio) / comboRatio);
+        } else {
+            mGLState.setTextureAlpha(1f);
+        }
+
+        gl.glActiveTexture(GL11.GL_TEXTURE1);
+        if (!bindTexture(to)) {
+            // Disable TEXTURE1.
+            gl.glDisable(GL11.GL_TEXTURE_2D);
+            // Switch back to the default texture unit.
+            gl.glActiveTexture(GL11.GL_TEXTURE0);
+            return;
+        }
+        gl.glEnable(GL11.GL_TEXTURE_2D);
+
+        // Interpolate the RGB and alpha values between both textures.
+        mGLState.setTexEnvMode(GL11.GL_COMBINE);
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_COMBINE_RGB, GL11.GL_INTERPOLATE);
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_COMBINE_ALPHA, GL11.GL_INTERPOLATE);
+
+        // Specify the interpolation factor via the alpha component of
+        // GL_TEXTURE_ENV_COLORs.
+        // We don't use the RGB color, so just give them 0s.
+        setTextureColor(0, 0, 0, comboRatio);
+        gl.glTexEnvfv(GL11.GL_TEXTURE_ENV, GL11.GL_TEXTURE_ENV_COLOR, mTextureColor, 0);
+
+        // Wire up the interpolation factor for RGB.
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_SRC2_RGB, GL11.GL_CONSTANT);
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_OPERAND2_RGB, GL11.GL_SRC_ALPHA);
+
+        // Wire up the interpolation factor for alpha.
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_SRC2_ALPHA, GL11.GL_CONSTANT);
+        gl.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_OPERAND2_ALPHA, GL11.GL_SRC_ALPHA);
+
+        // Draw the combined texture.
+        drawBoundTexture(to, x, y, width, height);
+
+        // Disable TEXTURE1.
+        gl.glDisable(GL11.GL_TEXTURE_2D);
+        // Switch back to the default texture unit.
+        gl.glActiveTexture(GL11.GL_TEXTURE0);
+    }
+
+    // TODO: the code only work for 2D should get fixed for 3D or removed
+    private static final int MSKEW_X = 4;
+    private static final int MSKEW_Y = 1;
+    private static final int MSCALE_X = 0;
+    private static final int MSCALE_Y = 5;
+
+    private static boolean isMatrixRotatedOrFlipped(float matrix[]) {
+        final float eps = 1e-5f;
+        return Math.abs(matrix[MSKEW_X]) > eps
+                || Math.abs(matrix[MSKEW_Y]) > eps
+                || matrix[MSCALE_X] < -eps
+                || matrix[MSCALE_Y] > eps;
+    }
+
+    public BasicTexture copyTexture(int x, int y, int width, int height) {
+
+        if (isMatrixRotatedOrFlipped(mMatrixValues)) {
+            throw new IllegalArgumentException("cannot support rotated matrix");
+        }
+        float points[] = mapPoints(mMatrixValues, x, y + height, x + width, y);
+        x = (int) points[0];
+        y = (int) points[1];
+        width = (int) points[2] - x;
+        height = (int) points[3] - y;
+
+        GL11 gl = mGL;
+
+        RawTexture texture = RawTexture.newInstance(this);
+        gl.glBindTexture(GL11.GL_TEXTURE_2D, texture.getId());
+        texture.setSize(width, height);
+
+        int[] cropRect = {0,  0, width, height};
+        gl.glTexParameteriv(GL11.GL_TEXTURE_2D,
+                GL11Ext.GL_TEXTURE_CROP_RECT_OES, cropRect, 0);
+        gl.glTexParameteri(GL11.GL_TEXTURE_2D,
+                GL11.GL_TEXTURE_WRAP_S, GL11.GL_CLAMP_TO_EDGE);
+        gl.glTexParameteri(GL11.GL_TEXTURE_2D,
+                GL11.GL_TEXTURE_WRAP_T, GL11.GL_CLAMP_TO_EDGE);
+        gl.glTexParameterf(GL11.GL_TEXTURE_2D,
+                GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR);
+        gl.glTexParameterf(GL11.GL_TEXTURE_2D,
+                GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR);
+        gl.glCopyTexImage2D(GL11.GL_TEXTURE_2D, 0,
+                GL11.GL_RGB, x, y, texture.getTextureWidth(),
+                texture.getTextureHeight(), 0);
+
+        return texture;
+    }
+
+    private static class GLState {
+
+        private final GL11 mGL;
+
+        private int mTexEnvMode = GL11.GL_REPLACE;
+        private float mTextureAlpha = 1.0f;
+        private boolean mTexture2DEnabled = true;
+        private boolean mBlendEnabled = true;
+        private float mLineWidth = 1.0f;
+        private boolean mLineSmooth = false;
+
+        public GLState(GL11 gl) {
+            mGL = gl;
+
+            // Disable unused state
+            gl.glDisable(GL11.GL_LIGHTING);
+
+            // Enable used features
+            gl.glEnable(GL11.GL_DITHER);
+            gl.glEnable(GL11.GL_SCISSOR_TEST);
+
+            gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
+            gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
+            gl.glEnable(GL11.GL_TEXTURE_2D);
+
+            gl.glTexEnvf(GL11.GL_TEXTURE_ENV,
+                    GL11.GL_TEXTURE_ENV_MODE, GL11.GL_REPLACE);
+
+            // Set the background color
+            gl.glClearColor(0f, 0f, 0f, 0f);
+            gl.glClearStencil(0);
+
+            gl.glEnable(GL11.GL_BLEND);
+            gl.glBlendFunc(GL11.GL_ONE, GL11.GL_ONE_MINUS_SRC_ALPHA);
+
+            // We use 565 or 8888 format, so set the alignment to 2 bytes/pixel.
+            gl.glPixelStorei(GL11.GL_UNPACK_ALIGNMENT, 2);
+        }
+
+        public void setTexEnvMode(int mode) {
+            if (mTexEnvMode == mode) return;
+            mTexEnvMode = mode;
+            mGL.glTexEnvf(GL11.GL_TEXTURE_ENV, GL11.GL_TEXTURE_ENV_MODE, mode);
+        }
+
+        public void setLineWidth(float width) {
+            if (mLineWidth == width) return;
+            mLineWidth = width;
+            mGL.glLineWidth(width);
+        }
+
+        public void setLineSmooth(boolean enabled) {
+            if (mLineSmooth == enabled) return;
+            mLineSmooth = enabled;
+            if (enabled) {
+                mGL.glEnable(GL11.GL_LINE_SMOOTH);
+            } else {
+                mGL.glDisable(GL11.GL_LINE_SMOOTH);
+            }
+        }
+
+        public void setTextureAlpha(float alpha) {
+            if (mTextureAlpha == alpha) return;
+            mTextureAlpha = alpha;
+            if (alpha >= OPAQUE_ALPHA) {
+                // The alpha is need for those texture without alpha channel
+                mGL.glColor4f(1, 1, 1, 1);
+                setTexEnvMode(GL11.GL_REPLACE);
+            } else {
+                mGL.glColor4f(alpha, alpha, alpha, alpha);
+                setTexEnvMode(GL11.GL_MODULATE);
+            }
+        }
+
+        public void setColorMode(int color, float alpha) {
+            setBlendEnabled(!Utils.isOpaque(color) || alpha < OPAQUE_ALPHA);
+
+            // Set mTextureAlpha to an invalid value, so that it will reset
+            // again in setTextureAlpha(float) later.
+            mTextureAlpha = -1.0f;
+
+            setTexture2DEnabled(false);
+
+            float prealpha = (color >>> 24) * alpha * 65535f / 255f / 255f;
+            mGL.glColor4x(
+                    Math.round(((color >> 16) & 0xFF) * prealpha),
+                    Math.round(((color >> 8) & 0xFF) * prealpha),
+                    Math.round((color & 0xFF) * prealpha),
+                    Math.round(255 * prealpha));
+        }
+
+        public void setTexture2DEnabled(boolean enabled) {
+            if (mTexture2DEnabled == enabled) return;
+            mTexture2DEnabled = enabled;
+            if (enabled) {
+                mGL.glEnable(GL11.GL_TEXTURE_2D);
+            } else {
+                mGL.glDisable(GL11.GL_TEXTURE_2D);
+            }
+        }
+
+        public void setBlendEnabled(boolean enabled) {
+            if (mBlendEnabled == enabled) return;
+            mBlendEnabled = enabled;
+            if (enabled) {
+                mGL.glEnable(GL11.GL_BLEND);
+            } else {
+                mGL.glDisable(GL11.GL_BLEND);
+            }
+        }
+    }
+
+    public GL11 getGLInstance() {
+        return mGL;
+    }
+
+    public void setCurrentAnimationTimeMillis(long time) {
+        Utils.assertTrue(time >= 0);
+        mAnimationTime = time;
+    }
+
+    public void clearBuffer() {
+        mGL.glClear(GL10.GL_COLOR_BUFFER_BIT);
+    }
+
+    private void setTextureCoords(RectF source) {
+        setTextureCoords(source.left, source.top, source.right, source.bottom);
+    }
+
+    private void setTextureCoords(float left, float top,
+            float right, float bottom) {
+        mGL.glMatrixMode(GL11.GL_TEXTURE);
+        mTextureMatrixValues[0] = right - left;
+        mTextureMatrixValues[5] = bottom - top;
+        mTextureMatrixValues[10] = 1;
+        mTextureMatrixValues[12] = left;
+        mTextureMatrixValues[13] = top;
+        mTextureMatrixValues[15] = 1;
+        mGL.glLoadMatrixf(mTextureMatrixValues, 0);
+        mGL.glMatrixMode(GL11.GL_MODELVIEW);
+    }
+
+    // unloadTexture and deleteBuffer can be called from the finalizer thread,
+    // so we synchronized on the mUnboundTextures object.
+    public boolean unloadTexture(BasicTexture t) {
+        synchronized (mUnboundTextures) {
+            if (!t.isLoaded(this)) return false;
+            mUnboundTextures.add(t.mId);
+            return true;
+        }
+    }
+
+    public void deleteBuffer(int bufferId) {
+        synchronized (mUnboundTextures) {
+            mDeleteBuffers.add(bufferId);
+        }
+    }
+
+    public void deleteRecycledResources() {
+        synchronized (mUnboundTextures) {
+            IntArray ids = mUnboundTextures;
+            if (ids.size() > 0) {
+                mGL.glDeleteTextures(ids.size(), ids.getInternalArray(), 0);
+                ids.clear();
+            }
+
+            ids = mDeleteBuffers;
+            if (ids.size() > 0) {
+                mGL.glDeleteBuffers(ids.size(), ids.getInternalArray(), 0);
+                ids.clear();
+            }
+        }
+    }
+
+    public int save() {
+        return save(SAVE_FLAG_ALL);
+    }
+
+    public int save(int saveFlags) {
+        ConfigState config = obtainRestoreConfig();
+
+        if ((saveFlags & SAVE_FLAG_ALPHA) != 0) {
+            config.mAlpha = mAlpha;
+        } else {
+            config.mAlpha = -1;
+        }
+
+        if ((saveFlags & SAVE_FLAG_CLIP) != 0) {
+            config.mRect.set(mClipRect);
+        } else {
+            config.mRect.left = Integer.MAX_VALUE;
+        }
+
+        if ((saveFlags & SAVE_FLAG_MATRIX) != 0) {
+            System.arraycopy(mMatrixValues, 0, config.mMatrix, 0, 16);
+        } else {
+            config.mMatrix[0] = Float.NEGATIVE_INFINITY;
+        }
+
+        mRestoreStack.push(config);
+        return mRestoreStack.size() - 1;
+    }
+
+    public void restore() {
+        if (mRestoreStack.isEmpty()) throw new IllegalStateException();
+        ConfigState config = mRestoreStack.pop();
+        config.restore(this);
+        freeRestoreConfig(config);
+    }
+
+    private void freeRestoreConfig(ConfigState action) {
+        action.mNextFree = mRecycledRestoreAction;
+        mRecycledRestoreAction = action;
+    }
+
+    private ConfigState obtainRestoreConfig() {
+        if (mRecycledRestoreAction != null) {
+            ConfigState result = mRecycledRestoreAction;
+            mRecycledRestoreAction = result.mNextFree;
+            return result;
+        }
+        return new ConfigState();
+    }
+
+    private static class ConfigState {
+        float mAlpha;
+        Rect mRect = new Rect();
+        float mMatrix[] = new float[16];
+        ConfigState mNextFree;
+
+        public void restore(GLCanvasImpl canvas) {
+            if (mAlpha >= 0) canvas.setAlpha(mAlpha);
+            if (mRect.left != Integer.MAX_VALUE) {
+                Rect rect = mRect;
+                canvas.mClipRect.set(rect);
+                canvas.mGL.glScissor(
+                        rect.left, rect.top, rect.width(), rect.height());
+            }
+            if (mMatrix[0] != Float.NEGATIVE_INFINITY) {
+                System.arraycopy(mMatrix, 0, canvas.mMatrixValues, 0, 16);
+            }
+        }
+    }
+
+    public void dumpStatisticsAndClear() {
+        String line = String.format(
+                "MESH:%d, TEX_OES:%d, TEX_RECT:%d, FILL_RECT:%d, LINE:%d",
+                mCountDrawMesh, mCountTextureRect, mCountTextureOES,
+                mCountFillRect, mCountDrawLine);
+        mCountDrawMesh = 0;
+        mCountTextureRect = 0;
+        mCountTextureOES = 0;
+        mCountFillRect = 0;
+        mCountDrawLine = 0;
+        Log.d(TAG, line);
+    }
+
+    private void saveTransform() {
+        System.arraycopy(mMatrixValues, 0, mTempMatrix, 0, 16);
+    }
+
+    private void restoreTransform() {
+        System.arraycopy(mTempMatrix, 0, mMatrixValues, 0, 16);
+    }
+}
diff --git a/src/com/android/gallery3d/ui/GLPaint.java b/src/com/android/gallery3d/ui/GLPaint.java
new file mode 100644
index 0000000..9f7b6f1
--- /dev/null
+++ b/src/com/android/gallery3d/ui/GLPaint.java
@@ -0,0 +1,65 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+
+
+public class GLPaint {
+    public static final int FLAG_ANTI_ALIAS = 0x01;
+
+    private int mFlags = 0;
+    private float mLineWidth = 1f;
+    private int mColor = 0;
+
+    public int getFlags() {
+        return mFlags;
+    }
+
+    public void setFlags(int flags) {
+        mFlags = flags;
+    }
+
+    public void setColor(int color) {
+        mColor = color;
+    }
+
+    public int getColor() {
+        return mColor;
+    }
+
+    public void setLineWidth(float width) {
+        Utils.assertTrue(width >= 0);
+        mLineWidth = width;
+    }
+
+    public float getLineWidth() {
+        return mLineWidth;
+    }
+
+    public void setAntiAlias(boolean enabled) {
+        if (enabled) {
+            mFlags |= FLAG_ANTI_ALIAS;
+        } else {
+            mFlags &= ~FLAG_ANTI_ALIAS;
+        }
+    }
+
+    public boolean getAntiAlias(){
+        return (mFlags & FLAG_ANTI_ALIAS) != 0;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/GLRoot.java b/src/com/android/gallery3d/ui/GLRoot.java
new file mode 100644
index 0000000..24e5794
--- /dev/null
+++ b/src/com/android/gallery3d/ui/GLRoot.java
@@ -0,0 +1,37 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.anim.CanvasAnimation;
+
+public interface GLRoot {
+
+    public static interface OnGLIdleListener {
+        public boolean onGLIdle(GLRoot root, GLCanvas canvas);
+    }
+
+    public void addOnGLIdleListener(OnGLIdleListener listener);
+    public void registerLaunchedAnimation(CanvasAnimation animation);
+    public void requestRender();
+    public void requestLayoutContentPane();
+    public boolean hasStencil();
+
+    public void lockRenderThread();
+    public void unlockRenderThread();
+
+    public void setContentPane(GLView content);
+}
diff --git a/src/com/android/gallery3d/ui/GLRootView.java b/src/com/android/gallery3d/ui/GLRootView.java
new file mode 100644
index 0000000..e03adf1
--- /dev/null
+++ b/src/com/android/gallery3d/ui/GLRootView.java
@@ -0,0 +1,414 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.anim.CanvasAnimation;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.app.Activity;
+import android.content.Context;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.opengl.GLSurfaceView;
+import android.os.Process;
+import android.os.SystemClock;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.view.MotionEvent;
+
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.concurrent.locks.ReentrantLock;
+import javax.microedition.khronos.egl.EGLConfig;
+import javax.microedition.khronos.opengles.GL10;
+import javax.microedition.khronos.opengles.GL11;
+
+// The root component of all <code>GLView</code>s. The rendering is done in GL
+// thread while the event handling is done in the main thread.  To synchronize
+// the two threads, the entry points of this package need to synchronize on the
+// <code>GLRootView</code> instance unless it can be proved that the rendering
+// thread won't access the same thing as the method. The entry points include:
+// (1) The public methods of HeadUpDisplay
+// (2) The public methods of CameraHeadUpDisplay
+// (3) The overridden methods in GLRootView.
+public class GLRootView extends GLSurfaceView
+        implements GLSurfaceView.Renderer, GLRoot {
+    private static final String TAG = "GLRootView";
+
+    private static final boolean DEBUG_FPS = false;
+    private int mFrameCount = 0;
+    private long mFrameCountingStart = 0;
+
+    private static final boolean DEBUG_INVALIDATE = false;
+    private int mInvalidateColor = 0;
+
+    private static final boolean DEBUG_DRAWING_STAT = false;
+
+    private static final int FLAG_INITIALIZED = 1;
+    private static final int FLAG_NEED_LAYOUT = 2;
+
+    private GL11 mGL;
+    private GLCanvasImpl mCanvas;
+
+    private GLView mContentView;
+    private DisplayMetrics mDisplayMetrics;
+
+    private int mFlags = FLAG_NEED_LAYOUT;
+    private volatile boolean mRenderRequested = false;
+
+    private Rect mClipRect = new Rect();
+    private int mClipRetryCount = 0;
+
+    private final GalleryEGLConfigChooser mEglConfigChooser =
+            new GalleryEGLConfigChooser();
+
+    private final ArrayList<CanvasAnimation> mAnimations =
+            new ArrayList<CanvasAnimation>();
+
+    private final LinkedList<OnGLIdleListener> mIdleListeners =
+            new LinkedList<OnGLIdleListener>();
+
+    private final IdleRunner mIdleRunner = new IdleRunner();
+
+    private final ReentrantLock mRenderLock = new ReentrantLock();
+
+    private static final int TARGET_FRAME_TIME = 33;
+    private long mLastDrawFinishTime;
+    private boolean mInDownState = false;
+
+    public GLRootView(Context context) {
+        this(context, null);
+    }
+
+    public GLRootView(Context context, AttributeSet attrs) {
+        super(context, attrs);
+        mFlags |= FLAG_INITIALIZED;
+        setBackgroundDrawable(null);
+        setEGLConfigChooser(mEglConfigChooser);
+        setRenderer(this);
+        getHolder().setFormat(PixelFormat.RGB_565);
+
+        // Uncomment this to enable gl error check.
+        //setDebugFlags(DEBUG_CHECK_GL_ERROR);
+    }
+
+    public GalleryEGLConfigChooser getEGLConfigChooser() {
+        return mEglConfigChooser;
+    }
+
+    @Override
+    public boolean hasStencil() {
+        return getEGLConfigChooser().getStencilBits() > 0;
+    }
+
+    @Override
+    public void registerLaunchedAnimation(CanvasAnimation animation) {
+        // Register the newly launched animation so that we can set the start
+        // time more precisely. (Usually, it takes much longer for first
+        // rendering, so we set the animation start time as the time we
+        // complete rendering)
+        mAnimations.add(animation);
+    }
+
+    @Override
+    public void addOnGLIdleListener(OnGLIdleListener listener) {
+        synchronized (mIdleListeners) {
+            mIdleListeners.addLast(listener);
+            mIdleRunner.enable();
+        }
+    }
+
+    @Override
+    public void setContentPane(GLView content) {
+        if (mContentView == content) return;
+        if (mContentView != null) {
+            if (mInDownState) {
+                long now = SystemClock.uptimeMillis();
+                MotionEvent cancelEvent = MotionEvent.obtain(
+                        now, now, MotionEvent.ACTION_CANCEL, 0, 0, 0);
+                mContentView.dispatchTouchEvent(cancelEvent);
+                cancelEvent.recycle();
+                mInDownState = false;
+            }
+            mContentView.detachFromRoot();
+            BasicTexture.yieldAllTextures();
+        }
+        mContentView = content;
+        if (content != null) {
+            content.attachToRoot(this);
+            requestLayoutContentPane();
+        }
+    }
+
+    public GLView getContentPane() {
+        return mContentView;
+    }
+
+    @Override
+    public void requestRender() {
+        if (DEBUG_INVALIDATE) {
+            StackTraceElement e = Thread.currentThread().getStackTrace()[4];
+            String caller = e.getFileName() + ":" + e.getLineNumber() + " ";
+            Log.d(TAG, "invalidate: " + caller);
+        }
+        if (mRenderRequested) return;
+        mRenderRequested = true;
+        super.requestRender();
+    }
+
+    @Override
+    public void requestLayoutContentPane() {
+        mRenderLock.lock();
+        try {
+            if (mContentView == null || (mFlags & FLAG_NEED_LAYOUT) != 0) return;
+
+            // "View" system will invoke onLayout() for initialization(bug ?), we
+            // have to ignore it since the GLThread is not ready yet.
+            if ((mFlags & FLAG_INITIALIZED) == 0) return;
+
+            mFlags |= FLAG_NEED_LAYOUT;
+            requestRender();
+        } finally {
+            mRenderLock.unlock();
+        }
+    }
+
+    private void layoutContentPane() {
+        mFlags &= ~FLAG_NEED_LAYOUT;
+        int width = getWidth();
+        int height = getHeight();
+        Log.i(TAG, "layout content pane " + width + "x" + height);
+        if (mContentView != null && width != 0 && height != 0) {
+            mContentView.layout(0, 0, width, height);
+        }
+        // Uncomment this to dump the view hierarchy.
+        //mContentView.dumpTree("");
+    }
+
+    @Override
+    protected void onLayout(
+            boolean changed, int left, int top, int right, int bottom) {
+        if (changed) requestLayoutContentPane();
+    }
+
+    /**
+     * Called when the context is created, possibly after automatic destruction.
+     */
+    // This is a GLSurfaceView.Renderer callback
+    @Override
+    public void onSurfaceCreated(GL10 gl1, EGLConfig config) {
+        GL11 gl = (GL11) gl1;
+        if (mGL != null) {
+            // The GL Object has changed
+            Log.i(TAG, "GLObject has changed from " + mGL + " to " + gl);
+        }
+        mGL = gl;
+        mCanvas = new GLCanvasImpl(gl);
+        if (!DEBUG_FPS) {
+            setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);
+        } else {
+            setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
+        }
+    }
+
+    /**
+     * Called when the OpenGL surface is recreated without destroying the
+     * context.
+     */
+    // This is a GLSurfaceView.Renderer callback
+    @Override
+    public void onSurfaceChanged(GL10 gl1, int width, int height) {
+        Log.i(TAG, "onSurfaceChanged: " + width + "x" + height
+                + ", gl10: " + gl1.toString());
+        Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY);
+        GalleryUtils.setRenderThread();
+        GL11 gl = (GL11) gl1;
+        Utils.assertTrue(mGL == gl);
+
+        mCanvas.setSize(width, height);
+
+        mClipRect.set(0, 0, width, height);
+        mClipRetryCount = 2;
+    }
+
+    private void outputFps() {
+        long now = System.nanoTime();
+        if (mFrameCountingStart == 0) {
+            mFrameCountingStart = now;
+        } else if ((now - mFrameCountingStart) > 1000000000) {
+            Log.d(TAG, "fps: " + (double) mFrameCount
+                    * 1000000000 / (now - mFrameCountingStart));
+            mFrameCountingStart = now;
+            mFrameCount = 0;
+        }
+        ++mFrameCount;
+    }
+
+    @Override
+    public void onDrawFrame(GL10 gl) {
+        mRenderLock.lock();
+        try {
+            onDrawFrameLocked(gl);
+        } finally {
+            mRenderLock.unlock();
+        }
+        long end = SystemClock.uptimeMillis();
+
+        if (mLastDrawFinishTime != 0) {
+            long wait = mLastDrawFinishTime + TARGET_FRAME_TIME - end;
+            if (wait > 0) {
+                SystemClock.sleep(wait);
+            }
+        }
+        mLastDrawFinishTime = SystemClock.uptimeMillis();
+    }
+
+    private void onDrawFrameLocked(GL10 gl) {
+        if (DEBUG_FPS) outputFps();
+
+        // release the unbound textures and deleted buffers.
+        mCanvas.deleteRecycledResources();
+
+        // reset texture upload limit
+        UploadedTexture.resetUploadLimit();
+
+        mRenderRequested = false;
+
+        if ((mFlags & FLAG_NEED_LAYOUT) != 0) layoutContentPane();
+
+        // OpenGL seems having a bug causing us not being able to reset the
+        // scissor box in "onSurfaceChanged()". We have to do it in the second
+        // onDrawFrame().
+        if (mClipRetryCount > 0) {
+            --mClipRetryCount;
+            Rect clip = mClipRect;
+            gl.glScissor(clip.left, clip.top, clip.width(), clip.height());
+        }
+
+        mCanvas.setCurrentAnimationTimeMillis(SystemClock.uptimeMillis());
+        if (mContentView != null) {
+           mContentView.render(mCanvas);
+        }
+
+        if (!mAnimations.isEmpty()) {
+            long now = SystemClock.uptimeMillis();
+            for (int i = 0, n = mAnimations.size(); i < n; i++) {
+                mAnimations.get(i).setStartTime(now);
+            }
+            mAnimations.clear();
+        }
+
+        if (UploadedTexture.uploadLimitReached()) {
+            requestRender();
+        }
+
+        synchronized (mIdleListeners) {
+            if (!mRenderRequested && !mIdleListeners.isEmpty()) {
+                mIdleRunner.enable();
+            }
+        }
+
+        if (DEBUG_INVALIDATE) {
+            mCanvas.fillRect(10, 10, 5, 5, mInvalidateColor);
+            mInvalidateColor = ~mInvalidateColor;
+        }
+
+        if (DEBUG_DRAWING_STAT) {
+            mCanvas.dumpStatisticsAndClear();
+        }
+    }
+
+    @Override
+    public boolean dispatchTouchEvent(MotionEvent event) {
+        int action = event.getAction();
+        if (action == MotionEvent.ACTION_CANCEL
+                || action == MotionEvent.ACTION_UP) {
+            mInDownState = false;
+        } else if (!mInDownState && action != MotionEvent.ACTION_DOWN) {
+            return false;
+        }
+        mRenderLock.lock();
+        try {
+            // If this has been detached from root, we don't need to handle event
+            boolean handled = mContentView != null
+                    && mContentView.dispatchTouchEvent(event);
+            if (action == MotionEvent.ACTION_DOWN && handled) {
+                mInDownState = true;
+            }
+            return handled;
+        } finally {
+            mRenderLock.unlock();
+        }
+    }
+
+    public DisplayMetrics getDisplayMetrics() {
+        if (mDisplayMetrics == null) {
+            mDisplayMetrics = new DisplayMetrics();
+            ((Activity) getContext()).getWindowManager()
+                    .getDefaultDisplay().getMetrics(mDisplayMetrics);
+        }
+        return mDisplayMetrics;
+    }
+
+    public GLCanvas getCanvas() {
+        return mCanvas;
+    }
+
+    private class IdleRunner implements Runnable {
+        // true if the idle runner is in the queue
+        private boolean mActive = false;
+
+        @Override
+        public void run() {
+            OnGLIdleListener listener;
+            synchronized (mIdleListeners) {
+                mActive = false;
+                if (mRenderRequested) return;
+                if (mIdleListeners.isEmpty()) return;
+                listener = mIdleListeners.removeFirst();
+            }
+            mRenderLock.lock();
+            try {
+                if (!listener.onGLIdle(GLRootView.this, mCanvas)) return;
+            } finally {
+                mRenderLock.unlock();
+            }
+            synchronized (mIdleListeners) {
+                mIdleListeners.addLast(listener);
+                enable();
+            }
+        }
+
+        public void enable() {
+            // Who gets the flag can add it to the queue
+            if (mActive) return;
+            mActive = true;
+            queueEvent(this);
+        }
+    }
+
+    @Override
+    public void lockRenderThread() {
+        mRenderLock.lock();
+    }
+
+    @Override
+    public void unlockRenderThread() {
+        mRenderLock.unlock();
+    }
+}
diff --git a/src/com/android/gallery3d/ui/GLView.java b/src/com/android/gallery3d/ui/GLView.java
new file mode 100644
index 0000000..c593278
--- /dev/null
+++ b/src/com/android/gallery3d/ui/GLView.java
@@ -0,0 +1,431 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.anim.CanvasAnimation;
+import com.android.gallery3d.common.Utils;
+
+import android.graphics.Rect;
+import android.os.SystemClock;
+import android.view.MotionEvent;
+
+import java.util.ArrayList;
+
+// GLView is a UI component. It can render to a GLCanvas and accept touch
+// events. A GLView may have zero or more child GLView and they form a tree
+// structure. The rendering and event handling will pass through the tree
+// structure.
+//
+// A GLView tree should be attached to a GLRoot before event dispatching and
+// rendering happens. GLView asks GLRoot to re-render or re-layout the
+// GLView hierarchy using requestRender() and requestLayoutContentPane().
+//
+// The render() method is called in a separate thread. Before calling
+// dispatchTouchEvent() and layout(), GLRoot acquires a lock to avoid the
+// rendering thread running at the same time. If there are other entry points
+// from main thread (like a Handler) in your GLView, you need to call
+// lockRendering() if the rendering thread should not run at the same time.
+//
+public class GLView {
+    private static final String TAG = "GLView";
+
+    public static final int VISIBLE = 0;
+    public static final int INVISIBLE = 1;
+
+    private static final int FLAG_INVISIBLE = 1;
+    private static final int FLAG_SET_MEASURED_SIZE = 2;
+    private static final int FLAG_LAYOUT_REQUESTED = 4;
+
+    protected final Rect mBounds = new Rect();
+    protected final Rect mPaddings = new Rect();
+
+    private GLRoot mRoot;
+    protected GLView mParent;
+    private ArrayList<GLView> mComponents;
+    private GLView mMotionTarget;
+
+    private CanvasAnimation mAnimation;
+
+    private int mViewFlags = 0;
+
+    protected int mMeasuredWidth = 0;
+    protected int mMeasuredHeight = 0;
+
+    private int mLastWidthSpec = -1;
+    private int mLastHeightSpec = -1;
+
+    protected int mScrollY = 0;
+    protected int mScrollX = 0;
+    protected int mScrollHeight = 0;
+    protected int mScrollWidth = 0;
+
+    public void startAnimation(CanvasAnimation animation) {
+        GLRoot root = getGLRoot();
+        if (root == null) throw new IllegalStateException();
+
+        mAnimation = animation;
+        mAnimation.start();
+        root.registerLaunchedAnimation(mAnimation);
+        invalidate();
+    }
+
+    // Sets the visiblity of this GLView (either GLView.VISIBLE or
+    // GLView.INVISIBLE).
+    public void setVisibility(int visibility) {
+        if (visibility == getVisibility()) return;
+        if (visibility == VISIBLE) {
+            mViewFlags &= ~FLAG_INVISIBLE;
+        } else {
+            mViewFlags |= FLAG_INVISIBLE;
+        }
+        onVisibilityChanged(visibility);
+        invalidate();
+    }
+
+    // Returns GLView.VISIBLE or GLView.INVISIBLE
+    public int getVisibility() {
+        return (mViewFlags & FLAG_INVISIBLE) == 0 ? VISIBLE : INVISIBLE;
+    }
+
+    // This should only be called on the content pane (the topmost GLView).
+    public void attachToRoot(GLRoot root) {
+        Utils.assertTrue(mParent == null && mRoot == null);
+        onAttachToRoot(root);
+    }
+
+    // This should only be called on the content pane (the topmost GLView).
+    public void detachFromRoot() {
+        Utils.assertTrue(mParent == null && mRoot != null);
+        onDetachFromRoot();
+    }
+
+    // Returns the number of children of the GLView.
+    public int getComponentCount() {
+        return mComponents == null ? 0 : mComponents.size();
+    }
+
+    // Returns the children for the given index.
+    public GLView getComponent(int index) {
+        if (mComponents == null) {
+            throw new ArrayIndexOutOfBoundsException(index);
+        }
+        return mComponents.get(index);
+    }
+
+    // Adds a child to this GLView.
+    public void addComponent(GLView component) {
+        // Make sure the component doesn't have a parent currently.
+        if (component.mParent != null) throw new IllegalStateException();
+
+        // Build parent-child links
+        if (mComponents == null) {
+            mComponents = new ArrayList<GLView>();
+        }
+        mComponents.add(component);
+        component.mParent = this;
+
+        // If this is added after we have a root, tell the component.
+        if (mRoot != null) {
+            component.onAttachToRoot(mRoot);
+        }
+    }
+
+    // Removes a child from this GLView.
+    public boolean removeComponent(GLView component) {
+        if (mComponents == null) return false;
+        if (mComponents.remove(component)) {
+            removeOneComponent(component);
+            return true;
+        }
+        return false;
+    }
+
+    // Removes all children of this GLView.
+    public void removeAllComponents() {
+        for (int i = 0, n = mComponents.size(); i < n; ++i) {
+            removeOneComponent(mComponents.get(i));
+        }
+        mComponents.clear();
+    }
+
+    private void removeOneComponent(GLView component) {
+        if (mMotionTarget == component) {
+            long now = SystemClock.uptimeMillis();
+            MotionEvent cancelEvent = MotionEvent.obtain(
+                    now, now, MotionEvent.ACTION_CANCEL, 0, 0, 0);
+            dispatchTouchEvent(cancelEvent);
+            cancelEvent.recycle();
+        }
+        component.onDetachFromRoot();
+        component.mParent = null;
+    }
+
+    public Rect bounds() {
+        return mBounds;
+    }
+
+    public int getWidth() {
+        return mBounds.right - mBounds.left;
+    }
+
+    public int getHeight() {
+        return mBounds.bottom - mBounds.top;
+    }
+
+    public GLRoot getGLRoot() {
+        return mRoot;
+    }
+
+    // Request re-rendering of the view hierarchy.
+    // This is used for animation or when the contents changed.
+    public void invalidate() {
+        GLRoot root = getGLRoot();
+        if (root != null) root.requestRender();
+    }
+
+    // Request re-layout of the view hierarchy.
+    public void requestLayout() {
+        mViewFlags |= FLAG_LAYOUT_REQUESTED;
+        mLastHeightSpec = -1;
+        mLastWidthSpec = -1;
+        if (mParent != null) {
+            mParent.requestLayout();
+        } else {
+            // Is this a content pane ?
+            GLRoot root = getGLRoot();
+            if (root != null) root.requestLayoutContentPane();
+        }
+    }
+
+    protected void render(GLCanvas canvas) {
+        renderBackground(canvas);
+        for (int i = 0, n = getComponentCount(); i < n; ++i) {
+            renderChild(canvas, getComponent(i));
+        }
+    }
+
+    protected void renderBackground(GLCanvas view) {
+    }
+
+    protected void renderChild(GLCanvas canvas, GLView component) {
+        if (component.getVisibility() != GLView.VISIBLE
+                && component.mAnimation == null) return;
+
+        int xoffset = component.mBounds.left - mScrollX;
+        int yoffset = component.mBounds.top - mScrollY;
+
+        canvas.translate(xoffset, yoffset, 0);
+
+        CanvasAnimation anim = component.mAnimation;
+        if (anim != null) {
+            canvas.save(anim.getCanvasSaveFlags());
+            if (anim.calculate(canvas.currentAnimationTimeMillis())) {
+                invalidate();
+            } else {
+                component.mAnimation = null;
+            }
+            anim.apply(canvas);
+        }
+        component.render(canvas);
+        if (anim != null) canvas.restore();
+        canvas.translate(-xoffset, -yoffset, 0);
+    }
+
+    protected boolean onTouch(MotionEvent event) {
+        return false;
+    }
+
+    protected boolean dispatchTouchEvent(MotionEvent event,
+            int x, int y, GLView component, boolean checkBounds) {
+        Rect rect = component.mBounds;
+        int left = rect.left;
+        int top = rect.top;
+        if (!checkBounds || rect.contains(x, y)) {
+            event.offsetLocation(-left, -top);
+            if (component.dispatchTouchEvent(event)) {
+                event.offsetLocation(left, top);
+                return true;
+            }
+            event.offsetLocation(left, top);
+        }
+        return false;
+    }
+
+    protected boolean dispatchTouchEvent(MotionEvent event) {
+        int x = (int) event.getX();
+        int y = (int) event.getY();
+        int action = event.getAction();
+        if (mMotionTarget != null) {
+            if (action == MotionEvent.ACTION_DOWN) {
+                MotionEvent cancel = MotionEvent.obtain(event);
+                cancel.setAction(MotionEvent.ACTION_CANCEL);
+                dispatchTouchEvent(cancel, x, y, mMotionTarget, false);
+                mMotionTarget = null;
+            } else {
+                dispatchTouchEvent(event, x, y, mMotionTarget, false);
+                if (action == MotionEvent.ACTION_CANCEL
+                        || action == MotionEvent.ACTION_UP) {
+                    mMotionTarget = null;
+                }
+                return true;
+            }
+        }
+        if (action == MotionEvent.ACTION_DOWN) {
+            // in the reverse rendering order
+            for (int i = getComponentCount() - 1; i >= 0; --i) {
+                GLView component = getComponent(i);
+                if (component.getVisibility() != GLView.VISIBLE) continue;
+                if (dispatchTouchEvent(event, x, y, component, true)) {
+                    mMotionTarget = component;
+                    return true;
+                }
+            }
+        }
+        return onTouch(event);
+    }
+
+    public Rect getPaddings() {
+        return mPaddings;
+    }
+
+    public void setPaddings(Rect paddings) {
+        mPaddings.set(paddings);
+    }
+
+    public void setPaddings(int left, int top, int right, int bottom) {
+        mPaddings.set(left, top, right, bottom);
+    }
+
+    public void layout(int left, int top, int right, int bottom) {
+        boolean sizeChanged = setBounds(left, top, right, bottom);
+        if (sizeChanged) {
+            mViewFlags &= ~FLAG_LAYOUT_REQUESTED;
+            onLayout(true, left, top, right, bottom);
+        } else if ((mViewFlags & FLAG_LAYOUT_REQUESTED)!= 0) {
+            mViewFlags &= ~FLAG_LAYOUT_REQUESTED;
+            onLayout(false, left, top, right, bottom);
+        }
+    }
+
+    private boolean setBounds(int left, int top, int right, int bottom) {
+        boolean sizeChanged = (right - left) != (mBounds.right - mBounds.left)
+                || (bottom - top) != (mBounds.bottom - mBounds.top);
+        mBounds.set(left, top, right, bottom);
+        return sizeChanged;
+    }
+
+    public void measure(int widthSpec, int heightSpec) {
+        if (widthSpec == mLastWidthSpec && heightSpec == mLastHeightSpec
+                && (mViewFlags & FLAG_LAYOUT_REQUESTED) == 0) {
+            return;
+        }
+
+        mLastWidthSpec = widthSpec;
+        mLastHeightSpec = heightSpec;
+
+        mViewFlags &= ~FLAG_SET_MEASURED_SIZE;
+        onMeasure(widthSpec, heightSpec);
+        if ((mViewFlags & FLAG_SET_MEASURED_SIZE) == 0) {
+            throw new IllegalStateException(getClass().getName()
+                    + " should call setMeasuredSize() in onMeasure()");
+        }
+    }
+
+    protected void onMeasure(int widthSpec, int heightSpec) {
+    }
+
+    protected void setMeasuredSize(int width, int height) {
+        mViewFlags |= FLAG_SET_MEASURED_SIZE;
+        mMeasuredWidth = width;
+        mMeasuredHeight = height;
+    }
+
+    public int getMeasuredWidth() {
+        return mMeasuredWidth;
+    }
+
+    public int getMeasuredHeight() {
+        return mMeasuredHeight;
+    }
+
+    protected void onLayout(
+            boolean changeSize, int left, int top, int right, int bottom) {
+    }
+
+    /**
+     * Gets the bounds of the given descendant that relative to this view.
+     */
+    public boolean getBoundsOf(GLView descendant, Rect out) {
+        int xoffset = 0;
+        int yoffset = 0;
+        GLView view = descendant;
+        while (view != this) {
+            if (view == null) return false;
+            Rect bounds = view.mBounds;
+            xoffset += bounds.left;
+            yoffset += bounds.top;
+            view = view.mParent;
+        }
+        out.set(xoffset, yoffset, xoffset + descendant.getWidth(),
+                yoffset + descendant.getHeight());
+        return true;
+    }
+
+    protected void onVisibilityChanged(int visibility) {
+        for (int i = 0, n = getComponentCount(); i < n; ++i) {
+            GLView child = getComponent(i);
+            if (child.getVisibility() == GLView.VISIBLE) {
+                child.onVisibilityChanged(visibility);
+            }
+        }
+    }
+
+    protected void onAttachToRoot(GLRoot root) {
+        mRoot = root;
+        for (int i = 0, n = getComponentCount(); i < n; ++i) {
+            getComponent(i).onAttachToRoot(root);
+        }
+    }
+
+    protected void onDetachFromRoot() {
+        for (int i = 0, n = getComponentCount(); i < n; ++i) {
+            getComponent(i).onDetachFromRoot();
+        }
+        mRoot = null;
+    }
+
+    public void lockRendering() {
+        if (mRoot != null) {
+            mRoot.lockRenderThread();
+        }
+    }
+
+    public void unlockRendering() {
+        if (mRoot != null) {
+            mRoot.unlockRenderThread();
+        }
+    }
+
+    // This is for debugging only.
+    // Dump the view hierarchy into log.
+    void dumpTree(String prefix) {
+        Log.d(TAG, prefix + getClass().getSimpleName());
+        for (int i = 0, n = getComponentCount(); i < n; ++i) {
+            getComponent(i).dumpTree(prefix + "....");
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/GalleryEGLConfigChooser.java b/src/com/android/gallery3d/ui/GalleryEGLConfigChooser.java
new file mode 100644
index 0000000..1d50d43
--- /dev/null
+++ b/src/com/android/gallery3d/ui/GalleryEGLConfigChooser.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.gallery3d.ui;
+
+import android.opengl.GLSurfaceView.EGLConfigChooser;
+
+import javax.microedition.khronos.egl.EGL10;
+import javax.microedition.khronos.egl.EGLConfig;
+import javax.microedition.khronos.egl.EGLDisplay;
+
+/*
+ * The code is copied/adapted from
+ * <code>android.opengl.GLSurfaceView.BaseConfigChooser</code>. Here we try to
+ * choose a configuration that support RGBA_8888 format and if possible,
+ * with stencil buffer, but is not required.
+ */
+class GalleryEGLConfigChooser implements EGLConfigChooser {
+
+    private static final String TAG = "GalleryEGLConfigChooser";
+    private int mStencilBits;
+
+    private final int mConfigSpec[] = new int[] {
+            EGL10.EGL_RED_SIZE, 5,
+            EGL10.EGL_GREEN_SIZE, 6,
+            EGL10.EGL_BLUE_SIZE, 5,
+            EGL10.EGL_ALPHA_SIZE, 0,
+            EGL10.EGL_NONE
+    };
+
+    public int getStencilBits() {
+        return mStencilBits;
+    }
+
+    public EGLConfig chooseConfig(EGL10 egl, EGLDisplay display) {
+        int[] numConfig = new int[1];
+        if (!egl.eglChooseConfig(display, mConfigSpec, null, 0, numConfig)) {
+            throw new RuntimeException("eglChooseConfig failed");
+        }
+
+        if (numConfig[0] <= 0) {
+            throw new RuntimeException("No configs match configSpec");
+        }
+
+        EGLConfig[] configs = new EGLConfig[numConfig[0]];
+        if (!egl.eglChooseConfig(display,
+                mConfigSpec, configs, configs.length, numConfig)) {
+            throw new RuntimeException();
+        }
+
+        return chooseConfig(egl, display, configs);
+    }
+
+    private EGLConfig chooseConfig(
+            EGL10 egl, EGLDisplay display, EGLConfig configs[]) {
+
+        EGLConfig result = null;
+        int minStencil = Integer.MAX_VALUE;
+        int value[] = new int[1];
+
+        // Because we need only one bit of stencil, try to choose a config that
+        // has stencil support but with smallest number of stencil bits. If
+        // none is found, choose any one.
+        for (int i = 0, n = configs.length; i < n; ++i) {
+            if (egl.eglGetConfigAttrib(
+                display, configs[i], EGL10.EGL_RED_SIZE, value)) {
+                // Filter out ARGB 8888 configs.
+                if (value[0] == 8) continue;
+            }
+            if (egl.eglGetConfigAttrib(
+                    display, configs[i], EGL10.EGL_STENCIL_SIZE, value)) {
+                if (value[0] == 0) continue;
+                if (value[0] < minStencil) {
+                    minStencil = value[0];
+                    result = configs[i];
+                }
+            } else {
+                throw new RuntimeException(
+                        "eglGetConfigAttrib error: " + egl.eglGetError());
+            }
+        }
+        if (result == null) result = configs[0];
+        egl.eglGetConfigAttrib(
+                display, result, EGL10.EGL_STENCIL_SIZE, value);
+        mStencilBits = value[0];
+        logConfig(egl, display, result);
+        return result;
+    }
+
+    private static final int[] ATTR_ID = {
+            EGL10.EGL_RED_SIZE,
+            EGL10.EGL_GREEN_SIZE,
+            EGL10.EGL_BLUE_SIZE,
+            EGL10.EGL_ALPHA_SIZE,
+            EGL10.EGL_DEPTH_SIZE,
+            EGL10.EGL_STENCIL_SIZE,
+            EGL10.EGL_CONFIG_ID,
+            EGL10.EGL_CONFIG_CAVEAT
+    };
+
+    private static final String[] ATTR_NAME = {
+        "R", "G", "B", "A", "D", "S", "ID", "CAVEAT"
+    };
+
+    private void logConfig(EGL10 egl, EGLDisplay display, EGLConfig config) {
+        int value[] = new int[1];
+        StringBuilder sb = new StringBuilder();
+        for (int j = 0; j < ATTR_ID.length; j++) {
+            egl.eglGetConfigAttrib(display, config, ATTR_ID[j], value);
+            sb.append(ATTR_NAME[j] + value[0] + " ");
+        }
+        Log.i(TAG, "Config chosen: " + sb.toString());
+    }
+}
diff --git a/src/com/android/gallery3d/ui/GridDrawer.java b/src/com/android/gallery3d/ui/GridDrawer.java
new file mode 100644
index 0000000..54b175c
--- /dev/null
+++ b/src/com/android/gallery3d/ui/GridDrawer.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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.data.Path;
+
+import android.content.Context;
+import android.graphics.Color;
+
+public class GridDrawer extends IconDrawer {
+    private final NinePatchTexture mFrame;
+    private final NinePatchTexture mFrameSelected;
+    private final NinePatchTexture mFrameSelectedTop;
+    private final NinePatchTexture mImportBackground;
+    private Texture mImportLabel;
+    private int mGridWidth;
+    private final SelectionManager mSelectionManager;
+    private final Context mContext;
+    private final int FONT_SIZE = 14;
+    private final int FONT_COLOR = Color.WHITE;
+    private final int IMPORT_LABEL_PADDING = 10;
+    private boolean mSelectionMode;
+
+    public GridDrawer(Context context, SelectionManager selectionManager) {
+        super(context);
+        mContext = context;
+        mFrame = new NinePatchTexture(context, R.drawable.album_frame);
+        mFrameSelected = new NinePatchTexture(context, R.drawable.grid_selected);
+        mFrameSelectedTop = new NinePatchTexture(context, R.drawable.grid_selected_top);
+        mImportBackground = new NinePatchTexture(context, R.drawable.import_translucent);
+        mSelectionManager = selectionManager;
+    }
+
+    @Override
+    public void prepareDrawing() {
+        mSelectionMode = mSelectionManager.inSelectionMode();
+    }
+
+    @Override
+    public void draw(GLCanvas canvas, Texture content, int width, int height,
+            int rotation, Path path, int topIndex, int dataSourceType,
+            int mediaType, boolean wantCache, boolean isCaching) {
+
+        int x = -width / 2;
+        int y = -height / 2;
+
+        drawWithRotationAndGray(canvas, content, x, y, width, height, rotation,
+                topIndex);
+
+        if (((rotation / 90) & 0x01) == 1) {
+            int temp = width;
+            width = height;
+            height = temp;
+            x = -width / 2;
+            y = -height / 2;
+        }
+
+        drawVideoOverlay(canvas, mediaType, x, y, width, height, topIndex);
+
+        NinePatchTexture frame;
+        if (mSelectionMode && mSelectionManager.isItemSelected(path)) {
+            frame = topIndex == 0 ? mFrameSelectedTop : mFrameSelected;
+        } else {
+            frame = mFrame;
+        }
+
+        drawFrame(canvas, frame, x, y, width, height);
+
+        if (topIndex == 0) {
+            ResourceTexture icon = getIcon(dataSourceType);
+            if (icon != null) {
+                IconDimension id = getIconDimension(icon, width, height);
+                if (dataSourceType == DATASOURCE_TYPE_MTP) {
+                    if (mImportLabel == null || mGridWidth != width) {
+                        mGridWidth = width;
+                        mImportLabel = MultiLineTexture.newInstance(
+                                mContext.getString(R.string.click_import),
+                                width - id.width - IMPORT_LABEL_PADDING, FONT_SIZE, FONT_COLOR);
+                    }
+                    int bgHeight = Math.max(id.height, mImportLabel.getHeight());
+                    mImportBackground.setSize(width, bgHeight);
+                    mImportBackground.draw(canvas, x, -y - bgHeight);
+                    mImportLabel.draw(canvas, x + id.width + IMPORT_LABEL_PADDING,
+                            -y - bgHeight + Math.abs(bgHeight - mImportLabel.getHeight()) / 2);
+                }
+                icon.draw(canvas, id.x, id.y, id.width, id.height);
+            }
+        }
+    }
+
+    @Override
+    public void drawFocus(GLCanvas canvas, int width, int height) {
+    }
+}
diff --git a/src/com/android/gallery3d/ui/HighlightDrawer.java b/src/com/android/gallery3d/ui/HighlightDrawer.java
new file mode 100644
index 0000000..9d5868b
--- /dev/null
+++ b/src/com/android/gallery3d/ui/HighlightDrawer.java
@@ -0,0 +1,73 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.data.Path;
+
+import android.content.Context;
+
+public class HighlightDrawer extends IconDrawer {
+    private final NinePatchTexture mFrame;
+    private final NinePatchTexture mFrameSelected;
+    private final NinePatchTexture mFrameSelectedTop;
+    private SelectionManager mSelectionManager;
+    private Path mHighlightItem;
+
+    public HighlightDrawer(Context context) {
+        super(context);
+        mFrame = new NinePatchTexture(context, R.drawable.album_frame);
+        mFrameSelected = new NinePatchTexture(context, R.drawable.grid_selected);
+        mFrameSelectedTop = new NinePatchTexture(context, R.drawable.grid_selected_top);
+    }
+
+    public void setHighlightItem(Path item) {
+        mHighlightItem = item;
+    }
+
+    public void draw(GLCanvas canvas, Texture content, int width, int height,
+            int rotation, Path path, int topIndex, int dataSourceType,
+            int mediaType, boolean wantCache, boolean isCaching) {
+        int x = -width / 2;
+        int y = -height / 2;
+
+        drawWithRotationAndGray(canvas, content, x, y, width, height, rotation,
+                topIndex);
+
+        if (((rotation / 90) & 0x01) == 1) {
+            int temp = width;
+            width = height;
+            height = temp;
+            x = -width / 2;
+            y = -height / 2;
+        }
+
+        drawVideoOverlay(canvas, mediaType, x, y, width, height, topIndex);
+
+        NinePatchTexture frame;
+        if (path == mHighlightItem) {
+            frame = topIndex == 0 ? mFrameSelectedTop : mFrameSelected;
+        } else {
+            frame = mFrame;
+        }
+
+        drawFrame(canvas, frame, x, y, width, height);
+
+        if (topIndex == 0) {
+            drawIcon(canvas, width, height, dataSourceType);
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/Icon.java b/src/com/android/gallery3d/ui/Icon.java
new file mode 100644
index 0000000..c710859
--- /dev/null
+++ b/src/com/android/gallery3d/ui/Icon.java
@@ -0,0 +1,59 @@
+/*
+ * 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.ui;
+
+import android.content.Context;
+import android.graphics.Rect;
+
+public class Icon extends GLView {
+    private final BasicTexture mIcon;
+
+    // The width and height requested by the user.
+    private int mReqWidth;
+    private int mReqHeight;
+
+    public Icon(Context context, int iconId, int width, int height) {
+        this(context, new ResourceTexture(context, iconId), width, height);
+    }
+
+    public Icon(Context context, BasicTexture icon, int width, int height) {
+        mIcon = icon;
+        mReqWidth = width;
+        mReqHeight = height;
+    }
+
+    @Override
+    protected void onMeasure(int widthSpec, int heightSpec) {
+        MeasureHelper.getInstance(this)
+                .setPreferredContentSize(mReqWidth, mReqHeight)
+                .measure(widthSpec, heightSpec);
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        Rect p = mPaddings;
+
+        int width = getWidth() - p.left - p.right;
+        int height = getHeight() - p.top - p.bottom;
+
+        // Draw the icon in the center of the space
+        int xoffset = p.left + (width - mReqWidth) / 2;
+        int yoffset = p.top + (height - mReqHeight) / 2;
+
+        mIcon.draw(canvas, xoffset, yoffset, mReqWidth, mReqHeight);
+    }
+}
diff --git a/src/com/android/gallery3d/ui/IconDrawer.java b/src/com/android/gallery3d/ui/IconDrawer.java
new file mode 100644
index 0000000..91732d3
--- /dev/null
+++ b/src/com/android/gallery3d/ui/IconDrawer.java
@@ -0,0 +1,112 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.data.MediaObject;
+
+import android.content.Context;
+
+public abstract class IconDrawer extends SelectionDrawer {
+    private final String TAG = "IconDrawer";
+    private final ResourceTexture mLocalSetIcon;
+    private final ResourceTexture mCameraIcon;
+    private final ResourceTexture mPicasaIcon;
+    private final ResourceTexture mMtpIcon;
+    private final Texture mVideoOverlay;
+    private final Texture mVideoPlayIcon;
+
+    public static class IconDimension {
+        int x;
+        int y;
+        int width;
+        int height;
+    }
+
+    public IconDrawer(Context context) {
+        mLocalSetIcon = new ResourceTexture(context, R.drawable.ic_album_overlay_folder_holo);
+        mCameraIcon = new ResourceTexture(context, R.drawable.ic_album_overlay_camera_holo);
+        mPicasaIcon = new ResourceTexture(context, R.drawable.ic_album_overlay_picassa_holo);
+        mMtpIcon = new ResourceTexture(context, R.drawable.ic_album_overlay_ptp_holo);
+        mVideoOverlay = new ResourceTexture(context,
+                R.drawable.thumbnail_album_video_overlay_holo);
+        mVideoPlayIcon = new ResourceTexture(context,
+                R.drawable.videooverlay);
+    }
+
+    @Override
+    public void prepareDrawing() {
+    }
+
+    protected IconDimension drawIcon(GLCanvas canvas, int width, int height,
+            int dataSourceType) {
+        ResourceTexture icon = getIcon(dataSourceType);
+
+        if (icon != null) {
+            IconDimension id = getIconDimension(icon, width, height);
+            icon.draw(canvas, id.x, id.y, id.width, id.height);
+            return id;
+        }
+        return null;
+    }
+
+    protected ResourceTexture getIcon(int dataSourceType) {
+        ResourceTexture icon = null;
+        switch (dataSourceType) {
+            case DATASOURCE_TYPE_LOCAL:
+                icon = mLocalSetIcon;
+                break;
+            case DATASOURCE_TYPE_PICASA:
+                icon = mPicasaIcon;
+                break;
+            case DATASOURCE_TYPE_CAMERA:
+                icon = mCameraIcon;
+                break;
+            case DATASOURCE_TYPE_MTP:
+                icon = mMtpIcon;
+                break;
+            default:
+                break;
+        }
+
+        return icon;
+    }
+
+    protected IconDimension getIconDimension(ResourceTexture icon, int width,
+            int height) {
+        IconDimension id = new IconDimension();
+        float scale = 0.25f * width / icon.getWidth();
+        id.width = (int) (scale * icon.getWidth());
+        id.height = (int) (scale * icon.getHeight());
+        id.x = -width / 2;
+        id.y = height / 2 - id.height;
+        return id;
+    }
+
+    protected void drawVideoOverlay(GLCanvas canvas, int mediaType,
+            int x, int y, int width, int height, int topIndex) {
+        if (mediaType != MediaObject.MEDIA_TYPE_VIDEO) return;
+        mVideoOverlay.draw(canvas, x, y, width, height);
+        if (topIndex == 0) {
+            int side = Math.min(width, height) / 6;
+            mVideoPlayIcon.draw(canvas, -side / 2, -side / 2, side, side);
+        }
+    }
+
+    @Override
+    public void drawFocus(GLCanvas canvas, int width, int height) {
+    }
+}
diff --git a/src/com/android/gallery3d/ui/ImportCompleteListener.java b/src/com/android/gallery3d/ui/ImportCompleteListener.java
new file mode 100644
index 0000000..5c52ea1
--- /dev/null
+++ b/src/com/android/gallery3d/ui/ImportCompleteListener.java
@@ -0,0 +1,57 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.AlbumPage;
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.util.MediaSetUtils;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.widget.Toast;
+
+public class ImportCompleteListener implements MenuExecutor.ProgressListener {
+    private GalleryActivity mActivity;
+
+    public ImportCompleteListener(GalleryActivity galleryActivity) {
+        mActivity = galleryActivity;
+    }
+
+    public void onProgressComplete(int result) {
+        int message;
+        if (result == MenuExecutor.EXECUTION_RESULT_SUCCESS) {
+            message = R.string.import_complete;
+            goToImportedAlbum();
+        } else {
+            message = R.string.import_fail;
+        }
+        Toast.makeText(mActivity.getAndroidContext(), message, Toast.LENGTH_LONG).show();
+    }
+
+    public void onProgressUpdate(int index) {
+    }
+
+    private void goToImportedAlbum() {
+        String pathOfImportedAlbum = "/local/all/" + MediaSetUtils.IMPORTED_BUCKET_ID;
+        Bundle data = new Bundle();
+        data.putString(AlbumPage.KEY_MEDIA_PATH, pathOfImportedAlbum);
+        mActivity.getStateManager().startState(AlbumPage.class, data);
+    }
+
+}
diff --git a/src/com/android/gallery3d/ui/Label.java b/src/com/android/gallery3d/ui/Label.java
new file mode 100644
index 0000000..6a70a18
--- /dev/null
+++ b/src/com/android/gallery3d/ui/Label.java
@@ -0,0 +1,84 @@
+/*
+ * 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.ui;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.Rect;
+
+public class Label extends GLView {
+    private static final String TAG = "Label";
+    public static final int NULL_ID = 0;
+
+    private static final int FONT_SIZE = 18;
+    private static final int FONT_COLOR = Color.WHITE;
+
+    private String mText;
+    private StringTexture mTexture;
+    private int mFontSize, mFontColor;
+
+    public Label(Context context, int stringId,
+            int fontSize, int fontColor) {
+        this(context, context.getString(stringId), fontSize, fontColor);
+    }
+
+    public Label(Context context, int stringId) {
+        this(context, stringId, FONT_SIZE, FONT_COLOR);
+    }
+
+    public Label(Context context, String text) {
+        this(context, text, FONT_SIZE, FONT_COLOR);
+    }
+
+    public Label(Context context, String text, int fontSize, int fontColor) {
+        //TODO: cut the text if it is too long
+        mText = text;
+        mTexture = StringTexture.newInstance(text, fontSize, fontColor);
+        mFontSize = fontSize;
+        mFontColor = fontColor;
+    }
+
+    public void setText(String text) {
+        if (!mText.equals(text)) {
+            mText = text;
+            mTexture = StringTexture.newInstance(text, mFontSize, mFontColor);
+            requestLayout();
+        }
+    }
+
+    @Override
+    protected void onMeasure(int widthSpec, int heightSpec) {
+        int width = mTexture.getWidth();
+        int height = mTexture.getHeight();
+        MeasureHelper.getInstance(this)
+                .setPreferredContentSize(width, height)
+                .measure(widthSpec, heightSpec);
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        Rect p = mPaddings;
+
+        int width = getWidth() - p.left - p.right;
+        int height = getHeight() - p.top - p.bottom;
+
+        int xoffset = p.left + (width - mTexture.getWidth()) / 2;
+        int yoffset = p.top + (height - mTexture.getHeight()) / 2;
+
+        mTexture.draw(canvas, xoffset, yoffset);
+    }
+}
diff --git a/src/com/android/gallery3d/ui/Log.java b/src/com/android/gallery3d/ui/Log.java
new file mode 100644
index 0000000..32adc98
--- /dev/null
+++ b/src/com/android/gallery3d/ui/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.ui;
+
+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/ui/ManageCacheDrawer.java b/src/com/android/gallery3d/ui/ManageCacheDrawer.java
new file mode 100644
index 0000000..cf1e39e
--- /dev/null
+++ b/src/com/android/gallery3d/ui/ManageCacheDrawer.java
@@ -0,0 +1,126 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.Path;
+
+import android.content.Context;
+
+public class ManageCacheDrawer extends IconDrawer {
+    private static final int COLOR_CACHING_BACKGROUND = 0x7F000000;
+    private static final int ICON_SIZE = 36;
+    private final NinePatchTexture mFrame;
+    private final ResourceTexture mCheckedItem;
+    private final ResourceTexture mUnCheckedItem;
+    private final SelectionManager mSelectionManager;
+
+    private final ResourceTexture mLocalAlbumIcon;
+    private final StringTexture mCaching;
+
+    public ManageCacheDrawer(Context context, SelectionManager selectionManager) {
+        super(context);
+        mFrame = new NinePatchTexture(context, R.drawable.manage_frame);
+        mCheckedItem = new ResourceTexture(context, R.drawable.btn_make_offline_normal_on_holo_dark);
+        mUnCheckedItem = new ResourceTexture(context, R.drawable.btn_make_offline_normal_off_holo_dark);
+        mLocalAlbumIcon = new ResourceTexture(context, R.drawable.btn_make_offline_disabled_on_holo_dark);
+        String cachingLabel = context.getString(R.string.caching_label);
+        mCaching = StringTexture.newInstance(cachingLabel, 12, 0xffffffff);
+        mSelectionManager = selectionManager;
+    }
+
+    @Override
+    public void prepareDrawing() {
+    }
+
+    private static boolean isLocal(int dataSourceType) {
+        return dataSourceType != DATASOURCE_TYPE_PICASA;
+    }
+
+    @Override
+    public void draw(GLCanvas canvas, Texture content, int width, int height,
+            int rotation, Path path, int topIndex, int dataSourceType,
+            int mediaType, boolean wantCache, boolean isCaching) {
+
+        boolean selected = mSelectionManager.isItemSelected(path);
+        boolean chooseToCache = wantCache ^ selected;
+
+        int x = -width / 2;
+        int y = -height / 2;
+
+        drawWithRotationAndGray(canvas, content, x, y, width, height, rotation,
+                topIndex);
+
+        if (((rotation / 90) & 0x01) == 1) {
+            int temp = width;
+            width = height;
+            height = temp;
+            x = -width / 2;
+            y = -height / 2;
+        }
+
+        drawVideoOverlay(canvas, mediaType, x, y, width, height, topIndex);
+
+        drawFrame(canvas, mFrame, x, y, width, height);
+
+        if (topIndex == 0) {
+            drawIcon(canvas, width, height, dataSourceType);
+        }
+
+        if (topIndex == 0) {
+            ResourceTexture icon = null;
+            if (isLocal(dataSourceType)) {
+                icon = mLocalAlbumIcon;
+            } else if (chooseToCache) {
+                icon = mCheckedItem;
+            } else {
+                icon = mUnCheckedItem;
+            }
+
+            int w = ICON_SIZE;
+            int h = ICON_SIZE;
+            x = width / 2 - w / 2;
+            y = -height / 2 - h / 2;
+
+            icon.draw(canvas, x, y, w, h);
+
+            if (isCaching) {
+                int textWidth = mCaching.getWidth();
+                int textHeight = mCaching.getHeight();
+                x = -textWidth / 2;
+                y = height / 2 - textHeight;
+
+                // Leave a few pixels of margin in the background rect.
+                float sideMargin = Utils.clamp(textWidth * 0.1f, 2.0f,
+                        6.0f);
+                float clearance = Utils.clamp(textHeight * 0.1f, 2.0f,
+                        6.0f);
+
+                // Overlay the "Caching" wording at the bottom-center of the content.
+                canvas.fillRect(x - sideMargin, y - clearance,
+                        textWidth + sideMargin * 2, textHeight + clearance,
+                        COLOR_CACHING_BACKGROUND);
+                mCaching.draw(canvas, x, y);
+            }
+        }
+    }
+
+    @Override
+    public void drawFocus(GLCanvas canvas, int width, int height) {
+    }
+}
diff --git a/src/com/android/gallery3d/ui/MeasureHelper.java b/src/com/android/gallery3d/ui/MeasureHelper.java
new file mode 100644
index 0000000..f65dc10
--- /dev/null
+++ b/src/com/android/gallery3d/ui/MeasureHelper.java
@@ -0,0 +1,65 @@
+/*
+ * 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.ui;
+
+import android.graphics.Rect;
+import android.view.View.MeasureSpec;
+
+class MeasureHelper {
+
+    private static MeasureHelper sInstance = new MeasureHelper(null);
+
+    private GLView mComponent;
+    private int mPreferredWidth;
+    private int mPreferredHeight;
+
+    private MeasureHelper(GLView component) {
+        mComponent = component;
+    }
+
+    public static MeasureHelper getInstance(GLView component) {
+        sInstance.mComponent = component;
+        return sInstance;
+    }
+
+    public MeasureHelper setPreferredContentSize(int width, int height) {
+        mPreferredWidth = width;
+        mPreferredHeight = height;
+        return this;
+    }
+
+    public void measure(int widthSpec, int heightSpec) {
+        Rect p = mComponent.getPaddings();
+        setMeasuredSize(
+                getLength(widthSpec, mPreferredWidth + p.left + p.right),
+                getLength(heightSpec, mPreferredHeight + p.top + p.bottom));
+    }
+
+    private static int getLength(int measureSpec, int prefered) {
+        int specLength = MeasureSpec.getSize(measureSpec);
+        switch(MeasureSpec.getMode(measureSpec)) {
+            case MeasureSpec.EXACTLY: return specLength;
+            case MeasureSpec.AT_MOST: return Math.min(prefered, specLength);
+            default: return prefered;
+        }
+    }
+
+    protected void setMeasuredSize(int width, int height) {
+        mComponent.setMeasuredSize(width, height);
+    }
+
+}
diff --git a/src/com/android/gallery3d/ui/MenuExecutor.java b/src/com/android/gallery3d/ui/MenuExecutor.java
new file mode 100644
index 0000000..710ddc4
--- /dev/null
+++ b/src/com/android/gallery3d/ui/MenuExecutor.java
@@ -0,0 +1,398 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.CropImage;
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.util.Future;
+import com.android.gallery3d.util.GalleryUtils;
+import com.android.gallery3d.util.ThreadPool.Job;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.app.Activity;
+import android.app.ProgressDialog;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Message;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+
+public class MenuExecutor {
+    @SuppressWarnings("unused")
+    private static final String TAG = "MenuExecutor";
+
+    private static final int MSG_TASK_COMPLETE = 1;
+    private static final int MSG_TASK_UPDATE = 2;
+    private static final int MSG_DO_SHARE = 3;
+
+    public static final int EXECUTION_RESULT_SUCCESS = 1;
+    public static final int EXECUTION_RESULT_FAIL = 2;
+    public static final int EXECUTION_RESULT_CANCEL = 3;
+
+    private ProgressDialog mDialog;
+    private Future<?> mTask;
+
+    private final GalleryActivity mActivity;
+    private final SelectionManager mSelectionManager;
+    private final Handler mHandler;
+
+    private static ProgressDialog showProgressDialog(
+            Context context, int titleId, int progressMax) {
+        ProgressDialog dialog = new ProgressDialog(context);
+        dialog.setTitle(titleId);
+        dialog.setMax(progressMax);
+        dialog.setCancelable(false);
+        dialog.setIndeterminate(false);
+        if (progressMax > 1) {
+            dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
+        }
+        dialog.show();
+        return dialog;
+    }
+
+    public interface ProgressListener {
+        public void onProgressUpdate(int index);
+        public void onProgressComplete(int result);
+    }
+
+    public MenuExecutor(
+            GalleryActivity activity, SelectionManager selectionManager) {
+        mActivity = Utils.checkNotNull(activity);
+        mSelectionManager = Utils.checkNotNull(selectionManager);
+        mHandler = new SynchronizedHandler(mActivity.getGLRoot()) {
+            @Override
+            public void handleMessage(Message message) {
+                switch (message.what) {
+                    case MSG_TASK_COMPLETE: {
+                        if (mDialog != null) {
+                            mDialog.dismiss();
+                            mDialog = null;
+                            mTask = null;
+                        }
+                        if (message.obj != null) {
+                            ProgressListener listener = (ProgressListener) message.obj;
+                            listener.onProgressComplete(message.arg1);
+                        }
+                        mSelectionManager.leaveSelectionMode();
+                        break;
+                    }
+                    case MSG_TASK_UPDATE: {
+                        if (mDialog != null) mDialog.setProgress(message.arg1);
+                        if (message.obj != null) {
+                            ProgressListener listener = (ProgressListener) message.obj;
+                            listener.onProgressUpdate(message.arg1);
+                        }
+                        break;
+                    }
+                    case MSG_DO_SHARE: {
+                        ((Activity) mActivity).startActivity((Intent) message.obj);
+                        break;
+                    }
+                }
+            }
+        };
+    }
+
+    public void pause() {
+        if (mTask != null) {
+            mTask.cancel();
+            mTask.waitDone();
+            mDialog.dismiss();
+            mDialog = null;
+            mTask = null;
+        }
+    }
+
+    private void onProgressUpdate(int index, ProgressListener listener) {
+        mHandler.sendMessage(
+                mHandler.obtainMessage(MSG_TASK_UPDATE, index, 0, listener));
+    }
+
+    private void onProgressComplete(int result, ProgressListener listener) {
+        mHandler.sendMessage(mHandler.obtainMessage(MSG_TASK_COMPLETE, result, 0, listener));
+    }
+
+    private int getShareType(SelectionManager selectionManager) {
+        ArrayList<Path> items = selectionManager.getSelected(false);
+        int type = 0;
+        DataManager dataManager = mActivity.getDataManager();
+        for (Path id : items) {
+            type |= dataManager.getMediaType(id);
+        }
+        return type;
+    }
+
+    private void onShareItemClicked(final SelectionManager selectionManager,
+            final String mimeType, final ComponentName component) {
+        Utils.assertTrue(mDialog == null);
+        final ArrayList<Path> items = selectionManager.getSelected(true);
+        mDialog = showProgressDialog((Activity) mActivity,
+                R.string.loading_image, items.size());
+
+        mTask = mActivity.getThreadPool().submit(new Job<Void>() {
+            @Override
+            public Void run(JobContext jc) {
+                DataManager manager = mActivity.getDataManager();
+                ArrayList<Uri> uris = new ArrayList<Uri>(items.size());
+                int index = 0;
+                for (Path path : items) {
+                    if ((manager.getSupportedOperations(path)
+                            & MediaObject.SUPPORT_SHARE) != 0) {
+                        uris.add(manager.getContentUri(path));
+                    }
+                    onProgressUpdate(++index, null);
+                }
+                if (jc.isCancelled()) return null;
+                Intent intent = new Intent()
+                        .setComponent(component).setType(mimeType);
+                if (uris.isEmpty()) {
+                    return null;
+                } else if (uris.size() == 1) {
+                    intent.setAction(Intent.ACTION_SEND);
+                    intent.putExtra(Intent.EXTRA_STREAM, uris.get(0));
+                } else {
+                    intent.setAction(Intent.ACTION_SEND_MULTIPLE);
+                    intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
+                }
+                onProgressComplete(EXECUTION_RESULT_SUCCESS, null);
+                mHandler.sendMessage(mHandler.obtainMessage(MSG_DO_SHARE, intent));
+                return null;
+            }
+        }, null);
+    }
+
+    private static void setMenuItemVisibility(
+            Menu menu, int id, boolean visibility) {
+        MenuItem item = menu.findItem(id);
+        if (item != null) item.setVisible(visibility);
+    }
+
+    public static void updateMenuOperation(Menu menu, int supported) {
+        boolean supportDelete = (supported & MediaObject.SUPPORT_DELETE) != 0;
+        boolean supportRotate = (supported & MediaObject.SUPPORT_ROTATE) != 0;
+        boolean supportCrop = (supported & MediaObject.SUPPORT_CROP) != 0;
+        boolean supportShare = (supported & MediaObject.SUPPORT_SHARE) != 0;
+        boolean supportSetAs = (supported & MediaObject.SUPPORT_SETAS) != 0;
+        boolean supportShowOnMap = (supported & MediaObject.SUPPORT_SHOW_ON_MAP) != 0;
+        boolean supportCache = (supported & MediaObject.SUPPORT_CACHE) != 0;
+        boolean supportEdit = (supported & MediaObject.SUPPORT_EDIT) != 0;
+        boolean supportInfo = (supported & MediaObject.SUPPORT_INFO) != 0;
+        boolean supportImport = (supported & MediaObject.SUPPORT_IMPORT) != 0;
+
+        setMenuItemVisibility(menu, R.id.action_delete, supportDelete);
+        setMenuItemVisibility(menu, R.id.action_rotate_ccw, supportRotate);
+        setMenuItemVisibility(menu, R.id.action_rotate_cw, supportRotate);
+        setMenuItemVisibility(menu, R.id.action_crop, supportCrop);
+        setMenuItemVisibility(menu, R.id.action_share, supportShare);
+        setMenuItemVisibility(menu, R.id.action_setas, supportSetAs);
+        setMenuItemVisibility(menu, R.id.action_show_on_map, supportShowOnMap);
+        setMenuItemVisibility(menu, R.id.action_edit, supportEdit);
+        setMenuItemVisibility(menu, R.id.action_details, supportInfo);
+        setMenuItemVisibility(menu, R.id.action_import, supportImport);
+    }
+
+    private Path getSingleSelectedPath() {
+        ArrayList<Path> ids = mSelectionManager.getSelected(true);
+        Utils.assertTrue(ids.size() == 1);
+        return ids.get(0);
+    }
+
+    public boolean onMenuClicked(MenuItem menuItem, ProgressListener listener) {
+        int title;
+        DataManager manager = mActivity.getDataManager();
+        int action = menuItem.getItemId();
+        switch (action) {
+            case R.id.action_select_all:
+                if (mSelectionManager.inSelectAllMode()) {
+                    mSelectionManager.deSelectAll();
+                } else {
+                    mSelectionManager.selectAll();
+                }
+                return true;
+            case R.id.action_crop: {
+                Path path = getSingleSelectedPath();
+                String mimeType = getMimeType(manager.getMediaType(path));
+                Intent intent = new Intent(CropImage.ACTION_CROP)
+                        .setDataAndType(manager.getContentUri(path), mimeType);
+                ((Activity) mActivity).startActivity(intent);
+                return true;
+            }
+            case R.id.action_setas: {
+                Path path = getSingleSelectedPath();
+                int type = manager.getMediaType(path);
+                Intent intent = new Intent(Intent.ACTION_ATTACH_DATA);
+                String mimeType = getMimeType(type);
+                intent.setDataAndType(manager.getContentUri(path), mimeType);
+                intent.putExtra("mimeType", mimeType);
+                intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+                Activity activity = (Activity) mActivity;
+                activity.startActivity(Intent.createChooser(
+                        intent, activity.getString(R.string.set_as)));
+                return true;
+            }
+            case R.id.action_confirm_delete:
+                title = R.string.delete;
+                break;
+            case R.id.action_rotate_cw:
+                title = R.string.rotate_right;
+                break;
+            case R.id.action_rotate_ccw:
+                title = R.string.rotate_left;
+                break;
+            case R.id.action_show_on_map:
+                title = R.string.show_on_map;
+                break;
+            case R.id.action_edit:
+                title = R.string.edit;
+                break;
+            case R.id.action_import:
+                title = R.string.Import;
+                break;
+            default:
+                return false;
+        }
+        startAction(action, title, listener);
+        return true;
+    }
+
+    public void startAction(int action, int title, ProgressListener listener) {
+        ArrayList<Path> ids = mSelectionManager.getSelected(false);
+        Utils.assertTrue(mDialog == null);
+
+        Activity activity = (Activity) mActivity;
+        mDialog = showProgressDialog(activity, title, ids.size());
+        MediaOperation operation = new MediaOperation(action, ids, listener);
+        mTask = mActivity.getThreadPool().submit(operation, null);
+    }
+
+    public static String getMimeType(int type) {
+        switch (type) {
+            case MediaObject.MEDIA_TYPE_IMAGE :
+                return "image/*";
+            case MediaObject.MEDIA_TYPE_VIDEO :
+                return "video/*";
+            default: return "*/*";
+        }
+    }
+
+    private boolean execute(
+            DataManager manager, JobContext jc, int cmd, Path path) {
+        boolean result = true;
+        switch (cmd) {
+            case R.id.action_confirm_delete:
+                manager.delete(path);
+                break;
+            case R.id.action_rotate_cw:
+                manager.rotate(path, 90);
+                break;
+            case R.id.action_rotate_ccw:
+                manager.rotate(path, -90);
+                break;
+            case R.id.action_toggle_full_caching: {
+                MediaObject obj = manager.getMediaObject(path);
+                int cacheFlag = obj.getCacheFlag();
+                if (cacheFlag == MediaObject.CACHE_FLAG_FULL) {
+                    cacheFlag = MediaObject.CACHE_FLAG_SCREENNAIL;
+                } else {
+                    cacheFlag = MediaObject.CACHE_FLAG_FULL;
+                }
+                obj.cache(cacheFlag);
+                break;
+            }
+            case R.id.action_show_on_map: {
+                MediaItem item = (MediaItem) manager.getMediaObject(path);
+                double latlng[] = new double[2];
+                item.getLatLong(latlng);
+                if (GalleryUtils.isValidLocation(latlng[0], latlng[1])) {
+                    GalleryUtils.showOnMap((Context) mActivity, latlng[0], latlng[1]);
+                }
+                break;
+            }
+            case R.id.action_import: {
+                MediaObject obj = manager.getMediaObject(path);
+                result = obj.Import();
+                break;
+            }
+            case R.id.action_edit: {
+                Activity activity = (Activity) mActivity;
+                MediaItem item = (MediaItem) manager.getMediaObject(path);
+                try {
+                    activity.startActivity(Intent.createChooser(
+                            new Intent(Intent.ACTION_EDIT)
+                                    .setData(item.getContentUri())
+                                    .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION),
+                            null));
+                } catch (Throwable t) {
+                    Log.w(TAG, "failed to start edit activity: ", t);
+                    Toast.makeText(activity,
+                            activity.getString(R.string.activity_not_found),
+                            Toast.LENGTH_SHORT).show();
+                }
+                break;
+            }
+            default:
+                throw new AssertionError();
+        }
+        return result;
+    }
+
+    private class MediaOperation implements Job<Void> {
+        private final ArrayList<Path> mItems;
+        private final int mOperation;
+        private final ProgressListener mListener;
+
+        public MediaOperation(int operation, ArrayList<Path> items, ProgressListener listener) {
+            mOperation = operation;
+            mItems = items;
+            mListener = listener;
+        }
+
+        public Void run(JobContext jc) {
+            int index = 0;
+            DataManager manager = mActivity.getDataManager();
+            int result = EXECUTION_RESULT_SUCCESS;
+            for (Path id : mItems) {
+                if (jc.isCancelled()) {
+                    result = EXECUTION_RESULT_CANCEL;
+                    break;
+                }
+                try {
+                    if (!execute(manager, jc, mOperation, id)) result = EXECUTION_RESULT_FAIL;
+                } catch (Throwable th) {
+                    Log.e(TAG, "failed to execute operation " + mOperation
+                            + " for " + id, th);
+                }
+                onProgressUpdate(index++, mListener);
+            }
+            onProgressComplete(result, mListener);
+            return null;
+        }
+    }
+}
+
diff --git a/src/com/android/gallery3d/ui/MultiLineTexture.java b/src/com/android/gallery3d/ui/MultiLineTexture.java
new file mode 100644
index 0000000..be62d59
--- /dev/null
+++ b/src/com/android/gallery3d/ui/MultiLineTexture.java
@@ -0,0 +1,50 @@
+/*
+ * 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.ui;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.text.Layout;
+import android.text.StaticLayout;
+import android.text.TextPaint;
+
+// MultiLineTexture is a texture shows the content of a specified String.
+//
+// To create a MultiLineTexture, use the newInstance() method and specify
+// the String, the font size, and the color.
+class MultiLineTexture extends CanvasTexture {
+    private final Layout mLayout;
+
+    private MultiLineTexture(Layout layout) {
+        super(layout.getWidth(), layout.getHeight());
+        mLayout = layout;
+    }
+
+    public static MultiLineTexture newInstance(
+            String text, int maxWidth, float textSize, int color) {
+        TextPaint paint = StringTexture.getDefaultPaint(textSize, color);
+        Layout layout = new StaticLayout(text, 0, text.length(), paint,
+                maxWidth, Layout.Alignment.ALIGN_NORMAL, 1, 0, true, null, 0);
+
+        return new MultiLineTexture(layout);
+    }
+
+    @Override
+    protected void onDraw(Canvas canvas, Bitmap backing) {
+        mLayout.draw(canvas);
+    }
+}
diff --git a/src/com/android/gallery3d/ui/NinePatchChunk.java b/src/com/android/gallery3d/ui/NinePatchChunk.java
new file mode 100644
index 0000000..61bf22c
--- /dev/null
+++ b/src/com/android/gallery3d/ui/NinePatchChunk.java
@@ -0,0 +1,82 @@
+/*
+ * 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.ui;
+
+import android.graphics.Rect;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+// See "frameworks/base/include/utils/ResourceTypes.h" for the format of
+// NinePatch chunk.
+class NinePatchChunk {
+
+    public static final int NO_COLOR = 0x00000001;
+    public static final int TRANSPARENT_COLOR = 0x00000000;
+
+    public Rect mPaddings = new Rect();
+
+    public int mDivX[];
+    public int mDivY[];
+    public int mColor[];
+
+    private static void readIntArray(int[] data, ByteBuffer buffer) {
+        for (int i = 0, n = data.length; i < n; ++i) {
+            data[i] = buffer.getInt();
+        }
+    }
+
+    private static void checkDivCount(int length) {
+        if (length == 0 || (length & 0x01) != 0) {
+            throw new RuntimeException("invalid nine-patch: " + length);
+        }
+    }
+
+    public static NinePatchChunk deserialize(byte[] data) {
+        ByteBuffer byteBuffer =
+                ByteBuffer.wrap(data).order(ByteOrder.nativeOrder());
+
+        byte wasSerialized = byteBuffer.get();
+        if (wasSerialized == 0) return null;
+
+        NinePatchChunk chunk = new NinePatchChunk();
+        chunk.mDivX = new int[byteBuffer.get()];
+        chunk.mDivY = new int[byteBuffer.get()];
+        chunk.mColor = new int[byteBuffer.get()];
+
+        checkDivCount(chunk.mDivX.length);
+        checkDivCount(chunk.mDivY.length);
+
+        // skip 8 bytes
+        byteBuffer.getInt();
+        byteBuffer.getInt();
+
+        chunk.mPaddings.left = byteBuffer.getInt();
+        chunk.mPaddings.right = byteBuffer.getInt();
+        chunk.mPaddings.top = byteBuffer.getInt();
+        chunk.mPaddings.bottom = byteBuffer.getInt();
+
+        // skip 4 bytes
+        byteBuffer.getInt();
+
+        readIntArray(chunk.mDivX, byteBuffer);
+        readIntArray(chunk.mDivY, byteBuffer);
+        readIntArray(chunk.mColor, byteBuffer);
+
+        return chunk;
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/gallery3d/ui/NinePatchTexture.java b/src/com/android/gallery3d/ui/NinePatchTexture.java
new file mode 100644
index 0000000..15b057a
--- /dev/null
+++ b/src/com/android/gallery3d/ui/NinePatchTexture.java
@@ -0,0 +1,401 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Rect;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.FloatBuffer;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import javax.microedition.khronos.opengles.GL11;
+
+// NinePatchTexture is a texture backed by a NinePatch resource.
+//
+// getPaddings() returns paddings specified in the NinePatch.
+// getNinePatchChunk() returns the layout data specified in the NinePatch.
+//
+public class NinePatchTexture extends ResourceTexture {
+    @SuppressWarnings("unused")
+    private static final String TAG = "NinePatchTexture";
+    private NinePatchChunk mChunk;
+    private MyCacheMap<Long, NinePatchInstance> mInstanceCache =
+            new MyCacheMap<Long, NinePatchInstance>();
+
+    public NinePatchTexture(Context context, int resId) {
+        super(context, resId);
+    }
+
+    @Override
+    protected Bitmap onGetBitmap() {
+        if (mBitmap != null) return mBitmap;
+
+        BitmapFactory.Options options = new BitmapFactory.Options();
+        options.inPreferredConfig = Bitmap.Config.ARGB_8888;
+        Bitmap bitmap = BitmapFactory.decodeResource(
+                mContext.getResources(), mResId, options);
+        mBitmap = bitmap;
+        setSize(bitmap.getWidth(), bitmap.getHeight());
+        byte[] chunkData = bitmap.getNinePatchChunk();
+        mChunk = chunkData == null
+                ? null
+                : NinePatchChunk.deserialize(bitmap.getNinePatchChunk());
+        if (mChunk == null) {
+            throw new RuntimeException("invalid nine-patch image: " + mResId);
+        }
+        return bitmap;
+    }
+
+    public Rect getPaddings() {
+        // get the paddings from nine patch
+        if (mChunk == null) onGetBitmap();
+        return mChunk.mPaddings;
+    }
+
+    public NinePatchChunk getNinePatchChunk() {
+        if (mChunk == null) onGetBitmap();
+        return mChunk;
+    }
+
+    private static class MyCacheMap<K, V> extends LinkedHashMap<K, V> {
+        private int CACHE_SIZE = 16;
+        private V mJustRemoved;
+
+        public MyCacheMap() {
+            super(4, 0.75f, true);
+        }
+
+        @Override
+        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
+            if (size() > CACHE_SIZE) {
+                mJustRemoved = eldest.getValue();
+                return true;
+            }
+            return false;
+        }
+
+        public V getJustRemoved() {
+            V result = mJustRemoved;
+            mJustRemoved = null;
+            return result;
+        }
+    }
+
+    private NinePatchInstance findInstance(GLCanvas canvas, int w, int h) {
+        long key = w;
+        key = (key << 32) | h;
+        NinePatchInstance instance = mInstanceCache.get(key);
+
+        if (instance == null) {
+            instance = new NinePatchInstance(this, w, h);
+            mInstanceCache.put(key, instance);
+            NinePatchInstance removed = mInstanceCache.getJustRemoved();
+            if (removed != null) {
+                removed.recycle(canvas);
+            }
+        }
+
+        return instance;
+    }
+
+    @Override
+    public void draw(GLCanvas canvas, int x, int y, int w, int h) {
+        if (!isLoaded(canvas)) {
+            mInstanceCache.clear();
+        }
+
+        if (w != 0 && h != 0) {
+            findInstance(canvas, w, h).draw(canvas, this, x, y);
+        }
+    }
+
+    @Override
+    public void recycle() {
+        super.recycle();
+        GLCanvas canvas = mCanvasRef == null ? null : mCanvasRef.get();
+        if (canvas == null) return;
+        for (NinePatchInstance instance : mInstanceCache.values()) {
+            instance.recycle(canvas);
+        }
+        mInstanceCache.clear();
+    }
+}
+
+// This keeps data for a specialization of NinePatchTexture with the size
+// (width, height). We pre-compute the coordinates for efficiency.
+class NinePatchInstance {
+
+    @SuppressWarnings("unused")
+    private static final String TAG = "NinePatchInstance";
+
+    // We need 16 vertices for a normal nine-patch image (the 4x4 vertices)
+    private static final int VERTEX_BUFFER_SIZE = 16 * 2;
+
+    // We need 22 indices for a normal nine-patch image, plus 2 for each
+    // transparent region. Current there are at most 1 transparent region.
+    private static final int INDEX_BUFFER_SIZE = 22 + 2;
+
+    private FloatBuffer mXyBuffer;
+    private FloatBuffer mUvBuffer;
+    private ByteBuffer mIndexBuffer;
+
+    // Names for buffer names: xy, uv, index.
+    private int[] mBufferNames;
+
+    private int mIdxCount;
+
+    public NinePatchInstance(NinePatchTexture tex, int width, int height) {
+        NinePatchChunk chunk = tex.getNinePatchChunk();
+
+        if (width <= 0 || height <= 0) {
+            throw new RuntimeException("invalid dimension");
+        }
+
+        // The code should be easily extended to handle the general cases by
+        // allocating more space for buffers. But let's just handle the only
+        // use case.
+        if (chunk.mDivX.length != 2 || chunk.mDivY.length != 2) {
+            throw new RuntimeException("unsupported nine patch");
+        }
+
+        float divX[] = new float[4];
+        float divY[] = new float[4];
+        float divU[] = new float[4];
+        float divV[] = new float[4];
+
+        int nx = stretch(divX, divU, chunk.mDivX, tex.getWidth(), width);
+        int ny = stretch(divY, divV, chunk.mDivY, tex.getHeight(), height);
+
+        prepareVertexData(divX, divY, divU, divV, nx, ny, chunk.mColor);
+    }
+
+    /**
+     * Stretches the texture according to the nine-patch rules. It will
+     * linearly distribute the strechy parts defined in the nine-patch chunk to
+     * the target area.
+     *
+     * <pre>
+     *                      source
+     *          /--------------^---------------\
+     *         u0    u1       u2  u3     u4   u5
+     * div ---> |fffff|ssssssss|fff|ssssss|ffff| ---> u
+     *          |    div0    div1 div2   div3  |
+     *          |     |       /   /      /    /
+     *          |     |      /   /     /    /
+     *          |     |     /   /    /    /
+     *          |fffff|ssss|fff|sss|ffff| ---> x
+     *         x0    x1   x2  x3  x4   x5
+     *          \----------v------------/
+     *                  target
+     *
+     * f: fixed segment
+     * s: stretchy segment
+     * </pre>
+     *
+     * @param div the stretch parts defined in nine-patch chunk
+     * @param source the length of the texture
+     * @param target the length on the drawing plan
+     * @param u output, the positions of these dividers in the texture
+     *        coordinate
+     * @param x output, the corresponding position of these dividers on the
+     *        drawing plan
+     * @return the number of these dividers.
+     */
+    private static int stretch(
+            float x[], float u[], int div[], int source, int target) {
+        int textureSize = Utils.nextPowerOf2(source);
+        float textureBound = (float) source / textureSize;
+
+        float stretch = 0;
+        for (int i = 0, n = div.length; i < n; i += 2) {
+            stretch += div[i + 1] - div[i];
+        }
+
+        float remaining = target - source + stretch;
+
+        float lastX = 0;
+        float lastU = 0;
+
+        x[0] = 0;
+        u[0] = 0;
+        for (int i = 0, n = div.length; i < n; i += 2) {
+            // Make the stretchy segment a little smaller to prevent sampling
+            // on neighboring fixed segments.
+            // fixed segment
+            x[i + 1] = lastX + (div[i] - lastU) + 0.5f;
+            u[i + 1] = Math.min((div[i] + 0.5f) / textureSize, textureBound);
+
+            // stretchy segment
+            float partU = div[i + 1] - div[i];
+            float partX = remaining * partU / stretch;
+            remaining -= partX;
+            stretch -= partU;
+
+            lastX = x[i + 1] + partX;
+            lastU = div[i + 1];
+            x[i + 2] = lastX - 0.5f;
+            u[i + 2] = Math.min((lastU - 0.5f)/ textureSize, textureBound);
+        }
+        // the last fixed segment
+        x[div.length + 1] = target;
+        u[div.length + 1] = textureBound;
+
+        // remove segments with length 0.
+        int last = 0;
+        for (int i = 1, n = div.length + 2; i < n; ++i) {
+            if ((x[i] - x[last]) < 1f) continue;
+            x[++last] = x[i];
+            u[last] = u[i];
+        }
+        return last + 1;
+    }
+
+    private void prepareVertexData(float x[], float y[], float u[], float v[],
+            int nx, int ny, int[] color) {
+        /*
+         * Given a 3x3 nine-patch image, the vertex order is defined as the
+         * following graph:
+         *
+         * (0) (1) (2) (3)
+         *  |  /|  /|  /|
+         *  | / | / | / |
+         * (4) (5) (6) (7)
+         *  | \ | \ | \ |
+         *  |  \|  \|  \|
+         * (8) (9) (A) (B)
+         *  |  /|  /|  /|
+         *  | / | / | / |
+         * (C) (D) (E) (F)
+         *
+         * And we draw the triangle strip in the following index order:
+         *
+         * index: 04152637B6A5948C9DAEBF
+         */
+        int pntCount = 0;
+        float xy[] = new float[VERTEX_BUFFER_SIZE];
+        float uv[] = new float[VERTEX_BUFFER_SIZE];
+        for (int j = 0; j < ny; ++j) {
+            for (int i = 0; i < nx; ++i) {
+                int xIndex = (pntCount++) << 1;
+                int yIndex = xIndex + 1;
+                xy[xIndex] = x[i];
+                xy[yIndex] = y[j];
+                uv[xIndex] = u[i];
+                uv[yIndex] = v[j];
+            }
+        }
+
+        int idxCount = 1;
+        boolean isForward = false;
+        byte index[] = new byte[INDEX_BUFFER_SIZE];
+        for (int row = 0; row < ny - 1; row++) {
+            --idxCount;
+            isForward = !isForward;
+
+            int start, end, inc;
+            if (isForward) {
+                start = 0;
+                end = nx;
+                inc = 1;
+            } else {
+                start = nx - 1;
+                end = -1;
+                inc = -1;
+            }
+
+            for (int col = start; col != end; col += inc) {
+                int k = row * nx + col;
+                if (col != start) {
+                    int colorIdx = row * (nx - 1) + col;
+                    if (isForward) colorIdx--;
+                    if (color[colorIdx] == NinePatchChunk.TRANSPARENT_COLOR) {
+                        index[idxCount] = index[idxCount - 1];
+                        ++idxCount;
+                        index[idxCount++] = (byte) k;
+                    }
+                }
+
+                index[idxCount++] = (byte) k;
+                index[idxCount++] = (byte) (k + nx);
+            }
+        }
+
+        mIdxCount = idxCount;
+
+        int size = (pntCount * 2) * (Float.SIZE / Byte.SIZE);
+        mXyBuffer = allocateDirectNativeOrderBuffer(size).asFloatBuffer();
+        mUvBuffer = allocateDirectNativeOrderBuffer(size).asFloatBuffer();
+        mIndexBuffer = allocateDirectNativeOrderBuffer(mIdxCount);
+
+        mXyBuffer.put(xy, 0, pntCount * 2).position(0);
+        mUvBuffer.put(uv, 0, pntCount * 2).position(0);
+        mIndexBuffer.put(index, 0, idxCount).position(0);
+    }
+
+    private static ByteBuffer allocateDirectNativeOrderBuffer(int size) {
+        return ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder());
+    }
+
+    private void prepareBuffers(GLCanvas canvas) {
+        mBufferNames = new int[3];
+        GL11 gl = canvas.getGLInstance();
+        gl.glGenBuffers(3, mBufferNames, 0);
+
+        gl.glBindBuffer(GL11.GL_ARRAY_BUFFER, mBufferNames[0]);
+        gl.glBufferData(GL11.GL_ARRAY_BUFFER,
+                mXyBuffer.capacity() * (Float.SIZE / Byte.SIZE),
+                mXyBuffer, GL11.GL_STATIC_DRAW);
+
+        gl.glBindBuffer(GL11.GL_ARRAY_BUFFER, mBufferNames[1]);
+        gl.glBufferData(GL11.GL_ARRAY_BUFFER,
+                mUvBuffer.capacity() * (Float.SIZE / Byte.SIZE),
+                mUvBuffer, GL11.GL_STATIC_DRAW);
+
+        gl.glBindBuffer(GL11.GL_ELEMENT_ARRAY_BUFFER, mBufferNames[2]);
+        gl.glBufferData(GL11.GL_ELEMENT_ARRAY_BUFFER,
+                mIndexBuffer.capacity(),
+                mIndexBuffer, GL11.GL_STATIC_DRAW);
+
+        // These buffers are never used again.
+        mXyBuffer = null;
+        mUvBuffer = null;
+        mIndexBuffer = null;
+    }
+
+    public void draw(GLCanvas canvas, NinePatchTexture tex, int x, int y) {
+        if (mBufferNames == null) {
+            prepareBuffers(canvas);
+        }
+        canvas.drawMesh(tex, x, y, mBufferNames[0], mBufferNames[1],
+                mBufferNames[2], mIdxCount);
+    }
+
+    public void recycle(GLCanvas canvas) {
+        if (mBufferNames != null) {
+            canvas.deleteBuffer(mBufferNames[0]);
+            canvas.deleteBuffer(mBufferNames[1]);
+            canvas.deleteBuffer(mBufferNames[2]);
+            mBufferNames = null;
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/OnSelectedListener.java b/src/com/android/gallery3d/ui/OnSelectedListener.java
new file mode 100644
index 0000000..2cc5809
--- /dev/null
+++ b/src/com/android/gallery3d/ui/OnSelectedListener.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.ui;
+
+public interface OnSelectedListener {
+    public void onSelected(GLView source);
+}
diff --git a/src/com/android/gallery3d/ui/Paper.java b/src/com/android/gallery3d/ui/Paper.java
new file mode 100644
index 0000000..641fc2c
--- /dev/null
+++ b/src/com/android/gallery3d/ui/Paper.java
@@ -0,0 +1,112 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.ui.PositionRepository.Position;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.opengl.Matrix;
+
+import javax.microedition.khronos.opengles.GL11;
+import javax.microedition.khronos.opengles.GL11ExtensionPack;
+
+// This class does the overscroll effect.
+class Paper {
+    private static final String TAG = "Paper";
+    private static final int ROTATE_FACTOR = 4;
+    private OverscrollAnimation mAnimationLeft = new OverscrollAnimation();
+    private OverscrollAnimation mAnimationRight = new OverscrollAnimation();
+    private int mWidth, mHeight;
+    private float[] mMatrix = new float[16];
+
+    public void overScroll(float distance) {
+        if (distance < 0) {
+            mAnimationLeft.scroll(-distance);
+        } else {
+            mAnimationRight.scroll(distance);
+        }
+    }
+
+    public boolean advanceAnimation(long currentTimeMillis) {
+        return mAnimationLeft.advanceAnimation(currentTimeMillis)
+            | mAnimationRight.advanceAnimation(currentTimeMillis);
+    }
+
+    public void setSize(int width, int height) {
+        mWidth = width;
+        mHeight = height;
+    }
+
+    public float[] getTransform(Position target, Position base,
+            float scrollX, float scrollY) {
+        float left = mAnimationLeft.getValue();
+        float right = mAnimationRight.getValue();
+        float screenX = target.x - scrollX;
+        float t = ((mWidth - screenX) * left - screenX * right) / (mWidth * mWidth);
+        // compress t to the range (-1, 1) by the function
+        // f(t) = (1 / (1 + e^-t) - 0.5) * 2
+        // then multiply by 90 to make the range (-45, 45)
+        float degrees =
+                (1 / (1 + (float) Math.exp(-t * ROTATE_FACTOR)) - 0.5f) * 2 * -45;
+        Matrix.setIdentityM(mMatrix, 0);
+        Matrix.translateM(mMatrix, 0, mMatrix, 0, base.x, base.y, base.z);
+        Matrix.rotateM(mMatrix, 0, degrees, 0, 1, 0);
+        Matrix.translateM(mMatrix, 0, mMatrix, 0,
+                target.x - base.x, target.y - base.y, target.z - base.z);
+        return mMatrix;
+    }
+}
+
+class OverscrollAnimation {
+    private static final String TAG = "OverscrollAnimation";
+    private static final long START_ANIMATION = -1;
+    private static final long NO_ANIMATION = -2;
+    private static final long ANIMATION_DURATION = 500;
+
+    private long mAnimationStartTime = NO_ANIMATION;
+    private float mVelocity;
+    private float mCurrentValue;
+
+    public void scroll(float distance) {
+        mAnimationStartTime = START_ANIMATION;
+        mCurrentValue += distance;
+    }
+
+    public boolean advanceAnimation(long currentTimeMillis) {
+        if (mAnimationStartTime == NO_ANIMATION) return false;
+        if (mAnimationStartTime == START_ANIMATION) {
+            mAnimationStartTime = currentTimeMillis;
+            return true;
+        }
+
+        long deltaTime = currentTimeMillis - mAnimationStartTime;
+        float t = deltaTime / 100f;
+        mCurrentValue *= Math.pow(0.5f, t);
+        mAnimationStartTime = currentTimeMillis;
+
+        if (mCurrentValue < 1) {
+            mAnimationStartTime = NO_ANIMATION;
+            mCurrentValue = 0;
+            return false;
+        }
+        return true;
+    }
+
+    public float getValue() {
+        return mCurrentValue;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/PhotoView.java b/src/com/android/gallery3d/ui/PhotoView.java
new file mode 100644
index 0000000..aba572b
--- /dev/null
+++ b/src/com/android/gallery3d/ui/PhotoView.java
@@ -0,0 +1,1191 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.Path;
+import com.android.gallery3d.ui.PositionRepository.Position;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.RectF;
+import android.os.Message;
+import android.os.SystemClock;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+
+public class PhotoView extends GLView {
+    @SuppressWarnings("unused")
+    private static final String TAG = "PhotoView";
+
+    public static final int INVALID_SIZE = -1;
+
+    private static final int MSG_TRANSITION_COMPLETE = 1;
+    private static final int MSG_SHOW_LOADING = 2;
+
+    private static final long DELAY_SHOW_LOADING = 250; // 250ms;
+
+    private static final int TRANS_NONE = 0;
+    private static final int TRANS_SWITCH_NEXT = 3;
+    private static final int TRANS_SWITCH_PREVIOUS = 4;
+
+    public static final int TRANS_SLIDE_IN_RIGHT = 1;
+    public static final int TRANS_SLIDE_IN_LEFT = 2;
+    public static final int TRANS_OPEN_ANIMATION = 5;
+
+    private static final int LOADING_INIT = 0;
+    private static final int LOADING_TIMEOUT = 1;
+    private static final int LOADING_COMPLETE = 2;
+    private static final int LOADING_FAIL = 3;
+
+    private static final int ENTRY_PREVIOUS = 0;
+    private static final int ENTRY_NEXT = 1;
+
+    private static final int IMAGE_GAP = 96;
+    private static final int SWITCH_THRESHOLD = 256;
+    private static final float SWIPE_THRESHOLD = 300f;
+
+    private static final float DEFAULT_TEXT_SIZE = 20;
+
+    // We try to scale up the image to fill the screen. But in order not to
+    // scale too much for small icons, we limit the max up-scaling factor here.
+    private static final float SCALE_LIMIT = 4;
+
+    public interface PhotoTapListener {
+        public void onSingleTapUp(int x, int y);
+    }
+
+    // the previous/next image entries
+    private final ScreenNailEntry mScreenNails[] = new ScreenNailEntry[2];
+
+    private final ScaleGestureDetector mScaleDetector;
+    private final GestureDetector mGestureDetector;
+    private final DownUpDetector mDownUpDetector;
+
+    private PhotoTapListener mPhotoTapListener;
+
+    private final PositionController mPositionController;
+
+    private Model mModel;
+    private StringTexture mLoadingText;
+    private StringTexture mNoThumbnailText;
+    private int mTransitionMode = TRANS_NONE;
+    private final TileImageView mTileView;
+    private Texture mVideoPlayIcon;
+
+    private boolean mShowVideoPlayIcon;
+    private ProgressSpinner mLoadingSpinner;
+
+    private SynchronizedHandler mHandler;
+
+    private int mLoadingState = LOADING_COMPLETE;
+
+    private RectF mTempRect = new RectF();
+    private float[] mTempPoints = new float[8];
+
+    private int mImageRotation;
+
+    private Path mOpenedItemPath;
+    private GalleryActivity mActivity;
+
+    public PhotoView(GalleryActivity activity) {
+        mActivity = activity;
+        mTileView = new TileImageView(activity);
+        addComponent(mTileView);
+        Context context = activity.getAndroidContext();
+        mLoadingSpinner = new ProgressSpinner(context);
+        mLoadingText = StringTexture.newInstance(
+                context.getString(R.string.loading),
+                DEFAULT_TEXT_SIZE, Color.WHITE);
+        mNoThumbnailText = StringTexture.newInstance(
+                context.getString(R.string.no_thumbnail),
+                DEFAULT_TEXT_SIZE, Color.WHITE);
+
+        mHandler = new SynchronizedHandler(activity.getGLRoot()) {
+            @Override
+            public void handleMessage(Message message) {
+                switch (message.what) {
+                    case MSG_TRANSITION_COMPLETE: {
+                        onTransitionComplete();
+                        break;
+                    }
+                    case MSG_SHOW_LOADING: {
+                        if (mLoadingState == LOADING_INIT) {
+                            // We don't need the opening animation
+                            mOpenedItemPath = null;
+
+                            mLoadingSpinner.startAnimation();
+                            mLoadingState = LOADING_TIMEOUT;
+                            invalidate();
+                        }
+                        break;
+                    }
+                    default: throw new AssertionError(message.what);
+                }
+            }
+        };
+
+        mGestureDetector = new GestureDetector(context,
+                new MyGestureListener(), null, true /* ignoreMultitouch */);
+        mScaleDetector = new ScaleGestureDetector(context, new MyScaleListener());
+        mDownUpDetector = new DownUpDetector(new MyDownUpListener());
+
+        for (int i = 0, n = mScreenNails.length; i < n; ++i) {
+            mScreenNails[i] = new ScreenNailEntry();
+        }
+
+        mPositionController = new PositionController(this);
+        mVideoPlayIcon = new ResourceTexture(context, R.drawable.ic_control_play);
+    }
+
+
+    public void setModel(Model model) {
+        if (mModel == model) return;
+        mModel = model;
+        mTileView.setModel(model);
+        if (model != null) notifyOnNewImage();
+    }
+
+    public void setPhotoTapListener(PhotoTapListener listener) {
+        mPhotoTapListener = listener;
+    }
+
+    private boolean setTileViewPosition(int centerX, int centerY, float scale) {
+        int inverseX = mPositionController.mImageW - centerX;
+        int inverseY = mPositionController.mImageH - centerY;
+        TileImageView t = mTileView;
+        int rotation = mImageRotation;
+        switch (rotation) {
+            case 0: return t.setPosition(centerX, centerY, scale, 0);
+            case 90: return t.setPosition(centerY, inverseX, scale, 90);
+            case 180: return t.setPosition(inverseX, inverseY, scale, 180);
+            case 270: return t.setPosition(inverseY, centerX, scale, 270);
+            default: throw new IllegalArgumentException(String.valueOf(rotation));
+        }
+    }
+
+    public void setPosition(int centerX, int centerY, float scale) {
+        if (setTileViewPosition(centerX, centerY, scale)) {
+            layoutScreenNails();
+        }
+    }
+
+    private void updateScreenNailEntry(int which, ImageData data) {
+        if (mTransitionMode == TRANS_SWITCH_NEXT
+                || mTransitionMode == TRANS_SWITCH_PREVIOUS) {
+            // ignore screen nail updating during switching
+            return;
+        }
+        ScreenNailEntry entry = mScreenNails[which];
+        if (data == null) {
+            entry.set(false, null, 0);
+        } else {
+            entry.set(true, data.bitmap, data.rotation);
+        }
+    }
+
+    // -1 previous, 0 current, 1 next
+    public void notifyImageInvalidated(int which) {
+        switch (which) {
+            case -1: {
+                updateScreenNailEntry(
+                        ENTRY_PREVIOUS, mModel.getPreviousImage());
+                layoutScreenNails();
+                invalidate();
+                break;
+            }
+            case 1: {
+                updateScreenNailEntry(ENTRY_NEXT, mModel.getNextImage());
+                layoutScreenNails();
+                invalidate();
+                break;
+            }
+            case 0: {
+                // mImageWidth and mImageHeight will get updated
+                mTileView.notifyModelInvalidated();
+
+                mImageRotation = mModel.getImageRotation();
+                if (((mImageRotation / 90) & 1) == 0) {
+                    mPositionController.setImageSize(
+                            mTileView.mImageWidth, mTileView.mImageHeight);
+                } else {
+                    mPositionController.setImageSize(
+                            mTileView.mImageHeight, mTileView.mImageWidth);
+                }
+                updateLoadingState();
+                break;
+            }
+        }
+    }
+
+    private void updateLoadingState() {
+        // Possible transitions of mLoadingState:
+        //        INIT --> TIMEOUT, COMPLETE, FAIL
+        //     TIMEOUT --> COMPLETE, FAIL, INIT
+        //    COMPLETE --> INIT
+        //        FAIL --> INIT
+        if (mModel.getLevelCount() != 0 || mModel.getBackupImage() != null) {
+            mHandler.removeMessages(MSG_SHOW_LOADING);
+            mLoadingState = LOADING_COMPLETE;
+        } else if (mModel.isFailedToLoad()) {
+            mHandler.removeMessages(MSG_SHOW_LOADING);
+            mLoadingState = LOADING_FAIL;
+        } else if (mLoadingState != LOADING_INIT) {
+            mLoadingState = LOADING_INIT;
+            mHandler.removeMessages(MSG_SHOW_LOADING);
+            mHandler.sendEmptyMessageDelayed(
+                    MSG_SHOW_LOADING, DELAY_SHOW_LOADING);
+        }
+    }
+
+    public void notifyModelInvalidated() {
+        if (mModel == null) {
+            updateScreenNailEntry(ENTRY_PREVIOUS, null);
+            updateScreenNailEntry(ENTRY_NEXT, null);
+        } else {
+            updateScreenNailEntry(ENTRY_PREVIOUS, mModel.getPreviousImage());
+            updateScreenNailEntry(ENTRY_NEXT, mModel.getNextImage());
+        }
+        layoutScreenNails();
+
+        if (mModel == null) {
+            mTileView.notifyModelInvalidated();
+            mImageRotation = 0;
+            mPositionController.setImageSize(0, 0);
+            updateLoadingState();
+        } else {
+            notifyImageInvalidated(0);
+        }
+    }
+
+    @Override
+    protected boolean onTouch(MotionEvent event) {
+        mGestureDetector.onTouchEvent(event);
+        mScaleDetector.onTouchEvent(event);
+        mDownUpDetector.onTouchEvent(event);
+        return true;
+    }
+
+    @Override
+    protected void onLayout(
+            boolean changeSize, int left, int top, int right, int bottom) {
+        mTileView.layout(left, top, right, bottom);
+        if (changeSize) {
+            mPositionController.setViewSize(getWidth(), getHeight());
+            for (ScreenNailEntry entry : mScreenNails) {
+                entry.updateDrawingSize();
+            }
+        }
+    }
+
+    private static int gapToSide(int imageWidth, int viewWidth) {
+        return Math.max(0, (viewWidth - imageWidth) / 2);
+    }
+
+    private RectF getImageBounds() {
+        PositionController p = mPositionController;
+        float points[] = mTempPoints;
+
+        /*
+         * (p0,p1)----------(p2,p3)
+         *   |                  |
+         *   |                  |
+         * (p4,p5)----------(p6,p7)
+         */
+        points[0] = points[4] = -p.mCurrentX;
+        points[1] = points[3] = -p.mCurrentY;
+        points[2] = points[6] = p.mImageW - p.mCurrentX;
+        points[5] = points[7] = p.mImageH - p.mCurrentY;
+
+        RectF rect = mTempRect;
+        rect.set(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY,
+                Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY);
+
+        float scale = p.mCurrentScale;
+        float offsetX = p.mViewW / 2;
+        float offsetY = p.mViewH / 2;
+        for (int i = 0; i < 4; ++i) {
+            float x = points[i + i] * scale + offsetX;
+            float y = points[i + i + 1] * scale + offsetY;
+            if (x < rect.left) rect.left = x;
+            if (x > rect.right) rect.right = x;
+            if (y < rect.top) rect.top = y;
+            if (y > rect.bottom) rect.bottom = y;
+        }
+        return rect;
+    }
+
+
+    /*
+     * Here is how we layout the screen nails
+     *
+     *  previous            current           next
+     *  ___________       ________________     __________
+     * |  _______  |     |   __________   |   |  ______  |
+     * | |       | |     |  |   right->|  |   | |      | |
+     * | |       |<-------->|<--left   |  |   | |      | |
+     * | |_______| |  |  |  |__________|  |   | |______| |
+     * |___________|  |  |________________|   |__________|
+     *                |  <--> gapToSide()
+     *                |
+     * IMAGE_GAP + Max(previous.gapToSide(), current.gapToSide)
+     */
+    private void layoutScreenNails() {
+        int width = getWidth();
+        int height = getHeight();
+
+        // Use the image width in AC, since we may fake the size if the
+        // image is unavailable
+        RectF bounds = getImageBounds();
+        int left = Math.round(bounds.left);
+        int right = Math.round(bounds.right);
+        int gap = gapToSide(right - left, width);
+
+        // layout the previous image
+        ScreenNailEntry entry = mScreenNails[ENTRY_PREVIOUS];
+
+        if (entry.isEnabled()) {
+            entry.layoutRightEdgeAt(left - (
+                    IMAGE_GAP + Math.max(gap, entry.gapToSide())));
+        }
+
+        // layout the next image
+        entry = mScreenNails[ENTRY_NEXT];
+        if (entry.isEnabled()) {
+            entry.layoutLeftEdgeAt(right + (
+                    IMAGE_GAP + Math.max(gap, entry.gapToSide())));
+        }
+    }
+
+    private static class PositionController {
+        private long mAnimationStartTime = NO_ANIMATION;
+        private static final long NO_ANIMATION = -1;
+        private static final long LAST_ANIMATION = -2;
+
+        // Animation time in milliseconds.
+        private static final float ANIM_TIME_SCROLL = 0;
+        private static final float ANIM_TIME_SCALE = 50;
+        private static final float ANIM_TIME_SNAPBACK = 600;
+        private static final float ANIM_TIME_SLIDE = 400;
+        private static final float ANIM_TIME_ZOOM = 300;
+
+        private int mAnimationKind;
+        private final static int ANIM_KIND_SCROLL = 0;
+        private final static int ANIM_KIND_SCALE = 1;
+        private final static int ANIM_KIND_SNAPBACK = 2;
+        private final static int ANIM_KIND_SLIDE = 3;
+        private final static int ANIM_KIND_ZOOM = 4;
+
+        private PhotoView mViewer;
+        private int mImageW, mImageH;
+        private int mViewW, mViewH;
+
+        // The X, Y are the coordinate on bitmap which shows on the center of
+        // the view. We always keep the mCurrent{X,Y,SCALE} sync with the actual
+        // values used currently.
+        private int mCurrentX, mFromX, mToX;
+        private int mCurrentY, mFromY, mToY;
+        private float mCurrentScale, mFromScale, mToScale;
+
+        // The offsets from the center of the view to the user's focus point,
+        // converted to the bitmap domain.
+        private float mPrevOffsetX;
+        private float mPrevOffsetY;
+        private boolean mInScale;
+        private boolean mUseViewSize = true;
+
+        // The limits for position and scale.
+        private float mScaleMin, mScaleMax = 4f;
+
+        PositionController(PhotoView viewer) {
+            mViewer = viewer;
+        }
+
+        public void setImageSize(int width, int height) {
+
+            // If no image available, use view size.
+            if (width == 0 || height == 0) {
+                mUseViewSize = true;
+                mImageW = mViewW;
+                mImageH = mViewH;
+                mCurrentX = mImageW / 2;
+                mCurrentY = mImageH / 2;
+                mCurrentScale = 1;
+                mScaleMin = 1;
+                mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale);
+                return;
+            }
+
+            mUseViewSize = false;
+
+            float ratio = Math.min(
+                    (float) mImageW / width, (float) mImageH / height);
+
+            mCurrentX = translate(mCurrentX, mImageW, width, ratio);
+            mCurrentY = translate(mCurrentY, mImageH, height, ratio);
+            mCurrentScale = mCurrentScale * ratio;
+
+            mFromX = translate(mFromX, mImageW, width, ratio);
+            mFromY = translate(mFromY, mImageH, height, ratio);
+            mFromScale = mFromScale * ratio;
+
+            mToX = translate(mToX, mImageW, width, ratio);
+            mToY = translate(mToY, mImageH, height, ratio);
+            mToScale = mToScale * ratio;
+
+            mImageW = width;
+            mImageH = height;
+
+            mScaleMin = getMinimalScale(width, height, 0);
+
+            // Scale the new image to fit into the old one
+            if (mViewer.mOpenedItemPath != null) {
+                Position position = PositionRepository
+                        .getInstance(mViewer.mActivity).get(Long.valueOf(
+                        System.identityHashCode(mViewer.mOpenedItemPath)));
+                mViewer.mOpenedItemPath = null;
+                if (position != null) {
+                    float scale = 240f / Math.min(width, height);
+                    mCurrentX = Math.round((mViewW / 2f - position.x) / scale) + mImageW / 2;
+                    mCurrentY = Math.round((mViewH / 2f - position.y) / scale) + mImageH / 2;
+                    mCurrentScale = scale;
+                    mViewer.mTransitionMode = TRANS_OPEN_ANIMATION;
+                    startSnapback();
+                }
+            } else if (mAnimationStartTime == NO_ANIMATION) {
+                mCurrentScale = Utils.clamp(mCurrentScale, mScaleMin, mScaleMax);
+            }
+            mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale);
+        }
+
+        public void zoomIn(float tapX, float tapY, float targetScale) {
+            if (targetScale > mScaleMax) targetScale = mScaleMax;
+            float scale = mCurrentScale;
+            float tempX = (tapX - mViewW / 2) / mCurrentScale + mCurrentX;
+            float tempY = (tapY - mViewH / 2) / mCurrentScale + mCurrentY;
+
+            // mCurrentX + (mViewW / 2) * (1 / targetScale) < mImageW
+            // mCurrentX - (mViewW / 2) * (1 / targetScale) > 0
+            float min = mViewW / 2.0f / targetScale;
+            float max = mImageW  - mViewW / 2.0f / targetScale;
+            int targetX = (int) Utils.clamp(tempX, min, max);
+
+            min = mViewH / 2.0f / targetScale;
+            max = mImageH  - mViewH / 2.0f / targetScale;
+            int targetY = (int) Utils.clamp(tempY,  min, max);
+
+            // If the width of the image is less then the view, center the image
+            if (mImageW * targetScale < mViewW) targetX = mImageW / 2;
+            if (mImageH * targetScale < mViewH) targetY = mImageH / 2;
+
+            startAnimation(targetX, targetY, targetScale, ANIM_KIND_ZOOM);
+        }
+
+        public void resetToFullView() {
+            startAnimation(mImageW / 2, mImageH / 2, mScaleMin, ANIM_KIND_ZOOM);
+        }
+
+        private float getMinimalScale(int w, int h, int rotation) {
+            return Math.min(SCALE_LIMIT, ((rotation / 90) & 0x01) == 0
+                    ? Math.min((float) mViewW / w, (float) mViewH / h)
+                    : Math.min((float) mViewW / h, (float) mViewH / w));
+        }
+
+        private static int translate(int value, int size, int updateSize, float ratio) {
+            return Math.round(
+                    (value + (updateSize * ratio - size) / 2f) / ratio);
+        }
+
+        public void setViewSize(int viewW, int viewH) {
+            boolean needLayout = mViewW == 0 || mViewH == 0;
+
+            mViewW = viewW;
+            mViewH = viewH;
+
+            if (mUseViewSize) {
+                mImageW = viewW;
+                mImageH = viewH;
+                mCurrentX = mImageW / 2;
+                mCurrentY = mImageH / 2;
+                mCurrentScale = 1;
+                mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale);
+            } else {
+                boolean wasMinScale = (mCurrentScale == mScaleMin);
+                mScaleMin = Math.min(SCALE_LIMIT, Math.min(
+                        (float) viewW / mImageW, (float) viewH / mImageH));
+                if (needLayout || mCurrentScale < mScaleMin || wasMinScale) {
+                    mCurrentX = mImageW / 2;
+                    mCurrentY = mImageH / 2;
+                    mCurrentScale = mScaleMin;
+                    mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale);
+                }
+            }
+        }
+
+        public void stopAnimation() {
+            mAnimationStartTime = NO_ANIMATION;
+        }
+
+        public void skipAnimation() {
+            if (mAnimationStartTime == NO_ANIMATION) return;
+            mAnimationStartTime = NO_ANIMATION;
+            mCurrentX = mToX;
+            mCurrentY = mToY;
+            mCurrentScale = mToScale;
+        }
+
+        public void scrollBy(float dx, float dy, int type) {
+            startAnimation(getTargetX() + Math.round(dx / mCurrentScale),
+                    getTargetY() + Math.round(dy / mCurrentScale),
+                    mCurrentScale, type);
+        }
+
+        public void beginScale(float focusX, float focusY) {
+            mInScale = true;
+            mPrevOffsetX = (focusX - mViewW / 2f) / mCurrentScale;
+            mPrevOffsetY = (focusY - mViewH / 2f) / mCurrentScale;
+        }
+
+        public void scaleBy(float s, float focusX, float focusY) {
+
+            // The focus point should keep this position on the ImageView.
+            // So, mCurrentX + mPrevOffsetX = mCurrentX' + offsetX.
+            // mCurrentY + mPrevOffsetY = mCurrentY' + offsetY.
+            float offsetX = (focusX - mViewW / 2f) / mCurrentScale;
+            float offsetY = (focusY - mViewH / 2f) / mCurrentScale;
+
+            startAnimation(getTargetX() - Math.round(offsetX - mPrevOffsetX),
+                           getTargetY() - Math.round(offsetY - mPrevOffsetY),
+                           getTargetScale() * s, ANIM_KIND_SCALE);
+            mPrevOffsetX = offsetX;
+            mPrevOffsetY = offsetY;
+        }
+
+        public void endScale() {
+            mInScale = false;
+            startSnapbackIfNeeded();
+        }
+
+        public void up() {
+            startSnapback();
+        }
+
+        public void startSlideInAnimation(int fromX) {
+            mFromX = Math.round(fromX + (mImageW - mViewW) / 2f);
+            mFromY = Math.round(mImageH / 2f);
+            mCurrentX = mFromX;
+            mCurrentY = mFromY;
+            startAnimation(mImageW / 2, mImageH / 2, mCurrentScale,
+                    ANIM_KIND_SLIDE);
+        }
+
+        public void startHorizontalSlide(int distance) {
+            scrollBy(distance, 0, ANIM_KIND_SLIDE);
+        }
+
+        private void startAnimation(
+                int centerX, int centerY, float scale, int kind) {
+            if (centerX == mCurrentX && centerY == mCurrentY
+                    && scale == mCurrentScale) return;
+
+            mFromX = mCurrentX;
+            mFromY = mCurrentY;
+            mFromScale = mCurrentScale;
+
+            mToX = centerX;
+            mToY = centerY;
+            mToScale = Utils.clamp(scale, 0.6f * mScaleMin, 1.4f * mScaleMax);
+
+            // If the scaled dimension is smaller than the view,
+            // force it to be in the center.
+            if (Math.floor(mImageH * mToScale) <= mViewH) {
+                mToY = mImageH / 2;
+            }
+
+            mAnimationStartTime = SystemClock.uptimeMillis();
+            mAnimationKind = kind;
+            if (advanceAnimation()) mViewer.invalidate();
+        }
+
+        // Returns true if redraw is needed.
+        public boolean advanceAnimation() {
+            if (mAnimationStartTime == NO_ANIMATION) {
+                return false;
+            } else if (mAnimationStartTime == LAST_ANIMATION) {
+                mAnimationStartTime = NO_ANIMATION;
+                if (mViewer.mTransitionMode != TRANS_NONE) {
+                    mViewer.mHandler.sendEmptyMessage(MSG_TRANSITION_COMPLETE);
+                    return false;
+                } else {
+                    return startSnapbackIfNeeded();
+                }
+            }
+
+            float animationTime;
+            if (mAnimationKind == ANIM_KIND_SCROLL) {
+                animationTime = ANIM_TIME_SCROLL;
+            } else if (mAnimationKind == ANIM_KIND_SCALE) {
+                animationTime = ANIM_TIME_SCALE;
+            } else if (mAnimationKind == ANIM_KIND_SLIDE) {
+                animationTime = ANIM_TIME_SLIDE;
+            } else if (mAnimationKind == ANIM_KIND_ZOOM) {
+                animationTime = ANIM_TIME_ZOOM;
+            } else /* if (mAnimationKind == ANIM_KIND_SNAPBACK) */ {
+                animationTime = ANIM_TIME_SNAPBACK;
+            }
+
+            float progress;
+            if (animationTime == 0) {
+                progress = 1;
+            } else {
+                long now = SystemClock.uptimeMillis();
+                progress = (now - mAnimationStartTime) / animationTime;
+            }
+
+            if (progress >= 1) {
+                progress = 1;
+                mCurrentX = mToX;
+                mCurrentY = mToY;
+                mCurrentScale = mToScale;
+                mAnimationStartTime = LAST_ANIMATION;
+            } else {
+                float f = 1 - progress;
+                if (mAnimationKind == ANIM_KIND_SCROLL) {
+                    progress = 1 - f;  // linear
+                } else if (mAnimationKind == ANIM_KIND_SCALE) {
+                    progress = 1 - f * f;  // quadratic
+                } else /* if mAnimationKind is ANIM_KIND_SNAPBACK,
+                            ANIM_KIND_ZOOM or ANIM_KIND_SLIDE */ {
+                    progress = 1 - f * f * f * f * f; // x^5
+                }
+                linearInterpolate(progress);
+            }
+            mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale);
+            return true;
+        }
+
+        private void linearInterpolate(float progress) {
+            // To linearly interpolate the position, we have to translate the
+            // coordinates. The meaning of the translated point (x, y) is the
+            // coordinates of the center of the bitmap on the view component.
+            float fromX = mViewW / 2f + (mImageW / 2f - mFromX) * mFromScale;
+            float toX = mViewW / 2f + (mImageW / 2f - mToX) * mToScale;
+            float currentX = fromX + progress * (toX - fromX);
+
+            float fromY = mViewH / 2f + (mImageH / 2f - mFromY) * mFromScale;
+            float toY = mViewH / 2f + (mImageH / 2f - mToY) * mToScale;
+            float currentY = fromY + progress * (toY - fromY);
+
+            mCurrentScale = mFromScale + progress * (mToScale - mFromScale);
+            mCurrentX = Math.round(
+                    mImageW / 2f + (mViewW / 2f - currentX) / mCurrentScale);
+            mCurrentY = Math.round(
+                    mImageH / 2f + (mViewH / 2f - currentY) / mCurrentScale);
+        }
+
+        // Returns true if redraw is needed.
+        private boolean startSnapbackIfNeeded() {
+            if (mAnimationStartTime != NO_ANIMATION) return false;
+            if (mInScale) return false;
+            if (mAnimationKind == ANIM_KIND_SCROLL && mViewer.isDown()) {
+                return false;
+            }
+            return startSnapback();
+        }
+
+        public boolean startSnapback() {
+            boolean needAnimation = false;
+            int x = mCurrentX;
+            int y = mCurrentY;
+            float scale = mCurrentScale;
+
+            if (mCurrentScale < mScaleMin || mCurrentScale > mScaleMax) {
+                needAnimation = true;
+                scale = Utils.clamp(mCurrentScale, mScaleMin, mScaleMax);
+            }
+
+            // The number of pixels when the edge is aligned.
+            int left = (int) Math.ceil(mViewW / (2 * scale));
+            int right = mImageW - left;
+            int top = (int) Math.ceil(mViewH / (2 * scale));
+            int bottom = mImageH - top;
+
+            if (mImageW * scale > mViewW) {
+                if (mCurrentX < left) {
+                    needAnimation = true;
+                    x = left;
+                } else if (mCurrentX > right) {
+                    needAnimation = true;
+                    x = right;
+                }
+            } else if (mCurrentX != mImageW / 2) {
+                needAnimation = true;
+                x = mImageW / 2;
+            }
+
+            if (mImageH * scale > mViewH) {
+                if (mCurrentY < top) {
+                    needAnimation = true;
+                    y = top;
+                } else if (mCurrentY > bottom) {
+                    needAnimation = true;
+                    y = bottom;
+                }
+            } else if (mCurrentY != mImageH / 2) {
+                needAnimation = true;
+                y = mImageH / 2;
+            }
+
+            if (needAnimation) {
+                startAnimation(x, y, scale, ANIM_KIND_SNAPBACK);
+            }
+
+            return needAnimation;
+        }
+
+        private float getTargetScale() {
+            if (mAnimationStartTime == NO_ANIMATION
+                    || mAnimationKind == ANIM_KIND_SNAPBACK) return mCurrentScale;
+            return mToScale;
+        }
+
+        private int getTargetX() {
+            if (mAnimationStartTime == NO_ANIMATION
+                    || mAnimationKind == ANIM_KIND_SNAPBACK) return mCurrentX;
+            return mToX;
+        }
+
+        private int getTargetY() {
+            if (mAnimationStartTime == NO_ANIMATION
+                    || mAnimationKind == ANIM_KIND_SNAPBACK) return mCurrentY;
+            return mToY;
+        }
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        PositionController p = mPositionController;
+
+        // Draw the current photo
+        if (mLoadingState == LOADING_COMPLETE) {
+            super.render(canvas);
+        }
+
+        // Draw the previous and the next photo
+        if (mTransitionMode != TRANS_SLIDE_IN_LEFT
+                && mTransitionMode != TRANS_SLIDE_IN_RIGHT
+                && mTransitionMode != TRANS_OPEN_ANIMATION) {
+            ScreenNailEntry prevNail = mScreenNails[ENTRY_PREVIOUS];
+            ScreenNailEntry nextNail = mScreenNails[ENTRY_NEXT];
+
+            if (prevNail.mVisible) prevNail.draw(canvas);
+            if (nextNail.mVisible) nextNail.draw(canvas);
+        }
+
+        // Draw the progress spinner and the text below it
+        //
+        // (x, y) is where we put the center of the spinner.
+        // s is the size of the video play icon, and we use s to layout text
+        // because we want to keep the text at the same place when the video
+        // play icon is shown instead of the spinner.
+        int w = getWidth();
+        int h = getHeight();
+        int x = Math.round(getImageBounds().centerX());
+        int y = h / 2;
+        int s = Math.min(getWidth(), getHeight()) / 6;
+
+        if (mLoadingState == LOADING_TIMEOUT) {
+            StringTexture m = mLoadingText;
+            ProgressSpinner r = mLoadingSpinner;
+            r.draw(canvas, x - r.getWidth() / 2, y - r.getHeight() / 2);
+            m.draw(canvas, x - m.getWidth() / 2, y + s / 2 + 5);
+            invalidate(); // we need to keep the spinner rotating
+        } else if (mLoadingState == LOADING_FAIL) {
+            StringTexture m = mNoThumbnailText;
+            m.draw(canvas, x - m.getWidth() / 2, y + s / 2 + 5);
+        }
+
+        // Draw the video play icon (in the place where the spinner was)
+        if (mShowVideoPlayIcon
+                && mLoadingState != LOADING_INIT
+                && mLoadingState != LOADING_TIMEOUT) {
+            mVideoPlayIcon.draw(canvas, x - s / 2, y - s / 2, s, s);
+        }
+
+        if (mPositionController.advanceAnimation()) invalidate();
+    }
+
+    private void stopCurrentSwipingIfNeeded() {
+        // Enable fast sweeping
+        if (mTransitionMode == TRANS_SWITCH_NEXT) {
+            mTransitionMode = TRANS_NONE;
+            mPositionController.stopAnimation();
+            switchToNextImage();
+        } else if (mTransitionMode == TRANS_SWITCH_PREVIOUS) {
+            mTransitionMode = TRANS_NONE;
+            mPositionController.stopAnimation();
+            switchToPreviousImage();
+        }
+    }
+
+    private static boolean isAlmostEquals(float a, float b) {
+        float diff = a - b;
+        return (diff < 0 ? -diff : diff) < 0.02f;
+    }
+
+    private boolean swipeImages(float velocity) {
+        if (mTransitionMode != TRANS_NONE
+                && mTransitionMode != TRANS_SWITCH_NEXT
+                && mTransitionMode != TRANS_SWITCH_PREVIOUS) return false;
+
+        ScreenNailEntry next = mScreenNails[ENTRY_NEXT];
+        ScreenNailEntry prev = mScreenNails[ENTRY_PREVIOUS];
+
+        int width = getWidth();
+
+        // If the edge of the current photo is visible and the sweeping velocity
+        // exceed the threshold, switch to next / previous image
+        PositionController controller = mPositionController;
+        if (isAlmostEquals(controller.mCurrentScale, controller.mScaleMin)) {
+            if (velocity < -SWIPE_THRESHOLD) {
+                stopCurrentSwipingIfNeeded();
+                if (next.isEnabled()) {
+                    mTransitionMode = TRANS_SWITCH_NEXT;
+                    controller.startHorizontalSlide(next.mOffsetX - width / 2);
+                    return true;
+                }
+                return false;
+            }
+            if (velocity > SWIPE_THRESHOLD) {
+                stopCurrentSwipingIfNeeded();
+                if (prev.isEnabled()) {
+                    mTransitionMode = TRANS_SWITCH_PREVIOUS;
+                    controller.startHorizontalSlide(prev.mOffsetX - width / 2);
+                    return true;
+                }
+                return false;
+            }
+        }
+
+        if (mTransitionMode != TRANS_NONE) return false;
+
+        // Decide whether to swiping to the next/prev image in the zoom-in case
+        RectF bounds = getImageBounds();
+        int left = Math.round(bounds.left);
+        int right = Math.round(bounds.right);
+        int threshold = SWITCH_THRESHOLD + gapToSide(right - left, width);
+
+        // If we have moved the picture a lot, switching.
+        if (next.isEnabled() && threshold < width - right) {
+            mTransitionMode = TRANS_SWITCH_NEXT;
+            controller.startHorizontalSlide(next.mOffsetX - width / 2);
+            return true;
+        }
+        if (prev.isEnabled() && threshold < left) {
+            mTransitionMode = TRANS_SWITCH_PREVIOUS;
+            controller.startHorizontalSlide(prev.mOffsetX - width / 2);
+            return true;
+        }
+
+        return false;
+    }
+
+    private boolean mIgnoreUpEvent = false;
+
+    private class MyGestureListener
+            extends GestureDetector.SimpleOnGestureListener {
+        @Override
+        public boolean onScroll(
+                MotionEvent e1, MotionEvent e2, float dx, float dy) {
+            if (mTransitionMode != TRANS_NONE) return true;
+            mPositionController.scrollBy(
+                    dx, dy, PositionController.ANIM_KIND_SCROLL);
+            return true;
+        }
+
+        @Override
+        public boolean onSingleTapUp(MotionEvent e) {
+            if (mPhotoTapListener != null) {
+                mPhotoTapListener.onSingleTapUp((int) e.getX(), (int) e.getY());
+            }
+            return true;
+        }
+
+        @Override
+        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
+                float velocityY) {
+            mIgnoreUpEvent = true;
+            if (!swipeImages(velocityX) && mTransitionMode == TRANS_NONE) {
+                mPositionController.up();
+            }
+            return true;
+        }
+
+        @Override
+        public boolean onDoubleTap(MotionEvent e) {
+            if (mTransitionMode != TRANS_NONE) return true;
+            PositionController controller = mPositionController;
+            float scale = controller.mCurrentScale;
+            // onDoubleTap happened on the second ACTION_DOWN.
+            // We need to ignore the next UP event.
+            mIgnoreUpEvent = true;
+            if (scale <= 1.0f || isAlmostEquals(scale, controller.mScaleMin)) {
+                controller.zoomIn(
+                        e.getX(), e.getY(), Math.max(1.5f, scale * 1.5f));
+            } else {
+                controller.resetToFullView();
+            }
+            return true;
+        }
+    }
+
+    private class MyScaleListener
+            extends ScaleGestureDetector.SimpleOnScaleGestureListener {
+
+        @Override
+        public boolean onScale(ScaleGestureDetector detector) {
+            float scale = detector.getScaleFactor();
+            if (Float.isNaN(scale) || Float.isInfinite(scale)
+                    || mTransitionMode != TRANS_NONE) return true;
+            mPositionController.scaleBy(scale,
+                    detector.getFocusX(), detector.getFocusY());
+            return true;
+        }
+
+        @Override
+        public boolean onScaleBegin(ScaleGestureDetector detector) {
+            if (mTransitionMode != TRANS_NONE) return false;
+            mPositionController.beginScale(
+                detector.getFocusX(), detector.getFocusY());
+            return true;
+        }
+
+        @Override
+        public void onScaleEnd(ScaleGestureDetector detector) {
+            mPositionController.endScale();
+            swipeImages(0);
+        }
+    }
+
+    public void notifyOnNewImage() {
+        mPositionController.setImageSize(0, 0);
+    }
+
+    public void startSlideInAnimation(int direction) {
+        PositionController a = mPositionController;
+        a.stopAnimation();
+        switch (direction) {
+            case TRANS_SLIDE_IN_LEFT: {
+                mTransitionMode = TRANS_SLIDE_IN_LEFT;
+                a.startSlideInAnimation(a.mViewW);
+                break;
+            }
+            case TRANS_SLIDE_IN_RIGHT: {
+                mTransitionMode = TRANS_SLIDE_IN_RIGHT;
+                a.startSlideInAnimation(-a.mViewW);
+                break;
+            }
+            default: throw new IllegalArgumentException(String.valueOf(direction));
+        }
+    }
+
+    private class MyDownUpListener implements DownUpDetector.DownUpListener {
+        public void onDown(MotionEvent e) {
+        }
+
+        public void onUp(MotionEvent e) {
+            if (mIgnoreUpEvent) {
+                mIgnoreUpEvent = false;
+                return;
+            }
+            if (!swipeImages(0) && mTransitionMode == TRANS_NONE) {
+                mPositionController.up();
+            }
+        }
+    }
+
+    private void switchToNextImage() {
+        // We update the texture here directly to prevent texture uploading.
+        ScreenNailEntry prevNail = mScreenNails[ENTRY_PREVIOUS];
+        ScreenNailEntry nextNail = mScreenNails[ENTRY_NEXT];
+        mTileView.invalidateTiles();
+        if (prevNail.mTexture != null) prevNail.mTexture.recycle();
+        prevNail.mTexture = mTileView.mBackupImage;
+        mTileView.mBackupImage = nextNail.mTexture;
+        nextNail.mTexture = null;
+        mModel.next();
+    }
+
+    private void switchToPreviousImage() {
+        // We update the texture here directly to prevent texture uploading.
+        ScreenNailEntry prevNail = mScreenNails[ENTRY_PREVIOUS];
+        ScreenNailEntry nextNail = mScreenNails[ENTRY_NEXT];
+        mTileView.invalidateTiles();
+        if (nextNail.mTexture != null) nextNail.mTexture.recycle();
+        nextNail.mTexture = mTileView.mBackupImage;
+        mTileView.mBackupImage = prevNail.mTexture;
+        nextNail.mTexture = null;
+        mModel.previous();
+    }
+
+    private void onTransitionComplete() {
+        int mode = mTransitionMode;
+        mTransitionMode = TRANS_NONE;
+
+        if (mModel == null) return;
+        if (mode == TRANS_SWITCH_NEXT) {
+            switchToNextImage();
+        } else if (mode == TRANS_SWITCH_PREVIOUS) {
+            switchToPreviousImage();
+        }
+    }
+
+    private boolean isDown() {
+        return mDownUpDetector.isDown();
+    }
+
+    public static interface Model extends TileImageView.Model {
+        public void next();
+        public void previous();
+        public int getImageRotation();
+
+        // Return null if the specified image is unavailable.
+        public ImageData getNextImage();
+        public ImageData getPreviousImage();
+    }
+
+    public static class ImageData {
+        public int rotation;
+        public Bitmap bitmap;
+
+        public ImageData(Bitmap bitmap, int rotation) {
+            this.bitmap = bitmap;
+            this.rotation = rotation;
+        }
+    }
+
+    private static int getRotated(int degree, int original, int theother) {
+        return ((degree / 90) & 1) == 0 ? original : theother;
+    }
+
+    private class ScreenNailEntry {
+        private boolean mVisible;
+        private boolean mEnabled;
+
+        private int mRotation;
+        private int mDrawWidth;
+        private int mDrawHeight;
+        private int mOffsetX;
+
+        private BitmapTexture mTexture;
+
+        public void set(boolean enabled, Bitmap bitmap, int rotation) {
+            mEnabled = enabled;
+            mRotation = rotation;
+            if (bitmap == null) {
+                if (mTexture != null) mTexture.recycle();
+                mTexture = null;
+            } else {
+                if (mTexture != null) {
+                    if (mTexture.getBitmap() != bitmap) {
+                        mTexture.recycle();
+                        mTexture = new BitmapTexture(bitmap);
+                    }
+                } else {
+                    mTexture = new BitmapTexture(bitmap);
+                }
+                updateDrawingSize();
+            }
+        }
+
+        public void layoutRightEdgeAt(int x) {
+            mVisible = x > 0;
+            mOffsetX = x - getRotated(
+                    mRotation, mDrawWidth, mDrawHeight) / 2;
+        }
+
+        public void layoutLeftEdgeAt(int x) {
+            mVisible = x < getWidth();
+            mOffsetX = x + getRotated(
+                    mRotation, mDrawWidth, mDrawHeight) / 2;
+        }
+
+        public int gapToSide() {
+            return ((mRotation / 90) & 1) != 0
+                    ? PhotoView.gapToSide(mDrawHeight, getWidth())
+                    : PhotoView.gapToSide(mDrawWidth, getWidth());
+        }
+
+        public void updateDrawingSize() {
+            if (mTexture == null) return;
+
+            int width = mTexture.getWidth();
+            int height = mTexture.getHeight();
+            float s = mPositionController.getMinimalScale(width, height, mRotation);
+            mDrawWidth = Math.round(width * s);
+            mDrawHeight = Math.round(height * s);
+        }
+
+        public boolean isEnabled() {
+            return mEnabled;
+        }
+
+        public void draw(GLCanvas canvas) {
+            int x = mOffsetX;
+            int y = getHeight() / 2;
+
+            if (mTexture != null) {
+                if (mRotation != 0) {
+                    canvas.save(GLCanvas.SAVE_FLAG_MATRIX);
+                    canvas.translate(x, y, 0);
+                    canvas.rotate(mRotation, 0, 0, 1); //mRotation
+                    canvas.translate(-x, -y, 0);
+                }
+                mTexture.draw(canvas, x - mDrawWidth / 2, y - mDrawHeight / 2,
+                        mDrawWidth, mDrawHeight);
+                if (mRotation != 0) {
+                    canvas.restore();
+                }
+            }
+        }
+    }
+
+    public void pause() {
+        mPositionController.skipAnimation();
+        mTransitionMode = TRANS_NONE;
+        mTileView.freeTextures();
+        for (ScreenNailEntry entry : mScreenNails) {
+            entry.set(false, null, 0);
+        }
+    }
+
+    public void resume() {
+        mTileView.prepareTextures();
+    }
+
+    public void setOpenedItem(Path itemPath) {
+        mOpenedItemPath = itemPath;
+    }
+
+    public void showVideoPlayIcon(boolean show) {
+        mShowVideoPlayIcon = show;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/PositionProvider.java b/src/com/android/gallery3d/ui/PositionProvider.java
new file mode 100644
index 0000000..930c61e
--- /dev/null
+++ b/src/com/android/gallery3d/ui/PositionProvider.java
@@ -0,0 +1,23 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.ui.PositionRepository.Position;
+
+public interface PositionProvider {
+    public Position getPosition(long identity, Position target);
+}
diff --git a/src/com/android/gallery3d/ui/PositionRepository.java b/src/com/android/gallery3d/ui/PositionRepository.java
new file mode 100644
index 0000000..0b829fa
--- /dev/null
+++ b/src/com/android/gallery3d/ui/PositionRepository.java
@@ -0,0 +1,139 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.app.GalleryActivity;
+import com.android.gallery3d.common.Utils;
+
+import java.util.HashMap;
+import java.util.WeakHashMap;
+
+public class PositionRepository {
+    private static final WeakHashMap<GalleryActivity, PositionRepository>
+            sMap = new WeakHashMap<GalleryActivity, PositionRepository>();
+
+    public static class Position implements Cloneable {
+        public float x;
+        public float y;
+        public float z;
+        public float theta;
+        public float alpha;
+
+        public Position() {
+        }
+
+        public Position(float x, float y, float z) {
+            this(x, y, z, 0f, 1f);
+        }
+
+        public Position(float x, float y, float z, float ftheta, float alpha) {
+            this.x = x;
+            this.y = y;
+            this.z = z;
+            this.theta = ftheta;
+            this.alpha = alpha;
+        }
+
+        @Override
+        public Position clone() {
+            try {
+                return (Position) super.clone();
+            } catch (CloneNotSupportedException e) {
+                throw new AssertionError(); // we do support clone.
+            }
+        }
+
+        public void set(Position another) {
+            x = another.x;
+            y = another.y;
+            z = another.z;
+            theta = another.theta;
+            alpha = another.alpha;
+        }
+
+        public void set(float x, float y, float z, float ftheta, float alpha) {
+            this.x = x;
+            this.y = y;
+            this.z = z;
+            this.theta = ftheta;
+            this.alpha = alpha;
+        }
+
+        @Override
+        public boolean equals(Object object) {
+            if (!(object instanceof Position)) return false;
+            Position position = (Position) object;
+            return x == position.x && y == position.y && z == position.z
+                    && theta == position.theta
+                    && alpha == position.alpha;
+        }
+
+        public static void interpolate(
+                Position source, Position target, Position output, float progress) {
+            if (progress < 1f) {
+                output.set(
+                        Utils.interpolateScale(source.x, target.x, progress),
+                        Utils.interpolateScale(source.y, target.y, progress),
+                        Utils.interpolateScale(source.z, target.z, progress),
+                        Utils.interpolateAngle(source.theta, target.theta, progress),
+                        Utils.interpolateScale(source.alpha, target.alpha, progress));
+            } else {
+                output.set(target);
+            }
+        }
+    }
+
+    public static PositionRepository getInstance(GalleryActivity activity) {
+        PositionRepository repository = sMap.get(activity);
+        if (repository == null) {
+            repository = new PositionRepository();
+            sMap.put(activity, repository);
+        }
+        return repository;
+    }
+
+    private HashMap<Long, Position> mData = new HashMap<Long, Position>();
+    private int mOffsetX;
+    private int mOffsetY;
+    private Position mTempPosition = new Position();
+
+    public Position get(Long identity) {
+        Position position = mData.get(identity);
+        if (position == null) return null;
+        mTempPosition.set(position);
+        position = mTempPosition;
+        position.x -= mOffsetX;
+        position.y -= mOffsetY;
+        return position;
+    }
+
+    public void setOffset(int offsetX, int offsetY) {
+        mOffsetX = offsetX;
+        mOffsetY = offsetY;
+    }
+
+    public void putPosition(Long identity, Position position) {
+        Position clone = position.clone();
+        clone.x += mOffsetX;
+        clone.y += mOffsetY;
+        mData.put(identity, clone);
+    }
+
+    public void clear() {
+        mData.clear();
+    }
+}
diff --git a/src/com/android/gallery3d/ui/ProgressBar.java b/src/com/android/gallery3d/ui/ProgressBar.java
new file mode 100644
index 0000000..c62fa9a
--- /dev/null
+++ b/src/com/android/gallery3d/ui/ProgressBar.java
@@ -0,0 +1,65 @@
+/*
+ * 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.ui;
+
+import android.content.Context;
+import android.graphics.Rect;
+
+public class ProgressBar extends GLView {
+    private final int MAX_PROGRESS = 10000;
+    private int mProgress;
+    private int mSecondaryProgress;
+    private BasicTexture mProgressTexture;
+    private BasicTexture mSecondaryProgressTexture;
+    private BasicTexture mBackgrondTexture;
+
+
+    public ProgressBar(Context context, int resProgress,
+            int resSecondaryProgress, int resBackground) {
+        mProgressTexture = new NinePatchTexture(context, resProgress);
+        mSecondaryProgressTexture = new NinePatchTexture(
+                context, resSecondaryProgress);
+        mBackgrondTexture = new NinePatchTexture(context, resBackground);
+
+    }
+
+    // The progress value is between 0 (empty) and MAX_PROGRESS (full).
+    public void setProgress(int progress) {
+        mProgress = progress;
+    }
+
+    public void setSecondaryProgress(int progress) {
+        mSecondaryProgress = progress;
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        Rect p = mPaddings;
+
+        int width = getWidth() - p.left - p.right;
+        int height = getHeight() - p.top - p.bottom;
+
+        int primary = width * mProgress / MAX_PROGRESS;
+        int secondary = width * mSecondaryProgress / MAX_PROGRESS;
+        int x = p.left;
+        int y = p.top;
+
+        canvas.drawTexture(mBackgrondTexture, x, y, width, height);
+        canvas.drawTexture(mProgressTexture, x, y, primary, height);
+        canvas.drawTexture(mSecondaryProgressTexture, x, y, secondary, height);
+    }
+}
diff --git a/src/com/android/gallery3d/ui/ProgressSpinner.java b/src/com/android/gallery3d/ui/ProgressSpinner.java
new file mode 100644
index 0000000..e4d6024
--- /dev/null
+++ b/src/com/android/gallery3d/ui/ProgressSpinner.java
@@ -0,0 +1,78 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+
+import android.content.Context;
+
+public class ProgressSpinner {
+    private static float ROTATE_SPEED_OUTER = 1080f / 3500f;
+    private static float ROTATE_SPEED_INNER = -720f / 3500f;
+    private final ResourceTexture mOuter;
+    private final ResourceTexture mInner;
+    private final int mWidth;
+    private final int mHeight;
+
+    private float mInnerDegree = 0f;
+    private float mOuterDegree = 0f;
+    private long mAnimationTimestamp = -1;
+
+    public ProgressSpinner(Context context) {
+        mOuter = new ResourceTexture(context, R.drawable.spinner_76_outer_holo);
+        mInner = new ResourceTexture(context, R.drawable.spinner_76_inner_holo);
+
+        mWidth = Math.max(mOuter.getWidth(), mInner.getWidth());
+        mHeight = Math.max(mOuter.getHeight(), mInner.getHeight());
+    }
+
+    public int getWidth() {
+        return mWidth;
+    }
+
+    public int getHeight() {
+        return mHeight;
+    }
+
+    public void startAnimation() {
+        mAnimationTimestamp = -1;
+        mOuterDegree = 0;
+        mInnerDegree = 0;
+    }
+
+    public void draw(GLCanvas canvas, int x, int y) {
+        long now = canvas.currentAnimationTimeMillis();
+        if (mAnimationTimestamp == -1) mAnimationTimestamp = now;
+        mOuterDegree += (now - mAnimationTimestamp) * ROTATE_SPEED_OUTER;
+        mInnerDegree += (now - mAnimationTimestamp) * ROTATE_SPEED_INNER;
+
+        mAnimationTimestamp = now;
+
+        // just preventing overflow
+        if (mOuterDegree > 360) mOuterDegree -= 360f;
+        if (mInnerDegree < 0) mInnerDegree += 360f;
+
+        canvas.save(GLCanvas.SAVE_FLAG_MATRIX);
+
+        canvas.translate(x + mWidth / 2, y + mHeight / 2, 0);
+        canvas.rotate(mInnerDegree, 0, 0, 1);
+        mOuter.draw(canvas, -mOuter.getWidth() / 2, -mOuter.getHeight() / 2);
+        canvas.rotate(mOuterDegree - mInnerDegree, 0, 0, 1);
+        mInner.draw(canvas, -mInner.getWidth() / 2, -mInner.getHeight() / 2);
+        canvas.restore();
+    }
+}
diff --git a/src/com/android/gallery3d/ui/RawTexture.java b/src/com/android/gallery3d/ui/RawTexture.java
new file mode 100644
index 0000000..c1be435
--- /dev/null
+++ b/src/com/android/gallery3d/ui/RawTexture.java
@@ -0,0 +1,54 @@
+/*
+ * 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.ui;
+
+import javax.microedition.khronos.opengles.GL11;
+
+// RawTexture is used for texture created by glCopyTexImage2D.
+//
+// It will throw RuntimeException in onBind() if used with a different GL
+// context. It is only used internally by copyTexture() in GLCanvas.
+class RawTexture extends BasicTexture {
+
+    private RawTexture(GLCanvas canvas, int id) {
+        super(canvas, id, STATE_LOADED);
+    }
+
+    public static RawTexture newInstance(GLCanvas canvas) {
+        int[] textureId = new int[1];
+        GL11 gl = canvas.getGLInstance();
+        gl.glGenTextures(1, textureId, 0);
+        return new RawTexture(canvas, textureId[0]);
+    }
+
+    @Override
+    protected boolean onBind(GLCanvas canvas) {
+        if (mCanvasRef.get() != canvas) {
+            throw new RuntimeException("cannot bind to different canvas");
+        }
+        return true;
+    }
+
+    public boolean isOpaque() {
+        return true;
+    }
+
+    @Override
+    public void yield() {
+        // we cannot free the texture because we have no backup.
+    }
+}
diff --git a/src/com/android/gallery3d/ui/ResourceTexture.java b/src/com/android/gallery3d/ui/ResourceTexture.java
new file mode 100644
index 0000000..08fb891
--- /dev/null
+++ b/src/com/android/gallery3d/ui/ResourceTexture.java
@@ -0,0 +1,52 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+// ResourceTexture is a texture whose Bitmap is decoded from a resource.
+// By default ResourceTexture is not opaque.
+public class ResourceTexture extends UploadedTexture {
+
+    protected final Context mContext;
+    protected final int mResId;
+
+    public ResourceTexture(Context context, int resId) {
+        mContext = Utils.checkNotNull(context);
+        mResId = resId;
+        setOpaque(false);
+    }
+
+    @Override
+    protected Bitmap onGetBitmap() {
+        BitmapFactory.Options options = new BitmapFactory.Options();
+        options.inPreferredConfig = Bitmap.Config.ARGB_8888;
+        return BitmapFactory.decodeResource(
+                mContext.getResources(), mResId, options);
+    }
+
+    @Override
+    protected void onFreeBitmap(Bitmap bitmap) {
+        if (!inFinalizer()) {
+            bitmap.recycle();
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/ScrollBarView.java b/src/com/android/gallery3d/ui/ScrollBarView.java
new file mode 100644
index 0000000..7e375c9
--- /dev/null
+++ b/src/com/android/gallery3d/ui/ScrollBarView.java
@@ -0,0 +1,135 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+
+import android.content.Context;
+import android.graphics.Rect;
+
+public class ScrollBarView extends GLView {
+    @SuppressWarnings("unused")
+    private static final String TAG = "ScrollBarView";
+
+    public interface Listener {
+        void onScrollBarPositionChanged(int position);
+    }
+
+    private int mBarHeight;
+
+    private int mGripHeight;
+    private int mGripPosition;  // left side of the grip
+    private int mGripWidth;     // zero if the grip is disabled
+    private int mGivenGripWidth;
+
+    private int mContentPosition;
+    private int mContentTotal;
+
+    private Listener mListener;
+    private NinePatchTexture mScrollBarTexture;
+
+    public ScrollBarView(Context context, int gripHeight, int gripWidth) {
+        mScrollBarTexture = new NinePatchTexture(
+                context, R.drawable.scrollbar_handle_holo_dark);
+        mGripPosition = 0;
+        mGripWidth = 0;
+        mGivenGripWidth = gripWidth;
+        mGripHeight = gripHeight;
+    }
+
+    public void setListener(Listener listener) {
+        mListener = listener;
+    }
+
+    @Override
+    protected void onLayout(
+            boolean changed, int left, int top, int right, int bottom) {
+        if (!changed) return;
+        mBarHeight = bottom - top;
+    }
+
+    // The content position is between 0 to "total". The current position is
+    // in "position".
+    public void setContentPosition(int position, int total) {
+        if (position == mContentPosition && total == mContentTotal) {
+            return;
+        }
+
+        invalidate();
+
+        mContentPosition = position;
+        mContentTotal = total;
+
+        // If the grip cannot move, don't draw it.
+        if (mContentTotal <= 0) {
+            mGripPosition = 0;
+            mGripWidth = 0;
+            return;
+        }
+
+        // Map from the content range to scroll bar range.
+        //
+        // mContentTotal --> getWidth() - mGripWidth
+        // mContentPosition --> mGripPosition
+        mGripWidth = mGivenGripWidth;
+        float r = (getWidth() - mGripWidth) / (float) mContentTotal;
+        mGripPosition = Math.round(r * mContentPosition);
+    }
+
+    private void notifyContentPositionFromGrip() {
+        if (mContentTotal <= 0) return;
+        float r = (getWidth() - mGripWidth) / (float) mContentTotal;
+        int newContentPosition = Math.round(mGripPosition / r);
+        mListener.onScrollBarPositionChanged(newContentPosition);
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        super.render(canvas);
+        if (mGripWidth == 0) return;
+        Rect b = bounds();
+        int y = (mBarHeight - mGripHeight) / 2;
+        mScrollBarTexture.draw(canvas, mGripPosition, y, mGripWidth, mGripHeight);
+    }
+
+    // The onTouch() handler is disabled because now we don't want the user
+    // to drag the bar (it's an indicator only).
+    /*
+    @Override
+    protected boolean onTouch(MotionEvent event) {
+        switch (event.getAction()) {
+            case MotionEvent.ACTION_DOWN: {
+                int x = (int) event.getX();
+                return (x >= mGripPosition && x < mGripPosition + mGripWidth);
+            }
+            case MotionEvent.ACTION_MOVE: {
+                // Adjust x by mGripWidth / 2 so the center of the grip
+                // matches the touch position.
+                int x = (int) event.getX() - mGripWidth / 2;
+                x = Utils.clamp(x, 0, getWidth() - mGripWidth);
+                if (mGripPosition != x) {
+                    mGripPosition = x;
+                    notifyContentPositionFromGrip();
+                    invalidate();
+                }
+                break;
+            }
+        }
+        return true;
+    }
+    */
+}
diff --git a/src/com/android/gallery3d/ui/ScrollView.java b/src/com/android/gallery3d/ui/ScrollView.java
new file mode 100644
index 0000000..f762833
--- /dev/null
+++ b/src/com/android/gallery3d/ui/ScrollView.java
@@ -0,0 +1,99 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.common.Utils;
+
+import android.content.Context;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.View.MeasureSpec;
+
+// The current implementation can only scroll vertically.
+public class ScrollView extends GLView {
+
+    private static final int MIN_SCROLLER_HEIGHT = 20;
+
+    private NinePatchTexture mScroller;
+    private int mScrollLimit = 0;
+    private int mScrollerHeight = MIN_SCROLLER_HEIGHT;
+    private GestureDetector mGestureDetector;
+
+    public ScrollView(Context context) {
+        mScroller = new NinePatchTexture(context, R.drawable.scrollbar_handle_holo_dark);
+        mGestureDetector = new GestureDetector(context, new MyGestureListener());
+    }
+
+    private GLView getContentView() {
+        return getComponentCount() == 0 ? null : getComponent(0);
+    }
+
+    @Override
+    public void onLayout(boolean sizeChange, int l, int t, int r, int b) {
+        GLView content = getContentView();
+        int width = getWidth();
+        int height = getHeight();
+        content.measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
+                MeasureSpec.UNSPECIFIED);
+        int contentHeight = content.getMeasuredHeight();
+        content.layout(0, 0, width, contentHeight);
+        if (height < contentHeight) {
+            mScrollLimit = contentHeight - height;
+            mScrollerHeight = Math.max(MIN_SCROLLER_HEIGHT,
+                    height * height / contentHeight);
+        } else {
+            mScrollLimit = 0;
+        }
+        mScrollY = Utils.clamp(mScrollY, 0, mScrollLimit);
+    }
+
+    @Override
+    public void render(GLCanvas canvas) {
+        GLView content = getContentView();
+        if (content == null) return;
+        int width = getWidth();
+        int height = getHeight();
+
+        canvas.save(GLCanvas.SAVE_FLAG_CLIP);
+        canvas.clipRect(0, 0, width, height);
+        super.render(canvas);
+        if (mScrollLimit > 0) {
+            int x = getWidth() - mScroller.getWidth();
+            int y = (height - mScrollerHeight) * mScrollY / mScrollLimit;
+            mScroller.draw(canvas, x, y, mScroller.getWidth(), mScrollerHeight);
+        }
+        canvas.restore();
+    }
+
+    @Override
+    public boolean onTouch(MotionEvent event) {
+        mGestureDetector.onTouchEvent(event);
+        return true;
+    }
+
+    private class MyGestureListener
+            extends GestureDetector.SimpleOnGestureListener {
+        @Override
+        public boolean onScroll(MotionEvent e1,
+                MotionEvent e2, float distanceX, float distanceY) {
+            mScrollY = Utils.clamp(mScrollY + (int) distanceY, 0, mScrollLimit);
+            invalidate();
+            return true;
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/ScrollerHelper.java b/src/com/android/gallery3d/ui/ScrollerHelper.java
new file mode 100644
index 0000000..9f19cec
--- /dev/null
+++ b/src/com/android/gallery3d/ui/ScrollerHelper.java
@@ -0,0 +1,93 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+
+import android.content.Context;
+import android.view.ViewConfiguration;
+import android.widget.OverScroller;
+
+public class ScrollerHelper {
+    private OverScroller mScroller;
+    private int mOverflingDistance;
+    private boolean mOverflingEnabled;
+
+    public ScrollerHelper(Context context) {
+        mScroller = new OverScroller(context);
+        ViewConfiguration configuration = ViewConfiguration.get(context);
+        mOverflingDistance = configuration.getScaledOverflingDistance();
+    }
+
+    public void setOverfling(boolean enabled) {
+        mOverflingEnabled = enabled;
+    }
+
+    /**
+     * Call this when you want to know the new location. The position will be
+     * updated and can be obtained by getPosition(). Returns true if  the
+     * animation is not yet finished.
+     */
+    public boolean advanceAnimation(long currentTimeMillis) {
+        return mScroller.computeScrollOffset();
+    }
+
+    public boolean isFinished() {
+        return mScroller.isFinished();
+    }
+
+    public void forceFinished() {
+        mScroller.forceFinished(true);
+    }
+
+    public int getPosition() {
+        return mScroller.getCurrX();
+    }
+
+    public void setPosition(int position) {
+        mScroller.startScroll(
+                position, 0,    // startX, startY
+                0, 0, 0);       // dx, dy, duration
+
+        // This forces the scroller to reach the final position.
+        mScroller.abortAnimation();
+    }
+
+    public void fling(int velocity, int min, int max) {
+        int currX = getPosition();
+        mScroller.fling(
+                currX, 0,      // startX, startY
+                velocity, 0,   // velocityX, velocityY
+                min, max,      // minX, maxX
+                0, 0,          // minY, maxY
+                mOverflingEnabled ? mOverflingDistance : 0, 0);
+    }
+
+    public boolean startScroll(int distance, int min, int max) {
+        int currPosition = mScroller.getCurrX();
+        int finalPosition = mScroller.getFinalX();
+        int newPosition = Utils.clamp(finalPosition + distance, min, max);
+        if (newPosition != currPosition) {
+            mScroller.startScroll(
+                currPosition, 0,                    // startX, startY
+                newPosition - currPosition, 0, 0);  // dx, dy, duration
+            return true;
+        } else {
+            return false;
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/SelectionDrawer.java b/src/com/android/gallery3d/ui/SelectionDrawer.java
new file mode 100644
index 0000000..2655a22
--- /dev/null
+++ b/src/com/android/gallery3d/ui/SelectionDrawer.java
@@ -0,0 +1,89 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.Path;
+
+import android.graphics.Rect;
+
+/**
+ * Drawer class responsible for drawing selectable frame.
+ */
+public abstract class SelectionDrawer {
+    public static final int DATASOURCE_TYPE_NOT_CATEGORIZED = 0;
+    public static final int DATASOURCE_TYPE_LOCAL = 1;
+    public static final int DATASOURCE_TYPE_PICASA = 2;
+    public static final int DATASOURCE_TYPE_MTP = 3;
+    public static final int DATASOURCE_TYPE_CAMERA = 4;
+
+    public abstract void prepareDrawing();
+    public abstract void draw(GLCanvas canvas, Texture content,
+            int width, int height, int rotation, Path path,
+            int topIndex, int dataSourceType, int mediaType,
+            boolean wantCache, boolean isCaching);
+    public abstract void drawFocus(GLCanvas canvas, int width, int height);
+
+    public void draw(GLCanvas canvas, Texture content, int width, int height,
+            int rotation, Path path, int mediaType) {
+        draw(canvas, content, width, height, rotation, path, 0,
+                DATASOURCE_TYPE_NOT_CATEGORIZED, mediaType,
+                false, false);
+    }
+
+    public static void drawWithRotation(GLCanvas canvas, Texture content,
+            int x, int y, int width, int height, int rotation) {
+        if (rotation != 0) {
+            canvas.save(GLCanvas.SAVE_FLAG_MATRIX);
+            canvas.rotate(rotation, 0, 0, 1);
+        }
+
+        content.draw(canvas, x, y, width, height);
+
+        if (rotation != 0) {
+            canvas.restore();
+        }
+    }
+
+    public static void drawWithRotationAndGray(GLCanvas canvas, Texture content,
+                int x, int y, int width, int height, int rotation,
+                int topIndex) {
+        if (rotation != 0) {
+            canvas.save(GLCanvas.SAVE_FLAG_MATRIX);
+            canvas.rotate(rotation, 0, 0, 1);
+        }
+
+        if (topIndex > 0 && (content instanceof BasicTexture)) {
+            float ratio = Utils.clamp(0.3f + 0.2f * topIndex, 0f, 1f);
+            canvas.drawMixed((BasicTexture) content, 0xFF222222, ratio,
+                    x, y, width, height);
+        } else {
+            content.draw(canvas, x, y, width, height);
+        }
+
+        if (rotation != 0) {
+            canvas.restore();
+        }
+    }
+
+    public static void drawFrame(GLCanvas canvas, NinePatchTexture frame,
+            int x, int y, int width, int height) {
+        Rect p = frame.getPaddings();
+        frame.draw(canvas, x - p.left, y - p.top, width + p.left + p.right,
+                 height + p.top + p.bottom);
+    }
+}
diff --git a/src/com/android/gallery3d/ui/SelectionManager.java b/src/com/android/gallery3d/ui/SelectionManager.java
new file mode 100644
index 0000000..b85ca7a
--- /dev/null
+++ b/src/com/android/gallery3d/ui/SelectionManager.java
@@ -0,0 +1,221 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.app.GalleryContext;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.MediaSet;
+import com.android.gallery3d.data.Path;
+
+import android.content.Context;
+import android.os.Vibrator;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Set;
+
+public class SelectionManager {
+    @SuppressWarnings("unused")
+    private static final String TAG = "SelectionManager";
+
+    public static final int ENTER_SELECTION_MODE = 1;
+    public static final int LEAVE_SELECTION_MODE = 2;
+    public static final int SELECT_ALL_MODE = 3;
+
+    private Set<Path> mClickedSet;
+    private MediaSet mSourceMediaSet;
+    private final Vibrator mVibrator;
+    private SelectionListener mListener;
+    private DataManager mDataManager;
+    private boolean mInverseSelection;
+    private boolean mIsAlbumSet;
+    private boolean mInSelectionMode;
+    private boolean mAutoLeave = true;
+    private int mTotal;
+
+    public interface SelectionListener {
+        public void onSelectionModeChange(int mode);
+        public void onSelectionChange(Path path, boolean selected);
+    }
+
+    public SelectionManager(GalleryContext galleryContext, boolean isAlbumSet) {
+        Context context = galleryContext.getAndroidContext();
+        mDataManager = galleryContext.getDataManager();
+        mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
+        mClickedSet = new HashSet<Path>();
+        mIsAlbumSet = isAlbumSet;
+        mTotal = -1;
+    }
+
+    // Whether we will leave selection mode automatically once the number of
+    // selected items is down to zero.
+    public void setAutoLeaveSelectionMode(boolean enable) {
+        mAutoLeave = enable;
+    }
+
+    public void setSelectionListener(SelectionListener listener) {
+        mListener = listener;
+    }
+
+    public void selectAll() {
+        enterSelectionMode();
+        mInverseSelection = true;
+        mClickedSet.clear();
+        if (mListener != null) mListener.onSelectionModeChange(SELECT_ALL_MODE);
+    }
+
+    public void deSelectAll() {
+        leaveSelectionMode();
+        mInverseSelection = false;
+        mClickedSet.clear();
+    }
+
+    public boolean inSelectAllMode() {
+        return mInverseSelection;
+    }
+
+    public boolean inSelectionMode() {
+        return mInSelectionMode;
+    }
+
+    public void enterSelectionMode() {
+        if (mInSelectionMode) return;
+
+        mInSelectionMode = true;
+        mVibrator.vibrate(100);
+        if (mListener != null) mListener.onSelectionModeChange(ENTER_SELECTION_MODE);
+    }
+
+    public void leaveSelectionMode() {
+        if (!mInSelectionMode) return;
+
+        mInSelectionMode = false;
+        mInverseSelection = false;
+        mClickedSet.clear();
+        if (mListener != null) mListener.onSelectionModeChange(LEAVE_SELECTION_MODE);
+    }
+
+    public boolean isItemSelected(Path itemId) {
+        return mInverseSelection ^ mClickedSet.contains(itemId);
+    }
+
+    public int getSelectedCount() {
+        int count = mClickedSet.size();
+        if (mInverseSelection) {
+            if (mTotal < 0) {
+                mTotal = mIsAlbumSet
+                        ? mSourceMediaSet.getSubMediaSetCount()
+                        : mSourceMediaSet.getMediaItemCount();
+            }
+            count = mTotal - count;
+        }
+        return count;
+    }
+
+    public void toggle(Path path) {
+        if (mClickedSet.contains(path)) {
+            mClickedSet.remove(path);
+        } else {
+            enterSelectionMode();
+            mClickedSet.add(path);
+        }
+
+        if (mListener != null) mListener.onSelectionChange(path, isItemSelected(path));
+        if (getSelectedCount() == 0 && mAutoLeave) {
+            leaveSelectionMode();
+        }
+    }
+
+    private static void expandMediaSet(ArrayList<Path> items, MediaSet set) {
+        int subCount = set.getSubMediaSetCount();
+        for (int i = 0; i < subCount; i++) {
+            expandMediaSet(items, set.getSubMediaSet(i));
+        }
+        int total = set.getMediaItemCount();
+        int batch = 50;
+        int index = 0;
+
+        while (index < total) {
+            int count = index + batch < total
+                    ? batch
+                    : total - index;
+            ArrayList<MediaItem> list = set.getMediaItem(index, count);
+            for (MediaItem item : list) {
+                items.add(item.getPath());
+            }
+            index += batch;
+        }
+    }
+
+    public ArrayList<Path> getSelected(boolean expandSet) {
+        ArrayList<Path> selected = new ArrayList<Path>();
+        if (mIsAlbumSet) {
+            if (mInverseSelection) {
+                int max = mSourceMediaSet.getSubMediaSetCount();
+                for (int i = 0; i < max; i++) {
+                    MediaSet set = mSourceMediaSet.getSubMediaSet(i);
+                    Path id = set.getPath();
+                    if (!mClickedSet.contains(id)) {
+                        if (expandSet) {
+                            expandMediaSet(selected, set);
+                        } else {
+                            selected.add(id);
+                        }
+                    }
+                }
+            } else {
+                for (Path id : mClickedSet) {
+                    if (expandSet) {
+                        expandMediaSet(selected, mDataManager.getMediaSet(id));
+                    } else {
+                        selected.add(id);
+                    }
+                }
+            }
+        } else {
+            if (mInverseSelection) {
+
+                int total = mSourceMediaSet.getMediaItemCount();
+                int index = 0;
+                while (index < total) {
+                    int count = Math.min(total - index, MediaSet.MEDIAITEM_BATCH_FETCH_COUNT);
+                    ArrayList<MediaItem> list = mSourceMediaSet.getMediaItem(index, count);
+                    for (MediaItem item : list) {
+                        Path id = item.getPath();
+                        if (!mClickedSet.contains(id)) selected.add(id);
+                    }
+                    index += count;
+                }
+            } else {
+                for (Path id : mClickedSet) {
+                    selected.add(id);
+                }
+            }
+        }
+        return selected;
+    }
+
+    public void setSourceMediaSet(MediaSet set) {
+        mSourceMediaSet = set;
+        mTotal = -1;
+    }
+
+    public MediaSet getSourceMediaSet() {
+        return mSourceMediaSet;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/SlideshowView.java b/src/com/android/gallery3d/ui/SlideshowView.java
new file mode 100644
index 0000000..79a6bf0
--- /dev/null
+++ b/src/com/android/gallery3d/ui/SlideshowView.java
@@ -0,0 +1,165 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.anim.CanvasAnimation;
+import com.android.gallery3d.anim.FloatAnimation;
+
+import android.graphics.Bitmap;
+import android.graphics.PointF;
+
+import java.util.Random;
+import javax.microedition.khronos.opengles.GL11;
+
+public class SlideshowView extends GLView {
+    @SuppressWarnings("unused")
+    private static final String TAG = "SlideshowView";
+
+    private static final int SLIDESHOW_DURATION = 3500;
+    private static final int TRANSITION_DURATION = 1000;
+
+    private static final float SCALE_SPEED = 0.20f ;
+    private static final float MOVE_SPEED = SCALE_SPEED;
+
+    private int mCurrentRotation;
+    private BitmapTexture mCurrentTexture;
+    private SlideshowAnimation mCurrentAnimation;
+
+    private int mPrevRotation;
+    private BitmapTexture mPrevTexture;
+    private SlideshowAnimation mPrevAnimation;
+
+    private final FloatAnimation mTransitionAnimation =
+            new FloatAnimation(0, 1, TRANSITION_DURATION);
+
+    private Random mRandom = new Random();
+
+    public void next(Bitmap bitmap, int rotation) {
+
+        mTransitionAnimation.start();
+
+        if (mPrevTexture != null) {
+            mPrevTexture.getBitmap().recycle();
+            mPrevTexture.recycle();
+        }
+
+        mPrevTexture = mCurrentTexture;
+        mPrevAnimation = mCurrentAnimation;
+        mPrevRotation = mCurrentRotation;
+
+        mCurrentRotation = rotation;
+        mCurrentTexture = new BitmapTexture(bitmap);
+        if (((rotation / 90) & 0x01) == 0) {
+            mCurrentAnimation = new SlideshowAnimation(
+                    mCurrentTexture.getWidth(), mCurrentTexture.getHeight(),
+                    mRandom);
+        } else {
+            mCurrentAnimation = new SlideshowAnimation(
+                    mCurrentTexture.getHeight(), mCurrentTexture.getWidth(),
+                    mRandom);
+        }
+        mCurrentAnimation.start();
+
+        invalidate();
+    }
+
+    public void release() {
+        if (mPrevTexture != null) {
+            mPrevTexture.recycle();
+            mPrevTexture = null;
+        }
+        if (mCurrentTexture != null) {
+            mCurrentTexture.recycle();
+            mCurrentTexture = null;
+        }
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        long currentTimeMillis = canvas.currentAnimationTimeMillis();
+        boolean requestRender = mTransitionAnimation.calculate(currentTimeMillis);
+        GL11 gl = canvas.getGLInstance();
+        gl.glBlendFunc(GL11.GL_ONE, GL11.GL_ONE);
+        float alpha = mPrevTexture == null ? 1f : mTransitionAnimation.get();
+
+        if (mPrevTexture != null && alpha != 1f) {
+            requestRender |= mPrevAnimation.calculate(currentTimeMillis);
+            canvas.save(GLCanvas.SAVE_FLAG_ALPHA | GLCanvas.SAVE_FLAG_MATRIX);
+            canvas.setAlpha(1f - alpha);
+            mPrevAnimation.apply(canvas);
+            canvas.rotate(mPrevRotation, 0, 0, 1);
+            mPrevTexture.draw(canvas, -mPrevTexture.getWidth() / 2,
+                    -mPrevTexture.getHeight() / 2);
+            canvas.restore();
+        }
+        if (mCurrentTexture != null) {
+            requestRender |= mCurrentAnimation.calculate(currentTimeMillis);
+            canvas.save(GLCanvas.SAVE_FLAG_ALPHA | GLCanvas.SAVE_FLAG_MATRIX);
+            canvas.setAlpha(alpha);
+            mCurrentAnimation.apply(canvas);
+            canvas.rotate(mCurrentRotation, 0, 0, 1);
+            mCurrentTexture.draw(canvas, -mCurrentTexture.getWidth() / 2,
+                    -mCurrentTexture.getHeight() / 2);
+            canvas.restore();
+        }
+        if (requestRender) invalidate();
+        gl.glBlendFunc(GL11.GL_ONE, GL11.GL_ONE_MINUS_SRC_ALPHA);
+    }
+
+    private class SlideshowAnimation extends CanvasAnimation {
+        private final int mWidth;
+        private final int mHeight;
+
+        private final PointF mMovingVector;
+        private float mProgress;
+
+        public SlideshowAnimation(int width, int height, Random random) {
+            mWidth = width;
+            mHeight = height;
+            mMovingVector = new PointF(
+                    MOVE_SPEED * mWidth * (random.nextFloat() - 0.5f),
+                    MOVE_SPEED * mHeight * (random.nextFloat() - 0.5f));
+            setDuration(SLIDESHOW_DURATION);
+        }
+
+        @Override
+        public void apply(GLCanvas canvas) {
+            int viewWidth = getWidth();
+            int viewHeight = getHeight();
+
+            float initScale = Math.min(2f, Math.min((float)
+                    viewWidth / mWidth, (float) viewHeight / mHeight));
+            float scale = initScale * (1 + SCALE_SPEED * mProgress);
+
+            float centerX = viewWidth / 2 + mMovingVector.x * mProgress;
+            float centerY = viewHeight / 2 + mMovingVector.y * mProgress;
+
+            canvas.translate(centerX, centerY, 0);
+            canvas.scale(scale, scale, 0);
+        }
+
+        @Override
+        public int getCanvasSaveFlags() {
+            return GLCanvas.SAVE_FLAG_MATRIX;
+        }
+
+        @Override
+        protected void onCalculate(float progress) {
+            mProgress = progress;
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/SlotView.java b/src/com/android/gallery3d/ui/SlotView.java
new file mode 100644
index 0000000..a8ca5f2
--- /dev/null
+++ b/src/com/android/gallery3d/ui/SlotView.java
@@ -0,0 +1,607 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.anim.Animation;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.ui.PositionRepository.Position;
+import com.android.gallery3d.util.LinkedNode;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.animation.DecelerateInterpolator;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+
+public class SlotView extends GLView {
+    @SuppressWarnings("unused")
+    private static final String TAG = "SlotView";
+
+    private static final boolean WIDE = true;
+
+    private static final int INDEX_NONE = -1;
+
+    public interface Listener {
+        public void onSingleTapUp(int index);
+        public void onLongTap(int index);
+        public void onScrollPositionChanged(int position, int total);
+    }
+
+    public static class SimpleListener implements Listener {
+        public void onSingleTapUp(int index) {}
+        public void onLongTap(int index) {}
+        public void onScrollPositionChanged(int position, int total) {}
+    }
+
+    private final GestureDetector mGestureDetector;
+    private final ScrollerHelper mScroller;
+    private final Paper mPaper = new Paper();
+
+    private Listener mListener;
+    private UserInteractionListener mUIListener;
+
+    // Use linked hash map to keep the rendering order
+    private HashMap<DisplayItem, ItemEntry> mItems =
+            new HashMap<DisplayItem, ItemEntry>();
+
+    public LinkedNode.List<ItemEntry> mItemList = LinkedNode.newList();
+
+    // This is used for multipass rendering
+    private ArrayList<ItemEntry> mCurrentItems = new ArrayList<ItemEntry>();
+    private ArrayList<ItemEntry> mNextItems = new ArrayList<ItemEntry>();
+
+    private boolean mMoreAnimation = false;
+    private MyAnimation mAnimation = null;
+    private final Position mTempPosition = new Position();
+    private final Layout mLayout = new Layout();
+    private PositionProvider mPositions;
+    private int mStartIndex = INDEX_NONE;
+
+    // whether the down action happened while the view is scrolling.
+    private boolean mDownInScrolling;
+    private int mOverscrollEffect = OVERSCROLL_3D;
+
+    public static final int OVERSCROLL_3D = 0;
+    public static final int OVERSCROLL_SYSTEM = 1;
+    public static final int OVERSCROLL_NONE = 2;
+
+    public SlotView(Context context) {
+        mGestureDetector =
+                new GestureDetector(context, new MyGestureListener());
+        mScroller = new ScrollerHelper(context);
+    }
+
+    public void setCenterIndex(int index) {
+        int slotCount = mLayout.mSlotCount;
+        if (index < 0 || index >= slotCount) {
+            return;
+        }
+        Rect rect = mLayout.getSlotRect(index);
+        int position = WIDE
+                ? (rect.left + rect.right - getWidth()) / 2
+                : (rect.top + rect.bottom - getHeight()) / 2;
+        setScrollPosition(position);
+    }
+
+    public void makeSlotVisible(int index) {
+        Rect rect = mLayout.getSlotRect(index);
+        int visibleBegin = WIDE ? mScrollX : mScrollY;
+        int visibleLength = WIDE ? getWidth() : getHeight();
+        int visibleEnd = visibleBegin + visibleLength;
+        int slotBegin = WIDE ? rect.left : rect.top;
+        int slotEnd = WIDE ? rect.right : rect.bottom;
+
+        int position = visibleBegin;
+        if (visibleLength < slotEnd - slotBegin) {
+            position = visibleBegin;
+        } else if (slotBegin < visibleBegin) {
+            position = slotBegin;
+        } else if (slotEnd > visibleEnd) {
+            position = slotEnd - visibleLength;
+        }
+
+        setScrollPosition(position);
+    }
+
+    public void setScrollPosition(int position) {
+        position = Utils.clamp(position, 0, mLayout.getScrollLimit());
+        mScroller.setPosition(position);
+        updateScrollPosition(position, false);
+    }
+
+    public void setSlotSize(int slotWidth, int slotHeight) {
+        mLayout.setSlotSize(slotWidth, slotHeight);
+    }
+
+    @Override
+    public void addComponent(GLView view) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean removeComponent(GLView view) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    protected void onLayout(boolean changeSize, int l, int t, int r, int b) {
+        if (!changeSize) return;
+        mLayout.setSize(r - l, b - t);
+        onLayoutChanged(r - l, b - t);
+        if (mOverscrollEffect == OVERSCROLL_3D) {
+            mPaper.setSize(r - l, b - t);
+        }
+    }
+
+    protected void onLayoutChanged(int width, int height) {
+    }
+
+    public void startTransition(PositionProvider position) {
+        mPositions = position;
+        mAnimation = new MyAnimation();
+        mAnimation.start();
+        if (mItems.size() != 0) invalidate();
+    }
+
+    public void savePositions(PositionRepository repository) {
+        repository.clear();
+        LinkedNode.List<ItemEntry> list = mItemList;
+        ItemEntry entry = list.getFirst();
+        Position position = new Position();
+        while (entry != null) {
+            position.set(entry.target);
+            position.x -= mScrollX;
+            position.y -= mScrollY;
+            repository.putPosition(entry.item.getIdentity(), position);
+            entry = list.nextOf(entry);
+        }
+    }
+
+    private void updateScrollPosition(int position, boolean force) {
+        if (!force && (WIDE ? position == mScrollX : position == mScrollY)) return;
+        if (WIDE) {
+            mScrollX = position;
+        } else {
+            mScrollY = position;
+        }
+        mLayout.setScrollPosition(position);
+        onScrollPositionChanged(position);
+    }
+
+    protected void onScrollPositionChanged(int newPosition) {
+        int limit = mLayout.getScrollLimit();
+        mListener.onScrollPositionChanged(newPosition, limit);
+    }
+
+    public void putDisplayItem(Position target, Position base, DisplayItem item) {
+        ItemEntry entry = new ItemEntry(item, target, base);
+        mItemList.insertLast(entry);
+        mItems.put(item, entry);
+    }
+
+    public void removeDisplayItem(DisplayItem item) {
+        ItemEntry entry = mItems.remove(item);
+        if (entry != null) entry.remove();
+    }
+
+    public Rect getSlotRect(int slotIndex) {
+        return mLayout.getSlotRect(slotIndex);
+    }
+
+    @Override
+    protected boolean onTouch(MotionEvent event) {
+        if (mUIListener != null) mUIListener.onUserInteraction();
+        mGestureDetector.onTouchEvent(event);
+        switch (event.getAction()) {
+            case MotionEvent.ACTION_DOWN:
+                mDownInScrolling = !mScroller.isFinished();
+                mScroller.forceFinished();
+                break;
+        }
+        return true;
+    }
+
+    public void setListener(Listener listener) {
+        mListener = listener;
+    }
+
+    public void setUserInteractionListener(UserInteractionListener listener) {
+        mUIListener = listener;
+    }
+
+    public void setOverscrollEffect(int kind) {
+        mOverscrollEffect = kind;
+        mScroller.setOverfling(kind == OVERSCROLL_SYSTEM);
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        canvas.save(GLCanvas.SAVE_FLAG_CLIP);
+        canvas.clipRect(0, 0, getWidth(), getHeight());
+        super.render(canvas);
+
+        long currentTimeMillis = canvas.currentAnimationTimeMillis();
+        boolean more = mScroller.advanceAnimation(currentTimeMillis);
+        boolean paperActive = (mOverscrollEffect == OVERSCROLL_3D)
+                && mPaper.advanceAnimation(currentTimeMillis);
+        updateScrollPosition(mScroller.getPosition(), false);
+        float interpolate = 1f;
+        if (mAnimation != null) {
+            more |= mAnimation.calculate(currentTimeMillis);
+            interpolate = mAnimation.value;
+        }
+
+        more |= paperActive;
+
+        if (WIDE) {
+            canvas.translate(-mScrollX, 0, 0);
+        } else {
+            canvas.translate(0, -mScrollY, 0);
+        }
+
+        LinkedNode.List<ItemEntry> list = mItemList;
+        for (ItemEntry entry = list.getLast(); entry != null;) {
+            if (renderItem(canvas, entry, interpolate, 0, paperActive)) {
+                mCurrentItems.add(entry);
+            }
+            entry = list.previousOf(entry);
+        }
+
+        int pass = 1;
+        while (!mCurrentItems.isEmpty()) {
+            for (int i = 0, n = mCurrentItems.size(); i < n; i++) {
+                ItemEntry entry = mCurrentItems.get(i);
+                if (renderItem(canvas, entry, interpolate, pass, paperActive)) {
+                    mNextItems.add(entry);
+                }
+            }
+            mCurrentItems.clear();
+            // swap mNextItems with mCurrentItems
+            ArrayList<ItemEntry> tmp = mNextItems;
+            mNextItems = mCurrentItems;
+            mCurrentItems = tmp;
+            pass += 1;
+        }
+
+        if (WIDE) {
+            canvas.translate(mScrollX, 0, 0);
+        } else {
+            canvas.translate(0, mScrollY, 0);
+        }
+
+        if (more) invalidate();
+        if (mMoreAnimation && !more && mUIListener != null) {
+            mUIListener.onUserInteractionEnd();
+        }
+        mMoreAnimation = more;
+        canvas.restore();
+    }
+
+    private boolean renderItem(GLCanvas canvas, ItemEntry entry,
+            float interpolate, int pass, boolean paperActive) {
+        canvas.save(GLCanvas.SAVE_FLAG_ALPHA | GLCanvas.SAVE_FLAG_MATRIX);
+        Position position = entry.target;
+        if (mPositions != null) {
+            position = mTempPosition;
+            position.set(entry.target);
+            position.x -= mScrollX;
+            position.y -= mScrollY;
+            Position source = mPositions
+                    .getPosition(entry.item.getIdentity(), position);
+            source.x += mScrollX;
+            source.y += mScrollY;
+            position = mTempPosition;
+            Position.interpolate(
+                    source, entry.target, position, interpolate);
+        }
+        canvas.multiplyAlpha(position.alpha);
+        if (paperActive) {
+            canvas.multiplyMatrix(mPaper.getTransform(
+                    position, entry.base, mScrollX, mScrollY), 0);
+        } else {
+            canvas.translate(position.x, position.y, position.z);
+        }
+        canvas.rotate(position.theta, 0, 0, 1);
+        boolean more = entry.item.render(canvas, pass);
+        canvas.restore();
+        return more;
+    }
+
+    public static class MyAnimation extends Animation {
+        public float value;
+
+        public MyAnimation() {
+            setInterpolator(new DecelerateInterpolator(4));
+            setDuration(1500);
+        }
+
+        @Override
+        protected void onCalculate(float progress) {
+            value = progress;
+        }
+    }
+
+    private static class ItemEntry extends LinkedNode {
+        public DisplayItem item;
+        public Position target;
+        public Position base;
+
+        public ItemEntry(DisplayItem item, Position target, Position base) {
+            this.item = item;
+            this.target = target;
+            this.base = base;
+        }
+    }
+
+    public static class Layout {
+
+        private int mVisibleStart;
+        private int mVisibleEnd;
+
+        private int mSlotCount;
+        private int mSlotWidth;
+        private int mSlotHeight;
+
+        private int mWidth;
+        private int mHeight;
+
+        private int mUnitCount;
+        private int mContentLength;
+        private int mScrollPosition;
+
+        private int mVerticalPadding;
+        private int mHorizontalPadding;
+
+        public void setSlotSize(int slotWidth, int slotHeight) {
+            mSlotWidth = slotWidth;
+            mSlotHeight = slotHeight;
+        }
+
+        public boolean setSlotCount(int slotCount) {
+            mSlotCount = slotCount;
+            int hPadding = mHorizontalPadding;
+            int vPadding = mVerticalPadding;
+            initLayoutParameters();
+            return vPadding != mVerticalPadding || hPadding != mHorizontalPadding;
+        }
+
+        public Rect getSlotRect(int index) {
+            int col, row;
+            if (WIDE) {
+                col = index / mUnitCount;
+                row = index - col * mUnitCount;
+            } else {
+                row = index / mUnitCount;
+                col = index - row * mUnitCount;
+            }
+
+            int x = mHorizontalPadding + col * mSlotWidth;
+            int y = mVerticalPadding + row * mSlotHeight;
+            return new Rect(x, y, x + mSlotWidth, y + mSlotHeight);
+        }
+
+        public int getContentLength() {
+            return mContentLength;
+        }
+
+        // Calculate
+        // (1) mUnitCount: the number of slots we can fit into one column (or row).
+        // (2) mContentLength: the width (or height) we need to display all the
+        //     columns (rows).
+        // (3) padding[]: the vertical and horizontal padding we need in order
+        //     to put the slots towards to the center of the display.
+        //
+        // The "major" direction is the direction the user can scroll. The other
+        // direction is the "minor" direction.
+        //
+        // The comments inside this method are the description when the major
+        // directon is horizontal (X), and the minor directon is vertical (Y).
+        private void initLayoutParameters(
+                int majorLength, int minorLength,  /* The view width and height */
+                int majorUnitSize, int minorUnitSize,  /* The slot width and height */
+                int[] padding) {
+            int unitCount = minorLength / minorUnitSize;
+            if (unitCount == 0) unitCount = 1;
+            mUnitCount = unitCount;
+
+            // We put extra padding above and below the column.
+            int availableUnits = Math.min(mUnitCount, mSlotCount);
+            padding[0] = (minorLength - availableUnits * minorUnitSize) / 2;
+
+            // Then calculate how many columns we need for all slots.
+            int count = ((mSlotCount + mUnitCount - 1) / mUnitCount);
+            mContentLength = count * majorUnitSize;
+
+            // If the content length is less then the screen width, put
+            // extra padding in left and right.
+            padding[1] = Math.max(0, (majorLength - mContentLength) / 2);
+        }
+
+        private void initLayoutParameters() {
+            int[] padding = new int[2];
+            if (WIDE) {
+                initLayoutParameters(mWidth, mHeight, mSlotWidth, mSlotHeight, padding);
+                mVerticalPadding = padding[0];
+                mHorizontalPadding = padding[1];
+            } else {
+                initLayoutParameters(mHeight, mWidth, mSlotHeight, mSlotWidth, padding);
+                mVerticalPadding = padding[1];
+                mHorizontalPadding = padding[0];
+            }
+            updateVisibleSlotRange();
+        }
+
+        public void setSize(int width, int height) {
+            mWidth = width;
+            mHeight = height;
+            initLayoutParameters();
+        }
+
+        private void updateVisibleSlotRange() {
+            int position = mScrollPosition;
+
+            if (WIDE) {
+                int start = Math.max(0, (position / mSlotWidth) * mUnitCount);
+                int end = Math.min(mSlotCount, mUnitCount
+                        * (position + mWidth + mSlotWidth - 1) / mSlotWidth);
+                setVisibleRange(start, end);
+            } else {
+                int start = Math.max(0, mUnitCount * (position / mSlotHeight));
+                int end = Math.min(mSlotCount, mUnitCount
+                        * (position + mHeight + mSlotHeight - 1) / mSlotHeight);
+                setVisibleRange(start, end);
+            }
+        }
+
+        public void setScrollPosition(int position) {
+            if (mScrollPosition == position) return;
+            mScrollPosition = position;
+            updateVisibleSlotRange();
+        }
+
+        private void setVisibleRange(int start, int end) {
+            if (start == mVisibleStart && end == mVisibleEnd) return;
+            if (start < end) {
+                mVisibleStart = start;
+                mVisibleEnd = end;
+            } else {
+                mVisibleStart = mVisibleEnd = 0;
+            }
+        }
+
+        public int getVisibleStart() {
+            return mVisibleStart;
+        }
+
+        public int getVisibleEnd() {
+            return mVisibleEnd;
+        }
+
+        public int getSlotIndexByPosition(float x, float y) {
+            float absoluteX = x + (WIDE ? mScrollPosition : 0);
+            absoluteX -= mHorizontalPadding;
+            int columnIdx = (int) (absoluteX + 0.5) / mSlotWidth;
+            if ((absoluteX - mSlotWidth * columnIdx) < 0
+                    || (!WIDE && columnIdx >= mUnitCount)) {
+                return INDEX_NONE;
+            }
+
+            float absoluteY = y + (WIDE ? 0 : mScrollPosition);
+            absoluteY -= mVerticalPadding;
+            int rowIdx = (int) (absoluteY + 0.5) / mSlotHeight;
+            if (((absoluteY - mSlotHeight * rowIdx) < 0)
+                    || (WIDE && rowIdx >= mUnitCount)) {
+                return INDEX_NONE;
+            }
+            int index = WIDE
+                    ? (columnIdx * mUnitCount + rowIdx)
+                    : (rowIdx * mUnitCount + columnIdx);
+
+            return index >= mSlotCount ? INDEX_NONE : index;
+        }
+
+        public int getScrollLimit() {
+            int limit = WIDE ? mContentLength - mWidth : mContentLength - mHeight;
+            return limit <= 0 ? 0 : limit;
+        }
+    }
+
+    private class MyGestureListener
+            extends GestureDetector.SimpleOnGestureListener {
+
+        @Override
+        public boolean onFling(MotionEvent e1,
+                MotionEvent e2, float velocityX, float velocityY) {
+            int scrollLimit = mLayout.getScrollLimit();
+            if (scrollLimit == 0) return false;
+            float velocity = WIDE ? velocityX : velocityY;
+            mScroller.fling((int) -velocity, 0, scrollLimit);
+            if (mUIListener != null) mUIListener.onUserInteractionBegin();
+            invalidate();
+            return true;
+        }
+
+        @Override
+        public boolean onScroll(MotionEvent e1,
+                MotionEvent e2, float distanceX, float distanceY) {
+            float distance = WIDE ? distanceX : distanceY;
+            boolean canMove = mScroller.startScroll(
+                    Math.round(distance), 0, mLayout.getScrollLimit());
+            if (mOverscrollEffect == OVERSCROLL_3D && !canMove) {
+                mPaper.overScroll(distance);
+            }
+            invalidate();
+            return true;
+        }
+
+        @Override
+        public boolean onSingleTapUp(MotionEvent e) {
+            if (mDownInScrolling) return true;
+            int index = mLayout.getSlotIndexByPosition(e.getX(), e.getY());
+            if (index != INDEX_NONE) mListener.onSingleTapUp(index);
+            return true;
+        }
+
+        @Override
+        public void onLongPress(MotionEvent e) {
+            if (mDownInScrolling) return;
+            lockRendering();
+            try {
+                int index = mLayout.getSlotIndexByPosition(e.getX(), e.getY());
+                if (index != INDEX_NONE) mListener.onLongTap(index);
+            } finally {
+                unlockRendering();
+            }
+        }
+    }
+
+    public void setStartIndex(int index) {
+        mStartIndex = index;
+    }
+
+    // Return true if the layout parameters have been changed
+    public boolean setSlotCount(int slotCount) {
+        boolean changed = mLayout.setSlotCount(slotCount);
+
+        // mStartIndex is applied the first time setSlotCount is called.
+        if (mStartIndex != INDEX_NONE) {
+            setCenterIndex(mStartIndex);
+            mStartIndex = INDEX_NONE;
+        }
+        updateScrollPosition(WIDE ? mScrollX : mScrollY, true);
+        return changed;
+    }
+
+    public int getVisibleStart() {
+        return mLayout.getVisibleStart();
+    }
+
+    public int getVisibleEnd() {
+        return mLayout.getVisibleEnd();
+    }
+
+    public int getScrollX() {
+        return mScrollX;
+    }
+
+    public int getScrollY() {
+        return mScrollY;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/StaticBackground.java b/src/com/android/gallery3d/ui/StaticBackground.java
new file mode 100644
index 0000000..08c55c3
--- /dev/null
+++ b/src/com/android/gallery3d/ui/StaticBackground.java
@@ -0,0 +1,62 @@
+/*
+ * 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.ui;
+
+import android.content.Context;
+
+public class StaticBackground extends GLView {
+
+    private Context mContext;
+    private int mLandscapeResource;
+    private int mPortraitResource;
+
+    private BasicTexture mBackground;
+    private boolean mIsLandscape = false;
+
+    public StaticBackground(Context context) {
+        mContext = context;
+    }
+
+    @Override
+    protected void onLayout(boolean changeSize, int l, int t, int r, int b) {
+        setOrientation(getWidth() >= getHeight());
+    }
+
+    private void setOrientation(boolean isLandscape) {
+        if (mIsLandscape == isLandscape) return;
+        mIsLandscape = isLandscape;
+        if (mBackground != null) mBackground.recycle();
+        mBackground = new ResourceTexture(
+                mContext, mIsLandscape ? mLandscapeResource : mPortraitResource);
+        invalidate();
+    }
+
+    public void setImage(int landscapeId, int portraitId) {
+        mLandscapeResource = landscapeId;
+        mPortraitResource = portraitId;
+        if (mBackground != null) mBackground.recycle();
+        mBackground = new ResourceTexture(
+                mContext, mIsLandscape ? landscapeId : portraitId);
+        invalidate();
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        //mBackground.draw(canvas, 0, 0, getWidth(), getHeight());
+        canvas.fillRect(0, 0, getWidth(), getHeight(), 0xFF000000);
+    }
+}
diff --git a/src/com/android/gallery3d/ui/StringTexture.java b/src/com/android/gallery3d/ui/StringTexture.java
new file mode 100644
index 0000000..71ab9b3
--- /dev/null
+++ b/src/com/android/gallery3d/ui/StringTexture.java
@@ -0,0 +1,92 @@
+/*
+ * 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.ui;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint.FontMetricsInt;
+import android.graphics.Typeface;
+import android.text.TextPaint;
+import android.text.TextUtils;
+
+// StringTexture is a texture shows the content of a specified String.
+//
+// To create a StringTexture, use the newInstance() method and specify
+// the String, the font size, and the color.
+class StringTexture extends CanvasTexture {
+    private final String mText;
+    private final TextPaint mPaint;
+    private final FontMetricsInt mMetrics;
+
+    private StringTexture(String text, TextPaint paint,
+            FontMetricsInt metrics, int width, int height) {
+        super(width, height);
+        mText = text;
+        mPaint = paint;
+        mMetrics = metrics;
+    }
+
+    public static TextPaint getDefaultPaint(float textSize, int color) {
+        TextPaint paint = new TextPaint();
+        paint.setTextSize(textSize);
+        paint.setAntiAlias(true);
+        paint.setColor(color);
+        paint.setShadowLayer(2f, 0f, 0f, Color.BLACK);
+        return paint;
+    }
+
+    public static StringTexture newInstance(
+            String text, float textSize, int color) {
+        return newInstance(text, getDefaultPaint(textSize, color));
+    }
+
+    public static StringTexture newInstance(
+            String text, String postfix, float textSize, int color,
+            float lengthLimit, boolean isBold) {
+        TextPaint paint = getDefaultPaint(textSize, color);
+        if (isBold) {
+            paint.setTypeface(Typeface.defaultFromStyle(Typeface.BOLD));
+        }
+        if (postfix != null) {
+            lengthLimit = Math.max(0,
+                    lengthLimit - paint.measureText(postfix));
+            text = TextUtils.ellipsize(text, paint, lengthLimit,
+                    TextUtils.TruncateAt.END).toString() + postfix;
+        } else {
+            text = TextUtils.ellipsize(
+                    text, paint, lengthLimit, TextUtils.TruncateAt.END).toString();
+        }
+        return newInstance(text, paint);
+    }
+
+    private static StringTexture newInstance(String text, TextPaint paint) {
+        FontMetricsInt metrics = paint.getFontMetricsInt();
+        int width = (int) Math.ceil(paint.measureText(text));
+        int height = metrics.bottom - metrics.top;
+        // The texture size needs to be at least 1x1.
+        if (width <= 0) width = 1;
+        if (height <= 0) height = 1;
+        return new StringTexture(text, paint, metrics, width, height);
+    }
+
+    @Override
+    protected void onDraw(Canvas canvas, Bitmap backing) {
+        canvas.translate(0, -mMetrics.ascent);
+        canvas.drawText(mText, 0, 0, mPaint);
+    }
+}
diff --git a/src/com/android/gallery3d/ui/StripDrawer.java b/src/com/android/gallery3d/ui/StripDrawer.java
new file mode 100644
index 0000000..0910612
--- /dev/null
+++ b/src/com/android/gallery3d/ui/StripDrawer.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.ui;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.data.Path;
+
+import android.content.Context;
+import android.graphics.Rect;
+
+public class StripDrawer extends SelectionDrawer {
+    private NinePatchTexture mFocusBox;
+    private Rect mFocusBoxPadding;
+
+    public StripDrawer(Context context) {
+        mFocusBox = new NinePatchTexture(context, R.drawable.focus_box);
+        mFocusBoxPadding = mFocusBox.getPaddings();
+    }
+
+    @Override
+    public void prepareDrawing() {
+    }
+
+    @Override
+    public void draw(GLCanvas canvas, Texture content, int width, int height,
+            int rotation, Path path, int topIndex, int dataSourceType,
+            int mediaType, boolean wantCache, boolean isCaching) {
+
+        int x = -width / 2;
+        int y = -height / 2;
+
+        drawWithRotation(canvas, content, x, y, width, height, rotation);
+    }
+
+    @Override
+    public void drawFocus(GLCanvas canvas, int width, int height) {
+        int x = -width / 2;
+        int y = -height / 2;
+        Rect p = mFocusBoxPadding;
+        mFocusBox.draw(canvas, x - p.left, y - p.top,
+                width + p.left + p.right, height + p.top + p.bottom);
+    }
+}
diff --git a/src/com/android/gallery3d/ui/SynchronizedHandler.java b/src/com/android/gallery3d/ui/SynchronizedHandler.java
new file mode 100644
index 0000000..bd494a3
--- /dev/null
+++ b/src/com/android/gallery3d/ui/SynchronizedHandler.java
@@ -0,0 +1,41 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+
+import android.os.Handler;
+import android.os.Message;
+
+public class SynchronizedHandler extends Handler {
+
+    private final GLRoot mRoot;
+
+    public SynchronizedHandler(GLRoot root) {
+        mRoot = Utils.checkNotNull(root);
+    }
+
+    @Override
+    public void dispatchMessage(Message message) {
+        mRoot.lockRenderThread();
+        try {
+            super.dispatchMessage(message);
+        } finally {
+            mRoot.unlockRenderThread();
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/TextButton.java b/src/com/android/gallery3d/ui/TextButton.java
new file mode 100644
index 0000000..c6b85bf
--- /dev/null
+++ b/src/com/android/gallery3d/ui/TextButton.java
@@ -0,0 +1,91 @@
+/*
+ * 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.ui;
+
+import static com.android.gallery3d.ui.TextButtonConfig.*;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.view.MotionEvent;
+
+public class TextButton extends Label {
+    private static final String TAG = "TextButton";
+    private boolean mPressed;
+    private Texture mPressedBackground;
+    private Texture mNormalBackground;
+    private OnClickedListener mOnClickListener;
+
+    public interface OnClickedListener {
+        public void onClicked(GLView source);
+    }
+
+    public TextButton(Context context, int label) {
+        super(context, label);
+        setPaddings(HORIZONTAL_PADDINGS, VERTICAL_PADDINGS,
+                HORIZONTAL_PADDINGS, VERTICAL_PADDINGS);
+    }
+
+    public void setOnClickListener(OnClickedListener listener) {
+        mOnClickListener = listener;
+    }
+
+    public void setPressedBackground(Texture texture) {
+        mPressedBackground = texture;
+    }
+
+    public void setNormalBackground(Texture texture) {
+        mNormalBackground = texture;
+    }
+
+    @SuppressWarnings("fallthrough")
+    @Override
+    protected boolean onTouch(MotionEvent event) {
+        switch (event.getAction()) {
+            case MotionEvent.ACTION_DOWN:
+                mPressed = true;
+                invalidate();
+                break;
+            case MotionEvent.ACTION_UP:
+                if (mOnClickListener != null) {
+                    mOnClickListener.onClicked(this);
+                }
+                // fall-through
+            case MotionEvent.ACTION_CANCEL:
+                mPressed = false;
+                invalidate();
+                break;
+        }
+        return true;
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        Texture bg = mPressed ? mPressedBackground : mNormalBackground;
+        if (bg != null) {
+            int width = getWidth();
+            int height = getHeight();
+            if (bg instanceof NinePatchTexture) {
+                Rect p = ((NinePatchTexture) bg).getPaddings();
+                bg.draw(canvas, -p.left, -p.top,
+                        width + p.left + p.right, height + p.top + p.bottom);
+            } else {
+                bg.draw(canvas, 0, 0, width, height);
+            }
+        }
+        super.render(canvas);
+    }
+}
diff --git a/src/com/android/gallery3d/ui/Texture.java b/src/com/android/gallery3d/ui/Texture.java
new file mode 100644
index 0000000..feb7b0a
--- /dev/null
+++ b/src/com/android/gallery3d/ui/Texture.java
@@ -0,0 +1,44 @@
+/*
+ * 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.ui;
+
+// Texture is a rectangular image which can be drawn on GLCanvas.
+// The isOpaque() function gives a hint about whether the texture is opaque,
+// so the drawing can be done faster.
+//
+// This is the current texture hierarchy:
+//
+// Texture
+// -- ColorTexture
+// -- BasicTexture
+//    -- RawTexture
+//    -- UploadedTexture
+//       -- BitmapTexture
+//       -- Tile
+//       -- ResourceTexture
+//          -- NinePatchTexture
+//       -- CanvasTexture
+//          -- DrawableTexture
+//          -- StringTexture
+//
+public interface Texture {
+    public int getWidth();
+    public int getHeight();
+    public void draw(GLCanvas canvas, int x, int y);
+    public void draw(GLCanvas canvas, int x, int y, int w, int h);
+    public boolean isOpaque();
+}
diff --git a/src/com/android/gallery3d/ui/TileImageView.java b/src/com/android/gallery3d/ui/TileImageView.java
new file mode 100644
index 0000000..cf06851
--- /dev/null
+++ b/src/com/android/gallery3d/ui/TileImageView.java
@@ -0,0 +1,693 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.app.GalleryContext;
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.util.Future;
+import com.android.gallery3d.util.ThreadPool;
+import com.android.gallery3d.util.ThreadPool.CancelListener;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.graphics.Bitmap;
+import android.graphics.Rect;
+import android.graphics.RectF;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class TileImageView extends GLView {
+    public static final int SIZE_UNKNOWN = -1;
+
+    @SuppressWarnings("unused")
+    private static final String TAG = "TileImageView";
+
+    // TILE_SIZE must be 2^N - 2. We put one pixel border in each side of the
+    // texture to avoid seams between tiles.
+    private static final int TILE_SIZE = 254;
+    private static final int TILE_BORDER = 1;
+    private static final int UPLOAD_LIMIT = 1;
+
+    /*
+     *  This is the tile state in the CPU side.
+     *  Life of a Tile:
+     *      ACTIVATED (initial state)
+     *              --> IN_QUEUE - by queueForDecode()
+     *              --> RECYCLED - by recycleTile()
+     *      IN_QUEUE --> DECODING - by decodeTile()
+     *               --> RECYCLED - by recycleTile)
+     *      DECODING --> RECYCLING - by recycleTile()
+     *               --> DECODED  - by decodeTile()
+     *      RECYCLING --> RECYCLED - by decodeTile()
+     *      DECODED --> ACTIVATED - (after the decoded bitmap is uploaded)
+     *      DECODED --> RECYCLED - by recycleTile()
+     *      RECYCLED --> ACTIVATED - by obtainTile()
+     */
+    private static final int STATE_ACTIVATED = 0x01;
+    private static final int STATE_IN_QUEUE = 0x02;
+    private static final int STATE_DECODING = 0x04;
+    private static final int STATE_DECODED = 0x08;
+    private static final int STATE_RECYCLING = 0x10;
+    private static final int STATE_RECYCLED = 0x20;
+
+    private Model mModel;
+    protected BitmapTexture mBackupImage;
+    protected int mLevelCount;  // cache the value of mScaledBitmaps.length
+
+    // The mLevel variable indicates which level of bitmap we should use.
+    // Level 0 means the original full-sized bitmap, and a larger value means
+    // a smaller scaled bitmap (The width and height of each scaled bitmap is
+    // half size of the previous one). If the value is in [0, mLevelCount), we
+    // use the bitmap in mScaledBitmaps[mLevel] for display, otherwise the value
+    // is mLevelCount, and that means we use mBackupTexture for display.
+    private int mLevel = 0;
+
+    // The offsets of the (left, top) of the upper-left tile to the (left, top)
+    // of the view.
+    private int mOffsetX;
+    private int mOffsetY;
+
+    private int mUploadQuota;
+    private boolean mRenderComplete;
+
+    private final RectF mSourceRect = new RectF();
+    private final RectF mTargetRect = new RectF();
+
+    private final HashMap<Long, Tile> mActiveTiles = new HashMap<Long, Tile>();
+
+    // The following three queue is guarded by TileImageView.this
+    private TileQueue mRecycledQueue = new TileQueue();
+    private TileQueue mUploadQueue = new TileQueue();
+    private TileQueue mDecodeQueue = new TileQueue();
+
+    // The width and height of the full-sized bitmap
+    protected int mImageWidth = SIZE_UNKNOWN;
+    protected int mImageHeight = SIZE_UNKNOWN;
+
+    protected int mCenterX;
+    protected int mCenterY;
+    protected float mScale;
+    protected int mRotation;
+
+    // Temp variables to avoid memory allocation
+    private final Rect mTileRange = new Rect();
+    private final Rect mActiveRange[] = {new Rect(), new Rect()};
+
+    private final TileUploader mTileUploader = new TileUploader();
+    private boolean mIsTextureFreed;
+    private Future<Void> mTileDecoder;
+    private ThreadPool mThreadPool;
+    private boolean mBackgroundTileUploaded;
+
+    public static interface Model {
+        public int getLevelCount();
+        public Bitmap getBackupImage();
+        public int getImageWidth();
+        public int getImageHeight();
+
+        // The method would be called in another thread
+        public Bitmap getTile(int level, int x, int y, int tileSize);
+        public boolean isFailedToLoad();
+    }
+
+    public TileImageView(GalleryContext context) {
+        mThreadPool = context.getThreadPool();
+        mTileDecoder = mThreadPool.submit(new TileDecoder());
+    }
+
+    public void setModel(Model model) {
+        mModel = model;
+        if (model != null) notifyModelInvalidated();
+    }
+
+    private void updateBackupTexture(Bitmap backup) {
+        if (backup == null) {
+            if (mBackupImage != null) mBackupImage.recycle();
+            mBackupImage = null;
+        } else {
+            if (mBackupImage != null) {
+                if (mBackupImage.getBitmap() != backup) {
+                    mBackupImage.recycle();
+                    mBackupImage = new BitmapTexture(backup);
+                }
+            } else {
+                mBackupImage = new BitmapTexture(backup);
+            }
+        }
+    }
+
+    public void notifyModelInvalidated() {
+        invalidateTiles();
+        if (mModel == null) {
+            mBackupImage = null;
+            mImageWidth = 0;
+            mImageHeight = 0;
+            mLevelCount = 0;
+        } else {
+            updateBackupTexture(mModel.getBackupImage());
+            mImageWidth = mModel.getImageWidth();
+            mImageHeight = mModel.getImageHeight();
+            mLevelCount = mModel.getLevelCount();
+        }
+        layoutTiles(mCenterX, mCenterY, mScale, mRotation);
+        invalidate();
+    }
+
+    @Override
+    protected void onLayout(
+            boolean changeSize, int left, int top, int right, int bottom) {
+        super.onLayout(changeSize, left, top, right, bottom);
+        if (changeSize) layoutTiles(mCenterX, mCenterY, mScale, mRotation);
+    }
+
+    // Prepare the tiles we want to use for display.
+    //
+    // 1. Decide the tile level we want to use for display.
+    // 2. Decide the tile levels we want to keep as texture (in addition to
+    //    the one we use for display).
+    // 3. Recycle unused tiles.
+    // 4. Activate the tiles we want.
+    private void layoutTiles(int centerX, int centerY, float scale, int rotation) {
+        // The width and height of this view.
+        int width = getWidth();
+        int height = getHeight();
+
+        // The tile levels we want to keep as texture is in the range
+        // [fromLevel, endLevel).
+        int fromLevel;
+        int endLevel;
+
+        // We want to use a texture larger than or equal to the display size.
+        mLevel = Utils.clamp(Utils.floorLog2(1f / scale), 0, mLevelCount);
+
+        // We want to keep one more tile level as texture in addition to what
+        // we use for display. So it can be faster when the scale moves to the
+        // next level. We choose a level closer to the current scale.
+        if (mLevel != mLevelCount) {
+            Rect range = mTileRange;
+            getRange(range, centerX, centerY, mLevel, scale, rotation);
+            mOffsetX = Math.round(width / 2f + (range.left - centerX) * scale);
+            mOffsetY = Math.round(height / 2f + (range.top - centerY) * scale);
+            fromLevel = scale * (1 << mLevel) > 0.75f ? mLevel - 1 : mLevel;
+        } else {
+            // Activate the tiles of the smallest two levels.
+            fromLevel = mLevel - 2;
+            mOffsetX = Math.round(width / 2f - centerX * scale);
+            mOffsetY = Math.round(height / 2f - centerY * scale);
+        }
+
+        fromLevel = Math.max(0, Math.min(fromLevel, mLevelCount - 2));
+        endLevel = Math.min(fromLevel + 2, mLevelCount);
+
+        Rect range[] = mActiveRange;
+        for (int i = fromLevel; i < endLevel; ++i) {
+            getRange(range[i - fromLevel], centerX, centerY, i, rotation);
+        }
+
+        // If rotation is transient, don't update the tile.
+        if (rotation % 90 != 0) return;
+
+        synchronized (this) {
+            mDecodeQueue.clean();
+            mUploadQueue.clean();
+            mBackgroundTileUploaded = false;
+        }
+
+        // Recycle unused tiles: if the level of the active tile is outside the
+        // range [fromLevel, endLevel) or not in the visible range.
+        Iterator<Map.Entry<Long, Tile>>
+                iter = mActiveTiles.entrySet().iterator();
+        while (iter.hasNext()) {
+            Tile tile = iter.next().getValue();
+            int level = tile.mTileLevel;
+            if (level < fromLevel || level >= endLevel
+                    || !range[level - fromLevel].contains(tile.mX, tile.mY)) {
+                iter.remove();
+                recycleTile(tile);
+            }
+        }
+
+        for (int i = fromLevel; i < endLevel; ++i) {
+            int size = TILE_SIZE << i;
+            Rect r = range[i - fromLevel];
+            for (int y = r.top, bottom = r.bottom; y < bottom; y += size) {
+                for (int x = r.left, right = r.right; x < right; x += size) {
+                    activateTile(x, y, i);
+                }
+            }
+        }
+        invalidate();
+    }
+
+    protected synchronized void invalidateTiles() {
+        mDecodeQueue.clean();
+        mUploadQueue.clean();
+        // TODO disable decoder
+        for (Tile tile : mActiveTiles.values()) {
+            recycleTile(tile);
+        }
+        mActiveTiles.clear();
+    }
+
+    private void getRange(Rect out, int cX, int cY, int level, int rotation) {
+        getRange(out, cX, cY, level, 1f / (1 << (level + 1)), rotation);
+    }
+
+    // If the bitmap is scaled by the given factor "scale", return the
+    // rectangle containing visible range. The left-top coordinate returned is
+    // aligned to the tile boundary.
+    //
+    // (cX, cY) is the point on the original bitmap which will be put in the
+    // center of the ImageViewer.
+    private void getRange(Rect out,
+            int cX, int cY, int level, float scale, int rotation) {
+
+        double radians = Math.toRadians(-rotation);
+        double w = getWidth();
+        double h = getHeight();
+
+        double cos = Math.cos(radians);
+        double sin = Math.sin(radians);
+        int width = (int) Math.ceil(Math.max(
+                Math.abs(cos * w - sin * h), Math.abs(cos * w + sin * h)));
+        int height = (int) Math.ceil(Math.max(
+                Math.abs(sin * w + cos * h), Math.abs(sin * w - cos * h)));
+
+        int left = (int) Math.floor(cX - width / (2f * scale));
+        int top = (int) Math.floor(cY - height / (2f * scale));
+        int right = (int) Math.ceil(left + width / scale);
+        int bottom = (int) Math.ceil(top + height / scale);
+
+        // align the rectangle to tile boundary
+        int size = TILE_SIZE << level;
+        left = Math.max(0, size * (left / size));
+        top = Math.max(0, size * (top / size));
+        right = Math.min(mImageWidth, right);
+        bottom = Math.min(mImageHeight, bottom);
+
+        out.set(left, top, right, bottom);
+    }
+
+    public boolean setPosition(int centerX, int centerY, float scale, int rotation) {
+        if (mCenterX == centerX
+                && mCenterY == centerY && mScale == scale) return false;
+        mCenterX = centerX;
+        mCenterY = centerY;
+        mScale = scale;
+        mRotation = rotation;
+        layoutTiles(centerX, centerY, scale, rotation);
+        invalidate();
+        return true;
+    }
+
+    public void freeTextures() {
+        mIsTextureFreed = true;
+
+        if (mTileDecoder != null) {
+            mTileDecoder.cancel();
+            mTileDecoder.get();
+            mTileDecoder = null;
+        }
+
+        for (Tile texture : mActiveTiles.values()) {
+            texture.recycle();
+        }
+        mTileRange.set(0, 0, 0, 0);
+        mActiveTiles.clear();
+
+        synchronized (this) {
+            mUploadQueue.clean();
+            mDecodeQueue.clean();
+            Tile tile = mRecycledQueue.pop();
+            while (tile != null) {
+                tile.recycle();
+                tile = mRecycledQueue.pop();
+            }
+        }
+        updateBackupTexture(null);
+    }
+
+    public void prepareTextures() {
+        if (mTileDecoder == null) {
+            mTileDecoder = mThreadPool.submit(new TileDecoder());
+        }
+        if (mIsTextureFreed) {
+            layoutTiles(mCenterX, mCenterY, mScale, mRotation);
+            mIsTextureFreed = false;
+            updateBackupTexture(mModel.getBackupImage());
+        }
+    }
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        mUploadQuota = UPLOAD_LIMIT;
+        mRenderComplete = true;
+
+        int level = mLevel;
+        int rotation = mRotation;
+
+        if (rotation != 0) {
+            canvas.save(GLCanvas.SAVE_FLAG_MATRIX);
+            int centerX = getWidth() / 2, centerY = getHeight() / 2;
+            canvas.translate(centerX, centerY, 0);
+            canvas.rotate(rotation, 0, 0, 1);
+            canvas.translate(-centerX, -centerY, 0);
+        }
+        try {
+            if (level != mLevelCount) {
+                int size = (TILE_SIZE << level);
+                float length = size * mScale;
+                Rect r = mTileRange;
+
+                for (int ty = r.top, i = 0; ty < r.bottom; ty += size, i++) {
+                    float y = mOffsetY + i * length;
+                    for (int tx = r.left, j = 0; tx < r.right; tx += size, j++) {
+                        float x = mOffsetX + j * length;
+                        drawTile(canvas, tx, ty, level, x, y, length);
+                    }
+                }
+            } else if (mBackupImage != null) {
+                mBackupImage.draw(canvas, mOffsetX, mOffsetY,
+                        Math.round(mImageWidth * mScale),
+                        Math.round(mImageHeight * mScale));
+            }
+        } finally {
+            if (rotation != 0) canvas.restore();
+        }
+
+        if (mRenderComplete) {
+            if (!mBackgroundTileUploaded) uploadBackgroundTiles(canvas);
+        } else {
+            invalidate();
+        }
+    }
+
+    private void uploadBackgroundTiles(GLCanvas canvas) {
+        mBackgroundTileUploaded = true;
+        for (Tile tile : mActiveTiles.values()) {
+            if (!tile.isContentValid(canvas)) queueForDecode(tile);
+        }
+    }
+
+    void queueForUpload(Tile tile) {
+        synchronized (this) {
+            mUploadQueue.push(tile);
+        }
+        if (mTileUploader.mActive.compareAndSet(false, true)) {
+            getGLRoot().addOnGLIdleListener(mTileUploader);
+        }
+    }
+
+    synchronized void queueForDecode(Tile tile) {
+        if (tile.mTileState == STATE_ACTIVATED) {
+            tile.mTileState = STATE_IN_QUEUE;
+            if (mDecodeQueue.push(tile)) notifyAll();
+        }
+    }
+
+    boolean decodeTile(Tile tile) {
+        synchronized (this) {
+            if (tile.mTileState != STATE_IN_QUEUE) return false;
+            tile.mTileState = STATE_DECODING;
+        }
+        boolean decodeComplete = tile.decode();
+        synchronized (this) {
+            if (tile.mTileState == STATE_RECYCLING) {
+                tile.mTileState = STATE_RECYCLED;
+                tile.mDecodedTile = null;
+                mRecycledQueue.push(tile);
+                return false;
+            }
+            tile.mTileState = STATE_DECODED;
+            return decodeComplete;
+        }
+    }
+
+    private synchronized Tile obtainTile(int x, int y, int level) {
+        Tile tile = mRecycledQueue.pop();
+        if (tile != null) {
+            tile.mTileState = STATE_ACTIVATED;
+            tile.update(x, y, level);
+            return tile;
+        }
+        return new Tile(x, y, level);
+    }
+
+    synchronized void recycleTile(Tile tile) {
+        if (tile.mTileState == STATE_DECODING) {
+            tile.mTileState = STATE_RECYCLING;
+            return;
+        }
+        tile.mTileState = STATE_RECYCLED;
+        tile.mDecodedTile = null;
+        mRecycledQueue.push(tile);
+    }
+
+    private void activateTile(int x, int y, int level) {
+        Long key = makeTileKey(x, y, level);
+        Tile tile = mActiveTiles.get(key);
+        if (tile != null) {
+            if (tile.mTileState == STATE_IN_QUEUE) {
+                tile.mTileState = STATE_ACTIVATED;
+            }
+            return;
+        }
+        tile = obtainTile(x, y, level);
+        mActiveTiles.put(key, tile);
+    }
+
+    private Tile getTile(int x, int y, int level) {
+        return mActiveTiles.get(makeTileKey(x, y, level));
+    }
+
+    private static Long makeTileKey(int x, int y, int level) {
+        long result = x;
+        result = (result << 16) | y;
+        result = (result << 16) | level;
+        return Long.valueOf(result);
+    }
+
+    private class TileUploader implements GLRoot.OnGLIdleListener {
+        AtomicBoolean mActive = new AtomicBoolean(false);
+
+        @Override
+        public boolean onGLIdle(GLRoot root, GLCanvas canvas) {
+            int quota = UPLOAD_LIMIT;
+            Tile tile;
+            while (true) {
+                synchronized (TileImageView.this) {
+                    tile = mUploadQueue.pop();
+                }
+                if (tile == null || quota <= 0) break;
+                if (!tile.isContentValid(canvas)) {
+                    Utils.assertTrue(tile.mTileState == STATE_DECODED);
+                    tile.updateContent(canvas);
+                    --quota;
+                }
+            }
+            mActive.set(tile != null);
+            return tile != null;
+        }
+    }
+
+    // Draw the tile to a square at canvas that locates at (x, y) and
+    // has a side length of length.
+    public void drawTile(GLCanvas canvas,
+            int tx, int ty, int level, float x, float y, float length) {
+        RectF source = mSourceRect;
+        RectF target = mTargetRect;
+        target.set(x, y, x + length, y + length);
+        source.set(0, 0, TILE_SIZE, TILE_SIZE);
+
+        Tile tile = getTile(tx, ty, level);
+        if (tile != null) {
+            if (!tile.isContentValid(canvas)) {
+                if (tile.mTileState == STATE_DECODED) {
+                    if (mUploadQuota > 0) {
+                        --mUploadQuota;
+                        tile.updateContent(canvas);
+                    } else {
+                        mRenderComplete = false;
+                    }
+                } else {
+                    mRenderComplete = false;
+                    queueForDecode(tile);
+                }
+            }
+            if (drawTile(tile, canvas, source, target)) return;
+        }
+        if (mBackupImage != null) {
+            BasicTexture backup = mBackupImage;
+            int size = TILE_SIZE << level;
+            float scaleX = (float) backup.getWidth() / mImageWidth;
+            float scaleY = (float) backup.getHeight() / mImageHeight;
+            source.set(tx * scaleX, ty * scaleY, (tx + size) * scaleX,
+                    (ty + size) * scaleY);
+            canvas.drawTexture(backup, source, target);
+        }
+    }
+
+    // TODO: avoid drawing the unused part of the textures.
+    static boolean drawTile(
+            Tile tile, GLCanvas canvas, RectF source, RectF target) {
+        while (true) {
+            if (tile.isContentValid(canvas)) {
+                // offset source rectangle for the texture border.
+                source.offset(TILE_BORDER, TILE_BORDER);
+                canvas.drawTexture(tile, source, target);
+                return true;
+            }
+
+            // Parent can be divided to four quads and tile is one of the four.
+            Tile parent = tile.getParentTile();
+            if (parent == null) return false;
+            if (tile.mX == parent.mX) {
+                source.left /= 2f;
+                source.right /= 2f;
+            } else {
+                source.left = (TILE_SIZE + source.left) / 2f;
+                source.right = (TILE_SIZE + source.right) / 2f;
+            }
+            if (tile.mY == parent.mY) {
+                source.top /= 2f;
+                source.bottom /= 2f;
+            } else {
+                source.top = (TILE_SIZE + source.top) / 2f;
+                source.bottom = (TILE_SIZE + source.bottom) / 2f;
+            }
+            tile = parent;
+        }
+    }
+
+    private class Tile extends UploadedTexture {
+        int mX;
+        int mY;
+        int mTileLevel;
+        Tile mNext;
+        Bitmap mDecodedTile;
+        volatile int mTileState = STATE_ACTIVATED;
+
+        public Tile(int x, int y, int level) {
+            mX = x;
+            mY = y;
+            mTileLevel = level;
+        }
+
+        @Override
+        protected void onFreeBitmap(Bitmap bitmap) {
+            bitmap.recycle();
+        }
+
+        boolean decode() {
+            // Get a tile from the original image. The tile is down-scaled
+            // by (1 << mTilelevel) from a region in the original image.
+            int tileLength = (TILE_SIZE + 2 * TILE_BORDER);
+            int borderLength = TILE_BORDER << mTileLevel;
+            try {
+                mDecodedTile = mModel.getTile(
+                        mTileLevel, mX - borderLength, mY - borderLength, tileLength);
+                return mDecodedTile != null;
+            } catch (Throwable t) {
+                Log.w(TAG, "fail to decode tile", t);
+                return false;
+            }
+        }
+
+        @Override
+        protected Bitmap onGetBitmap() {
+            Utils.assertTrue(mTileState == STATE_DECODED);
+            Bitmap bitmap = mDecodedTile;
+            mDecodedTile = null;
+            mTileState = STATE_ACTIVATED;
+            return bitmap;
+        }
+
+        public void update(int x, int y, int level) {
+            mX = x;
+            mY = y;
+            mTileLevel = level;
+            invalidateContent();
+        }
+
+        public Tile getParentTile() {
+            if (mTileLevel + 1 == mLevelCount) return null;
+            int size = TILE_SIZE << (mTileLevel + 1);
+            int x = size * (mX / size);
+            int y = size * (mY / size);
+            return getTile(x, y, mTileLevel + 1);
+        }
+
+        @Override
+        public String toString() {
+            return String.format("tile(%s, %s, %s / %s)",
+                    mX / TILE_SIZE, mY / TILE_SIZE, mLevel, mLevelCount);
+        }
+    }
+
+    private static class TileQueue {
+        private Tile mHead;
+
+        public Tile pop() {
+            Tile tile = mHead;
+            if (tile != null) mHead = tile.mNext;
+            return tile;
+        }
+
+        public boolean push(Tile tile) {
+            boolean wasEmpty = mHead == null;
+            tile.mNext = mHead;
+            mHead = tile;
+            return wasEmpty;
+        }
+
+        public void clean() {
+            mHead = null;
+        }
+    }
+
+    private class TileDecoder implements ThreadPool.Job<Void> {
+
+        private CancelListener mNotifier = new CancelListener() {
+            @Override
+            public void onCancel() {
+                synchronized (TileImageView.this) {
+                    TileImageView.this.notifyAll();
+                }
+            }
+        };
+
+        @Override
+        public Void run(JobContext jc) {
+            jc.setMode(ThreadPool.MODE_NONE);
+            jc.setCancelListener(mNotifier);
+            while (!jc.isCancelled()) {
+                Tile tile = null;
+                synchronized(TileImageView.this) {
+                    tile = mDecodeQueue.pop();
+                    if (tile == null && !jc.isCancelled()) {
+                        Utils.waitWithoutInterrupt(TileImageView.this);
+                    }
+                }
+                if (tile == null) continue;
+                if (decodeTile(tile)) queueForUpload(tile);
+            }
+            return null;
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/ui/TileImageViewAdapter.java b/src/com/android/gallery3d/ui/TileImageViewAdapter.java
new file mode 100644
index 0000000..65dea0e
--- /dev/null
+++ b/src/com/android/gallery3d/ui/TileImageViewAdapter.java
@@ -0,0 +1,144 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapRegionDecoder;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+
+public class TileImageViewAdapter implements TileImageView.Model {
+    protected BitmapRegionDecoder mRegionDecoder;
+    protected int mImageWidth;
+    protected int mImageHeight;
+    protected Bitmap mBackupImage;
+    protected int mLevelCount;
+    protected boolean mFailedToLoad;
+
+    private final Rect mIntersectRect = new Rect();
+    private final Rect mRegionRect = new Rect();
+
+    public TileImageViewAdapter() {
+    }
+
+    public TileImageViewAdapter(Bitmap backup, BitmapRegionDecoder regionDecoder) {
+        mBackupImage = Utils.checkNotNull(backup);
+        mRegionDecoder = regionDecoder;
+        mImageWidth = regionDecoder.getWidth();
+        mImageHeight = regionDecoder.getHeight();
+        mLevelCount = calculateLevelCount();
+    }
+
+    public synchronized void clear() {
+        mBackupImage = null;
+        mImageWidth = 0;
+        mImageHeight = 0;
+        mLevelCount = 0;
+        mRegionDecoder = null;
+        mFailedToLoad = false;
+    }
+
+    public synchronized void setBackupImage(Bitmap backup, int width, int height) {
+        mBackupImage = Utils.checkNotNull(backup);
+        mImageWidth = width;
+        mImageHeight = height;
+        mRegionDecoder = null;
+        mLevelCount = 0;
+        mFailedToLoad = false;
+    }
+
+    public synchronized void setRegionDecoder(BitmapRegionDecoder decoder) {
+        mRegionDecoder = Utils.checkNotNull(decoder);
+        mImageWidth = decoder.getWidth();
+        mImageHeight = decoder.getHeight();
+        mLevelCount = calculateLevelCount();
+        mFailedToLoad = false;
+    }
+
+    private int calculateLevelCount() {
+        return Math.max(0, Utils.ceilLog2(
+                (float) mImageWidth / mBackupImage.getWidth()));
+    }
+
+    @Override
+    public synchronized Bitmap getTile(int level, int x, int y, int length) {
+        Rect region = mRegionRect;
+        Rect intersectRect = mIntersectRect;
+        region.set(x, y, x + (length << level), y + (length << level));
+        intersectRect.set(0, 0, mImageWidth, mImageHeight);
+
+        // Get the intersected rect of the requested region and the image.
+        Utils.assertTrue(intersectRect.intersect(region));
+
+        BitmapFactory.Options options = new BitmapFactory.Options();
+        options.inPreferredConfig = Config.ARGB_8888;
+        options.inPreferQualityOverSpeed = true;
+        options.inSampleSize =  (1 << level);
+
+        Bitmap bitmap;
+
+        // In CropImage, we may call the decodeRegion() concurrently.
+        synchronized (mRegionDecoder) {
+            bitmap = mRegionDecoder.decodeRegion(intersectRect, options);
+        }
+
+        // The returned region may not match with the targetLength.
+        // If so, we fill black pixels on it.
+        if (intersectRect.equals(region)) return bitmap;
+
+        Bitmap tile = Bitmap.createBitmap(length, length, Config.ARGB_8888);
+        Canvas canvas = new Canvas(tile);
+        canvas.drawBitmap(bitmap,
+                (intersectRect.left - region.left) >> level,
+                (intersectRect.top - region.top) >> level, null);
+        bitmap.recycle();
+        return tile;
+    }
+
+    @Override
+    public Bitmap getBackupImage() {
+        return mBackupImage;
+    }
+
+    @Override
+    public int getImageHeight() {
+        return mImageHeight;
+    }
+
+    @Override
+    public int getImageWidth() {
+        return mImageWidth;
+    }
+
+    @Override
+    public int getLevelCount() {
+        return mLevelCount;
+    }
+
+    public void setFailedToLoad() {
+        mFailedToLoad = true;
+    }
+
+    @Override
+    public boolean isFailedToLoad() {
+        return mFailedToLoad;
+    }
+}
diff --git a/src/com/android/gallery3d/ui/UploadedTexture.java b/src/com/android/gallery3d/ui/UploadedTexture.java
new file mode 100644
index 0000000..b063824
--- /dev/null
+++ b/src/com/android/gallery3d/ui/UploadedTexture.java
@@ -0,0 +1,285 @@
+/*
+ * 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.ui;
+
+import com.android.gallery3d.common.Utils;
+
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.opengl.GLUtils;
+
+import java.util.HashMap;
+import javax.microedition.khronos.opengles.GL11;
+import javax.microedition.khronos.opengles.GL11Ext;
+
+// UploadedTextures use a Bitmap for the content of the texture.
+//
+// Subclasses should implement onGetBitmap() to provide the Bitmap and
+// implement onFreeBitmap(mBitmap) which will be called when the Bitmap
+// is not needed anymore.
+//
+// isContentValid() is meaningful only when the isLoaded() returns true.
+// It means whether the content needs to be updated.
+//
+// The user of this class should call recycle() when the texture is not
+// needed anymore.
+//
+// By default an UploadedTexture is opaque (so it can be drawn faster without
+// blending). The user or subclass can override it using setOpaque().
+abstract class UploadedTexture extends BasicTexture {
+
+    // To prevent keeping allocation the borders, we store those used borders here.
+    // Since the length will be power of two, it won't use too much memory.
+    private static HashMap<BorderKey, Bitmap> sBorderLines =
+            new HashMap<BorderKey, Bitmap>();
+    private static BorderKey sBorderKey = new BorderKey();
+
+    @SuppressWarnings("unused")
+    private static final String TAG = "Texture";
+    private boolean mContentValid = true;
+    private boolean mOpaque = true;
+    private boolean mThrottled = false;
+    private static int sUploadedCount;
+    private static final int UPLOAD_LIMIT = 100;
+
+    protected Bitmap mBitmap;
+
+    protected UploadedTexture() {
+        super(null, 0, STATE_UNLOADED);
+    }
+
+    private static class BorderKey implements Cloneable {
+        public boolean vertical;
+        public Config config;
+        public int length;
+
+        @Override
+        public int hashCode() {
+            int x = config.hashCode() ^ length;
+            return vertical ? x : -x;
+        }
+
+        @Override
+        public boolean equals(Object object) {
+            if (!(object instanceof BorderKey)) return false;
+            BorderKey o = (BorderKey) object;
+            return vertical == o.vertical
+                    && config == o.config && length == o.length;
+        }
+
+        @Override
+        public BorderKey clone() {
+            try {
+                return (BorderKey) super.clone();
+            } catch (CloneNotSupportedException e) {
+                throw new AssertionError(e);
+            }
+        }
+    }
+
+    protected void setThrottled(boolean throttled) {
+        mThrottled = throttled;
+    }
+
+    private static Bitmap getBorderLine(
+            boolean vertical, Config config, int length) {
+        BorderKey key = sBorderKey;
+        key.vertical = vertical;
+        key.config = config;
+        key.length = length;
+        Bitmap bitmap = sBorderLines.get(key);
+        if (bitmap == null) {
+            bitmap = vertical
+                    ? Bitmap.createBitmap(1, length, config)
+                    : Bitmap.createBitmap(length, 1, config);
+            sBorderLines.put(key.clone(), bitmap);
+        }
+        return bitmap;
+    }
+
+    private Bitmap getBitmap() {
+        if (mBitmap == null) {
+            mBitmap = onGetBitmap();
+            if (mWidth == UNSPECIFIED) {
+                setSize(mBitmap.getWidth(), mBitmap.getHeight());
+            } else if (mWidth != mBitmap.getWidth()
+                    || mHeight != mBitmap.getHeight()) {
+                throw new IllegalStateException(String.format(
+                        "cannot change size: this = %s, orig = %sx%s, new = %sx%s",
+                        toString(), mWidth, mHeight, mBitmap.getWidth(),
+                        mBitmap.getHeight()));
+            }
+        }
+        return mBitmap;
+    }
+
+    private void freeBitmap() {
+        Utils.assertTrue(mBitmap != null);
+        onFreeBitmap(mBitmap);
+        mBitmap = null;
+    }
+
+    @Override
+    public int getWidth() {
+        if (mWidth == UNSPECIFIED) getBitmap();
+        return mWidth;
+    }
+
+    @Override
+    public int getHeight() {
+        if (mWidth == UNSPECIFIED) getBitmap();
+        return mHeight;
+    }
+
+    protected abstract Bitmap onGetBitmap();
+
+    protected abstract void onFreeBitmap(Bitmap bitmap);
+
+    protected void invalidateContent() {
+        if (mBitmap != null) freeBitmap();
+        mContentValid = false;
+    }
+
+    /**
+     * Whether the content on GPU is valid.
+     */
+    public boolean isContentValid(GLCanvas canvas) {
+        return isLoaded(canvas) && mContentValid;
+    }
+
+    /**
+     * Updates the content on GPU's memory.
+     * @param canvas
+     */
+    public void updateContent(GLCanvas canvas) {
+        if (!isLoaded(canvas)) {
+            if (mThrottled && ++sUploadedCount > UPLOAD_LIMIT) {
+                return;
+            }
+            uploadToCanvas(canvas);
+        } else if (!mContentValid) {
+            Bitmap bitmap = getBitmap();
+            int format = GLUtils.getInternalFormat(bitmap);
+            int type = GLUtils.getType(bitmap);
+            canvas.getGLInstance().glBindTexture(GL11.GL_TEXTURE_2D, mId);
+            GLUtils.texSubImage2D(
+                    GL11.GL_TEXTURE_2D, 0, 0, 0, bitmap, format, type);
+            freeBitmap();
+            mContentValid = true;
+        }
+    }
+
+    public static void resetUploadLimit() {
+        sUploadedCount = 0;
+    }
+
+    public static boolean uploadLimitReached() {
+        return sUploadedCount > UPLOAD_LIMIT;
+    }
+
+    static int[] sTextureId = new int[1];
+    static float[] sCropRect = new float[4];
+
+    private void uploadToCanvas(GLCanvas canvas) {
+        GL11 gl = canvas.getGLInstance();
+
+        Bitmap bitmap = getBitmap();
+        if (bitmap != null) {
+            try {
+                // Define a vertically flipped crop rectangle for
+                // OES_draw_texture.
+                int width = bitmap.getWidth();
+                int height = bitmap.getHeight();
+                sCropRect[0] = 0;
+                sCropRect[1] = height;
+                sCropRect[2] = width;
+                sCropRect[3] = -height;
+
+                // Upload the bitmap to a new texture.
+                gl.glGenTextures(1, sTextureId, 0);
+                gl.glBindTexture(GL11.GL_TEXTURE_2D, sTextureId[0]);
+                gl.glTexParameterfv(GL11.GL_TEXTURE_2D,
+                        GL11Ext.GL_TEXTURE_CROP_RECT_OES, sCropRect, 0);
+                gl.glTexParameteri(GL11.GL_TEXTURE_2D,
+                        GL11.GL_TEXTURE_WRAP_S, GL11.GL_CLAMP_TO_EDGE);
+                gl.glTexParameteri(GL11.GL_TEXTURE_2D,
+                        GL11.GL_TEXTURE_WRAP_T, GL11.GL_CLAMP_TO_EDGE);
+                gl.glTexParameterf(GL11.GL_TEXTURE_2D,
+                        GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR);
+                gl.glTexParameterf(GL11.GL_TEXTURE_2D,
+                        GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR);
+
+                if (width == getTextureWidth() && height == getTextureHeight()) {
+                    GLUtils.texImage2D(GL11.GL_TEXTURE_2D, 0, bitmap, 0);
+                } else {
+                    int format = GLUtils.getInternalFormat(bitmap);
+                    int type = GLUtils.getType(bitmap);
+                    Config config = bitmap.getConfig();
+
+                    gl.glTexImage2D(GL11.GL_TEXTURE_2D, 0, format,
+                            getTextureWidth(), getTextureHeight(),
+                            0, format, type, null);
+                    GLUtils.texSubImage2D(GL11.GL_TEXTURE_2D, 0, 0, 0, bitmap,
+                            format, type);
+
+                    if (width != getTextureWidth()) {
+                        Bitmap line = getBorderLine(true, config, getTextureHeight());
+                        GLUtils.texSubImage2D(
+                                GL11.GL_TEXTURE_2D, 0, width, 0, line, format, type);
+                    }
+
+                    if (height != getTextureHeight()) {
+                        Bitmap line = getBorderLine(false, config, getTextureWidth());
+                        GLUtils.texSubImage2D(
+                                GL11.GL_TEXTURE_2D, 0, 0, height, line, format, type);
+                    }
+
+                }
+            } finally {
+                freeBitmap();
+            }
+            // Update texture state.
+            setAssociatedCanvas(canvas);
+            mId = sTextureId[0];
+            mState = UploadedTexture.STATE_LOADED;
+            mContentValid = true;
+        } else {
+            mState = STATE_ERROR;
+            throw new RuntimeException("Texture load fail, no bitmap");
+        }
+    }
+
+    @Override
+    protected boolean onBind(GLCanvas canvas) {
+        updateContent(canvas);
+        return isContentValid(canvas);
+    }
+
+    public void setOpaque(boolean isOpaque) {
+        mOpaque = isOpaque;
+    }
+
+    public boolean isOpaque() {
+        return mOpaque;
+    }
+
+    @Override
+    public void recycle() {
+        super.recycle();
+        if (mBitmap != null) freeBitmap();
+    }
+}
diff --git a/src/com/android/gallery3d/ui/UserInteractionListener.java b/src/com/android/gallery3d/ui/UserInteractionListener.java
new file mode 100644
index 0000000..bc4a718
--- /dev/null
+++ b/src/com/android/gallery3d/ui/UserInteractionListener.java
@@ -0,0 +1,26 @@
+/*
+ * 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.ui;
+
+public interface UserInteractionListener {
+    // Called when a user interaction begins (for example, fling).
+    public void onUserInteractionBegin();
+    // Called when the user interaction ends.
+    public void onUserInteractionEnd();
+    // Other one-shot user interactions.
+    public void onUserInteraction();
+}
diff --git a/src/com/android/gallery3d/util/CacheManager.java b/src/com/android/gallery3d/util/CacheManager.java
new file mode 100644
index 0000000..fcc444e
--- /dev/null
+++ b/src/com/android/gallery3d/util/CacheManager.java
@@ -0,0 +1,82 @@
+/*
+ * 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.util;
+
+import com.android.gallery3d.common.BlobCache;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+
+public class CacheManager {
+    private static final String TAG = "CacheManager";
+    private static final String KEY_CACHE_UP_TO_DATE = "cache-up-to-date";
+    private static HashMap<String, BlobCache> sCacheMap =
+            new HashMap<String, BlobCache>();
+    private static boolean sOldCheckDone = false;
+
+    // Return null when we cannot instantiate a BlobCache, e.g.:
+    // there is no SD card found.
+    // This can only be called from data thread.
+    public static BlobCache getCache(Context context, String filename,
+            int maxEntries, int maxBytes, int version) {
+        synchronized (sCacheMap) {
+            if (!sOldCheckDone) {
+                removeOldFilesIfNecessary(context);
+                sOldCheckDone = true;
+            }
+            BlobCache cache = sCacheMap.get(filename);
+            if (cache == null) {
+                File cacheDir = context.getExternalCacheDir();
+                String path = cacheDir.getAbsolutePath() + "/" + filename;
+                try {
+                    cache = new BlobCache(path, maxEntries, maxBytes, false,
+                            version);
+                    sCacheMap.put(filename, cache);
+                } catch (IOException e) {
+                    Log.e(TAG, "Cannot instantiate cache!", e);
+                }
+            }
+            return cache;
+        }
+    }
+
+    // Removes the old files if the data is wiped.
+    private static void removeOldFilesIfNecessary(Context context) {
+        SharedPreferences pref = PreferenceManager
+                .getDefaultSharedPreferences(context);
+        int n = 0;
+        try {
+            n = pref.getInt(KEY_CACHE_UP_TO_DATE, 0);
+        } catch (Throwable t) {
+            // ignore.
+        }
+        if (n != 0) return;
+        pref.edit().putInt(KEY_CACHE_UP_TO_DATE, 1).commit();
+
+        File cacheDir = context.getExternalCacheDir();
+        String prefix = cacheDir.getAbsolutePath() + "/";
+
+        BlobCache.deleteFiles(prefix + "imgcache");
+        BlobCache.deleteFiles(prefix + "rev_geocoding");
+        BlobCache.deleteFiles(prefix + "bookmark");
+    }
+}
diff --git a/src/com/android/gallery3d/util/Future.java b/src/com/android/gallery3d/util/Future.java
new file mode 100644
index 0000000..580a2a1
--- /dev/null
+++ b/src/com/android/gallery3d/util/Future.java
@@ -0,0 +1,35 @@
+/*
+ * 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.util;
+
+// This Future differs from the java.util.concurrent.Future in these aspects:
+//
+// - Once cancel() is called, isCancelled() always returns true. It is a sticky
+//   flag used to communicate to the implementation. The implmentation may
+//   ignore that flag. Regardless whether the Future is cancelled, a return
+//   value will be provided to get(). The implementation may choose to return
+//   null if it finds the Future is cancelled.
+//
+// - get() does not throw exceptions.
+//
+public interface Future<T> {
+    public void cancel();
+    public boolean isCancelled();
+    public boolean isDone();
+    public T get();
+    public void waitDone();
+}
diff --git a/src/com/android/gallery3d/util/FutureListener.java b/src/com/android/gallery3d/util/FutureListener.java
new file mode 100644
index 0000000..ed1f820
--- /dev/null
+++ b/src/com/android/gallery3d/util/FutureListener.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.util;
+
+public interface FutureListener<T> {
+    public void onFutureDone(Future<T> future);
+}
diff --git a/src/com/android/gallery3d/util/FutureTask.java b/src/com/android/gallery3d/util/FutureTask.java
new file mode 100644
index 0000000..9cfab27
--- /dev/null
+++ b/src/com/android/gallery3d/util/FutureTask.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.util;
+
+import java.util.concurrent.Callable;
+
+// NOTE: If the Callable throws any Throwable, the result value will be null.
+public class FutureTask<T> implements Runnable, Future<T> {
+    private static final String TAG = "FutureTask";
+    private Callable<T> mCallable;
+    private FutureListener<T> mListener;
+    private volatile boolean mIsCancelled;
+    private boolean mIsDone;
+    private T mResult;
+
+    public FutureTask(Callable<T> callable, FutureListener<T> listener) {
+        mCallable = callable;
+        mListener = listener;
+    }
+
+    public FutureTask(Callable<T> callable) {
+        this(callable, null);
+    }
+
+    public void cancel() {
+        mIsCancelled = true;
+    }
+
+    public synchronized T get() {
+        while (!mIsDone) {
+            try {
+                wait();
+            } catch (InterruptedException t) {
+                // ignore.
+            }
+        }
+        return mResult;
+    }
+
+    public void waitDone() {
+        get();
+    }
+
+    public synchronized boolean isDone() {
+        return mIsDone;
+    }
+
+    public boolean isCancelled() {
+        return mIsCancelled;
+    }
+
+    public void run() {
+        T result = null;
+
+        if (!mIsCancelled) {
+            try {
+                result = mCallable.call();
+            } catch (Throwable ex) {
+                Log.w(TAG, "Exception in running a task", ex);
+            }
+        }
+
+        synchronized(this) {
+            mResult = result;
+            mIsDone = true;
+            if (mListener != null) {
+                mListener.onFutureDone(this);
+            }
+            notifyAll();
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/util/GalleryUtils.java b/src/com/android/gallery3d/util/GalleryUtils.java
new file mode 100644
index 0000000..2fed46a
--- /dev/null
+++ b/src/com/android/gallery3d/util/GalleryUtils.java
@@ -0,0 +1,327 @@
+/*
+ * 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.util;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.PackagesMonitor;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.util.ThreadPool.CancelListener;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.ConditionVariable;
+import android.os.Environment;
+import android.os.StatFs;
+import android.preference.PreferenceManager;
+import android.provider.MediaStore;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.WindowManager;
+
+import java.util.Arrays;
+import java.util.List;
+
+public class GalleryUtils {
+    private static final String TAG = "GalleryUtils";
+    private static final String MAPS_PACKAGE_NAME = "com.google.android.apps.maps";
+    private static final String MAPS_CLASS_NAME = "com.google.android.maps.MapsActivity";
+
+    private static final String MIME_TYPE_IMAGE = "image/*";
+    private static final String MIME_TYPE_VIDEO = "video/*";
+    private static final String MIME_TYPE_ALL = "*/*";
+
+    private static final String PREFIX_PHOTO_EDITOR_UPDATE = "editor-update-";
+    private static final String PREFIX_HAS_PHOTO_EDITOR = "has-editor-";
+
+    private static final String KEY_CAMERA_UPDATE = "camera-update";
+    private static final String KEY_HAS_CAMERA = "has-camera";
+
+    private static Context sContext;
+
+
+    static float sPixelDensity = -1f;
+
+    public static void initialize(Context context) {
+        sContext = context;
+        if (sPixelDensity < 0) {
+            DisplayMetrics metrics = new DisplayMetrics();
+            WindowManager wm = (WindowManager)
+                    context.getSystemService(Context.WINDOW_SERVICE);
+            wm.getDefaultDisplay().getMetrics(metrics);
+            sPixelDensity = metrics.density;
+        }
+    }
+
+    public static float dpToPixel(float dp) {
+        return sPixelDensity * dp;
+    }
+
+    public static int dpToPixel(int dp) {
+        return Math.round(dpToPixel((float) dp));
+    }
+
+    public static int meterToPixel(float meter) {
+        // 1 meter = 39.37 inches, 1 inch = 160 dp.
+        return Math.round(dpToPixel(meter * 39.37f * 160));
+    }
+
+    public static byte[] getBytes(String in) {
+        byte[] result = new byte[in.length() * 2];
+        int output = 0;
+        for (char ch : in.toCharArray()) {
+            result[output++] = (byte) (ch & 0xFF);
+            result[output++] = (byte) (ch >> 8);
+        }
+        return result;
+    }
+
+    // Below are used the detect using database in the render thread. It only
+    // works most of the time, but that's ok because it's for debugging only.
+
+    private static volatile Thread sCurrentThread;
+    private static volatile boolean sWarned;
+
+    public static void setRenderThread() {
+        sCurrentThread = Thread.currentThread();
+    }
+
+    public static void assertNotInRenderThread() {
+        if (!sWarned) {
+            if (Thread.currentThread() == sCurrentThread) {
+                sWarned = true;
+                Log.w(TAG, new Throwable("Should not do this in render thread"));
+            }
+        }
+    }
+
+    private static final double RAD_PER_DEG = Math.PI / 180.0;
+    private static final double EARTH_RADIUS_METERS = 6367000.0;
+
+    public static double fastDistanceMeters(double latRad1, double lngRad1,
+            double latRad2, double lngRad2) {
+       if ((Math.abs(latRad1 - latRad2) > RAD_PER_DEG)
+             || (Math.abs(lngRad1 - lngRad2) > RAD_PER_DEG)) {
+           return accurateDistanceMeters(latRad1, lngRad1, latRad2, lngRad2);
+       }
+       // Approximate sin(x) = x.
+       double sineLat = (latRad1 - latRad2);
+
+       // Approximate sin(x) = x.
+       double sineLng = (lngRad1 - lngRad2);
+
+       // Approximate cos(lat1) * cos(lat2) using
+       // cos((lat1 + lat2)/2) ^ 2
+       double cosTerms = Math.cos((latRad1 + latRad2) / 2.0);
+       cosTerms = cosTerms * cosTerms;
+       double trigTerm = sineLat * sineLat + cosTerms * sineLng * sineLng;
+       trigTerm = Math.sqrt(trigTerm);
+
+       // Approximate arcsin(x) = x
+       return EARTH_RADIUS_METERS * trigTerm;
+    }
+
+    public static double accurateDistanceMeters(double lat1, double lng1,
+            double lat2, double lng2) {
+        double dlat = Math.sin(0.5 * (lat2 - lat1));
+        double dlng = Math.sin(0.5 * (lng2 - lng1));
+        double x = dlat * dlat + dlng * dlng * Math.cos(lat1) * Math.cos(lat2);
+        return (2 * Math.atan2(Math.sqrt(x), Math.sqrt(Math.max(0.0,
+                1.0 - x)))) * EARTH_RADIUS_METERS;
+    }
+
+
+    public static final double toMile(double meter) {
+        return meter / 1609;
+    }
+
+    // For debugging, it will block the caller for timeout millis.
+    public static void fakeBusy(JobContext jc, int timeout) {
+        final ConditionVariable cv = new ConditionVariable();
+        jc.setCancelListener(new CancelListener() {
+            public void onCancel() {
+                cv.open();
+            }
+        });
+        cv.block(timeout);
+        jc.setCancelListener(null);
+    }
+
+    public static boolean isEditorAvailable(Context context, String mimeType) {
+        int version = PackagesMonitor.getPackagesVersion(context);
+
+        String updateKey = PREFIX_PHOTO_EDITOR_UPDATE + mimeType;
+        String hasKey = PREFIX_HAS_PHOTO_EDITOR + mimeType;
+
+        SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
+        if (prefs.getInt(updateKey, 0) != version) {
+            PackageManager packageManager = context.getPackageManager();
+            List<ResolveInfo> infos = packageManager.queryIntentActivities(
+                    new Intent(Intent.ACTION_EDIT).setType(mimeType), 0);
+            prefs.edit().putInt(updateKey, version)
+                        .putBoolean(hasKey, !infos.isEmpty())
+                        .commit();
+        }
+
+        return prefs.getBoolean(hasKey, true);
+    }
+
+    public static boolean isCameraAvailable(Context context) {
+        int version = PackagesMonitor.getPackagesVersion(context);
+        SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
+        if (prefs.getInt(KEY_CAMERA_UPDATE, 0) != version) {
+            PackageManager packageManager = context.getPackageManager();
+            List<ResolveInfo> infos = packageManager.queryIntentActivities(
+                    new Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA), 0);
+            prefs.edit().putInt(KEY_CAMERA_UPDATE, version)
+                        .putBoolean(KEY_HAS_CAMERA, !infos.isEmpty())
+                        .commit();
+        }
+        return prefs.getBoolean(KEY_HAS_CAMERA, true);
+    }
+
+    public static boolean isValidLocation(double latitude, double longitude) {
+        // TODO: change || to && after we fix the default location issue
+        return (latitude != MediaItem.INVALID_LATLNG || longitude != MediaItem.INVALID_LATLNG);
+    }
+    public static void showOnMap(Context context, double latitude, double longitude) {
+        try {
+            // We don't use "geo:latitude,longitude" because it only centers
+            // the MapView to the specified location, but we need a marker
+            // for further operations (routing to/from).
+            // The q=(lat, lng) syntax is suggested by geo-team.
+            String uri = String.format("http://maps.google.com/maps?f=q&q=(%f,%f)",
+                    latitude, longitude);
+            ComponentName compName = new ComponentName(MAPS_PACKAGE_NAME,
+                    MAPS_CLASS_NAME);
+            Intent mapsIntent = new Intent(Intent.ACTION_VIEW,
+                    Uri.parse(uri)).setComponent(compName);
+            context.startActivity(mapsIntent);
+        } catch (ActivityNotFoundException e) {
+            // Use the "geo intent" if no GMM is installed
+            Log.e(TAG, "GMM activity not found!", e);
+            String url = String.format("geo:%f,%f", latitude, longitude);
+            Intent mapsIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+            context.startActivity(mapsIntent);
+        }
+    }
+
+    public static void setViewPointMatrix(
+            float matrix[], float x, float y, float z) {
+        // The matrix is
+        // -z,  0,  x,  0
+        //  0, -z,  y,  0
+        //  0,  0,  1,  0
+        //  0,  0,  1, -z
+        Arrays.fill(matrix, 0, 16, 0);
+        matrix[0] = matrix[5] = matrix[15] = -z;
+        matrix[8] = x;
+        matrix[9] = y;
+        matrix[10] = matrix[11] = 1;
+    }
+
+    public static int getBucketId(String path) {
+        return path.toLowerCase().hashCode();
+    }
+
+    // Returns a (localized) string for the given duration (in seconds).
+    public static String formatDuration(final Context context, int duration) {
+        int h = duration / 3600;
+        int m = (duration - h * 3600) / 60;
+        int s = duration - (h * 3600 + m * 60);
+        String durationValue;
+        if (h == 0) {
+            durationValue = String.format(context.getString(R.string.details_ms), m, s);
+        } else {
+            durationValue = String.format(context.getString(R.string.details_hms), h, m, s);
+        }
+        return durationValue;
+    }
+
+    public static void setSpinnerVisibility(final Activity activity,
+            final boolean visible) {
+        activity.runOnUiThread(new Runnable() {
+            public void run() {
+                activity.setProgressBarIndeterminateVisibility(visible);
+            }
+        });
+    }
+
+    public static int determineTypeBits(Context context, Intent intent) {
+        int typeBits = 0;
+        String type = intent.resolveType(context);
+        if (intent.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false)) {
+            if (MIME_TYPE_ALL.equals(type)) {
+                typeBits = DataManager.INCLUDE_LOCAL_ALL_ONLY;
+            } else if (MIME_TYPE_IMAGE.equals(type)) {
+                typeBits = DataManager.INCLUDE_LOCAL_IMAGE_ONLY;
+            } else if (MIME_TYPE_VIDEO.equals(type)) {
+                typeBits = DataManager.INCLUDE_LOCAL_VIDEO_ONLY;
+            }
+        } else {
+            if (MIME_TYPE_ALL.equals(type)) {
+                typeBits = DataManager.INCLUDE_ALL;
+            } else if (MIME_TYPE_IMAGE.equals(type)) {
+                typeBits = DataManager.INCLUDE_IMAGE;
+            } else if (MIME_TYPE_VIDEO.equals(type)) {
+                typeBits = DataManager.INCLUDE_VIDEO;
+            }
+        }
+        if (typeBits == 0) typeBits = DataManager.INCLUDE_ALL;
+
+        return typeBits;
+    }
+
+    public static int getSelectionModePrompt(int typeBits) {
+        if ((typeBits & DataManager.INCLUDE_VIDEO) != 0) {
+            return (typeBits & DataManager.INCLUDE_IMAGE) == 0
+                    ? R.string.select_video
+                    : R.string.select_item;
+        }
+        return R.string.select_image;
+    }
+
+    public static boolean hasSpaceForSize(long size) {
+        String state = Environment.getExternalStorageState();
+        if (!Environment.MEDIA_MOUNTED.equals(state)) {
+            return false;
+        }
+
+        String path = Environment.getExternalStorageDirectory().getPath();
+        try {
+            StatFs stat = new StatFs(path);
+            return stat.getAvailableBlocks() * (long) stat.getBlockSize() > size;
+        } catch (Exception e) {
+            Log.i(TAG, "Fail to access external storage", e);
+        }
+        return false;
+    }
+
+    public static void assertInMainThread() {
+        if (Thread.currentThread() == sContext.getMainLooper().getThread()) {
+            throw new AssertionError();
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/util/IdentityCache.java b/src/com/android/gallery3d/util/IdentityCache.java
new file mode 100644
index 0000000..02a46ae
--- /dev/null
+++ b/src/com/android/gallery3d/util/IdentityCache.java
@@ -0,0 +1,74 @@
+/*
+ * 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.util;
+
+import java.lang.ref.ReferenceQueue;
+import java.lang.ref.WeakReference;
+import java.util.HashMap;
+import java.util.ArrayList;
+import java.util.Set;
+
+public class IdentityCache<K, V> {
+
+    private final HashMap<K, Entry<K, V>> mWeakMap =
+            new HashMap<K, Entry<K, V>>();
+    private ReferenceQueue<V> mQueue = new ReferenceQueue<V>();
+
+    public IdentityCache() {
+    }
+
+    private static class Entry<K, V> extends WeakReference<V> {
+        K mKey;
+
+        public Entry(K key, V value, ReferenceQueue<V> queue) {
+            super(value, queue);
+            mKey = key;
+        }
+    }
+
+    private void cleanUpWeakMap() {
+        Entry<K, V> entry = (Entry<K, V>) mQueue.poll();
+        while (entry != null) {
+            mWeakMap.remove(entry.mKey);
+            entry = (Entry<K, V>) mQueue.poll();
+        }
+    }
+
+    public synchronized V put(K key, V value) {
+        cleanUpWeakMap();
+        Entry<K, V> entry = mWeakMap.put(
+                key, new Entry<K, V>(key, value, mQueue));
+        return entry == null ? null : entry.get();
+    }
+
+    public synchronized V get(K key) {
+        cleanUpWeakMap();
+        Entry<K, V> entry = mWeakMap.get(key);
+        return entry == null ? null : entry.get();
+    }
+
+    public synchronized void clear() {
+        mWeakMap.clear();
+        mQueue = new ReferenceQueue<V>();
+    }
+
+    public synchronized ArrayList<K> keys() {
+        Set<K> set = mWeakMap.keySet();
+        ArrayList<K> result = new ArrayList<K>(set);
+        return result;
+    }
+}
diff --git a/src/com/android/gallery3d/util/IntArray.java b/src/com/android/gallery3d/util/IntArray.java
new file mode 100644
index 0000000..88657bb
--- /dev/null
+++ b/src/com/android/gallery3d/util/IntArray.java
@@ -0,0 +1,54 @@
+/*
+ * 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.util;
+
+public class IntArray {
+    private static final int INIT_CAPACITY = 8;
+
+    private int mData[] = new int[INIT_CAPACITY];
+    private int mSize = 0;
+
+    public void add(int value) {
+        if (mData.length == mSize) {
+            int temp[] = new int[mSize + mSize];
+            System.arraycopy(mData, 0, temp, 0, mSize);
+            mData = temp;
+        }
+        mData[mSize++] = value;
+    }
+
+    public int size() {
+        return mSize;
+    }
+
+    public int[] toArray(int[] result) {
+        if (result == null || result.length < mSize) {
+            result = new int[mSize];
+        }
+        System.arraycopy(mData, 0, result, 0, mSize);
+        return result;
+    }
+
+    public int[] getInternalArray() {
+        return mData;
+    }
+
+    public void clear() {
+        mSize = 0;
+        if (mData.length != INIT_CAPACITY) mData = new int[INIT_CAPACITY];
+    }
+}
diff --git a/src/com/android/gallery3d/util/InterruptableOutputStream.java b/src/com/android/gallery3d/util/InterruptableOutputStream.java
new file mode 100644
index 0000000..1ab62ab
--- /dev/null
+++ b/src/com/android/gallery3d/util/InterruptableOutputStream.java
@@ -0,0 +1,67 @@
+/*
+ * 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.util;
+
+import com.android.gallery3d.common.Utils;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.io.OutputStream;
+
+public class InterruptableOutputStream extends OutputStream {
+
+    private static final int MAX_WRITE_BYTES = 4096;
+
+    private OutputStream mOutputStream;
+    private volatile boolean mIsInterrupted = false;
+
+    public InterruptableOutputStream(OutputStream outputStream) {
+        mOutputStream = Utils.checkNotNull(outputStream);
+    }
+
+    @Override
+    public void write(int oneByte) throws IOException {
+        if (mIsInterrupted) throw new InterruptedIOException();
+        mOutputStream.write(oneByte);
+    }
+
+    @Override
+    public void write(byte[] buffer, int offset, int count) throws IOException {
+        int end = offset + count;
+        while (offset < end) {
+            if (mIsInterrupted) throw new InterruptedIOException();
+            int bytesCount = Math.min(MAX_WRITE_BYTES, end - offset);
+            mOutputStream.write(buffer, offset, bytesCount);
+            offset += bytesCount;
+        }
+    }
+
+    @Override
+    public void close() throws IOException {
+        mOutputStream.close();
+    }
+
+    @Override
+    public void flush() throws IOException {
+        if (mIsInterrupted) throw new InterruptedIOException();
+        mOutputStream.flush();
+    }
+
+    public void interrupt() {
+        mIsInterrupted = true;
+    }
+}
diff --git a/src/com/android/gallery3d/util/LinkedNode.java b/src/com/android/gallery3d/util/LinkedNode.java
new file mode 100644
index 0000000..8554acd
--- /dev/null
+++ b/src/com/android/gallery3d/util/LinkedNode.java
@@ -0,0 +1,75 @@
+/*
+ * 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.util;
+
+
+public class LinkedNode {
+    private LinkedNode mPrev;
+    private LinkedNode mNext;
+
+    public LinkedNode() {
+        mPrev = mNext = this;
+    }
+
+    public void insert(LinkedNode node) {
+        node.mNext = mNext;
+        mNext.mPrev = node;
+        node.mPrev = this;
+        mNext = node;
+    }
+
+    public void remove() {
+        if (mNext == this) throw new IllegalStateException();
+        mPrev.mNext = mNext;
+        mNext.mPrev = mPrev;
+        mPrev = mNext = null;
+    }
+
+    @SuppressWarnings("unchecked")
+    public static class List<T extends LinkedNode> {
+        private LinkedNode mHead = new LinkedNode();
+
+        public void insertFirst(T node) {
+            mHead.insert(node);
+        }
+
+        public void insertLast(T node) {
+            mHead.mPrev.insert(node);
+        }
+
+        public T getFirst() {
+            return (T) (mHead.mNext == mHead ? null : mHead.mNext);
+        }
+
+        public T getLast() {
+            return (T) (mHead.mPrev == mHead ? null : mHead.mPrev);
+        }
+
+        public T nextOf(T node) {
+            return (T) (node.mNext == mHead ? null : node.mNext);
+        }
+
+        public T previousOf(T node) {
+            return (T) (node.mPrev == mHead ? null : node.mPrev);
+        }
+
+    }
+
+    public static <T extends LinkedNode> List<T> newList() {
+        return new List<T>();
+    }
+}
diff --git a/src/com/android/gallery3d/util/Log.java b/src/com/android/gallery3d/util/Log.java
new file mode 100644
index 0000000..d7f8e85
--- /dev/null
+++ b/src/com/android/gallery3d/util/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.util;
+
+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/util/MediaSetUtils.java b/src/com/android/gallery3d/util/MediaSetUtils.java
new file mode 100644
index 0000000..817ffed
--- /dev/null
+++ b/src/com/android/gallery3d/util/MediaSetUtils.java
@@ -0,0 +1,56 @@
+/*
+ * 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.util;
+
+import com.android.gallery3d.data.MediaSet;
+import com.android.gallery3d.data.MtpContext;
+import com.android.gallery3d.data.Path;
+
+import android.os.Environment;
+
+import java.util.Comparator;
+
+public class MediaSetUtils {
+    public static final Comparator<MediaSet> NAME_COMPARATOR = new NameComparator();
+
+    public static final int CAMERA_BUCKET_ID = GalleryUtils.getBucketId(
+            Environment.getExternalStorageDirectory().toString() + "/DCIM/Camera");
+    public static final int DOWNLOAD_BUCKET_ID = GalleryUtils.getBucketId(
+            Environment.getExternalStorageDirectory().toString() + "/download");
+    public static final int IMPORTED_BUCKET_ID = GalleryUtils.getBucketId(
+            Environment.getExternalStorageDirectory().toString() + "/"
+            + MtpContext.NAME_IMPORTED_FOLDER);
+
+    private static final Path[] CAMERA_PATHS = {
+            Path.fromString("/local/all/" + CAMERA_BUCKET_ID),
+            Path.fromString("/local/image/" + CAMERA_BUCKET_ID),
+            Path.fromString("/local/video/" + CAMERA_BUCKET_ID)};
+
+    public static boolean isCameraSource(Path path) {
+        return CAMERA_PATHS[0] == path || CAMERA_PATHS[1] == path
+                || CAMERA_PATHS[2] == path;
+    }
+
+    // Sort MediaSets by name
+    public static class NameComparator implements Comparator<MediaSet> {
+        public int compare(MediaSet set1, MediaSet set2) {
+            int result = set1.getName().compareToIgnoreCase(set2.getName());
+            if (result != 0) return result;
+            return set1.getPath().toString().compareTo(set2.getPath().toString());
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/util/PriorityThreadFactory.java b/src/com/android/gallery3d/util/PriorityThreadFactory.java
new file mode 100644
index 0000000..67b2152
--- /dev/null
+++ b/src/com/android/gallery3d/util/PriorityThreadFactory.java
@@ -0,0 +1,48 @@
+/*
+ * 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.util;
+
+
+import android.os.Process;
+
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * A thread factory that creates threads with a given thread priority.
+ */
+public class PriorityThreadFactory implements ThreadFactory {
+
+    private final int mPriority;
+    private final AtomicInteger mNumber = new AtomicInteger();
+    private final String mName;
+
+    public PriorityThreadFactory(String name, int priority) {
+        mName = name;
+        mPriority = priority;
+    }
+
+    public Thread newThread(Runnable r) {
+        return new Thread(r, mName + '-' + mNumber.getAndIncrement()) {
+            @Override
+            public void run() {
+                Process.setThreadPriority(mPriority);
+                super.run();
+            }
+        };
+    }
+
+}
diff --git a/src/com/android/gallery3d/util/ReverseGeocoder.java b/src/com/android/gallery3d/util/ReverseGeocoder.java
new file mode 100644
index 0000000..d253b4b
--- /dev/null
+++ b/src/com/android/gallery3d/util/ReverseGeocoder.java
@@ -0,0 +1,417 @@
+/*
+ * 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.util;
+
+import com.android.gallery3d.common.BlobCache;
+
+import android.content.Context;
+import android.location.Address;
+import android.location.Geocoder;
+import android.location.Location;
+import android.location.LocationManager;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+
+public class ReverseGeocoder {
+    private static final String TAG = "ReverseGeocoder";
+    public static final int EARTH_RADIUS_METERS = 6378137;
+    public static final int LAT_MIN = -90;
+    public static final int LAT_MAX = 90;
+    public static final int LON_MIN = -180;
+    public static final int LON_MAX = 180;
+    private static final int MAX_COUNTRY_NAME_LENGTH = 8;
+    // If two points are within 20 miles of each other, use
+    // "Around Palo Alto, CA" or "Around Mountain View, CA".
+    // instead of directly jumping to the next level and saying
+    // "California, US".
+    private static final int MAX_LOCALITY_MILE_RANGE = 20;
+
+    private static final String GEO_CACHE_FILE = "rev_geocoding";
+    private static final int GEO_CACHE_MAX_ENTRIES = 1000;
+    private static final int GEO_CACHE_MAX_BYTES = 500 * 1024;
+    private static final int GEO_CACHE_VERSION = 0;
+
+    public static class SetLatLong {
+        // The latitude and longitude of the min latitude point.
+        public double mMinLatLatitude = LAT_MAX;
+        public double mMinLatLongitude;
+        // The latitude and longitude of the max latitude point.
+        public double mMaxLatLatitude = LAT_MIN;
+        public double mMaxLatLongitude;
+        // The latitude and longitude of the min longitude point.
+        public double mMinLonLatitude;
+        public double mMinLonLongitude = LON_MAX;
+        // The latitude and longitude of the max longitude point.
+        public double mMaxLonLatitude;
+        public double mMaxLonLongitude = LON_MIN;
+    }
+
+    private Context mContext;
+    private Geocoder mGeocoder;
+    private BlobCache mGeoCache;
+    private ConnectivityManager mConnectivityManager;
+    private static Address sCurrentAddress; // last known address
+
+    public ReverseGeocoder(Context context) {
+        mContext = context;
+        mGeocoder = new Geocoder(mContext);
+        mGeoCache = CacheManager.getCache(context, GEO_CACHE_FILE,
+                GEO_CACHE_MAX_ENTRIES, GEO_CACHE_MAX_BYTES,
+                GEO_CACHE_VERSION);
+        mConnectivityManager = (ConnectivityManager)
+                context.getSystemService(Context.CONNECTIVITY_SERVICE);
+    }
+
+    public String computeAddress(SetLatLong set) {
+        // The overall min and max latitudes and longitudes of the set.
+        double setMinLatitude = set.mMinLatLatitude;
+        double setMinLongitude = set.mMinLatLongitude;
+        double setMaxLatitude = set.mMaxLatLatitude;
+        double setMaxLongitude = set.mMaxLatLongitude;
+        if (Math.abs(set.mMaxLatLatitude - set.mMinLatLatitude)
+                < Math.abs(set.mMaxLonLongitude - set.mMinLonLongitude)) {
+            setMinLatitude = set.mMinLonLatitude;
+            setMinLongitude = set.mMinLonLongitude;
+            setMaxLatitude = set.mMaxLonLatitude;
+            setMaxLongitude = set.mMaxLonLongitude;
+        }
+        Address addr1 = lookupAddress(setMinLatitude, setMinLongitude, true);
+        Address addr2 = lookupAddress(setMaxLatitude, setMaxLongitude, true);
+        if (addr1 == null)
+            addr1 = addr2;
+        if (addr2 == null)
+            addr2 = addr1;
+        if (addr1 == null || addr2 == null) {
+            return null;
+        }
+
+        // Get current location, we decide the granularity of the string based
+        // on this.
+        LocationManager locationManager =
+                (LocationManager) mContext.getSystemService(Context.LOCATION_SERVICE);
+        Location location = null;
+        List<String> providers = locationManager.getAllProviders();
+        for (int i = 0; i < providers.size(); ++i) {
+            String provider = providers.get(i);
+            location = (provider != null) ? locationManager.getLastKnownLocation(provider) : null;
+            if (location != null)
+                break;
+        }
+        String currentCity = "";
+        String currentAdminArea = "";
+        String currentCountry = Locale.getDefault().getCountry();
+        if (location != null) {
+            Address currentAddress = lookupAddress(
+                    location.getLatitude(), location.getLongitude(), true);
+            if (currentAddress == null) {
+                currentAddress = sCurrentAddress;
+            } else {
+                sCurrentAddress = currentAddress;
+            }
+            if (currentAddress != null && currentAddress.getCountryCode() != null) {
+                currentCity = checkNull(currentAddress.getLocality());
+                currentCountry = checkNull(currentAddress.getCountryCode());
+                currentAdminArea = checkNull(currentAddress.getAdminArea());
+            }
+        }
+
+        String closestCommonLocation = null;
+        String addr1Locality = checkNull(addr1.getLocality());
+        String addr2Locality = checkNull(addr2.getLocality());
+        String addr1AdminArea = checkNull(addr1.getAdminArea());
+        String addr2AdminArea = checkNull(addr2.getAdminArea());
+        String addr1CountryCode = checkNull(addr1.getCountryCode());
+        String addr2CountryCode = checkNull(addr2.getCountryCode());
+
+        if (currentCity.equals(addr1Locality) || currentCity.equals(addr2Locality)) {
+            String otherCity = currentCity;
+            if (currentCity.equals(addr1Locality)) {
+                otherCity = addr2Locality;
+                if (otherCity.length() == 0) {
+                    otherCity = addr2AdminArea;
+                    if (!currentCountry.equals(addr2CountryCode)) {
+                        otherCity += " " + addr2CountryCode;
+                    }
+                }
+                addr2Locality = addr1Locality;
+                addr2AdminArea = addr1AdminArea;
+                addr2CountryCode = addr1CountryCode;
+            } else {
+                otherCity = addr1Locality;
+                if (otherCity.length() == 0) {
+                    otherCity = addr1AdminArea;
+                    if (!currentCountry.equals(addr1CountryCode)) {
+                        otherCity += " " + addr1CountryCode;
+                    }
+                }
+                addr1Locality = addr2Locality;
+                addr1AdminArea = addr2AdminArea;
+                addr1CountryCode = addr2CountryCode;
+            }
+            closestCommonLocation = valueIfEqual(addr1.getAddressLine(0), addr2.getAddressLine(0));
+            if (closestCommonLocation != null && !("null".equals(closestCommonLocation))) {
+                if (!currentCity.equals(otherCity)) {
+                    closestCommonLocation += " - " + otherCity;
+                }
+                return closestCommonLocation;
+            }
+
+            // Compare thoroughfare (street address) next.
+            closestCommonLocation = valueIfEqual(addr1.getThoroughfare(), addr2.getThoroughfare());
+            if (closestCommonLocation != null && !("null".equals(closestCommonLocation))) {
+                return closestCommonLocation;
+            }
+        }
+
+        // Compare the locality.
+        closestCommonLocation = valueIfEqual(addr1Locality, addr2Locality);
+        if (closestCommonLocation != null && !("".equals(closestCommonLocation))) {
+            String adminArea = addr1AdminArea;
+            String countryCode = addr1CountryCode;
+            if (adminArea != null && adminArea.length() > 0) {
+                if (!countryCode.equals(currentCountry)) {
+                    closestCommonLocation += ", " + adminArea + " " + countryCode;
+                } else {
+                    closestCommonLocation += ", " + adminArea;
+                }
+            }
+            return closestCommonLocation;
+        }
+
+        // If the admin area is the same as the current location, we hide it and
+        // instead show the city name.
+        if (currentAdminArea.equals(addr1AdminArea) && currentAdminArea.equals(addr2AdminArea)) {
+            if ("".equals(addr1Locality)) {
+                addr1Locality = addr2Locality;
+            }
+            if ("".equals(addr2Locality)) {
+                addr2Locality = addr1Locality;
+            }
+            if (!"".equals(addr1Locality)) {
+                if (addr1Locality.equals(addr2Locality)) {
+                    closestCommonLocation = addr1Locality + ", " + currentAdminArea;
+                } else {
+                    closestCommonLocation = addr1Locality + " - " + addr2Locality;
+                }
+                return closestCommonLocation;
+            }
+        }
+
+        // Just choose one of the localities if within a MAX_LOCALITY_MILE_RANGE
+        // mile radius.
+        float[] distanceFloat = new float[1];
+        Location.distanceBetween(setMinLatitude, setMinLongitude,
+                setMaxLatitude, setMaxLongitude, distanceFloat);
+        int distance = (int) GalleryUtils.toMile(distanceFloat[0]);
+        if (distance < MAX_LOCALITY_MILE_RANGE) {
+            // Try each of the points and just return the first one to have a
+            // valid address.
+            closestCommonLocation = getLocalityAdminForAddress(addr1, true);
+            if (closestCommonLocation != null) {
+                return closestCommonLocation;
+            }
+            closestCommonLocation = getLocalityAdminForAddress(addr2, true);
+            if (closestCommonLocation != null) {
+                return closestCommonLocation;
+            }
+        }
+
+        // Check the administrative area.
+        closestCommonLocation = valueIfEqual(addr1AdminArea, addr2AdminArea);
+        if (closestCommonLocation != null && !("".equals(closestCommonLocation))) {
+            String countryCode = addr1CountryCode;
+            if (!countryCode.equals(currentCountry)) {
+                if (countryCode != null && countryCode.length() > 0) {
+                    closestCommonLocation += " " + countryCode;
+                }
+            }
+            return closestCommonLocation;
+        }
+
+        // Check the country codes.
+        closestCommonLocation = valueIfEqual(addr1CountryCode, addr2CountryCode);
+        if (closestCommonLocation != null && !("".equals(closestCommonLocation))) {
+            return closestCommonLocation;
+        }
+        // There is no intersection, let's choose a nicer name.
+        String addr1Country = addr1.getCountryName();
+        String addr2Country = addr2.getCountryName();
+        if (addr1Country == null)
+            addr1Country = addr1CountryCode;
+        if (addr2Country == null)
+            addr2Country = addr2CountryCode;
+        if (addr1Country == null || addr2Country == null)
+            return null;
+        if (addr1Country.length() > MAX_COUNTRY_NAME_LENGTH || addr2Country.length() > MAX_COUNTRY_NAME_LENGTH) {
+            closestCommonLocation = addr1CountryCode + " - " + addr2CountryCode;
+        } else {
+            closestCommonLocation = addr1Country + " - " + addr2Country;
+        }
+        return closestCommonLocation;
+    }
+
+    private String checkNull(String locality) {
+        if (locality == null)
+            return "";
+        if (locality.equals("null"))
+            return "";
+        return locality;
+    }
+
+    private String getLocalityAdminForAddress(final Address addr, final boolean approxLocation) {
+        if (addr == null)
+            return "";
+        String localityAdminStr = addr.getLocality();
+        if (localityAdminStr != null && !("null".equals(localityAdminStr))) {
+            if (approxLocation) {
+                // TODO: Uncomment these lines as soon as we may translations
+                // for Res.string.around.
+                // localityAdminStr =
+                // mContext.getResources().getString(Res.string.around) + " " +
+                // localityAdminStr;
+            }
+            String adminArea = addr.getAdminArea();
+            if (adminArea != null && adminArea.length() > 0) {
+                localityAdminStr += ", " + adminArea;
+            }
+            return localityAdminStr;
+        }
+        return null;
+    }
+
+    public Address lookupAddress(final double latitude, final double longitude,
+            boolean useCache) {
+        try {
+            long locationKey = (long) (((latitude + LAT_MAX) * 2 * LAT_MAX
+                    + (longitude + LON_MAX)) * EARTH_RADIUS_METERS);
+            byte[] cachedLocation = null;
+            if (useCache && mGeoCache != null) {
+                cachedLocation = mGeoCache.lookup(locationKey);
+            }
+            Address address = null;
+            NetworkInfo networkInfo = mConnectivityManager.getActiveNetworkInfo();
+            if (cachedLocation == null || cachedLocation.length == 0) {
+                if (networkInfo == null || !networkInfo.isConnected()) {
+                    return null;
+                }
+                List<Address> addresses = mGeocoder.getFromLocation(latitude, longitude, 1);
+                if (!addresses.isEmpty()) {
+                    address = addresses.get(0);
+                    ByteArrayOutputStream bos = new ByteArrayOutputStream();
+                    DataOutputStream dos = new DataOutputStream(bos);
+                    Locale locale = address.getLocale();
+                    writeUTF(dos, locale.getLanguage());
+                    writeUTF(dos, locale.getCountry());
+                    writeUTF(dos, locale.getVariant());
+
+                    writeUTF(dos, address.getThoroughfare());
+                    int numAddressLines = address.getMaxAddressLineIndex();
+                    dos.writeInt(numAddressLines);
+                    for (int i = 0; i < numAddressLines; ++i) {
+                        writeUTF(dos, address.getAddressLine(i));
+                    }
+                    writeUTF(dos, address.getFeatureName());
+                    writeUTF(dos, address.getLocality());
+                    writeUTF(dos, address.getAdminArea());
+                    writeUTF(dos, address.getSubAdminArea());
+
+                    writeUTF(dos, address.getCountryName());
+                    writeUTF(dos, address.getCountryCode());
+                    writeUTF(dos, address.getPostalCode());
+                    writeUTF(dos, address.getPhone());
+                    writeUTF(dos, address.getUrl());
+
+                    dos.flush();
+                    if (mGeoCache != null) {
+                        mGeoCache.insert(locationKey, bos.toByteArray());
+                    }
+                    dos.close();
+                }
+            } else {
+                // Parsing the address from the byte stream.
+                DataInputStream dis = new DataInputStream(
+                        new ByteArrayInputStream(cachedLocation));
+                String language = readUTF(dis);
+                String country = readUTF(dis);
+                String variant = readUTF(dis);
+                Locale locale = null;
+                if (language != null) {
+                    if (country == null) {
+                        locale = new Locale(language);
+                    } else if (variant == null) {
+                        locale = new Locale(language, country);
+                    } else {
+                        locale = new Locale(language, country, variant);
+                    }
+                }
+                if (!locale.getLanguage().equals(Locale.getDefault().getLanguage())) {
+                    dis.close();
+                    return lookupAddress(latitude, longitude, false);
+                }
+                address = new Address(locale);
+
+                address.setThoroughfare(readUTF(dis));
+                int numAddressLines = dis.readInt();
+                for (int i = 0; i < numAddressLines; ++i) {
+                    address.setAddressLine(i, readUTF(dis));
+                }
+                address.setFeatureName(readUTF(dis));
+                address.setLocality(readUTF(dis));
+                address.setAdminArea(readUTF(dis));
+                address.setSubAdminArea(readUTF(dis));
+
+                address.setCountryName(readUTF(dis));
+                address.setCountryCode(readUTF(dis));
+                address.setPostalCode(readUTF(dis));
+                address.setPhone(readUTF(dis));
+                address.setUrl(readUTF(dis));
+                dis.close();
+            }
+            return address;
+        } catch (Exception e) {
+            // Ignore.
+        }
+        return null;
+    }
+
+    private String valueIfEqual(String a, String b) {
+        return (a != null && b != null && a.equalsIgnoreCase(b)) ? a : null;
+    }
+
+    public static final void writeUTF(DataOutputStream dos, String string) throws IOException {
+        if (string == null) {
+            dos.writeUTF("");
+        } else {
+            dos.writeUTF(string);
+        }
+    }
+
+    public static final String readUTF(DataInputStream dis) throws IOException {
+        String retVal = dis.readUTF();
+        if (retVal.length() == 0)
+            return null;
+        return retVal;
+    }
+}
diff --git a/src/com/android/gallery3d/util/ThreadPool.java b/src/com/android/gallery3d/util/ThreadPool.java
new file mode 100644
index 0000000..71bb3c5
--- /dev/null
+++ b/src/com/android/gallery3d/util/ThreadPool.java
@@ -0,0 +1,252 @@
+/*
+ * 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.util;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+public class ThreadPool {
+    private static final String TAG = "ThreadPool";
+    private static final int CORE_POOL_SIZE = 4;
+    private static final int MAX_POOL_SIZE = 8;
+    private static final int KEEP_ALIVE_TIME = 10; // 10 seconds
+
+    // Resource type
+    public static final int MODE_NONE = 0;
+    public static final int MODE_CPU = 1;
+    public static final int MODE_NETWORK = 2;
+
+    public static final JobContext JOB_CONTEXT_STUB = new JobContextStub();
+
+    ResourceCounter mCpuCounter = new ResourceCounter(2);
+    ResourceCounter mNetworkCounter = new ResourceCounter(2);
+
+    // A Job is like a Callable, but it has an addition JobContext parameter.
+    public interface Job<T> {
+        public T run(JobContext jc);
+    }
+
+    public interface JobContext {
+        boolean isCancelled();
+        void setCancelListener(CancelListener listener);
+        boolean setMode(int mode);
+    }
+
+    private static class JobContextStub implements JobContext {
+        @Override
+        public boolean isCancelled() {
+            return false;
+        }
+
+        @Override
+        public void setCancelListener(CancelListener listener) {
+        }
+
+        @Override
+        public boolean setMode(int mode) {
+            return true;
+        }
+    }
+
+    public interface CancelListener {
+        public void onCancel();
+    }
+
+    private static class ResourceCounter {
+        public int value;
+        public ResourceCounter(int v) {
+            value = v;
+        }
+    }
+
+    private final Executor mExecutor;
+
+    public ThreadPool() {
+        mExecutor = new ThreadPoolExecutor(
+                CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME,
+                TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(),
+                new PriorityThreadFactory("thread-pool",
+                android.os.Process.THREAD_PRIORITY_BACKGROUND));
+    }
+
+    // Submit a job to the thread pool. The listener will be called when the
+    // job is finished (or cancelled).
+    public <T> Future<T> submit(Job<T> job, FutureListener<T> listener) {
+        Worker<T> w = new Worker<T>(job, listener);
+        mExecutor.execute(w);
+        return w;
+    }
+
+    public <T> Future<T> submit(Job<T> job) {
+        return submit(job, null);
+    }
+
+    private class Worker<T> implements Runnable, Future<T>, JobContext {
+        private static final String TAG = "Worker";
+        private Job<T> mJob;
+        private FutureListener<T> mListener;
+        private CancelListener mCancelListener;
+        private ResourceCounter mWaitOnResource;
+        private volatile boolean mIsCancelled;
+        private boolean mIsDone;
+        private T mResult;
+        private int mMode;
+
+        public Worker(Job<T> job, FutureListener<T> listener) {
+            mJob = job;
+            mListener = listener;
+        }
+
+        // This is called by a thread in the thread pool.
+        public void run() {
+            T result = null;
+
+            // A job is in CPU mode by default. setMode returns false
+            // if the job is cancelled.
+            if (setMode(MODE_CPU)) {
+                try {
+                    result = mJob.run(this);
+                } catch (Throwable ex) {
+                    Log.w(TAG, "Exception in running a job", ex);
+                }
+            }
+
+            synchronized(this) {
+                setMode(MODE_NONE);
+                mResult = result;
+                mIsDone = true;
+                notifyAll();
+            }
+            if (mListener != null) mListener.onFutureDone(this);
+        }
+
+        // Below are the methods for Future.
+        public synchronized void cancel() {
+            if (mIsCancelled) return;
+            mIsCancelled = true;
+            if (mWaitOnResource != null) {
+                synchronized (mWaitOnResource) {
+                    mWaitOnResource.notifyAll();
+                }
+            }
+            if (mCancelListener != null) {
+                mCancelListener.onCancel();
+            }
+        }
+
+        public boolean isCancelled() {
+            return mIsCancelled;
+        }
+
+        public synchronized boolean isDone() {
+            return mIsDone;
+        }
+
+        public synchronized T get() {
+            while (!mIsDone) {
+                try {
+                    wait();
+                } catch (Exception ex) {
+                    Log.w(TAG, "ingore exception", ex);
+                    // ignore.
+                }
+            }
+            return mResult;
+        }
+
+        public void waitDone() {
+            get();
+        }
+
+        // Below are the methods for JobContext (only called from the
+        // thread running the job)
+        public synchronized void setCancelListener(CancelListener listener) {
+            mCancelListener = listener;
+            if (mIsCancelled && mCancelListener != null) {
+                mCancelListener.onCancel();
+            }
+        }
+
+        public boolean setMode(int mode) {
+            // Release old resource
+            ResourceCounter rc = modeToCounter(mMode);
+            if (rc != null) releaseResource(rc);
+            mMode = MODE_NONE;
+
+            // Acquire new resource
+            rc = modeToCounter(mode);
+            if (rc != null) {
+                if (!acquireResource(rc)) {
+                    return false;
+                }
+                mMode = mode;
+            }
+
+            return true;
+        }
+
+        private ResourceCounter modeToCounter(int mode) {
+            if (mode == MODE_CPU) {
+                return mCpuCounter;
+            } else if (mode == MODE_NETWORK) {
+                return mNetworkCounter;
+            } else {
+                return null;
+            }
+        }
+
+        private boolean acquireResource(ResourceCounter counter) {
+            while (true) {
+                synchronized (this) {
+                    if (mIsCancelled) {
+                        mWaitOnResource = null;
+                        return false;
+                    }
+                    mWaitOnResource = counter;
+                }
+
+                synchronized (counter) {
+                    if (counter.value > 0) {
+                        counter.value--;
+                        break;
+                    } else {
+                        try {
+                            counter.wait();
+                        } catch (InterruptedException ex) {
+                            // ignore.
+                        }
+                    }
+                }
+            }
+
+            synchronized (this) {
+                mWaitOnResource = null;
+            }
+
+            return true;
+        }
+
+        private void releaseResource(ResourceCounter counter) {
+            synchronized (counter) {
+                counter.value++;
+                counter.notifyAll();
+            }
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/util/UpdateHelper.java b/src/com/android/gallery3d/util/UpdateHelper.java
new file mode 100644
index 0000000..9fdade6
--- /dev/null
+++ b/src/com/android/gallery3d/util/UpdateHelper.java
@@ -0,0 +1,67 @@
+/*
+ * 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.util;
+
+import com.android.gallery3d.common.Utils;
+
+public class UpdateHelper {
+
+    private boolean mUpdated = false;
+
+    public int update(int original, int update) {
+        if (original != update) {
+            mUpdated = true;
+            original = update;
+        }
+        return original;
+    }
+
+    public long update(long original, long update) {
+        if (original != update) {
+            mUpdated = true;
+            original = update;
+        }
+        return original;
+    }
+
+    public double update(double original, double update) {
+        if (original != update) {
+            mUpdated = true;
+            original = update;
+        }
+        return original;
+    }
+
+    public double update(float original, float update) {
+        if (original != update) {
+            mUpdated = true;
+            original = update;
+        }
+        return original;
+    }
+
+    public <T> T update(T original, T update) {
+        if (!Utils.equals(original, update)) {
+            mUpdated = true;
+            original = update;
+        }
+        return original;
+    }
+
+    public boolean isUpdated() {
+        return mUpdated;
+    }
+}
diff --git a/src/com/android/gallery3d/widget/LocalPhotoSource.java b/src/com/android/gallery3d/widget/LocalPhotoSource.java
new file mode 100644
index 0000000..de16a71
--- /dev/null
+++ b/src/com/android/gallery3d/widget/LocalPhotoSource.java
@@ -0,0 +1,202 @@
+/*
+ * 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.widget;
+
+import com.android.gallery3d.app.GalleryApp;
+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.Path;
+import com.android.gallery3d.util.GalleryUtils;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Environment;
+import android.os.Handler;
+import android.provider.MediaStore.Images.Media;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Random;
+
+public class LocalPhotoSource implements WidgetSource {
+
+    private static final String TAG = "LocalPhotoSource";
+
+    private static final int MAX_PHOTO_COUNT = 128;
+
+    /* Static fields used to query for the correct set of images */
+    private static final Uri CONTENT_URI = Media.EXTERNAL_CONTENT_URI;
+    private static final String DATE_TAKEN = Media.DATE_TAKEN;
+    private static final String[] PROJECTION = {Media._ID};
+    private static final String[] COUNT_PROJECTION = {"count(*)"};
+    /* We don't want to include the download directory */
+    private static final String SELECTION =
+            String.format("%s != %s", Media.BUCKET_ID, getDownloadBucketId());
+    private static final String ORDER = String.format("%s DESC", DATE_TAKEN);
+
+    private Context mContext;
+    private ArrayList<Long> mPhotos = new ArrayList<Long>();
+    private ContentListener mContentListener;
+    private ContentObserver mContentObserver;
+    private boolean mContentDirty = true;
+    private DataManager mDataManager;
+    private static final Path LOCAL_IMAGE_ROOT = Path.fromString("/local/image/item");
+
+    public LocalPhotoSource(Context context) {
+        mContext = context;
+        mDataManager = ((GalleryApp) context.getApplicationContext()).getDataManager();
+        mContentObserver = new ContentObserver(new Handler()) {
+            @Override
+            public void onChange(boolean selfChange) {
+                mContentDirty = true;
+                if (mContentListener != null) mContentListener.onContentDirty();
+            }
+        };
+        mContext.getContentResolver()
+                .registerContentObserver(CONTENT_URI, true, mContentObserver);
+    }
+
+    public void close() {
+        mContext.getContentResolver().unregisterContentObserver(mContentObserver);
+    }
+
+    @Override
+    public Uri getContentUri(int index) {
+        if (index < mPhotos.size()) {
+            return CONTENT_URI.buildUpon()
+                    .appendPath(String.valueOf(mPhotos.get(index)))
+                    .build();
+        }
+        return null;
+    }
+
+    @Override
+    public Bitmap getImage(int index) {
+        if (index >= mPhotos.size()) return null;
+        long id = mPhotos.get(index);
+        MediaItem image = (MediaItem)
+                mDataManager.getMediaObject(LOCAL_IMAGE_ROOT.getChild(id));
+        if (image == null) return null;
+
+        return WidgetUtils.createWidgetBitmap(image);
+    }
+
+    private int[] getExponentialIndice(int total, int count) {
+        Random random = new Random();
+        if (count > total) count = total;
+        HashSet<Integer> selected = new HashSet<Integer>(count);
+        while (selected.size() < count) {
+            int row = (int)(-Math.log(random.nextDouble()) * total / 2);
+            if (row < total) selected.add(row);
+        }
+        int values[] = new int[count];
+        int index = 0;
+        for (int value : selected) {
+            values[index++] = value;
+        }
+        return values;
+    }
+
+    private int getPhotoCount(ContentResolver resolver) {
+        Cursor cursor = resolver.query(
+                CONTENT_URI, COUNT_PROJECTION, SELECTION, null, null);
+        if (cursor == null) return 0;
+        try {
+            Utils.assertTrue(cursor.moveToNext());
+            return cursor.getInt(0);
+        } finally {
+            cursor.close();
+        }
+    }
+
+    private boolean isContentSound(int totalCount) {
+        if (mPhotos.size() < Math.min(totalCount, MAX_PHOTO_COUNT)) return false;
+        if (mPhotos.size() == 0) return true; // totalCount is also 0
+
+        StringBuilder builder = new StringBuilder();
+        for (Long imageId : mPhotos) {
+            if (builder.length() > 0) builder.append(",");
+            builder.append(imageId);
+        }
+        Cursor cursor = mContext.getContentResolver().query(
+                CONTENT_URI, COUNT_PROJECTION,
+                String.format("%s in (%s)", Media._ID, builder.toString()),
+                null, null);
+        if (cursor == null) return false;
+        try {
+            Utils.assertTrue(cursor.moveToNext());
+            return cursor.getInt(0) == mPhotos.size();
+        } finally {
+            cursor.close();
+        }
+    }
+
+    public void reload() {
+        if (!mContentDirty) return;
+        mContentDirty = false;
+
+        ContentResolver resolver = mContext.getContentResolver();
+        int photoCount = getPhotoCount(resolver);
+        if (isContentSound(photoCount)) return;
+
+        int choosedIds[] = getExponentialIndice(photoCount, MAX_PHOTO_COUNT);
+        Arrays.sort(choosedIds);
+
+        mPhotos.clear();
+        Cursor cursor = mContext.getContentResolver().query(
+                CONTENT_URI, PROJECTION, SELECTION, null, ORDER);
+        if (cursor == null) return;
+        try {
+            for (int index : choosedIds) {
+                if (cursor.moveToPosition(index)) {
+                    mPhotos.add(cursor.getLong(0));
+                }
+            }
+        } finally {
+            cursor.close();
+        }
+    }
+
+    @Override
+    public int size() {
+        reload();
+        return mPhotos.size();
+    }
+
+    /**
+     * Builds the bucket ID for the public external storage Downloads directory
+     * @return the bucket ID
+     */
+    private static int getDownloadBucketId() {
+        String downloadsPath = Environment
+                .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+                .getAbsolutePath();
+        return GalleryUtils.getBucketId(downloadsPath);
+    }
+
+    @Override
+    public void setContentListener(ContentListener listener) {
+        mContentListener = listener;
+    }
+}
diff --git a/src/com/android/gallery3d/widget/MediaSetSource.java b/src/com/android/gallery3d/widget/MediaSetSource.java
new file mode 100644
index 0000000..1677f69
--- /dev/null
+++ b/src/com/android/gallery3d/widget/MediaSetSource.java
@@ -0,0 +1,113 @@
+/*
+ * 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.widget;
+
+import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.ContentListener;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.data.MediaSet;
+
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Binder;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+
+public class MediaSetSource implements WidgetSource, ContentListener {
+    private static final int CACHE_SIZE = 32;
+
+    private static final String TAG = "MediaSetSource";
+
+    private MediaSet mSource;
+    private MediaItem mCache[] = new MediaItem[CACHE_SIZE];
+    private int mCacheStart;
+    private int mCacheEnd;
+    private long mSourceVersion = MediaObject.INVALID_DATA_VERSION;
+
+    private ContentListener mContentListener;
+
+    public MediaSetSource(MediaSet source) {
+        mSource = Utils.checkNotNull(source);
+        mSource.addContentListener(this);
+    }
+
+    @Override
+    public void close() {
+        mSource.removeContentListener(this);
+    }
+
+    private void ensureCacheRange(int index) {
+        if (index >= mCacheStart && index < mCacheEnd) return;
+
+        long token = Binder.clearCallingIdentity();
+        try {
+            mCacheStart = index;
+            ArrayList<MediaItem> items = mSource.getMediaItem(mCacheStart, CACHE_SIZE);
+            mCacheEnd = mCacheStart + items.size();
+            items.toArray(mCache);
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    @Override
+    public synchronized Uri getContentUri(int index) {
+        ensureCacheRange(index);
+        if (index < mCacheStart || index >= mCacheEnd) return null;
+        return mCache[index - mCacheStart].getContentUri();
+    }
+
+    @Override
+    public synchronized Bitmap getImage(int index) {
+        ensureCacheRange(index);
+        if (index < mCacheStart || index >= mCacheEnd) return null;
+        return WidgetUtils.createWidgetBitmap(mCache[index - mCacheStart]);
+    }
+
+    @Override
+    public void reload() {
+        long version = mSource.reload();
+        if (mSourceVersion != version) {
+            mSourceVersion = version;
+            mCacheStart = 0;
+            mCacheEnd = 0;
+            Arrays.fill(mCache, null);
+        }
+    }
+
+    @Override
+    public void setContentListener(ContentListener listener) {
+        mContentListener = listener;
+    }
+
+    @Override
+    public int size() {
+        long token = Binder.clearCallingIdentity();
+        try {
+            return mSource.getMediaItemCount();
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    @Override
+    public void onContentDirty() {
+        if (mContentListener != null) mContentListener.onContentDirty();
+    }
+}
diff --git a/src/com/android/gallery3d/widget/WidgetClickHandler.java b/src/com/android/gallery3d/widget/WidgetClickHandler.java
new file mode 100644
index 0000000..362e4d2
--- /dev/null
+++ b/src/com/android/gallery3d/widget/WidgetClickHandler.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.widget;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.Gallery;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.res.AssetFileDescriptor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.widget.Toast;
+
+public class WidgetClickHandler extends Activity {
+    private static final String TAG = "PhotoAppWidgetClickHandler";
+
+    private boolean isValidDataUri(Uri dataUri) {
+        if (dataUri == null) return false;
+        try {
+            AssetFileDescriptor f = getContentResolver()
+                    .openAssetFileDescriptor(dataUri, "r");
+            f.close();
+            return true;
+        } catch (Throwable e) {
+            Log.w(TAG, "cannot open uri: " + dataUri, e);
+            return false;
+        }
+    }
+
+    @Override
+    protected void onCreate(Bundle savedState) {
+        super.onCreate(savedState);
+        Intent intent = getIntent();
+        if (isValidDataUri(intent.getData())) {
+            startActivity(new Intent(Intent.ACTION_VIEW, intent.getData()));
+        } else {
+            Toast.makeText(this,
+                    R.string.no_such_item, Toast.LENGTH_LONG).show();
+            startActivity(new Intent(this, Gallery.class));
+        }
+        finish();
+    }
+}
diff --git a/src/com/android/gallery3d/widget/WidgetConfigure.java b/src/com/android/gallery3d/widget/WidgetConfigure.java
new file mode 100644
index 0000000..3bcd9c4
--- /dev/null
+++ b/src/com/android/gallery3d/widget/WidgetConfigure.java
@@ -0,0 +1,167 @@
+/*
+ * 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.widget;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.AlbumPicker;
+import com.android.gallery3d.app.CropImage;
+import com.android.gallery3d.app.DialogPicker;
+
+import android.app.Activity;
+import android.appwidget.AppWidgetManager;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.RemoteViews;
+
+public class WidgetConfigure extends Activity {
+    @SuppressWarnings("unused")
+    private static final String TAG = "WidgetConfigure";
+
+    public static final String KEY_WIDGET_TYPE = "widget-type";
+
+    private static final int REQUEST_WIDGET_TYPE = 1;
+    private static final int REQUEST_CHOOSE_ALBUM = 2;
+    private static final int REQUEST_CROP_IMAGE = 3;
+    private static final int REQUEST_GET_PHOTO = 4;
+
+    public static final int RESULT_ERROR = RESULT_FIRST_USER;
+
+    // Scale up the widget size since we only specified the minimized
+    // size of the gadget. The real size could be larger.
+    // Note: There is also a limit on the size of data that can be
+    // passed in Binder's transaction.
+    private static float WIDGET_SCALE_FACTOR = 1.5f;
+
+    private int mAppWidgetId = -1;
+    private int mWidgetType = 0;
+    private Uri mPickedItem;
+
+    @Override
+    protected void onCreate(Bundle bundle) {
+        super.onCreate(bundle);
+        mAppWidgetId = getIntent().getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1);
+
+        if (mAppWidgetId == -1) {
+            setResult(Activity.RESULT_CANCELED);
+            finish();
+            return;
+        }
+
+        if (mWidgetType == 0) {
+            Intent intent = new Intent(this, WidgetTypeChooser.class);
+            startActivityForResult(intent, REQUEST_WIDGET_TYPE);
+        }
+    }
+
+    private void updateWidgetAndFinish(WidgetDatabaseHelper.Entry entry) {
+        AppWidgetManager manager = AppWidgetManager.getInstance(this);
+        RemoteViews views = WidgetProvider.buildWidget(this, mAppWidgetId, entry);
+        manager.updateAppWidget(mAppWidgetId, views);
+        setResult(RESULT_OK, new Intent().putExtra(
+                AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId));
+        finish();
+    }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+        if (resultCode != RESULT_OK) {
+            setResult(resultCode, new Intent().putExtra(
+                    AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId));
+            finish();
+            return;
+        }
+
+        if (requestCode == REQUEST_WIDGET_TYPE) {
+            setWidgetType(data);
+        } else if (requestCode == REQUEST_CHOOSE_ALBUM) {
+            setChoosenAlbum(data);
+        } else if (requestCode == REQUEST_GET_PHOTO) {
+            setChoosenPhoto(data);
+        } else if (requestCode == REQUEST_CROP_IMAGE) {
+            setPhotoWidget(data);
+        } else {
+            throw new AssertionError("unknown request: " + requestCode);
+        }
+    }
+
+    private void setPhotoWidget(Intent data) {
+        // Store the cropped photo in our database
+        Bitmap bitmap = (Bitmap) data.getParcelableExtra("data");
+        WidgetDatabaseHelper helper = new WidgetDatabaseHelper(this);
+        try {
+            helper.setPhoto(mAppWidgetId, mPickedItem, bitmap);
+            updateWidgetAndFinish(helper.getEntry(mAppWidgetId));
+        } finally {
+            helper.close();
+        }
+    }
+
+    private void setChoosenPhoto(Intent data) {
+        Resources res = getResources();
+        int widgetWidth = Math.round(WIDGET_SCALE_FACTOR
+                * res.getDimension(R.dimen.appwidget_width));
+        int widgetHeight = Math.round(WIDGET_SCALE_FACTOR
+                * res.getDimension(R.dimen.appwidget_height));
+        mPickedItem = data.getData();
+        Intent request = new Intent(CropImage.ACTION_CROP, mPickedItem)
+                .putExtra(CropImage.KEY_OUTPUT_X, widgetWidth)
+                .putExtra(CropImage.KEY_OUTPUT_Y, widgetHeight)
+                .putExtra(CropImage.KEY_ASPECT_X, widgetWidth)
+                .putExtra(CropImage.KEY_ASPECT_Y, widgetHeight)
+                .putExtra(CropImage.KEY_SCALE_UP_IF_NEEDED, true)
+                .putExtra(CropImage.KEY_SCALE, true)
+                .putExtra(CropImage.KEY_RETURN_DATA, true);
+        startActivityForResult(request, REQUEST_CROP_IMAGE);
+    }
+
+    private void setChoosenAlbum(Intent data) {
+        String albumPath = data.getStringExtra(AlbumPicker.KEY_ALBUM_PATH);
+        WidgetDatabaseHelper helper = new WidgetDatabaseHelper(this);
+        try {
+            helper.setWidget(mAppWidgetId,
+                    WidgetDatabaseHelper.TYPE_ALBUM, albumPath);
+            updateWidgetAndFinish(helper.getEntry(mAppWidgetId));
+        } finally {
+            helper.close();
+        }
+    }
+
+    private void setWidgetType(Intent data) {
+        mWidgetType = data.getIntExtra(KEY_WIDGET_TYPE, R.id.widget_type_shuffle);
+        if (mWidgetType == R.id.widget_type_album) {
+            Intent intent = new Intent(this, AlbumPicker.class);
+            startActivityForResult(intent, REQUEST_CHOOSE_ALBUM);
+        } else if (mWidgetType == R.id.widget_type_shuffle) {
+            WidgetDatabaseHelper helper = new WidgetDatabaseHelper(this);
+            try {
+                helper.setWidget(mAppWidgetId, WidgetDatabaseHelper.TYPE_SHUFFLE, null);
+                updateWidgetAndFinish(helper.getEntry(mAppWidgetId));
+            } finally {
+                helper.close();
+            }
+        } else {
+            // Explicitly send the intent to the DialogPhotoPicker
+            Intent request = new Intent(this, DialogPicker.class)
+                    .setAction(Intent.ACTION_GET_CONTENT)
+                    .setType("image/*");
+            startActivityForResult(request, REQUEST_GET_PHOTO);
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/widget/WidgetDatabaseHelper.java b/src/com/android/gallery3d/widget/WidgetDatabaseHelper.java
new file mode 100644
index 0000000..d5bf22e
--- /dev/null
+++ b/src/com/android/gallery3d/widget/WidgetDatabaseHelper.java
@@ -0,0 +1,187 @@
+/*
+ * 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.widget;
+
+import com.android.gallery3d.common.Utils;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.util.Log;
+
+import java.io.ByteArrayOutputStream;
+
+public class WidgetDatabaseHelper extends SQLiteOpenHelper {
+    private static final String TAG = "PhotoDatabaseHelper";
+    private static final String DATABASE_NAME = "launcher.db";
+
+    private static final int DATABASE_VERSION = 4;
+
+    private static final String TABLE_WIDGETS = "widgets";
+
+    private static final String FIELD_APPWIDGET_ID = "appWidgetId";
+    private static final String FIELD_IMAGE_URI = "imageUri";
+    private static final String FIELD_PHOTO_BLOB = "photoBlob";
+    private static final String FIELD_WIDGET_TYPE = "widgetType";
+    private static final String FIELD_ALBUM_PATH = "albumPath";
+
+    public static final int TYPE_SINGLE_PHOTO = 0;
+    public static final int TYPE_SHUFFLE = 1;
+    public static final int TYPE_ALBUM = 2;
+
+    private static final String[] PROJECTION = {
+            FIELD_WIDGET_TYPE, FIELD_IMAGE_URI, FIELD_PHOTO_BLOB, FIELD_ALBUM_PATH};
+    private static final int INDEX_WIDGET_TYPE = 0;
+    private static final int INDEX_IMAGE_URI = 1;
+    private static final int INDEX_PHOTO_BLOB = 2;
+    private static final int INDEX_ALBUM_PATH = 3;
+    private static final String WHERE_CLAUSE = FIELD_APPWIDGET_ID + " = ?";
+
+    public static class Entry {
+        public int widgetId;
+        public int type;
+        public Uri imageUri;
+        public Bitmap image;
+        public String albumPath;
+
+        private Entry(int id, Cursor cursor) {
+            widgetId = id;
+            type = cursor.getInt(INDEX_WIDGET_TYPE);
+
+            if (type == TYPE_SINGLE_PHOTO) {
+                imageUri = Uri.parse(cursor.getString(INDEX_IMAGE_URI));
+                image = loadBitmap(cursor, INDEX_PHOTO_BLOB);
+            } else if (type == TYPE_ALBUM) {
+                albumPath = cursor.getString(INDEX_ALBUM_PATH);
+            }
+        }
+    }
+
+    public WidgetDatabaseHelper(Context context) {
+        super(context, DATABASE_NAME, null, DATABASE_VERSION);
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        db.execSQL("CREATE TABLE " + TABLE_WIDGETS + " ("
+                + FIELD_APPWIDGET_ID + " INTEGER PRIMARY KEY, "
+                + FIELD_WIDGET_TYPE + " INTEGER DEFAULT 0, "
+                + FIELD_IMAGE_URI + " TEXT, "
+                + FIELD_ALBUM_PATH + " TEXT, "
+                + FIELD_PHOTO_BLOB + " BLOB)");
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        int version = oldVersion;
+
+        if (version != DATABASE_VERSION) {
+            Log.w(TAG, "destroying all old data.");
+            // Table "photos" is renamed to "widget" in version 4
+            db.execSQL("DROP TABLE IF EXISTS photos");
+            db.execSQL("DROP TABLE IF EXISTS " + TABLE_WIDGETS);
+            onCreate(db);
+        }
+    }
+
+    /**
+     * Store the given bitmap in this database for the given appWidgetId.
+     */
+    public boolean setPhoto(int appWidgetId, Uri imageUri, Bitmap bitmap) {
+        try {
+            // Try go guesstimate how much space the icon will take when
+            // serialized to avoid unnecessary allocations/copies during
+            // the write.
+            int size = bitmap.getWidth() * bitmap.getHeight() * 4;
+            ByteArrayOutputStream out = new ByteArrayOutputStream(size);
+            bitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
+            out.close();
+
+            ContentValues values = new ContentValues();
+            values.put(FIELD_APPWIDGET_ID, appWidgetId);
+            values.put(FIELD_WIDGET_TYPE, TYPE_SINGLE_PHOTO);
+            values.put(FIELD_IMAGE_URI, imageUri.toString());
+            values.put(FIELD_PHOTO_BLOB, out.toByteArray());
+
+            SQLiteDatabase db = getWritableDatabase();
+            db.replaceOrThrow(TABLE_WIDGETS, null, values);
+            return true;
+        } catch (Throwable e) {
+            Log.e(TAG, "set widget photo fail", e);
+            return false;
+        }
+    }
+
+    public boolean setWidget(int id, int type, String albumPath) {
+        try {
+            ContentValues values = new ContentValues();
+            values.put(FIELD_APPWIDGET_ID, id);
+            values.put(FIELD_WIDGET_TYPE, type);
+            values.put(FIELD_ALBUM_PATH, Utils.ensureNotNull(albumPath));
+            getWritableDatabase().replaceOrThrow(TABLE_WIDGETS, null, values);
+            return true;
+        } catch (Throwable e) {
+            Log.e(TAG, "set widget fail", e);
+            return false;
+        }
+    }
+
+    private static Bitmap loadBitmap(Cursor cursor, int columnIndex) {
+        byte[] data = cursor.getBlob(columnIndex);
+        if (data == null) return null;
+        return BitmapFactory.decodeByteArray(data, 0, data.length);
+    }
+
+    public Entry getEntry(int appWidgetId) {
+        Cursor cursor = null;
+        try {
+            SQLiteDatabase db = getReadableDatabase();
+            cursor = db.query(TABLE_WIDGETS, PROJECTION,
+                    WHERE_CLAUSE, new String[] {String.valueOf(appWidgetId)},
+                    null, null, null);
+            if (cursor == null || !cursor.moveToNext()) {
+                Log.e(TAG, "query fail: empty cursor: " + cursor);
+                return null;
+            }
+            return new Entry(appWidgetId, cursor);
+        } catch (Throwable e) {
+            Log.e(TAG, "Could not load photo from database", e);
+            return null;
+        } finally {
+            Utils.closeSilently(cursor);
+        }
+    }
+
+    /**
+     * Remove any bitmap associated with the given appWidgetId.
+     */
+    public void deleteEntry(int appWidgetId) {
+        try {
+            SQLiteDatabase db = getWritableDatabase();
+            db.delete(TABLE_WIDGETS, WHERE_CLAUSE,
+                    new String[] {String.valueOf(appWidgetId)});
+        } catch (SQLiteException e) {
+            Log.e(TAG, "Could not delete photo from database", e);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/gallery3d/widget/WidgetProvider.java b/src/com/android/gallery3d/widget/WidgetProvider.java
new file mode 100644
index 0000000..0a2fbfb
--- /dev/null
+++ b/src/com/android/gallery3d/widget/WidgetProvider.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.widget;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.widget.WidgetDatabaseHelper.Entry;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.util.Log;
+import android.widget.RemoteViews;
+
+public class WidgetProvider extends AppWidgetProvider {
+
+    private static final String TAG = "WidgetProvider";
+
+    static RemoteViews buildWidget(Context context, int id, Entry entry) {
+
+        switch (entry.type) {
+            case WidgetDatabaseHelper.TYPE_ALBUM:
+            case WidgetDatabaseHelper.TYPE_SHUFFLE:
+                return buildStackWidget(context, id, entry);
+            case WidgetDatabaseHelper.TYPE_SINGLE_PHOTO:
+                return buildFrameWidget(context, id, entry);
+        }
+        throw new RuntimeException("invalid type - " + entry.type);
+    }
+
+    @Override
+    public void onUpdate(Context context,
+            AppWidgetManager appWidgetManager, int[] appWidgetIds) {
+        WidgetDatabaseHelper helper = new WidgetDatabaseHelper(context);
+        try {
+            for (int id : appWidgetIds) {
+                Entry entry = helper.getEntry(id);
+                if (entry != null) {
+                    RemoteViews views = buildWidget(context, id, entry);
+                    appWidgetManager.updateAppWidget(id, views);
+                } else {
+                    Log.e(TAG, "cannot load widget: " + id);
+                }
+            }
+        } finally {
+            helper.close();
+        }
+        super.onUpdate(context, appWidgetManager, appWidgetIds);
+    }
+
+    private static RemoteViews buildStackWidget(Context context, int widgetId, Entry entry) {
+        RemoteViews views = new RemoteViews(
+                context.getPackageName(), R.layout.appwidget_main);
+
+        Intent intent = new Intent(context, WidgetService.class);
+        intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId);
+        intent.putExtra(WidgetService.EXTRA_WIDGET_TYPE, entry.type);
+        intent.putExtra(WidgetService.EXTRA_ALBUM_PATH, entry.albumPath);
+        intent.setData(Uri.parse("widget://gallery/" + widgetId));
+
+        views.setRemoteAdapter(R.id.appwidget_stack_view, intent);
+        views.setEmptyView(R.id.appwidget_stack_view, R.id.appwidget_empty_view);
+
+        Intent clickIntent = new Intent(context, WidgetClickHandler.class);
+        PendingIntent pendingIntent = PendingIntent.getActivity(
+                context, 0, clickIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+        views.setPendingIntentTemplate(R.id.appwidget_stack_view, pendingIntent);
+
+        return views;
+    }
+
+    static RemoteViews buildFrameWidget(Context context, int appWidgetId, Entry entry) {
+        RemoteViews views = new RemoteViews(
+                context.getPackageName(), R.layout.photo_frame);
+        views.setImageViewBitmap(R.id.photo, entry.image);
+        Intent clickIntent = new Intent(context,
+                WidgetClickHandler.class).setData(entry.imageUri);
+        PendingIntent pendingClickIntent = PendingIntent.getActivity(context, 0,
+                clickIntent, PendingIntent.FLAG_CANCEL_CURRENT);
+        views.setOnClickPendingIntent(R.id.photo, pendingClickIntent);
+        return views;
+    }
+
+    @Override
+    public void onDeleted(Context context, int[] appWidgetIds) {
+        // Clean deleted photos out of our database
+        WidgetDatabaseHelper helper = new WidgetDatabaseHelper(context);
+        for (int appWidgetId : appWidgetIds) {
+            helper.deleteEntry(appWidgetId);
+        }
+        helper.close();
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/gallery3d/widget/WidgetService.java b/src/com/android/gallery3d/widget/WidgetService.java
new file mode 100644
index 0000000..aa167c7
--- /dev/null
+++ b/src/com/android/gallery3d/widget/WidgetService.java
@@ -0,0 +1,169 @@
+/*
+ * 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.widget;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.app.GalleryApp;
+import com.android.gallery3d.data.ContentListener;
+import com.android.gallery3d.data.DataManager;
+import com.android.gallery3d.data.MediaSet;
+import com.android.gallery3d.data.Path;
+
+import android.appwidget.AppWidgetManager;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.widget.RemoteViews;
+import android.widget.RemoteViewsService;
+
+public class WidgetService extends RemoteViewsService {
+
+    @SuppressWarnings("unused")
+    private static final String TAG = "GalleryAppWidgetService";
+
+    public static final String EXTRA_WIDGET_TYPE = "widget-type";
+    public static final String EXTRA_ALBUM_PATH = "album-path";
+
+    @Override
+    public RemoteViewsFactory onGetViewFactory(Intent intent) {
+        int id = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
+                AppWidgetManager.INVALID_APPWIDGET_ID);
+        int type = intent.getIntExtra(EXTRA_WIDGET_TYPE, 0);
+        String albumPath = intent.getStringExtra(EXTRA_ALBUM_PATH);
+
+        return new PhotoRVFactory((GalleryApp) getApplicationContext(),
+                id, type, albumPath);
+    }
+
+    private static class EmptySource implements WidgetSource {
+
+        @Override
+        public int size() {
+            return 0;
+        }
+
+        @Override
+        public Bitmap getImage(int index) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Uri getContentUri(int index) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void setContentListener(ContentListener listener) {}
+
+        @Override
+        public void reload() {}
+
+        @Override
+        public void close() {}
+    }
+
+    private static class PhotoRVFactory implements
+            RemoteViewsService.RemoteViewsFactory, ContentListener {
+
+        private final int mAppWidgetId;
+        private final int mType;
+        private final String mAlbumPath;
+        private final GalleryApp mApp;
+
+        private WidgetSource mSource;
+
+        public PhotoRVFactory(GalleryApp app, int id, int type, String albumPath) {
+            mApp = app;
+            mAppWidgetId = id;
+            mType = type;
+            mAlbumPath = albumPath;
+        }
+
+        @Override
+        public void onCreate() {
+            if (mType == WidgetDatabaseHelper.TYPE_ALBUM) {
+                Path path = Path.fromString(mAlbumPath);
+                DataManager manager = mApp.getDataManager();
+                MediaSet mediaSet = (MediaSet) manager.getMediaObject(path);
+                mSource = mediaSet == null
+                        ? new EmptySource()
+                        : new MediaSetSource(mediaSet);
+            } else {
+                mSource = new LocalPhotoSource(mApp.getAndroidContext());
+            }
+            mSource.setContentListener(this);
+            AppWidgetManager.getInstance(mApp.getAndroidContext())
+                    .notifyAppWidgetViewDataChanged(
+                    mAppWidgetId, R.id.appwidget_stack_view);
+        }
+
+        @Override
+        public void onDestroy() {
+            mSource.close();
+            mSource = null;
+        }
+
+        public int getCount() {
+            return mSource.size();
+        }
+
+        public long getItemId(int position) {
+            return position;
+        }
+
+        public int getViewTypeCount() {
+            return 1;
+        }
+
+        public boolean hasStableIds() {
+            return true;
+        }
+
+        public RemoteViews getLoadingView() {
+            RemoteViews rv = new RemoteViews(
+                    mApp.getAndroidContext().getPackageName(),
+                    R.layout.appwidget_loading_item);
+            rv.setProgressBar(R.id.appwidget_loading_item, 0, 0, true);
+            return rv;
+        }
+
+        public RemoteViews getViewAt(int position) {
+            Bitmap bitmap = mSource.getImage(position);
+            if (bitmap == null) return getLoadingView();
+            RemoteViews views = new RemoteViews(
+                    mApp.getAndroidContext().getPackageName(),
+                    R.layout.appwidget_photo_item);
+            views.setImageViewBitmap(R.id.appwidget_photo_item, bitmap);
+            views.setOnClickFillInIntent(R.id.appwidget_photo_item, new Intent()
+                    .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+                    .setData(mSource.getContentUri(position)));
+            return views;
+        }
+
+        @Override
+        public void onDataSetChanged() {
+            mSource.reload();
+        }
+
+        @Override
+        public void onContentDirty() {
+            AppWidgetManager.getInstance(mApp.getAndroidContext())
+                    .notifyAppWidgetViewDataChanged(
+                    mAppWidgetId, R.id.appwidget_stack_view);
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/widget/WidgetSource.java b/src/com/android/gallery3d/widget/WidgetSource.java
new file mode 100644
index 0000000..3c73e88
--- /dev/null
+++ b/src/com/android/gallery3d/widget/WidgetSource.java
@@ -0,0 +1,31 @@
+/*
+ * 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.widget;
+
+import com.android.gallery3d.data.ContentListener;
+
+import android.graphics.Bitmap;
+import android.net.Uri;
+
+public interface WidgetSource {
+    public int size();
+    public Bitmap getImage(int index);
+    public Uri getContentUri(int index);
+    public void setContentListener(ContentListener listener);
+    public void reload();
+    public void close();
+}
diff --git a/src/com/android/gallery3d/widget/WidgetTypeChooser.java b/src/com/android/gallery3d/widget/WidgetTypeChooser.java
new file mode 100644
index 0000000..9718e0c
--- /dev/null
+++ b/src/com/android/gallery3d/widget/WidgetTypeChooser.java
@@ -0,0 +1,59 @@
+/*
+ * 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.widget;
+
+import com.android.gallery3d.R;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.RadioGroup;
+import android.widget.RadioGroup.OnCheckedChangeListener;
+
+public class WidgetTypeChooser extends Activity {
+
+    private OnCheckedChangeListener mListener = new OnCheckedChangeListener() {
+        @Override
+        public void onCheckedChanged(RadioGroup group, int checkedId) {
+            Intent data = new Intent()
+                    .putExtra(WidgetConfigure.KEY_WIDGET_TYPE, checkedId);
+            setResult(RESULT_OK, data);
+            finish();
+        }
+    };
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setTitle(R.string.widget_type);
+        setContentView(R.layout.choose_widget_type);
+        RadioGroup rg = (RadioGroup) findViewById(R.id.widget_type);
+        rg.setOnCheckedChangeListener(mListener);
+
+        Button cancel = (Button) findViewById(R.id.cancel);
+        cancel.setOnClickListener(new OnClickListener() {
+            @Override
+            public void onClick(View v) {
+                setResult(RESULT_CANCELED);
+                finish();
+            }
+        });
+    }
+}
diff --git a/src/com/android/gallery3d/widget/WidgetUtils.java b/src/com/android/gallery3d/widget/WidgetUtils.java
new file mode 100644
index 0000000..481bbdd
--- /dev/null
+++ b/src/com/android/gallery3d/widget/WidgetUtils.java
@@ -0,0 +1,80 @@
+/*
+ * 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.widget;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.util.ThreadPool;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Bitmap.Config;
+import android.util.Log;
+
+public class WidgetUtils {
+
+    private static final String TAG = "WidgetUtils";
+
+    private static int sStackPhotoWidth = 220;
+    private static int sStackPhotoHeight = 170;
+
+    private WidgetUtils() {
+    }
+
+    public static void initialize(Context context) {
+        Resources r = context.getResources();
+        sStackPhotoWidth = r.getDimensionPixelSize(R.dimen.stack_photo_width);
+        sStackPhotoHeight = r.getDimensionPixelSize(R.dimen.stack_photo_height);
+    }
+
+    public static Bitmap createWidgetBitmap(MediaItem image) {
+        Bitmap bitmap = image.requestImage(MediaItem.TYPE_THUMBNAIL)
+               .run(ThreadPool.JOB_CONTEXT_STUB);
+        if (bitmap == null) {
+            Log.w(TAG, "fail to get image of " + image.toString());
+            return null;
+        }
+        return createWidgetBitmap(bitmap, image.getRotation());
+    }
+
+    public static Bitmap createWidgetBitmap(Bitmap bitmap, int rotation) {
+        int w = bitmap.getWidth();
+        int h = bitmap.getHeight();
+
+        float scale;
+        if (((rotation / 90) & 1) == 0) {
+            scale = Math.max((float) sStackPhotoWidth / w,
+                    (float) sStackPhotoHeight / h);
+        } else {
+            scale = Math.max((float) sStackPhotoWidth / h,
+                    (float) sStackPhotoHeight / w);
+        }
+
+        Bitmap target = Bitmap.createBitmap(
+                sStackPhotoWidth, sStackPhotoHeight, Config.ARGB_8888);
+        Canvas canvas = new Canvas(target);
+        canvas.translate(sStackPhotoWidth / 2, sStackPhotoHeight / 2);
+        canvas.rotate(rotation);
+        canvas.scale(scale, scale);
+        Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.DITHER_FLAG);
+        canvas.drawBitmap(bitmap, -w / 2, -h / 2, paint);
+        return target;
+    }
+}