Merge "Added "discard unsaved changes" behavior for exiting." into gb-ub-photos-bryce
diff --git a/src/com/android/gallery3d/app/GalleryAppImpl.java b/src/com/android/gallery3d/app/GalleryAppImpl.java
index 5b4a872..2abdaa0 100644
--- a/src/com/android/gallery3d/app/GalleryAppImpl.java
+++ b/src/com/android/gallery3d/app/GalleryAppImpl.java
@@ -17,12 +17,9 @@
 package com.android.gallery3d.app;
 
 import android.app.Application;
-import android.content.ComponentName;
 import android.content.Context;
-import android.content.pm.PackageManager;
 import android.os.AsyncTask;
 
-import com.android.gallery3d.common.ApiHelper;
 import com.android.gallery3d.data.DataManager;
 import com.android.gallery3d.data.DownloadCache;
 import com.android.gallery3d.data.ImageCacheService;
@@ -32,6 +29,7 @@
 import com.android.gallery3d.util.LightCycleHelper;
 import com.android.gallery3d.util.ThreadPool;
 import com.android.gallery3d.util.UsageStatistics;
+import com.android.photos.data.MediaCache;
 
 import java.io.File;
 
@@ -56,6 +54,7 @@
         WidgetUtils.initialize(this);
         PicasaSource.initialize(this);
         UsageStatistics.initialize(this);
+        MediaCache.initialize(this);
 
         mStitchingProgressManager = LightCycleHelper.createStitchingManagerInstance(this);
         if (mStitchingProgressManager != null) {
diff --git a/src/com/android/gallery3d/filtershow/PanelController.java b/src/com/android/gallery3d/filtershow/PanelController.java
index 8352032..6b20fe1 100644
--- a/src/com/android/gallery3d/filtershow/PanelController.java
+++ b/src/com/android/gallery3d/filtershow/PanelController.java
@@ -151,7 +151,7 @@
         private final View mView;
         private final LinearLayout mAccessoryViewList;
         private Vector<View> mAccessoryViews = new Vector<View>();
-        private final TextView mTextView;
+        private final Button mTextView;
         private boolean mSelected = false;
         private String mEffectName = null;
         private int mParameterValue = 0;
@@ -160,10 +160,9 @@
         public UtilityPanel(Context context, View utilityPanel) {
             mView = utilityPanel;
             View accessoryViewList = mView.findViewById(R.id.panelAccessoryViewList);
-            Button textView = (Button) mView.findViewById(R.id.applyEffect);
+            mTextView = (Button) mView.findViewById(R.id.applyEffect);
             mContext = context;
             mAccessoryViewList = (LinearLayout) accessoryViewList;
-            mTextView = (TextView) textView;
         }
 
         public boolean selected() {
@@ -212,6 +211,16 @@
         public View getEditControl() {
             return mView.findViewById(R.id.controlArea);
         }
+
+        public void removeControlChildren() {
+            LinearLayout controlArea = (LinearLayout) mView.findViewById(R.id.controlArea);
+            controlArea.removeAllViews();
+        }
+
+        public Button getEditTitle() {
+            return mTextView;
+        }
+
         public void updateText() {
             String s;
             if (mCurrentEditor == null) {
@@ -589,8 +598,10 @@
                     if (mEditorPlaceHolder.contains(representation.getEditorId())) {
                         mCurrentEditor = mEditorPlaceHolder.showEditor(
                                 representation.getEditorId());
-                        mCurrentEditor.setUtilityPanelUI(
-                                mUtilityPanel.getActionControl(), mUtilityPanel.getEditControl());
+                        mUtilityPanel.removeControlChildren();
+                        mCurrentEditor.setUpEditorUI(
+                                mUtilityPanel.getActionControl(), mUtilityPanel.getEditControl(),
+                                mUtilityPanel.getEditTitle());
                         mCurrentImage = mCurrentEditor.getImageShow();
                         mCurrentEditor.setPanelController(this);
 
diff --git a/src/com/android/gallery3d/filtershow/controller/ActionSlider.java b/src/com/android/gallery3d/filtershow/controller/ActionSlider.java
new file mode 100644
index 0000000..6ed2467
--- /dev/null
+++ b/src/com/android/gallery3d/filtershow/controller/ActionSlider.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.filtershow.controller;
+
+import android.util.Log;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.filtershow.editors.Editor;
+
+public class ActionSlider extends TitledSlider {
+    private static final String LOGTAG = "ActionSlider";
+    ImageButton mActionButton;
+    public ActionSlider() {
+        mLayoutID = R.layout.filtershow_control_action_slider;
+    }
+
+    @Override
+    public void setUp(ViewGroup container, Parameter parameter, Editor editor) {
+        super.setUp(container, parameter, editor);
+        mActionButton = (ImageButton) mTopView.findViewById(R.id.actionButton);
+        mActionButton.setOnClickListener(new OnClickListener() {
+
+            @Override
+            public void onClick(View v) {
+                ((ParameterActionAndInt) mParameter).fireAction();
+            }
+        });
+    }
+
+    @Override
+    public void updateUI() {
+        super.updateUI();
+        if (mActionButton != null) {
+            int iconId = ((ParameterActionAndInt) mParameter).getActionIcon();
+            mActionButton.setImageResource(iconId);
+        }
+    }
+}
diff --git a/src/com/android/gallery3d/filtershow/controller/BasicSlider.java b/src/com/android/gallery3d/filtershow/controller/BasicSlider.java
new file mode 100644
index 0000000..c54fe77
--- /dev/null
+++ b/src/com/android/gallery3d/filtershow/controller/BasicSlider.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.filtershow.controller;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.SeekBar;
+import android.widget.SeekBar.OnSeekBarChangeListener;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.filtershow.editors.Editor;
+
+public class BasicSlider implements Control {
+    private SeekBar mSeekBar;
+    private ParameterInteger mParameter;
+    Editor mEditor;
+
+    @Override
+    public void setUp(ViewGroup container, Parameter parameter, Editor editor) {
+        mEditor = editor;
+        Context context = container.getContext();
+        mParameter = (ParameterInteger) parameter;
+        LayoutInflater inflater =
+                (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+        LinearLayout lp = (LinearLayout) inflater.inflate(
+                R.layout.filtershow_seekbar, container, true);
+        mSeekBar = (SeekBar) lp.findViewById(R.id.primarySeekBar);
+
+        updateUI();
+        mSeekBar.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
+
+            @Override
+            public void onStopTrackingTouch(SeekBar seekBar) {
+            }
+
+            @Override
+            public void onStartTrackingTouch(SeekBar seekBar) {
+            }
+
+            @Override
+            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+                if (mParameter != null) {
+                    mParameter.setValue(progress + mParameter.getMinimum());
+                    mEditor.commitLocalRepresentation();
+
+                }
+            }
+        });
+    }
+
+    @Override
+    public View getTopView() {
+        return mSeekBar;
+    }
+
+    @Override
+    public void setPrameter(Parameter parameter) {
+        mParameter = (ParameterInteger) parameter;
+        if (mSeekBar != null) {
+            updateUI();
+        }
+    }
+
+    @Override
+    public void updateUI() {
+        mSeekBar.setMax(mParameter.getMaximum() - mParameter.getMinimum());
+        mSeekBar.setProgress(mParameter.getValue() - mParameter.getMinimum());
+    }
+
+}
diff --git a/src/com/android/gallery3d/filtershow/controller/Control.java b/src/com/android/gallery3d/filtershow/controller/Control.java
new file mode 100644
index 0000000..4342290
--- /dev/null
+++ b/src/com/android/gallery3d/filtershow/controller/Control.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.filtershow.controller;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.gallery3d.filtershow.editors.Editor;
+
+public interface Control {
+    public void setUp(ViewGroup container, Parameter parameter, Editor editor);
+
+    public View getTopView();
+
+    public void setPrameter(Parameter parameter);
+
+    public void updateUI();
+}
diff --git a/src/com/android/gallery3d/filtershow/controller/Parameter.java b/src/com/android/gallery3d/filtershow/controller/Parameter.java
new file mode 100644
index 0000000..1e8694a
--- /dev/null
+++ b/src/com/android/gallery3d/filtershow/controller/Parameter.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.filtershow.controller;
+
+public interface Parameter {
+    String getParameterName();
+
+    String getParameterType();
+
+    String getValueString();
+
+    public void setController(Control c);
+}
diff --git a/src/com/android/gallery3d/filtershow/controller/ParameterActionAndInt.java b/src/com/android/gallery3d/filtershow/controller/ParameterActionAndInt.java
new file mode 100644
index 0000000..04567e2
--- /dev/null
+++ b/src/com/android/gallery3d/filtershow/controller/ParameterActionAndInt.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.filtershow.controller;
+
+public interface ParameterActionAndInt extends ParameterInteger {
+    static String sParameterType = "ParameterActionAndInt";
+
+    public void fireAction();
+
+    public int getActionIcon();
+}
diff --git a/src/com/android/gallery3d/filtershow/controller/ParameterInteger.java b/src/com/android/gallery3d/filtershow/controller/ParameterInteger.java
new file mode 100644
index 0000000..0bfd201
--- /dev/null
+++ b/src/com/android/gallery3d/filtershow/controller/ParameterInteger.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.filtershow.controller;
+
+public interface ParameterInteger extends Parameter {
+    static String sParameterType = "ParameterInteger";
+
+    int getMaximum();
+
+    int getMinimum();
+
+    int getDefaultValue();
+
+    int getValue();
+
+    void setValue(int value);
+}
diff --git a/src/com/android/gallery3d/filtershow/controller/TitledSlider.java b/src/com/android/gallery3d/filtershow/controller/TitledSlider.java
new file mode 100644
index 0000000..30b6fdb
--- /dev/null
+++ b/src/com/android/gallery3d/filtershow/controller/TitledSlider.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.filtershow.controller;
+
+import android.content.Context;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.SeekBar;
+import android.widget.SeekBar.OnSeekBarChangeListener;
+import android.widget.TextView;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.filtershow.editors.Editor;
+
+public class TitledSlider implements Control {
+    private final String LOGTAG = "ParametricEditor";
+    private SeekBar mSeekBar;
+    private TextView mControlName;
+    private TextView mControlValue;
+    protected ParameterInteger mParameter;
+    Editor mEditor;
+    View mTopView;
+    protected int mLayoutID = R.layout.filtershow_control_title_slider;
+
+    @Override
+    public void setUp(ViewGroup container, Parameter parameter, Editor editor) {
+        mEditor = editor;
+        Context context = container.getContext();
+        mParameter = (ParameterInteger) parameter;
+        LayoutInflater inflater =
+                (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+        mTopView = inflater.inflate(mLayoutID, container, true);
+        mTopView.setVisibility(View.VISIBLE);
+        mSeekBar = (SeekBar) mTopView.findViewById(R.id.controlValueSeekBar);
+        mControlName = (TextView) mTopView.findViewById(R.id.controlName);
+        mControlValue = (TextView) mTopView.findViewById(R.id.controlValue);
+        updateUI();
+        mSeekBar.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
+
+            @Override
+            public void onStopTrackingTouch(SeekBar seekBar) {
+            }
+
+            @Override
+            public void onStartTrackingTouch(SeekBar seekBar) {
+            }
+
+            @Override
+            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+                if (mParameter != null) {
+                    mParameter.setValue(progress + mParameter.getMinimum());
+                    if (mControlName != null) {
+                        mControlName.setText(mParameter.getParameterName());
+                    }
+                    if (mControlValue != null) {
+                        mControlValue.setText(Integer.toString(mParameter.getValue()));
+                    }
+                    mEditor.commitLocalRepresentation();
+                }
+            }
+        });
+    }
+
+    @Override
+    public void setPrameter(Parameter parameter) {
+        mParameter = (ParameterInteger) parameter;
+        if (mSeekBar != null)
+            updateUI();
+    }
+
+    @Override
+    public void updateUI() {
+        if (mControlName != null) {
+            mControlName.setText(mParameter.getParameterName());
+        }
+        if (mControlValue != null) {
+            mControlValue.setText(
+                    Integer.toString(mParameter.getValue()));
+        }
+        mSeekBar.setMax(mParameter.getMaximum() - mParameter.getMinimum());
+        mSeekBar.setProgress(mParameter.getValue() - mParameter.getMinimum());
+        mEditor.commitLocalRepresentation();
+    }
+
+    @Override
+    public View getTopView() {
+        return mTopView;
+    }
+}
diff --git a/src/com/android/gallery3d/filtershow/editors/Editor.java b/src/com/android/gallery3d/filtershow/editors/Editor.java
index 69266ff..013590c 100644
--- a/src/com/android/gallery3d/filtershow/editors/Editor.java
+++ b/src/com/android/gallery3d/filtershow/editors/Editor.java
@@ -17,18 +17,22 @@
 package com.android.gallery3d.filtershow.editors;
 
 import android.content.Context;
-import android.graphics.drawable.Drawable;
-import android.text.Html;
+import android.util.AttributeSet;
 import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.View;
 import android.view.ViewGroup;
-import android.widget.*;
+import android.widget.Button;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.PopupMenu;
+import android.widget.SeekBar;
 import android.widget.SeekBar.OnSeekBarChangeListener;
 
 import com.android.gallery3d.R;
 import com.android.gallery3d.filtershow.PanelController;
 import com.android.gallery3d.filtershow.cache.ImageLoader;
+import com.android.gallery3d.filtershow.controller.Control;
 import com.android.gallery3d.filtershow.filters.FilterRepresentation;
 import com.android.gallery3d.filtershow.imageshow.ImageShow;
 import com.android.gallery3d.filtershow.imageshow.MasterImage;
@@ -43,6 +47,7 @@
     protected ImageShow mImageShow;
     protected FrameLayout mFrameLayout;
     protected SeekBar mSeekBar;
+    Button mEditTitle;
     protected PanelController mPanelController;
     protected int mID;
     private final String LOGTAG = "Editor";
@@ -57,7 +62,7 @@
     }
 
     public String calculateUserMessage(Context context, String effectName, Object parameterValue) {
-        String apply = context.getString(R.string.apply_effect);
+        String apply = "";
         if (mShowParameter == SHOW_VALUE_INT) {
             apply += " " + effectName + " " + parameterValue;
         } else {
@@ -83,6 +88,12 @@
         return true;
     }
 
+    public void setUpEditorUI(View actionButton, View editControl, Button editTitle) {
+        this.mEditTitle = editTitle;
+        setMenuIcon(true);
+        setUtilityPanelUI(actionButton, editControl);
+    }
+
     public boolean showsPopupIndicator() {
         return true;
     }
@@ -92,17 +103,28 @@
      * @param editControl this is the black area for sliders etc
      */
     public void setUtilityPanelUI(View actionButton, View editControl) {
-        mSeekBar = (SeekBar) editControl.findViewById(R.id.primarySeekBar);
+
+        AttributeSet aset;
+        Context context = editControl.getContext();
+        LayoutInflater inflater =
+                (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+        LinearLayout lp = (LinearLayout) inflater.inflate(
+                R.layout.filtershow_seekbar, (ViewGroup) editControl, true);
+        mSeekBar = (SeekBar) lp.findViewById(R.id.primarySeekBar);
+        mSeekBar.setOnSeekBarChangeListener(this);
+
         if (showsSeekBar()) {
             mSeekBar.setOnSeekBarChangeListener(this);
             mSeekBar.setVisibility(View.VISIBLE);
         } else {
             mSeekBar.setVisibility(View.INVISIBLE);
         }
+
         Button button = (Button) actionButton.findViewById(R.id.applyEffect);
         if (button != null) {
             if (showsPopupIndicator()) {
-                button.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.filtershow_menu_marker, 0);
+                button.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0,
+                        R.drawable.filtershow_menu_marker, 0);
             } else {
                 button.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0);
             }
@@ -205,19 +227,29 @@
     }
 
     public void openUtilityPanel(LinearLayout mAccessoryViewList) {
+        setMenuIcon(false);
         if (mImageShow != null) {
             mImageShow.openUtilityPanel(mAccessoryViewList);
         }
     }
 
+    protected void setMenuIcon(boolean on) {
+        mEditTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(
+                0, 0, on ? R.drawable.filtershow_menu_marker : 0, 0);
+    }
     protected void createMenu(int[] strId, View button) {
         PopupMenu pmenu = new PopupMenu(mContext, button);
         Menu menu = pmenu.getMenu();
         for (int i = 0; i < strId.length; i++) {
             menu.add(Menu.NONE, Menu.FIRST + i, 0, mContext.getString(strId[i]));
         }
+        setMenuIcon(true);
+
     }
 
+    public Control[] getControls() {
+        return null;
+    }
     @Override
     public void onStartTrackingTouch(SeekBar arg0) {
 
diff --git a/src/com/android/gallery3d/filtershow/editors/EditorVignette.java b/src/com/android/gallery3d/filtershow/editors/EditorVignette.java
index a7d99e4..7127b21 100644
--- a/src/com/android/gallery3d/filtershow/editors/EditorVignette.java
+++ b/src/com/android/gallery3d/filtershow/editors/EditorVignette.java
@@ -24,7 +24,7 @@
 import com.android.gallery3d.filtershow.filters.FilterVignetteRepresentation;
 import com.android.gallery3d.filtershow.imageshow.ImageVignette;
 
-public class EditorVignette extends BasicEditor {
+public class EditorVignette extends ParametricEditor {
     public static final int ID = R.id.vignetteEditor;
     private static final String LOGTAG = "EditorVignettePlanet";
     ImageVignette mImageVignette;
diff --git a/src/com/android/gallery3d/filtershow/editors/ParametricEditor.java b/src/com/android/gallery3d/filtershow/editors/ParametricEditor.java
new file mode 100644
index 0000000..cf00f4a
--- /dev/null
+++ b/src/com/android/gallery3d/filtershow/editors/ParametricEditor.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.gallery3d.filtershow.editors;
+
+import android.content.Context;
+import android.graphics.Point;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.SeekBar;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.filtershow.controller.ActionSlider;
+import com.android.gallery3d.filtershow.controller.BasicSlider;
+import com.android.gallery3d.filtershow.controller.Control;
+import com.android.gallery3d.filtershow.controller.Parameter;
+import com.android.gallery3d.filtershow.controller.ParameterActionAndInt;
+import com.android.gallery3d.filtershow.controller.ParameterInteger;
+import com.android.gallery3d.filtershow.controller.TitledSlider;
+import com.android.gallery3d.filtershow.filters.FilterBasicRepresentation;
+import com.android.gallery3d.filtershow.filters.FilterRepresentation;
+
+import java.lang.reflect.Constructor;
+import java.util.HashMap;
+
+public class ParametricEditor extends Editor {
+    private int mLayoutID;
+    private int mViewID;
+    public static int ID = R.id.editorParametric;
+    private final String LOGTAG = "ParametricEditor";
+    protected Control mControl;
+    public static final int MINIMUM_WIDTH = 600;
+    public static final int MINIMUM_HEIGHT = 800;
+
+    static HashMap<String, Class> portraitMap = new HashMap<String, Class>();
+    static HashMap<String, Class> landscapeMap = new HashMap<String, Class>();
+    static {
+        portraitMap.put(ParameterInteger.sParameterType, BasicSlider.class);
+        landscapeMap.put(ParameterInteger.sParameterType, TitledSlider.class);
+        portraitMap.put(ParameterActionAndInt.sParameterType, ActionSlider.class);
+        landscapeMap.put(ParameterActionAndInt.sParameterType, ActionSlider.class);
+    }
+
+    static Constructor getConstructor(Class cl) {
+        try {
+            return cl.getConstructor(Context.class, ViewGroup.class);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    public ParametricEditor() {
+        super(ID);
+    }
+
+    protected ParametricEditor(int id) {
+        super(id);
+    }
+
+    protected ParametricEditor(int id, int layoutID, int viewID) {
+        super(id);
+        mLayoutID = layoutID;
+        mViewID = viewID;
+    }
+
+    @Override
+    public String calculateUserMessage(Context context, String effectName, Object parameterValue) {
+        String apply = "";
+
+        if (mShowParameter == SHOW_VALUE_INT & useCompact(context)) {
+           if (getLocalRepresentation() instanceof FilterBasicRepresentation) {
+            FilterBasicRepresentation interval = (FilterBasicRepresentation) getLocalRepresentation();
+            apply += " " + effectName + " " + interval.getStateRepresentation();
+           } else {
+                apply += " " + effectName + " " + parameterValue;
+           }
+        } else {
+            apply += " " + effectName;
+        }
+        return apply;
+    }
+
+    @Override
+    public void createEditor(Context context, FrameLayout frameLayout) {
+        super.createEditor(context, frameLayout);
+        unpack(mViewID, mLayoutID);
+    }
+
+    @Override
+    public void reflectCurrentFilter() {
+        super.reflectCurrentFilter();
+        if (getLocalRepresentation() != null
+                && getLocalRepresentation() instanceof FilterBasicRepresentation) {
+            FilterBasicRepresentation interval = (FilterBasicRepresentation) getLocalRepresentation();
+            mControl.setPrameter(interval);
+        }
+    }
+
+    @Override
+    public Control[] getControls() {
+        BasicSlider slider = new BasicSlider();
+        return new Control[] {
+                slider
+        };
+    }
+
+    // TODO: need a better way to decide when which representation
+    static boolean useCompact(Context context) {
+        WindowManager w = ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE));
+        Point size = new Point();
+        w.getDefaultDisplay().getSize(size);
+        if (size.x < size.y) { // if tall than wider
+            return true;
+        }
+        if (size.x < MINIMUM_WIDTH) {
+            return true;
+        }
+        if (size.y < MINIMUM_HEIGHT) {
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public void setUtilityPanelUI(View actionButton, View editControl) {
+        FilterRepresentation rep = getLocalRepresentation();
+        if (this instanceof Parameter) {
+            control((Parameter) this, editControl);
+        } else if (rep instanceof Parameter) {
+            control((Parameter) rep, editControl);
+        } else {
+            mSeekBar = new SeekBar(editControl.getContext());
+            LayoutParams lp = new LinearLayout.LayoutParams(
+                    LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
+            mSeekBar.setLayoutParams(lp);
+            ((LinearLayout) editControl).addView(mSeekBar);
+            mSeekBar.setOnSeekBarChangeListener(this);
+        }
+    }
+
+    protected void control(Parameter p, View editControl) {
+        String pType = p.getParameterType();
+        Context context = editControl.getContext();
+        Class c = ((useCompact(context)) ? portraitMap : landscapeMap).get(pType);
+
+        if (c != null) {
+            try {
+                mControl = (Control) c.newInstance();
+                mControl.setUp((ViewGroup) editControl, p, this);
+
+            } catch (Exception e) {
+                Log.e(LOGTAG, "Error in loading Control ", e);
+            }
+        } else {
+            Log.e(LOGTAG, "Unable to find class for " + pType);
+            for (String string : portraitMap.keySet()) {
+                Log.e(LOGTAG, "for " + string + " use " + portraitMap.get(string));
+            }
+        }
+    }
+
+    @Override
+    public void commitLocalRepresentation() {
+        super.commitLocalRepresentation();
+        FilterRepresentation rep = getLocalRepresentation();
+    }
+
+    @Override
+    public void onProgressChanged(SeekBar sbar, int progress, boolean arg2) {
+    }
+
+    @Override
+    public void onStartTrackingTouch(SeekBar arg0) {
+    }
+
+    @Override
+    public void onStopTrackingTouch(SeekBar arg0) {
+    }
+}
diff --git a/src/com/android/gallery3d/filtershow/filters/FilterBasicRepresentation.java b/src/com/android/gallery3d/filtershow/filters/FilterBasicRepresentation.java
index 2410ebe..34323c4 100644
--- a/src/com/android/gallery3d/filtershow/filters/FilterBasicRepresentation.java
+++ b/src/com/android/gallery3d/filtershow/filters/FilterBasicRepresentation.java
@@ -17,8 +17,10 @@
 package com.android.gallery3d.filtershow.filters;
 
 import com.android.gallery3d.app.Log;
+import com.android.gallery3d.filtershow.controller.Control;
+import com.android.gallery3d.filtershow.controller.ParameterInteger;
 
-public class FilterBasicRepresentation extends FilterRepresentation {
+public class FilterBasicRepresentation extends FilterRepresentation implements ParameterInteger {
     private static final String LOGTAG = "FilterBasicRepresentation";
     private int mMinimum;
     private int mValue;
@@ -33,6 +35,7 @@
         setValue(value);
     }
 
+    @Override
     public String toString() {
         return getName() + " : " + mMinimum + " < " + mValue + " < " + mMaximum;
     }
@@ -47,6 +50,7 @@
         return representation;
     }
 
+    @Override
     public void useParametersFrom(FilterRepresentation a) {
         if (a instanceof FilterBasicRepresentation) {
             FilterBasicRepresentation representation = (FilterBasicRepresentation) a;
@@ -76,6 +80,7 @@
         return false;
     }
 
+    @Override
     public int getMinimum() {
         return mMinimum;
     }
@@ -84,10 +89,12 @@
         mMinimum = minimum;
     }
 
+    @Override
     public int getValue() {
         return mValue;
     }
 
+    @Override
     public void setValue(int value) {
         mValue = value;
         if (mValue < mMinimum) {
@@ -98,6 +105,7 @@
         }
     }
 
+    @Override
     public int getMaximum() {
         return mMaximum;
     }
@@ -110,6 +118,7 @@
         mDefaultValue = defaultValue;
     }
 
+    @Override
     public int getDefaultValue() {
         return mDefaultValue;
     }
@@ -122,7 +131,27 @@
         mPreviewValue = previewValue;
     }
 
+    @Override
     public String getStateRepresentation() {
         return "" + getValue();
     }
+
+    @Override
+    public String getParameterType(){
+        return sParameterType;
+    }
+
+    @Override
+    public void setController(Control control) {
+    }
+
+    @Override
+    public String getValueString() {
+        return getStateRepresentation();
+    }
+
+    @Override
+    public String getParameterName() {
+        return getName();
+    }
 }
diff --git a/src/com/android/gallery3d/filtershow/imageshow/ImageVignette.java b/src/com/android/gallery3d/filtershow/imageshow/ImageVignette.java
index 1149263..7ce9e51 100644
--- a/src/com/android/gallery3d/filtershow/imageshow/ImageVignette.java
+++ b/src/com/android/gallery3d/filtershow/imageshow/ImageVignette.java
@@ -65,10 +65,6 @@
                     mActiveHandle = -1;
                     break;
                 case MotionEvent.ACTION_DOWN:
-                    if (event.getPointerCount() == 1) {
-                        Log.v(LOGTAG, "################### ACTION_DOWN odd " + mActiveHandle
-                                + " touches=1");
-                    }
                     break;
             }
         }
diff --git a/src/com/android/photos/data/FileRetriever.java b/src/com/android/photos/data/FileRetriever.java
new file mode 100644
index 0000000..eb7686e
--- /dev/null
+++ b/src/com/android/photos/data/FileRetriever.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.photos.data;
+
+import android.graphics.Bitmap;
+import android.media.ExifInterface;
+import android.net.Uri;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import com.android.gallery3d.common.BitmapUtils;
+
+import java.io.File;
+import java.io.IOException;
+
+public class FileRetriever implements MediaRetriever {
+    private static final String TAG = FileRetriever.class.getSimpleName();
+
+    @Override
+    public File getLocalFile(Uri contentUri) {
+        return new File(contentUri.getPath());
+    }
+
+    @Override
+    public MediaSize getFastImageSize(Uri contentUri, MediaSize size) {
+        if (isVideo(contentUri)) {
+            return null;
+        }
+        return MediaSize.TemporaryThumbnail;
+    }
+
+    @Override
+    public byte[] getTemporaryImage(Uri contentUri, MediaSize fastImageSize) {
+
+        try {
+            ExifInterface exif = new ExifInterface(contentUri.getPath());
+            if (exif.hasThumbnail()) {
+                return exif.getThumbnail();
+            }
+        } catch (IOException e) {
+            Log.w(TAG, "Unable to load exif for " + contentUri);
+        }
+        return null;
+    }
+
+    @Override
+    public boolean getMedia(Uri contentUri, MediaSize imageSize, File tempFile) {
+        if (imageSize == MediaSize.Original) {
+            return false; // getLocalFile should always return the original.
+        }
+        if (imageSize == MediaSize.Thumbnail) {
+            File preview = MediaCache.getInstance().getCachedFile(contentUri, MediaSize.Preview);
+            if (preview != null) {
+                // Just downsample the preview, it is faster.
+                return MediaCacheUtils.downsample(preview, imageSize, tempFile);
+            }
+        }
+        File highRes = new File(contentUri.getPath());
+        boolean success;
+        if (!isVideo(contentUri)) {
+            success = MediaCacheUtils.downsample(highRes, imageSize, tempFile);
+        } else {
+            // Video needs to extract the bitmap.
+            Bitmap bitmap = BitmapUtils.createVideoThumbnail(highRes.getPath());
+            if (bitmap == null) {
+                return false;
+            } else if (imageSize == MediaSize.Thumbnail
+                    && !MediaCacheUtils.needsDownsample(bitmap, MediaSize.Preview)
+                    && MediaCacheUtils.writeToFile(bitmap, tempFile)) {
+                // Opportunistically save preview
+                MediaCache mediaCache = MediaCache.getInstance();
+                mediaCache.insertIntoCache(contentUri, MediaSize.Preview, tempFile);
+            }
+            // Now scale the image
+            success = MediaCacheUtils.downsample(bitmap, imageSize, tempFile);
+        }
+        return success;
+    }
+
+    @Override
+    public Uri normalizeUri(Uri contentUri, MediaSize size) {
+        return contentUri;
+    }
+
+    @Override
+    public MediaSize normalizeMediaSize(Uri contentUri, MediaSize size) {
+        return size;
+    }
+
+    private static boolean isVideo(Uri uri) {
+        MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
+        String extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString());
+        String mimeType = mimeTypeMap.getMimeTypeFromExtension(extension);
+        return (mimeType != null && mimeType.startsWith("video/"));
+    }
+}
diff --git a/src/com/android/photos/data/MediaCache.java b/src/com/android/photos/data/MediaCache.java
new file mode 100644
index 0000000..7b5eca5
--- /dev/null
+++ b/src/com/android/photos/data/MediaCache.java
@@ -0,0 +1,649 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.photos.data;
+
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.os.Environment;
+import android.util.Log;
+
+import com.android.photos.data.MediaCacheDatabase.Action;
+import com.android.photos.data.MediaRetriever.MediaSize;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+
+/**
+ * MediaCache keeps a cache of images, videos, thumbnails and previews. Calls to
+ * retrieve a specific media item are executed asynchronously. The caller has an
+ * option to receive a notification for lower resolution images that happen to
+ * be available prior to the one requested.
+ * <p>
+ * When an media item has been retrieved, the notification for it is called on a
+ * separate notifier thread. This thread should not be held for a long time so
+ * that other notifications may happen.
+ * </p>
+ * <p>
+ * Media items are uniquely identified by their content URIs. Each
+ * scheme/authority can offer its own MediaRetriever, running in its own thread.
+ * </p>
+ * <p>
+ * The MediaCache is an LRU cache, but does not allow the thumbnail cache to
+ * drop below a minimum size. This prevents browsing through original images to
+ * wipe out the thumbnails.
+ * </p>
+ */
+public class MediaCache {
+    static final String TAG = MediaCache.class.getSimpleName();
+    /** Subdirectory containing the image cache. */
+    static final String IMAGE_CACHE_SUBDIR = "image_cache";
+    /** File name extension to use for cached images. */
+    static final String IMAGE_EXTENSION = ".cache";
+    /** File name extension to use for temporary cached images while retrieving. */
+    static final String TEMP_IMAGE_EXTENSION = ".temp";
+
+    public static interface ImageReady {
+        void imageReady(InputStream bitmapInputStream);
+    }
+
+    public static interface OriginalReady {
+        void originalReady(File originalFile);
+    }
+
+    /** A Thread for each MediaRetriever */
+    private class ProcessQueue extends Thread {
+        private Queue<ProcessingJob> mQueue;
+
+        public ProcessQueue(Queue<ProcessingJob> queue) {
+            mQueue = queue;
+        }
+
+        @Override
+        public void run() {
+            while (mRunning) {
+                ProcessingJob status;
+                synchronized (mQueue) {
+                    while (mQueue.isEmpty()) {
+                        try {
+                            mQueue.wait();
+                        } catch (InterruptedException e) {
+                            if (!mRunning) {
+                                return;
+                            }
+                            Log.w(TAG, "Unexpected interruption", e);
+                        }
+                    }
+                    status = mQueue.remove();
+                }
+                processTask(status);
+            }
+        }
+    };
+
+    private interface NotifyReady {
+        void notifyReady();
+
+        void setFile(File file) throws FileNotFoundException;
+    }
+
+    private static class NotifyOriginalReady implements NotifyReady {
+        private final OriginalReady mCallback;
+        private File mFile;
+
+        public NotifyOriginalReady(OriginalReady callback) {
+            mCallback = callback;
+        }
+
+        @Override
+        public void notifyReady() {
+            mCallback.originalReady(mFile);
+        }
+
+        @Override
+        public void setFile(File file) {
+            mFile = file;
+        }
+    }
+
+    private static class NotifyImageReady implements NotifyReady {
+        private final ImageReady mCallback;
+        private InputStream mInputStream;
+
+        public NotifyImageReady(ImageReady callback) {
+            mCallback = callback;
+        }
+
+        @Override
+        public void notifyReady() {
+            mCallback.imageReady(mInputStream);
+        }
+
+        @Override
+        public void setFile(File file) throws FileNotFoundException {
+            mInputStream = new FileInputStream(file);
+        }
+
+        public void setBytes(byte[] bytes) {
+            mInputStream = new ByteArrayInputStream(bytes);
+        }
+    }
+
+    /** A media item to be retrieved and its notifications. */
+    private static class ProcessingJob {
+        public ProcessingJob(Uri uri, MediaSize size, NotifyReady complete,
+                NotifyImageReady lowResolution) {
+            this.contentUri = uri;
+            this.size = size;
+            this.complete = complete;
+            this.lowResolution = lowResolution;
+        }
+        public Uri contentUri;
+        public MediaSize size;
+        public NotifyImageReady lowResolution;
+        public NotifyReady complete;
+    }
+
+    private boolean mRunning = true;
+    private static MediaCache sInstance;
+    private File mCacheDir;
+    private Context mContext;
+    private Queue<NotifyReady> mCallbacks = new LinkedList<NotifyReady>();
+    private Map<String, MediaRetriever> mRetrievers = new HashMap<String, MediaRetriever>();
+    private Map<String, List<ProcessingJob>> mTasks = new HashMap<String, List<ProcessingJob>>();
+    private List<ProcessQueue> mProcessingThreads = new ArrayList<ProcessQueue>();
+    private MediaCacheDatabase mDatabaseHelper;
+    private long mTempImageNumber = 1;
+    private Object mTempImageNumberLock = new Object();
+
+    private long mMaxCacheSize = 40 * 1024 * 1024; // 40 MB
+    private long mMinThumbCacheSize = 4 * 1024 * 1024; // 4 MB
+    private long mCacheSize = -1;
+    private long mThumbCacheSize = -1;
+    private Object mCacheSizeLock = new Object();
+
+    private Action mNotifyCachedLowResolution = new Action() {
+        @Override
+        public void execute(Uri uri, long id, MediaSize size, Object parameter) {
+            ProcessingJob job = (ProcessingJob) parameter;
+            File file = createCacheImagePath(id);
+            addNotification(job.lowResolution, file);
+        }
+    };
+
+    private Action mMoveTempToCache = new Action() {
+        @Override
+        public void execute(Uri uri, long id, MediaSize size, Object parameter) {
+            File tempFile = (File) parameter;
+            File cacheFile = createCacheImagePath(id);
+            tempFile.renameTo(cacheFile);
+        }
+    };
+
+    private Action mDeleteFile = new Action() {
+        @Override
+        public void execute(Uri uri, long id, MediaSize size, Object parameter) {
+            File file = createCacheImagePath(id);
+            file.delete();
+            synchronized (mCacheSizeLock) {
+                if (mCacheSize != -1) {
+                    long length = (Long) parameter;
+                    mCacheSize -= length;
+                    if (size == MediaSize.Thumbnail) {
+                        mThumbCacheSize -= length;
+                    }
+                }
+            }
+        }
+    };
+
+    /** The thread used to make ImageReady and OriginalReady callbacks. */
+    private Thread mProcessNotifications = new Thread() {
+        @Override
+        public void run() {
+            while (mRunning) {
+                NotifyReady notifyImage;
+                synchronized (mCallbacks) {
+                    while (mCallbacks.isEmpty()) {
+                        try {
+                            mCallbacks.wait();
+                        } catch (InterruptedException e) {
+                            if (!mRunning) {
+                                return;
+                            }
+                            Log.w(TAG, "Unexpected Interruption, continuing");
+                        }
+                    }
+                    notifyImage = mCallbacks.remove();
+                }
+
+                notifyImage.notifyReady();
+            }
+        }
+    };
+
+    public static synchronized void initialize(Context context) {
+        if (sInstance == null) {
+            sInstance = new MediaCache(context);
+            MediaCacheUtils.initialize(context);
+        }
+    }
+
+    public static MediaCache getInstance() {
+        return sInstance;
+    }
+
+    public static synchronized void shutdown() {
+        sInstance.mRunning = false;
+        sInstance.mProcessNotifications.interrupt();
+        for (ProcessQueue processingThread : sInstance.mProcessingThreads) {
+            processingThread.interrupt();
+        }
+        sInstance = null;
+    }
+
+    private MediaCache(Context context) {
+        mDatabaseHelper = new MediaCacheDatabase(context);
+        mProcessNotifications.start();
+        mContext = context;
+    }
+
+    // This is used for testing.
+    public void setCacheDir(File cacheDir) {
+        cacheDir.mkdirs();
+        mCacheDir = cacheDir;
+    }
+
+    private File getCacheDir() {
+        synchronized (mContext) {
+            if (mCacheDir == null) {
+                String state = Environment.getExternalStorageState();
+                File baseDir;
+                if (Environment.MEDIA_MOUNTED.equals(state)) {
+                    baseDir = mContext.getExternalCacheDir();
+                } else {
+                    // Stored in internal cache
+                    baseDir = mContext.getCacheDir();
+                }
+                mCacheDir = new File(baseDir, IMAGE_CACHE_SUBDIR);
+                mCacheDir.mkdirs();
+            }
+            return mCacheDir;
+        }
+    }
+
+    /**
+     * Invalidates all cached images related to a given contentUri. This call
+     * doesn't complete until the images have been removed from the cache.
+     */
+    public void invalidate(Uri contentUri) {
+        mDatabaseHelper.delete(contentUri, mDeleteFile);
+    }
+
+    public void clearCacheDir() {
+        File[] cachedFiles = getCacheDir().listFiles();
+        if (cachedFiles != null) {
+            for (File cachedFile : cachedFiles) {
+                cachedFile.delete();
+            }
+        }
+    }
+
+    /**
+     * Add a MediaRetriever for a Uri scheme and authority. This MediaRetriever
+     * will be granted its own thread for retrieving images.
+     */
+    public void addRetriever(String scheme, String authority, MediaRetriever retriever) {
+        String differentiator = getDifferentiator(scheme, authority);
+        synchronized (mRetrievers) {
+            mRetrievers.put(differentiator, retriever);
+        }
+        synchronized (mTasks) {
+            LinkedList<ProcessingJob> queue = new LinkedList<ProcessingJob>();
+            mTasks.put(differentiator, queue);
+            new ProcessQueue(queue).start();
+        }
+    }
+
+    /**
+     * Retrieves a thumbnail. complete will be called when the thumbnail is
+     * available. If lowResolution is not null and a lower resolution thumbnail
+     * is available before the thumbnail, lowResolution will be called prior to
+     * complete. All callbacks will be made on a thread other than the calling
+     * thread.
+     *
+     * @param contentUri The URI for the full resolution image to search for.
+     * @param complete Callback for when the image has been retrieved.
+     * @param lowResolution If not null and a lower resolution image is
+     *            available prior to retrieving the thumbnail, this will be
+     *            called with the low resolution bitmap.
+     */
+    public void retrieveThumbnail(Uri contentUri, ImageReady complete, ImageReady lowResolution) {
+        addTask(contentUri, complete, lowResolution, MediaSize.Thumbnail);
+    }
+
+    /**
+     * Retrieves a preview. complete will be called when the preview is
+     * available. If lowResolution is not null and a lower resolution preview is
+     * available before the preview, lowResolution will be called prior to
+     * complete. All callbacks will be made on a thread other than the calling
+     * thread.
+     *
+     * @param contentUri The URI for the full resolution image to search for.
+     * @param complete Callback for when the image has been retrieved.
+     * @param lowResolution If not null and a lower resolution image is
+     *            available prior to retrieving the preview, this will be called
+     *            with the low resolution bitmap.
+     */
+    public void retrievePreview(Uri contentUri, ImageReady complete, ImageReady lowResolution) {
+        addTask(contentUri, complete, lowResolution, MediaSize.Preview);
+    }
+
+    /**
+     * Retrieves the original image or video. complete will be called when the
+     * media is available on the local file system. If lowResolution is not null
+     * and a lower resolution preview is available before the original,
+     * lowResolution will be called prior to complete. All callbacks will be
+     * made on a thread other than the calling thread.
+     *
+     * @param contentUri The URI for the full resolution image to search for.
+     * @param complete Callback for when the image has been retrieved.
+     * @param lowResolution If not null and a lower resolution image is
+     *            available prior to retrieving the preview, this will be called
+     *            with the low resolution bitmap.
+     */
+    public void retrieveOriginal(Uri contentUri, OriginalReady complete, ImageReady lowResolution) {
+        File localFile = getLocalFile(contentUri);
+        if (localFile != null) {
+            addNotification(new NotifyOriginalReady(complete), localFile);
+        } else {
+            NotifyImageReady notifyLowResolution = (lowResolution == null) ? null
+                    : new NotifyImageReady(lowResolution);
+            addTask(contentUri, new NotifyOriginalReady(complete), notifyLowResolution,
+                    MediaSize.Original);
+        }
+    }
+
+    /**
+     * Looks for an already cached media at a specific size.
+     *
+     * @param contentUri The original media item content URI
+     * @param size The target size to search for in the cache
+     * @return The cached file location or null if it is not cached.
+     */
+    public File getCachedFile(Uri contentUri, MediaSize size) {
+        Long cachedId = mDatabaseHelper.getCached(contentUri, size);
+        File file = null;
+        if (cachedId != null) {
+            file = createCacheImagePath(cachedId);
+        }
+        return file;
+    }
+
+    /**
+     * Inserts a media item into the cache.
+     *
+     * @param contentUri The original media item URI.
+     * @param size The size of the media item to store in the cache.
+     * @param tempFile The temporary file where the image is stored. This file
+     *            will no longer exist after executing this method.
+     * @return The new location, in the cache, of the media item or null if it
+     *         wasn't possible to move into the cache.
+     */
+    public File insertIntoCache(Uri contentUri, MediaSize size, File tempFile) {
+        long fileSize = tempFile.length();
+        if (fileSize == 0) {
+            return null;
+        }
+        File cacheFile = null;
+        SQLiteDatabase db = mDatabaseHelper.getWritableDatabase();
+        // Ensure that this step is atomic
+        db.beginTransaction();
+        try {
+            Long id = mDatabaseHelper.getCached(contentUri, size);
+            if (id != null) {
+                cacheFile = createCacheImagePath(id);
+                if (tempFile.renameTo(cacheFile)) {
+                    mDatabaseHelper.updateLength(id, fileSize);
+                } else {
+                    Log.w(TAG, "Could not update cached file with " + tempFile);
+                    tempFile.delete();
+                    cacheFile = null;
+                }
+            } else {
+                ensureFreeCacheSpace(tempFile.length(), size);
+                id = mDatabaseHelper.insert(contentUri, size, mMoveTempToCache, tempFile);
+                cacheFile = createCacheImagePath(id);
+            }
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+        return cacheFile;
+    }
+
+    /**
+     * For testing purposes.
+     */
+    public void setMaxCacheSize(long maxCacheSize) {
+        synchronized (mCacheSizeLock) {
+            mMaxCacheSize = maxCacheSize;
+            mMinThumbCacheSize = mMaxCacheSize / 10;
+            mCacheSize = -1;
+            mThumbCacheSize = -1;
+        }
+    }
+
+    private File createCacheImagePath(long id) {
+        return new File(getCacheDir(), String.valueOf(id) + IMAGE_EXTENSION);
+    }
+
+    private void addTask(Uri contentUri, ImageReady complete, ImageReady lowResolution,
+            MediaSize size) {
+        NotifyReady notifyComplete = new NotifyImageReady(complete);
+        NotifyImageReady notifyLowResolution = null;
+        if (lowResolution != null) {
+            notifyLowResolution = new NotifyImageReady(lowResolution);
+        }
+        addTask(contentUri, notifyComplete, notifyLowResolution, size);
+    }
+
+    private void addTask(Uri contentUri, NotifyReady complete, NotifyImageReady lowResolution,
+            MediaSize size) {
+        MediaRetriever retriever = getMediaRetriever(contentUri);
+        Uri uri = retriever.normalizeUri(contentUri, size);
+        if (uri == null) {
+            throw new IllegalArgumentException("No MediaRetriever for " + contentUri);
+        }
+        size = retriever.normalizeMediaSize(uri, size);
+
+        Long cachedId = mDatabaseHelper.getCached(uri, size);
+        if (cachedId != null) {
+            addNotification(complete, createCacheImagePath(cachedId));
+            return;
+        }
+        String differentiator = getDifferentiator(uri.getScheme(), uri.getAuthority());
+        synchronized (mTasks) {
+            List<ProcessingJob> tasks = mTasks.get(differentiator);
+            if (tasks == null) {
+                throw new IllegalArgumentException("Cannot find retriever for: " + uri);
+            }
+            synchronized (tasks) {
+                ProcessingJob job = new ProcessingJob(uri, size, complete, lowResolution);
+                tasks.add(job);
+                tasks.notifyAll();
+            }
+        }
+    }
+
+    private MediaRetriever getMediaRetriever(Uri uri) {
+        String differentiator = getDifferentiator(uri.getScheme(), uri.getAuthority());
+        MediaRetriever retriever;
+        synchronized (mRetrievers) {
+            retriever = mRetrievers.get(differentiator);
+        }
+        if (retriever == null) {
+            throw new IllegalArgumentException("No MediaRetriever for " + uri);
+        }
+        return retriever;
+    }
+
+    private File getLocalFile(Uri uri) {
+        MediaRetriever retriever = getMediaRetriever(uri);
+        File localFile = null;
+        if (retriever != null) {
+            localFile = retriever.getLocalFile(uri);
+        }
+        return localFile;
+    }
+
+    private MediaSize getFastImageSize(Uri uri, MediaSize size) {
+        MediaRetriever retriever = getMediaRetriever(uri);
+        return retriever.getFastImageSize(uri, size);
+    }
+
+    private boolean isFastImageBetter(MediaSize fastImageType, MediaSize size) {
+        if (fastImageType == null) {
+            return false;
+        }
+        if (size == null) {
+            return true;
+        }
+        return fastImageType.isBetterThan(size);
+    }
+
+    private byte[] getTemporaryImage(Uri uri, MediaSize fastImageType) {
+        MediaRetriever retriever = getMediaRetriever(uri);
+        return retriever.getTemporaryImage(uri, fastImageType);
+    }
+
+    private void processTask(ProcessingJob job) {
+        Long cachedId = mDatabaseHelper.getCached(job.contentUri, job.size);
+        if (cachedId != null) {
+            File file = createCacheImagePath(cachedId);
+            addNotification(job.complete, file);
+            return;
+        }
+
+        boolean hasLowResolution = job.lowResolution != null;
+        if (hasLowResolution) {
+            MediaSize cachedSize = mDatabaseHelper.executeOnBestCached(job.contentUri, job.size,
+                    mNotifyCachedLowResolution);
+            MediaSize fastImageSize = getFastImageSize(job.contentUri, job.size);
+            if (isFastImageBetter(fastImageSize, cachedSize)) {
+                if (fastImageSize.isTemporary()) {
+                    byte[] bytes = getTemporaryImage(job.contentUri, fastImageSize);
+                    if (bytes != null) {
+                        addNotification(job.lowResolution, bytes);
+                    }
+                } else {
+                    File lowFile = getMedia(job.contentUri, fastImageSize);
+                    if (lowFile != null) {
+                        addNotification(job.lowResolution, lowFile);
+                    }
+                }
+            }
+        }
+
+        // Now get the full size desired
+        File fullSizeFile = getMedia(job.contentUri, job.size);
+        if (fullSizeFile != null) {
+            addNotification(job.complete, fullSizeFile);
+        }
+    }
+
+    private void addNotification(NotifyReady callback, File file) {
+        try {
+            callback.setFile(file);
+            synchronized (mCallbacks) {
+                mCallbacks.add(callback);
+                mCallbacks.notifyAll();
+            }
+        } catch (FileNotFoundException e) {
+            Log.e(TAG, "Unable to read file " + file, e);
+        }
+    }
+
+    private void addNotification(NotifyImageReady callback, byte[] bytes) {
+        callback.setBytes(bytes);
+        synchronized (mCallbacks) {
+            mCallbacks.add(callback);
+            mCallbacks.notifyAll();
+        }
+    }
+
+    private File getMedia(Uri uri, MediaSize size) {
+        long imageNumber;
+        synchronized (mTempImageNumberLock) {
+            imageNumber = mTempImageNumber++;
+        }
+        File tempFile = new File(getCacheDir(), String.valueOf(imageNumber) + TEMP_IMAGE_EXTENSION);
+        MediaRetriever retriever = getMediaRetriever(uri);
+        boolean retrieved = retriever.getMedia(uri, size, tempFile);
+        File cachedFile = null;
+        if (retrieved) {
+            ensureFreeCacheSpace(tempFile.length(), size);
+            long id = mDatabaseHelper.insert(uri, size, mMoveTempToCache, tempFile);
+            cachedFile = createCacheImagePath(id);
+        }
+        return cachedFile;
+    }
+
+    private static String getDifferentiator(String scheme, String authority) {
+        if (authority == null) {
+            return scheme;
+        }
+        StringBuilder differentiator = new StringBuilder(scheme);
+        differentiator.append(':');
+        differentiator.append(authority);
+        return differentiator.toString();
+    }
+
+    private void ensureFreeCacheSpace(long size, MediaSize mediaSize) {
+        synchronized (mCacheSizeLock) {
+            if (mCacheSize == -1 || mThumbCacheSize == -1) {
+                mCacheSize = mDatabaseHelper.getCacheSize();
+                mThumbCacheSize = mDatabaseHelper.getThumbnailCacheSize();
+                if (mCacheSize == -1 || mThumbCacheSize == -1) {
+                    Log.e(TAG, "Can't determine size of the image cache");
+                    return;
+                }
+            }
+            mCacheSize += size;
+            if (mediaSize == MediaSize.Thumbnail) {
+                mThumbCacheSize += size;
+            }
+            if (mCacheSize > mMaxCacheSize) {
+                shrinkCacheLocked();
+            }
+        }
+    }
+
+    private void shrinkCacheLocked() {
+        long deleteSize = mMinThumbCacheSize;
+        boolean includeThumbnails = (mThumbCacheSize - deleteSize) > mMinThumbCacheSize;
+        mDatabaseHelper.deleteOldCached(includeThumbnails, deleteSize, mDeleteFile);
+    }
+}
diff --git a/src/com/android/photos/data/MediaCacheDatabase.java b/src/com/android/photos/data/MediaCacheDatabase.java
new file mode 100644
index 0000000..16265b5
--- /dev/null
+++ b/src/com/android/photos/data/MediaCacheDatabase.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.photos.data;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
+import android.provider.BaseColumns;
+
+import com.android.photos.data.MediaRetriever.MediaSize;
+
+import java.io.File;
+
+class MediaCacheDatabase extends SQLiteOpenHelper {
+    public static final int DB_VERSION = 1;
+    public static final String DB_NAME = "mediacache.db";
+
+    /** Internal database table used for the media cache */
+    public static final String TABLE = "media_cache";
+
+    private static interface Columns extends BaseColumns {
+        /** The Content URI of the original image. */
+        public static final String URI = "uri";
+        /** MediaSize.getValue() values. */
+        public static final String MEDIA_SIZE = "media_size";
+        /** The last time this image was queried. */
+        public static final String LAST_ACCESS = "last_access";
+        /** The image size in bytes. */
+        public static final String SIZE_IN_BYTES = "size";
+    }
+
+    static interface Action {
+        void execute(Uri uri, long id, MediaRetriever.MediaSize size, Object parameter);
+    }
+
+    private static final String[] PROJECTION_ID = {
+        Columns._ID,
+    };
+
+    private static final String[] PROJECTION_CACHED = {
+        Columns._ID, Columns.MEDIA_SIZE, Columns.SIZE_IN_BYTES,
+    };
+
+    private static final String[] PROJECTION_CACHE_SIZE = {
+        "SUM(" + Columns.SIZE_IN_BYTES + ")"
+    };
+
+    private static final String[] PROJECTION_DELETE_OLD = {
+        Columns._ID, Columns.URI, Columns.MEDIA_SIZE, Columns.SIZE_IN_BYTES, Columns.LAST_ACCESS,
+    };
+
+    public static final String CREATE_TABLE = "CREATE TABLE " + TABLE + "("
+            + Columns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
+            + Columns.URI + " TEXT NOT NULL,"
+            + Columns.MEDIA_SIZE + " INTEGER NOT NULL,"
+            + Columns.LAST_ACCESS + " INTEGER NOT NULL,"
+            + Columns.SIZE_IN_BYTES + " INTEGER NOT NULL,"
+            + "UNIQUE(" + Columns.URI + ", " + Columns.MEDIA_SIZE + "))";
+
+    public static final String DROP_TABLE = "DROP TABLE IF EXISTS " + TABLE;
+
+    public static final String WHERE_THUMBNAIL = Columns.MEDIA_SIZE + " = "
+            + MediaSize.Thumbnail.getValue();
+
+    public static final String WHERE_NOT_THUMBNAIL = Columns.MEDIA_SIZE + " <> "
+            + MediaSize.Thumbnail.getValue();
+
+    public static final String WHERE_CLEAR_CACHE = Columns.LAST_ACCESS + " <= ?";
+
+    public static final String WHERE_CLEAR_CACHE_LARGE = WHERE_CLEAR_CACHE + " AND "
+            + WHERE_NOT_THUMBNAIL;
+
+    static class QueryCacheResults {
+        public QueryCacheResults(long id, int sizeVal) {
+            this.id = id;
+            this.size = MediaRetriever.MediaSize.fromInteger(sizeVal);
+        }
+        public long id;
+        public MediaRetriever.MediaSize size;
+    }
+
+    public MediaCacheDatabase(Context context) {
+        super(context, DB_NAME, null, DB_VERSION);
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        db.execSQL(CREATE_TABLE);
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        db.execSQL(DROP_TABLE);
+        onCreate(db);
+        MediaCache.getInstance().clearCacheDir();
+    }
+
+    public Long getCached(Uri uri, MediaRetriever.MediaSize size) {
+        String where = Columns.URI + " = ? AND " + Columns.MEDIA_SIZE + " = ?";
+        SQLiteDatabase db = getWritableDatabase();
+        String[] whereArgs = {
+                uri.toString(), String.valueOf(size.getValue()),
+        };
+        Cursor cursor = db.query(TABLE, PROJECTION_ID, where, whereArgs, null, null, null);
+        Long id = null;
+        if (cursor.moveToNext()) {
+            id = cursor.getLong(0);
+        }
+        cursor.close();
+        if (id != null) {
+            String[] updateArgs = {
+                id.toString()
+            };
+            ContentValues values = new ContentValues();
+            values.put(Columns.LAST_ACCESS, System.currentTimeMillis());
+            db.beginTransaction();
+            try {
+                db.update(TABLE, values, Columns._ID + " = ?", updateArgs);
+                db.setTransactionSuccessful();
+            } finally {
+                db.endTransaction();
+            }
+        }
+        return id;
+    }
+
+    public MediaRetriever.MediaSize executeOnBestCached(Uri uri, MediaRetriever.MediaSize size, Action action) {
+        String where = Columns.URI + " = ? AND " + Columns.MEDIA_SIZE + " < ?";
+        String orderBy = Columns.MEDIA_SIZE + " DESC";
+        SQLiteDatabase db = getReadableDatabase();
+        String[] whereArgs = {
+                uri.toString(), String.valueOf(size.getValue()),
+        };
+        Cursor cursor = db.query(TABLE, PROJECTION_CACHED, where, whereArgs, null, null, orderBy);
+        MediaRetriever.MediaSize bestSize = null;
+        if (cursor.moveToNext()) {
+            long id = cursor.getLong(0);
+            bestSize = MediaRetriever.MediaSize.fromInteger(cursor.getInt(1));
+            long fileSize = cursor.getLong(2);
+            action.execute(uri, id, bestSize, fileSize);
+        }
+        cursor.close();
+        return bestSize;
+    }
+
+    public long insert(Uri uri, MediaRetriever.MediaSize size, Action action, File tempFile) {
+        SQLiteDatabase db = getWritableDatabase();
+        db.beginTransaction();
+        try {
+            ContentValues values = new ContentValues();
+            values.put(Columns.LAST_ACCESS, System.currentTimeMillis());
+            values.put(Columns.MEDIA_SIZE, size.getValue());
+            values.put(Columns.URI, uri.toString());
+            values.put(Columns.SIZE_IN_BYTES, tempFile.length());
+            long id = db.insert(TABLE, null, values);
+            if (id != -1) {
+                action.execute(uri, id, size, tempFile);
+                db.setTransactionSuccessful();
+            }
+            return id;
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    public void updateLength(long id, long fileSize) {
+        ContentValues values = new ContentValues();
+        values.put(Columns.SIZE_IN_BYTES, fileSize);
+        String[] whereArgs = {
+            String.valueOf(id)
+        };
+        SQLiteDatabase db = getWritableDatabase();
+        db.beginTransaction();
+        try {
+            db.update(TABLE, values, Columns._ID + " = ?", whereArgs);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    public void delete(Uri uri, Action action) {
+        SQLiteDatabase db = getWritableDatabase();
+        String where = Columns.URI + " = ?";
+        String[] whereArgs = {
+            uri.toString()
+        };
+        Cursor cursor = db.query(TABLE, PROJECTION_CACHED, where, whereArgs, null, null, null);
+        while (cursor.moveToNext()) {
+            long id = cursor.getLong(0);
+            MediaRetriever.MediaSize size = MediaRetriever.MediaSize.fromInteger(cursor.getInt(1));
+            action.execute(uri, id, size, null);
+        }
+        cursor.close();
+        db.beginTransaction();
+        try {
+            db.delete(TABLE, where, whereArgs);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    public void deleteOldCached(boolean includeThumbnails, long deleteSize, Action action) {
+        String where = includeThumbnails ? null : WHERE_NOT_THUMBNAIL;
+        long lastAccess = 0;
+        SQLiteDatabase db = getWritableDatabase();
+        db.beginTransaction();
+        try {
+            Cursor cursor = db.query(TABLE, PROJECTION_DELETE_OLD, where, null, null, null,
+                    Columns.LAST_ACCESS);
+            while (cursor.moveToNext()) {
+                long id = cursor.getLong(0);
+                String uri = cursor.getString(1);
+                MediaSize size = MediaSize.fromInteger(cursor.getInt(2));
+                long length = cursor.getLong(3);
+                long imageLastAccess = cursor.getLong(4);
+
+                if (imageLastAccess != lastAccess && deleteSize < 0) {
+                    break; // We've deleted enough.
+                }
+                lastAccess = imageLastAccess;
+                action.execute(Uri.parse(uri), id, size, length);
+                deleteSize -= length;
+            }
+            cursor.close();
+            String[] whereArgs = {
+                String.valueOf(lastAccess),
+            };
+            String whereDelete = includeThumbnails ? WHERE_CLEAR_CACHE : WHERE_CLEAR_CACHE_LARGE;
+            db.delete(TABLE, whereDelete, whereArgs);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    public long getCacheSize() {
+        return getCacheSize(null);
+    }
+
+    public long getThumbnailCacheSize() {
+        return getCacheSize(WHERE_THUMBNAIL);
+    }
+
+    private long getCacheSize(String where) {
+        SQLiteDatabase db = getReadableDatabase();
+        Cursor cursor = db.query(TABLE, PROJECTION_CACHE_SIZE, where, null, null, null, null);
+        long size = -1;
+        if (cursor.moveToNext()) {
+            size = cursor.getLong(0);
+        }
+        cursor.close();
+        return size;
+    }
+}
diff --git a/src/com/android/photos/data/MediaCacheUtils.java b/src/com/android/photos/data/MediaCacheUtils.java
new file mode 100644
index 0000000..1463d52
--- /dev/null
+++ b/src/com/android/photos/data/MediaCacheUtils.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.photos.data;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.CompressFormat;
+import android.graphics.BitmapFactory;
+import android.util.Log;
+
+import com.android.gallery3d.R;
+import com.android.gallery3d.common.BitmapUtils;
+import com.android.gallery3d.data.DecodeUtils;
+import com.android.gallery3d.data.MediaItem;
+import com.android.gallery3d.util.ThreadPool.CancelListener;
+import com.android.gallery3d.util.ThreadPool.JobContext;
+import com.android.photos.data.MediaRetriever.MediaSize;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+public class MediaCacheUtils {
+    private static final String TAG = MediaCacheUtils.class.getSimpleName();
+    private static int QUALITY = 80;
+    private static final JobContext sJobStub = new JobContext() {
+
+        @Override
+        public boolean isCancelled() {
+            return false;
+        }
+
+        @Override
+        public void setCancelListener(CancelListener listener) {
+        }
+
+        @Override
+        public boolean setMode(int mode) {
+            return true;
+        }
+    };
+
+    private static int mTargetThumbnailSize;
+    private static int mTargetPreviewSize;
+
+    public static void initialize(Context context) {
+        Resources resources = context.getResources();
+        mTargetThumbnailSize = resources.getDimensionPixelSize(R.dimen.size_thumbnail);
+        mTargetPreviewSize = resources.getDimensionPixelSize(R.dimen.size_preview);
+    }
+
+    public static int getTargetSize(MediaSize size) {
+        return (size == MediaSize.Thumbnail) ? mTargetThumbnailSize : mTargetPreviewSize;
+    }
+
+    public static boolean downsample(File inBitmap, MediaSize targetSize, File outBitmap) {
+        if (MediaSize.Original == targetSize) {
+            return false; // MediaCache should use the local path for this.
+        }
+        int size = getTargetSize(targetSize);
+        BitmapFactory.Options options = new BitmapFactory.Options();
+        options.inPreferredConfig = Bitmap.Config.ARGB_8888;
+        // TODO: remove unnecessary job context from DecodeUtils.
+        Bitmap bitmap = DecodeUtils.decodeThumbnail(sJobStub, inBitmap.getPath(), options, size,
+                MediaItem.TYPE_THUMBNAIL);
+        boolean success = (bitmap != null);
+        if (success) {
+            success = writeAndRecycle(bitmap, outBitmap);
+        }
+        return success;
+    }
+
+    public static boolean downsample(Bitmap inBitmap, MediaSize size, File outBitmap) {
+        if (MediaSize.Original == size) {
+            return false; // MediaCache should use the local path for this.
+        }
+        int targetSize = getTargetSize(size);
+        boolean success;
+        if (!needsDownsample(inBitmap, size)) {
+            success = writeAndRecycle(inBitmap, outBitmap);
+        } else {
+            float maxDimension = Math.max(inBitmap.getWidth(), inBitmap.getHeight());
+            float scale = targetSize / maxDimension;
+            int targetWidth = Math.round(scale * inBitmap.getWidth());
+            int targetHeight = Math.round(scale * inBitmap.getHeight());
+            Bitmap scaled = Bitmap.createScaledBitmap(inBitmap, targetWidth, targetHeight, false);
+            success = writeAndRecycle(scaled, outBitmap);
+            inBitmap.recycle();
+        }
+        return success;
+    }
+
+    public static boolean extractImageFromVideo(File inVideo, File outBitmap) {
+        Bitmap bitmap = BitmapUtils.createVideoThumbnail(inVideo.getPath());
+        return writeAndRecycle(bitmap, outBitmap);
+    }
+
+    public static boolean needsDownsample(Bitmap bitmap, MediaSize size) {
+        if (size == MediaSize.Original) {
+            return false;
+        }
+        int targetSize = getTargetSize(size);
+        int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight());
+        return maxDimension > (targetSize * 4 / 3);
+    }
+
+    public static boolean writeAndRecycle(Bitmap bitmap, File outBitmap) {
+        boolean success = writeToFile(bitmap, outBitmap);
+        bitmap.recycle();
+        return success;
+    }
+
+    public static boolean writeToFile(Bitmap bitmap, File outBitmap) {
+        boolean success = false;
+        try {
+            FileOutputStream out = new FileOutputStream(outBitmap);
+            success = bitmap.compress(CompressFormat.JPEG, QUALITY, out);
+            out.close();
+        } catch (IOException e) {
+            Log.w(TAG, "Couldn't write bitmap to cache", e);
+            // success is already false
+        }
+        return success;
+    }
+}
diff --git a/src/com/android/photos/data/MediaRetriever.java b/src/com/android/photos/data/MediaRetriever.java
new file mode 100644
index 0000000..f383e5f
--- /dev/null
+++ b/src/com/android/photos/data/MediaRetriever.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.photos.data;
+
+import android.net.Uri;
+
+import java.io.File;
+
+public interface MediaRetriever {
+    public enum MediaSize {
+        TemporaryThumbnail(5), Thumbnail(10), TemporaryPreview(15), Preview(20), Original(30);
+
+        private final int mValue;
+
+        private MediaSize(int value) {
+            mValue = value;
+        }
+
+        public int getValue() {
+            return mValue;
+        }
+
+        static MediaSize fromInteger(int value) {
+            switch (value) {
+                case 10:
+                    return MediaSize.Thumbnail;
+                case 20:
+                    return MediaSize.Preview;
+                case 30:
+                    return MediaSize.Original;
+                default:
+                    throw new IllegalArgumentException();
+            }
+        }
+
+        public boolean isBetterThan(MediaSize that) {
+            return mValue > that.mValue;
+        }
+
+        public boolean isTemporary() {
+            return this == TemporaryThumbnail || this == TemporaryPreview;
+        }
+    }
+
+    /**
+     * Returns the local File for the given Uri. If the image is not stored
+     * locally, null should be returned. The image should not be retrieved if it
+     * isn't already available.
+     *
+     * @param contentUri The media URI to search for.
+     * @return The local File of the image if it is available or null if it
+     *         isn't.
+     */
+    File getLocalFile(Uri contentUri);
+
+    /**
+     * Returns the fast access image type for a given image size, if supported.
+     * This image should be smaller than size and should be quick to retrieve.
+     * It does not have to obey the expected aspect ratio.
+     *
+     * @param contentUri The original media Uri.
+     * @param size The target size to search for a fast-access image.
+     * @return The fast image type supported for the given image size or null of
+     *         no fast image is supported.
+     */
+    MediaSize getFastImageSize(Uri contentUri, MediaSize size);
+
+    /**
+     * Returns a byte array containing the contents of the fast temporary image
+     * for a given image size. For example, a thumbnail may be smaller or of a
+     * different aspect ratio than the generated thumbnail.
+     *
+     * @param contentUri The original media Uri.
+     * @param temporarySize The target media size. Guaranteed to be a MediaSize
+     *            for which isTemporary() returns true.
+     * @return A byte array of contents for for the given contentUri and
+     *         fastImageType. null can be retrieved if the quick retrieval
+     *         fails.
+     */
+    byte[] getTemporaryImage(Uri contentUri, MediaSize temporarySize);
+
+    /**
+     * Retrieves an image and saves it to a file.
+     *
+     * @param contentUri The original media Uri.
+     * @param size The target media size.
+     * @param tempFile The file to write the bitmap to.
+     * @return <code>true</code> on success.
+     */
+    boolean getMedia(Uri contentUri, MediaSize imageSize, File tempFile);
+
+    /**
+     * Normalizes a URI that may have additional parameters. It is fine to
+     * return contentUri. This is executed on the calling thread, so it must be
+     * a fast access operation and cannot depend, for example, on I/O.
+     *
+     * @param contentUri The URI to normalize
+     * @param size The size of the image being requested
+     * @return The normalized URI representation of contentUri.
+     */
+    Uri normalizeUri(Uri contentUri, MediaSize size);
+
+    /**
+     * Normalize the MediaSize for a given URI. Typically the size returned
+     * would be the passed-in size. Some URIs may only have one size used and
+     * should be treaded as Thumbnails, for example. This is executed on the
+     * calling thread, so it must be a fast access operation and cannot depend,
+     * for example, on I/O.
+     *
+     * @param contentUri The URI for the size being normalized.
+     * @param size The size to be normalized.
+     * @return The normalized size of the given URI.
+     */
+    MediaSize normalizeMediaSize(Uri contentUri, MediaSize size);
+}
diff --git a/tests/src/com/android/photos/data/DataTestRunner.java b/tests/src/com/android/photos/data/DataTestRunner.java
index 4322585..10618d6 100644
--- a/tests/src/com/android/photos/data/DataTestRunner.java
+++ b/tests/src/com/android/photos/data/DataTestRunner.java
@@ -18,6 +18,9 @@
 import android.test.InstrumentationTestRunner;
 import android.test.InstrumentationTestSuite;
 
+import com.android.photos.data.TestHelper.TestInitialization;
+
+import junit.framework.TestCase;
 import junit.framework.TestSuite;
 
 public class DataTestRunner extends InstrumentationTestRunner {
@@ -26,6 +29,13 @@
         TestSuite suite = new InstrumentationTestSuite(this);
         suite.addTestSuite(PhotoDatabaseTest.class);
         suite.addTestSuite(PhotoProviderTest.class);
+        TestHelper.addTests(MediaCacheTest.class, suite, new TestInitialization() {
+            @Override
+            public void initialize(TestCase testCase) {
+                MediaCacheTest test = (MediaCacheTest) testCase;
+                test.setLocalContext(getContext());
+            }
+        });
         return suite;
     }
 
diff --git a/tests/src/com/android/photos/data/MediaCacheTest.java b/tests/src/com/android/photos/data/MediaCacheTest.java
new file mode 100644
index 0000000..df990ed
--- /dev/null
+++ b/tests/src/com/android/photos/data/MediaCacheTest.java
@@ -0,0 +1,388 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.photos.data;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.Environment;
+import android.os.SystemClock;
+import android.test.ProviderTestCase2;
+
+import com.android.gallery3d.tests.R;
+import com.android.photos.data.MediaCache.ImageReady;
+import com.android.photos.data.MediaCache.OriginalReady;
+import com.android.photos.data.MediaRetriever.MediaSize;
+import com.android.photos.data.PhotoProvider.Photos;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class MediaCacheTest extends ProviderTestCase2<PhotoProvider> {
+    @SuppressWarnings("unused")
+    private static final String TAG = MediaCacheTest.class.getSimpleName();
+
+    private File mDir;
+    private File mImage;
+    private File mCacheDir;
+    private Resources mResources;
+    private MediaCache mMediaCache;
+    private ReadyCollector mReady;
+
+    public static final long MAX_WAIT = 2000;
+
+    private static class ReadyCollector implements ImageReady, OriginalReady {
+        public File mOriginalFile;
+        public InputStream mInputStream;
+
+        @Override
+        public synchronized void originalReady(File originalFile) {
+            mOriginalFile = originalFile;
+            notifyAll();
+        }
+
+        @Override
+        public synchronized void imageReady(InputStream bitmapInputStream) {
+            mInputStream = bitmapInputStream;
+            notifyAll();
+        }
+
+        public synchronized boolean waitForNotification() {
+            long endWait = SystemClock.uptimeMillis() + MAX_WAIT;
+
+            try {
+                while (mInputStream == null && mOriginalFile == null
+                        && SystemClock.uptimeMillis() < endWait) {
+                    wait(endWait - SystemClock.uptimeMillis());
+                }
+            } catch (InterruptedException e) {
+            }
+            return mInputStream != null || mOriginalFile != null;
+        }
+    }
+
+    private static class DummyMediaRetriever implements MediaRetriever {
+        private boolean mNullUri = false;
+        @Override
+        public File getLocalFile(Uri contentUri) {
+            return null;
+        }
+
+        @Override
+        public MediaSize getFastImageSize(Uri contentUri, MediaSize size) {
+            return null;
+        }
+
+        @Override
+        public byte[] getTemporaryImage(Uri contentUri, MediaSize temporarySize) {
+            return null;
+        }
+
+        @Override
+        public boolean getMedia(Uri contentUri, MediaSize imageSize, File tempFile) {
+            return false;
+        }
+
+        @Override
+        public Uri normalizeUri(Uri contentUri, MediaSize size) {
+            if (mNullUri) {
+                return null;
+            } else {
+                return contentUri;
+            }
+        }
+
+        @Override
+        public MediaSize normalizeMediaSize(Uri contentUri, MediaSize size) {
+            return size;
+        }
+
+        public void setNullUri() {
+            mNullUri = true;
+        }
+    };
+
+    public MediaCacheTest() {
+        super(PhotoProvider.class, PhotoProvider.AUTHORITY);
+    }
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+
+        mReady = new ReadyCollector();
+        File externalDir = Environment.getExternalStorageDirectory();
+        mDir = new File(externalDir, "test");
+        mDir.mkdirs();
+        mCacheDir = new File(externalDir, "test_cache");
+        mImage = new File(mDir, "original.jpg");
+        MediaCache.initialize(getMockContext());
+        MediaCache.getInstance().setCacheDir(mCacheDir);
+        mMediaCache = MediaCache.getInstance();
+        mMediaCache.addRetriever("file", "", new FileRetriever());
+    }
+
+    @Override
+    public void tearDown() throws Exception {
+        super.tearDown();
+        mMediaCache.clearCacheDir();
+        MediaCache.shutdown();
+        mMediaCache = null;
+        mImage.delete();
+        mDir.delete();
+        mCacheDir.delete();
+    }
+
+    public void setLocalContext(Context context) {
+        mResources = context.getResources();
+    }
+
+    public void testRetrieveOriginal() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        mMediaCache.retrieveOriginal(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNull(mReady.mInputStream);
+        assertEquals(mImage, mReady.mOriginalFile);
+    }
+
+    public void testRetrievePreview() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        mMediaCache.retrievePreview(uri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+        assertNotNull(mReady.mInputStream);
+        assertNull(mReady.mOriginalFile);
+        Bitmap bitmap = BitmapFactory.decodeStream(mReady.mInputStream);
+        mReady.mInputStream.close();
+        assertNotNull(bitmap);
+        Bitmap original = BitmapFactory.decodeFile(mImage.getPath());
+        assertTrue(bitmap.getWidth() < original.getWidth());
+        assertTrue(bitmap.getHeight() < original.getHeight());
+        int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight());
+        int targetSize = MediaCacheUtils.getTargetSize(MediaSize.Preview);
+        assertTrue(maxDimension >= targetSize);
+        assertTrue(maxDimension < (targetSize * 2));
+    }
+
+    public void testRetrieveExifThumb() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        ReadyCollector done = new ReadyCollector();
+        mMediaCache.retrieveThumbnail(uri, done, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNotNull(mReady.mInputStream);
+        assertNull(mReady.mOriginalFile);
+        Bitmap bitmap = BitmapFactory.decodeStream(mReady.mInputStream);
+        mReady.mInputStream.close();
+        assertTrue(done.waitForNotification());
+        assertNotNull(done.mInputStream);
+        done.mInputStream.close();
+        assertNotNull(bitmap);
+        assertEquals(320, bitmap.getWidth());
+        assertEquals(240, bitmap.getHeight());
+    }
+
+    public void testRetrieveThumb() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        long downsampleStart = SystemClock.uptimeMillis();
+        mMediaCache.retrieveThumbnail(uri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+        long downsampleEnd = SystemClock.uptimeMillis();
+        assertNotNull(mReady.mInputStream);
+        assertNull(mReady.mOriginalFile);
+        Bitmap bitmap = BitmapFactory.decodeStream(mReady.mInputStream);
+        mReady.mInputStream.close();
+        assertNotNull(bitmap);
+        Bitmap original = BitmapFactory.decodeFile(mImage.getPath());
+        assertTrue(bitmap.getWidth() < original.getWidth());
+        assertTrue(bitmap.getHeight() < original.getHeight());
+        int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight());
+        int targetSize = MediaCacheUtils.getTargetSize(MediaSize.Thumbnail);
+        assertTrue(maxDimension >= targetSize);
+        assertTrue(maxDimension < (targetSize * 2));
+
+        // Retrieve cached thumb.
+        mReady = new ReadyCollector();
+        long start = SystemClock.uptimeMillis();
+        mMediaCache.retrieveThumbnail(uri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+        mReady.mInputStream.close();
+        long end = SystemClock.uptimeMillis();
+        // Already cached. Wait shorter time.
+        assertTrue((end - start) < (downsampleEnd - downsampleStart) / 2);
+    }
+
+    public void testGetVideo() throws IOException {
+        mImage = new File(mDir, "original.mp4");
+        copyResourceToFile(R.raw.android_lawn, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+
+        mMediaCache.retrieveOriginal(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNull(mReady.mInputStream);
+        assertNotNull(mReady.mOriginalFile);
+
+        mReady = new ReadyCollector();
+        mMediaCache.retrievePreview(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNotNull(mReady.mInputStream);
+        assertNull(mReady.mOriginalFile);
+        Bitmap bitmap = BitmapFactory.decodeStream(mReady.mInputStream);
+        mReady.mInputStream.close();
+        int maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight());
+        int targetSize = MediaCacheUtils.getTargetSize(MediaSize.Preview);
+        assertTrue(maxDimension >= targetSize);
+        assertTrue(maxDimension < (targetSize * 2));
+
+        mReady = new ReadyCollector();
+        mMediaCache.retrieveThumbnail(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNotNull(mReady.mInputStream);
+        assertNull(mReady.mOriginalFile);
+        bitmap = BitmapFactory.decodeStream(mReady.mInputStream);
+        mReady.mInputStream.close();
+        maxDimension = Math.max(bitmap.getWidth(), bitmap.getHeight());
+        targetSize = MediaCacheUtils.getTargetSize(MediaSize.Thumbnail);
+        assertTrue(maxDimension >= targetSize);
+        assertTrue(maxDimension < (targetSize * 2));
+    }
+
+    public void testFastImage() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        mMediaCache.retrieveThumbnail(uri, mReady, mReady);
+        mReady.waitForNotification();
+        mReady.mInputStream.close();
+
+        mMediaCache.retrieveOriginal(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        assertNotNull(mReady.mInputStream);
+        mReady.mInputStream.close();
+    }
+
+    public void testBadRetriever() {
+        Uri uri = Photos.CONTENT_URI;
+        try {
+            mMediaCache.retrieveOriginal(uri, mReady, mReady);
+            fail("Expected exception");
+        } catch (IllegalArgumentException e) {
+            // expected
+        }
+    }
+
+    public void testInsertIntoCache() throws IOException {
+        // FileRetriever inserts into the cache opportunistically with Videos
+        mImage = new File(mDir, "original.mp4");
+        copyResourceToFile(R.raw.android_lawn, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+
+        mMediaCache.retrieveThumbnail(uri, mReady, mReady);
+        assertTrue(mReady.waitForNotification());
+        mReady.mInputStream.close();
+        assertNotNull(mMediaCache.getCachedFile(uri, MediaSize.Preview));
+    }
+
+    public void testBadNormalizedUri() {
+        DummyMediaRetriever retriever = new DummyMediaRetriever();
+        Uri uri = Uri.fromParts("http", "world", "morestuff");
+        mMediaCache.addRetriever(uri.getScheme(), uri.getAuthority(), retriever);
+        retriever.setNullUri();
+        try {
+            mMediaCache.retrieveOriginal(uri, mReady, mReady);
+            fail("Expected IllegalArgumentException");
+        } catch (IllegalArgumentException e) {
+            // expected
+        }
+    }
+
+    public void testClearOldCache() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri uri = Uri.fromFile(mImage);
+        mMediaCache.retrievePreview(uri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+        mReady.mInputStream.close();
+        mMediaCache.setMaxCacheSize(mMediaCache.getCachedFile(uri, MediaSize.Preview).length());
+        assertNotNull(mMediaCache.getCachedFile(uri, MediaSize.Preview));
+
+        mReady = new ReadyCollector();
+        // This should kick the preview image out of the cache.
+        mMediaCache.retrieveThumbnail(uri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+        mReady.mInputStream.close();
+        assertNull(mMediaCache.getCachedFile(uri, MediaSize.Preview));
+        assertNotNull(mMediaCache.getCachedFile(uri, MediaSize.Thumbnail));
+    }
+
+    public void testClearLargeInCache() throws IOException {
+        copyResourceToFile(R.raw.galaxy_nexus, mImage.getPath());
+        Uri imageUri = Uri.fromFile(mImage);
+        mMediaCache.retrieveThumbnail(imageUri, mReady, null);
+        assertTrue(mReady.waitForNotification());
+            mReady.mInputStream.close();
+        assertNotNull(mMediaCache.getCachedFile(imageUri, MediaSize.Thumbnail));
+        long thumbSize = mMediaCache.getCachedFile(imageUri, MediaSize.Thumbnail).length();
+        mMediaCache.setMaxCacheSize(thumbSize * 10);
+
+        for (int i = 0; i < 9; i++) {
+            File tempImage = new File(mDir, "image" + i + ".jpg");
+            mImage.renameTo(tempImage);
+            Uri tempImageUri = Uri.fromFile(tempImage);
+            mReady = new ReadyCollector();
+            mMediaCache.retrieveThumbnail(tempImageUri, mReady, null);
+            assertTrue(mReady.waitForNotification());
+                mReady.mInputStream.close();
+            tempImage.renameTo(mImage);
+        }
+        assertNotNull(mMediaCache.getCachedFile(imageUri, MediaSize.Thumbnail));
+
+        for (int i = 0; i < 9; i++) {
+            File tempImage = new File(mDir, "image" + i + ".jpg");
+            mImage.renameTo(tempImage);
+            Uri tempImageUri = Uri.fromFile(tempImage);
+            mReady = new ReadyCollector();
+            mMediaCache.retrievePreview(tempImageUri, mReady, null);
+            assertTrue(mReady.waitForNotification());
+                mReady.mInputStream.close();
+            tempImage.renameTo(mImage);
+        }
+        assertNotNull(mMediaCache.getCachedFile(imageUri, MediaSize.Thumbnail));
+        Uri oldestUri = Uri.fromFile(new File(mDir, "image0.jpg"));
+        assertNull(mMediaCache.getCachedFile(oldestUri, MediaSize.Thumbnail));
+    }
+
+    private void copyResourceToFile(int resourceId, String path) throws IOException {
+        File outputDir = new File(path).getParentFile();
+        outputDir.mkdirs();
+
+        InputStream in = mResources.openRawResource(resourceId);
+        FileOutputStream out = new FileOutputStream(path);
+        byte[] buffer = new byte[1000];
+        int bytesRead;
+
+        while ((bytesRead = in.read(buffer)) >= 0) {
+            out.write(buffer, 0, bytesRead);
+        }
+
+        in.close();
+        out.close();
+    }
+}