summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrew Sapperstein <asapperstein@google.com>2012-05-23 17:52:01 -0700
committerAndrew Sapperstein <asapperstein@google.com>2012-05-24 10:26:01 -0700
commitf62c05bac7291de4bc738fb3284b4b821dfc5052 (patch)
tree23fbd545eaae292c611a4dcc6aacbd68d6c19d22
parent7dd054e39986de84a213c56a3c11ac94731402e6 (diff)
downloadandroid_packages_apps_UnifiedEmail-f62c05bac7291de4bc738fb3284b4b821dfc5052.tar.gz
android_packages_apps_UnifiedEmail-f62c05bac7291de4bc738fb3284b4b821dfc5052.tar.bz2
android_packages_apps_UnifiedEmail-f62c05bac7291de4bc738fb3284b4b821dfc5052.zip
First commit of the native photo viewer.
It compiles but you can't actually get to it yet. I figured that at some point, this big commit would have to happen so I'm just doing it now. Change-Id: I0a7675545ee7708a5eff2e60708f84b151a87114
-rw-r--r--AndroidManifest.xml6
-rw-r--r--res/anim/fade_in.xml21
-rw-r--r--res/anim/fade_out.xml20
-rw-r--r--res/drawable-hdpi/btn_bg_pressed.9.pngbin0 -> 3037 bytes
-rw-r--r--res/drawable-hdpi/btn_bg_selected.9.pngbin0 -> 338 bytes
-rw-r--r--res/drawable-hdpi/ic_ab_back_holo_dark.pngbin0 -> 938 bytes
-rw-r--r--res/drawable/photo_view_background.xml24
-rw-r--r--res/drawable/photo_view_selector.xml24
-rw-r--r--res/drawable/photo_view_selector_focused.xml22
-rw-r--r--res/drawable/photo_view_selector_pressed.xml21
-rw-r--r--res/drawable/title_button_background.xml23
-rw-r--r--res/layout/action_bar_progress_spinner_layout.xml34
-rw-r--r--res/layout/loading_message.xml34
-rw-r--r--res/layout/photo_activity_view.xml72
-rw-r--r--res/layout/photo_fragment_view.xml76
-rw-r--r--res/layout/photo_spacer_view.xml22
-rw-r--r--res/layout/title_layout.xml103
-rw-r--r--res/menu/photo_view_menu.xml102
-rw-r--r--res/values/attrs.xml1
-rw-r--r--res/values/colors.xml14
-rw-r--r--res/values/constants.xml6
-rw-r--r--res/values/dimen.xml8
-rw-r--r--res/values/ids.xml14
-rw-r--r--res/values/strings.xml71
-rw-r--r--res/values/themes.xml25
-rw-r--r--src/com/android/mail/photo/BaseFragmentActivity.java773
-rw-r--r--src/com/android/mail/photo/Intents.java192
-rw-r--r--src/com/android/mail/photo/MultiChoiceActionModeStub.java197
-rw-r--r--src/com/android/mail/photo/Pageable.java41
-rw-r--r--src/com/android/mail/photo/PhotoViewActivity.java864
-rw-r--r--src/com/android/mail/photo/PhotoViewPager.java154
-rw-r--r--src/com/android/mail/photo/adapters/BaseCursorAdapter.java106
-rw-r--r--src/com/android/mail/photo/adapters/BaseCursorPagerAdapter.java258
-rw-r--r--src/com/android/mail/photo/adapters/BaseFragmentPagerAdapter.java208
-rw-r--r--src/com/android/mail/photo/adapters/PhotoPagerAdapter.java95
-rw-r--r--src/com/android/mail/photo/content/ImageRequest.java29
-rw-r--r--src/com/android/mail/photo/content/LocalImageRequest.java108
-rw-r--r--src/com/android/mail/photo/content/MediaImageRequest.java153
-rw-r--r--src/com/android/mail/photo/fragments/BaseFragment.java315
-rw-r--r--src/com/android/mail/photo/fragments/LoadingFragment.java41
-rw-r--r--src/com/android/mail/photo/fragments/PhotoViewFragment.java1026
-rw-r--r--src/com/android/mail/photo/loaders/BaseCursorLoader.java151
-rw-r--r--src/com/android/mail/photo/loaders/PhotoBitmapLoader.java166
-rw-r--r--src/com/android/mail/photo/loaders/PhotoCursorLoader.java309
-rw-r--r--src/com/android/mail/photo/loaders/PhotoPagerLoader.java48
-rw-r--r--src/com/android/mail/photo/provider/PhotoContract.java53
-rw-r--r--src/com/android/mail/photo/util/FIFEUtil.java624
-rw-r--r--src/com/android/mail/photo/util/GifDrawable.java836
-rw-r--r--src/com/android/mail/photo/util/ImageCache.java1274
-rw-r--r--src/com/android/mail/photo/util/ImageProxyUtil.java268
-rw-r--r--src/com/android/mail/photo/util/ImageUtils.java1375
-rw-r--r--src/com/android/mail/photo/util/MediaStoreUtils.java362
-rw-r--r--src/com/android/mail/photo/views/PhotoLayout.java98
-rw-r--r--src/com/android/mail/photo/views/PhotoView.java1624
54 files changed, 12490 insertions, 1 deletions
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 9f5db0e03..7fb6a050e 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 000000000..a63b1440b
--- /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 000000000..6e834cbec
--- /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 000000000..b1afd4b6e
--- /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 000000000..331f96f41
--- /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 000000000..7855cda9a
--- /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 000000000..f7ebc1713
--- /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 000000000..55b118a3b
--- /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 000000000..3bda586fe
--- /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 000000000..ced1b0289
--- /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 000000000..dff0640f7
--- /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 000000000..38df7deb2
--- /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 000000000..4528513bd
--- /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 000000000..df2a95942
--- /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 000000000..05babdff5
--- /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 000000000..3697e3985
--- /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 000000000..f90434872
--- /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 000000000..263cc95d0
--- /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 746cce152..70fc10a18 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 4bac51032..e4c036668 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 d26abae52..06bdc500f 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 cf186e94a..d39558ba9 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 1443e9280..ff10d9244 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 167171836..00223b5f8 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 000000000..b62c7142f
--- /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 000000000..adfd66320
--- /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 000000000..e5a956fb1
--- /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 000000000..5662d1852
--- /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 000000000..8521deda5
--- /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 000000000..fd09cd6f3
--- /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 000000000..7982ff3e5
--- /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 000000000..4f9552899
--- /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 000000000..f54c7c069
--- /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 000000000..c1b9fab80
--- /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 000000000..39ae91972
--- /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 000000000..648e769ca
--- /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 000000000..fa30bb20b
--- /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 000000000..612dc7d83
--- /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 000000000..3d97c7ee7
--- /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 000000000..8f9d1fe30
--- /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 000000000..f90485141
--- /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 000000000..46572f7bd
--- /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 000000000..a126d8fb4
--- /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 000000000..e19a490de
--- /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 000000000..5e3f7c371
--- /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 000000000..69184a2ce
--- /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 000000000..b23182f12
--- /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 000000000..5c625ea40
--- /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 000000000..31194c4ff
--- /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 000000000..11370ade9
--- /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 000000000..03837af2a
--- /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 000000000..e1650e81b
--- /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 000000000..30c8b425d
--- /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 000000000..b2d6f8e0d
--- /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);
+ }
+ }
+}