Merge "First commit of the native photo viewer." into jb-ub-mail
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 9f5db0e..7fb6a05 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -106,6 +106,12 @@
             </intent-filter>
         </activity>
 
+        <activity
+            android:name=".photo.PhotoViewActivity"
+            android:label="@string/app_name"
+            android:theme="@style/PhotoViewTheme" >
+        </activity>
+
         <provider
             android:authorities="com.android.mail.mockprovider"
             android:label="@string/mock_content_provider"
diff --git a/res/anim/fade_in.xml b/res/anim/fade_in.xml
new file mode 100644
index 0000000..a63b144
--- /dev/null
+++ b/res/anim/fade_in.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+    <alpha android:fromAlpha="0" android:toAlpha="1" android:duration="200" />
+</set>
diff --git a/res/anim/fade_out.xml b/res/anim/fade_out.xml
new file mode 100644
index 0000000..6e834cb
--- /dev/null
+++ b/res/anim/fade_out.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+    <alpha android:fromAlpha="1" android:toAlpha="0" android:duration="200" />
+</set>
diff --git a/res/drawable-hdpi/btn_bg_pressed.9.png b/res/drawable-hdpi/btn_bg_pressed.9.png
new file mode 100644
index 0000000..b1afd4b
--- /dev/null
+++ b/res/drawable-hdpi/btn_bg_pressed.9.png
Binary files differ
diff --git a/res/drawable-hdpi/btn_bg_selected.9.png b/res/drawable-hdpi/btn_bg_selected.9.png
new file mode 100644
index 0000000..331f96f
--- /dev/null
+++ b/res/drawable-hdpi/btn_bg_selected.9.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_ab_back_holo_dark.png b/res/drawable-hdpi/ic_ab_back_holo_dark.png
new file mode 100644
index 0000000..7855cda
--- /dev/null
+++ b/res/drawable-hdpi/ic_ab_back_holo_dark.png
Binary files differ
diff --git a/res/drawable/photo_view_background.xml b/res/drawable/photo_view_background.xml
new file mode 100644
index 0000000..f7ebc17
--- /dev/null
+++ b/res/drawable/photo_view_background.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android" >
+    <item>
+        <shape>
+            <solid android:color="@color/photo_background_color"/>
+        </shape>
+    </item>
+</layer-list>
diff --git a/res/drawable/photo_view_selector.xml b/res/drawable/photo_view_selector.xml
new file mode 100644
index 0000000..55b118a
--- /dev/null
+++ b/res/drawable/photo_view_selector.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:state_pressed="true"
+        android:drawable="@drawable/photo_view_selector_pressed" />
+
+    <item android:state_focused="true"
+        android:drawable="@drawable/photo_view_selector_focused" />
+</selector>
\ No newline at end of file
diff --git a/res/drawable/photo_view_selector_focused.xml b/res/drawable/photo_view_selector_focused.xml
new file mode 100644
index 0000000..3bda586
--- /dev/null
+++ b/res/drawable/photo_view_selector_focused.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="@color/clear"/>
+    <stroke android:width="2dp" android:color="@color/photo_selection_color"/>
+</shape>
diff --git a/res/drawable/photo_view_selector_pressed.xml b/res/drawable/photo_view_selector_pressed.xml
new file mode 100644
index 0000000..ced1b02
--- /dev/null
+++ b/res/drawable/photo_view_selector_pressed.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="@color/clear"/>
+</shape>
diff --git a/res/drawable/title_button_background.xml b/res/drawable/title_button_background.xml
new file mode 100644
index 0000000..dff0640
--- /dev/null
+++ b/res/drawable/title_button_background.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>

+<!--

+     Copyright (C) 2011 Google Inc.

+     Licensed to 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.

+-->

+<selector xmlns:android="http://schemas.android.com/apk/res/android">

+    <item android:state_focused="true" android:state_pressed="true" android:drawable="@drawable/btn_bg_pressed"/>

+    <item android:state_focused="false" android:state_pressed="true" android:drawable="@drawable/btn_bg_pressed"/>

+    <item android:state_focused="true" android:drawable="@drawable/btn_bg_selected"/>

+    <item android:state_focused="false" android:state_pressed="false" android:drawable="@color/clear"/>

+</selector>
\ No newline at end of file
diff --git a/res/layout/action_bar_progress_spinner_layout.xml b/res/layout/action_bar_progress_spinner_layout.xml
new file mode 100644
index 0000000..38df7de
--- /dev/null
+++ b/res/layout/action_bar_progress_spinner_layout.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+
+<!-- ProgressBar must be wrapped into a container view if we want to control its
+    visibility programmatically. The visibility of the container is determined by the
+    visibility set on the MenuItem, not by the explicit setVisibility() calls. -->
+<FrameLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:paddingRight="16dp">
+    <ProgressBar
+        android:id="@+id/action_bar_progress_spinner_view"
+        style="?android:attr/progressBarStyleSmall"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:padding="5dip"
+        android:indeterminate="true" />
+</FrameLayout>
diff --git a/res/layout/loading_message.xml b/res/layout/loading_message.xml
new file mode 100644
index 0000000..4528513
--- /dev/null
+++ b/res/layout/loading_message.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="horizontal"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:layout_gravity="center"
+    android:gravity="center">
+    <ProgressBar
+        style="?android:attr/progressBarStyleSmall"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginRight="8dp" />
+    <TextView
+        android:text="@string/loading"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content" />
+</LinearLayout>
diff --git a/res/layout/photo_activity_view.xml b/res/layout/photo_activity_view.xml
new file mode 100644
index 0000000..df2a959
--- /dev/null
+++ b/res/layout/photo_activity_view.xml
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/photo_activity_root_view"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    >
+    <LinearLayout
+        android:orientation="vertical"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        >
+        <!-- <include layout="@layout/title_layout"/> -->
+        <com.android.mail.photo.PhotoViewPager
+            android:id="@+id/photo_view_pager"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            />
+    </LinearLayout>
+    <!-- We cannot directly include empty_layout as the IDs defined there
+         would clash with IDs defined underneath the PhotoViewPager. -->
+    <FrameLayout
+        android:id="@+id/photo_activity_empty"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_weight="1">
+        <TextView
+            android:id="@+id/photo_activity_empty_text"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:gravity="center"
+            android:textSize="18sp"/>
+
+        <LinearLayout
+            android:id="@+id/photo_activity_empty_progress"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center"
+            android:visibility="gone">
+            <ProgressBar
+                style="?android:attr/progressBarStyleSmall"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="5dip"
+                android:indeterminate="true"/>
+            <TextView
+                android:id="@+id/photo_activity_empty_progress_text"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginLeft="5dip"
+                android:textSize="18sp"
+                android:textColor="@color/title_text_color"
+                android:text="@string/loading_photo"/>
+        </LinearLayout>
+    </FrameLayout>
+</FrameLayout>
+
diff --git a/res/layout/photo_fragment_view.xml b/res/layout/photo_fragment_view.xml
new file mode 100644
index 0000000..05babdf
--- /dev/null
+++ b/res/layout/photo_fragment_view.xml
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@color/solid_black"
+    >
+    <com.android.mail.photo.views.PhotoLayout
+        android:id="@+id/photo_layout"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:orientation="vertical"
+        >
+        <com.android.mail.photo.views.PhotoView
+            android:id="@+id/photo_view"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            />
+    </com.android.mail.photo.views.PhotoLayout>
+
+
+    <FrameLayout
+        android:id="@id/android:empty"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_centerInParent="true"
+        >
+        <TextView
+            android:id="@+id/list_empty_text"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:gravity="center"
+            android:textSize="18sp"
+            android:visibility="gone"
+            />
+        <LinearLayout
+            android:id="@+id/list_empty_progress"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center"
+            android:visibility="gone"
+            >
+            <ProgressBar
+                style="?android:attr/progressBarStyleSmall"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="5dip"
+                android:indeterminate="true"
+                />
+            <TextView
+                android:id="@+id/list_empty_progress_text"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginLeft="5dip"
+                android:textSize="18sp"
+                android:textColor="@color/title_text_color"
+                android:text="@string/loading_photo"
+                />
+        </LinearLayout>
+    </FrameLayout>
+</RelativeLayout>
diff --git a/res/layout/photo_spacer_view.xml b/res/layout/photo_spacer_view.xml
new file mode 100644
index 0000000..3697e39
--- /dev/null
+++ b/res/layout/photo_spacer_view.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+<View xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="0dp"
+    android:background="@color/photo_background_color"
+    />
diff --git a/res/layout/title_layout.xml b/res/layout/title_layout.xml
new file mode 100644
index 0000000..f904348
--- /dev/null
+++ b/res/layout/title_layout.xml
@@ -0,0 +1,103 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/title_layout"
+    android:layout_width="match_parent"
+    android:layout_height="@dimen/titlebar_height"
+    android:background="@color/title_background"
+    android:padding="0dp"
+    android:visibility="gone">
+    <LinearLayout android:id="@+id/titlebar_icon_layout"
+        android:layout_width="wrap_content"
+        android:layout_height="match_parent"
+        android:layout_alignParentLeft="true"
+        android:gravity="center_vertical"
+        android:orientation="horizontal">
+        <ImageView
+            android:id="@+id/titlebar_up"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:src="@drawable/ic_ab_back_holo_dark"/>
+        <ImageView
+            android:id="@+id/titlebar_icon"
+            android:layout_width="@dimen/titlebar_icon_size"
+            android:layout_height="@dimen/titlebar_icon_size"/>
+    </LinearLayout>
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_toLeftOf="@+id/title_button_1"
+        android:layout_toRightOf="@+id/titlebar_icon_layout"
+        android:layout_centerVertical="true"
+        android:orientation="vertical"
+        android:layout_marginLeft="4dip"
+        android:layout_marginRight="4dip">
+        <TextView
+            android:id="@+id/titlebar_label"
+            android:layout_width="wrap_content"
+            android:layout_height="match_parent"
+            android:textSize="18sp"
+            android:textStyle="bold"
+            android:ellipsize="end"
+            android:singleLine="true"
+            android:textColor="@color/title_text_color"/>
+        <TextView
+            android:id="@+id/titlebar_label_2"
+            android:layout_width="wrap_content"
+            android:layout_height="match_parent"
+            android:textSize="12sp"
+            android:ellipsize="end"
+            android:singleLine="true"
+            android:textColor="@color/title_text_color"
+            android:visibility="gone"/>
+    </LinearLayout>
+
+    <ProgressBar android:id="@+id/progress_spinner"
+        style="?android:attr/progressBarStyleSmallInverse"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_toLeftOf="@+id/title_button_1"
+        android:layout_alignWithParentIfMissing="true"
+        android:layout_centerVertical="true"
+        android:layout_marginRight="16dip"
+        android:visibility="gone"
+        android:indeterminate="true"/>
+    <ImageButton android:id="@+id/title_button_1"
+        android:layout_width="@dimen/titlebar_height"
+        android:layout_height="match_parent"
+        android:layout_toLeftOf="@+id/title_button_2"
+        android:layout_alignWithParentIfMissing="true"
+        android:layout_alignParentTop="true"
+        android:layout_alignParentBottom="true"
+        android:visibility="gone"/>
+    <ImageButton android:id="@+id/title_button_2"
+        android:layout_width="@dimen/titlebar_height"
+        android:layout_height="match_parent"
+        android:layout_toLeftOf="@+id/title_button_3"
+        android:layout_alignWithParentIfMissing="true"
+        android:layout_alignParentTop="true"
+        android:layout_alignParentBottom="true"
+        android:visibility="gone"/>
+    <ImageButton android:id="@+id/title_button_3"
+        android:layout_width="@dimen/titlebar_height"
+        android:layout_height="match_parent"
+        android:layout_alignParentRight="true"
+        android:layout_alignParentTop="true"
+        android:layout_alignParentBottom="true"
+        android:visibility="gone"/>
+</RelativeLayout>
diff --git a/res/menu/photo_view_menu.xml b/res/menu/photo_view_menu.xml
new file mode 100644
index 0000000..263cc95
--- /dev/null
+++ b/res/menu/photo_view_menu.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+
+<!--     <item
+        android:id="@+id/action_bar_progress_spinner"
+        android:actionLayout="@layout/action_bar_progress_spinner_layout"
+        android:background="@null"
+        android:selectableItemBackground="@null"
+        android:showAsAction="always"/>
+
+    <item
+        android:id="@+id/share_photo"
+        android:icon="@drawable/ic_menu_reshare"
+        android:showAsAction="always"
+        android:title="@string/menu_photo_share"
+        android:visibility="gone"/>
+
+    <item
+        android:id="@+id/plus1"
+        android:icon="@drawable/ic_menu_plus1"
+        android:showAsAction="always"
+        android:title="@string/menu_add_plus_one"/>
+
+    <item
+        android:id="@+id/remove_plus1"
+        android:icon="@drawable/ic_menu_remove_plus1"
+        android:showAsAction="always"
+        android:title="@string/menu_remove_plus_one"
+        android:visibility="gone"/>
+
+    <item
+        android:id="@+id/set_profile_photo"
+        android:icon="@drawable/ic_menu_set_as_profile"
+        android:showAsAction="never"
+        android:title="@string/menu_set_profile_photo"/>
+
+    <item
+        android:id="@+id/set_wallpaper_photo"
+        android:icon="@drawable/ic_menu_wallpaper"
+        android:showAsAction="never"
+        android:title="@string/menu_set_wallpaper_photo"/>
+
+    <item
+        android:id="@+id/remove_tag"
+        android:icon="@drawable/ic_menu_remove_tag"
+        android:showAsAction="never"
+        android:title="@string/menu_remove_tag"
+        android:visibility="gone"/>
+
+    <item
+        android:id="@+id/refresh_photo"
+        android:icon="@drawable/ic_menu_refresh"
+        android:showAsAction="never"
+        android:title="@string/menu_refresh_photo"/>
+
+    <item
+        android:id="@+id/delete_photo"
+        android:icon="@drawable/ic_menu_delete"
+        android:showAsAction="never"
+        android:title="@string/menu_delete_photo"/>
+
+    <item
+        android:id="@+id/report_photo"
+        android:icon="@drawable/ic_menu_delete"
+        android:showAsAction="never"
+        android:title="@string/menu_report_photo"/>
+
+    <item
+        android:id="@+id/download_photo"
+        android:icon="@drawable/ic_menu_download"
+        android:showAsAction="never"
+        android:title="@string/menu_download_photo"/>
+
+    <item
+        android:id="@+id/feedback"
+        android:icon="@drawable/ic_menu_feedback"
+        android:showAsAction="never"
+        android:title="@string/home_menu_feedback"/>
+
+    <item
+        android:id="@+id/help"
+        android:icon="@drawable/ic_menu_help"
+        android:showAsAction="never"
+        android:title="@string/menu_home_help"/>
+ -->
+</menu>
diff --git a/res/values/attrs.xml b/res/values/attrs.xml
index 746cce1..70fc10a 100644
--- a/res/values/attrs.xml
+++ b/res/values/attrs.xml
@@ -27,5 +27,4 @@
     <add-resource name="RecipientComposeFieldLayout" type="style" />
     <add-resource name="ComposeBodyStyle" type="style" />
     <add-resource name="ComposeSubjectStyle" type="style" />
-
 </resources>
diff --git a/res/values/colors.xml b/res/values/colors.xml
index 4bac510..e4c0366 100644
--- a/res/values/colors.xml
+++ b/res/values/colors.xml
@@ -56,4 +56,18 @@
 
     <!-- Folder colors -->
     <color name="folder_disabled_drop_target_text_color">#999999</color>
+
+    <!-- Photo Viewer Colors -->
+    <color name="solid_black">#ff000000</color>
+    <color name="title_background">#ff292929</color>
+    <color name="title_text_color">#ffffffff</color>
+    <color name="clear">#00000000</color>
+    <color name="participants_gallery">#FF3d3d3d</color>
+    <color name="stream_content_color">#333</color>
+    <color name="stream_comment_bg_color">#aaedf0f4</color>
+    <color name="stream_link">#ff33bede</color>
+    <color name="photo_selection_color">#ff58d83e</color>
+    <color name="photo_background_color">#ff000000</color>
+    <color name="photo_crop_dim_color">#cc000000</color>
+    <color name="photo_crop_highlight_color">#fff</color>
 </resources>
diff --git a/res/values/constants.xml b/res/values/constants.xml
index d26abae..06bdc50 100644
--- a/res/values/constants.xml
+++ b/res/values/constants.xml
@@ -60,4 +60,10 @@
 
     <!-- Whether to show conversation subject in conversation view -->
     <bool name="show_conversation_subject">true</bool>
+
+	<!-- Amount of memory in bytes allocated for image cache -->
+    <integer name="config_image_cache_max_bytes">1500000</integer>
+
+    <!-- Number of decoded contact photo bitmaps retained in an LRU cache -->
+    <integer name="config_image_cache_max_bitmaps">24</integer>
 </resources>
diff --git a/res/values/dimen.xml b/res/values/dimen.xml
index cf186e9..d39558b 100644
--- a/res/values/dimen.xml
+++ b/res/values/dimen.xml
@@ -88,4 +88,12 @@
     <dimen name="min_vert">10dip</dimen>
     <dimen name="min_lock">20dip</dimen>
     <dimen name="search_view_width">400dip</dimen>
+    <dimen name="titlebar_height">48dip</dimen>
+    <dimen name="titlebar_icon_size">40dip</dimen>
+    <dimen name="micro_kind_max_dimension">64dip</dimen>
+    <dimen name="mini_kind_max_dimension">340dip</dimen>
+    <dimen name="photo_crop_width">280dp</dimen>
+    <dimen name="photo_crop_stroke_width">1dp</dimen>
+    <dimen name="photo_overlay_right_padding">4dp</dimen>
+    <dimen name="photo_overlay_bottom_padding">6dp</dimen>
 </resources>
diff --git a/res/values/ids.xml b/res/values/ids.xml
index 1443e92..ff10d92 100644
--- a/res/values/ids.xml
+++ b/res/values/ids.xml
@@ -20,4 +20,18 @@
     <item type="id" name="personal_level"/>
     <item type="id" name="reply_state" />
     <item type="id" name="manage_folders_item"/>
+
+    <!-- Loaders for PhotoViewActivity -->
+    <item type="id" name="photo_view_photo_list_loader_id"/>
+
+    <!-- Loaders for PhotoViewFragment -->
+    <item type="id" name="photo_view_photo_loader_id"/>
+    
+    <!--  Dialogs for PhotoViewActivity -->
+    <item type="id" name="photo_view_pending_dialog"/>
+    <item type="id" name="photo_view_download_nonfull_failed_dialog"/>
+    <item type="id" name="photo_view_download_full_failed_dialog"/>
+
+    <!-- Dialogs for Photo View -->
+    <item type="id" name="dialog_insert_photo"/>
 </resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 1671718..00223b5 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -622,4 +622,75 @@
     <string name="change_sync_settings">Change sync settings</string>
 
 
+    <!-- Photo View strings -->
+    <!-- Default title for photo view  [CHAR LIMIT=40] -->
+    <string name="photo_view_default_title">Photos from message</string>
+
+    <!-- Toast message if there was a problem loading the photo view.  [CHAR LIMIT=80] -->
+    <string name="photo_view_load_error">Photo couldn\'t be loaded.</string>
+
+    <!-- Message displayed when trying to play a video that isn't ready [CHAR LIMIT=100] -->
+    <string name="photo_view_video_not_ready">Video not available at this time. Please refresh.</string>
+
+        <!-- Message displayed when displaying a place holder image (for photos & videos) [CHAR LIMIT=50] -->
+    <string name="photo_view_placeholder_image">Item not available at this time. Please refresh.</string>
+
+    <!-- Text shown when a photo fails to download. -->
+    <string name="photo_network_error">Photo isn\'t available right now.</string>
+
+    <!-- Status message displayed while a list's content is loading -->
+    <string name="loading_photo">Loading&#8230;</string>
+
+    <!-- Displayed in a progress dialog while a network operation (create post, delete post, ...) is pending -->
+    <string name="post_operation_pending">Sending&#8230;</string>
+
+    <!-- Displayed in a dialog when prompting the user to retry a failed photo downloaded. -->
+    <string name="download_photo_retry">This image is too large to download, would you like
+        to retry at smaller resolution?</string>
+
+    <!-- Displayed in a dialog when a photo can't be downloaded. -->
+    <string name="download_photo_error">The photo couldn\'t be saved to the device.</string>
+
+    <!-- Positive button text -->
+    <string name="yes">Yes</string>
+
+    <!-- Negative button text -->
+    <string name="no">No</string>
+
+    <!-- Displayed in a toast if the photo taken from the camera was not found. -->
+    <string name="camera_photo_error">Can\'t find photo.</string>
+
+    <!-- Photo view sub-title for current photo position [CHAR LIMIT=10] -->
+    <string name="photo_view_count"><xliff:g id="current_pos">%d</xliff:g> of <xliff:g id="count">%d</xliff:g></string>
+
+    <!-- [EmSea] Indication that comment or post has been truncated (Unicode ellipses may not work here.) -->
+    <string name="truncated_info">... </string>
+    <string name="truncated_info_see_more">&#xa0;See more &#187;</string>
+
+    <!-- A post or comment was posted very recently -->
+    <string name="posted_just_now">Just now</string>
+
+    <!-- Abbreviated message to express that something occurred some number of minutes in the past (e.g., 5 minutes ago). -->
+    <plurals name="num_minutes_ago">
+        <item quantity="one"><xliff:g id="count">%d</xliff:g> min</item>
+        <item quantity="other"><xliff:g id="count">%d</xliff:g> mins</item>
+    </plurals>
+
+    <!-- Abbreviated message to express that something occurred some number of hours in the past (e.g., 5 hours ago). -->
+    <plurals name="num_hours_ago">
+        <item quantity="one"><xliff:g id="count">%d</xliff:g> hour</item>
+        <item quantity="other"><xliff:g id="count">%d</xliff:g> hours</item>
+    </plurals>
+
+    <!-- Abbreviated message to express that something occurred some number of days in the past (e.g., 5 days ago). -->
+    <plurals name="num_days_ago">
+        <item quantity="one"><xliff:g id="count">%d</xliff:g> day</item>
+        <item quantity="other"><xliff:g id="count">%d</xliff:g> days</item>
+    </plurals>
+
+    <!-- Dialog message when inserting a camera photo into the database. -->
+    <string name="dialog_inserting_camera_photo">Inserting photo&#8230;</string>
+
+    <!-- Camera format string for new image files. Passed to java.text.SimpleDateFormat. -->
+    <string name="image_file_name_format" translatable="false">"'IMG'_yyyyMMdd_HHmmss"</string>
 </resources>
diff --git a/res/values/themes.xml b/res/values/themes.xml
new file mode 100644
index 0000000..b62c714
--- /dev/null
+++ b/res/values/themes.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2011 Google Inc.
+     Licensed to 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.
+-->
+
+<resources>
+    <style name="PhotoViewTheme" parent="android:Theme.Holo">
+        <item name="android:windowNoTitle">false</item>
+        <item name="android:windowContentOverlay">@null</item>
+        <item name="android:windowBackground">@drawable/photo_view_background</item>
+    </style>
+</resources>
diff --git a/src/com/android/mail/photo/BaseFragmentActivity.java b/src/com/android/mail/photo/BaseFragmentActivity.java
new file mode 100644
index 0000000..adfd663
--- /dev/null
+++ b/src/com/android/mail/photo/BaseFragmentActivity.java
@@ -0,0 +1,773 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.support.v4.app.FragmentActivity;
+import android.util.Log;
+import android.view.ActionProvider;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.SubMenu;
+import android.view.View;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.mail.R;
+import com.android.mail.photo.util.ImageCache;
+
+import java.util.ArrayList;
+
+/**
+ * The base fragment activity
+ */
+public abstract class BaseFragmentActivity extends FragmentActivity {
+
+    // Logging
+    private static final String TAG = "BaseFragmentActivity";
+
+    // Instance variables
+    private final MenuItem[] mMenuItems = new MenuItem[3];
+    /** Whether or not to hide the title bar */
+    private boolean mHideTitleBar;
+
+    /**
+     * A simple implementation of the Menu interface
+     */
+    private static class TitleMenu implements Menu {
+        private final ArrayList<TitleMenuItem> mItems = new ArrayList<TitleMenuItem>();
+        private final Context mContext;
+
+        /**
+         * Constructor
+         *
+         * @param context The context
+         */
+        public TitleMenu(Context context) {
+            mContext = context;
+        }
+
+        @Override
+        public int size() {
+            return mItems.size();
+        }
+
+        @Override
+        public void setQwertyMode(boolean isQwerty) {
+        }
+
+        @Override
+        public void setGroupVisible(int group, boolean visible) {
+        }
+
+        @Override
+        public void setGroupEnabled(int group, boolean enabled) {
+        }
+
+        @Override
+        public void setGroupCheckable(int group, boolean checkable, boolean exclusive) {
+        }
+
+        @Override
+        public void removeItem(int id) {
+        }
+
+        @Override
+        public void removeGroup(int groupId) {
+        }
+
+        @Override
+        public boolean performShortcut(int keyCode, KeyEvent event, int flags) {
+            return false;
+        }
+
+        @Override
+        public boolean performIdentifierAction(int id, int flags) {
+            return false;
+        }
+
+        @Override
+        public boolean isShortcutKey(int keyCode, KeyEvent event) {
+            return false;
+        }
+
+        @Override
+        public boolean hasVisibleItems() {
+            return false;
+        }
+
+        @Override
+        public MenuItem getItem(int index) {
+            return mItems.get(index);
+        }
+
+        @Override
+        public MenuItem findItem(int id) {
+            for (MenuItem item : mItems) {
+                if (item.getItemId() == id) {
+                    return item;
+                }
+            }
+            return null;
+        }
+
+        @Override
+        public void close() {
+        }
+
+        @Override
+        public void clear() {
+            mItems.clear();
+        }
+
+        @Override
+        public SubMenu addSubMenu(int groupId, int itemId, int order, int titleRes) {
+            return null;
+        }
+
+        @Override
+        public SubMenu addSubMenu(int groupId, int itemId, int order, CharSequence title) {
+            return null;
+        }
+
+        @Override
+        public SubMenu addSubMenu(int titleRes) {
+            return null;
+        }
+
+        @Override
+        public SubMenu addSubMenu(CharSequence title) {
+            return null;
+        }
+
+        @Override
+        public int addIntentOptions(int groupId, int itemId, int order, ComponentName caller,
+                Intent[] specifics, Intent intent, int flags, MenuItem[] outSpecificItems) {
+            return 0;
+        }
+
+        @Override
+        public MenuItem add(int groupId, int itemId, int order, int titleRes) {
+            TitleMenuItem item = new TitleMenuItem(mContext, itemId, titleRes);
+            mItems.add(item);
+            return item;
+        }
+
+        @Override
+        public MenuItem add(int groupId, int itemId, int order, CharSequence title) {
+            TitleMenuItem item = new TitleMenuItem(mContext, itemId, title);
+            mItems.add(item);
+            return item;
+        }
+
+        @Override
+        public MenuItem add(int titleRes) {
+            TitleMenuItem item = new TitleMenuItem(mContext, 0, titleRes);
+            mItems.add(item);
+            return item;
+        }
+
+        @Override
+        public MenuItem add(CharSequence title) {
+            TitleMenuItem item = new TitleMenuItem(mContext, 0, title);
+            mItems.add(item);
+            return item;
+        }
+    }
+
+    /**
+     * A simple MenuItem implementation
+     */
+    private static class TitleMenuItem implements MenuItem {
+        private final Resources mResources;
+        private CharSequence mTitle;
+        private final int mItemId;
+        private Drawable mIcon;
+        private boolean mVisible;
+        private boolean mEnabled;
+        @SuppressWarnings("unused")
+        private int mActionEnum;
+
+        /**
+         * Constructor
+         *
+         * @param context The context
+         * @param itemId The item id
+         * @param titleRes The title resource
+         */
+        public TitleMenuItem(Context context, int itemId, int titleRes) {
+            mResources = context.getResources();
+            mTitle = mResources.getString(titleRes);
+            mItemId = itemId;
+        }
+
+        /**
+         * Constructor
+         *
+         * @param context The context
+         * @param itemId The item id
+         * @param title The title
+         */
+        public TitleMenuItem(Context context, int itemId, CharSequence title) {
+            mResources = context.getResources();
+            mTitle = title;
+            mItemId = itemId;
+        }
+
+        @Override
+        public View getActionView() {
+            return null;
+        }
+
+        @Override
+        public char getAlphabeticShortcut() {
+            return 0;
+        }
+
+        @Override
+        public int getGroupId() {
+            return 0;
+        }
+
+        @Override
+        public Drawable getIcon() {
+            return mIcon;
+        }
+
+        @Override
+        public Intent getIntent() {
+            return null;
+        }
+
+        @Override
+        public int getItemId() {
+            return mItemId;
+        }
+
+        @Override
+        public ContextMenuInfo getMenuInfo() {
+            return null;
+        }
+
+        @Override
+        public char getNumericShortcut() {
+            return 0;
+        }
+
+        @Override
+        public int getOrder() {
+            return 0;
+        }
+
+        @Override
+        public SubMenu getSubMenu() {
+            return null;
+        }
+
+        @Override
+        public CharSequence getTitle() {
+            return mTitle;
+        }
+
+        @Override
+        public CharSequence getTitleCondensed() {
+            return null;
+        }
+
+        @Override
+        public boolean hasSubMenu() {
+            return false;
+        }
+
+        @Override
+        public boolean isCheckable() {
+            return false;
+        }
+
+        @Override
+        public boolean isChecked() {
+            return false;
+        }
+
+        @Override
+        public boolean isEnabled() {
+            return mEnabled;
+        }
+
+        @Override
+        public boolean isVisible() {
+            return mVisible;
+        }
+
+        @Override
+        public MenuItem setActionView(View view) {
+            return this;
+        }
+
+        @Override
+        public MenuItem setActionView(int resId) {
+            return this;
+        }
+
+        @Override
+        public MenuItem setAlphabeticShortcut(char alphaChar) {
+            return this;
+        }
+
+        @Override
+        public MenuItem setCheckable(boolean checkable) {
+            return this;
+        }
+
+        @Override
+        public MenuItem setChecked(boolean checked) {
+            return this;
+        }
+
+        @Override
+        public MenuItem setEnabled(boolean enabled) {
+            mEnabled = enabled;
+            return this;
+        }
+
+        @Override
+        public MenuItem setIcon(Drawable icon) {
+            mIcon = icon;
+            return this;
+        }
+
+        @Override
+        public MenuItem setIcon(int iconRes) {
+            if (iconRes != 0) {
+                mIcon = mResources.getDrawable(iconRes);
+            }
+            return this;
+        }
+
+        @Override
+        public MenuItem setIntent(Intent intent) {
+            return this;
+        }
+
+        @Override
+        public MenuItem setNumericShortcut(char numericChar) {
+            return this;
+        }
+
+        @Override
+        public MenuItem setOnMenuItemClickListener(OnMenuItemClickListener menuItemClickListener) {
+            return this;
+        }
+
+        @Override
+        public MenuItem setShortcut(char numericChar, char alphaChar) {
+            return this;
+        }
+
+        @Override
+        public void setShowAsAction(int actionEnum) {
+            mActionEnum = actionEnum;
+        }
+
+        @Override
+        public MenuItem setTitle(CharSequence title) {
+            mTitle = title;
+            return this;
+        }
+
+        @Override
+        public MenuItem setTitle(int title) {
+            mTitle = mResources.getString(title);
+            return this;
+        }
+
+        @Override
+        public MenuItem setTitleCondensed(CharSequence title) {
+            return this;
+        }
+
+        @Override
+        public MenuItem setVisible(boolean visible) {
+            mVisible = visible;
+            return this;
+        }
+
+        @Override
+        public MenuItem setShowAsActionFlags(int actionEnum) {
+            return null;
+        }
+
+       @Override
+        public MenuItem setActionProvider(ActionProvider actionProvider) {
+            return null;
+        }
+
+        @Override
+        public ActionProvider getActionProvider() {
+            return null;
+        }
+
+        @Override
+        public boolean expandActionView() {
+            return false;
+        }
+
+        @Override
+        public boolean collapseActionView() {
+            return false;
+        }
+
+        @Override
+        public boolean isActionViewExpanded() {
+            return false;
+        }
+
+        @Override
+        public MenuItem setOnActionExpandListener(OnActionExpandListener listener) {
+            return null;
+        }
+    }
+
+    // The title bar click listener
+    private final View.OnClickListener mTitleClickListener = new TitleClickListener();
+    private class TitleClickListener implements View.OnClickListener {
+        @Override
+        public void onClick(View v) {
+            final int id = v.getId();
+            if (id == R.id.titlebar_icon_layout) {
+                onTitlebarLabelClick();
+            } else if (id == R.id.title_button_1) {
+                if (mMenuItems[0] != null) {
+                    onOptionsItemSelected(mMenuItems[0]);
+                }
+            } else if (id == R.id.title_button_2) {
+                if (mMenuItems[1] != null) {
+                    onOptionsItemSelected(mMenuItems[1]);
+                }
+            } else if (id == R.id.title_button_3) {
+                if (mMenuItems[2] != null) {
+                    onOptionsItemSelected(mMenuItems[2]);
+                }
+            } else {
+            }
+        }
+    }
+
+    /**
+     * Constructor
+     */
+    public BaseFragmentActivity() {
+    }
+
+    /**
+     * Show the title bar without animation
+     *
+     * @param enableUp true to enable up action
+     */
+    protected void showTitlebar(boolean enableUp) {
+        showTitlebar(false, enableUp);
+    }
+
+    /**
+     * Shows the title bar with optional animation.
+     *
+     * @param showAnimation If {@code true}, animate the title bar show.
+     * @param enableUp true to enable up action
+     */
+    protected void showTitlebar(boolean showAnimation, boolean enableUp) {
+        final View titleLayout = findViewById(R.id.title_layout);
+
+        if (mHideTitleBar == false && titleLayout.getVisibility() == View.VISIBLE) {
+            return;
+        }
+        mHideTitleBar = false;
+
+        final Animation currentAnimation = titleLayout.getAnimation();
+        if (currentAnimation != null) {
+            currentAnimation.cancel();
+        }
+
+        if (showAnimation) {
+            final Animation titleAnimation = AnimationUtils.loadAnimation(this,
+                    R.anim.fade_in);
+            titleLayout.startAnimation(titleAnimation);
+        }
+
+        titleLayout.findViewById(R.id.titlebar_up).setVisibility(
+                enableUp ? View.VISIBLE : View.GONE);
+
+        final View touchView = titleLayout.findViewById(R.id.titlebar_icon_layout);
+        if (enableUp) {
+            touchView.setOnClickListener(mTitleClickListener);
+        } else {
+            // If the title is not clickable, we want to make sure the
+            // background doesn't change in response to touch events (this will
+            // happen if something containing the title is clickable). To
+            // accomplish this, we replace the selectable drawable with the
+            // color transparent.
+            touchView.setBackgroundColor(Color.TRANSPARENT);
+        }
+
+        titleLayout.setVisibility(View.VISIBLE);
+    }
+
+    /**
+     * Hide the title bar without animation
+     */
+    protected void hideTitlebar() {
+        hideTitlebar(false);
+    }
+
+    /**
+     * Hides the title bar with optional animation.
+     *
+     * @param showAnimation If {@code true}, animate the title bar hide.
+     */
+    protected void hideTitlebar(boolean showAnimation) {
+        final View titleLayout = findViewById(R.id.title_layout);
+
+        if (mHideTitleBar == true) {
+            return;
+        }
+        mHideTitleBar = true;
+
+        final Animation currentAnimation = titleLayout.getAnimation();
+        if (currentAnimation != null) {
+            currentAnimation.cancel();
+        }
+
+        if (showAnimation) {
+            final Animation titleAnimation = AnimationUtils.loadAnimation(this,
+                    R.anim.fade_out);
+            titleAnimation.setAnimationListener(new Animation.AnimationListener() {
+                @Override
+                public void onAnimationStart(Animation animation) {
+                }
+
+                @Override
+                public void onAnimationRepeat(Animation animation) {
+                }
+
+                @Override
+                public void onAnimationEnd(Animation animation) {
+                    if (mHideTitleBar) {
+                        titleLayout.setVisibility(View.GONE);
+                    }
+                }
+            });
+            titleLayout.startAnimation(titleAnimation);
+        } else {
+            titleLayout.setVisibility(View.GONE);
+        }
+    }
+
+    /**
+     * Set the sub-title text in the title bar
+     *
+     * @param subtitle The text to display in the title
+     */
+    protected void setTitlebarSubtitle(String subtitle) {
+        final TextView textView = (TextView)findViewById(R.id.titlebar_label_2);
+
+        if (subtitle == null) {
+            textView.setVisibility(View.GONE);
+        } else {
+            textView.setVisibility(View.VISIBLE);
+            textView.setText(subtitle);
+        }
+    }
+
+    /**
+     * Create the title menu buttons
+     *
+     * @param menuResId The menu id
+     */
+    public void createTitlebarButtons(int menuResId) {
+        clearTitleButtons();
+
+        final Menu menu = new TitleMenu(this);
+        getMenuInflater().inflate(menuResId, menu);
+
+        // Allow the activity to specify which menu items shall be displayed
+        // in the titlebar
+        onPrepareTitlebarButtons(menu);
+
+        int visibleMenuCount = 0;
+        for (int i = 0; i < menu.size(); i++) {
+            if (menu.getItem(i).isVisible()) {
+                visibleMenuCount++;
+            }
+        }
+
+        switch (visibleMenuCount) {
+            case 0: {
+                break;
+            }
+
+            case 1: {
+                setupTitleButton3(getVisibleItem(menu, 0));
+                break;
+            }
+
+            case 2: {
+                setupTitleButton2(getVisibleItem(menu, 0));
+                setupTitleButton3(getVisibleItem(menu, 1));
+                break;
+            }
+
+            case 3: {
+                setupTitleButton1(getVisibleItem(menu, 0));
+                setupTitleButton2(getVisibleItem(menu, 1));
+                setupTitleButton3(getVisibleItem(menu, 2));
+                break;
+            }
+
+            default: {
+                Log.e("EsFragmentActivity", "Maximum title buttons is 3. You have "
+                        + visibleMenuCount + " visible menu items");
+                break;
+            }
+        }
+    }
+
+    /**
+     * Override this method and set to visible the items you want to
+     * show in the titlebar.
+     *
+     * @param menu The menu item
+     */
+    protected void onPrepareTitlebarButtons(Menu menu) {
+    }
+
+    /**
+     * The title bar label was clicked
+     */
+    public void onTitlebarLabelClick() {
+    }
+
+    /**
+     * Setup button 1
+     *
+     * @param menuItem The menu item
+     */
+    private void setupTitleButton1(MenuItem menuItem) {
+        final ImageButton button = (ImageButton)findViewById(R.id.title_button_1);
+
+        if (menuItem != null) {
+            button.setImageDrawable(menuItem.getIcon());
+            button.setVisibility(View.VISIBLE);
+            button.setEnabled(menuItem.isEnabled());
+            button.setOnClickListener(mTitleClickListener);
+        } else {
+            button.setVisibility(View.GONE);
+        }
+
+        mMenuItems[0] = menuItem;
+
+    }
+
+    /**
+     * Setup button 2
+     *
+     * @param menuItem The menu item
+     */
+    private void setupTitleButton2(MenuItem menuItem) {
+        final ImageButton button = (ImageButton)findViewById(R.id.title_button_2);
+
+        if (menuItem != null) {
+            button.setImageDrawable(menuItem.getIcon());
+            button.setVisibility(View.VISIBLE);
+            button.setEnabled(menuItem.isEnabled());
+            button.setOnClickListener(mTitleClickListener);
+        } else {
+            button.setVisibility(View.GONE);
+        }
+
+        mMenuItems[1] = menuItem;
+    }
+
+    /**
+     * Setup button 3
+     *
+     * @param menuItem The menu item
+     */
+    private void setupTitleButton3(MenuItem menuItem) {
+        final ImageButton button = (ImageButton)findViewById(R.id.title_button_3);
+
+        if (menuItem != null) {
+            button.setImageDrawable(menuItem.getIcon());
+            button.setVisibility(View.VISIBLE);
+            button.setEnabled(menuItem.isEnabled());
+            button.setOnClickListener(mTitleClickListener);
+        } else {
+            button.setVisibility(View.GONE);
+        }
+
+        mMenuItems[2] = menuItem;
+    }
+
+    /**
+     * Clear the action buttons
+     */
+    private void clearTitleButtons() {
+        setupTitleButton1(null);
+        setupTitleButton2(null);
+        setupTitleButton3(null);
+    }
+
+    /**
+     * Get the visible item with the specified index
+     *
+     * @param menu The menu
+     * @param index The index
+     *
+     * @return The menu item
+     */
+    private MenuItem getVisibleItem(Menu menu, int index) {
+        int visibleItemIndex = 0;
+        for (int i = 0; i < menu.size(); i++) {
+            if (menu.getItem(i).isVisible()) {
+                if (visibleItemIndex == index) {
+                    return menu.getItem(i);
+                }
+
+                visibleItemIndex++;
+            }
+        }
+
+        return null;
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+
+        ImageCache.getInstance(this).refresh();
+    }
+}
diff --git a/src/com/android/mail/photo/Intents.java b/src/com/android/mail/photo/Intents.java
new file mode 100644
index 0000000..e5a956f
--- /dev/null
+++ b/src/com/android/mail/photo/Intents.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo;
+
+import android.content.Context;
+import android.content.Intent;
+
+import com.android.mail.photo.fragments.PhotoViewFragment;
+import com.android.mail.photo.loaders.PhotoCursorLoader;
+
+/**
+ * Build intents to start app activities
+ */
+public class Intents {
+    // Logging
+    private static final String TAG = "Intents";
+
+    // Intent extras
+    public static final String EXTRA_PHOTO_INDEX = "photo_index";
+    public static final String EXTRA_PHOTO_ID = "photo_id";
+    public static final String EXTRA_PHOTOS_URI = "photos_uri";
+    public static final String EXTRA_PHOTO_URL = "photo_url";
+    public static final String EXTRA_ALBUM_NAME = "album_name";
+    public static final String EXTRA_OWNER_ID = "owner_id";
+    public static final String EXTRA_TAG = "tag";
+    public static final String EXTRA_SHOW_PHOTO_ONLY = "show_photo_only";
+    public static final String EXTRA_NOTIFICATION_ID = "notif_id";
+    public static final String EXTRA_REFRESH = "refresh";
+    public static final String EXTRA_PAGE_HINT = "page_hint";
+
+    /**
+     * Gets a photo view intent builder to display the photos from phone activity.
+     *
+     * @param context The context
+     * @return The intent builder
+     */
+    public static PhotoViewIntentBuilder newPhotoViewActivityIntentBuilder(Context context) {
+        return new PhotoViewIntentBuilder(context, PhotoViewActivity.class);
+    }
+
+    /**
+     * Gets a photo view intent builder to display the photo view fragment
+     *
+     * @param context The context
+     * @return The intent builder
+     */
+    public static PhotoViewIntentBuilder newPhotoViewFragmentIntentBuilder(Context context) {
+        return new PhotoViewIntentBuilder(context, PhotoViewFragment.class);
+    }
+
+    /** Gets a new photo view intent builder */
+    public static PhotoViewIntentBuilder newPhotoViewIntentBuilder(Context context, Class<?> cls) {
+        return new PhotoViewIntentBuilder(context, cls);
+    }
+
+    /** Builder to create a photo view intent */
+    public static class PhotoViewIntentBuilder {
+        private final Intent mIntent;
+
+        /** The id of the photo being displayed */
+        private long mPhotoId;
+        /** The name of the album being displayed */
+        private String mAlbumName;
+        /** The ID of the photo to force load */
+        private Long mForceLoadId;
+        /** The ID of the notification */
+        private String mNotificationId;
+        /** A hint for the number of pages to initially load */
+        private Integer mPageHint;
+        /** The index of the photo to show */
+        private Integer mPhotoIndex;
+        /** Whether or not to show the photo only [eg don't show comments, etc...] */
+        private Boolean mPhotoOnly;
+        /** The URI of the group of photos to display */
+        private String mPhotosUri;
+        /** The URL of the photo to display */
+        private String mPhotoUrl;
+
+        private PhotoViewIntentBuilder(Context context, Class<?> cls) {
+            mIntent = new Intent(context, cls);
+        }
+
+        public PhotoViewIntentBuilder setPhotoId(long photoId) {
+            mPhotoId = photoId;
+            return this;
+        }
+
+        /** Sets the album name */
+        public PhotoViewIntentBuilder setAlbumName(String albumName) {
+            mAlbumName = albumName;
+            return this;
+        }
+
+        /** Sets the photo ID to force load */
+        public PhotoViewIntentBuilder setForceLoadId(Long forceLoadId) {
+            mForceLoadId = forceLoadId;
+            return this;
+        }
+
+        /** Sets the notification ID */
+        public PhotoViewIntentBuilder setNotificationId(String notificationId) {
+            mNotificationId = notificationId;
+            return this;
+        }
+
+        /** Sets the page hint */
+        public PhotoViewIntentBuilder setPageHint(Integer pageHint) {
+            mPageHint = pageHint;
+            return this;
+        }
+
+        /** Sets the photo index */
+        public PhotoViewIntentBuilder setPhotoIndex(Integer photoIndex) {
+            mPhotoIndex = photoIndex;
+            return this;
+        }
+
+        /** Sets whether to show the photo only */
+        public PhotoViewIntentBuilder setPhotoOnly(Boolean photoOnly) {
+            mPhotoOnly = photoOnly;
+            return this;
+        }
+
+        /** Sets the photos URI */
+        public PhotoViewIntentBuilder setPhotosUri(String photosUri) {
+            mPhotosUri = photosUri;
+            return this;
+        }
+
+        /** Sets the photo URL */
+        public PhotoViewIntentBuilder setPhotoUrl(String photoUrl) {
+            mPhotoUrl = photoUrl;
+            return this;
+        }
+
+        /** Build the intent */
+        public Intent build() {
+            mIntent.setAction(Intent.ACTION_VIEW);
+
+            if (mAlbumName != null) {
+                mIntent.putExtra(EXTRA_ALBUM_NAME, mAlbumName);
+            }
+
+            if (mForceLoadId != null) {
+                mIntent.putExtra(EXTRA_REFRESH, (long) mForceLoadId);
+            }
+
+            if (mNotificationId != null) {
+                mIntent.putExtra(EXTRA_NOTIFICATION_ID, mNotificationId);
+            }
+
+            if (mPageHint != null) {
+                mIntent.putExtra(EXTRA_PAGE_HINT, (int) mPageHint);
+            } else {
+                mIntent.putExtra(EXTRA_PAGE_HINT, PhotoCursorLoader.LOAD_LIMIT_UNLIMITED);
+            }
+
+            if (mPhotoIndex != null) {
+                mIntent.putExtra(EXTRA_PHOTO_INDEX, (int) mPhotoIndex);
+            }
+
+            if ((mPhotoOnly != null && mPhotoOnly)) {
+                mIntent.putExtra(EXTRA_SHOW_PHOTO_ONLY, true);
+            }
+
+            if (mPhotosUri != null) {
+                mIntent.putExtra(EXTRA_PHOTOS_URI, mPhotosUri);
+            }
+
+            if (mPhotoUrl != null) {
+                mIntent.putExtra(EXTRA_PHOTO_URL, mPhotoUrl);
+            }
+
+            return mIntent;
+        }
+    }
+}
diff --git a/src/com/android/mail/photo/MultiChoiceActionModeStub.java b/src/com/android/mail/photo/MultiChoiceActionModeStub.java
new file mode 100644
index 0000000..5662d18
--- /dev/null
+++ b/src/com/android/mail/photo/MultiChoiceActionModeStub.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo;
+
+import android.graphics.Color;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.style.ForegroundColorSpan;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.ListView;
+
+/**
+ * A wrapper for the Honeycomb/ICS ActionMode class
+ */
+public class MultiChoiceActionModeStub {
+    // Instance variables
+    private final MultiChoiceCallbackStub mCallbackStub;
+    private final ListView.MultiChoiceModeListener mActionCallback;
+    private ActionMode mActionMode;
+
+    /**
+     * The multi choice callback
+     */
+    private class MultiChoiceCallback implements ListView.MultiChoiceModeListener {
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
+            mActionMode = actionMode;
+            return mCallbackStub.onCreateActionMode(MultiChoiceActionModeStub.this, menu);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
+            return mCallbackStub.onPrepareActionMode(MultiChoiceActionModeStub.this, menu);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
+            return mCallbackStub.onActionItemClicked(MultiChoiceActionModeStub.this, menuItem);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void onDestroyActionMode(ActionMode actionMode) {
+            mCallbackStub.onDestroyActionMode(MultiChoiceActionModeStub.this);
+            mActionMode = null;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void onItemCheckedStateChanged(ActionMode mode, int position, long id,
+                boolean checked) {
+            mCallbackStub.onItemCheckedStateChanged(MultiChoiceActionModeStub.this, position, id,
+                    checked);
+        }
+    }
+
+    /**
+     * The action mode callback
+     */
+    public interface MultiChoiceCallbackStub {
+        /**
+         * The action mode is created
+         *
+         * @param actionModeStub The actionMode stub
+         * @param menu The menu
+         *
+         * @return true if the action mode should be created
+         */
+        public boolean onCreateActionMode(MultiChoiceActionModeStub actionModeStub, Menu menu);
+
+        /**
+         * The action mode is prepared
+         *
+         * @param actionModeStub The actionMode stub
+         * @param menu The menu
+         *
+         * @return true if the action mode has changed
+         */
+        public boolean onPrepareActionMode(MultiChoiceActionModeStub actionModeStub, Menu menu);
+
+        /**
+         * An action button is clicked
+         *
+         * @param actionModeStub The actionMode stub
+         * @param menuItem The menu item
+         *
+         * @return true if the action was handled
+         */
+        public boolean onActionItemClicked(MultiChoiceActionModeStub actionModeStub,
+                MenuItem menuItem);
+
+        /**
+         * The action mode is destroyed
+         *
+         * @param actionModeStub The actionMode stub
+         */
+        public void onDestroyActionMode(MultiChoiceActionModeStub actionModeStub);
+
+        /**
+         * Check item state
+         *
+         * @param mode The action mode stub
+         * @param position The item position
+         * @param id The item id
+         * @param checked The checked state
+         */
+        public void onItemCheckedStateChanged(MultiChoiceActionModeStub mode, int position,
+                long id, boolean checked);
+    }
+
+    /**
+     * Constructor
+     *
+     * @param callbackStub The callback stub
+     */
+    public MultiChoiceActionModeStub(MultiChoiceCallbackStub callbackStub) {
+        mCallbackStub = callbackStub;
+        mActionCallback = new MultiChoiceCallback();
+    }
+
+    /**
+     * @return The action mode callback
+     */
+    public ListView.MultiChoiceModeListener getCallback() {
+        return mActionCallback;
+    }
+
+    /**
+     * Set the title of the action bar
+     *
+     * @param title The title
+     */
+    public void setTitle(CharSequence title) {
+        if (mActionMode != null) {
+            if (title != null) {
+                // Forcing the title to be white in a spannable because there is a
+                // bug in Honeycomb code that doesn't expose actionModeStyle, so
+                // we can't style this text correctly for action modes with our
+                // overall light theme and inverted action bar.
+                SpannableString s = new SpannableString(title);
+                s.setSpan(new ForegroundColorSpan(Color.WHITE), 0, s.length(),
+                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+                mActionMode.setTitle(s);
+            } else {
+                mActionMode.setTitle(null);
+            }
+        }
+    }
+
+    /**
+     * Invalidate the action mode
+     */
+    public void invalidate() {
+        if (mActionMode != null) {
+            mActionMode.invalidate();
+        }
+    }
+
+    /**
+     * Finish the action mode
+     */
+    public void finish() {
+        if (mActionMode != null) {
+            mActionMode.finish();
+        }
+    }
+}
diff --git a/src/com/android/mail/photo/Pageable.java b/src/com/android/mail/photo/Pageable.java
new file mode 100644
index 0000000..8521ded
--- /dev/null
+++ b/src/com/android/mail/photo/Pageable.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo;
+
+/**
+ * Defines the interface to a pageable data source.
+ */
+public interface Pageable {
+    /** Number of cursor rows in a page */
+    static final int CURSOR_PAGE_SIZE = 16;
+
+    /**
+     * @return true if more data is left to be read.
+     */
+    boolean hasMore();
+
+    /**
+     * Loads the next page of data.
+     */
+    void loadMore();
+
+    /**
+     * @return the current page
+     */
+    int getCurrentPage();
+}
diff --git a/src/com/android/mail/photo/PhotoViewActivity.java b/src/com/android/mail/photo/PhotoViewActivity.java
new file mode 100644
index 0000000..fd09cd6
--- /dev/null
+++ b/src/com/android/mail/photo/PhotoViewActivity.java
@@ -0,0 +1,864 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.ActivityManager;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.ProgressDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.Loader;
+import android.support.v4.view.ViewPager;
+import android.support.v4.view.ViewPager.OnPageChangeListener;
+import android.text.TextUtils;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import com.android.mail.R;
+import com.android.mail.photo.PhotoViewPager.InterceptType;
+import com.android.mail.photo.PhotoViewPager.OnInterceptTouchListener;
+import com.android.mail.photo.adapters.PhotoPagerAdapter;
+import com.android.mail.photo.adapters.BaseFragmentPagerAdapter.OnFragmentPagerListener;
+import com.android.mail.photo.fragments.PhotoViewFragment;
+import com.android.mail.photo.fragments.PhotoViewFragment.PhotoViewCallbacks;
+import com.android.mail.photo.loaders.PhotoCursorLoader;
+import com.android.mail.photo.loaders.PhotoPagerLoader;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Activity to view the contents of an album.
+ */
+public class PhotoViewActivity extends BaseFragmentActivity implements PhotoViewCallbacks,
+        LoaderCallbacks<Cursor>, OnPageChangeListener, OnInterceptTouchListener,
+        OnFragmentPagerListener {
+
+    /**
+     * Listener to be invoked for screen events.
+     */
+    public static interface OnScreenListener {
+
+        /**
+         * The full screen state has changed.
+         */
+        public void onFullScreenChanged(boolean fullScreen, boolean animate);
+
+        /**
+         * A new view has been activated and the previous view de-activated.
+         */
+        public void onViewActivated();
+
+        /**
+         * Updates the view that can be used to show progress.
+         *
+         * @param progressView a View that can be used to show progress
+         */
+        public void onUpdateProgressView(ProgressBar progressView);
+
+        /**
+         * Called when a right-to-left touch move intercept is about to occur.
+         *
+         * @param origX the raw x coordinate of the initial touch
+         * @param origY the raw y coordinate of the initial touch
+         * @return {@code true} if the touch should be intercepted.
+         */
+        public boolean onInterceptMoveLeft(float origX, float origY);
+
+        /**
+         * Called when a left-to-right touch move intercept is about to occur.
+         *
+         * @param origX the raw x coordinate of the initial touch
+         * @param origY the raw y coordinate of the initial touch
+         * @return {@code true} if the touch should be intercepted.
+         */
+        public boolean onInterceptMoveRight(float origX, float origY);
+
+        /**
+         * Called when the action bar height is calculated.
+         *
+         * @param actionBarHeight The height of the action bar.
+         */
+        public void onActionBarHeightCalculated(int actionBarHeight);
+    }
+
+    /**
+     * Listener to be invoked for menu item events.
+     */
+    public static interface OnMenuItemListener {
+
+        /**
+         * Prepare the title bar buttons.
+         *
+         * @return {@code true} if the title bar buttons were processed. Otherwise, {@code false}.
+         */
+        public boolean onPrepareTitlebarButtons(Menu menu);
+
+        /**
+         * Signals an item in your options menu was selected.
+         *
+         * @return {@code true} if the item selection was consumed. Otherwise, {@code false}.
+         */
+        public boolean onOptionsItemSelected(MenuItem item);
+    }
+
+    private final static String STATE_ITEM_KEY =
+            "com.google.android.apps.plus.PhotoViewFragment.ITEM";
+    private final static String STATE_FULLSCREEN_KEY =
+            "com.google.android.apps.plus.PhotoViewFragment.FULLSCREEN";
+
+    private static final int LOADER_PHOTO_LIST = R.id.photo_view_photo_list_loader_id;
+
+    /** Count used when the real photo count is unknown [but, may be determined] */
+    public static final int ALBUM_COUNT_UNKNOWN = -1;
+    /** Count used when the real photo count can't be know [eg for a photo stream] */
+    public static final int ALBUM_COUNT_UNKNOWABLE = -2;
+
+    /** Argument key for the dialog message */
+    public static final String KEY_MESSAGE = "dialog_message";
+
+    public static int sMemoryClass;
+
+    // TODO(toddke) This will need to be replaced by an array of MediaRefs to support local photos
+    /** The URI of the photos we're viewing; may be {@code null} */
+    private String mPhotosUri;
+    /** The index of the currently viewed photo */
+    private int mPhotoIndex;
+    /** A hint for which cursor page the photo is located on */
+    private int mPageHint = PhotoCursorLoader.LOAD_LIMIT_UNLIMITED;
+    /** The name of the album */
+    private String mAlbumName;
+    /** The total number of photos; only valid if {@link #mIsEmpty} is {@code false}. */
+    private int mAlbumCount = ALBUM_COUNT_UNKNOWN;
+    /** {@code true} if the view is empty. Otherwise, {@code false}. */
+    private boolean mIsEmpty;
+    /** The root view of the activity */
+    private View mRootView;
+    /** The main pager; provides left/right swipe between photos */
+    private PhotoViewPager mViewPager;
+    /** Adapter to create pager views */
+    private PhotoPagerAdapter mAdapter;
+    /** Whether or not the view is currently scrolling between photos */
+    private boolean mViewScrolling;
+    /** Whether or not we're in "full screen" mode */
+    private boolean mFullScreen;
+    /** Whether or not we should only show the photo and no extra information */
+    private boolean mShowPhotoOnly;
+    /** The set of listeners wanting full screen state */
+    private Set<OnScreenListener> mScreenListeners = new HashSet<OnScreenListener>();
+    /** The set of listeners wanting title bar state */
+    private Set<OnMenuItemListener> mMenuItemListeners = new HashSet<OnMenuItemListener>();
+    /** When {@code true}, restart the loader when the activity becomes active */
+    private boolean mRestartLoader;
+    /** Whether or not this activity is paused */
+    private boolean mIsPaused = true;
+    /** The action bar height */
+    private int mActionBarHeight;
+    /** A layout listener to track when the action bar is laid out */
+    private ActionBarLayoutListener mActionBarLayoutListener;
+    // TODO(toddke) Find a better way to do this. We basically want the activity to display the
+    // "loading..." progress until the fragment takes over and shows it's own "loading..."
+    // progress [located in photo_header_view.xml]. We could potentially have all status displayed
+    // by the activity, but, that gets tricky when it comes to screen rotation. For now, we
+    // track the loading by this variable which is fragile and may cause phantom "loading..."
+    // text.
+    /** {@code true} if the fragment is loading. */
+    private boolean mFragmentIsLoading;
+
+    /** Listener to handle dialog button clicks for the failed dialog. */
+    private DialogInterface.OnClickListener mFailedListener =
+            new DialogInterface.OnClickListener() {
+
+        @Override
+        public void onClick(DialogInterface dialog, int which) {
+            dialog.dismiss();
+        }
+    };
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        final ActivityManager mgr = (ActivityManager) getApplicationContext().
+                getSystemService(Activity.ACTIVITY_SERVICE);
+        sMemoryClass = mgr.getMemoryClass();
+
+        Intent mIntent = getIntent();
+        mShowPhotoOnly = mIntent.getBooleanExtra(Intents.EXTRA_SHOW_PHOTO_ONLY, false);
+
+        int currentItem = -1;
+        if (savedInstanceState != null) {
+            currentItem = savedInstanceState.getInt(STATE_ITEM_KEY, -1);
+            mFullScreen = savedInstanceState.getBoolean(STATE_FULLSCREEN_KEY, false);
+        } else {
+            mFullScreen = mShowPhotoOnly;
+        }
+
+        // album name; if not set, use a default name
+        if (mIntent.hasExtra(Intents.EXTRA_ALBUM_NAME)) {
+            mAlbumName = mIntent.getStringExtra(Intents.EXTRA_ALBUM_NAME);
+        } else {
+            mAlbumName = getResources().getString(R.string.photo_view_default_title);
+        }
+
+        // id of the photos to view; optional
+        if (mIntent.hasExtra(Intents.EXTRA_PHOTOS_URI)) {
+            mPhotosUri = mIntent.getStringExtra(Intents.EXTRA_PHOTOS_URI);
+        }
+
+        // the loader page hint
+        if (mIntent.hasExtra(Intents.EXTRA_PAGE_HINT) && currentItem < 0) {
+            mPageHint = mIntent.getIntExtra(Intents.EXTRA_PAGE_HINT,
+                    PhotoCursorLoader.LOAD_LIMIT_UNLIMITED);
+        }
+        // Set the current item from the intent if wasn't in the saved instance
+        if (mIntent.hasExtra(Intents.EXTRA_PHOTO_INDEX) && currentItem < 0) {
+            currentItem = mIntent.getIntExtra(Intents.EXTRA_PHOTO_INDEX, -1);
+        }
+        mPhotoIndex = currentItem;
+
+        setContentView(R.layout.photo_activity_view);
+        mRootView = findViewById(R.id.photo_activity_root_view);
+        // Create the adapter and add the view pager
+        final Long forceLoadId = (mIntent.hasExtra(Intents.EXTRA_REFRESH))
+                ? mIntent.getLongExtra(Intents.EXTRA_REFRESH, 0L)
+                : null;
+
+        mAdapter = new PhotoPagerAdapter(this, getSupportFragmentManager(), null,
+                forceLoadId, mAlbumName);
+        mAdapter.setFragmentPagerListener(this);
+
+        mViewPager = (PhotoViewPager) findViewById(R.id.photo_view_pager);
+        mViewPager.setAdapter(mAdapter);
+        mViewPager.setOnPageChangeListener(this);
+        mViewPager.setOnInterceptTouchListener(this);
+
+        // Kick off the loaders
+        getSupportLoaderManager().initLoader(LOADER_PHOTO_LIST, null, this);
+
+        if (Build.VERSION.SDK_INT >= 11) {
+            final ActionBar actionBar = getActionBar();
+            actionBar.setDisplayHomeAsUpEnabled(true);
+        } else {
+            showTitlebar(false, true);
+            createTitlebarButtons(R.menu.photo_view_menu);
+        }
+
+        updateView(mRootView);
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+//        if (isIntentAccountActive()) {
+//            createTitlebarButtons(R.menu.photo_view_menu);
+            setFullScreen(mFullScreen, false);
+
+            mIsPaused = false;
+            if (mRestartLoader) {
+                mRestartLoader = false;
+                getSupportLoaderManager().restartLoader(LOADER_PHOTO_LIST, null, this);
+            }
+//        } else {
+//            finish();
+//        }
+    }
+
+    @Override
+    protected void onPause() {
+        mIsPaused = true;
+
+        if (mActionBarLayoutListener != null) {
+            clearListener();
+        }
+
+        super.onPause();
+    }
+
+    @Override
+    public void onBackPressed() {
+        // If in full screen mode, toggle mode & eat the 'back'
+        if (mFullScreen && !mShowPhotoOnly) {
+            toggleFullScreen();
+        } else {
+            super.onBackPressed();
+        }
+    }
+
+    @Override
+    public void onAttachFragment(Fragment fragment) {
+        super.onAttachFragment(fragment);
+        PhotoViewFragment photoFragment = null;
+        if (fragment instanceof PhotoViewFragment) {
+            photoFragment = (PhotoViewFragment) fragment;
+        }
+
+        // Set the progress view as new fragments are attached
+        final ProgressBar progressView;
+        if (Build.VERSION.SDK_INT < 11) {
+            progressView = (ProgressBar) findViewById(R.id.progress_spinner);
+        } else {
+            progressView = (ProgressBar) findViewById(R.id.action_bar_progress_spinner_view);
+        }
+
+        if (photoFragment != null && progressView != null) {
+            photoFragment.onUpdateProgressView(progressView);
+        }
+    }
+
+    @Override
+    public void onAttachedToWindow() {
+        super.onAttachedToWindow();
+
+        if (Build.VERSION.SDK_INT < 11) {
+            // On SDK >= 11, we cannot set the progress bar view here; the menu may not be
+            // inflated yet. We will set the progress view later, in #onCreateOptionsMenu().
+            final ProgressBar progressView =
+                    (ProgressBar) findViewById(R.id.progress_spinner);
+
+            if (progressView != null) {
+                for (OnScreenListener listener : mScreenListeners) {
+                    listener.onUpdateProgressView(progressView);
+                }
+            }
+        }
+    }
+
+    @Override
+    protected void onPrepareDialog(int id, Dialog dialog, Bundle args) {
+        super.onPrepareDialog(id, dialog, args);
+        if (id == R.id.photo_view_pending_dialog) {
+            // Update the message each time this dialog is shown in order
+            // to ensure it matches the current operation.
+            if (dialog instanceof ProgressDialog) {
+                // This should always be true
+                final ProgressDialog pd = (ProgressDialog) dialog;
+                pd.setMessage(args.getString(KEY_MESSAGE));
+            }
+        }
+    }
+
+    @Override
+    protected Dialog onCreateDialog(int id, Bundle args) {
+        String tag = args.getString(Intents.EXTRA_TAG);
+        if (id == R.id.photo_view_pending_dialog) {
+            final ProgressDialog progressDialog = new ProgressDialog(this);
+            progressDialog.setMessage(args.getString(KEY_MESSAGE));
+            progressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
+            progressDialog.setCancelable(false);
+            return progressDialog;
+        } else if (id == R.id.photo_view_download_full_failed_dialog) {
+            final RetryDialogListener retryListener = new RetryDialogListener(tag);
+            final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+            builder.setMessage(R.string.download_photo_retry)
+                    .setPositiveButton(R.string.yes, retryListener)
+                    .setNegativeButton(R.string.no, retryListener);
+            return builder.create();
+        } else if (id == R.id.photo_view_download_nonfull_failed_dialog) {
+            final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+            builder.setMessage(R.string.download_photo_error)
+                    .setNeutralButton(R.string.ok, mFailedListener);
+            return builder.create();
+        }
+
+        return null;
+    }
+
+    @Override
+    public void onSaveInstanceState(Bundle outState) {
+        super.onSaveInstanceState(outState);
+
+        outState.putInt(STATE_ITEM_KEY, mViewPager.getCurrentItem());
+        outState.putBoolean(STATE_FULLSCREEN_KEY, mFullScreen);
+    }
+
+    @Override
+    protected void onPrepareTitlebarButtons(Menu menu) {
+        // Clear the menu items
+        for (int i = 0; i < menu.size(); i++) {
+            menu.getItem(i).setVisible(false);
+        }
+
+        // Let the fragments add back the ones it wants
+        for (OnMenuItemListener listener : mMenuItemListeners) {
+            if (listener.onPrepareTitlebarButtons(menu)) {
+                // First listener to claim the title bar, gets it
+                break;
+            }
+        }
+    }
+
+    @Override
+    public void onTitlebarLabelClick() {
+        finish();
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        for (OnMenuItemListener listener : mMenuItemListeners) {
+            if (listener.onOptionsItemSelected(item)) {
+                // First listener to claim the item selection, gets it
+                return true;
+            }
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    @Override
+    public void addScreenListener(OnScreenListener listener) {
+        mScreenListeners.add(listener);
+    }
+
+    @Override
+    public void removeScreenListener(OnScreenListener listener) {
+        mScreenListeners.remove(listener);
+    }
+
+    @Override
+    public void addMenuItemListener(OnMenuItemListener listener) {
+        mMenuItemListeners.add(listener);
+    }
+
+    @Override
+    public void removeMenuItemListener(OnMenuItemListener listener) {
+        mMenuItemListeners.remove(listener);
+    }
+
+    @Override
+    public boolean isFragmentFullScreen(Fragment fragment) {
+        if (mViewPager == null || mAdapter == null || mAdapter.getCount() == 0) {
+            return mFullScreen;
+        }
+        return mFullScreen || (mViewPager.getCurrentItem() != mAdapter.getItemPosition(fragment));
+    }
+
+    @Override
+    public boolean isShowPhotoOnly() {
+        return mShowPhotoOnly;
+    }
+
+    @Override
+    public void toggleFullScreen() {
+        setFullScreen(!mFullScreen, true);
+    }
+
+    @Override
+    public void onPhotoRemoved(long photoId) {
+        final Cursor data = mAdapter.getCursor();
+        if (data == null) {
+            // Huh?! How would this happen?
+            return;
+        }
+
+        final int dataCount = data.getCount();
+        if (dataCount <= 1) {
+            // The last photo was removed ... finish the activity & go to photos-home
+//            final Intent intent = Intents.getPhotosHomeIntent(this, mAccount, mAccount.getGaiaId());
+//
+//            intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+//            startActivity(intent);
+            finish();
+            return;
+        }
+
+        getSupportLoaderManager().restartLoader(LOADER_PHOTO_LIST, null, this);
+    }
+
+    @Override
+    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+        if (id == LOADER_PHOTO_LIST) {
+            mFragmentIsLoading = true;
+            return new PhotoPagerLoader(this, Uri.parse(mPhotosUri), mPageHint);
+        }
+        return null;
+    }
+
+    @Override
+    public void onLoadFinished(final Loader<Cursor> loader, final Cursor data) {
+        final int id = loader.getId();
+        if (id == LOADER_PHOTO_LIST) {
+            if (data == null || data.getCount() == 0) {
+                mIsEmpty = true;
+                mFragmentIsLoading = false;
+                updateView(mRootView);
+            } else {
+                // Cannot do this directly; need to be out of the loader
+                new Handler().post(new Runnable() {
+                    @Override
+                    public void run() {
+                        // We're paused; don't do anything now, we'll get re-invoked
+                        // when the activity becomes active again
+                        if (mIsPaused) {
+                            mRestartLoader = true;
+                            return;
+                        }
+                        mIsEmpty = false;
+
+                        // set the selected photo; if the index is invalid, default to '0'
+                        int itemIndex = mPhotoIndex;
+//                            if (itemIndex < 0 && mPhotoRef != null) {
+//                                itemIndex = getCursorPosition(data, mPhotoRef);
+//                            }
+
+                        // Use an index of 0 if the index wasn't specified or couldn't be found
+                        if (itemIndex < 0) {
+                            itemIndex = 0;
+                        }
+
+                        mAdapter.setPageable((Pageable) loader);
+                        mAdapter.swapCursor(data);
+                        updateView(mRootView);
+                        mViewPager.setCurrentItem(itemIndex, false);
+                    }
+                });
+            }
+            /** Loads the album name, if necessary */
+            final boolean needName = TextUtils.isEmpty(mAlbumName);
+            if (!needName) {
+                // At least show the album name if we have it
+                updateTitleAndSubtitle();
+            }
+        }
+    }
+
+    @Override
+    public void onLoaderReset(Loader<Cursor> loader) {
+    }
+
+    @Override
+    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+    }
+
+    @Override
+    public void onPageSelected(int position) {
+        setFullScreen(mFullScreen || mViewScrolling, true);
+        setViewActivated();
+        updateTitleAndSubtitle();
+        mPhotoIndex = position;
+    }
+
+    @Override
+    public void onPageScrollStateChanged(int state) {
+        mViewScrolling = (state != ViewPager.SCROLL_STATE_IDLE);
+    }
+
+    @Override
+    public void onPageActivated(Fragment fragment) {
+        setViewActivated();
+    }
+
+    @Override
+    public boolean isFragmentActive(Fragment fragment) {
+        if (mViewPager == null || mAdapter == null) {
+            return false;
+        }
+        return mViewPager.getCurrentItem() == mAdapter.getItemPosition(fragment);
+    }
+
+    @Override
+    public void onFragmentVisible(Fragment fragment) {
+        if (mViewPager == null || mAdapter == null) {
+            return;
+        }
+        if (mViewPager.getCurrentItem() == mAdapter.getItemPosition(fragment)) {
+            mFragmentIsLoading = false;
+        }
+        updateView(mRootView);
+    }
+
+    @Override
+    public void updateMenuItems() {
+        if (Build.VERSION.SDK_INT >= 11) {
+            // Invalidate the options menu
+            invalidateOptionsMenu();
+        } else {
+            // Set the title bar buttons
+            createTitlebarButtons(R.menu.photo_view_menu);
+        }
+    }
+
+    @Override
+    public InterceptType onTouchIntercept(float origX, float origY) {
+        boolean interceptLeft = false;
+        boolean interceptRight = false;
+
+        for (OnScreenListener listener : mScreenListeners) {
+            if (!interceptLeft) {
+                interceptLeft = listener.onInterceptMoveLeft(origX, origY);
+            }
+            if (!interceptRight) {
+                interceptRight = listener.onInterceptMoveRight(origX, origY);
+            }
+            listener.onViewActivated();
+        }
+
+        if (interceptLeft) {
+            if (interceptRight) {
+                return InterceptType.BOTH;
+            }
+            return InterceptType.LEFT;
+        } else if (interceptRight) {
+            return InterceptType.RIGHT;
+        }
+        return InterceptType.NONE;
+    }
+
+    /**
+     * Updates the title bar according to the value of {@link #mFullScreen}.
+     */
+    private void setFullScreen(boolean fullScreen, boolean animate) {
+        final boolean fullScreenChanged = (fullScreen != mFullScreen);
+        mFullScreen = fullScreen;
+
+        if (Build.VERSION.SDK_INT < 11) {
+            if (mFullScreen) {
+                hideTitlebar(animate);
+            } else {
+                showTitlebar(animate, true);
+            }
+        } else {
+            ActionBar actionBar = getActionBar();
+            if (mFullScreen) {
+                actionBar.hide();
+            } else {
+                // Workaround alert!
+                // Set a callback to listen for when the action bar is set, so
+                // that we can get its height and pass it along to all the
+                // adapters.
+                if (Build.VERSION.SDK_INT >= 11 && mActionBarHeight == 0) {
+                    final ViewTreeObserver observer = mRootView.getViewTreeObserver();
+                    mActionBarLayoutListener = new ActionBarLayoutListener();
+                    observer.addOnGlobalLayoutListener(mActionBarLayoutListener);
+                }
+                // Workaround alert!
+
+                actionBar.show();
+            }
+        }
+
+        if (fullScreenChanged) {
+            for (OnScreenListener listener : mScreenListeners) {
+                listener.onFullScreenChanged(mFullScreen, animate);
+            }
+        }
+    }
+
+    /**
+     * Updates the title bar according to the value of {@link #mFullScreen}.
+     */
+    private void setViewActivated() {
+        for (OnScreenListener listener : mScreenListeners) {
+            listener.onViewActivated();
+        }
+    }
+
+    /**
+     * Updates the view to show the correct content. If album data is available, show the album
+     * list. Otherwise, show either progress or no album view.
+     */
+    private void updateView(View view) {
+        if (view == null) {
+            return;
+        }
+
+        if (mFragmentIsLoading || (mAdapter.getCursor() == null && !mIsEmpty)) {
+            showEmptyViewProgress(view);
+        } else {
+            if (!mIsEmpty) {
+                showContent(view);
+            } else {
+                showEmptyView(view, getResources().getString(R.string.camera_photo_error));
+            }
+        }
+    }
+
+//    /**
+//     * Returns the index of the given photo ID within the cursor data.
+//     * If the ID is not found, return {@code -1}.
+//     */
+//    private int getCursorPosition(Cursor data, MediaRef photoRef) {
+//        int cursorPosition = -1;
+//        final long photoId = photoRef.getPhotoId();
+//        final Uri localUri = photoRef.getLocalUri();
+//        final String localUrl = (localUri == null) ? null : localUri.toString();
+//
+//        data.moveToPosition(-1);
+//        // Prefer local photos over remote photos
+//        if (!TextUtils.isEmpty(localUrl)) {
+//            while (data.moveToNext()) {
+//                String cursorLocalUrl = data.getString(PhotoQuery.INDEX_URL);
+//                if (localUrl.equals(cursorLocalUrl)) {
+//                    cursorPosition = data.getPosition();
+//                    break;
+//                }
+//            }
+//        } else if (photoId != 0L) {
+//            while (data.moveToNext()) {
+//                long cursorPhotoId = data.getLong(PhotoQuery.INDEX_PHOTO_ID);
+//                if (photoId == cursorPhotoId) {
+//                    cursorPosition = data.getPosition();
+//                    break;
+//                }
+//            }
+//        }
+//        return cursorPosition;
+//    }
+
+    /**
+     * Display loading progress
+     *
+     * @param view The layout view
+     */
+    private void showEmptyViewProgress(View view) {
+        view.findViewById(R.id.photo_activity_empty_text).setVisibility(View.GONE);
+        view.findViewById(R.id.photo_activity_empty_progress).setVisibility(View.VISIBLE);
+        view.findViewById(R.id.photo_activity_empty).setVisibility(View.VISIBLE);
+    }
+
+    /**
+     * Show only the empty view
+     *
+     * @param view The layout view
+     */
+    private void showEmptyView(View view, CharSequence emptyText) {
+        view.findViewById(R.id.photo_activity_empty_progress).setVisibility(View.GONE);
+        final TextView etv = (TextView) view.findViewById(R.id.photo_activity_empty_text);
+        etv.setText(emptyText);
+        etv.setVisibility(View.VISIBLE);
+        view.findViewById(R.id.photo_activity_empty).setVisibility(View.VISIBLE);
+    }
+
+    /**
+     * Hide the empty view and show the content
+     *
+     * @param view The layout view
+     */
+    private void showContent(View view) {
+        view.findViewById(R.id.photo_activity_empty).setVisibility(View.GONE);
+    }
+
+    /**
+     * Adjusts the activity title and subtitle to reflect the circle name and count.
+     */
+    private void updateTitleAndSubtitle() {
+        final int position = mViewPager.getCurrentItem() + 1;
+        final String subtitle;
+        final boolean hasAlbumCount = mAlbumCount >= 0;
+
+        if (mIsEmpty || !hasAlbumCount || position <= 0) {
+            subtitle = null;
+        } else {
+            subtitle = getResources().getString(R.string.photo_view_count, position, mAlbumCount);
+        }
+
+        if (Build.VERSION.SDK_INT >= 11) {
+            final ActionBar actionBar = getActionBar();
+
+            actionBar.setTitle(mAlbumName);
+            actionBar.setSubtitle(subtitle);
+        } else {
+//            setTitlebarTitle(mAlbumName);
+//            setTitlebarSubtitle(subtitle);
+//            createTitlebarButtons(R.menu.photo_view_menu);
+        }
+    }
+
+    /**
+     * @return The action bar height.
+     */
+    @Override
+    public int getActionBarHeight() {
+        return mActionBarHeight;
+    }
+
+    /**
+     * Clears the layout listener and removes any reference to it.
+     */
+    private void clearListener() {
+        if (mRootView != null) {
+            mRootView.getViewTreeObserver().removeGlobalOnLayoutListener(mActionBarLayoutListener);
+        }
+        mActionBarLayoutListener = null;
+    }
+
+    /**
+     * Listener to handle dialog button clicks for the retry dialog.
+     */
+    class RetryDialogListener implements DialogInterface.OnClickListener {
+        /** The tag of the fragment this dialog is opened for */
+        final String mTag;
+
+        public RetryDialogListener(String tag) {
+            mTag = tag;
+        }
+
+        @Override
+        public void onClick(DialogInterface dialog, int which) {
+            switch (which) {
+                case DialogInterface.BUTTON_POSITIVE: {
+                    final PhotoViewFragment fragment =
+                            (PhotoViewFragment) getSupportFragmentManager().findFragmentByTag(mTag);
+                    if (fragment != null) {
+                        fragment.downloadPhoto(PhotoViewActivity.this, false);
+                    }
+                    break;
+                }
+
+                case DialogInterface.BUTTON_NEGATIVE: {
+                    break;
+                }
+            }
+            dialog.dismiss();
+        }
+    }
+
+    /**
+     * Layout listener whose sole purpose is to determine when the Action Bar is laid out.
+     */
+    class ActionBarLayoutListener implements ViewTreeObserver.OnGlobalLayoutListener {
+        @Override
+        public void onGlobalLayout() {
+            final ActionBar ab = getActionBar();
+            final int abHeight = ab.getHeight();
+            if (ab.isShowing() && abHeight > 0) {
+                mActionBarHeight = abHeight;
+
+                for (OnScreenListener listener : mScreenListeners) {
+                    listener.onActionBarHeightCalculated(abHeight);
+                }
+
+                // The action bar has been laid out; no need to listen to layout changes any more
+                clearListener();
+            }
+        }
+    }
+}
diff --git a/src/com/android/mail/photo/PhotoViewPager.java b/src/com/android/mail/photo/PhotoViewPager.java
new file mode 100644
index 0000000..7982ff3
--- /dev/null
+++ b/src/com/android/mail/photo/PhotoViewPager.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo;
+
+import android.content.Context;
+import android.support.v4.view.MotionEventCompat;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+
+/**
+ * View pager for photo view fragments. Define our own class so we can specify the
+ * view pager in XML.
+ */
+public class PhotoViewPager extends ViewPager {
+    /**
+     * A type of intercept that should be performed
+     */
+    public static enum InterceptType { NONE, LEFT, RIGHT, BOTH }
+
+    /**
+     * Provides an ability to intercept touch events.
+     * <p>
+     * {@link ViewPager} intercepts all touch events and we need to be able to override this
+     * behaviour. Instead, we could perform a similar function by declaring a custom
+     * {@link ViewGroup} to contain the pager and intercept touch events at a higher level.
+     */
+    public static interface OnInterceptTouchListener {
+        /**
+         * Called when a touch intercept is about to occur.
+         *
+         * @param origX the raw x coordinate of the initial touch
+         * @param origY the raw y coordinate of the initial touch
+         * @return Which type of touch, if any, should should be intercepted.
+         */
+        public InterceptType onTouchIntercept(float origX, float origY);
+    }
+
+    private static final int INVALID_POINTER = -1;
+
+    private float mLastMotionX;
+    private int mActivePointerId;
+    /** The x coordinate where the touch originated */
+    private float mActivatedX;
+    /** The y coordinate where the touch originated */
+    private float mActivatedY;
+    private OnInterceptTouchListener mListener;
+
+    public PhotoViewPager(Context context) {
+        super(context);
+    }
+
+    public PhotoViewPager(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    /**
+     * {@inheritDoc}
+     * <p>
+     * We intercept touch event intercepts so we can prevent switching views when the
+     * current view is internally scrollable.
+     */
+    @Override
+    public boolean onInterceptTouchEvent(MotionEvent ev) {
+        final InterceptType intercept = (mListener != null)
+                ? mListener.onTouchIntercept(mActivatedX, mActivatedY)
+                : InterceptType.NONE;
+        final boolean ignoreScrollLeft =
+                (intercept == InterceptType.BOTH || intercept == InterceptType.LEFT);
+        final boolean ignoreScrollRight =
+                (intercept == InterceptType.BOTH || intercept == InterceptType.RIGHT);
+
+        // Only check ability to page if we can't scroll in one / both directions
+        final int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
+
+        if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
+            mActivePointerId = INVALID_POINTER;
+        }
+
+        switch (action) {
+            case MotionEvent.ACTION_MOVE: {
+                if (ignoreScrollLeft || ignoreScrollRight) {
+                    final int activePointerId = mActivePointerId;
+                    if (activePointerId == INVALID_POINTER) {
+                        // If we don't have a valid id, the touch down wasn't on content.
+                        break;
+                    }
+
+                    final int pointerIndex =
+                            MotionEventCompat.findPointerIndex(ev, activePointerId);
+                    final float x = MotionEventCompat.getX(ev, pointerIndex);
+
+                    if (ignoreScrollLeft && ignoreScrollRight) {
+                        mLastMotionX = x;
+                        return false;
+                    } else if (ignoreScrollLeft && (x > mLastMotionX)) {
+                        mLastMotionX = x;
+                        return false;
+                    } else if (ignoreScrollRight && (x < mLastMotionX)) {
+                        mLastMotionX = x;
+                        return false;
+                    }
+                }
+                break;
+            }
+
+            case MotionEvent.ACTION_DOWN: {
+                mLastMotionX = ev.getX();
+                // Use the raw x/y as the children can be located anywhere and there isn't a
+                // single offset that would be meaningful
+                mActivatedX = ev.getRawX();
+                mActivatedY = ev.getRawY();
+                mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
+                break;
+            }
+
+            case MotionEventCompat.ACTION_POINTER_UP: {
+                final int pointerIndex = MotionEventCompat.getActionIndex(ev);
+                final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
+                if (pointerId == mActivePointerId) {
+                    // Our active pointer going up; select a new active pointer
+                    final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
+                    mLastMotionX = MotionEventCompat.getX(ev, newPointerIndex);
+                    mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
+                }
+                break;
+            }
+        }
+
+        return super.onInterceptTouchEvent(ev);
+    }
+
+    /**
+     * sets the intercept touch listener.
+     */
+    public void setOnInterceptTouchListener(OnInterceptTouchListener l) {
+        mListener = l;
+    }
+}
diff --git a/src/com/android/mail/photo/adapters/BaseCursorAdapter.java b/src/com/android/mail/photo/adapters/BaseCursorAdapter.java
new file mode 100644
index 0000000..4f95528
--- /dev/null
+++ b/src/com/android/mail/photo/adapters/BaseCursorAdapter.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.adapters;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.widget.CursorAdapter;
+import android.view.View;
+import android.view.ViewGroup;
+
+/**
+ * The base cursor adapter
+ */
+public class BaseCursorAdapter extends CursorAdapter {
+    /**
+     * Constructor
+     *
+     * @param context The context
+     * @param cursor The cursor
+     */
+    public BaseCursorAdapter(Context context, Cursor cursor) {
+        super(context, cursor, false);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public View getView(int position, View convertView, ViewGroup viewGroup) {
+
+        // Since there is a bug in the framework that causes AbstractCursor.obtainView()
+        // to sometimes call this method with a position outside the bounds of
+        // the adapter, perform a check to prevent the IllegalStateException.
+        // See http://b/5147237
+        if (position >= getCount()) {
+            return convertView == null ? newView(mContext, getCursor(), viewGroup) : convertView;
+        }
+
+        return super.getView(position, convertView, viewGroup);
+    }
+
+    /**
+     * Get the view from the specified position
+     *
+     * @param pos The position
+     *
+     * @return The view
+     */
+    public View getViewFromPos(int pos) {
+        return null;
+    }
+
+    /**
+     * Called when the activity pauses
+     */
+    public void onPause() {
+    }
+
+    /**
+     * Called when the activity resumes
+     */
+    public void onResume() {
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean isEmpty() {
+        if (getCursor() == null) {
+            return true;
+        } else {
+            return super.isEmpty();
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void bindView(View view, Context context, Cursor cursor) {
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public View newView(Context context, Cursor cursor, ViewGroup parent) {
+        return null;
+    }
+}
diff --git a/src/com/android/mail/photo/adapters/BaseCursorPagerAdapter.java b/src/com/android/mail/photo/adapters/BaseCursorPagerAdapter.java
new file mode 100644
index 0000000..f54c7c0
--- /dev/null
+++ b/src/com/android/mail/photo/adapters/BaseCursorPagerAdapter.java
@@ -0,0 +1,258 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.adapters;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.util.Log;
+import android.util.SparseIntArray;
+import android.view.View;
+
+import java.util.HashMap;
+
+/**
+ * Page adapter for use with an EsCursorLoader. Unlike other cursor adapters, this has no
+ * observers for automatic refresh. Instead, it depends upon external mechanisms to provide
+ * the update signal.
+ */
+public abstract class BaseCursorPagerAdapter extends BaseFragmentPagerAdapter {
+    private static final String TAG = "EsCursorPagerAdapter";
+
+    Context mContext;
+    private boolean mDataValid;
+    private Cursor mCursor;
+    private int mRowIDColumn;
+    /** Mapping of row ID to cursor position */
+    private SparseIntArray mItemPosition;
+    /** Mapping of instantiated object to row ID */
+    private HashMap<Object, Integer> mObjectRowMap = new HashMap<Object, Integer>();
+
+    /**
+     * Constructor that always enables auto-requery.
+     *
+     * @param c The cursor from which to get the data.
+     * @param context The context
+     */
+    public BaseCursorPagerAdapter(Context context, FragmentManager fm, Cursor c) {
+        super(fm);
+        init(context, c);
+    }
+
+    /**
+     * Makes a fragment for the data pointed to by the cursor
+     *
+     * @param context Interface to application's global information
+     * @param cursor The cursor from which to get the data. The cursor is already
+     * moved to the correct position.
+     * @return the newly created fragment.
+     */
+    public abstract Fragment getItem(Context context, Cursor cursor);
+
+    @Override
+    public Fragment getItem(int position) {
+        if (mDataValid && moveCursorTo(position)) {
+            return getItem(mContext, mCursor);
+        }
+        return null;
+    }
+
+    @Override
+    public int getCount() {
+        if (mDataValid && mCursor != null) {
+            return mCursor.getCount();
+        } else {
+            return 0;
+        }
+    }
+
+    @Override
+    public Object instantiateItem(View container, int position) {
+        if (!mDataValid) {
+            throw new IllegalStateException("this should only be called when the cursor is valid");
+        }
+
+        final Integer rowId;
+        if (moveCursorTo(position)) {
+            rowId = mCursor.getInt(mRowIDColumn);
+        } else {
+            rowId = null;
+        }
+
+        // Create the fragment and bind cursor data
+        final Object obj = super.instantiateItem(container, position);
+        if (obj != null) {
+            mObjectRowMap.put(obj, rowId);
+        }
+        return obj;
+    }
+
+    @Override
+    public void destroyItem(View container, int position, Object object) {
+        mObjectRowMap.remove(object);
+
+        super.destroyItem(container, position, object);
+    }
+
+    @Override
+    public int getItemPosition(Object object) {
+        final Integer rowId = mObjectRowMap.get(object);
+        if (rowId == null || mItemPosition == null) {
+            return POSITION_NONE;
+        }
+
+        final int position = mItemPosition.get(rowId, POSITION_NONE);
+        return position;
+    }
+
+    /**
+     * @return true if data is valid
+     */
+    public boolean isDataValid() {
+        return mDataValid;
+    }
+
+    /**
+     * Returns the cursor.
+     */
+    public Cursor getCursor() {
+        return mCursor;
+    }
+
+    /**
+     * Returns the data item associated with the specified position in the data set.
+     */
+    public Object getDataItem(int position) {
+        if (mDataValid && moveCursorTo(position)) {
+            return mCursor;
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Returns the row id associated with the specified position in the list.
+     */
+    public long getItemId(int position) {
+        if (mDataValid && moveCursorTo(position)) {
+            return mCursor.getLong(mRowIDColumn);
+        } else {
+            return 0;
+        }
+    }
+
+    /**
+     * Swap in a new Cursor, returning the old Cursor.
+     *
+     * @param newCursor The new cursor to be used.
+     * @return Returns the previously set Cursor, or null if there was not one.
+     * If the given new Cursor is the same instance is the previously set
+     * Cursor, null is also returned.
+     */
+    public Cursor swapCursor(Cursor newCursor) {
+        if (Log.isLoggable(TAG, Log.VERBOSE)) {
+            Log.v(TAG, "swapCursor old=" + (mCursor == null ? -1 : mCursor.getCount()) +
+                    "; new=" + (newCursor == null ? -1 : newCursor.getCount()));
+        }
+
+        if (newCursor == mCursor) {
+            return null;
+        }
+        Cursor oldCursor = mCursor;
+        mCursor = newCursor;
+        if (newCursor != null) {
+            mRowIDColumn = newCursor.getColumnIndexOrThrow("_id");
+            mDataValid = true;
+        } else {
+            mRowIDColumn = -1;
+            mDataValid = false;
+        }
+
+        setItemPosition();
+        notifyDataSetChanged();     // notify the observers about the new cursor
+        return oldCursor;
+    }
+
+    /**
+     * Converts the cursor into a CharSequence. Subclasses should override this
+     * method to convert their results. The default implementation returns an
+     * empty String for null values or the default String representation of
+     * the value.
+     *
+     * @param cursor the cursor to convert to a CharSequence
+     * @return a CharSequence representing the value
+     */
+    public CharSequence convertToString(Cursor cursor) {
+        return cursor == null ? "" : cursor.toString();
+    }
+
+    @Override
+    protected String makeFragmentName(int viewId, int index) {
+        if (moveCursorTo(index)) {
+            return "android:espager:" + viewId + ":" + mCursor.getInt(mRowIDColumn);
+        } else {
+            return super.makeFragmentName(viewId, index);
+        }
+    }
+
+    /**
+     * Moves the cursor to the given position
+     *
+     * @return {@code true} if the cursor's position was set. Otherwise, {@code false}.
+     */
+    private boolean moveCursorTo(int position) {
+        if (mCursor != null && !mCursor.isClosed()) {
+            return mCursor.moveToPosition(position);
+        }
+        return false;
+    }
+
+    /**
+     * Initialize the adapter.
+     */
+    private void init(Context context, Cursor c) {
+        boolean cursorPresent = c != null;
+        mCursor = c;
+        mDataValid = cursorPresent;
+        mContext = context;
+        mRowIDColumn = cursorPresent ? c.getColumnIndexOrThrow("_id") : -1;
+    }
+
+    /**
+     * Sets the {@link #mItemPosition} instance variable with the current mapping of
+     * row id to cursor position.
+     */
+    private void setItemPosition() {
+        if (!mDataValid || mCursor == null || mCursor.isClosed()) {
+            mItemPosition = null;
+            return;
+        }
+
+        SparseIntArray itemPosition = new SparseIntArray(mCursor.getCount());
+
+        mCursor.moveToPosition(-1);
+        while (mCursor.moveToNext()) {
+            final int rowId = mCursor.getInt(mRowIDColumn);
+            final int position = mCursor.getPosition();
+
+            itemPosition.append(rowId, position);
+        }
+        mItemPosition = itemPosition;
+    }
+}
diff --git a/src/com/android/mail/photo/adapters/BaseFragmentPagerAdapter.java b/src/com/android/mail/photo/adapters/BaseFragmentPagerAdapter.java
new file mode 100644
index 0000000..c1b9fab
--- /dev/null
+++ b/src/com/android/mail/photo/adapters/BaseFragmentPagerAdapter.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.adapters;
+
+import android.os.Parcelable;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentTransaction;
+import android.support.v4.util.LruCache;
+import android.support.v4.view.PagerAdapter;
+import android.util.Log;
+import android.view.View;
+
+/**
+ * NOTE: This is a direct copy of {@link FragmentPagerAdapter} with four very important
+ * modifications.
+ * <p>
+ * <ol>
+ * <li>The method {@link #makeFragmentName(int, int)} is declared "protected"
+ * in our class. We need to be able to re-define the fragment's name according to data
+ * only available to sub-classes.</li>
+ * <li>The method {@link #isViewFromObject(View, Object)} has been reimplemented to search
+ * the entire view hierarchy for the given view.</li>
+ * <li>In method {@link #destroyItem(View, int, Object)}, the fragment is detached and
+ * added to a cache. If the fragment is evicted from the cache, it will be deleted.
+ * An album may contain thousands of photos and we want to avoid having thousands of
+ * fragments.</li>
+ * <li>The interface {@link OnFragmentPagerListener} and supporting plumbing has been
+ * added.</li>
+ * </ol>
+ */
+public abstract class BaseFragmentPagerAdapter extends PagerAdapter {
+    /**
+     * Listener for fragment pager events
+     */
+    public interface OnFragmentPagerListener {
+        /**
+         * The given fragment has been made the activated fragment.
+         */
+        public void onPageActivated(Fragment fragment);
+    }
+
+    /** The default size of {@link #mFragmentCache} */
+    private static final int DEFAULT_CACHE_SIZE = 5;
+    private static final String TAG = "FragmentPagerAdapter";
+    private static final boolean DEBUG = false;
+
+    private final FragmentManager mFragmentManager;
+    private FragmentTransaction mCurTransaction = null;
+    private Fragment mCurrentPrimaryItem = null;
+    private OnFragmentPagerListener mPagerListener;
+    /** A cache to store detached fragments before they are removed  */
+    private LruCache<String, Fragment> mFragmentCache = new FragmentCache(DEFAULT_CACHE_SIZE);
+
+    public BaseFragmentPagerAdapter(FragmentManager fm) {
+        mFragmentManager = fm;
+    }
+
+    /**
+     * Return the Fragment associated with a specified position.
+     */
+    public abstract Fragment getItem(int position);
+
+    @Override
+    public void startUpdate(View container) {
+    }
+
+    @Override
+    public Object instantiateItem(View container, int position) {
+        if (mCurTransaction == null) {
+            mCurTransaction = mFragmentManager.beginTransaction();
+        }
+
+        // Do we already have this fragment?
+        String name = makeFragmentName(container.getId(), position);
+
+        // Remove item from the cache
+        mFragmentCache.remove(name);
+
+        Fragment fragment = mFragmentManager.findFragmentByTag(name);
+        if (fragment != null) {
+            if (DEBUG) Log.v(TAG, "Attaching item #" + position + ": f=" + fragment);
+            mCurTransaction.attach(fragment);
+        } else {
+            fragment = getItem(position);
+            if (DEBUG) Log.v(TAG, "Adding item #" + position + ": f=" + fragment);
+            mCurTransaction.add(container.getId(), fragment,
+                    makeFragmentName(container.getId(), position));
+        }
+        if (fragment != mCurrentPrimaryItem) {
+            fragment.setMenuVisibility(false);
+        }
+
+        return fragment;
+    }
+
+    @Override
+    public void destroyItem(View container, int position, Object object) {
+        if (mCurTransaction == null) {
+            mCurTransaction = mFragmentManager.beginTransaction();
+        }
+        if (DEBUG) Log.v(TAG, "Detaching item #" + position + ": f=" + object
+                + " v=" + ((Fragment)object).getView());
+
+        Fragment fragment = (Fragment) object;
+        String name = fragment.getTag();
+        if (name == null) {
+            // We prefer to get the name directly from the fragment, but, if the fragment is
+            // detached before the add transaction is committed, this could be 'null'. In
+            // that case, generate a name so we can still cache the fragment.
+            name = makeFragmentName(container.getId(), position);
+        }
+
+        mFragmentCache.put(name, fragment);
+        mCurTransaction.detach(fragment);
+    }
+
+    @Override
+    public void setPrimaryItem(View container, int position, Object object) {
+        Fragment fragment = (Fragment) object;
+        if (fragment != mCurrentPrimaryItem) {
+            if (mCurrentPrimaryItem != null) {
+                mCurrentPrimaryItem.setMenuVisibility(false);
+            }
+            if (fragment != null) {
+                fragment.setMenuVisibility(true);
+            }
+            mCurrentPrimaryItem = fragment;
+        }
+
+        if (mPagerListener != null) {
+            mPagerListener.onPageActivated(fragment);
+        }
+    }
+
+    @Override
+    public void finishUpdate(View container) {
+        if (mCurTransaction != null) {
+            mCurTransaction.commitAllowingStateLoss();
+            mCurTransaction = null;
+            mFragmentManager.executePendingTransactions();
+        }
+    }
+
+    @Override
+    public boolean isViewFromObject(View view, Object object) {
+        // Ascend the tree to determine if the view is a child of the fragment
+        View root = ((Fragment) object).getView();
+        for (Object v = view; v instanceof View; v = ((View) v).getParent()) {
+            if (v == root) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    @Override
+    public Parcelable saveState() {
+        return null;
+    }
+
+    @Override
+    public void restoreState(Parcelable state, ClassLoader loader) {
+    }
+
+    /** Sets the fragment pager listener */
+    public void setFragmentPagerListener(OnFragmentPagerListener pagerListener) {
+        mPagerListener = pagerListener;
+    }
+
+    /** Creates a name for the fragment */
+    protected String makeFragmentName(int viewId, int index) {
+        return "android:switcher:" + viewId + ":" + index;
+    }
+
+    /**
+     * A cache of detached fragments.
+     */
+    private class FragmentCache extends LruCache<String, Fragment> {
+        public FragmentCache(int size) {
+            super(size);
+        }
+
+        @Override
+        protected void entryRemoved(boolean evicted, String key,
+                Fragment oldValue, Fragment newValue) {
+            // remove the fragment if it's evicted OR it's replaced by a new fragment
+            if (evicted || (newValue != null && oldValue != newValue)) {
+                mCurTransaction.remove(oldValue);
+            }
+        }
+    }
+}
diff --git a/src/com/android/mail/photo/adapters/PhotoPagerAdapter.java b/src/com/android/mail/photo/adapters/PhotoPagerAdapter.java
new file mode 100644
index 0000000..39ae919
--- /dev/null
+++ b/src/com/android/mail/photo/adapters/PhotoPagerAdapter.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.adapters;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+
+import com.android.mail.photo.Intents;
+import com.android.mail.photo.Pageable;
+import com.android.mail.photo.Intents.PhotoViewIntentBuilder;
+import com.android.mail.photo.fragments.LoadingFragment;
+import com.android.mail.photo.fragments.PhotoViewFragment;
+import com.android.mail.photo.provider.PhotoContract.PhotoQuery;
+
+/**
+ * Pager adapter for the photo view
+ */
+public class PhotoPagerAdapter extends BaseCursorPagerAdapter {
+    final Long mForceLoadId;
+    /** Album name used if the photo doesn't have one. See b/5678229. */
+    private final String mDefaultAlbumName;
+    private Pageable mPageable;
+
+    public PhotoPagerAdapter(Context context, FragmentManager fm, Cursor c,
+            Long forceLoadId, String defaultAlbumName) {
+        super(context, fm, c);
+        mForceLoadId = forceLoadId;
+        mDefaultAlbumName = defaultAlbumName;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int getCount() {
+        if (mPageable != null && mPageable.hasMore()) {
+            return super.getCount() + 1;
+        }
+        return super.getCount();
+    }
+
+    @Override
+    public Fragment getItem(Context context, Cursor cursor) {
+        final long photoId = cursor.getLong(PhotoQuery.INDEX_PHOTO_ID);
+        final String photoUrl = cursor.getString(PhotoQuery.INDEX_URI);
+
+        // create new PhotoViewFragment
+        final PhotoViewIntentBuilder builder =
+                Intents.newPhotoViewFragmentIntentBuilder(mContext);
+          builder.setPhotoId(photoId)
+            .setPhotoUrl(photoUrl)
+            .setAlbumName(mDefaultAlbumName)
+            .setForceLoadId(mForceLoadId);
+
+        return new PhotoViewFragment(builder.build(), cursor.getPosition());
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Fragment getItem(int position) {
+        final Cursor cursor = isDataValid() ? getCursor() : null;
+        if (cursor != null && (cursor.isClosed() || position >= cursor.getCount())) {
+            // Show the "loading" fragment while more data is loaded
+            mPageable.loadMore();
+            return new LoadingFragment();
+        }
+        return super.getItem(position);
+    }
+
+    /**
+     * Sets the {@link Pageable}
+     */
+    public void setPageable(Pageable pageable) {
+        mPageable = pageable;
+    }
+}
diff --git a/src/com/android/mail/photo/content/ImageRequest.java b/src/com/android/mail/photo/content/ImageRequest.java
new file mode 100644
index 0000000..648e769
--- /dev/null
+++ b/src/com/android/mail/photo/content/ImageRequest.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.content;
+
+/**
+ * A request for an image.
+ */
+public abstract class ImageRequest {
+
+    /**
+     * @return true if this request is known to resolve to a missing image.
+     */
+    public abstract boolean isEmpty();
+}
diff --git a/src/com/android/mail/photo/content/LocalImageRequest.java b/src/com/android/mail/photo/content/LocalImageRequest.java
new file mode 100644
index 0000000..fa30bb2
--- /dev/null
+++ b/src/com/android/mail/photo/content/LocalImageRequest.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2012 Google Inc.
+ * Licensed to 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.mail.photo.content;
+
+import android.net.Uri;
+
+/**
+ * A request for a media image of a specific size.
+ */
+public class LocalImageRequest extends ImageRequest {
+    private final Uri mUri;
+    private final int mWidth;
+    private final int mHeight;
+
+    private int mHashCode;
+
+    public LocalImageRequest(int width, int height) {
+        mUri = null;
+        mWidth = width;
+        mHeight = height;
+    }
+
+    /**
+     * @return the original Uri
+     */
+    public Uri getUri() {
+        return mUri;
+    }
+
+    /**
+     * @return the width
+     */
+    public int getWidth() {
+        return mWidth;
+    }
+
+    /**
+     * @return the height
+     */
+    public int getHeight() {
+        return mHeight;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean isEmpty() {
+        return mUri == null;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int hashCode() {
+        if (mHashCode == 0) {
+            int result = 17;
+            result = 31 * result + mUri.hashCode();
+            result = 31 * result + mWidth;
+            result = 31 * result + mHeight;
+            mHashCode = result;
+        }
+        return mHashCode;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean equals(Object o) {
+        if (o == this) {
+            return true;
+        }
+
+        if (!(o instanceof LocalImageRequest)) {
+            return false;
+        }
+
+        final LocalImageRequest other = (LocalImageRequest) o;
+        return (mUri.equals(other.mUri) &&
+                mWidth == other.mWidth &&
+                mHeight == other.mHeight);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String toString() {
+        return "LocalImageRequest: " + mUri.toString() + " (" + mWidth + ", " + mHeight + ")";
+    }
+}
diff --git a/src/com/android/mail/photo/content/MediaImageRequest.java b/src/com/android/mail/photo/content/MediaImageRequest.java
new file mode 100644
index 0000000..612dc7d
--- /dev/null
+++ b/src/com/android/mail/photo/content/MediaImageRequest.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2012 Google Inc.
+ * Licensed to 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.mail.photo.content;
+
+import android.text.TextUtils;
+
+import com.android.mail.photo.util.ImageUtils;
+
+/**
+ * A request for a media image of a specific size.
+ */
+public class MediaImageRequest extends ImageRequest {
+
+    private final String mUrl;
+    private final String mMediaType;
+    private final int mWidth;
+    private final int mHeight;
+    private final boolean mCropAndResize;
+
+    private String mDownloadUrl;
+    private int mHashCode;
+
+    public MediaImageRequest() {
+        this(null, null, 0, 0, false);
+    }
+
+    public MediaImageRequest(String url, String mediaType, int size) {
+        this(url, mediaType, size, size, true);
+    }
+
+    public MediaImageRequest(
+            String url, String mediaType, int width, int height, boolean cropAndResize) {
+        if (url == null) {
+            throw new NullPointerException();
+        }
+
+        mUrl = url;
+        mMediaType = mediaType;
+        mWidth = width;
+        mHeight = height;
+        mCropAndResize = cropAndResize;
+    }
+
+    /**
+     * @return the original URL
+     */
+    public String getUrl() {
+        return mUrl;
+    }
+
+    /**
+     * @return the URL
+     */
+    public String getDownloadUrl() {
+        if (mDownloadUrl == null) {
+            if (!mCropAndResize || mWidth == 0) {
+                mDownloadUrl = mUrl;
+            } else if (mWidth == mHeight) {
+                mDownloadUrl = ImageUtils.getCroppedAndResizedUrl(mWidth, mUrl);
+            } else {
+                mDownloadUrl = ImageUtils.getCenterCroppedAndResizedUrl(mWidth, mHeight, mUrl);
+            }
+
+            if (mDownloadUrl.startsWith("//")) {
+                mDownloadUrl = "http:" + mDownloadUrl;
+            }
+        }
+        return mDownloadUrl;
+    }
+
+    /**
+     * @return the media type
+     */
+    public String getMediaType() {
+        return mMediaType;
+    }
+
+    /**
+     * @return the width
+     */
+    public int getWidth() {
+        return mWidth;
+    }
+
+    /**
+     * @return the height
+     */
+    public int getHeight() {
+        return mHeight;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean isEmpty() {
+        return mUrl == null;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int hashCode() {
+        if (mHashCode == 0) {
+            if (mUrl != null) {
+                mHashCode = mUrl.hashCode();
+            } else {
+                mHashCode = 1;
+            }
+        }
+        return mHashCode;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean equals(Object o) {
+        if (!(o instanceof MediaImageRequest)) {
+            return false;
+        }
+
+        MediaImageRequest k = (MediaImageRequest) o;
+        return mWidth == k.mWidth && mHeight == k.mHeight
+                && TextUtils.equals(mUrl, k.mUrl)
+                && TextUtils.equals(mMediaType, k.mMediaType);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public String toString() {
+        return "MediaImageRequest: " + mMediaType + " " + mUrl + " (" + mWidth
+                + ", " + mHeight + ")";
+    }
+}
diff --git a/src/com/android/mail/photo/fragments/BaseFragment.java b/src/com/android/mail/photo/fragments/BaseFragment.java
new file mode 100644
index 0000000..3d97c7e
--- /dev/null
+++ b/src/com/android/mail/photo/fragments/BaseFragment.java
@@ -0,0 +1,315 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.fragments;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.support.v4.app.Fragment;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import com.android.mail.R;
+
+/**
+ * Base implementation for a list fragment
+ */
+public abstract class BaseFragment extends Fragment {
+
+    // State keys
+    private static final String STATE_PENDING_REQ_ID_NEWER_KEY = "n_pending_req";
+    private static final String STATE_PENDING_REQ_ID_OLDER_KEY = "o_pending_req";
+
+    // Progress flags
+    protected static final int PROGRESS_FLAG_NONE = 0;
+    protected static final int PROGRESS_FLAG_NEWER = 1;
+    protected static final int PROGRESS_FLAG_OLDER = 2;
+
+    // Handler message ID
+    protected static final int MESSAGE_ID_SHOW_PROGRESS_VIEW = 0;
+
+    // Progress view delay
+    private static final int PROGRESS_VIEW_DELAY = 800;
+
+    // Instance variables
+    protected Integer mNewerReqId;
+    protected Integer mOlderReqId;
+    private boolean mPaused;
+    private boolean mRestoredFragment;
+
+    private final Handler mHandler = new Handler() {
+
+        /**
+         * {@inheritDoc}
+         */
+        @Override
+        public void handleMessage(Message msg) {
+            if (msg.what == MESSAGE_ID_SHOW_PROGRESS_VIEW) {
+                doShowEmptyViewProgressDelayed();
+            }
+        }
+    };
+
+    /**
+     * Returns {@code true} if the content is empty (has no items). Otherwise, {@code false}.
+     */
+    protected abstract boolean isEmpty();
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        if (savedInstanceState != null) {
+            mRestoredFragment = true;
+            if (savedInstanceState.containsKey(STATE_PENDING_REQ_ID_NEWER_KEY)) {
+                mNewerReqId = savedInstanceState.getInt(STATE_PENDING_REQ_ID_NEWER_KEY);
+            }
+
+            if (savedInstanceState.containsKey(STATE_PENDING_REQ_ID_OLDER_KEY)) {
+                mOlderReqId = savedInstanceState.getInt(STATE_PENDING_REQ_ID_OLDER_KEY);
+            }
+        }
+    }
+
+    /**
+     * Create the view with the specified layout resource id
+     *
+     * @param inflater The inflater
+     * @param container The container
+     * @param savedInstanceState The saved instance state
+     * @param layoutResId The layout resource id
+     *
+     * @return The view
+     */
+    public View onCreateView(LayoutInflater inflater, ViewGroup container,
+            Bundle savedInstanceState, int layoutResId) {
+        final View view = inflater.inflate(layoutResId, container, false);
+
+        return view;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void onResume() {
+        super.onResume();
+
+//        boolean hadPending = false;
+//        if (mNewerReqId != null) {
+//            if (EsService.isRequestPending(mNewerReqId)) {
+//                if (isEmpty()) {
+//                    showEmptyViewProgress(getView());
+//                }
+//            } else {
+//                mNewerReqId = null;
+//                hadPending = true;
+//            }
+//        }
+//
+//        if (mOlderReqId != null) {
+//            if (EsService.isRequestPending(mOlderReqId)) {
+//                if (isEmpty()) {
+//                    showEmptyViewProgress(getView());
+//                }
+//            } else {
+//                mOlderReqId = null;
+//                hadPending = true;
+//            }
+//        }
+//
+//        if (hadPending && mNewerReqId == null && mOlderReqId == null) {
+//            onResumeContentFetched(getView());
+//
+//            if (isEmpty()) {
+//                showEmptyView(getView());
+//            }
+//        }
+
+        mPaused = false;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void onPause() {
+        super.onPause();
+
+        mPaused = true;
+    }
+
+    /**
+     * @return true if activity is paused
+     */
+    protected boolean isPaused() {
+        return mPaused;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void onSaveInstanceState(Bundle outState) {
+        super.onSaveInstanceState(outState);
+        if (mNewerReqId != null) {
+            outState.putInt(STATE_PENDING_REQ_ID_NEWER_KEY, mNewerReqId);
+        }
+
+        if (mOlderReqId != null) {
+            outState.putInt(STATE_PENDING_REQ_ID_OLDER_KEY, mOlderReqId);
+        }
+    }
+
+    public void startExternalActivity(Intent intent) {
+        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
+        startActivity(intent);
+    }
+
+    /**
+     * Display loading progress after a short delay.
+     *
+     * @param view The layout view
+     */
+    protected void showEmptyViewProgress(View view) {
+        if (mRestoredFragment) {
+            if (!mHandler.hasMessages(MESSAGE_ID_SHOW_PROGRESS_VIEW) && isEmpty()) {
+                mHandler.sendEmptyMessageDelayed(MESSAGE_ID_SHOW_PROGRESS_VIEW,
+                        PROGRESS_VIEW_DELAY);
+            }
+        } else {
+            doShowEmptyViewProgress(view);
+        }
+    }
+
+    /**
+     * Shows the progress view a
+     */
+    protected void doShowEmptyViewProgressDelayed() {
+        if (isAdded() && !isPaused()) {
+            View view = getView();
+            if (view != null) {
+                doShowEmptyViewProgress(view);
+            }
+        }
+    }
+
+    /**
+     * Display loading progress
+     */
+    protected void doShowEmptyViewProgress(View view) {
+        if (isEmpty()) {
+            final View emptyView = view.findViewById(android.R.id.empty);
+            emptyView.setVisibility(View.VISIBLE);
+            emptyView.findViewById(R.id.list_empty_text).setVisibility(View.GONE);
+            emptyView.findViewById(R.id.list_empty_progress).setVisibility(View.VISIBLE);
+        }
+    }
+
+    /**
+     * Display the empty view
+     */
+    protected void doShowEmptyView(View view) {
+        if (isEmpty()) {
+            final View emptyView = view.findViewById(android.R.id.empty);
+            emptyView.setVisibility(View.VISIBLE);
+            emptyView.findViewById(R.id.list_empty_text).setVisibility(View.VISIBLE);
+            emptyView.findViewById(R.id.list_empty_progress).setVisibility(View.GONE);
+        }
+    }
+
+    /**
+     * Display loading progress
+     *
+     * @param view The layout view
+     * @param progressText The progress text
+     */
+    protected void showEmptyViewProgress(View view, String progressText) {
+        if (isEmpty()) {
+            ((TextView) view.findViewById(R.id.list_empty_progress_text)).setText(progressText);
+            showEmptyViewProgress(view);
+        }
+    }
+
+    /**
+     * Show only the empty view
+     *
+     * @param view The layout view
+     */
+    protected void showEmptyView(View view) {
+        removeProgressViewMessages();
+        doShowEmptyView(view);
+    }
+
+    /**
+     * Hide the empty view and show the content
+     *
+     * @param view The layout view
+     */
+    protected void showContent(View view) {
+        removeProgressViewMessages();
+        view.findViewById(android.R.id.empty).setVisibility(View.GONE);
+    }
+
+    /**
+     * Setup the empty view
+     *
+     * @param view The view
+     * @param emptyViewText The empty list view text
+     */
+    protected void setupEmptyView(View view, int emptyViewText) {
+        final TextView etv = (TextView)view.findViewById(R.id.list_empty_text);
+        etv.setText(emptyViewText);
+    }
+
+    /**
+     * If there are no pending requests hide the spinner
+     *
+     * @param progressView The progress view
+     */
+    protected void updateSpinner(ProgressBar progressView) {
+        if (progressView == null) {
+            return;
+        }
+
+        progressView.setVisibility(
+                mNewerReqId == null && mOlderReqId == null ? View.GONE : View.VISIBLE);
+    }
+
+    /**
+     * Remove MESSAGE_ID_SHOW_PROGRESS_VIEW messages.
+     */
+    protected void removeProgressViewMessages() {
+        mHandler.removeMessages(MESSAGE_ID_SHOW_PROGRESS_VIEW);
+    }
+
+    /**
+     * The content fetch completed while the activity was paused
+     *
+     * @param view The context view
+     */
+    protected void onResumeContentFetched(View view) {
+    }
+}
diff --git a/src/com/android/mail/photo/fragments/LoadingFragment.java b/src/com/android/mail/photo/fragments/LoadingFragment.java
new file mode 100644
index 0000000..8f9d1fe
--- /dev/null
+++ b/src/com/android/mail/photo/fragments/LoadingFragment.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.fragments;
+
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.mail.R;
+
+/**
+ * Simple fragment to display the loading message.
+ */
+public class LoadingFragment extends Fragment {
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public View onCreateView(LayoutInflater inflater, ViewGroup container,
+            Bundle savedInstanceState) {
+        final View view = inflater.inflate(R.layout.loading_message, container, false);
+        return view;
+    }
+}
diff --git a/src/com/android/mail/photo/fragments/PhotoViewFragment.java b/src/com/android/mail/photo/fragments/PhotoViewFragment.java
new file mode 100644
index 0000000..f904851
--- /dev/null
+++ b/src/com/android/mail/photo/fragments/PhotoViewFragment.java
@@ -0,0 +1,1026 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.fragments;
+
+import android.app.Activity;
+import android.app.DownloadManager;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.LoaderManager.LoaderCallbacks;
+import android.support.v4.content.Loader;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.view.WindowManager;
+import android.widget.AdapterView;
+import android.widget.ProgressBar;
+import android.widget.Toast;
+
+import com.android.mail.R;
+import com.android.mail.photo.BaseFragmentActivity;
+import com.android.mail.photo.Intents;
+import com.android.mail.photo.MultiChoiceActionModeStub;
+import com.android.mail.photo.PhotoViewActivity.OnMenuItemListener;
+import com.android.mail.photo.PhotoViewActivity.OnScreenListener;
+import com.android.mail.photo.loaders.PhotoBitmapLoader;
+import com.android.mail.photo.util.ImageUtils;
+import com.android.mail.photo.views.PhotoLayout;
+import com.android.mail.photo.views.PhotoView;
+
+import java.io.File;
+
+/**
+ * Displays photo, comments and tags for a picasa photo id.
+ */
+public class PhotoViewFragment extends BaseFragment implements
+        LoaderCallbacks<Bitmap>, OnClickListener, OnScreenListener, OnMenuItemListener {
+
+    /**
+     * Interface that activities must implement in order to use this fragment.
+     */
+    public static interface PhotoViewCallbacks {
+        /**
+         * Returns true of the given fragment is the currently active fragment.
+         */
+        public boolean isFragmentActive(Fragment fragment);
+
+        /**
+         * Called when the given fragment becomes visible.
+         */
+        public void onFragmentVisible(Fragment fragment);
+
+        /**
+         * Toggles full screen mode.
+         */
+        public void toggleFullScreen();
+
+        /**
+         * Returns {@code true} if full screen mode is enabled for the given fragment.
+         * Otherwise, {@code false}.
+         */
+        public boolean isFragmentFullScreen(Fragment fragment);
+
+        /**
+         * Returns {@code true} if only the photo should be displayed. All ancillary
+         * information [eg album name, photo owner, comment counts, etc...] will be hidden.
+         */
+        public boolean isShowPhotoOnly();
+
+        /**
+         * Adds a full screen listener.
+         */
+        public void addScreenListener(OnScreenListener listener);
+
+        /**
+         * Removes a full screen listener.
+         */
+        public void removeScreenListener(OnScreenListener listener);
+
+        /**
+         * Adds a title bar listener.
+         */
+        public void addMenuItemListener(OnMenuItemListener listener);
+
+        /**
+         * Removes a title bar listener.
+         */
+        public void removeMenuItemListener(OnMenuItemListener listener);
+
+        /**
+         * A photo has been deleted.
+         */
+        public void onPhotoRemoved(long photoId);
+
+        /**
+         * Get the action bar height.
+         */
+        public int getActionBarHeight();
+
+        /**
+         * Updates the title bar menu.
+         */
+        public void updateMenuItems();
+    }
+
+    /**
+     * Interface for components that are internally scrollable left-to-right.
+     */
+    public static interface HorizontallyScrollable {
+        /**
+         * Return {@code true} if the component needs to receive right-to-left
+         * touch movements.
+         *
+         * @param origX the raw x coordinate of the initial touch
+         * @param origY the raw y coordinate of the initial touch
+         */
+
+        public boolean interceptMoveLeft(float origX, float origY);
+
+        /**
+         * Return {@code true} if the component needs to receive left-to-right
+         * touch movements.
+         *
+         * @param origX the raw x coordinate of the initial touch
+         * @param origY the raw y coordinate of the initial touch
+         */
+        public boolean interceptMoveRight(float origX, float origY);
+    }
+
+    private final static String STATE_INTENT_KEY =
+            "com.android.mail.photo.fragments.PhotoViewFragment.INTENT";
+    private final static String STATE_FRAGMENT_ID_KEY =
+            "com.android.mail.photo.fragments.PhotoViewFragment.FRAGMENT_ID";
+    private final static String STATE_FORCE_LOAD_KEY =
+            "com.android.mail.photo.fragments.PhotoViewFragment.FORCE_LOAD";
+    private final static String STATE_DOWNLOADABLE_KEY =
+            "com.android.mail.photo.fragments.PhotoViewFragment.DOWNLOADABLE";
+
+    private final static String TAG = "PhotoViewFragment";
+
+    /** An invalid ID */
+    private final static long INVALID_ID = 0L;
+
+    // Loader IDs
+    private final static int LOADER_ID_PHOTO = R.id.photo_view_photo_loader_id;
+
+    /** The size of the photo */
+    private static Integer sPhotoSize;
+
+    /** The ID of this photo */
+    private long mPhotoId;
+    /** The gaia ID of the photo owner */
+    private String mOwnerId;
+    /** The URL of a photo to display */
+    private String mPhotoUrl;
+    /** Name of the photo */
+    private String mDisplayName;
+    /** Album name used if the photo doesn't have one. See b/5678229. */
+    private String mDefaultAlbumName;
+    /** Whether or not this photo can be downloaded */
+    private Boolean mDownloadable;
+    /** The intent we were launched with */
+    private Intent mIntent;
+    private PhotoViewCallbacks mCallback;
+    private ProgressBar mProgressBarView;
+    /** If {@code true}, we will load photo data from the network instead of the database */
+    private Long mForceLoadId;
+    /** The ID of this fragment. {@code -1} is a special value meaning no ID. */
+    private int mFragmentId = -1;
+    private MultiChoiceActionModeStub mActionMode;
+    /** Whether or not the photo is a place holder */
+    private boolean mIsPlaceHolder = true;
+
+    private PhotoLayout mPhotoLayout;
+    private PhotoView mPhotoView;
+
+    /** The height of the action bar; may be {@code 0} if there is no action bar available */
+    private int mActionBarHeight;
+    /** When {@code true}, don't use a spacer */
+    private boolean mDisableSpacer = Build.VERSION.SDK_INT < 11;
+    /** Whether or not the fragment should make the photo full-screen */
+    private boolean mFullScreen;
+
+    public PhotoViewFragment() {
+    }
+
+    public PhotoViewFragment(Intent intent, int fragmentId) {
+        this();
+        mIntent = intent;
+        mFragmentId = fragmentId;
+    }
+
+    @Override
+    public void onAttach(Activity activity) {
+        super.onAttach(activity);
+        if (activity instanceof PhotoViewCallbacks) {
+            mCallback = (PhotoViewCallbacks) activity;
+        } else {
+            throw new IllegalArgumentException("Activity must implement PhotoViewCallbacks");
+        }
+
+        if (sPhotoSize == null) {
+            final DisplayMetrics metrics = new DisplayMetrics();
+            final WindowManager wm =
+                    (WindowManager) getActivity().getSystemService(Context.WINDOW_SERVICE);
+            final ImageUtils.ImageSize imageSize = ImageUtils.sUseImageSize;
+            wm.getDefaultDisplay().getMetrics(metrics);
+            switch (imageSize) {
+                case EXTRA_SMALL: {
+                    // Use a photo that's 80% of the "small" size
+                    sPhotoSize = (Math.min(metrics.heightPixels, metrics.widthPixels) * 800) / 1000;
+                    break;
+                }
+
+                case SMALL:
+                case NORMAL:
+                default: {
+                    sPhotoSize = Math.min(metrics.heightPixels, metrics.widthPixels);
+                    break;
+                }
+            }
+        }
+    }
+
+    @Override
+    public void onDetach() {
+        mCallback = null;
+        super.onDetach();
+    }
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        if (savedInstanceState != null) {
+            mIntent = new Intent().putExtras(savedInstanceState.getBundle(STATE_INTENT_KEY));
+            mFragmentId = savedInstanceState.getInt(STATE_FRAGMENT_ID_KEY);
+            if (savedInstanceState.containsKey(STATE_FORCE_LOAD_KEY)) {
+                mForceLoadId = savedInstanceState.getLong(STATE_FORCE_LOAD_KEY);
+            }
+            if (savedInstanceState.containsKey(STATE_DOWNLOADABLE_KEY)) {
+                mDownloadable = savedInstanceState.getBoolean(STATE_DOWNLOADABLE_KEY);
+            }
+        } else {
+            if (mIntent.hasExtra(Intents.EXTRA_REFRESH)) {
+                mForceLoadId = mIntent.getLongExtra(Intents.EXTRA_REFRESH, 0L);
+            }
+        }
+
+        mPhotoId = mIntent.getLongExtra(Intents.EXTRA_PHOTO_ID, INVALID_ID);
+        mOwnerId = mIntent.getStringExtra(Intents.EXTRA_OWNER_ID);
+        mPhotoUrl = mIntent.getStringExtra(Intents.EXTRA_PHOTO_URL);
+        mDefaultAlbumName = mIntent.getStringExtra(Intents.EXTRA_ALBUM_NAME);
+
+        setHasOptionsMenu(true);
+    }
+
+    @Override
+    public View onCreateView(LayoutInflater inflater, ViewGroup container,
+            Bundle savedInstanceState) {
+        final View view = super.onCreateView(inflater, container, savedInstanceState,
+                R.layout.photo_fragment_view);
+
+        mPhotoLayout = (PhotoLayout) view.findViewById(R.id.photo_layout);
+        mPhotoView = (PhotoView) view.findViewById(R.id.photo_view);
+
+        mIsPlaceHolder = true;
+        mPhotoView.setPhotoLoading(true);
+
+        // Bind the photo data
+        setPhotoLayoutFixedHeight();
+
+        mPhotoView.setOnClickListener(this);
+        mPhotoView.setFullScreen(mFullScreen, false);
+//        mPhotoView.setVideoBlob(videoData);
+
+        // Don't call until we've setup the entire view
+        setViewVisibility();
+
+        return view;
+    }
+
+    @Override
+    public void onResume() {
+        mCallback.addScreenListener(this);
+        mCallback.addMenuItemListener(this);
+
+        // the forceLoad call feels like a hack
+        getLoaderManager().initLoader(LOADER_ID_PHOTO, null, this);
+
+        super.onResume();
+    }
+
+    @Override
+    public void onPause() {
+        super.onPause();
+        // Remove listeners
+        mCallback.removeScreenListener(this);
+        mCallback.removeMenuItemListener(this);
+        resetPhotoView();
+    }
+
+    @Override
+    public void onDestroyView() {
+        // Clean up views and other components
+        mProgressBarView = null;
+        mIsPlaceHolder = true;
+
+        if (mPhotoView != null) {
+            mPhotoView.clear();
+            mPhotoView = null;
+        }
+
+        if (mPhotoLayout != null) {
+            mPhotoLayout.clear();
+            mPhotoLayout = null;
+        }
+
+        super.onDestroyView();
+    }
+
+    @Override
+    public void onSaveInstanceState(Bundle outState) {
+        super.onSaveInstanceState(outState);
+
+        if (mIntent != null) {
+            outState.putParcelable(STATE_INTENT_KEY, mIntent.getExtras());
+            outState.putInt(STATE_FRAGMENT_ID_KEY, mFragmentId);
+            if (mForceLoadId != null) {
+                outState.putLong(STATE_FORCE_LOAD_KEY, mForceLoadId);
+            }
+            if (mDownloadable != null) {
+                outState.putBoolean(STATE_DOWNLOADABLE_KEY, mDownloadable);
+            }
+        }
+    }
+
+    @Override
+    public Loader<Bitmap> onCreateLoader(int id, Bundle args) {
+        if (id == LOADER_ID_PHOTO) {
+            return new PhotoBitmapLoader(getActivity(), mPhotoUrl);
+        } else {
+            return null;
+        }
+    }
+
+    @Override
+    public void onLoadFinished(Loader<Bitmap> loader, Bitmap data) {
+        // If we don't have a view, the fragment has been paused. We'll get the cursor again later.
+        if (getView() == null) {
+            return;
+        }
+
+        final int id = loader.getId();
+        if (id == LOADER_ID_PHOTO) {
+            if (data == null) {
+                Toast.makeText(getActivity(), R.string.photo_view_load_error, Toast.LENGTH_SHORT)
+                        .show();
+                return;
+            }
+            final View view = getView();
+            if (view != null) {
+                bindPhoto(data);
+                updateView(view);
+            }
+            mForceLoadId = null;
+            //mAdapter.swapCursor(data);
+            mIsPlaceHolder = false;
+            if (Build.VERSION.SDK_INT >= 11 && mActionMode != null) {
+                // Invalidate the action mode menu
+                mActionMode.invalidate();
+            }
+            updateMenuItems();
+            setViewVisibility();
+        }
+    }
+
+    /**
+     * Binds an image to the photo view.
+     */
+    private void bindPhoto(Bitmap bitmap) {
+        if (mPhotoView != null) {
+            mPhotoView.setPhotoLoading(false);
+            mPhotoView.bindPhoto(bitmap);
+        }
+    }
+
+    /**
+     * Resets the photo view to it's default state w/ no bound photo.
+     */
+    private void resetPhotoView() {
+        if (mPhotoView != null) {
+            mPhotoView.setPhotoLoading(true);
+            mPhotoView.bindPhoto(null);
+        }
+    }
+
+    @Override
+    public void onLoaderReset(Loader<Bitmap> loader) {
+        // Do nothing
+    }
+
+    @Override
+    public void onClick(View v) {
+        switch (v.getId()) {
+            default: {
+                if (!isPhotoBound()) {
+                    // If there is no photo, don't allow any actions except to exit
+                    // full-screen mode. We want to let the user view comments, etc...
+                    if (mCallback.isFragmentFullScreen(this)) {
+                        mCallback.toggleFullScreen();
+                    }
+                    break;
+                }
+
+                // TODO: enable video
+                if (isVideo() && mCallback.isFragmentFullScreen(this)) {
+                    if (isVideoReady()) {
+//                        final Intent startIntent = Intents.getVideoViewActivityIntent(getActivity(),
+//                                mAccount, mOwnerId, mPhotoId, mAdapter.getVideoData());
+//                        startActivity(startIntent);
+                    } else {
+                        final String toastText = getString(R.string.photo_view_video_not_ready);
+                        Toast.makeText(getActivity(), toastText, Toast.LENGTH_LONG).show();
+                    }
+                } else {
+                    mCallback.toggleFullScreen();
+                }
+                break;
+            }
+        }
+    }
+
+    @Override
+    public void onFullScreenChanged(boolean fullScreen, boolean animate) {
+        setViewVisibility();
+    }
+
+    @Override
+    public void onViewActivated() {
+        if (!mCallback.isFragmentActive(this)) {
+            // we're not in the foreground; reset our view
+            resetViews();
+        } else {
+            mCallback.onFragmentVisible(this);
+            // The action bar will already be updated for HC and later and updating them
+            // here will corrupt the display.
+            if (Build.VERSION.SDK_INT < 11) {
+                updateMenuItems();
+            }
+        }
+    }
+
+    /**
+     * Reset the views to their default states
+     */
+    public void resetViews() {
+        if (mPhotoView != null) {
+            mPhotoView.resetTransformations();
+        }
+    }
+
+    @Override
+    public boolean onInterceptMoveLeft(float origX, float origY) {
+        if (!mCallback.isFragmentActive(this)) {
+            // we're not in the foreground; don't intercept any touches
+            return false;
+        }
+
+        return (mPhotoView != null && mPhotoView.interceptMoveLeft(origX, origY));
+    }
+
+    @Override
+    public boolean onInterceptMoveRight(float origX, float origY) {
+        if (!mCallback.isFragmentActive(this)) {
+            // we're not in the foreground; don't intercept any touches
+            return false;
+        }
+
+        return (mPhotoView != null && mPhotoView.interceptMoveRight(origX, origY));
+    }
+
+    @Override
+    public void onActionBarHeightCalculated(int actionBarHeight) {
+        final boolean heightChanged = (actionBarHeight != mActionBarHeight);
+        mActionBarHeight = actionBarHeight;
+        if (heightChanged && mActionBarHeight > 0) {
+            setPhotoLayoutFixedHeight();
+        }
+    }
+
+    private void setPhotoLayoutFixedHeight() {
+        if (mPhotoLayout != null) {
+            ViewParent viewParent = mPhotoLayout.getParent();
+            if (viewParent instanceof View) {
+                mPhotoLayout.setFixedHeight(
+                        ((View) mPhotoLayout.getParent()).getMeasuredHeight() -
+                        (mDisableSpacer ? 0 : mActionBarHeight));
+            }
+        }
+    }
+
+    @Override
+    protected boolean isEmpty() {
+        final View view = getView();
+        final boolean isViewAvailable =
+                (view != null && (view.findViewById(android.R.id.empty) != null));
+
+        return isViewAvailable && !isPhotoBound();
+    }
+
+    /**
+     * Returns {@code true} if a photo has been bound. Otherwise, returns {@code false}.
+     */
+    public boolean isPhotoBound() {
+        return (mPhotoView != null && mPhotoView.isPhotoBound());
+    }
+
+    /**
+     * Returns {@code true} if a photo is loading. Otherwise, returns {@code false}.
+     */
+    public boolean isPhotoLoading() {
+        return (mPhotoView != null && mPhotoView.isPhotoLoading());
+    }
+
+    /**
+     * Returns {@code true} if the photo represents a video. Otherwise, returns {@code false}.
+     */
+    public boolean isVideo() {
+        return (mPhotoView != null && mPhotoView.isVideo());
+    }
+
+    /**
+     * Returns {@code true} if the video is ready to play. Otherwise, returns {@code false}.
+     */
+    public boolean isVideoReady() {
+        return (mPhotoView != null && mPhotoView.isVideoReady());
+    }
+
+    /**
+     * Returns video data for the photo. Otherwise, {@code null} if the photo is not a video.
+     */
+    public byte[] getVideoData() {
+        return (mPhotoView == null ? null : mPhotoView.getVideoData());
+    }
+
+    /**
+     * Returns {@code true} if the user is allowed to download the photo.
+     * Otherwise, {@code false}.
+     */
+    private boolean canDownload() {
+        return mDownloadable != null && mDownloadable;
+    }
+
+    /**
+     * Sets the progress bar.
+     */
+    @Override
+    public void onUpdateProgressView(ProgressBar progressBarView) {
+        mProgressBarView = progressBarView;
+        updateSpinner(mProgressBarView);
+
+        final View myView = getView();
+        if (myView != null) {
+            updateView(myView);
+        }
+    }
+
+    @Override
+    public boolean onPrepareTitlebarButtons(Menu menu) {
+        if (!mCallback.isFragmentActive(this)) {
+            return false;
+        }
+
+//        final Uri photoUri = (mPhotoUrl != null) ? Uri.parse(mPhotoUrl) : null;
+//        final boolean isRemotePhoto =
+//                (mPhotoId != INVALID_ID) && !MediaStoreUtils.isMediaStoreUri(photoUri);
+//        final boolean myPhoto = //(mAccount.isMyGaiaId(mOwnerId)) ||
+//                (mOwnerId == null && MediaStoreUtils.isMediaStoreUri(photoUri));
+//        final boolean onlyPhotoUrl = (mPhotoId == INVALID_ID) && photoUri != null;
+//        final boolean allowDownload = onlyPhotoUrl || (isRemotePhoto && (myPhoto || canDownload()));
+
+//        if (hasPlusOned()) {
+//            setVisible(menu, R.id.remove_plus1, true);
+//            setVisible(menu, R.id.plus1, false);
+//        } else {
+//            setVisible(menu, R.id.remove_plus1, false);
+//            setVisible(menu, R.id.plus1, mAllowPlusOne && isRemotePhoto);
+//        }
+//        setVisible(menu, R.id.download_photo, allowDownload);
+
+        return true;
+    }
+
+    @Override
+    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+        if (!mCallback.isFragmentActive(this)) {
+            return;
+        }
+
+        // some menu stuff in the action bar
+//        inflater.inflate(R.menu.photo_view_menu, menu);
+//        if (Build.VERSION.SDK_INT >= 11) {
+//            // On SDK < 11, we cannot set the progress bar view here; the menu is only inflated
+//            // after the user presses the menu button. Since we want to be able to show the
+//            // progress bar at any time, we create it manually in onCreate().
+//            final View barLayout =
+//                    menu.findItem(R.id.action_bar_progress_spinner).getActionView();
+//            final ProgressBar progressBarView =
+//                    (ProgressBar) barLayout.findViewById(R.id.action_bar_progress_spinner_view);
+//            onUpdateProgressView(progressBarView);
+//        }
+    }
+
+    @Override
+    public void onPrepareOptionsMenu(Menu menu) {
+        if (!mCallback.isFragmentActive(this)) {
+            return;
+        }
+
+//        final Long shapeId = (mAdapter == null)
+//                ? null : mAdapter.getMyApprovedShapeId();
+//        final boolean taggedAsMe = (shapeId != null);
+//        final Uri photoUri = (mPhotoUrl != null) ? Uri.parse(mPhotoUrl) : null;
+//        final boolean isRemotePhoto =
+//                (mPhotoId != INVALID_ID) && !MediaStoreUtils.isMediaStoreUri(photoUri);
+//        final boolean onlyPhotoUrl = (mPhotoId == INVALID_ID) && photoUri != null;
+//        final boolean myPhoto = (mAccount.isMyGaiaId(mOwnerId)) ||
+//                (mOwnerId == null && MediaStoreUtils.isMediaStoreUri(photoUri));
+//        final String photoStream = mIntent.getStringExtra(Intents.EXTRA_STREAM_ID);
+//        final boolean isInstantUpload = ApiUtils.CAMERA_SYNC_STREAM_ID.equals(photoStream);
+//        final boolean allowDownload = onlyPhotoUrl || (isRemotePhoto && (myPhoto || canDownload()));
+//
+//        if (Build.VERSION.SDK_INT < 11) {
+//            setVisible(menu, R.id.remove_plus1, false);
+//            setVisible(menu, R.id.plus1, false);
+//        } else if (hasPlusOned()) {
+//            setVisible(menu, R.id.remove_plus1, true);
+//            setVisible(menu, R.id.plus1, false);
+//        } else {
+//            setVisible(menu, R.id.remove_plus1, false);
+//            setVisible(menu, R.id.plus1, mAllowPlusOne && isRemotePhoto);
+//        }
+//
+//        // For now, only allow sharing of a photo in the "Instant Upload" album
+//        setVisible(menu, R.id.share_photo, isInstantUpload);
+//
+//        // Only allow deletion of your own photos & reporting of other's photos
+//        setVisible(menu, R.id.set_profile_photo, myPhoto || taggedAsMe);
+//        setVisible(menu, R.id.set_wallpaper_photo, myPhoto);
+//        setVisible(menu, R.id.delete_photo, myPhoto);
+//        setVisible(menu, R.id.download_photo, allowDownload);
+//        setVisible(menu, R.id.report_photo, !myPhoto && isRemotePhoto);
+//        setVisible(menu, R.id.refresh_photo, isRemotePhoto);
+//        setVisible(menu, R.id.remove_tag, taggedAsMe);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        if (!mCallback.isFragmentActive(this)) {
+            return false;
+        }
+
+        final Activity activity = getActivity();
+
+        switch (item.getItemId()) {
+            case android.R.id.home: {
+                ((BaseFragmentActivity) activity).onTitlebarLabelClick();
+                return true;
+            }
+        }
+
+        return false;
+//            case R.id.plus1: {
+//                if (canTogglePlusOne()) {
+//                    EsService.photoPlusOne(getActivity(), mAccount, mOwnerId, mAlbumId, mPhotoId,
+//                            true);
+//                }
+//                return true;
+//            }
+//
+//            case R.id.remove_plus1: {
+//                if (canTogglePlusOne()) {
+//                    EsService.photoPlusOne(getActivity(), mAccount, mOwnerId, mAlbumId, mPhotoId,
+//                            false);
+//                }
+//                return true;
+//            }
+//
+//            case R.id.share_photo: {
+//                final Uri photoUri = (mPhotoUrl != null) ? Uri.parse(mPhotoUrl) : null;
+//                final Uri localUri = MediaStoreUtils.isMediaStoreUri(photoUri) ? photoUri : null;
+//                final String remoteUrl = (localUri != null) ? null : mPhotoUrl;
+//                final MediaRef ref = new MediaRef(mOwnerId, mPhotoId, remoteUrl,
+//                        localUri, MediaRef.MediaType.IMAGE);
+//                final ArrayList<MediaRef> refList = new ArrayList<MediaRef>();
+//                refList.add(ref);
+//
+//                final Context context = getActivity();
+//                final Intent intent = Intents.getPostActivityIntent(context, mAccount, refList);
+//                startActivity(intent);
+//                return true;
+//            }
+//
+//            case R.id.set_profile_photo: {
+//                final Uri photoUri = (mPhotoUrl != null) ? Uri.parse(mPhotoUrl) : null;
+//                final Uri localUri = MediaStoreUtils.isMediaStoreUri(photoUri) ? photoUri : null;
+//                final String remoteUrl = (localUri != null) ? null : mPhotoUrl;
+//                final MediaRef mediaRef = new MediaRef(mOwnerId, mPhotoId, remoteUrl,
+//                        localUri, MediaRef.MediaType.IMAGE);
+//                startActivityForResult(Intents.getPhotoPickerIntent(getActivity(), mAccount,
+//                        mDisplayName, mediaRef, true, Intents.PICKER_DEST_PROFILE),
+//                        REQUEST_PHOTO_PICKER);
+//                return true;
+//            }
+//
+//            case R.id.download_photo: {
+//                downloadPhoto(activity, true);
+//                return true;
+//            }
+//
+//            case R.id.set_wallpaper_photo: {
+//                showProgressDialog(OP_SET_WALLPAPER_PHOTO,
+//                        getString(R.string.set_wallpaper_photo_pending));
+//                new AsyncTask<Void, Void, Boolean>() {
+//                    @Override
+//                    protected void onPostExecute(Boolean result) {
+//                        final Resources res = getResources();
+//                        final String toastText;
+//
+//                        if (result) {
+//                            toastText = res.getString(R.string.set_wallpaper_photo_success);
+//                        } else {
+//                            toastText = res.getString(R.string.set_wallpaper_photo_error);
+//                        }
+//                        Toast.makeText(activity, toastText, Toast.LENGTH_SHORT).show();
+//
+//                        hideProgressDialog();
+//                    }
+//
+//                    @Override
+//                    protected Boolean doInBackground(Void... params) {
+//                        try {
+//                            final Bitmap bitmap = mAdapter.getPhotoImage();
+//                            if (bitmap != null) {
+//                                final WallpaperManager manager =
+//                                        WallpaperManager.getInstance(getActivity());
+//                                manager.setBitmap(bitmap);
+//
+//                                return Boolean.TRUE;
+//                            }
+//                        } catch (IOException e) {
+//                            Log.e(TAG, "Exception setting wallpaper", e);
+//                        }
+//                        return Boolean.FALSE;
+//                    }
+//                }.execute((Void) null);
+//
+//                return true;
+//            }
+//
+//            case R.id.remove_tag: {
+//                final AlertFragmentDialog dialog = AlertFragmentDialog.newInstance(
+//                        getString(R.string.menu_remove_tag),
+//                        getString(R.string.remove_tag_question),
+//                        getString(R.string.ok),
+//                        getString(R.string.cancel));
+//                dialog.setTargetFragment(this, 0);
+//                dialog.show(getFragmentManager(), DIALOG_TAG_REMOVE_TAG);
+//                return true;
+//            }
+//
+//            case R.id.refresh_photo: {
+//                refresh();
+//                return true;
+//            }
+//
+//            case R.id.delete_photo: {
+//                final Resources res = getResources();
+//                final Uri photoUri = (mPhotoUrl != null) ? Uri.parse(mPhotoUrl) : null;
+//                final Uri localUri =
+//                        MediaStoreUtils.isMediaStoreUri(photoUri) ? photoUri : null;
+//                final int messageId = localUri == null
+//                        ? R.plurals.delete_remote_photo_dialog_message
+//                        : R.plurals.delete_local_photo_dialog_message;
+//                final AlertFragmentDialog dialog = AlertFragmentDialog.newInstance(
+//                        res.getQuantityString(R.plurals.delete_photo_dialog_title, 1),
+//                        res.getQuantityString(messageId, 1),
+//                        res.getQuantityString(R.plurals.delete_photo, 1),
+//                        getString(R.string.cancel));
+//                dialog.setTargetFragment(this, 0);
+//                dialog.show(getFragmentManager(), DIALOG_TAG_REMOVE_PHOTO);
+//                return true;
+//            }
+//
+//            case R.id.report_photo: {
+//                final AlertFragmentDialog dialog = AlertFragmentDialog.newInstance(
+//                        getString(R.string.menu_report_photo),
+//                        getString(R.string.report_photo_question),
+//                        getString(R.string.ok),
+//                        getString(R.string.cancel));
+//                dialog.setTargetFragment(this, 0);
+//                dialog.show(getFragmentManager(), DIALOG_TAG_REPORT_PHOTO);
+//                return true;
+//            }
+//
+//            case R.id.settings: {
+//                final Intent intent = Intents.getSettingsActivityIntent(activity, mAccount);
+//                startActivity(intent);
+//                return true;
+//            }
+//
+//            case R.id.feedback: {
+//                recordUserAction(Logging.Targets.Action.SETTINGS_FEEDBACK);
+//                GoogleFeedback.launch(getActivity());
+//                return true;
+//            }
+//
+//            case R.id.help:
+//                startExternalActivity(new Intent(Intent.ACTION_VIEW,
+//                        HelpUrl.getHelpUrl(activity, HELP_LINK_PARAMETER)));
+//                return true;
+//
+//            default: {
+//                return false;
+//            }
+//        }
+    }
+
+    /**
+     * Download the currently showing photo.
+     *
+     * @param context The context
+     * @param fullRes If {@code true}, download the photo at max resolution. Otherwise, download
+     *          the photo no larger than {@link DownloadPhotoTask#MAX_DOWNLOAD_SIZE}.
+     */
+    public void downloadPhoto(Context context, boolean fullRes) {
+//        if (mAdapter == null) {
+//            return;
+//        }
+//
+//        final MediaRef mediaRef = mAdapter.getPhotoRef();
+//        final String albumName = mAdapter.getAlbumName();
+//
+//        final String imageUrl;
+//        if (mPhotoId == INVALID_ID) {
+//            imageUrl = mPhotoUrl;
+//        } else {
+//            imageUrl = (mediaRef == null) ? null : mediaRef.getUrl();
+//        }
+//
+//        // Modify the image URL to adjust the size parameters. If this is the first attempt,
+//        // try to download the full image. If this is not the first attempt, cap the image
+//        // size to {@link #MAX_DOWNLOAD_SIZE}.
+//        final String downloadUrl;
+//        if (FIFEUtil.isFifeHostedUrl(imageUrl)) {
+//            if (fullRes) {
+//                downloadUrl = FIFEUtil.setImageUrlOptions("d", imageUrl).toString();
+//            } else {
+//                downloadUrl = FIFEUtil.setImageUrlSize(REDUCED_DOWNLOAD_SIZE, imageUrl, false);
+//            }
+//        } else {
+//            downloadUrl = ImageProxyUtil.setImageUrlSize(
+//                    fullRes ? ImageProxyUtil.ORIGINAL_SIZE : REDUCED_DOWNLOAD_SIZE, imageUrl);
+//        }
+//
+//        if (downloadUrl != null) {
+//            if (EsLog.isLoggable(TAG, Log.DEBUG)) {
+//                Log.d(TAG, "Downloading image from: " + downloadUrl);
+//            }
+//
+//            mNewerReqId = EsService.savePhoto(context, mAccount, downloadUrl, fullRes, albumName);
+//            showProgressDialog(OP_DOWNLOAD_PHOTO, getString(R.string.download_photo_pending));
+//        } else {
+//            final String toastText = getResources().getString(R.string.download_photo_error);
+//            Toast.makeText(context, toastText, Toast.LENGTH_LONG).show();
+//        }
+    }
+
+    /**
+     * Adds the given file to the system. This makes it available through the Media Store
+     * and, optionally, the Downloads application.
+     *
+     * @param context The context
+     * @param file The file to add.
+     * @param description A description of the photo.
+     * @param mimeType The type of the image file.
+     */
+    private void addDownloadToSystem(Context context, File file,
+            String description, String mimeType) {
+        if (Build.VERSION.SDK_INT >= 12) {
+            // Can't add a file to the Downloads application until SDK v12
+            try {
+                final DownloadManager dm =
+                        (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
+                dm.addCompletedDownload(file.getName(), description, true, mimeType,
+                        file.getAbsolutePath(), file.length(), false);
+            } catch (IllegalArgumentException e) {
+                Log.w(TAG, "Could not add photo to the Downloads application", e);
+            }
+        }
+
+        Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
+        intent.setData(Uri.parse(file.toURI().toString()));
+        context.sendBroadcast(intent);
+    }
+
+    /**
+     * Sets view visibility depending upon whether or not we're in "full screen" mode.
+     *
+     * @param animate If {@code true}, animate views in/out. Otherwise, snap views.
+     */
+    private void setViewVisibility() {
+        final boolean fullScreen = mCallback.isFragmentFullScreen(this);
+        final boolean hide = fullScreen;
+
+        setFullScreen(hide);
+    }
+
+    /**
+     * Sets full-screen mode for the views.
+     */
+    public void setFullScreen(boolean fullScreen) {
+        mFullScreen = fullScreen;
+        mPhotoView.enableImageTransforms(mFullScreen);
+    }
+
+    @Override
+    public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) {
+    }
+
+    @Override
+    public boolean onContextItemSelected(MenuItem item) {
+        if (!mCallback.isFragmentActive(this)) {
+            return false;
+        }
+
+        AdapterView.AdapterContextMenuInfo info;
+        try {
+            info = (AdapterView.AdapterContextMenuInfo) item.getMenuInfo();
+        } catch (ClassCastException e) {
+            return false;
+        }
+
+        // Ignore the header view long click
+        if (info.position == 0) {
+            return false;
+        }
+
+        return false;
+    }
+
+    /**
+     * Helper function to set the visibility of a given menu item.
+     */
+    private void setVisible(Menu menu, int menuItemId, boolean visible) {
+        MenuItem item;
+        item = menu.findItem(menuItemId);
+        if (item != null) {
+            item.setVisible(visible);
+        }
+    }
+
+    /** Updates the menu items */
+    private void updateMenuItems() {
+        if (mCallback != null) {
+            mCallback.updateMenuItems();
+        }
+    }
+
+    /**
+     * Updates the view to show the correct content. If the view is null or does not contain
+     * the special id {@link android.R.id#empty}, performs no action.
+     */
+    private void updateView(View view) {
+        if (view == null || (view.findViewById(android.R.id.empty) == null)) {
+            return;
+        }
+
+        final boolean hasImage = isPhotoBound();
+        final boolean imageLoading = isPhotoLoading();
+
+        if (imageLoading) {
+            showEmptyViewProgress(view);
+        } else {
+            if (hasImage) {
+                showContent(view);
+            } else if (mIsPlaceHolder) {
+                setupEmptyView(view, R.string.photo_view_placeholder_image);
+                showEmptyView(view);
+            } else {
+                setupEmptyView(view, R.string.photo_network_error);
+                showEmptyView(view);
+            }
+        }
+        updateSpinner(mProgressBarView);
+    }
+}
diff --git a/src/com/android/mail/photo/loaders/BaseCursorLoader.java b/src/com/android/mail/photo/loaders/BaseCursorLoader.java
new file mode 100644
index 0000000..46572f7
--- /dev/null
+++ b/src/com/android/mail/photo/loaders/BaseCursorLoader.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.loaders;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.support.v4.content.CursorLoader;
+import android.util.Log;
+
+/**
+ * Cursor loader that automatically registers for notification on a URI.
+ */
+public class BaseCursorLoader extends CursorLoader {
+    /** Whether or not a content observer has been registered */
+    private boolean mObserverRegistered;
+    /** Observer that force loads the cursor if the observed uri is notified */
+    private final ForceLoadContentObserver mObserver = new ForceLoadContentObserver();
+    /** The observed uri */
+    private final Uri mNotificationUri;
+    /** If {@code true}, this loader received an exception and it can no longer be used */
+    private boolean mLoaderException;
+
+    /**
+     * @see CursorLoader#CursorLoader(Context)
+     */
+    public BaseCursorLoader(Context context) {
+        this(context, null);
+    }
+
+    /**
+     * @see CursorLoader#CursorLoader(Context)
+     */
+    public BaseCursorLoader(Context context, Uri notificationUri) {
+        super(context);
+        mNotificationUri = notificationUri;
+    }
+
+    /**
+     * @see CursorLoader#CursorLoader(Context, Uri, String[], String, String[], String)
+     */
+    public BaseCursorLoader(Context context, Uri uri, String[] projection, String selection,
+            String[] selectionArgs, String sortOrder) {
+        this(context, uri, projection, selection, selectionArgs, sortOrder, null);
+    }
+
+   /**
+    * @see CursorLoader#CursorLoader(Context, Uri, String[], String, String[], String)
+    */
+    public BaseCursorLoader(Context context, Uri uri, String[] projection, String selection,
+            String[] selectionArgs, String sortOrder, Uri notificationUri) {
+        super(context, uri, projection, selection, selectionArgs, sortOrder);
+        mNotificationUri = notificationUri;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    protected void onStartLoading() {
+        super.onStartLoading();
+        if (!mObserverRegistered && mNotificationUri != null) {
+            getContext().getContentResolver().registerContentObserver(mNotificationUri,
+                    false, mObserver);
+            mObserverRegistered = true;
+        }
+    }
+
+    /**
+     * Overriding the default behavior of CursorLoader, which currently leads to
+     * skipping data loads.  See http://b/6028807
+     */
+    @Override
+    protected void onStopLoading() {
+    }
+
+    /**
+     * Loads data in a background thread.
+     *
+     * @see CursorLoader#loadInBackground()
+     */
+    public Cursor esLoadInBackground() {
+        return super.loadInBackground();
+    }
+
+    /**
+     * Override {@link #esLoadInBackground()} instead.
+     *
+     * {@inheritDoc}
+     */
+    @Override
+    public final Cursor loadInBackground() {
+        Cursor cursor;
+        try {
+            cursor = esLoadInBackground();
+        } catch (Throwable ex) {
+            Log.w("EsCursorLoader", "loadInBackground failed", ex);
+            mLoaderException = true;
+            cursor = null;
+        }
+
+        return cursor;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void deliverResult(Cursor cursor) {
+        // Only deliver results if the loader is active
+        if (!mLoaderException) {
+            super.deliverResult(cursor);
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    protected void onAbandon() {
+        if (mObserverRegistered) {
+            getContext().getContentResolver().unregisterContentObserver(mObserver);
+            mObserverRegistered = false;
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    protected void onReset() {
+        cancelLoad();
+        super.onReset();
+        onAbandon();
+    }
+}
diff --git a/src/com/android/mail/photo/loaders/PhotoBitmapLoader.java b/src/com/android/mail/photo/loaders/PhotoBitmapLoader.java
new file mode 100644
index 0000000..a126d8f
--- /dev/null
+++ b/src/com/android/mail/photo/loaders/PhotoBitmapLoader.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.loaders;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.support.v4.content.AsyncTaskLoader;
+import android.util.Log;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Loader for the bitmap of a photo.
+ */
+public class PhotoBitmapLoader extends AsyncTaskLoader<Bitmap> {
+    private final String mPhotoUrl;
+
+    private Bitmap mBitmap;
+
+    public PhotoBitmapLoader(Context context, String photoUrl) {
+        super(context);
+        mPhotoUrl = photoUrl;
+    }
+
+    @Override
+    public Bitmap loadInBackground() {
+        Context context = getContext();
+
+        Bitmap bitmap = null;
+        InputStream stream = null;
+
+        try {
+            if (context != null) {
+                stream = context.getAssets().open(mPhotoUrl);
+
+                bitmap = BitmapFactory.decodeStream(stream);
+            }
+        } catch (final IOException e) {
+            Log.d("PhotoBitmapLoader", "Caught IOException", e);
+        } finally {
+            if (stream != null) {
+                try {
+                    stream.close();
+                } catch (IOException e) {
+                    // ignore
+                }
+            }
+        }
+
+        return bitmap;
+    }
+
+    /**
+     * Called when there is new data to deliver to the client.  The
+     * super class will take care of delivering it; the implementation
+     * here just adds a little more logic.
+     */
+    @Override
+    public void deliverResult(Bitmap bitmap) {
+        if (isReset()) {
+            // An async query came in while the loader is stopped.  We
+            // don't need the result.
+            if (bitmap != null) {
+                onReleaseResources(bitmap);
+            }
+        }
+        Bitmap oldBitmap = mBitmap;
+        mBitmap = bitmap;
+
+        if (isStarted()) {
+            // If the Loader is currently started, we can immediately
+            // deliver its results.
+            super.deliverResult(bitmap);
+        }
+
+        // At this point we can release the resources associated with
+        // 'oldBitmap' if needed; now that the new result is delivered we
+        // know that it is no longer in use.
+        if (oldBitmap != null && oldBitmap != bitmap && !oldBitmap.isRecycled()) {
+            onReleaseResources(oldBitmap);
+        }
+    }
+
+    /**
+     * Handles a request to start the Loader.
+     */
+    @Override
+    protected void onStartLoading() {
+        if (mBitmap != null) {
+            // If we currently have a result available, deliver it
+            // immediately.
+            deliverResult(mBitmap);
+        }
+
+        if (takeContentChanged() || mBitmap == null) {
+            // If the data has changed since the last time it was loaded
+            // or is not currently available, start a load.
+            forceLoad();
+        }
+    }
+
+    /**
+     * Handles a request to stop the Loader.
+     */
+    @Override protected void onStopLoading() {
+        // Attempt to cancel the current load task if possible.
+        cancelLoad();
+    }
+
+    /**
+     * Handles a request to cancel a load.
+     */
+    @Override
+    public void onCanceled(Bitmap bitmap) {
+        super.onCanceled(bitmap);
+
+        // At this point we can release the resources associated with 'bitmap'
+        // if needed.
+        onReleaseResources(bitmap);
+    }
+
+    /**
+     * Handles a request to completely reset the Loader.
+     */
+    @Override
+    protected void onReset() {
+        super.onReset();
+
+        // Ensure the loader is stopped
+        onStopLoading();
+
+        // At this point we can release the resources associated with 'bitmap'
+        // if needed.
+        if (mBitmap != null) {
+            onReleaseResources(mBitmap);
+            mBitmap = null;
+        }
+    }
+
+    /**
+     * Helper function to take care of releasing resources associated
+     * with an actively loaded data set.
+     */
+    protected void onReleaseResources(Bitmap bitmap) {
+        if (bitmap != null && !bitmap.isRecycled()) {
+            bitmap.recycle();
+        }
+    }
+}
diff --git a/src/com/android/mail/photo/loaders/PhotoCursorLoader.java b/src/com/android/mail/photo/loaders/PhotoCursorLoader.java
new file mode 100644
index 0000000..e19a490
--- /dev/null
+++ b/src/com/android/mail/photo/loaders/PhotoCursorLoader.java
@@ -0,0 +1,309 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.loaders;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.util.Log;
+
+import com.android.mail.photo.Pageable;
+
+/**
+ * Loader for all types of photo lists. This will load a set of photos for an album id,
+ * of a specific user, for a circle id or a stream id. See {@link AlbumViewFragment}
+ * for the algorithm that determines which type of album data will be retrieved.
+ */
+public abstract class PhotoCursorLoader extends BaseCursorLoader implements Pageable {
+    private final static String TAG = "PhotoCursorLoader";
+
+    /** Load an unlimited number of rows */
+    public static final int LOAD_LIMIT_UNLIMITED = -1;
+
+    private static final String DEFAULT_SORT_ORDER = "";
+
+    /** Whether or not a content observer has been registered */
+    private boolean mObserverRegistered;
+    /** Observer that force loads the cursor if the observed uri is notified */
+    private final ForceLoadContentObserver mObserver = new ForceLoadContentObserver();
+    /** Whether or not this cursor is a paging cursor */
+    private final boolean mPaging;
+    /** The initial number of pages to load */
+    private final int mInitialPageCount;
+
+    /** The total number of rows to load */
+    private int mLoadLimit = CURSOR_PAGE_SIZE;
+    /** Whether or not there are more rows to load */
+    private boolean mHasMore;
+    /** Whether or not rows are in the process of loading */
+    private boolean mIsLoadingMore;
+    /** Whether or not the cursor is pageable */
+    private boolean mPageable;
+    
+    private final Uri mPhotosUri;
+
+    public PhotoCursorLoader(Context context, Uri photosUri,
+            boolean paging, int initialPageCount) {
+        super(context, getNotificationUri());
+        mPaging = paging;
+        mPageable = paging;
+        mInitialPageCount = initialPageCount;
+        mLoadLimit = (mPageable && initialPageCount != LOAD_LIMIT_UNLIMITED)
+                ? initialPageCount * CURSOR_PAGE_SIZE : LOAD_LIMIT_UNLIMITED;
+        mPhotosUri = photosUri;
+    }
+
+    @Override
+    public Cursor esLoadInBackground() {
+        if (getUri() == null) {
+            Log.w(TAG, "load NULL URI; return empty cursor");
+            return new MatrixCursor(getProjection());
+        }
+
+        final int loadLimit = mLoadLimit;
+        final boolean changeSortOrder = mPageable && mLoadLimit != LOAD_LIMIT_UNLIMITED;
+        final String origSortOrder = getSortOrder();
+
+        // Make sure we're sorting photos correctly
+        if (origSortOrder == null) {
+            setSortOrder(getDefaultSortOrder());
+        }
+
+        // Optionally change the sort order to add a LIMIT / OFFSET to the query
+        if (changeSortOrder) {
+            final String sortOrder = getSortOrder();
+
+            // TODO(toddke) Make the limits parameters to enable query caching
+            setSortOrder((sortOrder != null ? sortOrder : "") + " LIMIT 0, " + loadLimit);
+        }
+
+        Cursor returnCursor = super.esLoadInBackground();
+
+        // commenting out the network request stuff
+//        int cursorCount = (returnCursor != null) ? returnCursor.getCount() : 0;
+//        boolean cursorFull = cursorCount == loadLimit;
+//        mHasMore = mPageable && (cursorFull /*|| isLoadingCirclePhotos()*/);
+//        mIsLoadingMore = (loadLimit != mLoadLimit);
+//
+//        // Either the database is empty or we only have a partial response; load more
+//        if (cursorCount == 0 || (!cursorFull && mHasMore)) {
+//            returnCursor.close();
+//            returnCursor = null;
+//        }
+//
+//        // If we don't have data to return, make network fetch and re-query
+//        if (returnCursor == null) {
+//            // adjust the loading offset
+//            mCircleOffset = cursorCount;
+//
+//            // issue network fetch
+//            doNetworkRequest();
+//
+//            // re-run the query
+//            returnCursor = super.esLoadInBackground();
+//
+//            cursorCount = (returnCursor != null) ? returnCursor.getCount() : 0;
+//            cursorFull = cursorCount == loadLimit;
+//            // If we didn't download anything new, disable paging
+//            mPageable = cursorCount != mCircleOffset;
+//            mHasMore = mPageable && (cursorFull /*|| isLoadingCirclePhotos()*/);
+//        }
+//
+//        // If we changed the sort order of the query, revert it
+//        if (changeSortOrder) {
+//            setSortOrder(origSortOrder);
+//        }
+
+        return returnCursor;
+    }
+
+    @Override
+    public void loadMore() {
+        if (mPageable && mHasMore) {
+            mLoadLimit += CURSOR_PAGE_SIZE;
+            mIsLoadingMore = true;
+            onContentChanged();
+        }
+    }
+
+    @Override
+    public boolean hasMore() {
+        return mPageable && mHasMore;
+    }
+
+    @Override
+    protected void onStartLoading() {
+        if (!mObserverRegistered) {
+            getContext().getContentResolver().registerContentObserver(mPhotosUri,
+                    false, mObserver);
+            mObserverRegistered = true;
+        }
+        super.onStartLoading();
+    }
+
+    @Override
+    protected void onAbandon() {
+        if (mObserverRegistered) {
+            getContext().getContentResolver().unregisterContentObserver(mObserver);
+            mObserverRegistered = false;
+        }
+        super.onAbandon();
+    }
+
+    /** Gets whether or not the loader is in the process of loading more data */
+    public boolean isLoadingMore() {
+        return mPageable && mIsLoadingMore;
+    }
+
+    /** Gets the current page */
+    @Override
+    public int getCurrentPage() {
+        return (mPageable && mLoadLimit != LOAD_LIMIT_UNLIMITED)
+                ? (mLoadLimit / CURSOR_PAGE_SIZE) : LOAD_LIMIT_UNLIMITED;
+    }
+
+    /** Reset paging to the default state */
+    public void resetPaging() {
+        mLoadLimit = (mPageable && mInitialPageCount != LOAD_LIMIT_UNLIMITED)
+                ? mInitialPageCount * CURSOR_PAGE_SIZE : LOAD_LIMIT_UNLIMITED;
+        mHasMore = false;
+        mPageable = mPaging;
+    }
+
+//    /**
+//     * Performs a network request. The actual request depends upon the values passed in.
+//     */
+//    private void doNetworkRequest() {
+//        if (mNetworkRequestMade /*&& !isLoadingCirclePhotos()*/) {
+//            return;
+//        }
+//        mNetworkRequestMade = true;
+//
+//        final TacoTruckOperation eso = new TacoTruckOperation(getContext(), mAccount, null, null);
+//        if (mStreamId != null && !IGNORE_STREAM_ID.equals(mStreamId)) {
+//            eso.getStreamPhotos(mOwnerGaiaId, mStreamId, 0, EsPhotosData.MAX_STREAM_PHOTOS_COUNT);
+//        } else if (mEventId != null) {
+//            // TODO(toddke) Implement network request for getting event photos
+//        } else if (mPhotoOfUserGaiaId != null) {
+//            eso.getPhotosOfUser(mPhotoOfUserGaiaId);
+//        } else if (mAlbumId != null) {
+//            eso.getAlbum(mOwnerGaiaId, mAlbumId);
+//        } else if (mActivityId != null) {
+//            eso.getActivityPhotos(mActivityId);
+//        } else {
+//            eso.getPhotoConsumptionStream(mCircleId, EsPhotosData.CIRCLE_LIST_PHOTO_COUNT,
+//                    mCircleOffset);
+//        }
+//        eso.start();
+//
+//        // No need to worry about any error condition. If the data cannot be fetched, we will
+//        // display a generic "no photos available" message.
+//    }
+
+    /**
+     * Returns a URI that can be used to load the cursor. May return {@code null} if a cursor
+     * cannot be loaded for the requested data.
+     */
+    final Uri getLoaderUri() {
+//        final Uri notificationUri = getNotificationUri(mOwnerGaiaId, mAlbumId, mCircleId,
+//                mPhotoOfUserGaiaId, mStreamId, mActivityId, mEventId, mPhotoUrl);
+//        final Uri loaderUri;
+//
+//        if (notificationUri != null) {
+//            loaderUri = EsProvider.appendAccountParameter(notificationUri, mAccount);
+//        } else {
+//            loaderUri = null;
+//        }
+//        return loaderUri;
+
+        return mPhotosUri;
+    }
+
+    /**
+     * Returns the default sort order for this loader. Can be used to extend the default ordering
+     * of the results.
+     */
+    final String getDefaultSortOrder() {
+//        if (mAlbumId != null) {
+//            return ALBUM_SORT_ORDER;
+//        } else if (mActivityId != null) {
+//            return ACTIVITY_SORT_ORDER;
+//        } else if (mEventId != null) {
+//            return EVENT_SORT_ORDER;
+//        } else if (isLoadingCirclePhotos()) {
+//            return CIRCLE_SORT_ORDER;
+//        }
+        return DEFAULT_SORT_ORDER;
+    }
+
+//    /**
+//     * Returns whether or not we're loading photos for a circle [including the consumption stream].
+//     */
+//    private boolean isLoadingCirclePhotos() {
+//        return (mStreamId == null) && (mPhotoOfUserGaiaId == null) && (mAlbumId == null) &&
+//                (mActivityId == null) && (mPhotoUrl == null);
+//    }
+
+    /**
+     * Returns a notification URI depending upon the values passed in.
+     */
+    private static Uri getNotificationUri() {
+//        final Uri notificationUri;
+//
+//        if (streamId != null && !IGNORE_STREAM_ID.equals(streamId)) {
+//            if (ownerGaiaId == null) {
+//                Log.w(TAG, "Viewing stream photos w/o a valid owner GAIA ID");
+//                notificationUri = null;
+//            } else {
+//                Uri.Builder builder = EsProvider.PHOTO_BY_STREAM_ID_AND_OWNER_ID_URI.buildUpon();
+//                notificationUri =
+//                        Uri.withAppendedPath(builder.appendPath(streamId).build(), ownerGaiaId);
+//            }
+//        } else if (eventId != null) {
+//            notificationUri =
+//                    Uri.withAppendedPath(EsProvider.PHOTO_BY_EVENT_ID_URI, eventId);
+//        } else if (photoOfUserId != null) {
+//            notificationUri =
+//                    Uri.withAppendedPath(EsProvider.PHOTO_OF_USER_ID_URI, photoOfUserId);
+//        } else if (albumId != null) {
+//            if (ownerGaiaId == null) {
+//                Log.w(TAG, "Viewing album photos w/o a valid owner GAIA ID");
+//                notificationUri = null;
+//            } else {
+//                notificationUri =
+//                        ContentUris.withAppendedId(EsProvider.PHOTO_BY_ALBUM_URI, albumId);
+//            }
+//        } else if (circleId != null) {
+//            notificationUri =
+//                    EsProvider.PHOTO_BY_CIRCLE_ID_URI.buildUpon().appendPath(circleId).build();
+//        } else if (activityId != null) {
+//            Uri.Builder builder = EsProvider.PHOTO_BY_ACTIVITY_ID_URI.buildUpon();
+//            notificationUri = builder.appendPath(activityId).build();
+//        } else if (photoUrl != null) {
+//            notificationUri = null;
+//        } else {
+//            notificationUri = EsProvider.PHOTO_BY_NULL_CIRCLE_ID_URI;
+//        }
+//
+//        return notificationUri;
+
+
+        return null;
+    }
+}
diff --git a/src/com/android/mail/photo/loaders/PhotoPagerLoader.java b/src/com/android/mail/photo/loaders/PhotoPagerLoader.java
new file mode 100644
index 0000000..5e3f7c3
--- /dev/null
+++ b/src/com/android/mail/photo/loaders/PhotoPagerLoader.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.loaders;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+
+import com.android.mail.photo.provider.PhotoContract.PhotoQuery;
+
+/**
+ * Loader for a set of photo IDs.
+ */
+public class PhotoPagerLoader extends PhotoCursorLoader {
+
+    public PhotoPagerLoader(
+            Context context, Uri photosUri, int pageHint) {
+        super(context, photosUri, pageHint != LOAD_LIMIT_UNLIMITED, pageHint);
+    }
+
+    @Override
+    public Cursor esLoadInBackground() {
+        Cursor returnCursor = null;
+
+        final Uri loaderUri = getLoaderUri();
+
+        setUri(loaderUri);
+        setProjection(PhotoQuery.PROJECTION);
+        returnCursor = super.esLoadInBackground();
+
+        return returnCursor;
+    }
+}
diff --git a/src/com/android/mail/photo/provider/PhotoContract.java b/src/com/android/mail/photo/provider/PhotoContract.java
new file mode 100644
index 0000000..69184a2
--- /dev/null
+++ b/src/com/android/mail/photo/provider/PhotoContract.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.provider;
+
+import android.provider.BaseColumns;
+
+public final class PhotoContract {
+    /** Columns for the view {@link #PHOTO_VIEW} */
+    public static interface PhotoViewColumns extends BaseColumns {
+        public static final String PHOTO_ID = "photo_id";
+        public static final String URI = "uri";
+        public static final String OWNER_ID = "owner_id";
+        public static final String TITLE = "title";
+        public static final String VIDEO_DATA = "video_data";
+        public static final String ALBUM_NAME = "album_name";
+    }
+
+    public static interface PhotoQuery {
+        /** Projection of the returned cursor */
+        public final static String[] PROJECTION = {
+            PhotoViewColumns._ID,
+            PhotoViewColumns.URI,
+            PhotoViewColumns.PHOTO_ID,
+            PhotoViewColumns.OWNER_ID,
+            PhotoViewColumns.TITLE,
+            PhotoViewColumns.VIDEO_DATA,
+            PhotoViewColumns.ALBUM_NAME,
+        };
+
+        public final static int INDEX_ID = 0;
+        public final static int INDEX_URI = 1;
+        public final static int INDEX_PHOTO_ID = 2;
+        public final static int INDEX_OWNER_ID = 3;
+        public final static int INDEX_TITLE = 4;
+        public final static int INDEX_VIDEO_DATA = 5;
+        public final static int INDEX_ALBUM_NAME = 6;
+    }
+}
diff --git a/src/com/android/mail/photo/util/FIFEUtil.java b/src/com/android/mail/photo/util/FIFEUtil.java
new file mode 100644
index 0000000..b23182f
--- /dev/null
+++ b/src/com/android/mail/photo/util/FIFEUtil.java
@@ -0,0 +1,624 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.util;
+
+import android.net.Uri;
+import android.text.TextUtils;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Useful FIFE image url manipulation routines.
+ */
+public class FIFEUtil {
+    private static final Splitter SPLIT_ON_EQUALS = Splitter.on("=").omitEmptyStrings();
+
+    private static final Splitter SPLIT_ON_SLASH = Splitter.on("/").omitEmptyStrings();
+
+    private static final Joiner JOIN_ON_SLASH = Joiner.on("/");
+
+    private static final Pattern FIFE_HOSTED_IMAGE_URL_RE = Pattern.compile("^((http(s)?):)?\\/\\/"
+            + "((((lh[3-6]\\.((ggpht)|(googleusercontent)|(google)))"
+            + "|([1-4]\\.bp\\.blogspot)|(bp[0-3]\\.blogger))\\.com)"
+            + "|(www\\.google\\.com\\/visualsearch\\/lh))\\/");
+
+    private static final String EMPTY_STRING = "";
+
+    // The ImageUrlOptions path part index for legacy Fife image URLs.
+    private static final int LEGACY_URL_PATH_OPTIONS_INDEX = 4;
+
+    // Num of path parts a legacy Fife image base URL contains. A base URL
+    // contains
+    // no ImageUrlOptions nor a filename and is terminated by a slash.
+    private static final int LEGACY_BASE_URL_NUM_PATH_PARTS = 4;
+    // Number of path parts a legacy Fife image URL contains that has both
+    // existing
+    // ImageUrlOptions and a filename.
+    private static final int LEGACY_WITH_OPTIONS_FILENAME = 5;
+
+    // Maximum number of path parts a legacy Fife image URL can contain.
+    private static final int LEGACY_URL_MAX_NUM_PATH_PARTS = 6;
+
+    // Maximum number of path parts a content Fife image URL can contain.
+    private static final int CONTENT_URL_MAX_NUM_PATH_PARTS = 1;
+
+    /**
+     * Add size options to the given url.
+     *
+     * @param size the image size
+     * @param url the url to apply the options to
+     * @param crop if {@code true}, crop the photo to the dimensions
+     * @return a {@code Uri} containting the new image url with options.
+     */
+    public static String setImageUrlSize(int size, String url, boolean crop) {
+        return setImageUrlSize(size, url, crop, false);
+    }
+
+    /**
+     * Add size options to the given url.
+     *
+     * @param size the image size
+     * @param url the url to apply the options to
+     * @param crop if {@code true}, crop the photo to the dimensions
+     * @param includeMetadata if {@code true}, the image returned by the URL will include meta data
+     * @return a {@code Uri} containting the new image url with options.
+     */
+    public static String setImageUrlSize(int size, String url, boolean crop,
+            boolean includeMetadata) {
+        if (url == null || !isFifeHostedUrl(url)) {
+            return url;
+        }
+
+        final StringBuffer options = new StringBuffer();
+        options.append("s").append(size);
+        options.append("-d");
+        if (crop) {
+            options.append("-c");
+        }
+        if (includeMetadata) {
+            options.append("-I");
+        }
+
+        final Uri uri = setImageUrlOptions(options.toString(), url);
+        final String returnUrl = makeUriString(uri);
+
+        return returnUrl;
+    }
+
+    /**
+     * Add size options to the given url.
+     *
+     * @param width the width of the image
+     * @param height the height of the image
+     * @param url the url to apply the options to
+     * @param crop if {@code true}, crop the photo to the dimensions
+     * @param includeMetadata if {@code true}, the image returned by the URL will include meta data
+     * @return a {@code Uri} containting the new image url with options.
+     */
+    public static String setImageUrlSize(int width, int height, String url, boolean crop,
+            boolean includeMetadata) {
+        if (url == null || !isFifeHostedUrl(url)) {
+            return url;
+        }
+
+        final StringBuffer options = new StringBuffer();
+        options.append("w").append(width);
+        options.append("-h").append(height);
+        options.append("-d");
+        if (crop) {
+            options.append("-c");
+        }
+        if (includeMetadata) {
+            options.append("-I");
+        }
+
+        final Uri uri = setImageUrlOptions(options.toString(), url);
+        final String returnUrl = makeUriString(uri);
+
+        return returnUrl;
+    }
+
+    /**
+     * Workaround. When encoding FIFE URL with content image options, the default
+     * implementation for Uri.toString() encodes the equals ['='] as "%3D". The
+     * FIFE servers choke on this and return a 404.
+     */
+    private static String makeUriString(Uri uri) {
+        final StringBuilder builder = new StringBuilder();
+
+        final String scheme = uri.getScheme();
+        if (scheme != null) {
+            builder.append(scheme).append(':');
+        }
+
+        final String encodedAuthority = uri.getEncodedAuthority();
+        if (encodedAuthority != null) {
+            // Even if the authority is "", we still want to append "//".
+            builder.append("//").append(encodedAuthority);
+        }
+
+        final String path = uri.getPath();
+        final String encodedPath = Uri.encode(path, "/=");
+        if (encodedPath != null) {
+            builder.append(encodedPath);
+        }
+
+        final String encodedQuery = uri.getEncodedQuery();
+        if (!TextUtils.isEmpty(encodedQuery)) {
+            builder.append('?').append(encodedQuery);
+        }
+
+        final String encodedFragment = uri.getEncodedFragment();
+        if (!TextUtils.isEmpty(encodedFragment)) {
+            builder.append('#').append(encodedFragment);
+        }
+
+        return builder.toString();
+    }
+
+    /**
+     * Add image url options to the given url.
+     *
+     * @param options the options to apply
+     * @param url the url to apply the options to
+     * @return a {@code Uri} containting the new image url with options.
+     */
+    public static Uri setImageUrlOptions(String options, String url) {
+        return setImageUriOptions(options, Uri.parse(url));
+    }
+
+    /**
+     * Add image url options to the given url.
+     *
+     * @param options the options to apply
+     * @param uri the uri to apply the options to
+     * @return a {@code Uri} containting the new image url with options.
+     */
+    public static Uri setImageUriOptions(String options, Uri uri) {
+        List<String> components = newArrayList(SPLIT_ON_SLASH.split(uri.getPath()));
+
+        // Delegate setting ImageUrlOptions based on the Fife image URL format
+        // determined by the number of path parts the URL contains.
+        int numParts = components.size();
+        if (components.size() > 1 && components.get(0).equals("image")) {
+            --numParts;
+        }
+
+        Uri modifiedUri;
+        if (numParts >= LEGACY_BASE_URL_NUM_PATH_PARTS
+                && numParts <= LEGACY_URL_MAX_NUM_PATH_PARTS) {
+            modifiedUri = setLegacyImageUrlOptions(options, uri);
+        } else if (numParts == CONTENT_URL_MAX_NUM_PATH_PARTS) {
+            modifiedUri = setContentImageUrlOptions(options, uri);
+        } else {
+            // not a valid URI; don't modify anything
+            modifiedUri = uri;
+        }
+        return modifiedUri;
+    }
+
+    /**
+     * Gets image options from the given url.
+     *
+     * @param url the url to get the options for
+     * @return the image options. or {@link #EMPTY_STRING} if options do not exist.
+     */
+    public static String getImageUrlOptions(String url) {
+        return getImageUriOptions(Uri.parse(url));
+    }
+
+    /**
+     * Gets image options from the given uri.
+     *
+     * @param uri the uri to get the options for
+     * @return the image options. or {@link #EMPTY_STRING} if options do not exist.
+     */
+    public static String getImageUriOptions(Uri uri) {
+        List<String> components = newArrayList(SPLIT_ON_SLASH.split(uri.getPath()));
+
+        // Delegate setting ImageUrlOptions based on the Fife image URL format
+        // determined by the number of path parts the URL contains.
+        int numParts = components.size();
+        if (components.size() > 1 && components.get(0).equals("image")) {
+            --numParts;
+        }
+
+        final String options;
+        if (numParts >= LEGACY_BASE_URL_NUM_PATH_PARTS
+                && numParts <= LEGACY_URL_MAX_NUM_PATH_PARTS) {
+            options = getLegacyImageUriOptions(uri);
+        } else if (numParts == CONTENT_URL_MAX_NUM_PATH_PARTS) {
+            options = getContentImageUriOptions(uri);
+        } else {
+            // not a valid URI; don't modify anything
+            options = EMPTY_STRING;
+        }
+        return options;
+    }
+
+    /**
+     * Checks if the host is a valid FIFE host.
+     *
+     * @param url an image url to check
+     * @return {@code true} iff the url has a valid FIFE host
+     */
+    public static boolean isFifeHostedUrl(String url) {
+        if (url == null) {
+            return false;
+        }
+
+        Matcher matcher = FIFE_HOSTED_IMAGE_URL_RE.matcher(url);
+        return matcher.find();
+    }
+
+    /**
+     * Checks if the host is a valid FIFE host.
+     *
+     * @param uri an image url to check
+     * @return {@code true} iff the url has a valid FIFE host
+     */
+    public static boolean isFifeHostedUri(Uri uri) {
+        return isFifeHostedUrl(uri.toString());
+    }
+
+    /**
+     * Add image url options to the given url.
+     *
+     * @param options the options to apply
+     * @param url the url to apply the options to
+     * @return a {@code Uri} containting the new image url with options.
+     */
+    private static Uri setLegacyImageUrlOptions(String options, Uri url) {
+        String path = url.getPath();
+        List<String> components = newArrayList(SPLIT_ON_SLASH.split(path));
+        boolean hasImagePrefix = false;
+
+        if (components.size() > 0 && components.get(0).equals("image")) {
+            components.remove(0);
+            hasImagePrefix = true;
+        }
+
+        int numParts = components.size();
+        boolean isPathSlashTerminated = path.endsWith("/");
+        boolean containsFilenameNoOptions =
+                !isPathSlashTerminated && numParts == LEGACY_WITH_OPTIONS_FILENAME;
+        boolean isBaseUrlFormat = numParts == LEGACY_BASE_URL_NUM_PATH_PARTS;
+
+        // Make room for the options in the path components if no options previously existed.
+        if (containsFilenameNoOptions) {
+            components.add(components.get(LEGACY_URL_PATH_OPTIONS_INDEX));
+        }
+
+        if (isBaseUrlFormat) {
+            components.add(options);
+        } else {
+            components.set(LEGACY_URL_PATH_OPTIONS_INDEX, options);
+        }
+
+        // Put back image component if was there before.
+        if (hasImagePrefix) {
+            components.add(0, "image");
+        }
+
+        // Terminate the new path with a slash if required.
+        if (isPathSlashTerminated) {
+            components.add("");
+        }
+
+        return url.buildUpon().path("/" + JOIN_ON_SLASH.join(components)).build();
+    }
+
+    /**
+     * Add image url options to the given url.
+     *
+     * @param options the options to apply
+     * @param url the url to apply the options to
+     * @return a {@code Uri} containting the new image url with options.
+     */
+    private static Uri setContentImageUrlOptions(String options, Uri url) {
+        List<String> splitPath = newArrayList(SPLIT_ON_EQUALS.split(url.getPath()));
+        String path = splitPath.get(0) + "=" + options;
+
+        return url.buildUpon().path(path).build();
+    }
+
+    /**
+     * Gets image options from the given URI.
+     *
+     * @param uri the URI to get the options for
+     * @return the image options. or {@link #EMPTY_STRING} if options do not exist.
+     */
+    private static String getLegacyImageUriOptions(Uri uri) {
+        String path = uri.getPath();
+        List<String> components = newArrayList(SPLIT_ON_SLASH.split(path));
+
+        if (components.size() > 0 && components.get(0).equals("image")) {
+            components.remove(0);
+        }
+
+        int numParts = components.size();
+        boolean isPathSlashTerminated = path.endsWith("/");
+        boolean containsFilenameNoOptions =
+                !isPathSlashTerminated && numParts == LEGACY_WITH_OPTIONS_FILENAME;
+        boolean isBaseUrlFormat = numParts == LEGACY_BASE_URL_NUM_PATH_PARTS;
+
+        // No options in the URI
+        if (containsFilenameNoOptions) {
+            return EMPTY_STRING;
+        }
+
+        if (!isBaseUrlFormat) {
+            return components.get(LEGACY_URL_PATH_OPTIONS_INDEX);
+        }
+
+        return EMPTY_STRING;
+    }
+
+    /**
+     * Gets image options from the given URI.
+     *
+     * @param uri the URI to get the options for
+     * @return the image options. or {@link #EMPTY_STRING} if options do not exist.
+     */
+    private static String getContentImageUriOptions(Uri uri) {
+        List<String> splitPath = newArrayList(SPLIT_ON_EQUALS.split(uri.getPath()));
+        return (splitPath.size() > 1) ? splitPath.get(1) : EMPTY_STRING;
+    }
+
+    // Private. Just a class full of static functions.
+    private FIFEUtil() {
+    }
+
+
+
+
+    /*
+     * The code below has been shamelessly copied from guava to avoid bringing in it's 700+K
+     * library for just a few lines of code. This is <em>NOT</em> meant to provide a fully
+     * functional replacement. It only provides enough functionality to modify FIFE URLs.
+     */
+
+    /**
+     * Creates a <i>mutable</i> {@code ArrayList} instance containing the given
+     * elements.
+     */
+    private static <E> ArrayList<E> newArrayList(Iterable<? extends E> elements) {
+        // Let ArrayList's sizing logic work, if possible
+        Iterator<? extends E> iterator = elements.iterator();
+        ArrayList<E> list = new ArrayList<E>();
+        while (iterator.hasNext()) {
+            list.add(iterator.next());
+        }
+        return list;
+    }
+
+    /**
+     * Joins pieces of text with a separator.
+     */
+    private static class Joiner {
+        public static Joiner on(String separator) {
+            return new Joiner(separator);
+        }
+
+        private final String separator;
+
+        private Joiner(String separator) {
+            this.separator = separator;
+        }
+
+        /**
+         * Appends each of part, using the configured separator between each.
+         */
+        public final StringBuilder appendTo(StringBuilder builder, Iterable<?> parts) {
+            Iterator<?> iterator = parts.iterator();
+            if (iterator.hasNext()) {
+                builder.append(toString(iterator.next()));
+                while (iterator.hasNext()) {
+                    builder.append(separator);
+                    builder.append(toString(iterator.next()));
+                }
+            }
+            return builder;
+        }
+
+        /**
+         * Returns a string containing the string representation of each of
+         * {@code parts}, using the previously configured separator between
+         * each.
+         */
+        public final String join(Iterable<?> parts) {
+            return appendTo(new StringBuilder(), parts).toString();
+        }
+
+        CharSequence toString(Object part) {
+            return (part instanceof CharSequence) ? (CharSequence) part : part.toString();
+        }
+    }
+
+    /**
+     * Divides strings into substrings, by recognizing a separator (a.k.a. "delimiter").
+     */
+    static class Splitter {
+        private final boolean omitEmptyStrings;
+        private final Strategy strategy;
+
+        private Splitter(Strategy strategy) {
+            this(strategy, false);
+        }
+
+        private Splitter(Strategy strategy, boolean omitEmptyStrings) {
+            this.strategy = strategy;
+            this.omitEmptyStrings = omitEmptyStrings;
+        }
+
+        public static Splitter on(final String separator) {
+            if (separator == null || separator.length() == 0) {
+                throw new IllegalArgumentException("separator may not be empty or null");
+            }
+
+            return new Splitter(new Strategy() {
+                @Override
+                public SplittingIterator iterator(Splitter splitter, CharSequence toSplit) {
+                    return new SplittingIterator(splitter, toSplit) {
+                        @Override
+                        public int separatorStart(int start) {
+                            int delimeterLength = separator.length();
+
+                            positions: for (
+                                    int p = start, last = toSplit.length() - delimeterLength;
+                                    p <= last;
+                                    p++) {
+                                for (int i = 0; i < delimeterLength; i++) {
+                                    if (toSplit.charAt(i + p) != separator.charAt(i)) {
+                                        continue positions;
+                                    }
+                                }
+                                return p;
+                            }
+                            return -1;
+                        }
+
+                        @Override
+                        public int separatorEnd(int separatorPosition) {
+                            return separatorPosition + separator.length();
+                        }
+                    };
+                }
+            });
+        }
+
+        public Splitter omitEmptyStrings() {
+            return new Splitter(strategy, true);
+        }
+
+        public Iterable<String> split(final CharSequence sequence) {
+            return new Iterable<String>() {
+                @Override
+                public Iterator<String> iterator() {
+                    return strategy.iterator(Splitter.this, sequence);
+                }
+            };
+        }
+
+        private interface Strategy {
+            Iterator<String> iterator(Splitter splitter, CharSequence toSplit);
+        }
+
+        private abstract static class SplittingIterator extends AbstractIterator<String> {
+            final CharSequence toSplit;
+            final boolean omitEmptyStrings;
+
+            abstract int separatorStart(int start);
+
+            abstract int separatorEnd(int separatorPosition);
+
+            int offset = 0;
+
+            protected SplittingIterator(Splitter splitter, CharSequence toSplit) {
+                this.omitEmptyStrings = splitter.omitEmptyStrings;
+                this.toSplit = toSplit;
+            }
+
+            @Override
+            protected String computeNext() {
+                while (offset != -1) {
+                    int start = offset;
+                    int end;
+
+                    int separatorPosition = separatorStart(offset);
+                    if (separatorPosition == -1) {
+                        end = toSplit.length();
+                        offset = -1;
+                    } else {
+                        end = separatorPosition;
+                        offset = separatorEnd(separatorPosition);
+                    }
+
+                    if (omitEmptyStrings && start == end) {
+                        continue;
+                    }
+
+                    return toSplit.subSequence(start, end).toString();
+                }
+                return endOfData();
+            }
+        }
+
+        private static abstract class AbstractIterator<T> implements Iterator<T> {
+            State state = State.NOT_READY;
+
+            enum State {
+                READY, NOT_READY, DONE, FAILED,
+            }
+
+            T next;
+
+            protected abstract T computeNext();
+
+            protected final T endOfData() {
+                state = State.DONE;
+                return null;
+            }
+
+            @Override
+            public final boolean hasNext() {
+                if (state == State.FAILED) {
+                    throw new IllegalStateException();
+                }
+
+                switch (state) {
+                    case DONE:
+                        return false;
+                    case READY:
+                        return true;
+                    default:
+                }
+                return tryToComputeNext();
+            }
+
+            boolean tryToComputeNext() {
+                state = State.FAILED; // temporary pessimism
+                next = computeNext();
+                if (state != State.DONE) {
+                    state = State.READY;
+                    return true;
+                }
+                return false;
+            }
+
+            @Override
+            public final T next() {
+                if (!hasNext()) {
+                    throw new NoSuchElementException();
+                }
+                state = State.NOT_READY;
+                return next;
+            }
+
+            @Override
+            public void remove() {
+                throw new UnsupportedOperationException();
+            }
+        }
+    }
+}
diff --git a/src/com/android/mail/photo/util/GifDrawable.java b/src/com/android/mail/photo/util/GifDrawable.java
new file mode 100644
index 0000000..5c625ea
--- /dev/null
+++ b/src/com/android/mail/photo/util/GifDrawable.java
@@ -0,0 +1,836 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.util;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.drawable.Animatable;
+import android.graphics.drawable.Drawable;
+import android.os.SystemClock;
+import android.util.Log;
+
+import java.io.ByteArrayInputStream;
+
+/**
+ * A GIF Drawable with support for animations.
+ *
+ * Inspired by http://code.google.com/p/android-gifview/
+ */
+public class GifDrawable extends Drawable implements Runnable, Animatable {
+
+    private static final String TAG = "GifDrawable";
+
+    // Run the animation at the most at 60 frames per second
+    private static final int MIN_FRAME_DELAY = 15;
+
+    // Max decoder pixel stack size
+    private static final int MAX_STACK_SIZE = 4096;
+
+    // Frame disposal methods
+    private static final int DISPOSAL_METHOD_UNKNOWN = 0;
+    private static final int DISPOSAL_METHOD_LEAVE = 1;
+    private static final int DISPOSAL_METHOD_BACKGROUND = 2;
+    private static final int DISPOSAL_METHOD_RESTORE = 3;
+
+    private static final byte[] NETSCAPE2_0 = "NETSCAPE2.0".getBytes();
+
+
+    private static Paint sPaint;
+    private static Paint sScalePaint;
+
+    private final ByteArrayInputStream mStream;
+
+    private int mIntrinsicWidth;
+    private int mIntrinsicHeight;
+    private int mWidth;
+    private int mHeight;
+
+    private Bitmap mBitmap;
+    private int[] mColors;
+    private boolean mScale;
+    private float mScaleFactor;
+
+    private Bitmap mFirstFrame;
+
+    private boolean mError;
+
+    private byte[] mColorTableBuffer = new byte[256 * 3];
+    private int[] mGlobalColorTable = new int[256];
+    private boolean mGlobalColorTableUsed;
+    private boolean mLocalColorTableUsed;
+    private int mGlobalColorTableSize;
+    private int mLocalColorTableSize;
+    private int[] mLocalColorTable;
+    private int[] mActiveColorTable;
+    private int mBackgroundIndex;
+    private int mBackgroundColor;
+    private boolean mInterlace;
+    private int mFrameX, mFrameY, mFrameWidth, mFrameHeight;
+    private byte[] mBlock = new byte[256];
+    private int mBlockSize;
+    private int mDisposalMethod = DISPOSAL_METHOD_BACKGROUND;
+    private boolean mTransparency;
+    private int mTransparentColorIndex;
+
+    // LZW decoder working arrays
+    private short[] mPrefix = new short[MAX_STACK_SIZE];
+    private byte[] mSuffix = new byte[MAX_STACK_SIZE];
+    private byte[] mPixelStack = new byte[MAX_STACK_SIZE + 1];
+    private byte[] mPixels;
+
+    private boolean mBackupSaved;
+    private int[] mBackup;
+
+    private int mFrameCount;
+
+    private boolean mRunning;
+    private boolean mDone;
+    private int mFrameDelay;
+
+    public GifDrawable(byte[] data) {
+        mStream = new ByteArrayInputStream(data);
+        readHeader();
+
+        // Mark the position of the first image frame in the stream.
+        mStream.mark(0);
+
+        if (!mError) {
+            mBitmap = Bitmap.createBitmap(mIntrinsicWidth, mIntrinsicHeight,
+                    Bitmap.Config.ARGB_4444);
+
+            int pixelCount = mIntrinsicWidth * mIntrinsicHeight;
+            mColors = new int[pixelCount];
+            mPixels = new byte[pixelCount];
+
+            mWidth = mIntrinsicHeight;
+            mHeight = mIntrinsicHeight;
+
+            // Read the first frame
+            readNextFrame();
+        }
+
+        if (sPaint == null) {
+            sPaint = new Paint();
+            sScalePaint = new Paint();
+            sScalePaint.setFilterBitmap(true);
+        }
+    }
+
+    public static boolean isGif(byte[] data) {
+        return data.length >= 3 &&  data[0] == 'G' && data[1] == 'I' && data[2] == 'F';
+    }
+
+    /**
+     * Returns the bitmap for the first frame in the GIF image.
+     */
+    public Bitmap getFirstFrame() {
+        if (mFirstFrame == null && !mError && mWidth > 0 && mHeight > 0) {
+            if (mScale) {
+                mFirstFrame = Bitmap.createBitmap(mWidth, mHeight,  Bitmap.Config.ARGB_4444);
+                draw(new Canvas(mFirstFrame));
+            } else {
+                mFirstFrame = Bitmap.createBitmap(mColors, mIntrinsicWidth, mIntrinsicHeight,
+                        Bitmap.Config.ARGB_4444);
+            }
+        }
+        return mFirstFrame;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    protected void onBoundsChange(Rect bounds) {
+        super.onBoundsChange(bounds);
+        mWidth = bounds.width();
+        mHeight =  bounds.height();
+        mScale = mWidth != mIntrinsicWidth && mHeight != mIntrinsicHeight;
+        if (mScale) {
+            mScaleFactor = Math.max((float) mWidth / mIntrinsicWidth,
+                    (float) mHeight / mIntrinsicHeight);
+        }
+        mFirstFrame = null;
+        reset();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean setVisible(boolean visible, boolean restart) {
+        boolean changed = super.setVisible(visible, restart);
+        if (visible) {
+            if (changed || restart) {
+                start();
+            }
+        } else {
+            stop();
+        }
+        return changed;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void draw(Canvas canvas) {
+        if (mError || mWidth == 0 || mHeight == 0) {
+            return;
+        }
+
+        if (mScale) {
+            canvas.save();
+            canvas.scale(mScaleFactor, mScaleFactor, 0, 0);
+            canvas.drawBitmap(mBitmap, 0, 0, sScalePaint);
+            canvas.restore();
+        } else {
+            canvas.drawBitmap(mBitmap, 0, 0, sPaint);
+        }
+
+        if (!mRunning && !mDone) {
+            start();
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int getIntrinsicWidth() {
+        return mIntrinsicWidth;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int getIntrinsicHeight() {
+        return mIntrinsicHeight;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public int getOpacity() {
+        return PixelFormat.UNKNOWN;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void setAlpha(int alpha) {
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void setColorFilter(ColorFilter cf) {
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public boolean isRunning() {
+        return mRunning;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void start() {
+        if (!isRunning()) {
+            mRunning = true;
+            run();
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void stop() {
+        if (isRunning()) {
+            mRunning = false;
+            unscheduleSelf(this);
+        }
+    }
+
+    /**
+     * Moves to the next frame.
+     */
+    @Override
+    public void run() {
+        // If the animation has been completed, see if we need to repeat it.
+        if (mDone) {
+
+            // Multiple frames - repeat
+            if (mFrameCount > 1) {
+                mDone = false;
+                reset();
+            } else {
+                stop();
+                return;
+            }
+        }
+
+        // Compose all frames that follow each other with 0 delay.
+        do {
+            readNextFrame();
+        } while (!mDone && mFrameDelay == 0 &&
+                (mDisposalMethod == DISPOSAL_METHOD_UNKNOWN
+                || mDisposalMethod == DISPOSAL_METHOD_LEAVE));
+
+        if (mFrameDelay == 0) {
+            mFrameDelay = MIN_FRAME_DELAY;
+        }
+
+        invalidateSelf();
+
+        if (mRunning) {
+            scheduleSelf(this, SystemClock.uptimeMillis() + mFrameDelay);
+        } else {
+            unscheduleSelf(this);
+        }
+    }
+
+    /**
+     * Restarts decoding the image from the beginning.
+     */
+    private void reset() {
+        // Return to the position of the first image frame in the stream.
+        mStream.reset();
+        mBackupSaved = false;
+        mFrameCount = 0;
+        mDisposalMethod = DISPOSAL_METHOD_UNKNOWN;
+    }
+
+    /**
+     * Reads GIF file header information.
+     */
+    private void readHeader() {
+        boolean valid = read() == 'G';
+        valid = valid && read() == 'I';
+        valid = valid && read() == 'F';
+        if (!valid) {
+            mError = true;
+            return;
+        }
+
+        // Skip the next three letter, which represent the variation of the GIF standard.
+        read();
+        read();
+        read();
+
+        readLogicalScreenDescriptor();
+
+        if (mGlobalColorTableUsed && !mError) {
+            readColorTable(mGlobalColorTable, mGlobalColorTableSize);
+            mBackgroundColor = mGlobalColorTable[mBackgroundIndex];
+        }
+    }
+
+    /**
+     * Reads Logical Screen Descriptor
+     */
+    private void readLogicalScreenDescriptor() {
+        // logical screen size
+        mIntrinsicWidth = mFrameWidth = readShort();
+        mIntrinsicHeight = mFrameHeight = readShort();
+        // packed fields
+        int packed = read();
+        mGlobalColorTableUsed = (packed & 0x80) != 0; // 1 : global color table flag
+        // 2-4 : color resolution - ignore
+        // 5 : gct sort flag - ignore
+        mGlobalColorTableSize = 2 << (packed & 7); // 6-8 : gct size
+        mBackgroundIndex = read();
+        read(); // pixel aspect ratio - ignore
+    }
+
+    /**
+     * Reads color table as 256 RGB integer values
+     *
+     * @param ncolors int number of colors to read
+     */
+    private void readColorTable(int[] colorTable, int ncolors) {
+        int nbytes = 3 * ncolors;
+        int n = 0;
+        try {
+            n = mStream.read(mColorTableBuffer, 0, nbytes);
+        } catch (Exception e) {
+            Log.e(TAG, "Cannot read color table", e);
+        }
+
+        if (n < nbytes) {
+            mError = true;
+        } else {
+            int i = 0;
+            int j = 0;
+            while (i < ncolors) {
+                int r = mColorTableBuffer[j++] & 0xff;
+                int g = mColorTableBuffer[j++] & 0xff;
+                int b = mColorTableBuffer[j++] & 0xff;
+                colorTable[i++] = 0xff000000 | (r << 16) | (g << 8) | b;
+            }
+        }
+    }
+
+    /**
+     * Reads GIF content blocks.
+     *
+     * @return true if the next frame has been parsed successfully, false if EOF
+     *         has been reached
+     */
+    private void readNextFrame() {
+        disposeOfLastFrame();
+
+        mDisposalMethod = DISPOSAL_METHOD_UNKNOWN;
+        mTransparency = false;
+        mFrameDelay = 0;
+        mLocalColorTable = null;
+
+        while (true) {
+            int code = read();
+            switch (code) {
+                case 0x21: // Extension.  Extensions precede the corresponding image.
+                    code = read();
+                    switch (code) {
+                        case 0xf9: // graphics control extension
+                            readGraphicControlExt();
+                            break;
+                        case 0xff: // application extension
+                            readBlock();
+                            boolean netscape = true;
+                            for (int i = 0; i < NETSCAPE2_0.length; i++) {
+                                if (mBlock[i] != NETSCAPE2_0[i]) {
+                                    netscape = false;
+                                }
+                            }
+                            if (netscape) {
+                                readNetscapeExtension();
+                            } else {
+                                skip(); // don't care
+                            }
+                            break;
+                        case 0xfe:// comment extension
+                            skip();
+                            break;
+                        case 0x01:// plain text extension
+                            skip();
+                            break;
+                        default: // uninteresting extension
+                            skip();
+                    }
+                    break;
+
+                case 0x2C: // Image separator
+                    readBitmap();
+                    return;
+
+                case 0x3b: // Terminator
+                    mDone = true;
+                    return;
+
+                default:
+                    mError = true;
+                    return;
+            }
+        }
+    }
+
+    /**
+     * Disposes of the previous frame.
+     */
+    private void disposeOfLastFrame() {
+        switch (mDisposalMethod) {
+            case DISPOSAL_METHOD_UNKNOWN:
+            case DISPOSAL_METHOD_LEAVE:
+                mBackupSaved = false;
+                break;
+
+            case DISPOSAL_METHOD_RESTORE:
+                if (mBackupSaved) {
+                    System.arraycopy(mBackup, 0, mColors, 0, mBackup.length);
+                }
+                break;
+
+            case DISPOSAL_METHOD_BACKGROUND:
+                mBackupSaved = false;
+
+                // Fill last image rect area with background color
+                int color = 0;
+                if (!mTransparency) {
+                    color = mBackgroundColor;
+                }
+                for (int i = 0; i < mFrameHeight; i++) {
+                    int n1 = (mFrameY + i) * mIntrinsicWidth + mFrameX;
+                    int n2 = n1 + mFrameWidth;
+                    for (int k = n1; k < n2; k++) {
+                        mColors[k] = color;
+                    }
+                }
+                break;
+
+        }
+    }
+
+    /**
+     * Reads Graphics Control Extension values
+     */
+    private void readGraphicControlExt() {
+        read(); // Block size, fixed
+
+        int packed = read(); // Packed fields
+
+        mDisposalMethod = (packed & 0x1c) >> 2;  // Disposal method
+        mTransparency = (packed & 1) != 0;
+        mFrameDelay = readShort() * 10; // Delay in milliseconds
+        mTransparentColorIndex = read();
+
+        read(); // Block terminator - ignore
+    }
+
+    /**
+     * Reads Netscape extension to obtain iteration count
+     */
+    private void readNetscapeExtension() {
+        do {
+            readBlock();
+        } while ((mBlockSize > 0) && !mError);
+    }
+
+    /**
+     * Reads next frame image
+     */
+    private void readBitmap() {
+        mFrameX = readShort(); // (sub)image position & size
+        mFrameY = readShort();
+        mFrameWidth = readShort();
+        mFrameHeight = readShort();
+        int packed = read();
+        mLocalColorTableUsed = (packed & 0x80) != 0; // 1 - local color table flag interlace
+        mLocalColorTableSize = (int) Math.pow(2, (packed & 0x07) + 1);
+
+        // 3 - sort flag
+        // 4-5 - reserved lctSize = 2 << (packed & 7); // 6-8 - local color
+        // table size
+        mInterlace = (packed & 0x40) != 0;
+        if (mLocalColorTableUsed) {
+            if (mLocalColorTable == null) {
+                mLocalColorTable = new int[256];
+            }
+            readColorTable(mLocalColorTable, mLocalColorTableSize);
+            mActiveColorTable = mLocalColorTable;
+        } else {
+            mActiveColorTable = mGlobalColorTable;
+            if (mBackgroundIndex == mTransparentColorIndex) {
+                mBackgroundColor = 0;
+            }
+        }
+        int savedColor = 0;
+        if (mTransparency) {
+            savedColor = mActiveColorTable[mTransparentColorIndex];
+            mActiveColorTable[mTransparentColorIndex] = 0;
+        }
+
+        if (mActiveColorTable == null) {
+            mError = true;
+        }
+
+        if (mError) {
+            return;
+        }
+
+        decodeBitmapData();
+
+        skip();
+
+        if (mError) {
+            return;
+        }
+
+        if (mDisposalMethod == DISPOSAL_METHOD_RESTORE) {
+            backupFrame();
+        }
+
+        populateImageData();
+
+        if (mTransparency) {
+            mActiveColorTable[mTransparentColorIndex] = savedColor;
+        }
+
+        mFrameCount++;
+    }
+
+    /**
+     * Stores the relevant portion of the current frame so that it can be restored
+     * before the next frame is rendered.
+     */
+    private void backupFrame() {
+        if (mBackupSaved) {
+            return;
+        }
+
+        if (mBackup == null) {
+            mBackup = null;
+            try {
+                mBackup = new int[mColors.length];
+            } catch (OutOfMemoryError e) {
+                Log.e(TAG, "GifDrawable.backupFrame threw an OOME", e);
+            }
+        }
+
+        if (mBackup != null) {
+            System.arraycopy(mColors, 0, mBackup, 0, mColors.length);
+            mBackupSaved = true;
+        }
+    }
+
+    /**
+     * Decodes LZW image data into pixel array.
+     */
+    private void decodeBitmapData() {
+        int nullCode = -1;
+        int npix = mFrameWidth * mFrameHeight;
+
+        // Initialize GIF data stream decoder.
+        int dataSize = read();
+        int clear = 1 << dataSize;
+        int endOfInformation = clear + 1;
+        int available = clear + 2;
+        int oldCode = nullCode;
+        int codeSize = dataSize + 1;
+        int codeMask = (1 << codeSize) - 1;
+        for (int code = 0; code < clear; code++) {
+            mPrefix[code] = 0; // XXX ArrayIndexOutOfBoundsException
+            mSuffix[code] = (byte) code;
+        }
+
+        // Decode GIF pixel stream.
+        int datum = 0;
+        int bits = 0;
+        int count = 0;
+        int first = 0;
+        int top = 0;
+        int pi = 0;
+        int bi = 0;
+        for (int i = 0; i < npix;) {
+            if (top == 0) {
+                if (bits < codeSize) {
+
+                    // Load bytes until there are enough bits for a code.
+                    if (count == 0) {
+
+                        // Read a new data block.
+                        count = readBlock();
+                        if (count <= 0) {
+                            break;
+                        }
+                        bi = 0;
+                    }
+                    datum += (mBlock[bi] & 0xff) << bits;
+                    bits += 8;
+                    bi++;
+                    count--;
+                    continue;
+                }
+
+                // Get the next code.
+                int code = datum & codeMask;
+                datum >>= codeSize;
+                bits -= codeSize;
+
+                // Interpret the code
+                if ((code > available) || (code == endOfInformation)) {
+                    break;
+                }
+                if (code == clear) {
+                    // Reset decoder.
+                    codeSize = dataSize + 1;
+                    codeMask = (1 << codeSize) - 1;
+                    available = clear + 2;
+                    oldCode = nullCode;
+                    continue;
+                }
+                if (oldCode == nullCode) {
+                    mPixelStack[top++] = mSuffix[code];
+                    oldCode = code;
+                    first = code;
+                    continue;
+                }
+                int inCode = code;
+                if (code == available) {
+                    mPixelStack[top++] = (byte) first;
+                    code = oldCode;
+                }
+                while (code > clear) {
+                    mPixelStack[top++] = mSuffix[code];
+                    code = mPrefix[code];
+                }
+                first = mSuffix[code] & 0xff;
+
+                // Add a new string to the string table,
+                if (available >= MAX_STACK_SIZE) {
+                    break;
+                }
+
+                mPixelStack[top++] = (byte) first;
+                mPrefix[available] = (short) oldCode;
+                mSuffix[available] = (byte) first;
+                available++;
+                if (((available & codeMask) == 0) && (available < MAX_STACK_SIZE)) {
+                    codeSize++;
+                    codeMask += available;
+                }
+                oldCode = inCode;
+            }
+
+            // Pop a pixel off the pixel stack.
+            top--;
+            mPixels[pi++] = mPixelStack[top];
+            i++;
+        }
+
+        for (int i = pi; i < npix; i++) {
+            mPixels[i] = 0; // clear missing pixels
+        }
+    }
+
+    /**
+     * Populates the color array with pixels for the next frame.
+     */
+    private void populateImageData() {
+
+        // Copy each source line to the appropriate place in the destination
+        int pass = 1;
+        int inc = 8;
+        int iline = 0;
+        for (int i = 0; i < mFrameHeight; i++) {
+            int line = i;
+            if (mInterlace) {
+                if (iline >= mFrameHeight) {
+                    pass++;
+                    switch (pass) {
+                        case 2:
+                            iline = 4;
+                            break;
+                        case 3:
+                            iline = 2;
+                            inc = 4;
+                            break;
+                        case 4:
+                            iline = 1;
+                            inc = 2;
+                            break;
+                        default:
+                            break;
+                    }
+                }
+                line = iline;
+                iline += inc;
+            }
+            line += mFrameY;
+            if (line < mIntrinsicHeight) {
+                int k = line * mIntrinsicWidth;
+                int dx = k + mFrameX; // start of line in dest
+                int dlim = dx + mFrameWidth; // end of dest line
+                if ((k + mIntrinsicWidth) < dlim) {
+                    dlim = k + mIntrinsicWidth; // past dest edge
+                }
+                int sx = i * mFrameWidth; // start of line in source
+                while (dx < dlim) {
+                    // map color and insert in destination
+                    int index = mPixels[sx++] & 0xff;
+                    int c = mActiveColorTable[index];
+                    if (c != 0) {
+                        mColors[dx] = c;
+                    }
+                    dx++;
+                }
+            }
+        }
+
+        mBitmap.setPixels(mColors, 0, mIntrinsicWidth, 0, 0, mIntrinsicWidth, mIntrinsicHeight);
+    }
+
+    /**
+     * Reads a single byte from the input stream.
+     */
+    private int read() {
+        int curByte = 0;
+        try {
+            curByte = mStream.read();
+        } catch (Exception e) {
+            mError = true;
+        }
+        return curByte;
+    }
+
+    /**
+     * Reads next variable length block from input.
+     *
+     * @return number of bytes stored in "buffer"
+     */
+    private int readBlock() {
+        mBlockSize = read();
+        int n = 0;
+        if (mBlockSize > 0) {
+            try {
+                int count = 0;
+                while (n < mBlockSize) {
+                    count = mStream.read(mBlock, n, mBlockSize - n);
+                    if (count == -1) {
+                        break;
+                    }
+                    n += count;
+                }
+            } catch (Exception e) {
+                e.printStackTrace();
+            }
+            if (n < mBlockSize) {
+                mError = true;
+            }
+        }
+        return n;
+    }
+
+    /**
+     * Reads next 16-bit value, LSB first
+     */
+    private int readShort() {
+        // read 16-bit value, LSB first
+        return read() | (read() << 8);
+    }
+
+    /**
+     * Skips variable length blocks up to and including next zero length block.
+     */
+    private void skip() {
+        do {
+            readBlock();
+        } while ((mBlockSize > 0) && !mError);
+    }
+}
diff --git a/src/com/android/mail/photo/util/ImageCache.java b/src/com/android/mail/photo/util/ImageCache.java
new file mode 100644
index 0000000..31194c4
--- /dev/null
+++ b/src/com/android/mail/photo/util/ImageCache.java
@@ -0,0 +1,1274 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.util;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Handler.Callback;
+import android.os.Looper;
+import android.os.Message;
+import android.support.v4.util.LruCache;
+
+import com.android.mail.R;
+import com.android.mail.photo.content.ImageRequest;
+import com.android.mail.photo.content.LocalImageRequest;
+import com.android.mail.photo.content.MediaImageRequest;
+
+import java.lang.ref.SoftReference;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Asynchronously loads images and maintains a cache of those.
+ */
+public class ImageCache implements Callback {
+
+    /**
+     * The listener interface notifying of avatar changes.
+     */
+    public interface OnAvatarChangeListener {
+
+        /**
+         * Invoked if a new avatar has been loaded for the specified Gaia ID.
+         */
+        void onAvatarChanged(String gaiaId);
+    }
+
+    /**
+     * The listener interface notifying of media image changes.
+     */
+    public interface OnMediaImageChangeListener {
+
+        /**
+         * Invoked if a new media image has been loaded for the specified URL.
+         */
+        void onMediaImageChanged(String url);
+    }
+
+    /**
+     * The listener interface notifying of image changes.
+     */
+    public interface OnRemoteImageChangeListener {
+
+        /**
+         * Invoked if a new media image has been loaded for the specified URL.
+         */
+        void onRemoteImageChanged(ImageRequest request, Bitmap bitmap);
+    }
+
+    /**
+     * The listener interface notifying of remote image changes.
+     */
+    public interface OnRemoteDrawableChangeListener extends OnRemoteImageChangeListener {
+
+        /**
+         * Invoked if a new image has been loaded for the specified URL.
+         */
+        void onRemoteImageChanged(ImageRequest request, Drawable drawable);
+    }
+
+    /**
+     * The listener interface notifying a listener that an image load request has been completed.
+     */
+    public interface OnImageRequestCompleteListener {
+
+        /**
+         * Invoked when a request is complete.
+         */
+        void onImageRequestComplete(ImageRequest request);
+    }
+
+    /**
+     * The callback interface that must be implemented by the views requesting images.
+     */
+    public interface ImageConsumer {
+
+        /**
+         * @param bitmap The bitmap
+         * @param loading The flag indicating if the image is still loading (if
+         *            true, bitmap will be null).
+         */
+        void setBitmap(Bitmap bitmap, boolean loading);
+    }
+
+    /**
+     * The callback interface that can optionally be implemented by the views
+     * requesting images if they want to support animated drawables.
+     */
+    public interface DrawableConsumer extends ImageConsumer {
+
+        /**
+         * @param drawable The image
+         * @param loading The flag indicating if the image is still loading (if
+         *            true, bitmap will be null).
+         */
+        void setDrawable(Drawable drawable, boolean loading);
+    }
+
+    // Logging.
+    static final String TAG = "ImageCache";
+
+    private static final String LOADER_THREAD_NAME = "ImageCache";
+
+    /**
+     * Type of message sent by the UI thread to itself to indicate that some photos
+     * need to be loaded.
+     */
+    private static final int MESSAGE_REQUEST_LOADING = 1;
+
+    /**
+     * Type of message sent by the loader thread to indicate that some photos have
+     * been loaded.
+     */
+    private static final int MESSAGE_IMAGES_LOADED = 2;
+
+    /**
+     * Type of message sent to indicate that an avatar has changed.
+     */
+    private static final int MESSAGE_AVATAR_CHANGED = 3;
+
+    /**
+     * Type of message sent to indicate that a media image has changed.
+     */
+    private static final int MESSAGE_MEDIA_IMAGE_CHANGED = 4;
+
+    /**
+     * Type of message sent to indicate that an image has changed.
+     */
+    private static final int MESSAGE_REMOTE_IMAGE_CHANGED = 5;
+
+    private static final byte[] EMPTY_ARRAY = new byte[0];
+
+    /**
+     * Maintains the state of a particular photo.
+     */
+    private static class ImageHolder {
+        final byte[] bytes;
+        final boolean complete;
+
+        volatile boolean fresh;
+
+        /**
+         * Either {@link Bitmap} or {@link Drawable}.
+         */
+        Object image;
+        SoftReference<Object> imageRef;
+
+
+        public ImageHolder(byte[] bytes, boolean complete) {
+            this.bytes = bytes;
+            this.fresh = true;
+            this.complete = complete;
+        }
+    }
+
+    private static class MediaImageChangeNotification {
+        MediaImageRequest request;
+        byte[] imageBytes;
+    }
+
+    private static class RemoteImageChangeNotification {
+        ImageRequest request;
+        byte[] imageBytes;
+    }
+
+    private static final float ESTIMATED_BYTES_PER_PIXEL = 0.3f;
+
+    private static int sTinyAvatarEstimatedSize;
+    private static int sSmallAvatarEstimatedSize;
+    private static int sMediumAvatarEstimatedSize;
+
+    private static boolean sUseSoftReferences;
+
+    private final Context mContext;
+
+    private static HashSet<OnAvatarChangeListener> mAvatarListeners =
+            new HashSet<OnAvatarChangeListener>();
+
+    private static HashSet<OnMediaImageChangeListener> mMediaImageListeners =
+            new HashSet<OnMediaImageChangeListener>();
+
+    private static HashSet<OnRemoteImageChangeListener> mRemoteImageListeners =
+            new HashSet<OnRemoteImageChangeListener>();
+
+    private static HashSet<OnImageRequestCompleteListener> mRequestCompleteListeners =
+            new HashSet<OnImageRequestCompleteListener>();
+
+    /**
+     * An LRU cache for image holders. The cache contains bytes for images just
+     * as they come from the database. Each holder has a soft reference to the
+     * actual image.
+     */
+    private final LruCache<ImageRequest, ImageHolder> mImageHolderCache;
+
+    /**
+     * Cache size threshold at which images will not be preloaded.
+     */
+    private final int mImageHolderCacheRedZoneBytes;
+
+    /**
+     * Level 2 LRU cache for images. This is a smaller cache that holds
+     * the most recently used images to save time on decoding
+     * them from bytes (the bytes are stored in {@link #mImageHolderCache}.
+     */
+    private final LruCache<ImageRequest, Object> mImageCache;
+
+    /**
+     * A map from {@link ImageConsumer} to the corresponding {@link ImageRequest}. Please
+     * note that this request may change before the photo loading request is
+     * started.
+     */
+    private final ConcurrentHashMap<ImageConsumer, ImageRequest> mPendingRequests =
+            new ConcurrentHashMap<ImageConsumer, ImageRequest>();
+
+    /**
+     * Handler for messages sent to the UI thread.
+     */
+    private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper(), this);
+
+//    /**
+//     * Thread responsible for loading photos from the database. Created upon
+//     * the first request.
+//     */
+//    private LoaderThread mLoaderThread;
+
+    /**
+     * A gate to make sure we only send one instance of MESSAGE_PHOTOS_NEEDED at a time.
+     */
+    private boolean mLoadingRequested;
+
+    /**
+     * Flag indicating if the image loading is paused.
+     */
+    private boolean mPaused;
+
+    private static ImageCache sInstance;
+
+    public static synchronized ImageCache getInstance(Context context) {
+
+        // We can use one global instance provided that we bind to the
+        // application context instead of the context that is passed in.
+        // Otherwise this static instance would retain the supplied context and
+        // cause a leak.
+        if (sInstance == null) {
+            sInstance = new ImageCache(context.getApplicationContext());
+        }
+        return sInstance;
+    }
+
+    private ImageCache(Context context) {
+        mContext = context;
+
+        Resources resources = context.getApplicationContext().getResources();
+        mImageCache = new LruCache<ImageRequest, Object>(
+                resources.getInteger(R.integer.config_image_cache_max_bitmaps));
+        int maxBytes = resources.getInteger(R.integer.config_image_cache_max_bytes);
+        mImageHolderCache = new LruCache<ImageRequest, ImageHolder>(maxBytes) {
+
+            /**
+             * {@inheritDoc}
+             */
+            @Override
+            protected int sizeOf(ImageRequest request, ImageHolder value) {
+                return value.bytes != null ? value.bytes.length : 0;
+            }
+        };
+
+        mImageHolderCacheRedZoneBytes = (int) (maxBytes * 0.9);
+
+        if (sTinyAvatarEstimatedSize == 0) {
+//            sTinyAvatarEstimatedSize = (int) (ESTIMATED_BYTES_PER_PIXEL
+//                    * EsAvatarData.getTinyAvatarSize(context)
+//                    * EsAvatarData.getTinyAvatarSize(context));
+//            sSmallAvatarEstimatedSize = (int) (ESTIMATED_BYTES_PER_PIXEL
+//                    * EsAvatarData.getSmallAvatarSize(context)
+//                    * EsAvatarData.getSmallAvatarSize(context));
+//            sMediumAvatarEstimatedSize = (int) (ESTIMATED_BYTES_PER_PIXEL
+//                    * EsAvatarData.getMediumAvatarSize(context)
+//                    * EsAvatarData.getMediumAvatarSize(context));
+
+            sUseSoftReferences = Build.VERSION.SDK_INT >= 11;
+        }
+    }
+
+//    /**
+//     * Returns an estimate of the avatar size in bytes.
+//     */
+//    private int getEstimatedSizeInBytes(AvatarRequest request) {
+//        switch (request.getSize()) {
+//            case AvatarRequest.TINY: return sTinyAvatarEstimatedSize;
+//            case AvatarRequest.SMALL: return sSmallAvatarEstimatedSize;
+//            case AvatarRequest.MEDIUM: return sMediumAvatarEstimatedSize;
+//        }
+//        return 0;
+//    }
+
+    /**
+     * Clears cache.
+     */
+    public void clear() {
+        mImageHolderCache.evictAll();
+        mImageCache.evictAll();
+        mPendingRequests.clear();
+    }
+
+//    /**
+//     * Starts preloading photos in the background.
+//     */
+//    public void preloadAvatarsInBackground(List<AvatarRequest> requests) {
+//        ensureLoaderThread();
+//
+//        boolean preloadingNeeded = touchRequestedEntries(requests);
+//        int totalTinyAvatarSize = touchTinyAvatars();
+//
+//        if (!preloadingNeeded) {
+//            return;
+//        }
+//
+//        requests = trimCache(requests, totalTinyAvatarSize);
+//
+//        mLoaderThread.startPreloading(requests);
+//    }
+
+//    /**
+//     * Adjust the LRU order of the requested images that are already cached to prevent them
+//     * from being evicted by preloading.
+//     */
+//    private boolean touchRequestedEntries(List<AvatarRequest> requests) {
+//        boolean cacheMissed = false;
+//        for (int i = requests.size() - 1; i >= 0; i--) {
+//            AvatarRequest request = requests.get(i);
+//            ImageHolder holder = mImageHolderCache.get(request);
+//            if (holder != null) {
+//                mImageHolderCache.put(request, holder);
+//            } else {
+//                cacheMissed = true;
+//            }
+//        }
+//
+//        return cacheMissed;
+//    }
+
+//    /**
+//     * Moves tiny avatars to the top of the LRU order to try and keep them from being evicted.
+//     * Caching tiny avatars is highly cost-effective.
+//     *
+//     * @return The total size of all tiny avatars in cache.
+//     */
+//    private int touchTinyAvatars() {
+//        int totalSize = 0;
+//
+//        Iterator<Entry<ImageRequest, ImageHolder>> iterator =
+//                mImageHolderCache.snapshot().entrySet().iterator();
+//        while (iterator.hasNext()) {
+//            Entry<ImageRequest, ImageHolder> entry = iterator.next();
+//            ImageRequest request = entry.getKey();
+//            if ((request instanceof AvatarRequest)
+//                    && ((AvatarRequest) request).getSize() == AvatarRequest.TINY) {
+//                ImageHolder holder = entry.getValue();
+//                if (holder.bytes != null) {
+//                    totalSize += holder.bytes.length;
+//                }
+//
+//                mImageHolderCache.put(request, holder);
+//            }
+//        }
+//
+//        return totalSize;
+//    }
+
+//    /**
+//     * Reduces the size of cache before preloading.
+//     */
+//    private List<AvatarRequest> trimCache(List<AvatarRequest> requests, int totalTinyAvatarSize) {
+//        int preferredCacheSize = mImageHolderCacheRedZoneBytes;
+//        int estimatedMemoryUse = totalTinyAvatarSize;
+//        for (int i = 0; i < requests.size(); i++) {
+//            if (estimatedMemoryUse >= mImageHolderCacheRedZoneBytes) {
+//                trimCache(preferredCacheSize);
+//                return requests.subList(0, i);
+//            }
+//
+//            AvatarRequest request = requests.get(i);
+//            ImageHolder holder = mImageHolderCache.get(request);
+//            if (holder != null && holder.bytes != null) {
+//                estimatedMemoryUse += holder.bytes.length;
+//            } else {
+//                int bytes = getEstimatedSizeInBytes(request);
+//                preferredCacheSize -= bytes;
+//                estimatedMemoryUse += bytes;
+//            }
+//        }
+//
+//        trimCache(preferredCacheSize);
+//        return requests;
+//    }
+
+    /**
+     * Shrinks cache to the desired size.
+     */
+    private void trimCache(int size) {
+        Iterator<Entry<ImageRequest, ImageHolder>> iterator =
+                mImageHolderCache.snapshot().entrySet().iterator();
+        while (mImageHolderCache.size() > size && iterator.hasNext()) {
+            mImageHolderCache.remove(iterator.next().getKey());
+        }
+    }
+
+    /**
+     * Evicts avatars that were requested but never loaded.  This will force them to be
+     * requested again if needed.
+     */
+    public void refresh() {
+        Iterator<ImageHolder> iterator = mImageHolderCache.snapshot().values().iterator();
+        while (iterator.hasNext()) {
+            ImageHolder holder = iterator.next();
+            if (!holder.complete) {
+                holder.fresh = false;
+            }
+        }
+    }
+
+    /**
+     * Requests asynchronous photo loading for the specified request.
+     *
+     * @param consumer Image consumer
+     * @param request The combination of URL, type and size.
+     */
+    public void loadImage(ImageConsumer consumer, ImageRequest request) {
+        loadImage(consumer, request, true);
+    }
+
+    /**
+     * Requests an asynchronous refresh of the image for the specified request.
+     *
+     * @param consumer Image consumer
+     * @param request The combination of URL, type and size.
+     */
+    public void refreshImage(ImageConsumer consumer, ImageRequest request) {
+        loadImage(consumer, request, false);
+    }
+
+    /**
+     * Evicts all local images from the cache.
+     */
+    public void evictAllLocalImages() {
+        Set<ImageRequest> iterator = mImageHolderCache.snapshot().keySet();
+        for (ImageRequest request : iterator) {
+            if (request instanceof LocalImageRequest) {
+                mImageCache.remove(request);
+                mImageHolderCache.remove(request);
+            }
+        }
+    }
+
+    private void loadImage(ImageConsumer consumer, ImageRequest request,
+            boolean clearIfNotCached) {
+        if (request.isEmpty()) {
+            // No photo is needed
+            consumer.setBitmap(null, false);
+            notifyRequestComplete(request);
+            mPendingRequests.remove(consumer);
+        } else {
+            boolean loaded = loadCachedImage(consumer, request, clearIfNotCached);
+            if (loaded) {
+                mPendingRequests.remove(consumer);
+            } else {
+                mPendingRequests.put(consumer, request);
+                if (!mPaused) {
+                    // Send a request to start loading photos
+                    requestLoading();
+                }
+            }
+        }
+    }
+
+    /**
+     * Registers an avatar change listener.
+     */
+    public void registerAvatarChangeListener(OnAvatarChangeListener listener) {
+        mAvatarListeners.add(listener);
+    }
+
+    /**
+     * Unregisters an avatar change listener.
+     */
+    public void unregisterAvatarChangeListener(OnAvatarChangeListener listener) {
+        mAvatarListeners.remove(listener);
+    }
+
+//    /**
+//     * Sends a notification to all registered listeners that the avatar for the
+//     * specified Gaia ID has changed.
+//     */
+//    public void notifyAvatarChange(String gaiaId) {
+//        if (gaiaId == null) {
+//            return;
+//        }
+//
+//        ensureLoaderThread();
+//        mLoaderThread.notifyAvatarChange(gaiaId);
+//    }
+
+    /**
+     * Registers a media image change listener.
+     */
+    public void registerMediaImageChangeListener(OnMediaImageChangeListener listener) {
+        mMediaImageListeners.add(listener);
+    }
+
+    /**
+     * Unregisters a media image change listener.
+     */
+    public void unregisterMediaImageChangeListener(OnMediaImageChangeListener listener) {
+        mMediaImageListeners.remove(listener);
+    }
+
+//    /**
+//     * Sends a notification to all registered listeners that the media image for the
+//     * specified URL has changed.
+//     */
+//    public void notifyMediaImageChange(MediaImageRequest request, byte[] imageBytes) {
+//        ensureLoaderThread();
+//        MediaImageChangeNotification notification = new MediaImageChangeNotification();
+//        notification.request = request;
+//        notification.imageBytes = imageBytes;
+//        mLoaderThread.notifyMediaImageChange(notification);
+//    }
+
+    /**
+     * Registers a remote image change listener.
+     */
+    public void registerRemoteImageChangeListener(OnRemoteImageChangeListener listener) {
+        mRemoteImageListeners.add(listener);
+    }
+
+    /**
+     * Unregisters a remote image change listener.
+     */
+    public void unregisterRemoteImageChangeListener(OnRemoteImageChangeListener listener) {
+        mRemoteImageListeners.remove(listener);
+    }
+
+//    /**
+//     * Sends a notification to all registered listeners that the remote image for the
+//     * specified URL has changed.
+//     */
+//    public void notifyRemoteImageChange(ImageRequest request, byte[] imageBytes) {
+//        ensureLoaderThread();
+//        RemoteImageChangeNotification notification = new RemoteImageChangeNotification();
+//        notification.request = request;
+//        notification.imageBytes = imageBytes;
+//        mLoaderThread.notifyRemoteImageChange(notification);
+//    }
+
+    /**
+     * Registers an image request completion listener.
+     */
+    public void registerRequestCompleteListener(OnImageRequestCompleteListener listener) {
+        mRequestCompleteListeners.add(listener);
+    }
+
+    /**
+     * Unregisters an image request completion listener.
+     */
+    public void unregisterRequestCompleteListener(OnImageRequestCompleteListener listener) {
+        mRequestCompleteListeners.remove(listener);
+    }
+
+    private void notifyRequestComplete(ImageRequest request) {
+        for (OnImageRequestCompleteListener listener : mRequestCompleteListeners) {
+            listener.onImageRequestComplete(request);
+        }
+    }
+
+    /**
+     * Checks if the photo is present in cache.  If so, sets the photo on the view.
+     *
+     * @return false if the photo needs to be (re)loaded from the provider.
+     */
+    private boolean loadCachedImage(ImageConsumer consumer, ImageRequest request,
+            boolean clearIfNotCached) {
+        ImageHolder holder = mImageHolderCache.get(request);
+        if (holder == null) {
+            if (clearIfNotCached) {
+                // The bitmap has not been loaded - should display the placeholder image.
+                consumer.setBitmap(null, true);
+            }
+            return false;
+        }
+
+        // Put this holder on top of the LRU list
+        mImageHolderCache.put(request, holder);
+
+        if (holder.bytes == null) {
+            if (holder.complete) {
+                consumer.setBitmap(null, false);
+                notifyRequestComplete(request);
+            } else {
+                // The bitmap has not been loaded from server - should display a placeholder.
+                consumer.setBitmap(null, true);
+            }
+            return holder.fresh;
+        }
+
+        // Optionally decode bytes into a bitmap.
+        inflateImage(request, holder);
+
+        Object image = holder.image;
+        if (image instanceof Bitmap) {
+            consumer.setBitmap((Bitmap) image, false);
+        } else if (consumer instanceof DrawableConsumer) {
+            ((DrawableConsumer)consumer).setDrawable((Drawable) image, false);
+        } else if (image instanceof GifDrawable) {
+            consumer.setBitmap(((GifDrawable)image).getFirstFrame(), false);
+        } else if (image != null) {
+            throw new UnsupportedOperationException("Cannot handle drawables of type "
+                    + image.getClass());
+        }
+
+        notifyRequestComplete(request);
+
+        // Put the bitmap in the LRU cache
+        if (image != null && holder.fresh) {
+            mImageCache.put(request, image);
+        }
+
+        // Soften the reference
+        holder.image = null;
+
+        return holder.fresh;
+    }
+
+//    /**
+//     * Returns a photo from cache or null if it is not cached.  Does not trigger a load.
+//     * Returns an empty byte array if the photo is known to be missing.
+//     */
+//    public byte[] getCachedAvatar(AvatarRequest request) {
+//        ImageHolder holder = mImageHolderCache.get(request);
+//        if (holder == null || !holder.fresh) {
+//            return null;
+//        }
+//
+//        if (holder.bytes == null) {
+//            return EMPTY_ARRAY;
+//        }
+//
+//        return holder.bytes;
+//    }
+
+    /**
+     * If necessary, decodes bytes stored in the holder to Bitmap.  As long as the
+     * bitmap is held either by {@link #mImageCache} or by a soft reference in
+     * the holder, it will not be necessary to decode the bitmap.
+     */
+    private void inflateImage(ImageRequest request, ImageHolder holder) {
+        if (holder.image != null) {
+            return;
+        }
+
+        byte[] bytes = holder.bytes;
+        if (bytes == null || bytes.length == 0) {
+            return;
+        }
+
+        holder.image = mImageCache.get(request);
+        if (holder.image != null) {
+            return;
+        }
+
+        // Check the soft reference.  If will be retained if the bitmap is also
+        // in the LRU cache, so we don't need to check the LRU cache explicitly.
+        if (holder.imageRef != null) {
+            holder.image = holder.imageRef.get();
+            if (holder.image != null) {
+                return;
+            }
+        }
+
+        try {
+            holder.image = ImageUtils.decodeMedia(bytes);
+            if (holder.image == null) {
+                holder.imageRef = null;
+            } else if (sUseSoftReferences) {
+                holder.imageRef = new SoftReference<Object>(holder.image);
+            }
+        } catch (OutOfMemoryError e) {
+            // Do nothing - the photo will appear to be missing
+        }
+    }
+
+    public void pause() {
+        mPaused = true;
+    }
+
+    public void resume() {
+        mPaused = false;
+        if (!mPendingRequests.isEmpty()) {
+            requestLoading();
+        }
+    }
+
+    /**
+     * Sends a message to this thread itself to start loading images.  If the current
+     * view contains multiple image views, all of those image views will get a chance
+     * to request their respective photos before any of those requests are executed.
+     * This allows us to load images in bulk.
+     */
+    private void requestLoading() {
+        if (!mLoadingRequested) {
+            mLoadingRequested = true;
+            mMainThreadHandler.sendEmptyMessage(MESSAGE_REQUEST_LOADING);
+        }
+    }
+
+    /**
+     * Processes requests on the main thread.
+     */
+    @Override
+    public boolean handleMessage(Message msg) {
+        switch (msg.what) {
+            case MESSAGE_REQUEST_LOADING: {
+                mLoadingRequested = false;
+                if (!mPaused) {
+//                    ensureLoaderThread();
+//                    mLoaderThread.requestLoading();
+                }
+                return true;
+            }
+
+            case MESSAGE_IMAGES_LOADED: {
+                if (!mPaused) {
+                    processLoadedImages();
+                }
+                return true;
+            }
+
+//            case MESSAGE_AVATAR_CHANGED: {
+//                String gaiaId = (String) msg.obj;
+//
+//                evictImage(new AvatarRequest(gaiaId, AvatarRequest.TINY));
+//                evictImage(new AvatarRequest(gaiaId, AvatarRequest.SMALL));
+//                evictImage(new AvatarRequest(gaiaId, AvatarRequest.MEDIUM));
+//
+//                for (OnAvatarChangeListener listener : mAvatarListeners) {
+//                    listener.onAvatarChanged(gaiaId);
+//                }
+//                return true;
+//            }
+
+            case MESSAGE_MEDIA_IMAGE_CHANGED: {
+                MediaImageChangeNotification notification = (MediaImageChangeNotification) msg.obj;
+                String url = notification.request.getUrl();
+                for (ImageRequest request : mImageHolderCache.snapshot().keySet()) {
+                    if (!request.equals(notification.request)
+                        && (request instanceof MediaImageRequest)
+                        && url.equals(((MediaImageRequest) request).getUrl())) {
+                        evictImage(request);
+                    }
+                }
+
+                for (OnMediaImageChangeListener listener : mMediaImageListeners) {
+                    listener.onMediaImageChanged(url);
+                }
+                return true;
+            }
+
+            case MESSAGE_REMOTE_IMAGE_CHANGED: {
+                final RemoteImageChangeNotification notification =
+                        (RemoteImageChangeNotification) msg.obj;
+                final ImageRequest notificationRequest = notification.request;
+                final ImageHolder holder = mImageHolderCache.get(notificationRequest);
+                final Object image = (holder != null) ? holder.image : null;
+
+                for (OnRemoteImageChangeListener listener : mRemoteImageListeners) {
+                    if (image instanceof Bitmap || image == null) {
+                        listener.onRemoteImageChanged(notificationRequest, (Bitmap) image);
+                    } else if (image instanceof GifDrawable) {
+                        GifDrawable drawable = (GifDrawable) image;
+                        if (listener instanceof OnRemoteDrawableChangeListener) {
+                            ((OnRemoteDrawableChangeListener) listener).onRemoteImageChanged(
+                                    notificationRequest, drawable);
+                        } else {
+                            listener.onRemoteImageChanged(
+                                    notificationRequest, drawable.getFirstFrame());
+                        }
+                    } else {
+                        throw new UnsupportedOperationException("Unsupported remote image type "
+                                + image.getClass());
+                    }
+                }
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void evictImage(ImageRequest request) {
+        mImageCache.remove(request);
+        ImageHolder holder = mImageHolderCache.get(request);
+        if (holder != null) {
+            holder.fresh = false;
+        }
+    }
+
+//    public void ensureLoaderThread() {
+//        if (mLoaderThread == null) {
+//            mLoaderThread = new LoaderThread(mContext.getContentResolver());
+//            mLoaderThread.start();
+//        }
+//    }
+
+    /**
+     * Goes over pending loading requests and displays loaded photos.  If some of the
+     * photos still haven't been loaded, sends another request for image loading.
+     */
+    private void processLoadedImages() {
+        Iterator<ImageConsumer> iterator = mPendingRequests.keySet().iterator();
+        while (iterator.hasNext()) {
+            ImageConsumer consumer = iterator.next();
+            ImageRequest request = mPendingRequests.get(consumer);
+            boolean loaded = loadCachedImage(consumer, request, false);
+            if (loaded) {
+                iterator.remove();
+            }
+        }
+
+        softenCache();
+
+        if (!mPendingRequests.isEmpty()) {
+            requestLoading();
+        }
+    }
+
+    /**
+     * Removes strong references to loaded images to allow them to be garbage collected
+     * if needed.  Some of the images will still be retained by {@link #mImageCache}.
+     */
+    private void softenCache() {
+        for (ImageHolder holder : mImageHolderCache.snapshot().values()) {
+            holder.image = null;
+        }
+    }
+
+    /**
+     * Stores the supplied image in cache.
+     */
+    private void deliverImage(ImageRequest request, byte[] bytes, boolean available,
+            boolean preloading) {
+        ImageHolder holder = new ImageHolder(bytes, available);
+        holder.fresh = true;
+
+        // Unless this image is being preloaded, decode it right away while
+        // we are still on the background thread.
+        if (available && !preloading) {
+            inflateImage(request, holder);
+        }
+
+        mImageHolderCache.put(request, holder);
+    }
+
+    /**
+     * Populates an array of photo IDs that need to be loaded.
+     */
+    private void obtainRequestsToLoad(HashSet<ImageRequest> requests) {
+        requests.clear();
+
+        /*
+         * Since the call is made from the loader thread, the map could be
+         * changing during the iteration. That's not really a problem:
+         * ConcurrentHashMap will allow those changes to happen without throwing
+         * exceptions. Since we may miss some requests in the situation of
+         * concurrent change, we will need to check the map again once loading
+         * is complete.
+         */
+        Iterator<ImageRequest> iterator = mPendingRequests.values().iterator();
+        while (iterator.hasNext()) {
+            ImageRequest key = iterator.next();
+            ImageHolder holder = mImageHolderCache.get(key);
+            if (holder == null || !holder.fresh) {
+                requests.add(key);
+            }
+        }
+    }
+
+//    /**
+//     * The thread that performs loading of photos from the database.
+//     */
+//    private class LoaderThread extends HandlerThread implements Callback {
+//        private static final int MESSAGE_PRELOAD_AVATARS = 0;
+//        private static final int MESSAGE_CONTINUE_PRELOAD = 1;
+//        private static final int MESSAGE_LOAD_IMAGES = 2;
+//        private static final int MESSAGE_NOTIFY_AVATAR_CHANGE = 3;
+//        private static final int MESSAGE_NOTIFY_MEDIA_IMAGE_CHANGE = 4;
+//        private static final int MESSAGE_NOTIFY_REMOTE_IMAGE_CHANGE = 5;
+//
+//        /**
+//         * A pause between preload batches that yields to the UI thread.
+//         */
+//        private static final int AVATAR_PRELOAD_DELAY = 50;
+//
+//        /**
+//         * Number of photos to preload per batch.
+//         */
+//        private static final int PRELOAD_BATCH = 25;
+//
+//        private final HashSet<ImageRequest> mRequests = new HashSet<ImageRequest>();
+////        private List<AvatarRequest> mPreloadRequests = new ArrayList<AvatarRequest>();
+//
+//        private Handler mLoaderThreadHandler;
+//
+//        private static final int PRELOAD_STATUS_NOT_STARTED = 0;
+//        private static final int PRELOAD_STATUS_IN_PROGRESS = 1;
+//        private static final int PRELOAD_STATUS_DONE = 2;
+//
+//        private int mPreloadStatus = PRELOAD_STATUS_NOT_STARTED;
+//
+//        public LoaderThread(ContentResolver resolver) {
+//            super(LOADER_THREAD_NAME);
+//        }
+//
+//        public void ensureHandler() {
+//            if (mLoaderThreadHandler == null) {
+//                mLoaderThreadHandler = new Handler(getLooper(), this);
+//            }
+//        }
+//
+////        /**
+////         * Kicks off preloading of the photos on the background thread.
+////         * Preloading will happen after a delay: we want to yield to the UI thread
+////         * as much as possible.
+////         * <p>
+////         * If preloading is already complete, does nothing.
+////         */
+////        public void startPreloading(List<AvatarRequest> requests) {
+////            ensureHandler();
+////
+////            mLoaderThreadHandler.sendMessage(mLoaderThreadHandler.obtainMessage(
+////                    MESSAGE_PRELOAD_AVATARS, requests));
+////        }
+//
+//        /**
+//         * Kicks off preloading of the next batch of photos on the background thread.
+//         * Preloading will happen after a delay: we want to yield to the UI thread
+//         * as much as possible.
+//         * <p>
+//         * If preloading is already complete, does nothing.
+//         */
+//        public void continuePreloading() {
+//            if (mPreloadStatus == PRELOAD_STATUS_DONE) {
+//                return;
+//            }
+//
+//            ensureHandler();
+//            if (mLoaderThreadHandler.hasMessages(MESSAGE_LOAD_IMAGES)) {
+//                return;
+//            }
+//
+//            mLoaderThreadHandler.sendEmptyMessageDelayed(MESSAGE_CONTINUE_PRELOAD,
+//                    AVATAR_PRELOAD_DELAY);
+//        }
+//
+//        /**
+//         * Sends a message to this thread to load requested photos.
+//         */
+//        public void requestLoading() {
+//            ensureHandler();
+//            mLoaderThreadHandler.removeMessages(MESSAGE_CONTINUE_PRELOAD);
+//            mLoaderThreadHandler.sendEmptyMessage(MESSAGE_LOAD_IMAGES);
+//        }
+//
+//        /**
+//         * Channels a change notification event through the loader thread to ensure
+//         * proper concurrency.
+//         */
+//        public void notifyAvatarChange(String gaiaId) {
+//            ensureHandler();
+//            Message msg = mLoaderThreadHandler.obtainMessage(MESSAGE_NOTIFY_AVATAR_CHANGE, gaiaId);
+//            mLoaderThreadHandler.sendMessage(msg);
+//        }
+//
+//        /**
+//         * Channels a change notification event through the loader thread to ensure
+//         * proper concurrency.
+//         */
+//        public void notifyMediaImageChange(MediaImageChangeNotification notification) {
+//            ensureHandler();
+//            Message msg = mLoaderThreadHandler.obtainMessage(
+//                    MESSAGE_NOTIFY_MEDIA_IMAGE_CHANGE, notification);
+//            mLoaderThreadHandler.sendMessage(msg);
+//        }
+//
+//        /**
+//         * Channels a change notification event through the loader thread to ensure
+//         * proper concurrency.
+//         */
+//        public void notifyRemoteImageChange(RemoteImageChangeNotification notification) {
+//            ensureHandler();
+//            Message msg = mLoaderThreadHandler.obtainMessage(
+//                    MESSAGE_NOTIFY_REMOTE_IMAGE_CHANGE, notification);
+//            mLoaderThreadHandler.sendMessage(msg);
+//        }
+//
+//        /**
+//         * Receives the above message, loads photos and then sends a message
+//         * to the main thread to process them.
+//         */
+//        @Override
+//        public boolean handleMessage(Message msg) {
+//            try {
+//                switch (msg.what) {
+////                    case MESSAGE_PRELOAD_AVATARS:
+////                        @SuppressWarnings("unchecked")
+////                        List<AvatarRequest> requests = (List<AvatarRequest>) msg.obj;
+////                        mPreloadRequests.clear();
+////                        mPreloadRequests.addAll(requests);
+////                        mPreloadStatus = PRELOAD_STATUS_NOT_STARTED;
+////                        preloadAvatarsInBackground();
+////                        break;
+//                    case MESSAGE_CONTINUE_PRELOAD:
+////                        preloadAvatarsInBackground();
+//                        break;
+//                    case MESSAGE_LOAD_IMAGES:
+//                        loadImagesInBackground();
+//                        break;
+//                    case MESSAGE_NOTIFY_AVATAR_CHANGE:
+//                        sendMessageAvatarChange((String) msg.obj);
+//                        break;
+//                    case MESSAGE_NOTIFY_MEDIA_IMAGE_CHANGE:
+//                        sendMessageMediaImageChange((MediaImageChangeNotification) msg.obj);
+//                        break;
+//                    case MESSAGE_NOTIFY_REMOTE_IMAGE_CHANGE:
+//                        sendMessageRemoteImageChange((RemoteImageChangeNotification) msg.obj);
+//                        break;
+//                }
+//                return true;
+//            } catch (Throwable t) {
+//                Thread.getDefaultUncaughtExceptionHandler()
+//                    .uncaughtException(Thread.currentThread(), t);
+//                return false;
+//            }
+//        }
+//
+////        /**
+////         * The first time it is called, figures out which photos need to be preloaded.
+////         * Each subsequent call preloads the next batch of photos and requests
+////         * another cycle of preloading after a delay.  The whole process ends when
+////         * we either run out of photos to preload or fill up cache.
+////         */
+////        private void preloadAvatarsInBackground() {
+////            if (mPreloadStatus == PRELOAD_STATUS_DONE) {
+////                return;
+////            }
+////
+////            if (mPreloadStatus == PRELOAD_STATUS_NOT_STARTED) {
+////                if (mPreloadRequests.isEmpty()) {
+////                    mPreloadStatus = PRELOAD_STATUS_DONE;
+////                } else {
+////                    mPreloadStatus = PRELOAD_STATUS_IN_PROGRESS;
+////                }
+////                continuePreloading();
+////                return;
+////            }
+////
+////            if (mImageHolderCache.size() > mImageHolderCacheRedZoneBytes) {
+////                mPreloadStatus = PRELOAD_STATUS_DONE;
+////                return;
+////            }
+////
+////            mRequests.clear();
+////
+////            int count = 0;
+////            int preloadSize = mPreloadRequests.size();
+////            while (preloadSize > 0 && mRequests.size() < PRELOAD_BATCH) {
+////                preloadSize--;
+////                AvatarRequest request = mPreloadRequests.get(preloadSize);
+////                mPreloadRequests.remove(preloadSize);
+////
+////                if (mImageHolderCache.get(request) == null) {
+////                    mRequests.add(request);
+////                    count++;
+////                }
+////            }
+////
+////            loadImagesFromDatabase(true);
+////
+////            if (preloadSize == 0) {
+////                mPreloadStatus = PRELOAD_STATUS_DONE;
+////            }
+////
+////            if (EsLog.isLoggable(TAG, Log.INFO)) {
+////                Log.v(TAG, "Preloaded " + count + " avatars. "
+////                        + "Cache size (bytes): " + mImageHolderCache.size());
+////            }
+////
+////            // Ask to preload the next batch.
+////            continuePreloading();
+////        }
+//
+//        /**
+//         * Forwards the change notification event to the main thread.
+//         */
+//        private void sendMessageAvatarChange(String gaiaId) {
+//            Message msg = mMainThreadHandler.obtainMessage(MESSAGE_AVATAR_CHANGED, gaiaId);
+//            mMainThreadHandler.sendMessage(msg);
+//        }
+//
+//        /**
+//         * Forwards the change notification event to the main thread.
+//         */
+//        private void sendMessageMediaImageChange(MediaImageChangeNotification notification) {
+//            deliverImage(notification.request, notification.imageBytes, true, false);
+//            Message msg = mMainThreadHandler.obtainMessage(
+//                    MESSAGE_MEDIA_IMAGE_CHANGED, notification);
+//            mMainThreadHandler.sendMessage(msg);
+//        }
+//
+//        /**
+//         * Forwards the change notification event to the main thread.
+//         */
+//        private void sendMessageRemoteImageChange(RemoteImageChangeNotification notification) {
+//            deliverImage(notification.request, notification.imageBytes, true, false);
+//            Message msg = mMainThreadHandler.obtainMessage(
+//                    MESSAGE_REMOTE_IMAGE_CHANGED, notification);
+//            mMainThreadHandler.sendMessage(msg);
+//        }
+//
+//        /**
+//         * Loads photos from the database, puts them in cache and then notifies the UI thread
+//         * that they have been loaded.
+//         */
+//        private void loadImagesInBackground() {
+//            obtainRequestsToLoad(mRequests);
+//            loadImagesFromDatabase(false);
+//            continuePreloading();
+//        }
+//
+////        /**
+////         * Loads photos from the database, puts them in cache and then notifies the UI thread
+////         * that they have been loaded.
+////         */
+////        private void loadImagesFromDatabase(boolean preloading) {
+////            int count = mRequests.size();
+////            if (count == 0) {
+////                return;
+////            }
+////
+////            // Remove loaded photos from the preload queue: we don't want
+////            // the preloading process to load them again.
+////            if (!preloading && mPreloadStatus == PRELOAD_STATUS_IN_PROGRESS) {
+////                mPreloadRequests.removeAll(mRequests);
+////                if (mPreloadRequests.isEmpty()) {
+////                    mPreloadStatus = PRELOAD_STATUS_DONE;
+////                }
+////            }
+////
+////            ArrayList<AvatarRequest> avatarRequests = null;
+////            ArrayList<MediaImageRequest> mediaRequests = null;
+////            ArrayList<EventThemeImageRequest> eventThemeRequests = null;
+////            ArrayList<ImageRequest> remoteRequests = null;
+////
+////            for (ImageRequest request : mRequests) {
+////                if (request instanceof AvatarRequest) {
+////                    if (avatarRequests == null) {
+////                        avatarRequests = new ArrayList<AvatarRequest>();
+////                    }
+////                    avatarRequests.add((AvatarRequest) request);
+////                } else if (request instanceof MediaImageRequest) {
+////                    if (mediaRequests == null) {
+////                        mediaRequests = new ArrayList<MediaImageRequest>();
+////                    }
+////                    mediaRequests.add((MediaImageRequest) request);
+////                } else if (request instanceof EventThemeImageRequest) {
+////                    if (eventThemeRequests == null) {
+////                        eventThemeRequests = new ArrayList<EventThemeImageRequest>();
+////                    }
+////                    eventThemeRequests.add((EventThemeImageRequest) request);
+////                } else {
+////                    if (remoteRequests == null) {
+////                        remoteRequests = new ArrayList<ImageRequest>();
+////                    }
+////                    remoteRequests.add(request);
+////                }
+////            }
+////
+////            if (mediaRequests != null) {
+////                Map<MediaImageRequest, byte[]> avatars = EsPostsData.loadMedia(
+////                        mContext, mediaRequests);
+////
+////                for (Entry<MediaImageRequest, byte[]> entry : avatars.entrySet()) {
+////                    MediaImageRequest request = entry.getKey();
+////                    deliverImage(request, entry.getValue(), true, preloading);
+////                    mRequests.remove(request);
+////                }
+////            }
+////
+////            if (avatarRequests != null) {
+////                Map<AvatarRequest, byte[]> avatars = EsAvatarData.loadAvatars(
+////                        mContext, avatarRequests);
+////
+////                for (Entry<AvatarRequest, byte[]> entry : avatars.entrySet()) {
+////                    AvatarRequest request = entry.getKey();
+////                    deliverImage(request, entry.getValue(), true, preloading);
+////                    mRequests.remove(request);
+////                }
+////            }
+////
+////            if (eventThemeRequests != null) {
+////                for (EventThemeImageRequest request : eventThemeRequests) {
+////                    byte[] themeBytes = EsEventData.loadEventTheme(mContext, request);
+////                    if (themeBytes != null) {
+////                        deliverImage(request, themeBytes, true, false);
+////                        mRequests.remove(request);
+////                    }
+////                }
+////            }
+////
+////            // NOTE: Do not use the same pattern as other images for the following image.
+////            // Since all of these photos are "local" [either because they're physically
+////            // stored on the device or because they're available through the Picasa
+////            // content provider], there is no need to store them in the database. Just
+////            // throw all of the requests into a loader thread and be done with it.
+////            if (remoteRequests != null) {
+////                final int requestCount = remoteRequests.size();
+////                for (int i = 0; i < requestCount; i++) {
+////                    final ImageRequest request = remoteRequests.get(i);
+////
+////                    // Only if we still need to load
+////                    if (mPendingRequests.containsValue(request)) {
+////                        RemoteImageLoader.downloadImage(mContext, request);
+////                    }
+////                }
+////            }
+////
+////            // Remaining photos were not found in the database - mark the cache accordingly.
+////            for (ImageRequest request : mRequests) {
+////                deliverImage(request, null, false, preloading);
+////            }
+////
+////            mMainThreadHandler.sendEmptyMessage(MESSAGE_IMAGES_LOADED);
+////        }
+//    }
+}
diff --git a/src/com/android/mail/photo/util/ImageProxyUtil.java b/src/com/android/mail/photo/util/ImageProxyUtil.java
new file mode 100644
index 0000000..11370ad
--- /dev/null
+++ b/src/com/android/mail/photo/util/ImageProxyUtil.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.util;
+
+import android.net.Uri;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Useful Image Proxy url manipulation routines.
+ */
+public class ImageProxyUtil {
+    private static final Pattern PROXY_HOSTED_IMAGE_URL_RE =
+            Pattern.compile("^(((http(s)?):)?\\/\\/"
+            + "images(\\d)?-.+-opensocial\\.googleusercontent\\.com\\/gadgets\\/proxy\\?)");
+
+    /** Default container, if we don't already have one */
+    static final String DEFAULT_CONTAINER = "esmobile";
+
+    static final String PROXY_DOMAIN_PREFIX = "images";
+    static final String PROXY_DOMAIN_SUFFIX = "-opensocial.googleusercontent.com";
+    static final String PROXY_PATH = "/gadgets/proxy";
+    static final String PARAM_URL = "url";
+    static final String PARAM_CONTAINER = "container";
+    static final String PARAM_GADGET = "gadget";
+    static final String PARAM_REWRITE_MIME = "rewriteMime";
+    static final String PARAM_REFRESH = "refresh";
+    static final String PARAM_HEIGHT = "resize_h";
+    static final String PARAM_WIDTH = "resize_w";
+    static final String PARAM_QUALITY = "resize_q";
+    static final String PARAM_NO_EXPAND = "no_expand";
+    static final String PARAM_FALLBACK_URL = "fallback_url";
+    static final int PROXY_COUNT = 3;
+    static int sProxyIndex;
+
+    public static final int ORIGINAL_SIZE = -1;
+
+    /**
+     * Add size options to the given url.
+     *
+     * @param size the image size
+     * @param url the url to apply the options to
+     * @return a {@code Uri} containting the new image url with options.
+     */
+    public static String setImageUrlSize(int size, String url) {
+        if (url == null) {
+            return url;
+        }
+
+        final String proxyUrl;
+        if (!isProxyHostedUrl(url)) {
+            proxyUrl = createProxyUrl();
+        } else {
+            proxyUrl = url;
+            url = null;
+        }
+        final Uri proxyUri = Uri.parse(proxyUrl);
+        return setImageUrlSizeOptions(size, size, proxyUri, url).toString();
+    }
+
+
+    /**
+     * Add size options to the given url.
+     *
+     * @param width the image width
+     * @param height the image height
+     * @param url the url to apply the options to
+     * @return a {@code Uri} containting the new image url with options.
+     */
+    public static String setImageUrlSize(int width, int height, String url) {
+        if (url == null) {
+            return url;
+        }
+
+        final String proxyUrl;
+        if (!isProxyHostedUrl(url)) {
+            proxyUrl = createProxyUrl();
+        } else {
+            proxyUrl = url;
+            url = null;
+        }
+        final Uri proxyUri = Uri.parse(proxyUrl);
+        return setImageUrlSizeOptions(width, height, proxyUri, url).toString();
+    }
+
+    /**
+     * Returns a default proxy URL.
+     */
+    private static String createProxyUrl() {
+        StringBuffer proxy = new StringBuffer();
+        proxy.append("http://")
+            .append(PROXY_DOMAIN_PREFIX)
+            .append(getNextProxyIndex())
+            .append("-")
+            .append(DEFAULT_CONTAINER)
+            .append(PROXY_DOMAIN_SUFFIX)
+            .append(PROXY_PATH);
+        return proxy.toString();
+    }
+
+    /**
+     * Returns the next proxy index.
+     */
+    private static synchronized int getNextProxyIndex() {
+        int toReturn = ++sProxyIndex;
+        sProxyIndex %= PROXY_COUNT;
+        return toReturn;
+    }
+
+    /**
+     * Add image url options to the given url.
+     *
+     * @param width the image width
+     * @param height the image height
+     * @param proxyUri the uri to apply the options to
+     * @return a {@code Uri} containing the image url with the width and height set.
+     */
+    public static Uri setImageUrlSizeOptions(int width, int height, Uri proxyUri, String imageUrl) {
+        Uri.Builder proxyUriBuilder;
+        Uri newProxyUri;
+
+        proxyUriBuilder = Uri.EMPTY.buildUpon();
+        proxyUriBuilder.authority(proxyUri.getAuthority());
+        proxyUriBuilder.scheme(proxyUri.getScheme());
+        proxyUriBuilder.path(proxyUri.getPath());
+        // Set these here to override any settings in the source proxy URI
+        if (width != ORIGINAL_SIZE && height != ORIGINAL_SIZE) {
+            proxyUriBuilder.appendQueryParameter(PARAM_WIDTH, Integer.toString(width));
+            proxyUriBuilder.appendQueryParameter(PARAM_HEIGHT, Integer.toString(height));
+            proxyUriBuilder.appendQueryParameter(PARAM_NO_EXPAND, "1");
+        }
+
+        newProxyUri = proxyUriBuilder.build();
+
+        final Set<String> paramNames = getQueryParameterNames(proxyUri);
+        for (String key : paramNames) {
+            if (newProxyUri.getQueryParameter(key) != null) {
+                continue;
+            }
+
+            proxyUriBuilder = newProxyUri.buildUpon();
+            if (PARAM_URL.equals(key)) {
+                // Ensure there's only one url parameter
+                proxyUriBuilder.appendQueryParameter(PARAM_URL,
+                        proxyUri.getQueryParameter(PARAM_URL));
+
+            } else if ((width == ORIGINAL_SIZE || height == ORIGINAL_SIZE) &&
+                    (PARAM_WIDTH.equals(key) || PARAM_HEIGHT.equals(key) ||
+                    PARAM_NO_EXPAND.equals(key))) {
+                // Don't allow width / height / no-expand parameters if we ask for a full-size image
+                continue;
+
+            } else {
+                final List<String> values = proxyUri.getQueryParameters(key);
+                for (String value : values) {
+                    proxyUriBuilder.appendQueryParameter(key, value);
+                }
+            }
+            newProxyUri = proxyUriBuilder.build();
+        }
+
+        // The following parameters are mandatory; make sure the URL has them
+        if (imageUrl != null && newProxyUri.getQueryParameter(PARAM_URL) == null) {
+            proxyUriBuilder = newProxyUri.buildUpon();
+            proxyUriBuilder.appendQueryParameter(PARAM_URL, imageUrl);
+            newProxyUri = proxyUriBuilder.build();
+        }
+        if (newProxyUri.getQueryParameter(PARAM_CONTAINER) == null) {
+            proxyUriBuilder = newProxyUri.buildUpon();
+            proxyUriBuilder.appendQueryParameter(PARAM_CONTAINER, DEFAULT_CONTAINER);
+            newProxyUri = proxyUriBuilder.build();
+        }
+        if (newProxyUri.getQueryParameter(PARAM_GADGET) == null) {
+            proxyUriBuilder = newProxyUri.buildUpon();
+            proxyUriBuilder.appendQueryParameter(PARAM_GADGET, "a");
+            newProxyUri = proxyUriBuilder.build();
+        }
+        if (newProxyUri.getQueryParameter(PARAM_REWRITE_MIME) == null) {
+            proxyUriBuilder = newProxyUri.buildUpon();
+            proxyUriBuilder.appendQueryParameter(PARAM_REWRITE_MIME, "image/*");
+            newProxyUri = proxyUriBuilder.build();
+        }
+
+        return newProxyUri;
+    }
+
+    /**
+     * Backwards-compatible implementation of
+     * {@link Uri#getQueryParameterNames()}.
+     */
+    private static Set<String> getQueryParameterNames(Uri uri) {
+        if (uri.isOpaque()) {
+            throw new UnsupportedOperationException("This isn't a hierarchical URI.");
+        }
+
+        String query = uri.getEncodedQuery();
+        if (query == null) {
+            return Collections.emptySet();
+        }
+
+        Set<String> names = new LinkedHashSet<String>();
+        int start = 0;
+        do {
+            int next = query.indexOf('&', start);
+            int end = (next == -1) ? query.length() : next;
+
+            int separator = query.indexOf('=', start);
+            if (separator > end || separator == -1) {
+                separator = end;
+            }
+
+            String name = query.substring(start, separator);
+            names.add(Uri.decode(name));
+
+            // Move start to end of name.
+            start = end + 1;
+        } while (start < query.length());
+
+        return Collections.unmodifiableSet(names);
+    }
+
+    /**
+     * Checks if the host is a valid FIFE host.
+     *
+     * @param url an image url to check
+     *
+     * @return {@code true} iff the url has a valid FIFE host
+     */
+    public static boolean isProxyHostedUrl(String url) {
+        if (url == null) {
+            return false;
+        }
+
+        final Matcher matcher = PROXY_HOSTED_IMAGE_URL_RE.matcher(url);
+        return matcher.find();
+    }
+
+    /**
+     * Checks if the host is a valid FIFE host.
+     *
+     * @param uri an image url to check
+     *
+     * @return {@code true} iff the url has a valid FIFE host
+     */
+    public static boolean isProxyHostedUri(Uri uri) {
+        return isProxyHostedUrl(uri.toString());
+    }
+}
diff --git a/src/com/android/mail/photo/util/ImageUtils.java b/src/com/android/mail/photo/util/ImageUtils.java
new file mode 100644
index 0000000..03837af
--- /dev/null
+++ b/src/com/android/mail/photo/util/ImageUtils.java
@@ -0,0 +1,1375 @@
+/*

+ * Copyright (C) 2011 Google Inc.

+ * Licensed to 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.mail.photo.util;

+

+import android.app.Dialog;

+import android.app.ProgressDialog;

+import android.content.ContentResolver;

+import android.content.ContentValues;

+import android.content.Context;

+import android.content.res.Resources;

+import android.database.Cursor;

+import android.graphics.Bitmap;

+import android.graphics.Bitmap.CompressFormat;

+import android.graphics.Bitmap.Config;

+import android.graphics.BitmapFactory;

+import android.graphics.Canvas;

+import android.graphics.Matrix;

+import android.graphics.Paint;

+import android.graphics.Point;

+import android.graphics.Rect;

+import android.graphics.drawable.Drawable;

+import android.media.ExifInterface;

+import android.net.Uri;

+import android.os.Build;

+import android.os.Environment;

+import android.provider.MediaStore.Images;

+import android.provider.MediaStore.Images.ImageColumns;

+import android.provider.MediaStore.Images.Thumbnails;

+import android.provider.MediaStore.MediaColumns;

+import android.text.TextUtils;

+import android.util.Base64;

+import android.util.Log;

+

+import com.android.mail.R;

+import com.android.mail.photo.PhotoViewActivity;

+

+import java.io.ByteArrayInputStream;

+import java.io.ByteArrayOutputStream;

+import java.io.File;

+import java.io.FileInputStream;

+import java.io.FileNotFoundException;

+import java.io.IOException;

+import java.io.InputStream;

+import java.io.OutputStream;

+import java.text.SimpleDateFormat;

+import java.util.Date;

+

+/**

+ * Image utilities

+ */

+public class ImageUtils {

+    /** Specifies no background colour should be added during image resizing */

+    public static int NO_COLOR = 0;

+

+    public static final int INSERT_PHOTO_DIALOG_ID = R.id.dialog_insert_photo;

+    

+    // added from EsService

+    public static final int CROP_NONE = 0;

+    public static final int CROP_SQUARE = 1;

+    public static final int CROP_WIDE = 2;

+

+    private static int MICRO_KIND_MAX_DIMENSION = 0;

+    private static int MINI_KIND_MAX_DIMENSION = 0;

+

+    private static int DEFAULT_JPEG_QUALITY = 90;

+

+    // Logging

+    private static final String TAG = "ImageUtils";

+

+    // Paints and modes

+    private static final Paint sResizePaint = new Paint(Paint.FILTER_BITMAP_FLAG);

+

+    /** The paint used for cropped photos */

+    private static final Paint sCropPaint;

+    static {

+        sCropPaint = new Paint();

+        sCropPaint.setAntiAlias(true);

+        sCropPaint.setFilterBitmap(true);

+        sCropPaint.setDither(true);

+    }

+

+    private static final Paint sOutStrokePaint = new Paint();

+    static {

+        sOutStrokePaint.setStrokeWidth(1);

+        sOutStrokePaint.setStyle(Paint.Style.STROKE);

+        sOutStrokePaint.setColor(0xff999999);

+    }

+

+    private static final Paint sInStrokePaint = new Paint();

+    static {

+        sInStrokePaint.setStrokeWidth(1);

+        sInStrokePaint.setStyle(Paint.Style.STROKE);

+        sInStrokePaint.setColor(0xfff0f0f0);

+    }

+

+    /** Minimum class memory class to use full-res photos */

+    private final static long MIN_NORMAL_CLASS = 32;

+    /** Minimum class memory class to use small photos */

+    private final static long MIN_SMALL_CLASS = 24;

+    public static final boolean sUseLowResImages;

+    static {

+        if (Build.VERSION.SDK_INT >= 11) {

+            // On HC and beyond, assume devices are more capable

+            sUseLowResImages = false;

+        } else {

+            if (PhotoViewActivity.sMemoryClass >= MIN_SMALL_CLASS) {

+                sUseLowResImages = false;

+            } else {

+                // If we're not in the small class, use low-res [i.e. RGB_565] photos

+                sUseLowResImages = true;

+            }

+        }

+    }

+

+    public static enum ImageSize {

+        EXTRA_SMALL,

+        SMALL,

+        NORMAL,

+    }

+

+    public static final ImageSize sUseImageSize;

+    static {

+        // On HC and beyond, assume devices are more capable

+        if (Build.VERSION.SDK_INT >= 11) {

+            sUseImageSize = ImageSize.NORMAL;

+        } else {

+            if (PhotoViewActivity.sMemoryClass >= MIN_NORMAL_CLASS) {

+                // We have plenty of memory; use full sized photos

+                sUseImageSize = ImageSize.NORMAL;

+            } else if (PhotoViewActivity.sMemoryClass >= MIN_SMALL_CLASS) {

+                // We have slight less memory; use smaller sized photos

+                sUseImageSize = ImageSize.SMALL;

+            } else {

+                // We have little memory; use very small sized photos

+                sUseImageSize = ImageSize.EXTRA_SMALL;

+            }

+        }

+    }

+

+    /**

+     * Interface for when a dialog informing about a camera photo insertion

+     * should be shown or hidden.

+     */

+    public interface InsertCameraPhotoDialogDisplayer {

+        public void showInsertCameraPhotoDialog();

+        public void hideInsertCameraPhotoDialog();

+    }

+

+    /**

+     * This class cannot be instantiated

+     */

+    private ImageUtils() {

+    }

+

+

+    /**

+     * Parses an image from a byte array. May return either a Bitmap or

+     * a {@link Drawable}.

+     *

+     * @param data byte array of compressed image data

+     * @return The decoded bitmap or {@link Drawable}, or null if the image could not be decoded.

+     */

+    public static Object decodeMedia(byte[] data) {

+        try {

+            if (GifDrawable.isGif(data)) {

+                return new GifDrawable(data);

+            } else {

+                return BitmapFactory.decodeByteArray(data, 0, data.length);

+            }

+        } catch (OutOfMemoryError oome) {

+            Log.e(TAG, "ImageUtils#decodeMedia(byte[]) threw an OOME", oome);

+            return null;

+        }

+    }

+

+    /**

+     * Wrapper around {@link BitmapFactory#decodeByteArray(byte[], int, int)}

+     * that returns {@code null} on {@link OutOfMemoryError}.

+     *

+     * @param data byte array of compressed image data

+     * @param offset offset into imageData for where the decoder should begin

+     *               parsing.

+     * @param length the number of bytes, beginning at offset, to parse

+     * @return The decoded bitmap, or null if the image could not be decode.

+     */

+    public static Bitmap decodeByteArray(byte[] data, int offset, int length) {

+        try {

+            return BitmapFactory.decodeByteArray(data, offset, length);

+        } catch (OutOfMemoryError oome) {

+            Log.e(TAG, "ImageUtils#decodeByteArray(byte[], int, int) threw an OOME", oome);

+            return null;

+        }

+    }

+

+    /**

+     * Wrapper around {@link BitmapFactory#decodeByteArray(byte[], int, int,

+     * BitmapFactory.Options)} that returns {@code null} on {@link

+     * OutOfMemoryError}.

+     *

+     * @param data byte array of compressed image data

+     * @param offset offset into imageData for where the decoder should begin

+     *               parsing.

+     * @param length the number of bytes, beginning at offset, to parse

+     * @param opts null-ok; Options that control downsampling and whether the

+     *             image should be completely decoded, or just is size returned.

+     * @return The decoded bitmap, or null if the image could not be decode.

+     */

+    public static Bitmap decodeByteArray(byte[] data, int offset, int length,

+            BitmapFactory.Options opts) {

+        try {

+            return BitmapFactory.decodeByteArray(data, offset, length, opts);

+        } catch (OutOfMemoryError oome) {

+            Log.e(TAG, "ImageUtils#decodeByteArray(byte[], int, int, Options) threw an OOME", oome);

+            return null;

+        }

+    }

+

+    /**

+     * Wrapper around {@link BitmapFactory#decodeResource(Resources, int)}

+     * that returns {@code null} on {@link OutOfMemoryError}.

+     *

+     * @param res The resources object containing the image data

+     * @param id The resource id of the image data

+     * @return The decoded bitmap, or null if the image could not be decode.

+     */

+    public static Bitmap decodeResource(Resources res, int id) {

+        try {

+            return BitmapFactory.decodeResource(res, id);

+        } catch (OutOfMemoryError oome) {

+            Log.e(TAG, "ImageUtils#decodeResource(Resources, int) threw an OOME", oome);

+            return null;

+        }

+    }

+

+    /**

+     * Wrapper around {@link BitmapFactory#decodeStream(InputStream, Rect,

+     * BitmapFactory.Options)} that returns {@code null} on {@link

+     * OutOfMemoryError}.

+     *

+     * @param is The input stream that holds the raw data to be decoded into a

+     *           bitmap.

+     * @param outPadding If not null, return the padding rect for the bitmap if

+     *                   it exists, otherwise set padding to [-1,-1,-1,-1]. If

+     *                   no bitmap is returned (null) then padding is

+     *                   unchanged.

+     * @param opts null-ok; Options that control downsampling and whether the

+     *             image should be completely decoded, or just is size returned.

+     * @return The decoded bitmap, or null if the image data could not be

+     *         decoded, or, if opts is non-null, if opts requested only the

+     *         size be returned (in opts.outWidth and opts.outHeight)

+     */

+    public static Bitmap decodeStream(InputStream is, Rect outPadding, BitmapFactory.Options opts) {

+        try {

+            return BitmapFactory.decodeStream(is, outPadding, opts);

+        } catch (OutOfMemoryError oome) {

+            Log.e(TAG, "ImageUtils#decodeStream(InputStream, Rect, Options) threw an OOME", oome);

+            return null;

+        }

+    }

+

+    /**

+     * Create a bitmap from a local URI

+     *

+     * @param resolver The ContentResolver

+     * @param uri The local URI

+     * @param maxSize The maximum size (either width or height)

+     *

+     * @return The new bitmap

+     */

+    public static Bitmap createLocalBitmap(ContentResolver resolver, Uri uri, int maxSize) {

+        InputStream inputStream = null;

+        try {

+            final BitmapFactory.Options opts = new BitmapFactory.Options();

+            final Point bounds = getImageBounds(resolver, uri);

+

+            inputStream = resolver.openInputStream(uri);

+            opts.inSampleSize = Math.max(bounds.x / maxSize, bounds.y / maxSize);

+

+            final Bitmap decodedBitmap = decodeStream(inputStream, null, opts);

+

+            // Correct thumbnail orientation as necessary

+            return rotateBitmap(resolver, uri, decodedBitmap);

+

+        } catch (FileNotFoundException exception) {

+            // Do nothing - the photo will appear to be missing

+        } catch (IOException exception) {

+            // Do nothing - the photo will appear to be missing

+        } finally {

+            try {

+                if (inputStream != null) {

+                    inputStream.close();

+                }

+            } catch (IOException ignore) {

+            }

+        }

+        return null;

+    }

+

+    /**

+     * Creates a bitmap from the given bytes at the specified dimension and with the

+     * specified crop. Sub-sample as necessary.

+     *

+     * TODO(toddke) Currently, we only perform the wide crop in this method. The square

+     * crop is already handled via the FIFE / Image Proxy URLs. When the photo cache and

+     * image cache are merged, we'll need to support square crop as well.

+     */

+    public static Bitmap createBitmap(byte[] imageBytes, int width, int height, int cropType) {

+        if (imageBytes == null || imageBytes.length == 0) {

+            return null;

+        }

+

+        final ByteArrayInputStream inputStream = new ByteArrayInputStream(imageBytes);

+        final boolean useLowResImages = ImageUtils.sUseLowResImages;

+        try {

+            final BitmapFactory.Options opts = new BitmapFactory.Options();

+            final Point bounds = getImageBounds(imageBytes);

+

+            if (Log.isLoggable(TAG, Log.DEBUG)) {

+                Log.d(TAG, "PhotoCache#createBitmap; w: " +

+                        bounds.x + ", h: " + bounds.y + ", max: " + width);

+            }

+            opts.inSampleSize = Math.max(bounds.x / width, bounds.y / height);

+            if (useLowResImages) {

+                opts.inPreferredConfig = Config.RGB_565;

+            }

+

+            final Bitmap decodedBitmap = decodeStream(inputStream, null, opts);

+            if (decodedBitmap == null) {

+                return null;

+            }

+

+            final Bitmap croppedBitmap;

+            if (cropType == CROP_WIDE) { // changed from EsService.CROP_WIDE

+                croppedBitmap = cropWideBitmap(decodedBitmap, width, height);

+                decodedBitmap.recycle();

+

+                if (croppedBitmap == null) {

+                    return null;

+                }

+            } else {

+                croppedBitmap = decodedBitmap;

+            }

+

+            if (useLowResImages) {

+                final Bitmap lowResBitmap = ImageUtils.getLowResBitmap(croppedBitmap);

+                if (lowResBitmap != croppedBitmap) {

+                    croppedBitmap.recycle();

+                }

+                return lowResBitmap;

+            } else {

+                return croppedBitmap;

+            }

+        } catch (OutOfMemoryError e) {

+            // Do nothing - the photo will appear to be missing

+        } finally {

+            try {

+                inputStream.close();

+            } catch (IOException ignore) {

+            }

+        }

+        return null;

+    }

+

+    /**

+     * Crops the given bitmap according to the {@link EsService#CROP_WIDE} style. The

+     * center of the bitmap is used to create a new bitmap of exactly width x height

+     * pixels, maintaining the original aspect ratio. The original bitmap will be

+     * cropped and/or enlarged as necessary.

+     */

+    private static Bitmap cropWideBitmap(Bitmap inputBitmap, int width, int height) {

+        final Rect srcRect;

+

+        final int srcWidth = inputBitmap.getWidth();

+        final int srcHeight = inputBitmap.getHeight();

+        final int dstWidth  = width;

+        final int dstHeight = height;

+

+        if (srcWidth == dstWidth && srcHeight == dstHeight) {

+            // Photo is exactly the same size as the on-screen image

+            srcRect = new Rect(0, 0, srcWidth, srcHeight);

+        } else {

+            // create a source rectangle of the same aspect ratio as the requested size.

+            int cropWidth = srcWidth;

+            int cropHeight = srcHeight;

+            if (srcWidth * dstHeight > srcHeight * dstWidth) {

+                // the input bitmap is a wider aspect ratio.  Crop the sides.

+                cropWidth = srcHeight * dstWidth / dstHeight;

+            } else {

+                // The input bitmap is a taller aspect ratio.  Crop the top and bottom.

+                cropHeight = srcWidth * dstHeight / dstWidth;

+            }

+

+            final int left = (srcWidth - cropWidth) / 2;

+            final int top = (srcHeight - cropHeight) / 2;

+            srcRect = new Rect(left, top, left + cropWidth, top + cropHeight);

+        }

+

+        // Create the new bitmap

+        final Bitmap.Config bitmapConfig =

+                ImageUtils.sUseLowResImages ? Bitmap.Config.RGB_565 : Bitmap.Config.ARGB_8888;

+        final Bitmap bitmap = Bitmap.createBitmap(width, height, bitmapConfig);

+        if (bitmap == null) {

+            return null;

+        }

+

+        final Canvas canvas = new Canvas(bitmap);

+        final Rect dstRect = new Rect(0, 0, width, height);

+

+        synchronized (sCropPaint) {

+            canvas.drawBitmap(inputBitmap, srcRect, dstRect, sCropPaint);

+        }

+

+        return bitmap;

+    }

+

+    /**

+     * Gets the image bounds

+     */

+    private static Point getImageBounds(byte[] imageBytes) {

+        final BitmapFactory.Options opts = new BitmapFactory.Options();

+        final ByteArrayInputStream inputStream = new ByteArrayInputStream(imageBytes);

+

+        try {

+            opts.inJustDecodeBounds = true;

+            decodeStream(inputStream, null, opts);

+            return new Point(opts.outWidth, opts.outHeight);

+        } finally {

+            try {

+                inputStream.close();

+            } catch (IOException ignore) {

+            }

+        }

+    }

+

+    /**

+     * Create a center-cropped bitmap from a uri.

+     *

+     * @param resolver The ContentResolver

+     * @param uri The uri

+     * @param width The width of the output bitmap

+     * @param height The height of the output bitmap

+     *

+     * @return the new bitmap

+     */

+    public static Bitmap createCroppedBitmap(ContentResolver resolver, Uri uri,

+            int width, int height) {

+        try {

+            InputStream inputStream = resolver.openInputStream(uri);

+            final BitmapFactory.Options opts = new BitmapFactory.Options();

+            opts.inJustDecodeBounds = true;

+            decodeStream(inputStream, null, opts);

+            inputStream.close();

+

+            // use Math.min() here to ensure that each of the image dimensions are

+            // >= the target size

+            inputStream = resolver.openInputStream(uri);

+            opts.inJustDecodeBounds = false;

+            opts.inSampleSize = Math.min(opts.outWidth / width, opts.outHeight / height);

+            Bitmap srcBitmap = decodeStream(inputStream, null, opts);

+            inputStream.close();

+            if (srcBitmap == null) {

+                return null;

+            }

+            final int srcWidth = srcBitmap.getWidth();

+            final int srcHeight = srcBitmap.getHeight();

+

+            if (srcWidth == width && srcHeight == height) {

+                return srcBitmap;

+            }

+

+            Bitmap destBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);

+            if (destBitmap == null) {

+                srcBitmap.recycle();

+                return null;

+            }

+

+            final Canvas canvas = new Canvas(destBitmap);

+            int croppedWidth = srcWidth;

+            int croppedHeight = srcHeight;

+            // We want to take the center part of the image with the same aspect

+            // ratio as the target, and crop the rest.  The same behavior as CENTER_CROP.

+            if (srcWidth * height > srcHeight * width) {

+                // The input bitmap is a wider aspect ratio.  Crop the sides.

+                croppedWidth = srcHeight * width / height;

+            } else {

+                // The input bitmap is a taller aspect ratio.  Crop the top and bottom.

+                croppedHeight = srcWidth * height / width;

+            }

+            final int left = (srcWidth - croppedWidth) / 2;

+            final int top = (srcHeight - croppedHeight) / 2;

+            final Rect src = new Rect(left, top, left + croppedWidth, top + croppedHeight);

+            synchronized (sResizePaint) {

+                canvas.drawBitmap(srcBitmap, src, new Rect(0, 0, width, height), sResizePaint);

+            }

+            srcBitmap.recycle();

+

+            // correct orientation, as necessary

+            return rotateBitmap(resolver, uri, destBitmap);

+        } catch (FileNotFoundException exception) {

+            return null;

+        } catch (IOException exception) {

+            return null;

+        }

+    }

+

+    /**

+     * Returns the maximum dimension in pixels for a given MediaStore.Images.Thumbnails kind.

+     *

+     * @param context The context

+     * @param kind MICRO_KIND or MINI_KIND

+     *

+     * @return maxDimension in pixels

+     */

+    public static int getMaxThumbnailDimension(Context context, int kind) {

+        // determine max dimension based on kind

+        final int maxDimension;

+        switch (kind) {

+            case Thumbnails.MICRO_KIND:

+                maxDimension = getThumbnailSize(context, Thumbnails.MICRO_KIND);

+                break;

+

+            case Thumbnails.MINI_KIND:

+                maxDimension = getThumbnailSize(context, Thumbnails.MINI_KIND);

+                break;

+

+            default:

+                if (Log.isLoggable(TAG, Log.DEBUG)) {

+                    Log.d(TAG, "illegal kind=" + kind + " specified; using MINI_KIND");

+                }

+                maxDimension = getThumbnailSize(context, Thumbnails.MINI_KIND);

+                break;

+        }

+        return maxDimension;

+    }

+

+    /**

+     * Convert thumbnail dimensions to pixels

+     *

+     * @param context The context

+     * @param kind The kind

+     *

+     * @return The size of the thumbnail in pixels

+     */

+    public static int getThumbnailSize(Context context, int kind) {

+        switch (kind) {

+            case Thumbnails.MICRO_KIND: {

+                if (MICRO_KIND_MAX_DIMENSION == 0) {

+                    MICRO_KIND_MAX_DIMENSION = context.getResources().getDimensionPixelSize(

+                            R.dimen.micro_kind_max_dimension);

+                }

+                return MICRO_KIND_MAX_DIMENSION;

+            }

+

+            case Thumbnails.MINI_KIND:

+            default: {

+                if (MINI_KIND_MAX_DIMENSION == 0) {

+                    MINI_KIND_MAX_DIMENSION = context.getResources().getDimensionPixelSize(

+                            R.dimen.mini_kind_max_dimension);

+                }

+                return MINI_KIND_MAX_DIMENSION;

+            }

+        }

+    }

+

+    /**

+     * Scale a bitmap to a square bitmap

+     *

+     * @param imageBytes The input bitmap

+     * @param size The width and height

+     *

+     * @return The new bitmap

+     */

+    public static byte[] resizeToSquareBitmap(byte[] imageBytes, int size) {

+        return resizeToSquareBitmap(imageBytes, size, NO_COLOR);

+    }

+

+    /**

+     * Scale a bitmap to a square bitmap

+     *

+     * @param imageBytes The input bitmap

+     * @param size The width and height

+     * @param backgroundColor The background color that should be used for translucent avatars.

+     *

+     * @return The new bitmap

+     */

+    public static byte[] resizeToSquareBitmap(byte[] imageBytes, int size, int backgroundColor) {

+        if (imageBytes == null) {

+            return imageBytes;

+        }

+

+        final BitmapFactory.Options dbo = new BitmapFactory.Options();

+        dbo.inJustDecodeBounds = true;

+        decodeByteArray(imageBytes, 0, imageBytes.length, dbo);

+

+        int nativeWidth = dbo.outWidth;

+        int nativeHeight = dbo.outHeight;

+        if (Log.isLoggable(TAG, Log.DEBUG)) {

+            Log.d(TAG, "resizeToSquareBitmap: Input: " + nativeWidth + "x" + nativeHeight

+                    + ", resize to: " + size);

+        }

+

+        Bitmap bitmap;

+        int sampleSize = Math.min(nativeWidth / size, nativeHeight / size);

+        if (sampleSize > 1) {

+            BitmapFactory.Options options = new BitmapFactory.Options();

+            options.inSampleSize = sampleSize;

+            bitmap = decodeByteArray(imageBytes, 0, imageBytes.length, options);

+        } else {

+            bitmap = decodeByteArray(imageBytes, 0, imageBytes.length);

+        }

+

+        if (bitmap == null) {

+            return null;

+        }

+

+        Bitmap scaledBitmap = resizeToSquareBitmap(bitmap, size, backgroundColor);

+        bitmap.recycle();

+

+        if (scaledBitmap == null) {

+            return null;

+        }

+

+        ByteArrayOutputStream stream = new ByteArrayOutputStream();

+        scaledBitmap.compress(CompressFormat.JPEG, 80, stream);

+        scaledBitmap.recycle();

+        scaledBitmap = null;

+

+        return stream.toByteArray();

+    }

+

+    /**

+     * Scale a bitmap to a square bitmap

+     *

+     * @param inputBitmap The input bitmap

+     * @param size The width and height

+     *

+     * @return The new bitmap

+     */

+    public static Bitmap resizeToSquareBitmap(Bitmap inputBitmap, int size) {

+        return resizeToSquareBitmap(inputBitmap, size, NO_COLOR);

+    }

+

+    /**

+     * Scale a bitmap to a square bitmap

+     *

+     * @param inputBitmap The input bitmap

+     * @param size The width and height

+     * @param backgroundColor The solid color used to paint the image background. If

+     *              {@link #NO_COLOR}, no background will be painted.

+     *

+     * @return The new bitmap

+     */

+    public static Bitmap resizeToSquareBitmap(Bitmap inputBitmap, int size,

+            int backgroundColor) {

+        if (Log.isLoggable(TAG, Log.DEBUG)) {

+            Log.d(TAG, "resizeToSquareBitmap: Input: " + inputBitmap.getWidth()

+                    + "x" + inputBitmap.getHeight() + ", output:" + size + "x" + size);

+        }

+

+        final Bitmap bitmap;

+        try {

+            // Create the new bitmap

+            bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);

+        } catch (OutOfMemoryError e) {

+            Log.w(TAG, "resizeToSquareBitmap OutOfMemoryError for image size: " + size, e);

+            return null;

+        }

+

+        if (bitmap == null) {

+            return null;

+        }

+

+        final Canvas canvas = new Canvas(bitmap);

+        if (backgroundColor != NO_COLOR) {

+            canvas.drawColor(backgroundColor);

+        }

+

+        if (inputBitmap.getWidth() != size || inputBitmap.getHeight() != size) {

+            final Rect src = new Rect(0, 0, inputBitmap.getWidth(), inputBitmap.getHeight());

+            final Rect dest = new Rect(0, 0, size, size);

+            synchronized(sResizePaint) {

+                canvas.drawBitmap(inputBitmap, src, dest, sResizePaint);

+            }

+        } else {

+            canvas.drawBitmap(inputBitmap, 0, 0, null);

+        }

+

+        return bitmap;

+    }

+

+    /**

+     * Resize and crop a bitmap.

+     *

+     * @param inputBitmap The input bitmap

+     * @param height The height

+     * @param width The width

+     *

+     * @return The new bitmap

+     */

+    public static Bitmap resizeAndCropBitmap(Bitmap inputBitmap, int width, int height) {

+        if (Log.isLoggable(TAG, Log.DEBUG)) {

+            Log.d(TAG, "resizeAndCropBitmap: Input: " + inputBitmap.getWidth()

+                    + "x" + inputBitmap.getHeight() + ", output:"

+                    + width + "x" + height);

+        }

+

+        // Create the new bitmap

+        final Bitmap bitmap = Bitmap.createBitmap(

+                width, height, Bitmap.Config.ARGB_8888);

+        if (bitmap == null) {

+            return null;

+        }

+

+        final Canvas canvas = new Canvas(bitmap);

+        if (inputBitmap.getWidth() != width || inputBitmap.getHeight() != height) {

+            // create a source rectangle of the same aspect ratio as the requested size.

+            int croppedWidth = inputBitmap.getWidth();

+            int croppedHeight = inputBitmap.getHeight();

+            if (inputBitmap.getWidth() * height > inputBitmap.getHeight() * width) {

+                // the input bitmap is a wider aspect ratio.  Crop the sides.

+                croppedWidth = inputBitmap.getHeight() * width / height;

+            } else {

+                // The input bitmap is a taller aspect ratio.  Crop the top and bottom.

+                croppedHeight = inputBitmap.getWidth() * height / width;

+            }

+

+            int left = (inputBitmap.getWidth() - croppedWidth) / 2;

+            int top = (inputBitmap.getHeight() - croppedHeight) / 2;

+            final Rect src = new Rect(left, top,

+                    left + croppedWidth, top + croppedHeight);

+            final Rect dest = new Rect(0, 0, width, height);

+            synchronized(sResizePaint) {

+                canvas.drawBitmap(inputBitmap, src, dest, sResizePaint);

+            }

+        } else {

+            canvas.drawBitmap(inputBitmap, 0, 0, null);

+        }

+

+        return bitmap;

+    }

+

+    /**

+     * Resize a bitmap

+     *

+     * @param imageBytes The image bytes

+     * @param width The width of the resized image

+     * @param height The width of the resized image

+     *

+     * @return The resized bitmap

+     */

+    public static Bitmap resizeBitmap(byte[] imageBytes, int width, int height) {

+        final BitmapFactory.Options dbo = new BitmapFactory.Options();

+        dbo.inJustDecodeBounds = true;

+        decodeByteArray(imageBytes, 0, imageBytes.length, dbo);

+

+        final int nativeWidth = dbo.outWidth;

+        final int nativeHeight = dbo.outHeight;

+        if (Log.isLoggable(TAG, Log.DEBUG)) {

+            Log.d(TAG, "resizeBitmap: Input: " + nativeWidth + "x" + nativeHeight

+                    + ", resize to: " + width + "x" + height);

+        }

+

+        final Bitmap srcBitmap;

+        if (nativeWidth > width || nativeHeight > height) {

+            final float bitmapWidth = (nativeWidth * width) / nativeHeight;

+            final float bitmapHeight = (nativeHeight * height) / nativeWidth;

+

+            if (nativeWidth / bitmapWidth > 1 || nativeHeight / bitmapHeight > 1) {

+                // Create a scaled bitmap

+                final BitmapFactory.Options options = new BitmapFactory.Options();

+                options.inSampleSize = Math.max(nativeWidth / (int)bitmapWidth,

+                        nativeHeight / (int)bitmapHeight);

+                srcBitmap = decodeByteArray(imageBytes, 0, imageBytes.length, options);

+            } else {

+                srcBitmap = decodeByteArray(imageBytes, 0, imageBytes.length);

+            }

+        } else {

+            srcBitmap = decodeByteArray(imageBytes, 0, imageBytes.length);

+        }

+

+        if (srcBitmap == null) {

+            return null;

+        }

+

+        // Crop the bitmap

+        final Bitmap croppedBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);

+        if (croppedBitmap == null) {

+            srcBitmap.recycle();

+            return null;

+        }

+

+        final int srcWidth = srcBitmap.getWidth();

+        final int srcHeight = srcBitmap.getHeight();

+

+        int croppedWidth = srcWidth;

+        int croppedHeight = srcHeight;

+        if (nativeWidth * height > width * nativeHeight) {

+            //  the input bitmap is a wider aspect ratio. Crop the sides.

+            croppedWidth = srcBitmap.getHeight() * width / height;

+        } else {

+            // the input bitmap is a taller aspect ratio.  Crop the top and bottom.

+            croppedHeight = srcBitmap.getWidth() * height / width;

+        }

+

+        if (Log.isLoggable(TAG, Log.DEBUG)) {

+            Log.d(TAG, "resizeBitmap: cropped: " + croppedWidth + "x" + croppedHeight);

+        }

+

+        final int srcLeft = (srcWidth - croppedWidth) / 2;

+        final int srcTop = (srcHeight - croppedHeight) / 2;

+        final Rect src = new Rect(srcLeft, srcTop, srcLeft + croppedWidth, srcTop + croppedHeight);

+        final Rect dest = new Rect(0, 0, width, height);

+

+        final Canvas croppedCanvas = new Canvas(croppedBitmap);

+        croppedCanvas.drawColor(0xffe0e0e0);

+        synchronized (sResizePaint) {

+            croppedCanvas.drawBitmap(srcBitmap, src, dest, sResizePaint);

+        }

+

+        srcBitmap.recycle();

+

+        return croppedBitmap;

+    }

+

+    /**

+     * Resize the bitmap so that its height does not exceed the supplied value.

+     *

+     * @param imageBytes The image bytes

+     * @param height The maximum height of the scaled image

+     *

+     * @return The resized bitmap as bytes

+     */

+    public static byte[] resizeBitmapToHeight(byte[] imageBytes, int height) {

+        if (imageBytes == null) {

+            return imageBytes;

+        }

+

+        final BitmapFactory.Options dbo = new BitmapFactory.Options();

+        dbo.inJustDecodeBounds = true;

+        decodeByteArray(imageBytes, 0, imageBytes.length, dbo);

+

+        int nativeWidth = dbo.outWidth;

+        int nativeHeight = dbo.outHeight;

+        if (Log.isLoggable(TAG, Log.DEBUG)) {

+            Log.d(TAG, "scaleBitmap: Input: " + nativeWidth + "x" + nativeHeight

+                    + ", resize to: " + height);

+        }

+

+        if (nativeHeight <= height) {

+            return imageBytes;

+        }

+

+        int width = (int) ((float) nativeWidth / nativeHeight * height);

+        Bitmap bitmap;

+        if (nativeWidth / width > 1 || nativeHeight / height > 1) {

+            BitmapFactory.Options options = new BitmapFactory.Options();

+            options.inSampleSize = Math.max(nativeWidth / width, nativeHeight / height);

+            bitmap = decodeByteArray(imageBytes, 0, imageBytes.length, options);

+            if (bitmap == null) {

+                return null;

+            }

+            nativeWidth = bitmap.getWidth();

+            nativeHeight = bitmap.getHeight();

+        } else {

+            bitmap = decodeByteArray(imageBytes, 0, imageBytes.length);

+            if (bitmap == null) {

+                return null;

+            }

+        }

+

+        Bitmap scaledBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);

+        if (scaledBitmap == null) {

+            bitmap.recycle();

+            return null;

+        }

+

+        final Canvas canvas = new Canvas(scaledBitmap);

+        synchronized (sResizePaint) {

+            canvas.drawBitmap(bitmap, new Rect(0, 0, nativeWidth, nativeHeight),

+                    new Rect(0, 0, width, height), sResizePaint);

+        }

+        bitmap.recycle();

+        bitmap = null;

+

+        ByteArrayOutputStream stream = new ByteArrayOutputStream();

+        scaledBitmap.compress(CompressFormat.PNG, 100, stream);

+        scaledBitmap.recycle();

+        scaledBitmap = null;

+

+        return stream.toByteArray();

+    }

+

+    /**

+     * @param context The context

+     * @return A {@link ProgressDialog} informing the user a photo is being

+     *         inserted

+     */

+    public static Dialog createInsertCameraPhotoDialog(Context context) {

+        final ProgressDialog dialog = new ProgressDialog(context);

+        dialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);

+        dialog.setCancelable(false);

+        dialog.setMessage(context.getString(R.string.dialog_inserting_camera_photo));

+        return dialog;

+    }

+

+    /**

+     * Inserts a newly taken photo into the media store. We cannot directly use

+     * {@code Images.Media#insertImage(ContentResolver, String, String, String)}

+     * as this method will not properly set the photo's timestamp. Additionally,

+     * any EXIF information in the original image is lost and there's a much higher

+     * chance for an OOME as insertImage() actually decodes the JPEG just to

+     * immediately re-encode it back to a JPEG.

+     * <p>

+     * NOTE: This code was shamelessly copied and merged from the Camera app

+     * [see method addImage() in Storage.java] and Images.Media#insertImage().

+     *

+     * NOTE: This method should not be called from the UI thread. It performs

+     * file IO and generates a thumbnail.

+     *

+     * @param context The context

+     * @param filename The name of the photo

+     * @return The media URL of the photo

+     * @throws FileNotFoundException If the file is not found

+     */

+    public static String insertCameraPhoto(Context context, String filename)

+            throws FileNotFoundException {

+        final File f = new File(Environment.getExternalStorageDirectory(), filename);

+

+        final long dateTaken = System.currentTimeMillis();

+        final String photoName = createPhotoName(context, dateTaken);

+        final ContentResolver resolver = context.getContentResolver();

+

+        // Insert into MediaStore

+        final ContentValues values = new ContentValues(5);

+        final int orientation = ImageUtils.getExifRotation(resolver, f.getAbsolutePath());

+

+        values.put(ImageColumns.TITLE, photoName);

+        values.put(ImageColumns.DISPLAY_NAME, photoName + ".jpg");

+        values.put(ImageColumns.DATE_TAKEN, dateTaken);

+        values.put(ImageColumns.MIME_TYPE, "image/jpeg");

+        values.put(ImageColumns.ORIENTATION, orientation);

+

+        // TODO(kkiyohara): be smarter about figuring out what storage is available, or

+        // maybe preventing the photo from being taken if the SD card (external storage)

+        // is missing.

+        Uri mediaUri;

+        try {

+            mediaUri = resolver.insert(Images.Media.EXTERNAL_CONTENT_URI, values);

+        } catch (Exception e1) {

+            // here when saving to external failed, try internal

+            try {

+                mediaUri = resolver.insert(Images.Media.INTERNAL_CONTENT_URI, values);

+            } catch (Exception e2) {

+                try {

+                    // last chance, try save to HTC-specific PhoneStorage

+                    mediaUri = resolver.insert(MediaStoreUtils.PHONE_STORAGE_IMAGES_URI, values);

+                } catch (Exception e3) {

+                    Log.e(TAG, "Failed to save image", e3);

+                    return null;

+                }

+            }

+        }

+

+        try {

+            // On some platforms this method may throw a NullPointerException

+            final OutputStream imageOut = resolver.openOutputStream(mediaUri);

+            final FileInputStream imageIn = new FileInputStream(f);

+

+            try {

+                final int downloadBufferSize = 10240;

+                final byte[] array = new byte[downloadBufferSize];

+                int bytesRead;

+

+                do {

+                    bytesRead = imageIn.read(array);

+                    if (bytesRead == -1) {

+                        break;

+                    }

+                    imageOut.write(array, 0, bytesRead);

+                } while (true);

+            } finally {

+                imageOut.close();

+            }

+

+            // Wait until MINI_KIND thumbnail is generated.

+            //

+            // If Images.Media.EXTERNAL_CONTENT_URI is not writable, then

+            // it is not possible to generate the thumbnail using public APIs.

+            if (MediaStoreUtils.isExternalMediaStoreUri(mediaUri)) {

+                Bitmap bmp = MediaStoreUtils.getThumbnail(

+                        context, mediaUri, Images.Thumbnails.MINI_KIND);

+                bmp.recycle();

+                bmp = null;

+            }

+        } catch (FileNotFoundException fe) {

+            Log.e(TAG, "File not found", fe);

+            throw fe;

+        } catch (Exception e) {

+            Log.e(TAG, "Failed to insert image", e);

+            if (mediaUri != null) {

+                resolver.delete(mediaUri, null, null);

+                mediaUri = null;

+            }

+        } finally {

+            f.delete();

+        }

+

+        return (mediaUri == null ? null : mediaUri.toString());

+    }

+

+    /**

+     * Returns a a name that is consistent with the Android camera application.

+     */

+    private static String createPhotoName(Context context, long dateTaken) {

+        final Date date = new Date(dateTaken);

+        final SimpleDateFormat dateFormat =

+                new SimpleDateFormat(context.getString(R.string.image_file_name_format));

+

+        return dateFormat.format(date);

+    }

+

+    /**

+     * Gets a URL that can be used to download an image at the given size. The size specifies

+     * the maximum width or height of the image. If the given URL is either a FIFE URL or an

+     * Image Proxy URL, it will be modified to contain the proper sizing parameters. Otherwise,

+     * the URL will be converted to an Image Proxy URL.

+     *

+     * @return A URL that can be used to retrieve an image of the given size.

+     */

+    public static String getResizedUrl(int size, String url) {

+        if (FIFEUtil.isFifeHostedUrl(url)) {

+            return FIFEUtil.setImageUrlSize(size, url, false);

+        } else {

+            return ImageProxyUtil.setImageUrlSize(size, url);

+        }

+    }

+

+    /**

+     * Gets a URL that can be used to download an image at the given size. The size specifies

+     * the maximum width or height of the image. If the given URL is either a FIFE URL or an

+     * Image Proxy URL, it will be modified to contain the proper sizing parameters. Otherwise,

+     * the URL will be converted to an Image Proxy URL.

+     *

+     * @return A URL that can be used to retrieve an image of the given size.

+     */

+    public static String getResizedUrl(int width, int height, String url) {

+        if (FIFEUtil.isFifeHostedUrl(url)) {

+            return FIFEUtil.setImageUrlSize(width, height, url, false, false);

+        } else {

+            return ImageProxyUtil.setImageUrlSize(width, height, url);

+        }

+    }

+

+    /**

+     * See {@link #getCroppedAndResizedUrl(int, String)} for more information. This method

+     * differs from getCroppedAndResizedUrl because it attempts to get a center cropped

+     * version of the requested image. This is only possible for FIFE hosted URLs; Image

+     * Proxy URLs will work as they do in getCroppedAndResizedUrl.

+     *

+     * @return A URL that can be used to retrieve an image of the given size.

+     */

+    public static String getCenterCroppedAndResizedUrl(int width, int height, String url) {

+        if (url == null) {

+            return null;

+        }

+

+        if (FIFEUtil.isFifeHostedUrl(url)) {

+            final StringBuilder options = new StringBuilder();

+            options.append("w").append(width);

+            options.append("-h").append(height);

+            options.append("-d");

+            options.append("-n");

+            return FIFEUtil.setImageUrlOptions(options.toString(), url).toString();

+        } else {

+            return ImageProxyUtil.setImageUrlSize(width, height, url);

+        }

+    }

+

+    /**

+     * See {@link #getResizedUrl(int, String)} for more information. This method differs

+     * from getResizedUrl because it attempts to get a cropped version of the requested

+     * image, meaning that for a given size, the returned image will be of dimension size

+     * in both x and y. This is only possible for FIFE hosted URLs; Image Proxy URLs will

+     * work as they do in getResizedUrl.

+     *

+     * @param size The size

+     * @param url The URL

+     * @return A URL that can be used to retrieve an image of the given size,

+     *         cropped if possible.

+     */

+    public static String getCroppedAndResizedUrl(int size, String url) {

+        if (FIFEUtil.isFifeHostedUrl(url)) {

+            return FIFEUtil.setImageUrlSize(size, url, true);

+        } else {

+            // The image proxy has no facility to crop images

+            return ImageProxyUtil.setImageUrlSize(size, url);

+        }

+    }

+

+    /**

+     * For some images, namely PNG images, the decode ignores the preferred config option and

+     * always decodes them as 32bpp. On devices that will see the most benefit, we re-encode

+     * the image as 16bpp. Otherwise, prefer to have greater fidelity in a PNG. The specified

+     * bitmap will be recycled automatically as necessary.

+     */

+    public static Bitmap getLowResBitmap(Bitmap bitmap) {

+        if (bitmap == null) {

+            return null;

+        }

+

+        if (bitmap.getConfig() == Config.ARGB_8888) {

+            final int width = bitmap.getWidth();

+            final int height = bitmap.getHeight();

+            final Bitmap lowResBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565);

+            final Canvas canvas = new Canvas(lowResBitmap);

+            final Rect src = new Rect(0, 0, width, height);

+            final Rect dest = new Rect(0, 0, width, height);

+

+            synchronized(sResizePaint) {

+                canvas.drawBitmap(bitmap, src, dest, sResizePaint);

+            }

+            bitmap.recycle();

+            return lowResBitmap;

+        }

+        return bitmap;

+    }

+

+    /**

+     * Gets the image bounds

+     *

+     * @param resolver The ContentResolver

+     * @param uri The uri

+     *

+     * @return The image bounds

+     */

+    private static Point getImageBounds(ContentResolver resolver, Uri uri)

+            throws IOException {

+        final BitmapFactory.Options opts = new BitmapFactory.Options();

+        InputStream inputStream = null;

+

+        try {

+            opts.inJustDecodeBounds = true;

+            inputStream = resolver.openInputStream(uri);

+            decodeStream(inputStream, null, opts);

+

+            return new Point(opts.outWidth, opts.outHeight);

+        } finally {

+            try {

+                if (inputStream != null) {

+                    inputStream.close();

+                }

+            } catch (IOException ignore) {

+            }

+        }

+    }

+

+    /**

+     * Get the file path of a media item

+     *

+     * @return the filepath for a given MediaStore uri, or null if there was a

+     *         problem

+     */

+    private static String getFilePath(ContentResolver resolver, Uri uri) {

+        // Ask MediaStore for the actual file path

+        final Cursor cursor = resolver.query(uri,

+                new String[] {MediaColumns._ID, MediaColumns.DATA}, null, null, null);

+        if (cursor == null) {

+            Log.w(TAG, "getFilePath: query returned null cursor for uri=" + uri);

+            return null;

+        }

+

+        String path = null;

+        try {

+            if (!cursor.moveToFirst()) {

+                Log.w(TAG, "getFilePath: query returned empty cursor for uri=" + uri);

+                return null;

+            }

+

+            // Get the file path

+            path = cursor.getString(cursor.getColumnIndexOrThrow(MediaColumns.DATA));

+            if (TextUtils.isEmpty(path)) {

+                Log.w(TAG, "getFilePath: MediaColumns.DATA was empty for uri=" + uri);

+                return null;

+            }

+        } finally {

+            cursor.close();

+        }

+

+        return path;

+    }

+

+    /**

+     * Encode the given image as a Base64 string (recycle the bitmap)

+     *

+     * @param imageBytes The image bytes

+     *

+     * @return A base64 encoded string

+     */

+    public static String encodeImageBytes(byte[] imageBytes) {

+        String base64 = Base64.encodeToString(imageBytes, Base64.NO_WRAP);

+        return "data:image/jpeg;base64," + base64;

+    }

+

+    /**

+     * Decode an image from a Base64 string

+     *

+     * @param string A base64 encoded string

+     *

+     * @return The image bytes

+     */

+    public static byte[] decodeImageBytes(String string) {

+        int start = string.indexOf("base64,");

+        if (start == -1) {

+            return null;

+        }

+

+        return Base64.decode(string.substring(start+7), Base64.DEFAULT);

+    }

+

+    /**

+     * Compress the bitmap to JPEG and return the compressed image bytes. The given bitmap will

+     * be recycled.

+     *

+     * @param bitmap The bitmap

+     * @param quality the quality level for JPEG coding (90 is default).

+     *

+     * @return The compressed image bytes

+     */

+    public static byte[] compressBitmap(Bitmap bitmap, int quality) {

+        final ByteArrayOutputStream stream = new ByteArrayOutputStream();

+        try {

+            bitmap.compress(CompressFormat.JPEG, quality, stream); // Copy #1

+            stream.flush();

+        } catch (IOException ignore) {

+        } finally {

+            try {

+                stream.close();

+            } catch (IOException ignore) {

+            }

+        }

+        bitmap.recycle();

+        bitmap = null;

+

+        final byte[] imageBytes = stream.toByteArray(); // Copy #2

+        if (Log.isLoggable(TAG, Log.DEBUG)) {

+            Log.d(TAG, "compressBitmap: Image size: " + imageBytes.length);

+        }

+        return imageBytes;

+    }

+

+    /**

+     * Compress the bitmap to JPEG and return the compressed image bytes. The given bitmap will

+     * be recycled.  A default quality level of 90 is used.

+     *

+     * @param bitmap The bitmap

+     *

+     * @return The compressed image bytes

+     */

+    public static byte[] compressBitmap(Bitmap bitmap) {

+        return compressBitmap(bitmap, DEFAULT_JPEG_QUALITY);

+    }

+

+    /**

+     * Retrieve the EXIF rotation of an image

+     *

+     * @param cr the content resolver, only used when the path given is an

+     *      actual content uri.

+     * @param path an absolute file path to the photo for which we want to get

+     *      the rotation angle. Can also be a content uri, in which case

+     *      the content resolver is used.

+     *

+     * @return the number of degrees an image needs to be rotated to face the

+     *      "correct" way. Does this by reading the actual file's EXIF

+     *       metadata.

+     */

+    private static int getExifRotation(ContentResolver cr, String path) {

+        // create the Exif interface

+        ExifInterface exif = null;

+        try {

+            exif = new ExifInterface(path);

+        } catch (IOException e) {

+            Log.w(TAG, "failed to create ExifInterface for " + path);

+        }

+

+        if (exif == null) {

+            return 0;

+        }

+

+        // get and translate the orientation

+        int orientation = exif.getAttributeInt(

+                ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);

+

+        int degrees = 0;

+        switch (orientation) {

+            case ExifInterface.ORIENTATION_NORMAL:

+                degrees = 0;

+                break;

+

+            case ExifInterface.ORIENTATION_ROTATE_90:

+                degrees = 90;

+                break;

+

+            case ExifInterface.ORIENTATION_ROTATE_180:

+                degrees = 180;

+                break;

+

+            case ExifInterface.ORIENTATION_ROTATE_270:

+                degrees = 270;

+                break;

+        }

+

+        return degrees;

+    }

+

+    /**

+     * Rotate a bitmap based on the MediaStore uri's EXIF information.

+     *

+     * @param cr standard content resolver

+     * @param uri MediaStore uri

+     * @param bmp bitmap to rotated

+     * @return bitmap with proper orientation

+     */

+    public static Bitmap rotateBitmap(ContentResolver cr, Uri uri, Bitmap bmp) {

+        if (bmp != null) {

+            final String path = getFilePath(cr, uri);

+            final int degrees = getExifRotation(cr, path);

+            if (degrees != 0) {

+                bmp = rotateBitmap(bmp, degrees);

+            }

+        }

+        return bmp;

+    }

+

+    /**

+     * Bitmap rotation method

+     *

+     * @param bitmap The input bitmap

+     * @param degrees The rotation angle

+     */

+    private static Bitmap rotateBitmap(Bitmap bitmap, int degrees) {

+        if (degrees != 0 && bitmap != null) {

+            final Matrix m = new Matrix();

+            final int w = bitmap.getWidth();

+            final int h = bitmap.getHeight();

+            m.setRotate(degrees, (float) w / 2, (float) h / 2);

+

+            try {

+                final Bitmap rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, w, h, m, true);

+                if (bitmap != rotatedBitmap) {

+                    bitmap.recycle();

+                    bitmap = rotatedBitmap;

+                }

+            } catch (OutOfMemoryError ex) {

+                // We have no memory to rotate. Return the original bitmap.

+            }

+        }

+

+        return bitmap;

+    }

+}

diff --git a/src/com/android/mail/photo/util/MediaStoreUtils.java b/src/com/android/mail/photo/util/MediaStoreUtils.java
new file mode 100644
index 0000000..e1650e8
--- /dev/null
+++ b/src/com/android/mail/photo/util/MediaStoreUtils.java
@@ -0,0 +1,362 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.util;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.provider.BaseColumns;
+import android.provider.MediaStore;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.io.File;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Utilities for MediaStore.
+ */
+public class MediaStoreUtils {
+    public static final String TAG = "MediaStoreUtils";
+
+    // Special HTC-only MediaStore storage volume
+    public static final Uri PHONE_STORAGE_IMAGES_URI =
+            MediaStore.Images.Media.getContentUri("phoneStorage");
+
+    public static final Uri PHONE_STORAGE_VIDEO_URI =
+            MediaStore.Video.Media.getContentUri("phoneStorage");
+
+    /**
+     * Define constants for Video info query.
+     */
+    @SuppressWarnings("unused")
+    private static interface VideoQuery {
+        /** Projection of the VideoQuery cursors */
+        public static final String[] PROJECTION = {
+            BaseColumns._ID,
+            MediaStore.Video.VideoColumns.DURATION,
+            MediaStore.Video.VideoColumns.RESOLUTION,
+        };
+
+        public static final int INDEX_ID = 0;
+        public static final int INDEX_DURATION_MSEC = 1;
+        public static final int INDEX_RESOLUTION = 2;
+    }
+
+    /** regex used to parse video resolution "XxY" -- never trust MediaStore! */
+    private static final Pattern PAT_RESOLUTION = Pattern.compile("(\\d+)[xX](\\d+)");
+
+    /**
+     * Prevent instantiation
+     */
+    private MediaStoreUtils() {
+    }
+
+    /**
+     * Check if a URI is from the MediaStore
+     *
+     * @param uri The URI
+     */
+    public static boolean isMediaStoreUri(Uri uri) {
+        return uri != null
+                && ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())
+                && MediaStore.AUTHORITY.equals(uri.getAuthority());
+    }
+
+    /**
+     * Checks if a {@link Uri} is an external {@link MediaStore} URI.
+     * <p>
+     * The {@code getThumbnail} methods of {@link MediaStore} are hard-coded to
+     * only support external media URIs. There is an API for loading internal
+     * thumbnails, but it is not public and the code cannot be copied easily.
+     *
+     * @param uri a content URI.
+     * @return {@code true} if the {@link Uri} belongs to {@link MediaStore} and
+     *         is external, {@code false} otherwise.
+     * @throws NullPointerException if the argument is {@code null}.
+     * @see android.provider.MediaStore.Images.Media#EXTERNAL_CONTENT_URI
+     * @see android.provider.MediaStore.Video.Media#EXTERNAL_CONTENT_URI
+     */
+    public static boolean isExternalMediaStoreUri(Uri uri) {
+        if (isMediaStoreUri(uri)) {
+            String path = uri.getPath();
+            String externalImagePrefix = MediaStore.Images.Media.EXTERNAL_CONTENT_URI.getPath();
+            String externalVideoPrefix = MediaStore.Video.Media.EXTERNAL_CONTENT_URI.getPath();
+            return path.startsWith(externalImagePrefix) || path.startsWith(externalVideoPrefix);
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * @return true if the MimeType type is image
+     */
+    public static boolean isImageMimeType(String mimeType) {
+        return mimeType != null && mimeType.startsWith("image/");
+    }
+
+    /**
+     * @return true if the MimeType type is video
+     */
+    public static boolean isVideoMimeType(String mimeType) {
+        return mimeType != null && mimeType.startsWith("video/");
+    }
+
+    /**
+     * Gets the MediaStore thumbnail bitmap for an image or video.
+     *
+     * @param context this can be an Application Context
+     * @param uri image or video Uri
+     * @param kind MediaStore.{Images|Video}.Thumbnails.MINI_KIND or MICRO_KIND
+     * @return thumbnail bitmap or null
+     */
+    public static Bitmap getThumbnail(Context context, Uri uri, int kind) {
+        // determine actual pixel dimensions
+        final int microSize = ImageUtils.getMaxThumbnailDimension(context, kind);
+        return getThumbnailHelper(context, uri, microSize, microSize, kind);
+    }
+
+    /**
+     * Gets the MediaStore thumbnail bitmap for an image or video.
+     *
+     * @param context this can be an Application Context
+     * @param uri image or video Uri
+     * @param width desired output width
+     * @param height desired output height
+     * @return thumbnail bitmap or null
+     */
+    public static Bitmap getThumbnail(Context context, Uri uri, int width, int height) {
+        // determine if we want mini or micro thumbnails
+        final int microSize = ImageUtils.getMaxThumbnailDimension(context,
+                MediaStore.Images.Thumbnails.MICRO_KIND);
+        int kind = (width > microSize || height > microSize)
+                ? MediaStore.Images.Thumbnails.MINI_KIND
+                : MediaStore.Images.Thumbnails.MICRO_KIND;
+
+        return getThumbnailHelper(context, uri, width, height, kind);
+    }
+
+    /**
+     * Deletes the MediaStore entry and, as necessary on some pre-ICS devices, corresponding
+     * native file
+     *
+     * @param resolver context reolver
+     * @param localContentUri image or video Uri
+     * @return true if delete succeeds, false otherwise
+     */
+    public static boolean deleteLocalFileAndMediaStore(ContentResolver resolver,
+            Uri localContentUri) {
+        final String filePath = MediaStoreUtils.getFilePath(resolver, localContentUri);
+
+        boolean status = resolver.delete(localContentUri, null, null) == 1;
+
+        if (status && filePath != null) {
+            final File file = new File(filePath);
+            if (file.exists()) {
+                status = file.delete();
+            }
+        }
+
+        return status;
+    }
+
+    /**
+     * Safe method to retrieve mimetype of a content Uri.
+     *
+     * On some phones, getType() can throw an exception for no good reason.
+     *
+     * @param resolver is a standard ContentResolver
+     * @param uri is a the target content Uri
+     * @return valid mime-type; null if type was unknown or an exception was thrown
+     */
+    public static String safeGetMimeType(ContentResolver resolver, Uri uri) {
+        String mimeType = null;
+        try {
+            mimeType = resolver.getType(uri);
+        } catch (Exception e) {
+            if (Log.isLoggable(TAG, Log.WARN)) {
+                Log.w(TAG, "safeGetMimeType failed for uri=" + uri, e);
+            }
+        }
+        return mimeType;
+    }
+
+//    /**
+//     * Converts a MediaStore video Uri to VideoData proto byte array.
+//     *
+//     * @param context can be an ApplicationContext
+//     * @param uri is a MediaStore Video Uri
+//     * @return byte[] proto byte array, or null if Uri is not a MediaStore video
+//     */
+//    public static byte[] toVideoDataBytes(Context context, Uri uri) {
+//        final VideoData videoData = toVideoData(context, uri);
+//        return videoData == null ? null : videoData.toByteArray();
+//    }
+//
+//    /**
+//     * Converts a MediaStore video Uri to an array of VideoData proto.
+//     *
+//     * @param context can be an ApplicationContext
+//     * @param uri is a MediaStore Video Uri
+//     * @return VideoData proto byte array, or null if Uri is not a MediaStore video
+//     */
+//    public static VideoData toVideoData(Context context, Uri uri) {
+//        // see if this is a video
+//        final ContentResolver cr = context.getContentResolver();
+//        if (!MediaStoreUtils.isVideoMimeType(safeGetMimeType(cr, uri))) {
+//            return null;
+//        }
+//
+//        // format VideoStream info
+//        final VideoStream.Builder vs = VideoStream.newBuilder();
+//        vs.setStreamUrl(uri.toString());
+//
+//        // 0 == unknown format, see ContentHeader.VideoFormat.INVALID_VIDEO_FORMAT
+//        vs.setFormatId(0);
+//
+//        // query for resolution -- string formatted as "XxY"
+//        int width = 0;
+//        int height = 0;
+//        long durationMsec = 0L;
+//        final Cursor cursor = cr.query(uri, VideoQuery.PROJECTION, null, null, null);
+//        if (cursor != null) {
+//            try {
+//                if (cursor.moveToFirst()) {
+//                    durationMsec = cursor.getLong(VideoQuery.INDEX_DURATION_MSEC);
+//
+//                    final String resolution = cursor.getString(VideoQuery.INDEX_RESOLUTION);
+//                    if (resolution != null) {
+//                        final Matcher m = PAT_RESOLUTION.matcher(resolution);
+//                        if (m.find()) {
+//                            width = Integer.parseInt(m.group(1));
+//                            height = Integer.parseInt(m.group(2));
+//                        }
+//                    }
+//                }
+//            } finally {
+//                cursor.close();
+//            }
+//        }
+//        vs.setVideoWidth(width);
+//        vs.setVideoHeight(height);
+//
+//        // manufacture VideoData bytes
+//        final VideoData vd = VideoData.newBuilder()
+//                .setStatus(VideoStatus.FINAL)
+//                .setDuration(durationMsec)
+//                .addStream(vs)
+//                .build();
+//        return vd;
+//    }
+
+    /**
+     * @return the file path for a given MediaStore uri, or null if there was a problem
+     */
+    private static String getFilePath(ContentResolver cr, Uri uri) {
+        // ask MediaStore for the actual file path
+        Cursor cursor = cr.query(uri, new String [] {MediaStore.MediaColumns.DATA},
+                null, null, null);
+        if (cursor == null) {
+            if (Log.isLoggable(TAG, Log.WARN)) {
+                Log.w(TAG, "getFilePath: query returned null cursor for uri=" + uri);
+            }
+            return null;
+        }
+
+        String path = null;
+        try {
+            if (!cursor.moveToFirst()) {
+                if (Log.isLoggable(TAG, Log.WARN)) {
+                    Log.w(TAG, "getFilePath: query returned empty cursor for uri=" + uri);
+                }
+                return null;
+            }
+            // read the file path
+            path = cursor.getString(0);
+            if (TextUtils.isEmpty(path)) {
+                if (Log.isLoggable(TAG, Log.WARN)) {
+                    Log.w(TAG, "getFilePath: MediaColumns.DATA was empty for uri=" + uri);
+                }
+                return null;
+            }
+
+        } finally {
+            cursor.close();
+        }
+        return path;
+    }
+
+    /**
+     * Gets the MediaStore thumbnail bitmap for an image or video.
+     *
+     * @param context this can be an Application Context
+     * @param uri image or video URI
+     * @param width desired output width
+     * @param height desired output height
+     * @param kind MediaStore.{Images|Video}.Thumbnails.MINI_KIND or MICRO_KIND
+     * @return the thumb nail image, or {@code null}
+     */
+    private static Bitmap getThumbnailHelper(
+            Context context, Uri uri, int width, int height, int kind) {
+        // guard against bogus Uri's
+        if (uri == null) {
+            return null;
+        }
+
+        // Thumb nails are only available for external media URIs
+        if (!isExternalMediaStoreUri(uri)) {
+            return null;
+        }
+
+        final ContentResolver cr = context.getContentResolver();
+        final long id = ContentUris.parseId(uri);
+
+        // query the appropriate MediaStore thumb nail provider
+        final String mimeType = safeGetMimeType(cr, uri);
+        Bitmap bmp;
+        if (isImageMimeType(mimeType)) {
+            bmp = MediaStore.Images.Thumbnails.getThumbnail(cr, id, kind, null);
+
+        } else if (isVideoMimeType(mimeType)) {
+            bmp = MediaStore.Video.Thumbnails.getThumbnail(cr, id, kind, null);
+
+        } else {
+            if (Log.isLoggable(TAG, Log.WARN)) {
+                Log.w(TAG, "getThumbnail: unrecognized mimeType=" + mimeType + ", uri=" + uri);
+            }
+            return null;
+        }
+
+        // if we got the thumb nail, we still have to rotate and crop as necessary
+        if (bmp != null) {
+            bmp = ImageUtils.rotateBitmap(cr, uri, bmp);
+
+            if (bmp.getWidth() != width || bmp.getHeight() != height) {
+                final Bitmap resizedBitmap = ImageUtils.resizeAndCropBitmap(
+                        bmp, width, height);
+                bmp.recycle();
+                bmp = resizedBitmap;
+            }
+        }
+        return bmp;
+    }
+}
diff --git a/src/com/android/mail/photo/views/PhotoLayout.java b/src/com/android/mail/photo/views/PhotoLayout.java
new file mode 100644
index 0000000..30c8b42
--- /dev/null
+++ b/src/com/android/mail/photo/views/PhotoLayout.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.views;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.LinearLayout;
+
+import com.android.mail.R;
+
+/**
+ * Custom layout for the photo view.
+ * <p>
+ * The photo view gives the photo a dynamic height -- it always takes up whatever's left of the
+ * screen. A normal {@link LinearLayout} does not allow this [at least not in the context of a
+ * list]. So, we create a layout that can fix it's height and ensures its children [such as the
+ * photo itself] are sized appropriately.
+ */
+public class PhotoLayout extends LinearLayout {
+    /** The fixed height of this view. If {@code -1}, calculate the height */
+    private int mFixedHeight = -1;
+    /** The view containing primary photo information */
+    private PhotoView mPhotoView;
+
+    public PhotoLayout(Context context) {
+        super(context);
+    }
+
+    public PhotoLayout(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public PhotoLayout(Context context, AttributeSet attrs, int defStyle) {
+        super(context, attrs, defStyle);
+    }
+
+    @Override
+    public void addView(View child, int index, android.view.ViewGroup.LayoutParams params) {
+        super.addView(child, index, params);
+
+        if (child.getId() == R.id.photo_view) {
+            mPhotoView = (PhotoView) child;
+        }
+    }
+
+    @Override
+    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+        setFixedHeight(mFixedHeight);
+    }
+
+    /**
+     * Clears any state or resources from the views. The layout cannot be used after this method
+     * is called.
+     */
+    public void clear() {
+        removeAllViews();
+        mPhotoView = null;
+    }
+
+    /**
+     * Sets the fixed height for this layout. If the given height is <= 0, it is ignored.
+     */
+    public void setFixedHeight(int fixedHeight) {
+        if (fixedHeight <= 0) {
+            return;
+        }
+
+        final boolean adjustBounds = (fixedHeight != mFixedHeight);
+        mFixedHeight = fixedHeight;
+
+        if (mPhotoView != null) {
+            int adjustHeight = 0;
+            mPhotoView.setFixedHeight(mFixedHeight - adjustHeight);
+        }
+        setMeasuredDimension(getMeasuredWidth(), mFixedHeight);
+
+        if (adjustBounds) {
+            requestLayout();
+        }
+    }
+}
diff --git a/src/com/android/mail/photo/views/PhotoView.java b/src/com/android/mail/photo/views/PhotoView.java
new file mode 100644
index 0000000..b2d6f8e
--- /dev/null
+++ b/src/com/android/mail/photo/views/PhotoView.java
@@ -0,0 +1,1624 @@
+/*
+ * Copyright (C) 2011 Google Inc.
+ * Licensed to 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.mail.photo.views;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.Paint.Style;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.drawable.BitmapDrawable;
+import android.text.TextPaint;
+import android.util.AttributeSet;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+import android.view.View;
+
+import com.android.mail.R;
+import com.android.mail.photo.fragments.PhotoViewFragment.HorizontallyScrollable;
+
+/**
+ * Layout for the photo list view header.
+ */
+public class PhotoView extends View implements GestureDetector.OnGestureListener,
+        GestureDetector.OnDoubleTapListener, ScaleGestureDetector.OnScaleGestureListener,
+        HorizontallyScrollable {
+
+    /** Zoom animation duration; in milliseconds */
+    private final static long ZOOM_ANIMATION_DURATION = 300L;
+    /** Rotate animation duration; in milliseconds */
+    private final static long ROTATE_ANIMATION_DURATION = 500L;
+    /** Snap animation duration; in milliseconds */
+    private static final long SNAP_DURATION = 100L;
+    /** Amount of time to wait before starting snap animation; in milliseconds */
+    private static final long SNAP_DELAY = 250L;
+    /** By how much to scale the image when double click occurs */
+    private final static float DOUBLE_TAP_SCALE_FACTOR = 1.5f;
+    /** Amount of translation needed before starting a snap animation */
+    private final static float SNAP_THRESHOLD = 20.0f;
+    /** The width & height of the bitmap returned by {@link #getCroppedPhoto()} */
+    private final static float CROPPED_SIZE = 256.0f;
+
+    /** If {@code true}, the static values have been initialized */
+    private static boolean sInitialized;
+
+    // Various dimensions
+    /** Right padding for overlay content */
+    private static int sPhotoOverlayRightPadding;
+    /** Bottom padding for overlay content */
+    private static int sPhotoOverlayBottomPadding;
+    /** Spacing between the comment count and the comment bitmap */
+    private static int sCommentCountLeftMargin;
+    /** Fixed width of the comment count text */
+    private static int sCommentCountTextWidth;
+    /** Spacing between the +1 count and the +1 bitmap */
+    private static int sPlusOneCountLeftMargin;
+    /** Fixed width of the +1 count text */
+    private static int sPlusOneCountTextWidth;
+    /** Space between the +1 icon and the comment icon */
+    private static int sPlusOneBottomMargin;
+    /** Temporary padding hack to left-align the +1 and comment icons */
+    private static int sPlusOneIconRightPaddingHack;
+    private static int sTagTextPadding;
+    /** Width & height of the crop region */
+    private static int sCropSize;
+
+    // Bitmaps
+    /** Comment bitmap */
+    private static Bitmap sCommentBitmap;
+    /** +1 bitmap */
+    private static Bitmap sPlusOneBitmap;
+    /** Video icon */
+    private static Bitmap sVideoImage;
+    /** Video icon */
+    private static Bitmap sVideoNotReadyImage;
+
+    // Features
+    private static boolean sHasMultitouchDistinct;
+
+    // Paints
+    // ----------------------------------------------------------
+    // NOTE: Please register static TextPaints in TextPaintUtils!
+    // ----------------------------------------------------------
+    /** Paint for the comment count text */
+    private static TextPaint sCommentCountPaint;
+    /** Paint for the +1 count text */
+    private static TextPaint sPlusOneCountPaint;
+    private static Paint sTagPaint;
+    /** Paint to partially dim the photo during crop */
+    private static Paint sCropDimPaint;
+    /** Paint to highlight the cropped portion of the photo */
+    private static Paint sCropPaint;
+    private static TextPaint sTagTextPaint;
+    private static Paint sTagTextBackgroundPaint;
+    // ----------------------------------------------------------
+    // NOTE: Please register static TextPaints in TextPaintUtils!
+    // ----------------------------------------------------------
+
+    // Colours
+    /** The colour of the header background */
+    private static int sBackgroundColor;
+
+    /** The photo to display */
+    private BitmapDrawable mDrawable;
+    /** Whether or not the photo is in the process of loading */
+    private boolean mLoading;
+    /** The matrix used for drawing; this may be {@code null} */
+    private Matrix mDrawMatrix;
+    /** A matrix to apply the scaling of the photo */
+    private Matrix mMatrix = new Matrix();
+    /** The original matrix for this image; used to reset any transformations applied by the user */
+    private Matrix mOriginalMatrix = new Matrix();
+
+    /** The fixed height of this view. If {@code -1}, calculate the height */
+    private int mFixedHeight = -1;
+    /** When {@code true}, the view has been laid out */
+    private boolean mHaveLayout;
+    /** Whether or not the photo is full-screen */
+    private boolean mFullScreen;
+    /** The number of comments */
+    private String mCommentText;
+    /** The number of +1's */
+    private String mPlusOneText;
+    /** Whether or not this is a still image of a video */
+    private byte[] mVideoBlob;
+    /** Whether or not this is a still image of a video */
+    private boolean mVideoReady;
+
+    /** Whether or not crop is allowed */
+    private boolean mAllowCrop;
+    /** The crop region */
+    private Rect mCropRect = new Rect();
+    /** Actual crop size; may differ from {@link #sCropSize} if the screen is smaller */
+    private int mCropSize;
+
+    /** A tag shape to display on top of the image */
+    private RectF mTagShape;
+    /** The name of the tagged shape */
+    private CharSequence mTagName;
+    /** If {@code true}, display the tag shape & name */
+    private boolean mShowTagShape;
+
+    /** Gesture detector */
+    private GestureDetector mGestureDetector;
+    /** Gesture detector that detects pinch gestures */
+    private ScaleGestureDetector mScaleGetureDetector;
+    /** An external click listener */
+    private OnClickListener mExternalClickListener;
+    /** When {@code true}, allows gestures to scale / pan the image */
+    private boolean mTransformsEnabled;
+
+    // To support zooming
+    /** When {@code true}, a double tap scales the image by {@link #DOUBLE_TAP_SCALE_FACTOR} */
+    private boolean mDoubleTapToZoomEnabled = true;
+    /** When {@code true}, prevents scale end gesture from falsely triggering a double click. */
+    private boolean mDoubleTapDebounce;
+    /** When {@code false}, event is a scale gesture. Otherwise, event is a double touch. */
+    private boolean mIsDoubleTouch;
+    /** Runnable that scales the image */
+    private ScaleRunnable mScaleRunnable;
+    /** Minimum scale the image can have. */
+    private float mMinScale;
+    /** Maximum scale to limit scaling to, 0 means no limit. */
+    private float mMaxScale;
+    /** When {@code true}, we're in the middle of a scaling. Otherwise, we're not. */
+    private boolean mPerformingScale;
+    /** When {@code true}, prevents scale end gesture from falsely triggering a fling. */
+    private boolean mFlingDebounce;
+
+    // To support translation [i.e. panning]
+    /** Runnable that can move the image */
+    private TranslateRunnable mTranslateRunnable;
+    private SnapRunnable mSnapRunnable;
+
+    // To support rotation
+    /** The rotate runnable used to animate rotations of the image */
+    private RotateRunnable mRotateRunnable;
+    /** The current rotation amount, in degrees */
+    private float mRotation;
+
+    // Convenience fields
+    // These are declared here not because they are important properties of the view. Rather, we
+    // declare them here to avoid object allocation during critical graphics operations; such as
+    // layout or drawing.
+    /** Source (i.e. the photo size) bounds */
+    private RectF mTempSrc = new RectF();
+    /** Destination (i.e. the display) bounds. The image is scaled to this size. */
+    private RectF mTempDst = new RectF();
+    /** Rectangle to handle translations */
+    private RectF mTranslateRect = new RectF();
+    /** Array to store a copy of the matrix values */
+    private float[] mValues = new float[9];
+    /** The background area of the tag text */
+    private RectF mTagNameBackground = new RectF();
+
+    public PhotoView(Context context) {
+        super(context);
+        initialize();
+    }
+
+    public PhotoView(Context context, AttributeSet attrs) {
+        super(context, attrs);
+        initialize();
+    }
+
+    public PhotoView(Context context, AttributeSet attrs, int defStyle) {
+        super(context, attrs, defStyle);
+        initialize();
+    }
+
+    @Override
+    public boolean onTouchEvent(MotionEvent event) {
+        if (mScaleGetureDetector == null || mGestureDetector == null) {
+            // We're being destroyed; ignore any touch events
+            return true;
+        }
+
+        mScaleGetureDetector.onTouchEvent(event);
+        mGestureDetector.onTouchEvent(event);
+        final int action = event.getAction();
+
+        switch (action) {
+            case MotionEvent.ACTION_UP:
+            case MotionEvent.ACTION_CANCEL:
+                if (!mTranslateRunnable.mRunning) {
+                    snap();
+                }
+                mPerformingScale = false;
+                break;
+        }
+
+        return true;
+    }
+
+    @Override
+    public boolean onDoubleTap(MotionEvent e) {
+        if (mDoubleTapToZoomEnabled && mTransformsEnabled) {
+            if (!mDoubleTapDebounce) {
+                float currentScale = getScale();
+                float targetScale = currentScale * DOUBLE_TAP_SCALE_FACTOR;
+
+                // Ensure the target scale is within our bounds
+                targetScale = Math.max(mMinScale, targetScale);
+                targetScale = Math.min(mMaxScale, targetScale);
+
+                mScaleRunnable.start(currentScale, targetScale, e.getX(), e.getY());
+            }
+            mDoubleTapDebounce = false;
+        }
+        return true;
+    }
+
+    @Override
+    public boolean onDoubleTapEvent(MotionEvent e) {
+        return true;
+    }
+
+    @Override
+    public boolean onSingleTapConfirmed(MotionEvent e) {
+        if (mExternalClickListener != null && !mIsDoubleTouch) {
+            mExternalClickListener.onClick(this);
+        }
+        mIsDoubleTouch = false;
+        return true;
+    }
+
+    @Override
+    public boolean onSingleTapUp(MotionEvent e) {
+        return false;
+    }
+
+    @Override
+    public void onLongPress(MotionEvent e) {
+    }
+
+    @Override
+    public void onShowPress(MotionEvent e) {
+    }
+
+    @Override
+    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
+        if (mTransformsEnabled && !mPerformingScale) {
+            translate(-distanceX, -distanceY);
+        }
+        return true;
+    }
+
+    @Override
+    public boolean onDown(MotionEvent e) {
+        if (mTransformsEnabled) {
+            mTranslateRunnable.stop();
+            mSnapRunnable.stop();
+        }
+        return true;
+    }
+
+    @Override
+    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+        if (mTransformsEnabled && !mPerformingScale) {
+            if (!mFlingDebounce) {
+                mTranslateRunnable.start(velocityX, velocityY);
+            }
+            mFlingDebounce = false;
+        }
+        return true;
+    }
+
+    @Override
+    public boolean onScale(ScaleGestureDetector detector) {
+        if (mTransformsEnabled) {
+            mPerformingScale = true;
+            mIsDoubleTouch = false;
+            float currentScale = getScale();
+            float newScale = currentScale * detector.getScaleFactor();
+            scale(newScale, detector.getFocusX(), detector.getFocusY());
+        }
+        return true;
+    }
+
+    @Override
+    public boolean onScaleBegin(ScaleGestureDetector detector) {
+        if (mTransformsEnabled) {
+            mScaleRunnable.stop();
+            mIsDoubleTouch = true;
+        }
+        return true;
+    }
+
+    @Override
+    public void onScaleEnd(ScaleGestureDetector detector) {
+        if (mTransformsEnabled && mIsDoubleTouch) {
+            mDoubleTapDebounce = true;
+            resetTransformations();
+        }
+        mPerformingScale = false;
+        mFlingDebounce = true;
+    }
+
+    @Override
+    public void setOnClickListener(OnClickListener listener) {
+        mExternalClickListener = listener;
+    }
+
+    @Override
+    public boolean interceptMoveLeft(float origX, float origY) {
+        if (!mTransformsEnabled) {
+            // Allow intercept if we're not in transform mode
+            return false;
+        } else if (mTranslateRunnable.mRunning) {
+            // Don't allow touch intercept until we've stopped flinging
+            return true;
+        } else {
+            mMatrix.getValues(mValues);
+            mTranslateRect.set(mTempSrc);
+            mMatrix.mapRect(mTranslateRect);
+
+            final float viewWidth = getWidth();
+            final float transX = mValues[Matrix.MTRANS_X];
+            final float drawWidth = mTranslateRect.right - mTranslateRect.left;
+
+            if (!mTransformsEnabled || drawWidth <= viewWidth) {
+                // Allow intercept if not in transform mode or the image is smaller than the view
+                return false;
+            } else if (transX == 0) {
+                // We're at the left-side of the image; allow intercepting movements to the right
+                return false;
+            } else if (viewWidth >= drawWidth + transX) {
+                // We're at the right-side of the image; allow intercepting movements to the left
+                return true;
+            } else {
+                // We're in the middle of the image; don't allow touch intercept
+                return true;
+            }
+        }
+    }
+
+    @Override
+    public boolean interceptMoveRight(float origX, float origY) {
+        if (!mTransformsEnabled) {
+            // Allow intercept if we're not in transform mode
+            return false;
+        } else if (mTranslateRunnable.mRunning) {
+            // Don't allow touch intercept until we've stopped flinging
+            return true;
+        } else {
+            mMatrix.getValues(mValues);
+            mTranslateRect.set(mTempSrc);
+            mMatrix.mapRect(mTranslateRect);
+
+            final float viewWidth = getWidth();
+            final float transX = mValues[Matrix.MTRANS_X];
+            final float drawWidth = mTranslateRect.right - mTranslateRect.left;
+
+            if (!mTransformsEnabled || drawWidth <= viewWidth) {
+                // Allow intercept if not in transform mode or the image is smaller than the view
+                return false;
+            } else if (transX == 0) {
+                // We're at the left-side of the image; allow intercepting movements to the right
+                return true;
+            } else if (viewWidth >= drawWidth + transX) {
+                // We're at the right-side of the image; allow intercepting movements to the left
+                return false;
+            } else {
+                // We're in the middle of the image; don't allow touch intercept
+                return true;
+            }
+        }
+    }
+
+    /**
+     * Free all resources held by this view.
+     * The view is on its way to be collected and will not be reused.
+     */
+    public void clear() {
+        mGestureDetector = null;
+        mScaleGetureDetector = null;
+        mDrawable = null;
+        mScaleRunnable.stop();
+        mScaleRunnable = null;
+        mTranslateRunnable.stop();
+        mTranslateRunnable = null;
+        mSnapRunnable.stop();
+        mSnapRunnable = null;
+        mRotateRunnable.stop();
+        mRotateRunnable = null;
+        setOnClickListener(null);
+        mExternalClickListener = null;
+    }
+
+    /**
+     * Binds a bitmap to the view.
+     *
+     * @param photoBitmap the bitmap to bind.
+     */
+    public void bindPhoto(Bitmap photoBitmap) {
+        boolean changed = false;
+        if (mDrawable != null) {
+            final Bitmap drawableBitmap = mDrawable.getBitmap();
+            if (photoBitmap == drawableBitmap) {
+                // setting the same bitmap; do nothing
+                return;
+            }
+
+            changed = photoBitmap != null &&
+                    (mDrawable.getIntrinsicWidth() != photoBitmap.getWidth() ||
+                    mDrawable.getIntrinsicHeight() != photoBitmap.getHeight());
+
+            // Reset mMinScale to ensure the bounds / matrix are recalculated
+            mMinScale = 0f;
+            mDrawable = null;
+        }
+
+        if (mDrawable == null && photoBitmap != null) {
+            mDrawable = new BitmapDrawable(getResources(), photoBitmap);
+        }
+
+        configureBounds(changed);
+        invalidate();
+    }
+
+    /**
+     * Returns the bound photo data if set. Otherwise, {@code null}.
+     */
+    public Bitmap getPhoto() {
+        if (mDrawable != null) {
+            return mDrawable.getBitmap();
+        }
+        return null;
+    }
+
+//    /**
+//     * Sets the number of comments for this photo
+//     */
+//    public void setCommentCount(int commentCount) {
+//        if (commentCount <= 0) {
+//            return;
+//        }
+//
+//        if (commentCount > 99) {
+//            mCommentText = getResources().getString(R.string.ninety_nine_plus);
+//        } else {
+//            mCommentText = Integer.toString(commentCount);
+//        }
+//    }
+//
+//    /**
+//     * Sets the number of +1's for this photo
+//     */
+//    public void setPlusOneCount(int plusOneCount) {
+//        if (plusOneCount < 0) {
+//            return;
+//        }
+//
+//        if (plusOneCount == 0) {
+//            mPlusOneText = null;
+//        } else {
+//            if (plusOneCount > 99) {
+//                mPlusOneText = getResources().getString(R.string.ninety_nine_plus);
+//            } else {
+//                mPlusOneText = Integer.toString(plusOneCount);
+//            }
+//        }
+//    }
+//
+//    /**
+//     * Sets video data if this item represents a video.
+//     */
+//    public void setVideoBlob(byte[] videoBlob) {
+//        mVideoBlob = videoBlob;
+//        if (videoBlob != null) {
+//            try {
+//                final VideoData proto = VideoData.parseFrom(videoBlob);
+//                final VideoStatus status = proto.getStatus();
+//                mVideoReady = (status == VideoStatus.FINAL || status == VideoStatus.READY);
+//            } catch (InvalidProtocolBufferException e) {
+//            }
+//        }
+//    }
+
+    /**
+     * Gets video data associated with this item. Returns {@code null} if this is not a video.
+     */
+    public byte[] getVideoData() {
+        return mVideoBlob;
+    }
+
+    /**
+     * Returns {@code true} if the photo represents a video. Otherwise, {@code false}.
+     */
+    public boolean isVideo() {
+        return mVideoBlob != null;
+    }
+
+    /**
+     * Returns {@code true} if the video is ready to play. Otherwise, {@code false}.
+     */
+    public boolean isVideoReady() {
+        return mVideoBlob != null && mVideoReady;
+    }
+
+    /**
+     * Binds tag data to this view.
+     */
+    public void bindTagData(RectF rect, CharSequence name) {
+        mTagShape = rect;
+        mTagName = name;
+    }
+
+    /**
+     * Shows the tag shape / name
+     */
+    public void showTagShape() {
+        mShowTagShape = true;
+
+        invalidate();
+    }
+
+    /**
+     * Hides the tag shape / name
+     */
+    public void hideTagShape() {
+        mShowTagShape = false;
+
+        invalidate();
+    }
+
+    /**
+     * Returns {@code true} if a photo has been bound. Otherwise, {@code false}.
+     */
+    public boolean isPhotoBound() {
+        return mDrawable != null;
+    }
+
+    /**
+     * Returns {@code true} if a photo has been bound. Otherwise, {@code false}.
+     */
+    public boolean isPhotoLoading() {
+        return mLoading;
+    }
+
+    /**
+     * Sets whether the photo is being loaded.
+     */
+    public void setPhotoLoading(boolean loading) {
+        mLoading = loading;
+    }
+
+    /**
+     * Hides the photo info portion of the header. As a side effect, this automatically enables
+     * or disables image transformations [eg zoom, pan, etc...] depending upon the value of
+     * fullScreen. If this is not desirable, enable / disable image transformations manually.
+     */
+    public void setFullScreen(boolean fullScreen, boolean animate) {
+        if (fullScreen != mFullScreen) {
+            mFullScreen = fullScreen;
+            if (!mFullScreen) {
+                mScaleRunnable.stop();
+                mTranslateRunnable.stop();
+                mRotateRunnable.stop();
+            }
+            requestLayout();
+            invalidate();
+        }
+    }
+
+    /**
+     * Enable or disable cropping of the displayed image. Cropping can only be enabled
+     * <em>before</em> the view has been laid out. Additionally, once cropping has been
+     * enabled, it cannot be disabled.
+     */
+    public void enableAllowCrop(boolean allowCrop) {
+        if (allowCrop && mHaveLayout) {
+            throw new IllegalArgumentException("Cannot set crop after view has been laid out");
+        }
+        if (!allowCrop && mAllowCrop) {
+            throw new IllegalArgumentException("Cannot unset crop mode");
+        }
+        mAllowCrop = allowCrop;
+    }
+
+    /**
+     * Gets a bitmap of the cropped region. If cropping is not enabled, returns {@code null}.
+     */
+    public Bitmap getCroppedPhoto() {
+        if (!mAllowCrop) {
+            return null;
+        }
+
+        final Bitmap croppedBitmap = Bitmap.createBitmap(
+                (int) CROPPED_SIZE, (int) CROPPED_SIZE, Bitmap.Config.ARGB_8888);
+        final Canvas croppedCanvas = new Canvas(croppedBitmap);
+
+        // scale for the final dimensions
+        final int cropWidth = mCropRect.right - mCropRect.left;
+        final float scaleWidth = CROPPED_SIZE / cropWidth;
+        final float scaleHeight = CROPPED_SIZE / cropWidth;
+
+        // translate to the origin & scale
+        final Matrix matrix = new Matrix(mDrawMatrix);
+        matrix.postTranslate(-mCropRect.left, -mCropRect.top);
+        matrix.postScale(scaleWidth, scaleHeight);
+
+        // Set the background to black
+        croppedCanvas.drawColor(sBackgroundColor);
+
+        // draw the photo
+        if (mDrawable != null) {
+            croppedCanvas.concat(matrix);
+            mDrawable.draw(croppedCanvas);
+        }
+        return croppedBitmap;
+    }
+
+    /**
+     * Resets the image transformation to its original value.
+     */
+    public void resetTransformations() {
+        // snap transformations; we don't animate
+        mMatrix.set(mOriginalMatrix);
+
+        // Invalidate the view because if you move off this PhotoHeaderView
+        // to another one and come back, you want it to draw from scratch
+        // in case you were zoomed in or translated (since those settings
+        // are not preserved and probably shouldn't be).
+        invalidate();
+    }
+
+    /**
+     * Rotates the image 90 degrees, clockwise.
+     */
+    public void rotateClockwise() {
+        rotate(90, true);
+    }
+
+    /**
+     * Rotates the image 90 degrees, counter clockwise.
+     */
+    public void rotateCounterClockwise() {
+        rotate(-90, true);
+    }
+
+    @Override
+    protected void onDraw(Canvas canvas) {
+        super.onDraw(canvas);
+        // Set the background to black
+        canvas.drawColor(sBackgroundColor);
+
+        // draw the photo
+        if (mDrawable != null) {
+            int saveCount = canvas.getSaveCount();
+            canvas.save();
+
+            if (mDrawMatrix != null) {
+                canvas.concat(mDrawMatrix);
+            }
+            mDrawable.draw(canvas);
+
+            canvas.restoreToCount(saveCount);
+
+            if (mVideoBlob != null) {
+                final Bitmap videoImage = (mVideoReady ? sVideoImage : sVideoNotReadyImage);
+                final int drawLeft = (getWidth() - videoImage.getWidth()) / 2;
+                final int drawTop = (getHeight() - videoImage.getHeight()) / 2;
+                canvas.drawBitmap(videoImage, drawLeft, drawTop, null);
+            }
+
+            // Extract the drawable's bounds (in our own copy, to not alter the image)
+            mTranslateRect.set(mDrawable.getBounds());
+            if (mDrawMatrix != null) {
+                mDrawMatrix.mapRect(mTranslateRect);
+            }
+            if (mShowTagShape && mTagShape != null) {
+                final float drawWidth = mTranslateRect.width();
+                final float drawHeight = mTranslateRect.height();
+
+                final float tagLeft = mTagShape.left * drawWidth + mTranslateRect.left;
+                final float tagTop = mTagShape.top * drawHeight + mTranslateRect.top;
+                final float tagRight = mTagShape.right * drawWidth + mTranslateRect.left;
+                final float tagBottom =  mTagShape.bottom * drawHeight + mTranslateRect.top;
+
+                canvas.drawRect(tagLeft, tagTop, tagRight, tagBottom, sTagPaint);
+
+                drawTagName(canvas, tagLeft, tagTop, tagRight, tagBottom);
+            }
+
+            if (mAllowCrop) {
+                int previousSaveCount = canvas.getSaveCount();
+                canvas.drawRect(0, 0, getWidth(), getHeight(), sCropDimPaint);
+                canvas.save();
+                canvas.clipRect(mCropRect);
+
+                if (mDrawMatrix != null) {
+                    canvas.concat(mDrawMatrix);
+                }
+
+                mDrawable.draw(canvas);
+                canvas.restoreToCount(previousSaveCount);
+                canvas.drawRect(mCropRect, sCropPaint);
+            }
+        }
+
+        // draw comment/+1 count overlays; only if header is not visible
+        int yPos = getHeight() - sPhotoOverlayBottomPadding;
+
+        if (mFullScreen && mCommentText != null && !mAllowCrop) {
+            // Top align comment count and the comment bitmap
+            final int commentTextHeight =
+                    (int) (sCommentCountPaint.ascent() - sCommentCountPaint.descent());
+            final int commentHeight = Math.max(sCommentBitmap.getHeight(), commentTextHeight);
+
+            int xPos = getWidth() - sPhotoOverlayRightPadding - sCommentCountTextWidth;
+
+            yPos -= commentHeight;
+            canvas.drawText(mCommentText, xPos,
+                    yPos - sCommentCountPaint.ascent(), sCommentCountPaint);
+
+            xPos -= (sCommentCountLeftMargin + sCommentBitmap.getWidth());
+            canvas.drawBitmap(sCommentBitmap, xPos, yPos, null);
+
+            yPos -= sPlusOneBottomMargin;
+        }
+
+        if (mFullScreen && mPlusOneText != null && !mAllowCrop) {
+            // Top align comment count and the comment bitmap
+            final int plusOneTextHeight =
+                    (int) (sPlusOneCountPaint.ascent() - sPlusOneCountPaint.descent());
+            final int plusOneHeight = Math.max(sPlusOneBitmap.getHeight(), plusOneTextHeight);
+
+            int xPos = getWidth() - sPhotoOverlayRightPadding - sPlusOneCountTextWidth;
+
+            yPos -= plusOneHeight;
+            canvas.drawText(mPlusOneText, xPos,
+                    yPos - sPlusOneCountPaint.ascent(), sPlusOneCountPaint);
+
+            xPos -= (sPlusOneCountLeftMargin + sPlusOneBitmap.getWidth());
+            xPos -= sPlusOneIconRightPaddingHack;
+            canvas.drawBitmap(sPlusOneBitmap, xPos, yPos, null);
+        }
+    }
+
+    @Override
+    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+        super.onLayout(changed, left, top, right, bottom);
+        mHaveLayout = true;
+        final int layoutWidth = getWidth();
+        final int layoutHeight = getHeight();
+
+        if (mAllowCrop) {
+            mCropSize = Math.min(sCropSize, Math.min(layoutWidth, layoutHeight));
+            final int cropLeft = (layoutWidth - mCropSize) / 2;
+            final int cropTop = (layoutHeight - mCropSize) / 2;
+            final int cropRight = cropLeft + mCropSize;
+            final int cropBottom =  cropTop + mCropSize;
+
+            // Create a crop region overlay. We need a separate canvas to be able to "punch
+            // a hole" through to the underlying image.
+            mCropRect.set(cropLeft, cropTop, cropRight, cropBottom);
+        }
+        configureBounds(changed);
+    }
+
+    @Override
+    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+        if (mFixedHeight != -1) {
+            super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(mFixedHeight,
+                    MeasureSpec.AT_MOST));
+            setMeasuredDimension(getMeasuredWidth(), mFixedHeight);
+        } else {
+            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+        }
+    }
+
+    /**
+     * Forces a fixed height for this view.
+     *
+     * @param fixedHeight The height. If {@code -1}, use the measured height.
+     */
+    public void setFixedHeight(int fixedHeight) {
+        final boolean adjustBounds = (fixedHeight != mFixedHeight);
+        mFixedHeight = fixedHeight;
+        setMeasuredDimension(getMeasuredWidth(), mFixedHeight);
+        if (adjustBounds) {
+            configureBounds(true);
+            requestLayout();
+        }
+    }
+
+    /**
+     * Enable or disable image transformations. When transformations are enabled, this view
+     * consumes all touch events.
+     */
+    public void enableImageTransforms(boolean enable) {
+        mTransformsEnabled = enable;
+        if (!mTransformsEnabled) {
+            resetTransformations();
+        }
+    }
+
+    /**
+     * Draws the tag name underneath & centered on the shape. If there isn't sufficient room
+     * below the photo, the text will be drawn above the shape. If there isn't sufficient room
+     * on either side to center the text, the text will be left/right aligned with the edge
+     * of the canvas.
+     */
+    private void drawTagName(Canvas canvas, float tagLeft, float tagTop, float tagRight,
+            float tagBottom) {
+        if (mTagName == null) {
+            return;
+        }
+
+        final float textPadding = 2f * sTagTextPadding;
+
+        float tagCenter = tagLeft + ((tagRight - tagLeft) / 2f);
+
+        float nameWidth = sTagTextPaint.measureText(mTagName, 0, mTagName.length());
+        float nameHeight = sTagTextPaint.descent() - sTagTextPaint.ascent();
+
+        float nameRectWidth = nameWidth + textPadding;
+        float nameRectHeight = nameHeight + textPadding;
+
+        // Calculate the bounding box for the background rectangle
+        float nameRectLeft = tagCenter - (nameRectWidth / 2f);
+        if (nameRectLeft < 0) {
+            // Ensure we don't draw off the side of the photo
+            nameRectLeft = 0;
+        }
+        float nameRectRight = nameRectLeft + nameRectWidth;
+        if (nameRectRight > getWidth()) {
+            nameRectRight = tagRight;
+            nameRectLeft = nameRectRight - nameRectWidth;
+        }
+
+        float nameRectTop = tagBottom;
+        float nameRectBottom = nameRectTop + nameRectHeight;
+
+        final int vheight = getHeight();
+        if (nameRectBottom > vheight) {
+            // Draw the text on top of the shape
+            nameRectBottom = tagTop;
+            nameRectTop = nameRectBottom - nameRectHeight;
+        }
+
+        // Calculate the bounding box for the text
+        float nameLeft = nameRectLeft + sTagTextPadding;
+        float nameTop = nameRectTop + sTagTextPadding;
+
+        mTagNameBackground.set(nameRectLeft, nameRectTop, nameRectRight, nameRectBottom);
+        // Draw the background:
+        canvas.drawRoundRect(mTagNameBackground, 3f, 3f, sTagTextBackgroundPaint);
+        canvas.drawText(mTagName, 0, mTagName.length(), nameLeft, nameTop - sTagTextPaint.ascent(),
+                sTagTextPaint);
+    }
+
+    /**
+     * Configures the bounds of the photo. The photo will always be scaled to fit center.
+     */
+    private void configureBounds(boolean changed) {
+        if (mDrawable == null || !mHaveLayout) {
+            return;
+        }
+        final int dwidth = mDrawable.getIntrinsicWidth();
+        final int dheight = mDrawable.getIntrinsicHeight();
+
+        final int vwidth = getWidth();
+        final int vheight = getHeight();
+
+        final boolean fits = (dwidth < 0 || vwidth == dwidth) &&
+                (dheight < 0 || vheight == dheight);
+
+        // We need to do the scaling ourself, so have the drawable use its native size.
+        mDrawable.setBounds(0, 0, dwidth, dheight);
+
+        // Create a matrix with the proper transforms
+        if (changed || (mMinScale == 0 && mDrawable != null && mHaveLayout)) {
+            generateMatrix();
+            generateScale();
+        }
+
+        if (fits || mMatrix.isIdentity()) {
+            // The bitmap fits exactly, no transform needed.
+            mDrawMatrix = null;
+        } else {
+            mDrawMatrix = mMatrix;
+        }
+    }
+
+    /**
+     * Generates the initial transformation matrix for drawing. Additionally, it sets the
+     * minimum and maximum scale values.
+     */
+    private void generateMatrix() {
+        final int dwidth = mDrawable.getIntrinsicWidth();
+        final int dheight = mDrawable.getIntrinsicHeight();
+
+        final int vwidth = mAllowCrop ? sCropSize : getWidth();
+        final int vheight = mAllowCrop ? sCropSize : getHeight();
+
+        final boolean fits = (dwidth < 0 || vwidth == dwidth) &&
+                (dheight < 0 || vheight == dheight);
+
+        // Set the matrix to fill the screen
+        if (fits && !mAllowCrop) {
+            mMatrix.reset();
+        } else {
+            // Generate the required transforms for the photo
+            mTempSrc.set(0, 0, dwidth, dheight);
+            if (mAllowCrop) {
+                mTempDst.set(mCropRect);
+            } else {
+                mTempDst.set(0, 0, vwidth, vheight);
+            }
+            mMatrix.setRectToRect(mTempSrc, mTempDst, Matrix.ScaleToFit.CENTER);
+        }
+        mOriginalMatrix.set(mMatrix);
+    }
+
+    private void generateScale() {
+        final int dwidth = mDrawable.getIntrinsicWidth();
+        final int dheight = mDrawable.getIntrinsicHeight();
+
+        final int vwidth = mAllowCrop ? getCropSize() : getWidth();
+        final int vheight = mAllowCrop ? getCropSize() : getHeight();
+
+        if (dwidth < vwidth && dheight < vheight && !mAllowCrop) {
+            mMinScale = 1.0f;
+        } else {
+            mMinScale = getScale();
+        }
+        mMaxScale = Math.max(mMinScale * 8, 8);
+    }
+
+    /**
+     * @return the size of the crop regions
+     */
+    private int getCropSize() {
+        return mCropSize > 0 ? mCropSize : sCropSize;
+    }
+
+    /**
+     * Returns the currently applied scale factor for the image.
+     * <p>
+     * NOTE: This method overwrites any values stored in {@link #mValues}.
+     */
+    private float getScale() {
+        mMatrix.getValues(mValues);
+        return mValues[Matrix.MSCALE_X];
+    }
+
+    /**
+     * Scales the image while keeping the aspect ratio.
+     *
+     * The given scale is capped so that the resulting scale of the image always remains
+     * between {@link #mMinScale} and {@link #mMaxScale}.
+     *
+     * The scaled image is never allowed to be outside of the viewable area. If the image
+     * is smaller than the viewable area, it will be centered.
+     *
+     * @param newScale the new scale
+     * @param centerX the center horizontal point around which to scale
+     * @param centerY the center vertical point around which to scale
+     */
+    private void scale(float newScale, float centerX, float centerY) {
+        // rotate back to the original orientation
+        mMatrix.postRotate(-mRotation, getWidth() / 2, getHeight() / 2);
+
+        // ensure that mMixScale <= newScale <= mMaxScale
+        newScale = Math.max(newScale, mMinScale);
+        newScale = Math.min(newScale, mMaxScale);
+
+        float currentScale = getScale();
+        float factor = newScale / currentScale;
+
+        // apply the scale factor
+        mMatrix.postScale(factor, factor, centerX, centerY);
+
+        // ensure the image is within the view bounds
+        snap();
+
+        // re-apply any rotation
+        mMatrix.postRotate(mRotation, getWidth() / 2, getHeight() / 2);
+
+        invalidate();
+    }
+
+    /**
+     * Translates the image.
+     *
+     * This method will not allow the image to be translated outside of the visible area.
+     *
+     * @param tx how many pixels to translate horizontally
+     * @param ty how many pixels to translate vertically
+     * @return {@code true} if the translation was applied as specified. Otherwise, {@code false}
+     *      if the translation was modified.
+     */
+    private boolean translate(float tx, float ty) {
+        mTranslateRect.set(mTempSrc);
+        mMatrix.mapRect(mTranslateRect);
+
+        final float maxLeft = mAllowCrop ? mCropRect.left : 0.0f;
+        final float maxRight = mAllowCrop ? mCropRect.right : getWidth();
+        float l = mTranslateRect.left;
+        float r = mTranslateRect.right;
+
+        final float translateX;
+        if (mAllowCrop) {
+            // If we're cropping, allow the image to scroll off the edge of the screen
+            translateX = Math.max(maxLeft - mTranslateRect.right,
+                    Math.min(maxRight - mTranslateRect.left, tx));
+        } else {
+            // Otherwise, ensure the image never leaves the screen
+            if (r - l < maxRight - maxLeft) {
+                translateX = maxLeft + ((maxRight - maxLeft) - (r + l)) / 2;
+            } else {
+                translateX = Math.max(maxRight - r, Math.min(maxLeft - l, tx));
+            }
+        }
+
+        float maxTop = mAllowCrop ? mCropRect.top: 0.0f;
+        float maxBottom = mAllowCrop ? mCropRect.bottom : getHeight();
+        float t = mTranslateRect.top;
+        float b = mTranslateRect.bottom;
+
+        final float translateY;
+
+        if (mAllowCrop) {
+            // If we're cropping, allow the image to scroll off the edge of the screen
+            translateY = Math.max(maxTop - mTranslateRect.bottom,
+                    Math.min(maxBottom - mTranslateRect.top, ty));
+        } else {
+            // Otherwise, ensure the image never leaves the screen
+            if (b - t < maxBottom - maxTop) {
+                translateY = maxTop + ((maxBottom - maxTop) - (b + t)) / 2;
+            } else {
+                translateY = Math.max(maxBottom - b, Math.min(maxTop - t, ty));
+            }
+        }
+
+        // Do the translation
+        mMatrix.postTranslate(translateX, translateY);
+        invalidate();
+
+        return (translateX == tx) && (translateY == ty);
+    }
+
+    /**
+     * Snaps the image so it touches all edges of the view.
+     */
+    private void snap() {
+        mTranslateRect.set(mTempSrc);
+        mMatrix.mapRect(mTranslateRect);
+
+        // Determine how much to snap in the horizontal direction [if any]
+        float maxLeft = mAllowCrop ? mCropRect.left : 0.0f;
+        float maxRight = mAllowCrop ? mCropRect.right : getWidth();
+        float l = mTranslateRect.left;
+        float r = mTranslateRect.right;
+
+        final float translateX;
+        if (r - l < maxRight - maxLeft) {
+            // Image is narrower than view; translate to the center of the view
+            translateX = maxLeft + ((maxRight - maxLeft) - (r + l)) / 2;
+        } else if (l > maxLeft) {
+            // Image is off right-edge of screen; bring it into view
+            translateX = maxLeft - l;
+        } else if (r < maxRight) {
+            // Image is off left-edge of screen; bring it into view
+            translateX = maxRight - r;
+        } else {
+            translateX = 0.0f;
+        }
+
+        // Determine how much to snap in the vertical direction [if any]
+        float maxTop = mAllowCrop ? mCropRect.top : 0.0f;
+        float maxBottom = mAllowCrop ? mCropRect.bottom : getHeight();
+        float t = mTranslateRect.top;
+        float b = mTranslateRect.bottom;
+
+        final float translateY;
+        if (b - t < maxBottom - maxTop) {
+            // Image is shorter than view; translate to the bottom edge of the view
+            translateY = maxTop + ((maxBottom - maxTop) - (b + t)) / 2;
+        } else if (t > maxTop) {
+            // Image is off bottom-edge of screen; bring it into view
+            translateY = maxTop - t;
+        } else if (b < maxBottom) {
+            // Image is off top-edge of screen; bring it into view
+            translateY = maxBottom - b;
+        } else {
+            translateY = 0.0f;
+        }
+
+        if (Math.abs(translateX) > SNAP_THRESHOLD || Math.abs(translateY) > SNAP_THRESHOLD) {
+            mSnapRunnable.start(translateX, translateY);
+        } else {
+            mMatrix.postTranslate(translateX, translateY);
+            invalidate();
+        }
+    }
+
+    /**
+     * Rotates the image, either instantly or gradually
+     *
+     * @param degrees how many degrees to rotate the image, positive rotates clockwise
+     * @param animate if {@code true}, animate during the rotation. Otherwise, snap rotate.
+     */
+    private void rotate(float degrees, boolean animate) {
+        if (animate) {
+            mRotateRunnable.start(degrees);
+        } else {
+            mRotation += degrees;
+            mMatrix.postRotate(degrees, getWidth() / 2, getHeight() / 2);
+            invalidate();
+        }
+    }
+
+    /**
+     * Initializes the header and any static values
+     */
+    private void initialize() {
+        Context context = getContext();
+
+        if (!sInitialized) {
+            sInitialized = true;
+
+            Resources resources = context.getApplicationContext().getResources();
+
+            // Initialize bitmaps
+//            sCommentBitmap = ImageUtils.decodeResource(resources, R.drawable.ic_comment);
+//            sPlusOneBitmap = ImageUtils.decodeResource(resources, R.drawable.ic_plus_one);
+//            sVideoImage = ImageUtils.decodeResource(resources, R.drawable.video_overlay);
+//            sVideoNotReadyImage =
+//                    ImageUtils.decodeResource(resources, R.drawable.ic_loading_video);
+
+            // Initialize colors
+            sBackgroundColor = resources.getColor(R.color.photo_background_color);
+
+            // Initialize paint
+//            sPlusOneCountPaint = new TextPaint();
+//            sPlusOneCountPaint.setAntiAlias(true);
+//            sPlusOneCountPaint.setColor(resources.getColor(R.color.photo_info_plusone_count_color));
+//            sPlusOneCountPaint.setTextSize(resources.getDimension(
+//                    R.dimen.photo_info_plusone_text_size));
+//            TextPaintUtils.registerTextPaint(sPlusOneCountPaint,
+//                    R.dimen.photo_info_plusone_text_size);
+
+//            sCommentCountPaint = new TextPaint();
+//            sCommentCountPaint.setAntiAlias(true);
+//            sCommentCountPaint.setColor(resources.getColor(R.color.photo_info_comment_count_color));
+//            sCommentCountPaint.setTextSize(resources.getDimension(
+//                    R.dimen.photo_info_comment_text_size));
+//            TextPaintUtils.registerTextPaint(sCommentCountPaint,
+//                    R.dimen.photo_info_comment_text_size);
+
+//            sTagPaint = new Paint();
+//            sTagPaint.setAntiAlias(true);
+//            sTagPaint.setColor(resources.getColor(R.color.photo_tag_color));
+//            sTagPaint.setStyle(Style.STROKE);
+//            sTagPaint.setStrokeWidth(resources.getDimension(R.dimen.photo_tag_stroke_width));
+//            sTagPaint.setShadowLayer(resources.getDimension(R.dimen.photo_tag_shadow_radius),
+//                    0.0f, 0.0f, resources.getColor(R.color.photo_tag_shadow_color));
+
+            sCropSize = resources.getDimensionPixelSize(R.dimen.photo_crop_width);
+
+            sCropDimPaint = new Paint();
+            sCropDimPaint.setAntiAlias(true);
+            sCropDimPaint.setColor(resources.getColor(R.color.photo_crop_dim_color));
+            sCropDimPaint.setStyle(Style.FILL);
+
+            sCropPaint = new Paint();
+            sCropPaint.setAntiAlias(true);
+            sCropPaint.setColor(resources.getColor(R.color.photo_crop_highlight_color));
+            sCropPaint.setStyle(Style.STROKE);
+            sCropPaint.setStrokeWidth(resources.getDimension(R.dimen.photo_crop_stroke_width));
+
+//            sTagTextPaint = new TextPaint();
+//            sTagTextPaint.setAntiAlias(true);
+//            sTagTextPaint.setColor(resources.getColor(R.color.photo_tag_text_color));
+//            sTagTextPaint.setTypeface(Typeface.DEFAULT_BOLD);
+//            sTagTextPaint.setTextSize(resources.getDimension(R.dimen.photo_tag_text_size));
+//            sTagTextPaint.setShadowLayer(0.0f, 0.0f, 0.0f, Color.BLACK);
+//            TextPaintUtils.registerTextPaint(sTagTextPaint, R.dimen.photo_tag_text_size);
+//
+//            sTagTextBackgroundPaint = new Paint();
+//            sTagTextBackgroundPaint.setColor(
+//                    resources.getColor(R.color.photo_tag_text_background_color));
+//            sTagTextBackgroundPaint.setStyle(Style.FILL);
+
+            // Initialize dimensions
+            sPhotoOverlayRightPadding = (int)resources.getDimension(
+                    R.dimen.photo_overlay_right_padding);
+            sPhotoOverlayBottomPadding = (int)resources.getDimension(
+                    R.dimen.photo_overlay_bottom_padding);
+//            sCommentCountLeftMargin = (int)resources.getDimension(
+//                    R.dimen.photo_info_comment_count_left_margin);
+//            sCommentCountTextWidth = (int)resources.getDimension(
+//                    R.dimen.photo_info_comment_count_text_width);
+//            sPlusOneCountLeftMargin = (int)resources.getDimension(
+//                    R.dimen.photo_info_plusone_count_left_margin);
+//            sPlusOneCountTextWidth = (int)resources.getDimension(
+//                    R.dimen.photo_info_plusone_count_text_width);
+//            sPlusOneBottomMargin = (int) resources.getDimension(
+//                    R.dimen.photo_info_plusone_bottom_margin);
+//            sPlusOneIconRightPaddingHack = (int) resources.getDimension(
+//                    R.dimen.photo_info_plusone_icon_right_padding_hack);
+//            sTagTextPadding = (int)resources.getDimension(
+//                    R.dimen.photo_tag_text_padding);
+
+            sHasMultitouchDistinct = context.getPackageManager().hasSystemFeature(
+                    PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_DISTINCT);
+        }
+
+        mGestureDetector = new GestureDetector(context, this, null, !sHasMultitouchDistinct);
+        mScaleGetureDetector = new ScaleGestureDetector(context, this);
+        mScaleRunnable = new ScaleRunnable(this);
+        mTranslateRunnable = new TranslateRunnable(this);
+        mSnapRunnable = new SnapRunnable(this);
+        mRotateRunnable = new RotateRunnable(this);
+    }
+
+    /**
+     * Runnable that animates an image scale operation.
+     */
+    private static class ScaleRunnable implements Runnable {
+
+        private final PhotoView mHeader;
+
+        private float mCenterX;
+        private float mCenterY;
+
+        private boolean mZoomingIn;
+
+        private float mTargetScale;
+        private float mStartScale;
+        private float mVelocity;
+        private long mStartTime;
+
+        private boolean mRunning;
+        private boolean mStop;
+
+        public ScaleRunnable(PhotoView header) {
+            mHeader = header;
+        }
+
+        /**
+         * Starts the animation. There is no target scale bounds check.
+         */
+        public boolean start(float startScale, float targetScale, float centerX, float centerY) {
+            if (mRunning) {
+                return false;
+            }
+
+            mCenterX = centerX;
+            mCenterY = centerY;
+
+            // Ensure the target scale is within the min/max bounds
+            mTargetScale = targetScale;
+            mStartTime = System.currentTimeMillis();
+            mStartScale = startScale;
+            mZoomingIn = mTargetScale > mStartScale;
+            mVelocity = (mTargetScale - mStartScale) / ZOOM_ANIMATION_DURATION;
+            mRunning = true;
+            mStop = false;
+            mHeader.post(this);
+            return true;
+        }
+
+        /**
+         * Stops the animation in place. It does not snap the image to its final zoom.
+         */
+        public void stop() {
+            mRunning = false;
+            mStop = true;
+        }
+
+        @Override
+        public void run() {
+            if (mStop) {
+                return;
+            }
+
+            // Scale
+            long now = System.currentTimeMillis();
+            long ellapsed = now - mStartTime;
+            float newScale = (mStartScale + mVelocity * ellapsed);
+            mHeader.scale(newScale, mCenterX, mCenterY);
+
+            // Stop when done
+            if (newScale == mTargetScale || (mZoomingIn == (newScale > mTargetScale))) {
+                mHeader.scale(mTargetScale, mCenterX, mCenterY);
+                stop();
+            }
+
+            if (!mStop) {
+                mHeader.post(this);
+            }
+        }
+    }
+
+    /**
+     * Runnable that animates an image translation operation.
+     */
+    private static class TranslateRunnable implements Runnable {
+
+        private static final float DECELERATION_RATE = 1000f;
+        private static final long NEVER = -1L;
+
+        private final PhotoView mHeader;
+
+        private float mVelocityX;
+        private float mVelocityY;
+
+        private long mLastRunTime;
+        private boolean mRunning;
+        private boolean mStop;
+
+        public TranslateRunnable(PhotoView header) {
+            mLastRunTime = NEVER;
+            mHeader = header;
+        }
+
+        /**
+         * Starts the animation.
+         */
+        public boolean start(float velocityX, float velocityY) {
+            if (mRunning) {
+                return false;
+            }
+            mLastRunTime = NEVER;
+            mVelocityX = velocityX;
+            mVelocityY = velocityY;
+            mStop = false;
+            mRunning = true;
+            mHeader.post(this);
+            return true;
+        }
+
+        /**
+         * Stops the animation in place. It does not snap the image to its final translation.
+         */
+        public void stop() {
+            mRunning = false;
+            mStop = true;
+        }
+
+        @Override
+        public void run() {
+            // See if we were told to stop:
+            if (mStop) {
+                return;
+            }
+
+            // Translate according to current velocities and time delta:
+            long now = System.currentTimeMillis();
+            float delta = (mLastRunTime != NEVER) ? (now - mLastRunTime) / 1000f : 0f;
+            final boolean didTranslate = mHeader.translate(mVelocityX * delta, mVelocityY * delta);
+            mLastRunTime = now;
+            // Slow down:
+            float slowDown = DECELERATION_RATE * delta;
+            if (mVelocityX > 0f) {
+                mVelocityX -= slowDown;
+                if (mVelocityX < 0f) {
+                    mVelocityX = 0f;
+                }
+            } else {
+                mVelocityX += slowDown;
+                if (mVelocityX > 0f) {
+                    mVelocityX = 0f;
+                }
+            }
+            if (mVelocityY > 0f) {
+                mVelocityY -= slowDown;
+                if (mVelocityY < 0f) {
+                    mVelocityY = 0f;
+                }
+            } else {
+                mVelocityY += slowDown;
+                if (mVelocityY > 0f) {
+                    mVelocityY = 0f;
+                }
+            }
+
+            // Stop when done
+            if ((mVelocityX == 0f && mVelocityY == 0f) || !didTranslate) {
+                stop();
+                mHeader.snap();
+            }
+
+            // See if we need to continue flinging:
+            if (mStop) {
+                return;
+            }
+            mHeader.post(this);
+        }
+    }
+
+    /**
+     * Runnable that animates an image translation operation.
+     */
+    private static class SnapRunnable implements Runnable {
+
+        private static final long NEVER = -1L;
+
+        private final PhotoView mHeader;
+
+        private float mTranslateX;
+        private float mTranslateY;
+
+        private long mStartRunTime;
+        private boolean mRunning;
+        private boolean mStop;
+
+        public SnapRunnable(PhotoView header) {
+            mStartRunTime = NEVER;
+            mHeader = header;
+        }
+
+        /**
+         * Starts the animation.
+         */
+        public boolean start(float translateX, float translateY) {
+            if (mRunning) {
+                return false;
+            }
+            mStartRunTime = NEVER;
+            mTranslateX = translateX;
+            mTranslateY = translateY;
+            mStop = false;
+            mRunning = true;
+            mHeader.postDelayed(this, SNAP_DELAY);
+            return true;
+        }
+
+        /**
+         * Stops the animation in place. It does not snap the image to its final translation.
+         */
+        public void stop() {
+            mRunning = false;
+            mStop = true;
+        }
+
+        @Override
+        public void run() {
+            // See if we were told to stop:
+            if (mStop) {
+                return;
+            }
+
+            // Translate according to current velocities and time delta:
+            long now = System.currentTimeMillis();
+            float delta = (mStartRunTime != NEVER) ? (now - mStartRunTime) : 0f;
+
+            if (mStartRunTime == NEVER) {
+                mStartRunTime = now;
+            }
+
+            float transX;
+            float transY;
+            if (delta >= SNAP_DURATION) {
+                transX = mTranslateX;
+                transY = mTranslateY;
+            } else {
+                transX = (mTranslateX / (SNAP_DURATION - delta)) * 10f;
+                transY = (mTranslateY / (SNAP_DURATION - delta)) * 10f;
+                if (Math.abs(transX) > Math.abs(mTranslateX) || transX == Float.NaN) {
+                    transX = mTranslateX;
+                }
+                if (Math.abs(transY) > Math.abs(mTranslateY) || transY == Float.NaN) {
+                    transY = mTranslateY;
+                }
+            }
+
+            mHeader.translate(transX, transY);
+            mTranslateX -= transX;
+            mTranslateY -= transY;
+
+            if (mTranslateX == 0 && mTranslateY == 0) {
+                stop();
+            }
+
+            // See if we need to continue flinging:
+            if (mStop) {
+                return;
+            }
+            mHeader.post(this);
+        }
+    }
+
+    /**
+     * Runnable that animates an image rotation operation.
+     */
+    private static class RotateRunnable implements Runnable {
+
+        private static final long NEVER = -1L;
+
+        private final PhotoView mHeader;
+
+        private float mTargetRotation;
+        private float mAppliedRotation;
+        private float mVelocity;
+        private long mLastRuntime;
+
+        private boolean mRunning;
+        private boolean mStop;
+
+        public RotateRunnable(PhotoView header) {
+            mHeader = header;
+        }
+
+        /**
+         * Starts the animation.
+         */
+        public void start(float rotation) {
+            if (mRunning) {
+                return;
+            }
+
+            mTargetRotation = rotation;
+            mVelocity = mTargetRotation / ROTATE_ANIMATION_DURATION;
+            mAppliedRotation = 0f;
+            mLastRuntime = NEVER;
+            mStop = false;
+            mRunning = true;
+            mHeader.post(this);
+        }
+
+        /**
+         * Stops the animation in place. It does not snap the image to its final rotation.
+         */
+        public void stop() {
+            mRunning = false;
+            mStop = true;
+        }
+
+        @Override
+        public void run() {
+            if (mStop) {
+                return;
+            }
+
+            if (mAppliedRotation != mTargetRotation) {
+                long now = System.currentTimeMillis();
+                long delta = mLastRuntime != NEVER ? now - mLastRuntime : 0L;
+                float rotationAmount = mVelocity * delta;
+                if (mAppliedRotation < mTargetRotation
+                        && mAppliedRotation + rotationAmount > mTargetRotation
+                        || mAppliedRotation > mTargetRotation
+                        && mAppliedRotation + rotationAmount < mTargetRotation) {
+                    rotationAmount = mTargetRotation - mAppliedRotation;
+                }
+                mHeader.rotate(rotationAmount, false);
+                mAppliedRotation += rotationAmount;
+                if (mAppliedRotation == mTargetRotation) {
+                    stop();
+                }
+                mLastRuntime = now;
+            }
+
+            if (mStop) {
+                return;
+            }
+            mHeader.post(this);
+        }
+    }
+}