diff options
author | Andrew Sapperstein <asapperstein@google.com> | 2012-05-23 17:52:01 -0700 |
---|---|---|
committer | Andrew Sapperstein <asapperstein@google.com> | 2012-05-24 10:26:01 -0700 |
commit | f62c05bac7291de4bc738fb3284b4b821dfc5052 (patch) | |
tree | 23fbd545eaae292c611a4dcc6aacbd68d6c19d22 | |
parent | 7dd054e39986de84a213c56a3c11ac94731402e6 (diff) | |
download | android_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
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 Binary files differnew file mode 100644 index 000000000..b1afd4b6e --- /dev/null +++ b/res/drawable-hdpi/btn_bg_pressed.9.png diff --git a/res/drawable-hdpi/btn_bg_selected.9.png b/res/drawable-hdpi/btn_bg_selected.9.png Binary files differnew file mode 100644 index 000000000..331f96f41 --- /dev/null +++ b/res/drawable-hdpi/btn_bg_selected.9.png diff --git a/res/drawable-hdpi/ic_ab_back_holo_dark.png b/res/drawable-hdpi/ic_ab_back_holo_dark.png Binary files differnew file mode 100644 index 000000000..7855cda9a --- /dev/null +++ b/res/drawable-hdpi/ic_ab_back_holo_dark.png 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…</string> + + <!-- Displayed in a progress dialog while a network operation (create post, delete post, ...) is pending --> + <string name="post_operation_pending">Sending…</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"> See more »</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…</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); + } + } +} |