Merge "Add highlight to selected item in editor dropdowns" into ub-contactsdialer-g-dev
diff --git a/Android.mk b/Android.mk
index 9e9a55a..b19df65 100644
--- a/Android.mk
+++ b/Android.mk
@@ -3,7 +3,6 @@
LOCAL_MODULE_TAGS := optional
-contacts_common_dir := ../ContactsCommon
phone_common_dir := ../PhoneCommon
ifeq ($(TARGET_BUILD_APPS),)
@@ -12,9 +11,9 @@
support_library_root_dir := prebuilts/sdk/current/support
endif
-src_dirs := src $(contacts_common_dir)/src $(phone_common_dir)/src
-res_dirs := res res-aosp $(contacts_common_dir)/res $(contacts_common_dir)/icons/res $(phone_common_dir)/res
-asset_dirs := $(contacts_common_dir)/assets
+src_dirs := src src-bind $(phone_common_dir)/src
+res_dirs := res res-aosp res-icons $(phone_common_dir)/res
+asset_dirs := assets
LOCAL_SRC_FILES := $(call all-java-files-under, $(src_dirs))
LOCAL_RESOURCE_DIR := $(addprefix $(LOCAL_PATH)/, $(res_dirs)) \
@@ -82,5 +81,5 @@
#########################################################################################
-# Use the folloing include to make our test apk.
+# Use the following include to make our test apk.
include $(call all-makefiles-under,$(LOCAL_PATH))
diff --git a/assets/licenses.html b/assets/licenses.html
new file mode 100644
index 0000000..c24ed63
--- /dev/null
+++ b/assets/licenses.html
@@ -0,0 +1,247 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta name="viewport" content="width=device-width">
+ <style> body { font-family: sans-serif; } pre { background-color: #eeeeee; padding: 1em; white-space: pre-wrap; word-wrap: break-word; } </style>
+</head>
+<body>
+
+<h3>Notices for: guava, libphonenumber, and libprotobuf-java-nano</h3>
+<pre>
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+</pre>
+
+<h3>Notices for JSR305</h3>
+<pre>
+Copyright (c) 2007-2009, JSR305 expert group
+All rights reserved.
+
+http://www.opensource.org/licenses/bsd-license.php
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+ * Neither the name of the JSR305 expert group nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+</pre>
+
+</body>
+</html>
diff --git a/res-icons/mipmap-hdpi/ic_contacts_launcher.png b/res-icons/mipmap-hdpi/ic_contacts_launcher.png
new file mode 100644
index 0000000..86380d1
--- /dev/null
+++ b/res-icons/mipmap-hdpi/ic_contacts_launcher.png
Binary files differ
diff --git a/res-icons/mipmap-hdpi/ic_contacts_launcher_square.png b/res-icons/mipmap-hdpi/ic_contacts_launcher_square.png
new file mode 100644
index 0000000..64eff00
--- /dev/null
+++ b/res-icons/mipmap-hdpi/ic_contacts_launcher_square.png
Binary files differ
diff --git a/res-icons/mipmap-mdpi/ic_contacts_launcher.png b/res-icons/mipmap-mdpi/ic_contacts_launcher.png
new file mode 100644
index 0000000..85132c5
--- /dev/null
+++ b/res-icons/mipmap-mdpi/ic_contacts_launcher.png
Binary files differ
diff --git a/res-icons/mipmap-mdpi/ic_contacts_launcher_square.png b/res-icons/mipmap-mdpi/ic_contacts_launcher_square.png
new file mode 100644
index 0000000..b4ee821
--- /dev/null
+++ b/res-icons/mipmap-mdpi/ic_contacts_launcher_square.png
Binary files differ
diff --git a/res-icons/mipmap-xhdpi/ic_contacts_launcher.png b/res-icons/mipmap-xhdpi/ic_contacts_launcher.png
new file mode 100644
index 0000000..c198749
--- /dev/null
+++ b/res-icons/mipmap-xhdpi/ic_contacts_launcher.png
Binary files differ
diff --git a/res-icons/mipmap-xhdpi/ic_contacts_launcher_square.png b/res-icons/mipmap-xhdpi/ic_contacts_launcher_square.png
new file mode 100644
index 0000000..6feeadf
--- /dev/null
+++ b/res-icons/mipmap-xhdpi/ic_contacts_launcher_square.png
Binary files differ
diff --git a/res-icons/mipmap-xxhdpi/ic_contacts_launcher.png b/res-icons/mipmap-xxhdpi/ic_contacts_launcher.png
new file mode 100644
index 0000000..4fa10a6
--- /dev/null
+++ b/res-icons/mipmap-xxhdpi/ic_contacts_launcher.png
Binary files differ
diff --git a/res-icons/mipmap-xxhdpi/ic_contacts_launcher_square.png b/res-icons/mipmap-xxhdpi/ic_contacts_launcher_square.png
new file mode 100644
index 0000000..01a3fde
--- /dev/null
+++ b/res-icons/mipmap-xxhdpi/ic_contacts_launcher_square.png
Binary files differ
diff --git a/res-icons/mipmap-xxxhdpi/ic_contacts_launcher.png b/res-icons/mipmap-xxxhdpi/ic_contacts_launcher.png
new file mode 100644
index 0000000..10bda63
--- /dev/null
+++ b/res-icons/mipmap-xxxhdpi/ic_contacts_launcher.png
Binary files differ
diff --git a/res-icons/mipmap-xxxhdpi/ic_contacts_launcher_square.png b/res-icons/mipmap-xxxhdpi/ic_contacts_launcher_square.png
new file mode 100644
index 0000000..328e067
--- /dev/null
+++ b/res-icons/mipmap-xxxhdpi/ic_contacts_launcher_square.png
Binary files differ
diff --git a/res/color/popup_menu_color.xml b/res/color/popup_menu_color.xml
new file mode 100644
index 0000000..15588c2
--- /dev/null
+++ b/res/color/popup_menu_color.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2014 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT 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_enabled="false" android:alpha="0.5" android:color="#ff000000"/>
+ <item android:color="#ff000000"/>
+</selector>
\ No newline at end of file
diff --git a/res/color/tab_text_color.xml b/res/color/tab_text_color.xml
new file mode 100644
index 0000000..5ef1fe3
--- /dev/null
+++ b/res/color/tab_text_color.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2014 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT 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:color="@color/actionbar_text_color" android:state_selected="true"/>
+ <item android:color="@color/actionbar_unselected_text_color" />
+</selector>
\ No newline at end of file
diff --git a/res/drawable-hdpi/ic_ab_search.png b/res/drawable-hdpi/ic_ab_search.png
new file mode 100644
index 0000000..d86b219
--- /dev/null
+++ b/res/drawable-hdpi/ic_ab_search.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_add_to_circles_black_24.png b/res/drawable-hdpi/ic_add_to_circles_black_24.png
deleted file mode 100644
index 6eb1fcc..0000000
--- a/res/drawable-hdpi/ic_add_to_circles_black_24.png
+++ /dev/null
Binary files differ
diff --git a/res/drawable-hdpi/ic_arrow_back_24dp.png b/res/drawable-hdpi/ic_arrow_back_24dp.png
new file mode 100644
index 0000000..ddbb2c4
--- /dev/null
+++ b/res/drawable-hdpi/ic_arrow_back_24dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_business_white_120dp.png b/res/drawable-hdpi/ic_business_white_120dp.png
new file mode 100644
index 0000000..d5942dc
--- /dev/null
+++ b/res/drawable-hdpi/ic_business_white_120dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_call_24dp.png b/res/drawable-hdpi/ic_call_24dp.png
new file mode 100644
index 0000000..4dc5065
--- /dev/null
+++ b/res/drawable-hdpi/ic_call_24dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_call_note_white_24dp.png b/res/drawable-hdpi/ic_call_note_white_24dp.png
new file mode 100644
index 0000000..503e58e
--- /dev/null
+++ b/res/drawable-hdpi/ic_call_note_white_24dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_call_voicemail_holo_dark.png b/res/drawable-hdpi/ic_call_voicemail_holo_dark.png
new file mode 100644
index 0000000..6d64a36
--- /dev/null
+++ b/res/drawable-hdpi/ic_call_voicemail_holo_dark.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_close_dk.png b/res/drawable-hdpi/ic_close_dk.png
new file mode 100644
index 0000000..9695529
--- /dev/null
+++ b/res/drawable-hdpi/ic_close_dk.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_create_24dp.png b/res/drawable-hdpi/ic_create_24dp.png
new file mode 100644
index 0000000..540ab4d
--- /dev/null
+++ b/res/drawable-hdpi/ic_create_24dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_group_white_24dp.png b/res/drawable-hdpi/ic_group_white_24dp.png
new file mode 100644
index 0000000..017e4bb
--- /dev/null
+++ b/res/drawable-hdpi/ic_group_white_24dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_history_white_drawable_24dp.png b/res/drawable-hdpi/ic_history_white_drawable_24dp.png
new file mode 100644
index 0000000..703d30b
--- /dev/null
+++ b/res/drawable-hdpi/ic_history_white_drawable_24dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_info_outline_24dp.png b/res/drawable-hdpi/ic_info_outline_24dp.png
new file mode 100644
index 0000000..c7b1113
--- /dev/null
+++ b/res/drawable-hdpi/ic_info_outline_24dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_menu_remove_field_holo_light.png b/res/drawable-hdpi/ic_menu_remove_field_holo_light.png
new file mode 100644
index 0000000..03fd2fb
--- /dev/null
+++ b/res/drawable-hdpi/ic_menu_remove_field_holo_light.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_message_24dp.png b/res/drawable-hdpi/ic_message_24dp.png
new file mode 100644
index 0000000..57177b7
--- /dev/null
+++ b/res/drawable-hdpi/ic_message_24dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_person_24dp.png b/res/drawable-hdpi/ic_person_24dp.png
new file mode 100644
index 0000000..56708b0
--- /dev/null
+++ b/res/drawable-hdpi/ic_person_24dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_person_add_24dp.png b/res/drawable-hdpi/ic_person_add_24dp.png
new file mode 100644
index 0000000..10ae5a7
--- /dev/null
+++ b/res/drawable-hdpi/ic_person_add_24dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_person_avatar.png b/res/drawable-hdpi/ic_person_avatar.png
new file mode 100644
index 0000000..2da477e
--- /dev/null
+++ b/res/drawable-hdpi/ic_person_avatar.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_person_white_120dp.png b/res/drawable-hdpi/ic_person_white_120dp.png
new file mode 100644
index 0000000..69e6f98
--- /dev/null
+++ b/res/drawable-hdpi/ic_person_white_120dp.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_scroll_handle.png b/res/drawable-hdpi/ic_scroll_handle.png
new file mode 100644
index 0000000..3aa29b8
--- /dev/null
+++ b/res/drawable-hdpi/ic_scroll_handle.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_videocam.png b/res/drawable-hdpi/ic_videocam.png
new file mode 100644
index 0000000..97905c9
--- /dev/null
+++ b/res/drawable-hdpi/ic_videocam.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_voicemail_avatar.png b/res/drawable-hdpi/ic_voicemail_avatar.png
new file mode 100644
index 0000000..2fb7826
--- /dev/null
+++ b/res/drawable-hdpi/ic_voicemail_avatar.png
Binary files differ
diff --git a/res/drawable-hdpi/list_activated_holo.9.png b/res/drawable-hdpi/list_activated_holo.9.png
new file mode 100644
index 0000000..4ea7afa
--- /dev/null
+++ b/res/drawable-hdpi/list_activated_holo.9.png
Binary files differ
diff --git a/res/drawable-hdpi/list_background_holo.9.png b/res/drawable-hdpi/list_background_holo.9.png
new file mode 100644
index 0000000..cddf9be
--- /dev/null
+++ b/res/drawable-hdpi/list_background_holo.9.png
Binary files differ
diff --git a/res/drawable-hdpi/list_focused_holo.9.png b/res/drawable-hdpi/list_focused_holo.9.png
new file mode 100644
index 0000000..86578be
--- /dev/null
+++ b/res/drawable-hdpi/list_focused_holo.9.png
Binary files differ
diff --git a/res/drawable-hdpi/list_longpressed_holo_light.9.png b/res/drawable-hdpi/list_longpressed_holo_light.9.png
new file mode 100644
index 0000000..e9afcc9
--- /dev/null
+++ b/res/drawable-hdpi/list_longpressed_holo_light.9.png
Binary files differ
diff --git a/res/drawable-hdpi/list_pressed_holo_light.9.png b/res/drawable-hdpi/list_pressed_holo_light.9.png
new file mode 100644
index 0000000..2054530
--- /dev/null
+++ b/res/drawable-hdpi/list_pressed_holo_light.9.png
Binary files differ
diff --git a/res/drawable-hdpi/list_title_holo.9.png b/res/drawable-hdpi/list_title_holo.9.png
new file mode 100644
index 0000000..ae93717
--- /dev/null
+++ b/res/drawable-hdpi/list_title_holo.9.png
Binary files differ
diff --git a/res/drawable-hdpi/unknown_source.png b/res/drawable-hdpi/unknown_source.png
new file mode 100644
index 0000000..0a8f37d
--- /dev/null
+++ b/res/drawable-hdpi/unknown_source.png
Binary files differ
diff --git a/res/drawable-ldrtl-hdpi/list_background_holo.9.png b/res/drawable-ldrtl-hdpi/list_background_holo.9.png
new file mode 100644
index 0000000..0d80482
--- /dev/null
+++ b/res/drawable-ldrtl-hdpi/list_background_holo.9.png
Binary files differ
diff --git a/res/drawable-ldrtl-hdpi/list_focused_holo.9.png b/res/drawable-ldrtl-hdpi/list_focused_holo.9.png
new file mode 100644
index 0000000..4139942
--- /dev/null
+++ b/res/drawable-ldrtl-hdpi/list_focused_holo.9.png
Binary files differ
diff --git a/res/drawable-ldrtl-hdpi/list_title_holo.9.png b/res/drawable-ldrtl-hdpi/list_title_holo.9.png
new file mode 100644
index 0000000..5ec4c96
--- /dev/null
+++ b/res/drawable-ldrtl-hdpi/list_title_holo.9.png
Binary files differ
diff --git a/res/drawable-ldrtl-mdpi/list_background_holo.9.png b/res/drawable-ldrtl-mdpi/list_background_holo.9.png
new file mode 100644
index 0000000..d86d611
--- /dev/null
+++ b/res/drawable-ldrtl-mdpi/list_background_holo.9.png
Binary files differ
diff --git a/res/drawable-ldrtl-mdpi/list_focused_holo.9.png b/res/drawable-ldrtl-mdpi/list_focused_holo.9.png
new file mode 100644
index 0000000..4139942
--- /dev/null
+++ b/res/drawable-ldrtl-mdpi/list_focused_holo.9.png
Binary files differ
diff --git a/res/drawable-ldrtl-mdpi/list_title_holo.9.png b/res/drawable-ldrtl-mdpi/list_title_holo.9.png
new file mode 100644
index 0000000..013d5e7
--- /dev/null
+++ b/res/drawable-ldrtl-mdpi/list_title_holo.9.png
Binary files differ
diff --git a/res/drawable-ldrtl-sw600dp-hdpi/list_activated_holo.9.png b/res/drawable-ldrtl-sw600dp-hdpi/list_activated_holo.9.png
new file mode 100644
index 0000000..947f03c
--- /dev/null
+++ b/res/drawable-ldrtl-sw600dp-hdpi/list_activated_holo.9.png
Binary files differ
diff --git a/res/drawable-ldrtl-sw600dp-mdpi/list_activated_holo.9.png b/res/drawable-ldrtl-sw600dp-mdpi/list_activated_holo.9.png
new file mode 100644
index 0000000..6d09d72
--- /dev/null
+++ b/res/drawable-ldrtl-sw600dp-mdpi/list_activated_holo.9.png
Binary files differ
diff --git a/res/drawable-ldrtl-sw600dp-xhdpi/list_activated_holo.9.png b/res/drawable-ldrtl-sw600dp-xhdpi/list_activated_holo.9.png
new file mode 100644
index 0000000..63c7456
--- /dev/null
+++ b/res/drawable-ldrtl-sw600dp-xhdpi/list_activated_holo.9.png
Binary files differ
diff --git a/res/drawable-ldrtl-xhdpi/list_background_holo.9.png b/res/drawable-ldrtl-xhdpi/list_background_holo.9.png
new file mode 100644
index 0000000..f709f2c
--- /dev/null
+++ b/res/drawable-ldrtl-xhdpi/list_background_holo.9.png
Binary files differ
diff --git a/res/drawable-ldrtl-xhdpi/list_focused_holo.9.png b/res/drawable-ldrtl-xhdpi/list_focused_holo.9.png
new file mode 100644
index 0000000..4139942
--- /dev/null
+++ b/res/drawable-ldrtl-xhdpi/list_focused_holo.9.png
Binary files differ
diff --git a/res/drawable-ldrtl-xhdpi/list_title_holo.9.png b/res/drawable-ldrtl-xhdpi/list_title_holo.9.png
new file mode 100644
index 0000000..cb801ac
--- /dev/null
+++ b/res/drawable-ldrtl-xhdpi/list_title_holo.9.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_ab_search.png b/res/drawable-mdpi/ic_ab_search.png
new file mode 100644
index 0000000..2b23b1e
--- /dev/null
+++ b/res/drawable-mdpi/ic_ab_search.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_add_to_circles_black_24.png b/res/drawable-mdpi/ic_add_to_circles_black_24.png
deleted file mode 100644
index fbffc97..0000000
--- a/res/drawable-mdpi/ic_add_to_circles_black_24.png
+++ /dev/null
Binary files differ
diff --git a/res/drawable-mdpi/ic_arrow_back_24dp.png b/res/drawable-mdpi/ic_arrow_back_24dp.png
new file mode 100644
index 0000000..1a21fb4
--- /dev/null
+++ b/res/drawable-mdpi/ic_arrow_back_24dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_business_white_120dp.png b/res/drawable-mdpi/ic_business_white_120dp.png
new file mode 100644
index 0000000..3dddca5
--- /dev/null
+++ b/res/drawable-mdpi/ic_business_white_120dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_call_24dp.png b/res/drawable-mdpi/ic_call_24dp.png
new file mode 100644
index 0000000..77f9de5
--- /dev/null
+++ b/res/drawable-mdpi/ic_call_24dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_call_note_white_24dp.png b/res/drawable-mdpi/ic_call_note_white_24dp.png
new file mode 100644
index 0000000..9d359db
--- /dev/null
+++ b/res/drawable-mdpi/ic_call_note_white_24dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_call_voicemail_holo_dark.png b/res/drawable-mdpi/ic_call_voicemail_holo_dark.png
new file mode 100644
index 0000000..bf6d006
--- /dev/null
+++ b/res/drawable-mdpi/ic_call_voicemail_holo_dark.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_close_dk.png b/res/drawable-mdpi/ic_close_dk.png
new file mode 100644
index 0000000..590a728
--- /dev/null
+++ b/res/drawable-mdpi/ic_close_dk.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_create_24dp.png b/res/drawable-mdpi/ic_create_24dp.png
new file mode 100644
index 0000000..8a2df39
--- /dev/null
+++ b/res/drawable-mdpi/ic_create_24dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_group_white_24dp.png b/res/drawable-mdpi/ic_group_white_24dp.png
new file mode 100644
index 0000000..ad268bf
--- /dev/null
+++ b/res/drawable-mdpi/ic_group_white_24dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_history_white_drawable_24dp.png b/res/drawable-mdpi/ic_history_white_drawable_24dp.png
new file mode 100644
index 0000000..b3000d3
--- /dev/null
+++ b/res/drawable-mdpi/ic_history_white_drawable_24dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_info_outline_24dp.png b/res/drawable-mdpi/ic_info_outline_24dp.png
new file mode 100644
index 0000000..353e064
--- /dev/null
+++ b/res/drawable-mdpi/ic_info_outline_24dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_menu_remove_field_holo_light.png b/res/drawable-mdpi/ic_menu_remove_field_holo_light.png
new file mode 100644
index 0000000..8c44e70
--- /dev/null
+++ b/res/drawable-mdpi/ic_menu_remove_field_holo_light.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_menu_star_holo_light.png b/res/drawable-mdpi/ic_menu_star_holo_light.png
new file mode 100644
index 0000000..8263b27
--- /dev/null
+++ b/res/drawable-mdpi/ic_menu_star_holo_light.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_message_24dp.png b/res/drawable-mdpi/ic_message_24dp.png
new file mode 100644
index 0000000..3072b75
--- /dev/null
+++ b/res/drawable-mdpi/ic_message_24dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_person_24dp.png b/res/drawable-mdpi/ic_person_24dp.png
new file mode 100644
index 0000000..f0b1c72
--- /dev/null
+++ b/res/drawable-mdpi/ic_person_24dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_person_add_24dp.png b/res/drawable-mdpi/ic_person_add_24dp.png
new file mode 100644
index 0000000..38e0a28
--- /dev/null
+++ b/res/drawable-mdpi/ic_person_add_24dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_person_avatar.png b/res/drawable-mdpi/ic_person_avatar.png
new file mode 100644
index 0000000..31a40fb
--- /dev/null
+++ b/res/drawable-mdpi/ic_person_avatar.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_person_white_120dp.png b/res/drawable-mdpi/ic_person_white_120dp.png
new file mode 100644
index 0000000..397d933
--- /dev/null
+++ b/res/drawable-mdpi/ic_person_white_120dp.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_scroll_handle.png b/res/drawable-mdpi/ic_scroll_handle.png
new file mode 100644
index 0000000..af75db4
--- /dev/null
+++ b/res/drawable-mdpi/ic_scroll_handle.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_videocam.png b/res/drawable-mdpi/ic_videocam.png
new file mode 100644
index 0000000..dc9655b
--- /dev/null
+++ b/res/drawable-mdpi/ic_videocam.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_voicemail_avatar.png b/res/drawable-mdpi/ic_voicemail_avatar.png
new file mode 100644
index 0000000..4005f24
--- /dev/null
+++ b/res/drawable-mdpi/ic_voicemail_avatar.png
Binary files differ
diff --git a/res/drawable-mdpi/list_activated_holo.9.png b/res/drawable-mdpi/list_activated_holo.9.png
new file mode 100644
index 0000000..3bf8e03
--- /dev/null
+++ b/res/drawable-mdpi/list_activated_holo.9.png
Binary files differ
diff --git a/res/drawable-mdpi/list_background_holo.9.png b/res/drawable-mdpi/list_background_holo.9.png
new file mode 100644
index 0000000..7d5d66d
--- /dev/null
+++ b/res/drawable-mdpi/list_background_holo.9.png
Binary files differ
diff --git a/res/drawable-mdpi/list_focused_holo.9.png b/res/drawable-mdpi/list_focused_holo.9.png
new file mode 100644
index 0000000..86578be
--- /dev/null
+++ b/res/drawable-mdpi/list_focused_holo.9.png
Binary files differ
diff --git a/res/drawable-mdpi/list_longpressed_holo_light.9.png b/res/drawable-mdpi/list_longpressed_holo_light.9.png
new file mode 100644
index 0000000..3226ab7
--- /dev/null
+++ b/res/drawable-mdpi/list_longpressed_holo_light.9.png
Binary files differ
diff --git a/res/drawable-mdpi/list_pressed_holo_light.9.png b/res/drawable-mdpi/list_pressed_holo_light.9.png
new file mode 100644
index 0000000..061904c
--- /dev/null
+++ b/res/drawable-mdpi/list_pressed_holo_light.9.png
Binary files differ
diff --git a/res/drawable-mdpi/list_title_holo.9.png b/res/drawable-mdpi/list_title_holo.9.png
new file mode 100644
index 0000000..64bd691
--- /dev/null
+++ b/res/drawable-mdpi/list_title_holo.9.png
Binary files differ
diff --git a/res/drawable-mdpi/unknown_source.png b/res/drawable-mdpi/unknown_source.png
new file mode 100644
index 0000000..356748f
--- /dev/null
+++ b/res/drawable-mdpi/unknown_source.png
Binary files differ
diff --git a/res/drawable-sw600dp-hdpi/list_activated_holo.9.png b/res/drawable-sw600dp-hdpi/list_activated_holo.9.png
new file mode 100644
index 0000000..046b24a
--- /dev/null
+++ b/res/drawable-sw600dp-hdpi/list_activated_holo.9.png
Binary files differ
diff --git a/res/drawable-sw600dp-mdpi/list_activated_holo.9.png b/res/drawable-sw600dp-mdpi/list_activated_holo.9.png
new file mode 100644
index 0000000..1ff3373
--- /dev/null
+++ b/res/drawable-sw600dp-mdpi/list_activated_holo.9.png
Binary files differ
diff --git a/res/drawable-sw600dp-xhdpi/list_activated_holo.9.png b/res/drawable-sw600dp-xhdpi/list_activated_holo.9.png
new file mode 100644
index 0000000..2eb7c7e
--- /dev/null
+++ b/res/drawable-sw600dp-xhdpi/list_activated_holo.9.png
Binary files differ
diff --git a/res/drawable-v21/view_pager_tab_background.xml b/res/drawable-v21/view_pager_tab_background.xml
index 00c6db7..b9e0805 100644
--- a/res/drawable-v21/view_pager_tab_background.xml
+++ b/res/drawable-v21/view_pager_tab_background.xml
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- ~ Copyright (C) 2015 The Android Open Source Project
+ ~ Copyright (C) 2016 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
@@ -15,8 +15,8 @@
~ limitations under the License
-->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
- android:color="@color/tab_ripple_color">
+ android:color="@color/tab_ripple_color">
<item android:id="@android:id/mask">
<color android:color="@android:color/white" />
</item>
-</ripple>
\ No newline at end of file
+</ripple>
diff --git a/res/drawable-xhdpi/ic_ab_search.png b/res/drawable-xhdpi/ic_ab_search.png
new file mode 100644
index 0000000..71f7827
--- /dev/null
+++ b/res/drawable-xhdpi/ic_ab_search.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_add_to_circles_black_24.png b/res/drawable-xhdpi/ic_add_to_circles_black_24.png
deleted file mode 100644
index 79116b5..0000000
--- a/res/drawable-xhdpi/ic_add_to_circles_black_24.png
+++ /dev/null
Binary files differ
diff --git a/res/drawable-xhdpi/ic_arrow_back_24dp.png b/res/drawable-xhdpi/ic_arrow_back_24dp.png
new file mode 100644
index 0000000..bb73272
--- /dev/null
+++ b/res/drawable-xhdpi/ic_arrow_back_24dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_business_white_120dp.png b/res/drawable-xhdpi/ic_business_white_120dp.png
new file mode 100644
index 0000000..6256300
--- /dev/null
+++ b/res/drawable-xhdpi/ic_business_white_120dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_call_24dp.png b/res/drawable-xhdpi/ic_call_24dp.png
new file mode 100644
index 0000000..ef45e93
--- /dev/null
+++ b/res/drawable-xhdpi/ic_call_24dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_call_note_white_24dp.png b/res/drawable-xhdpi/ic_call_note_white_24dp.png
new file mode 100644
index 0000000..40eed1d
--- /dev/null
+++ b/res/drawable-xhdpi/ic_call_note_white_24dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_call_voicemail_holo_dark.png b/res/drawable-xhdpi/ic_call_voicemail_holo_dark.png
new file mode 100644
index 0000000..d9684d1
--- /dev/null
+++ b/res/drawable-xhdpi/ic_call_voicemail_holo_dark.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_close_dk.png b/res/drawable-xhdpi/ic_close_dk.png
new file mode 100644
index 0000000..5769f11
--- /dev/null
+++ b/res/drawable-xhdpi/ic_close_dk.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_create_24dp.png b/res/drawable-xhdpi/ic_create_24dp.png
new file mode 100644
index 0000000..48e75be
--- /dev/null
+++ b/res/drawable-xhdpi/ic_create_24dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_group_white_24dp.png b/res/drawable-xhdpi/ic_group_white_24dp.png
new file mode 100644
index 0000000..09c0e3e
--- /dev/null
+++ b/res/drawable-xhdpi/ic_group_white_24dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_history_white_drawable_24dp.png b/res/drawable-xhdpi/ic_history_white_drawable_24dp.png
new file mode 100644
index 0000000..e188d4a
--- /dev/null
+++ b/res/drawable-xhdpi/ic_history_white_drawable_24dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_info_outline_24dp.png b/res/drawable-xhdpi/ic_info_outline_24dp.png
new file mode 100644
index 0000000..c571b2e
--- /dev/null
+++ b/res/drawable-xhdpi/ic_info_outline_24dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_menu_remove_field_holo_light.png b/res/drawable-xhdpi/ic_menu_remove_field_holo_light.png
new file mode 100644
index 0000000..65a6b7b
--- /dev/null
+++ b/res/drawable-xhdpi/ic_menu_remove_field_holo_light.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_message_24dp.png b/res/drawable-xhdpi/ic_message_24dp.png
new file mode 100644
index 0000000..763767b
--- /dev/null
+++ b/res/drawable-xhdpi/ic_message_24dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_person_24dp.png b/res/drawable-xhdpi/ic_person_24dp.png
new file mode 100644
index 0000000..aea15f0
--- /dev/null
+++ b/res/drawable-xhdpi/ic_person_24dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_person_add_24dp.png b/res/drawable-xhdpi/ic_person_add_24dp.png
new file mode 100644
index 0000000..7e7c289
--- /dev/null
+++ b/res/drawable-xhdpi/ic_person_add_24dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_person_avatar.png b/res/drawable-xhdpi/ic_person_avatar.png
new file mode 100644
index 0000000..aecc9af
--- /dev/null
+++ b/res/drawable-xhdpi/ic_person_avatar.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_person_white_120dp.png b/res/drawable-xhdpi/ic_person_white_120dp.png
new file mode 100644
index 0000000..8d80a05
--- /dev/null
+++ b/res/drawable-xhdpi/ic_person_white_120dp.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_scroll_handle.png b/res/drawable-xhdpi/ic_scroll_handle.png
new file mode 100644
index 0000000..2d43c4d
--- /dev/null
+++ b/res/drawable-xhdpi/ic_scroll_handle.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_videocam.png b/res/drawable-xhdpi/ic_videocam.png
new file mode 100644
index 0000000..c1783de
--- /dev/null
+++ b/res/drawable-xhdpi/ic_videocam.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_voicemail_avatar.png b/res/drawable-xhdpi/ic_voicemail_avatar.png
new file mode 100644
index 0000000..f24505d
--- /dev/null
+++ b/res/drawable-xhdpi/ic_voicemail_avatar.png
Binary files differ
diff --git a/res/drawable-xhdpi/list_activated_holo.9.png b/res/drawable-xhdpi/list_activated_holo.9.png
new file mode 100644
index 0000000..eda10e6
--- /dev/null
+++ b/res/drawable-xhdpi/list_activated_holo.9.png
Binary files differ
diff --git a/res/drawable-xhdpi/list_background_holo.9.png b/res/drawable-xhdpi/list_background_holo.9.png
new file mode 100644
index 0000000..b652725
--- /dev/null
+++ b/res/drawable-xhdpi/list_background_holo.9.png
Binary files differ
diff --git a/res/drawable-xhdpi/list_focused_holo.9.png b/res/drawable-xhdpi/list_focused_holo.9.png
new file mode 100644
index 0000000..86578be
--- /dev/null
+++ b/res/drawable-xhdpi/list_focused_holo.9.png
Binary files differ
diff --git a/res/drawable-xhdpi/list_longpressed_holo_light.9.png b/res/drawable-xhdpi/list_longpressed_holo_light.9.png
new file mode 100644
index 0000000..5532e88
--- /dev/null
+++ b/res/drawable-xhdpi/list_longpressed_holo_light.9.png
Binary files differ
diff --git a/res/drawable-xhdpi/list_pressed_holo_light.9.png b/res/drawable-xhdpi/list_pressed_holo_light.9.png
new file mode 100644
index 0000000..f4af926
--- /dev/null
+++ b/res/drawable-xhdpi/list_pressed_holo_light.9.png
Binary files differ
diff --git a/res/drawable-xhdpi/list_title_holo.9.png b/res/drawable-xhdpi/list_title_holo.9.png
new file mode 100644
index 0000000..f4f00ca
--- /dev/null
+++ b/res/drawable-xhdpi/list_title_holo.9.png
Binary files differ
diff --git a/res/drawable-xhdpi/unknown_source.png b/res/drawable-xhdpi/unknown_source.png
new file mode 100644
index 0000000..35e8fb4
--- /dev/null
+++ b/res/drawable-xhdpi/unknown_source.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_ab_search.png b/res/drawable-xxhdpi/ic_ab_search.png
new file mode 100644
index 0000000..142c545
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_ab_search.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_add_to_circles_black_24.png b/res/drawable-xxhdpi/ic_add_to_circles_black_24.png
deleted file mode 100644
index 23a2084..0000000
--- a/res/drawable-xxhdpi/ic_add_to_circles_black_24.png
+++ /dev/null
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_arrow_back_24dp.png b/res/drawable-xxhdpi/ic_arrow_back_24dp.png
new file mode 100644
index 0000000..72c51b0
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_arrow_back_24dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_business_white_120dp.png b/res/drawable-xxhdpi/ic_business_white_120dp.png
new file mode 100644
index 0000000..8d67e44
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_business_white_120dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_call_24dp.png b/res/drawable-xxhdpi/ic_call_24dp.png
new file mode 100644
index 0000000..90ead2e
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_call_24dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_call_note_white_24dp.png b/res/drawable-xxhdpi/ic_call_note_white_24dp.png
new file mode 100644
index 0000000..2656cad
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_call_note_white_24dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_call_voicemail_holo_dark.png b/res/drawable-xxhdpi/ic_call_voicemail_holo_dark.png
new file mode 100644
index 0000000..ac5b83b
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_call_voicemail_holo_dark.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_close_dk.png b/res/drawable-xxhdpi/ic_close_dk.png
new file mode 100644
index 0000000..670bf79
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_close_dk.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_create_24dp.png b/res/drawable-xxhdpi/ic_create_24dp.png
new file mode 100644
index 0000000..24142c7
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_create_24dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_group_white_24dp.png b/res/drawable-xxhdpi/ic_group_white_24dp.png
new file mode 100644
index 0000000..03cad4c
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_group_white_24dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_history_white_drawable_24dp.png b/res/drawable-xxhdpi/ic_history_white_drawable_24dp.png
new file mode 100644
index 0000000..f44df1a
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_history_white_drawable_24dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_info_outline_24dp.png b/res/drawable-xxhdpi/ic_info_outline_24dp.png
new file mode 100644
index 0000000..c41a5fc
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_info_outline_24dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_menu_remove_field_holo_light.png b/res/drawable-xxhdpi/ic_menu_remove_field_holo_light.png
new file mode 100644
index 0000000..0fec2f2
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_menu_remove_field_holo_light.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_message_24dp.png b/res/drawable-xxhdpi/ic_message_24dp.png
new file mode 100644
index 0000000..0a79824
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_message_24dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_person_24dp.png b/res/drawable-xxhdpi/ic_person_24dp.png
new file mode 100644
index 0000000..184f741
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_person_24dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_person_add_24dp.png b/res/drawable-xxhdpi/ic_person_add_24dp.png
new file mode 100644
index 0000000..8f744f0
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_person_add_24dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_person_avatar.png b/res/drawable-xxhdpi/ic_person_avatar.png
new file mode 100644
index 0000000..2cfc004
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_person_avatar.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_person_white_120dp.png b/res/drawable-xxhdpi/ic_person_white_120dp.png
new file mode 100644
index 0000000..b29df2f
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_person_white_120dp.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_scroll_handle.png b/res/drawable-xxhdpi/ic_scroll_handle.png
new file mode 100644
index 0000000..55f1d13
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_scroll_handle.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_videocam.png b/res/drawable-xxhdpi/ic_videocam.png
new file mode 100644
index 0000000..4ab5ad0
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_videocam.png
Binary files differ
diff --git a/res/drawable-xxhdpi/ic_voicemail_avatar.png b/res/drawable-xxhdpi/ic_voicemail_avatar.png
new file mode 100644
index 0000000..182def8
--- /dev/null
+++ b/res/drawable-xxhdpi/ic_voicemail_avatar.png
Binary files differ
diff --git a/res/drawable-xxhdpi/list_activated_holo.9.png b/res/drawable-xxhdpi/list_activated_holo.9.png
new file mode 100644
index 0000000..52c00dd
--- /dev/null
+++ b/res/drawable-xxhdpi/list_activated_holo.9.png
Binary files differ
diff --git a/res/drawable-xxhdpi/list_focused_holo.9.png b/res/drawable-xxhdpi/list_focused_holo.9.png
new file mode 100644
index 0000000..3e4ca68
--- /dev/null
+++ b/res/drawable-xxhdpi/list_focused_holo.9.png
Binary files differ
diff --git a/res/drawable-xxhdpi/list_longpressed_holo_light.9.png b/res/drawable-xxhdpi/list_longpressed_holo_light.9.png
new file mode 100644
index 0000000..230d649
--- /dev/null
+++ b/res/drawable-xxhdpi/list_longpressed_holo_light.9.png
Binary files differ
diff --git a/res/drawable-xxhdpi/list_pressed_holo_light.9.png b/res/drawable-xxhdpi/list_pressed_holo_light.9.png
new file mode 100644
index 0000000..1352a17
--- /dev/null
+++ b/res/drawable-xxhdpi/list_pressed_holo_light.9.png
Binary files differ
diff --git a/res/drawable-xxhdpi/list_title_holo.9.png b/res/drawable-xxhdpi/list_title_holo.9.png
new file mode 100644
index 0000000..7ddf14a
--- /dev/null
+++ b/res/drawable-xxhdpi/list_title_holo.9.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_ab_search.png b/res/drawable-xxxhdpi/ic_ab_search.png
new file mode 100644
index 0000000..2ffb2ec
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_ab_search.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_arrow_back_24dp.png b/res/drawable-xxxhdpi/ic_arrow_back_24dp.png
new file mode 100644
index 0000000..ae01a04
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_arrow_back_24dp.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_business_white_120dp.png b/res/drawable-xxxhdpi/ic_business_white_120dp.png
new file mode 100644
index 0000000..1741675
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_business_white_120dp.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_call_24dp.png b/res/drawable-xxxhdpi/ic_call_24dp.png
new file mode 100644
index 0000000..b0e0205
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_call_24dp.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_call_note_white_24dp.png b/res/drawable-xxxhdpi/ic_call_note_white_24dp.png
new file mode 100644
index 0000000..903c162
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_call_note_white_24dp.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_close_dk.png b/res/drawable-xxxhdpi/ic_close_dk.png
new file mode 100644
index 0000000..3a5540f
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_close_dk.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_create_24dp.png b/res/drawable-xxxhdpi/ic_create_24dp.png
new file mode 100644
index 0000000..d3ff0ec
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_create_24dp.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_history_white_drawable_24dp.png b/res/drawable-xxxhdpi/ic_history_white_drawable_24dp.png
new file mode 100644
index 0000000..5b96af5
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_history_white_drawable_24dp.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_info_outline_24dp.png b/res/drawable-xxxhdpi/ic_info_outline_24dp.png
new file mode 100644
index 0000000..3a82cab
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_info_outline_24dp.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_message_24dp.png b/res/drawable-xxxhdpi/ic_message_24dp.png
new file mode 100644
index 0000000..fa7c17a
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_message_24dp.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_person_24dp.png b/res/drawable-xxxhdpi/ic_person_24dp.png
new file mode 100644
index 0000000..33d40d8
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_person_24dp.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_person_add_24dp.png b/res/drawable-xxxhdpi/ic_person_add_24dp.png
new file mode 100644
index 0000000..2fa2cca
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_person_add_24dp.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_person_avatar.png b/res/drawable-xxxhdpi/ic_person_avatar.png
new file mode 100644
index 0000000..3233252
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_person_avatar.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_person_white_120dp.png b/res/drawable-xxxhdpi/ic_person_white_120dp.png
new file mode 100644
index 0000000..b53cc11
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_person_white_120dp.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_scroll_handle.png b/res/drawable-xxxhdpi/ic_scroll_handle.png
new file mode 100644
index 0000000..d90782a
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_scroll_handle.png
Binary files differ
diff --git a/res/drawable-xxxhdpi/ic_videocam.png b/res/drawable-xxxhdpi/ic_videocam.png
new file mode 100644
index 0000000..0643ea5
--- /dev/null
+++ b/res/drawable-xxxhdpi/ic_videocam.png
Binary files differ
diff --git a/res/drawable/dialog_background_material.xml b/res/drawable/dialog_background_material.xml
new file mode 100644
index 0000000..fb586a0
--- /dev/null
+++ b/res/drawable/dialog_background_material.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2015 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<inset xmlns:android="http://schemas.android.com/apk/res/android"
+ android:inset="16dp">
+ <shape android:shape="rectangle">
+ <corners android:radius="2dp" />
+ <solid android:color="@color/call_subject_history_background" />
+ </shape>
+</inset>
diff --git a/res/drawable/fastscroll_thumb.xml b/res/drawable/fastscroll_thumb.xml
new file mode 100644
index 0000000..eca4b39
--- /dev/null
+++ b/res/drawable/fastscroll_thumb.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2014 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT 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/ic_scroll_handle_pressed" />
+ <item android:drawable="@drawable/ic_scroll_handle_default" />
+</selector>
\ No newline at end of file
diff --git a/res/drawable/ic_back_arrow.xml b/res/drawable/ic_back_arrow.xml
new file mode 100644
index 0000000..68a875d
--- /dev/null
+++ b/res/drawable/ic_back_arrow.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2014 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
+ android:src="@drawable/ic_arrow_back_24dp"
+ android:autoMirrored="true"
+ android:tint="@color/actionbar_icon_color" />
\ No newline at end of file
diff --git a/res/drawable/ic_call.xml b/res/drawable/ic_call.xml
new file mode 100644
index 0000000..e06317b
--- /dev/null
+++ b/res/drawable/ic_call.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2015 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
+ android:src="@drawable/ic_call_24dp"
+ android:autoMirrored="true" />
diff --git a/res/drawable/ic_cancel_black_24dp.xml b/res/drawable/ic_cancel_black_24dp.xml
new file mode 100644
index 0000000..30f8ef5
--- /dev/null
+++ b/res/drawable/ic_cancel_black_24dp.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2016 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- An 'x' with a circle around it (used as a delete button). -->
+<vector android:alpha="0.54" android:height="24dp"
+ android:viewportHeight="24.0" android:viewportWidth="24.0"
+ android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillColor="#000000" android:pathData="M12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM17,15.59L15.59,17 12,13.41 8.41,17 7,15.59 10.59,12 7,8.41 8.41,7 12,10.59 15.59,7 17,8.41 13.41,12 17,15.59z"/>
+</vector>
diff --git a/res/drawable/ic_check_mark.xml b/res/drawable/ic_check_mark.xml
new file mode 100644
index 0000000..b0d73cd
--- /dev/null
+++ b/res/drawable/ic_check_mark.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2016 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- Checkmark icon used when some task is done -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24.0"
+ android:viewportWidth="24.0">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/>
+</vector>
\ No newline at end of file
diff --git a/res/drawable/ic_close_black_24dp.xml b/res/drawable/ic_close_black_24dp.xml
new file mode 100644
index 0000000..4ddacdf
--- /dev/null
+++ b/res/drawable/ic_close_black_24dp.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2016 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- 'X' icon (used in multi select mode and search bar). -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24.0"
+ android:viewportWidth="24.0">
+ <path
+ android:fillColor="#000000"
+ android:pathData="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
+</vector>
diff --git a/res/drawable/ic_device.xml b/res/drawable/ic_device.xml
new file mode 100644
index 0000000..7fd3bd5
--- /dev/null
+++ b/res/drawable/ic_device.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- ic_device is a phone-shaped icon. Since it is not tinted so we set the tint color in here. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24.0"
+ android:viewportWidth="24.0">
+<path
+ android:fillColor="#7f7f7f"
+ android:pathData="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/>
+</vector>
\ No newline at end of file
diff --git a/res/drawable/ic_material_star.xml b/res/drawable/ic_material_star.xml
new file mode 100644
index 0000000..cd7c61c
--- /dev/null
+++ b/res/drawable/ic_material_star.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2016 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- Material design star icon -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24.0"
+ android:viewportWidth="24.0">
+<path
+ android:fillColor="#000000"
+ android:pathData="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>
+</vector>
\ No newline at end of file
diff --git a/res/drawable/ic_message_24dp_mirrored.xml b/res/drawable/ic_message_24dp_mirrored.xml
new file mode 100644
index 0000000..b1bd743
--- /dev/null
+++ b/res/drawable/ic_message_24dp_mirrored.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2015 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
+ android:src="@drawable/ic_message_24dp"
+ android:autoMirrored="true" />
diff --git a/res/drawable/ic_more_vert.xml b/res/drawable/ic_more_vert.xml
new file mode 100644
index 0000000..749316a
--- /dev/null
+++ b/res/drawable/ic_more_vert.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24.0"
+ android:viewportWidth="24.0">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
+</vector>
diff --git a/res/drawable/ic_person_add_tinted_24dp.xml b/res/drawable/ic_person_add_tinted_24dp.xml
new file mode 100644
index 0000000..fdbf4fa
--- /dev/null
+++ b/res/drawable/ic_person_add_tinted_24dp.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2014 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
+ android:src="@drawable/ic_person_add_24dp"
+ android:autoMirrored="true"/>
diff --git a/res/drawable/ic_scroll_handle_default.xml b/res/drawable/ic_scroll_handle_default.xml
new file mode 100644
index 0000000..055005e
--- /dev/null
+++ b/res/drawable/ic_scroll_handle_default.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2014 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
+ android:src="@drawable/ic_scroll_handle"
+ android:tint="@color/dialtacts_secondary_text_color" />
\ No newline at end of file
diff --git a/res/drawable/ic_scroll_handle_pressed.xml b/res/drawable/ic_scroll_handle_pressed.xml
new file mode 100644
index 0000000..9109c81
--- /dev/null
+++ b/res/drawable/ic_scroll_handle_pressed.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2014 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
+ android:src="@drawable/ic_scroll_handle"
+ android:tint="@color/dialtacts_theme_color" />
\ No newline at end of file
diff --git a/res/drawable/ic_search_add_contact.xml b/res/drawable/ic_search_add_contact.xml
new file mode 100644
index 0000000..9a313cd
--- /dev/null
+++ b/res/drawable/ic_search_add_contact.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2014 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
+ android:src="@drawable/ic_person_add_24dp"
+ android:autoMirrored="true" />
diff --git a/res/drawable/ic_search_video_call.xml b/res/drawable/ic_search_video_call.xml
new file mode 100644
index 0000000..c2390e5
--- /dev/null
+++ b/res/drawable/ic_search_video_call.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2015 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+
+<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
+ android:src="@drawable/ic_videocam"
+ android:autoMirrored="true"
+ android:tint="@color/search_video_call_icon_tint" />
diff --git a/res/drawable/ic_work_profile.xml b/res/drawable/ic_work_profile.xml
new file mode 100644
index 0000000..bacfe69
--- /dev/null
+++ b/res/drawable/ic_work_profile.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="16dp"
+ android:height="16dp"
+ android:viewportWidth="48"
+ android:viewportHeight="48">
+
+ <!-- //java/com/google/android/assets/quantum:ic_enterprise_grey600_16 -->
+
+ <path
+ android:fillColor="#757575"
+ android:pathData="M28 33h-8v-3H6v8c0 2.2 1.8 4 4 4h28c2.2 0 4-1.8
+4-4v-8H28v3zm12-21h-7V9l-3-3H18l-3 3.1V12H8c-2.2 0-4 1.8-4 4v8c0 2.2 1.8 4 4
+4h12v-3h8v3h12c2.2 0 4-1.8 4-4v-8c0-2.2-1.8-4-4-4zm-10 0H18V9h12v3z" />
+ <path
+ android:pathData="M0 0h48v48H0z" />
+</vector>
diff --git a/res/drawable/item_background_material_borderless_dark.xml b/res/drawable/item_background_material_borderless_dark.xml
new file mode 100644
index 0000000..693bcaf
--- /dev/null
+++ b/res/drawable/item_background_material_borderless_dark.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2014 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- Based on the Theme.Material's default selectableItemBackgroundBorderless -->
+<ripple xmlns:android="http://schemas.android.com/apk/res/android"
+ android:color="@color/ripple_material_dark" />
\ No newline at end of file
diff --git a/res/drawable/item_background_material_dark.xml b/res/drawable/item_background_material_dark.xml
new file mode 100644
index 0000000..87b1e17
--- /dev/null
+++ b/res/drawable/item_background_material_dark.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2014 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- Based on the Theme.Material's default selectableItemBackground -->
+<ripple xmlns:android="http://schemas.android.com/apk/res/android"
+ android:color="@color/ripple_material_dark" >
+ <item android:id="@android:id/mask">
+ <color android:color="@android:color/white" />
+ </item>
+</ripple>
\ No newline at end of file
diff --git a/res/drawable/list_item_activated_background.xml b/res/drawable/list_item_activated_background.xml
new file mode 100644
index 0000000..a58577e
--- /dev/null
+++ b/res/drawable/list_item_activated_background.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2008 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT 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_activated="true" android:drawable="@drawable/list_activated_holo" />
+ <item android:drawable="@drawable/list_background_holo" />
+</selector>
diff --git a/res/drawable/searchedittext_custom_cursor.xml b/res/drawable/searchedittext_custom_cursor.xml
new file mode 100644
index 0000000..a6bb90f
--- /dev/null
+++ b/res/drawable/searchedittext_custom_cursor.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright 2014 Google Inc. All Rights Reserved. -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android" >
+ <size android:width="2dp" android:height="22dp" />
+ <solid android:color="@color/dialtacts_theme_color" />
+</shape>
\ No newline at end of file
diff --git a/res/drawable/unread_count_background.xml b/res/drawable/unread_count_background.xml
new file mode 100644
index 0000000..f70f84a
--- /dev/null
+++ b/res/drawable/unread_count_background.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2015 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT 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">
+ <corners android:radius="@dimen/tab_unread_count_background_radius"/>
+ <solid android:color="@color/tab_unread_count_background_color" />
+</shape>
diff --git a/res/drawable/view_pager_tab_background.xml b/res/drawable/view_pager_tab_background.xml
index f1ddbe2..9f59845 100644
--- a/res/drawable/view_pager_tab_background.xml
+++ b/res/drawable/view_pager_tab_background.xml
@@ -17,5 +17,5 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:state_pressed="true"
- android:drawable="@color/primary_color_dark"/>
+ android:drawable="@color/tab_ripple_color"/>
</selector>
\ No newline at end of file
diff --git a/res/layout-ldrtl/unread_count_tab.xml b/res/layout-ldrtl/unread_count_tab.xml
new file mode 100644
index 0000000..b23ab57
--- /dev/null
+++ b/res/layout-ldrtl/unread_count_tab.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2015 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!-- layoutDirection set to ltr as a workaround to a framework bug (b/22010411) causing view with
+ layout_centerInParent inside a RelativeLayout to expand to screen width when RTL is active -->
+<RelativeLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@drawable/view_pager_tab_background"
+ android:layoutDirection="ltr">
+ <!-- The tab icon -->
+ <ImageView
+ android:id="@+id/icon"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_centerInParent="true"/>
+ <TextView
+ android:id="@+id/count"
+ android:layout_width="wrap_content"
+ android:layout_height="@dimen/tab_unread_count_background_size"
+ android:layout_marginTop="@dimen/tab_unread_count_margin_top"
+ android:layout_marginStart="@dimen/tab_unread_count_margin_left"
+ android:layout_toStartOf="@id/icon"
+ android:paddingLeft="@dimen/tab_unread_count_text_padding"
+ android:paddingRight="@dimen/tab_unread_count_text_padding"
+ android:background="@drawable/unread_count_background"
+ android:fontFamily="sans-serif-medium"
+ android:importantForAccessibility="no"
+ android:minWidth="@dimen/tab_unread_count_background_size"
+ android:textAlignment="center"
+ android:textColor="@color/tab_accent_color"
+ android:textSize="@dimen/tab_unread_count_text_size"
+ android:layoutDirection="locale"/>
+</RelativeLayout>
+
diff --git a/res/layout/account_filter_header.xml b/res/layout/account_filter_header.xml
new file mode 100644
index 0000000..d348e82
--- /dev/null
+++ b/res/layout/account_filter_header.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- Layout showing the type of account filter
+ (e.g. All contacts filter, custom filter, etc.),
+ which is the header of all contact lists. -->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/account_filter_header_container"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/contact_browser_list_header_height"
+ android:paddingTop="@dimen/list_header_extra_top_padding"
+ android:visibility="gone"
+ android:orientation="horizontal"
+ android:background="@color/background_primary">
+
+ <ImageView
+ android:id="@+id/account_filter_icon"
+ android:layout_height="@dimen/contact_browser_list_header_icon_size"
+ android:layout_width="@dimen/contact_browser_list_header_icon_size"
+ android:layout_marginStart="@dimen/contact_browser_list_header_icon_left_margin"
+ android:layout_marginEnd="@dimen/contact_browser_list_header_icon_right_margin"
+ android:layout_gravity="center_vertical"/>
+
+ <TextView
+ android:id="@+id/account_filter_header"
+ android:layout_gravity="center_vertical"
+ android:layout_marginStart="@dimen/contact_browser_list_header_text_margin"
+ android:layout_marginEnd="@dimen/contact_browser_list_header_right_margin"
+ style="@style/ContactListSeparatorTextViewStyle"
+ android:textAlignment="viewStart"
+ android:paddingLeft="@dimen/contact_browser_list_item_text_indent"
+ android:paddingStart="@dimen/contact_browser_list_item_text_indent" />
+</LinearLayout>
diff --git a/res/layout/account_selector_list_item.xml b/res/layout/account_selector_list_item.xml
new file mode 100644
index 0000000..ba86a9b
--- /dev/null
+++ b/res/layout/account_selector_list_item.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2010 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="?android:attr/listPreferredItemHeight"
+ android:orientation="horizontal">
+ <ImageView android:id="@android:id/icon"
+ android:layout_width="@dimen/detail_network_icon_size"
+ android:layout_height="@dimen/detail_network_icon_size"
+ android:layout_margin="16dip"
+ android:layout_gravity="center_vertical" />
+
+ <LinearLayout
+ android:layout_width="0dip"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginLeft="8dp"
+ android:orientation="vertical"
+ android:layout_gravity="center_vertical">
+
+ <TextView android:id="@android:id/text1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginRight="8dip"
+ android:layout_marginEnd="8dip"
+ android:textAppearance="?android:attr/textAppearanceMedium"
+ android:singleLine="true"
+ android:ellipsize="end"/>
+
+ <TextView android:id="@android:id/text2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginRight="8dip"
+ android:layout_marginEnd="8dip"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="?android:attr/textColorSecondary"
+ android:singleLine="true"
+ android:ellipsize="end"/>
+ </LinearLayout>
+</LinearLayout>
diff --git a/res/layout/account_selector_list_item_condensed.xml b/res/layout/account_selector_list_item_condensed.xml
new file mode 100644
index 0000000..6720065
--- /dev/null
+++ b/res/layout/account_selector_list_item_condensed.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2015 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT 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:layout_width="280dp"
+ android:layout_height="72dp"
+ android:orientation="horizontal">
+ <ImageView android:id="@android:id/icon"
+ android:layout_width="@dimen/detail_network_icon_size"
+ android:layout_height="@dimen/detail_network_icon_size"
+ android:layout_marginStart="24dp"
+ android:layout_marginEnd="16dp"
+ android:layout_gravity="center_vertical" />
+
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:layout_gravity="center_vertical">
+
+ <TextView android:id="@android:id/text1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginRight="8dip"
+ android:layout_marginEnd="8dip"
+ android:textSize="16sp"
+ android:textColor="@color/contacts_text_color"
+ android:textAppearance="?android:attr/textAppearanceMedium"
+ android:singleLine="true"
+ android:ellipsize="end"/>
+
+ <TextView android:id="@android:id/text2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginRight="8dip"
+ android:layout_marginEnd="8dip"
+ android:textSize="14sp"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="?android:attr/textColorSecondary"
+ android:singleLine="true"
+ android:ellipsize="end"/>
+ </LinearLayout>
+</LinearLayout>
diff --git a/res/layout/call_subject_history_list_item.xml b/res/layout/call_subject_history_list_item.xml
new file mode 100644
index 0000000..b8cce47
--- /dev/null
+++ b/res/layout/call_subject_history_list_item.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2015 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License
+ -->
+
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@android:id/text1"
+ android:gravity="center_vertical"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingStart="@dimen/call_subject_dialog_margin"
+ android:paddingEnd="@dimen/call_subject_dialog_margin"
+ android:paddingTop="@dimen/call_subject_history_item_padding"
+ android:paddingBottom="@dimen/call_subject_history_item_padding"
+ android:singleLine="true"
+ android:textColor="@color/dialtacts_primary_text_color"
+ android:textSize="@dimen/call_subject_dialog_primary_text_size" />
diff --git a/res/layout/contact_list_card.xml b/res/layout/contact_list_card.xml
new file mode 100644
index 0000000..c20dbe7
--- /dev/null
+++ b/res/layout/contact_list_card.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2014 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT 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:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="horizontal"
+ android:id="@+id/list_card"
+ android:visibility="invisible">
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@color/contact_all_list_background_color"/>
+</LinearLayout>
diff --git a/res/layout/contact_list_content.xml b/res/layout/contact_list_content.xml
new file mode 100644
index 0000000..f18267d
--- /dev/null
+++ b/res/layout/contact_list_content.xml
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2008 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- android:paddingTop is used instead of android:layout_marginTop. It looks
+ android:layout_marginTop is ignored when used with <fragment></fragment>, which
+ only happens in Tablet UI since we rely on ViewPager in Phone UI.
+ Instead, android:layout_marginTop inside <fragment /> is effective. -->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/pinned_header_list_layout"
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:visibility="gone"
+ android:background="?attr/contact_browser_background" >
+
+ <!-- Shown only when an Account filter is set.
+ - paddingTop should be here to show "shade" effect correctly. -->
+ <include layout="@layout/account_filter_header" />
+
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="0dip"
+ android:layout_weight="1"
+ android:id="@+id/contact_list">
+
+ <include layout="@layout/contact_list_card"/>
+ <view
+ class="com.android.contacts.common.list.PinnedHeaderListView"
+ android:id="@android:id/list"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_marginLeft="?attr/contact_browser_list_padding_left"
+ android:layout_marginRight="?attr/contact_browser_list_padding_right"
+ android:layout_marginStart="?attr/contact_browser_list_padding_left"
+ android:layout_marginEnd="?attr/contact_browser_list_padding_right"
+ android:paddingTop="?attr/list_item_padding_top"
+ android:clipToPadding="false"
+ android:fastScrollEnabled="true"
+ android:visibility="gone"
+ android:fadingEdge="none" />
+ <ProgressBar
+ android:id="@+id/search_progress"
+ style="?android:attr/progressBarStyleLarge"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:visibility="gone" />
+ </FrameLayout>
+
+</LinearLayout>
diff --git a/res/layout/contact_list_filter.xml b/res/layout/contact_list_filter.xml
new file mode 100644
index 0000000..34c713c
--- /dev/null
+++ b/res/layout/contact_list_filter.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2011 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ -->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:fillViewport="true">
+
+ <ListView
+ android:id="@android:id/list"
+ android:layout_width="match_parent"
+ android:layout_height="0dip"
+ android:layout_weight="1" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dip"
+ android:background="?android:attr/dividerHorizontal" />
+</LinearLayout>
diff --git a/res/layout/contact_list_filter_custom.xml b/res/layout/contact_list_filter_custom.xml
new file mode 100644
index 0000000..845d52f
--- /dev/null
+++ b/res/layout/contact_list_filter_custom.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2007 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ style="@style/CustomContactListFilterView"
+ android:orientation="vertical"
+ android:fillViewport="true">
+
+ <ExpandableListView
+ android:id="@android:id/list"
+ android:layout_width="match_parent"
+ android:layout_height="0dip"
+ android:layout_weight="1"
+ android:overScrollMode="always" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dip"
+ android:layout_marginLeft="16dip"
+ android:layout_marginRight="16dip"
+ android:layout_marginStart="16dip"
+ android:layout_marginEnd="16dip"
+ android:background="?android:attr/dividerHorizontal" />
+
+</LinearLayout>
diff --git a/res/layout/contact_list_filter_item.xml b/res/layout/contact_list_filter_item.xml
new file mode 100644
index 0000000..9f297e6
--- /dev/null
+++ b/res/layout/contact_list_filter_item.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2010 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<view
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ class="com.android.contacts.common.list.ContactListFilterView"
+ android:descendantFocusability="blocksDescendants"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingStart="@dimen/contact_filter_left_margin"
+ android:paddingEnd="@dimen/contact_filter_right_margin"
+ android:minHeight="@dimen/contact_filter_item_min_height"
+ android:gravity="center_vertical">
+
+ <ImageView
+ android:id="@+id/icon"
+ android:scaleType="fitCenter"
+ android:layout_width="@dimen/contact_filter_icon_size"
+ android:layout_height="@dimen/contact_filter_icon_size"/>
+
+ <LinearLayout
+ android:layout_width="0dip"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginTop="-1dip"
+ android:orientation="vertical"
+ android:layout_marginLeft="8dip"
+ android:layout_marginStart="8dip">
+
+ <TextView
+ android:id="@+id/accountType"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="?android:attr/textAppearanceListItem"
+ android:textColor="@color/account_filter_text_color"
+ android:singleLine="true"
+ android:ellipsize="end"/>
+
+ <TextView
+ android:id="@+id/accountUserName"
+ android:layout_marginTop="-3dip"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="?android:attr/textAppearanceListItemSecondary"
+ android:textColor="@color/account_filter_text_color"
+ android:singleLine="true"
+ android:ellipsize="end"/>
+ </LinearLayout>
+
+ <RadioButton
+ android:id="@+id/radioButton"
+ android:clickable="false"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="end|center_vertical" />
+</view>
+
diff --git a/res/layout/contact_tile_frequent.xml b/res/layout/contact_tile_frequent.xml
new file mode 100644
index 0000000..b1e83ce
--- /dev/null
+++ b/res/layout/contact_tile_frequent.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<view
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ class="com.android.contacts.common.list.ContactTileFrequentView"
+ android:focusable="true"
+ android:background="?android:attr/selectableItemBackground">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:paddingTop="?list_item_padding_top"
+ android:paddingBottom="?list_item_padding_bottom">
+
+ <com.android.contacts.common.widget.LayoutSuppressingImageView
+ android:id="@+id/contact_tile_image"
+ android:layout_width="?list_item_photo_size"
+ android:layout_height="?list_item_photo_size"
+ android:scaleType="centerCrop"
+ android:layout_marginEnd="?list_item_gap_between_image_and_text"/>
+
+ <TextView
+ android:id="@+id/contact_tile_name"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textColor="@android:color/black"
+ android:textSize="@dimen/contact_browser_list_item_text_size"
+ android:singleLine="true"
+ android:fadingEdge="horizontal"
+ android:fadingEdgeLength="3dip"
+ android:ellipsize="marquee"
+ android:textAlignment="viewStart" />
+
+ </LinearLayout>
+
+</view>
diff --git a/res/layout/contact_tile_frequent_phone.xml b/res/layout/contact_tile_frequent_phone.xml
new file mode 100644
index 0000000..f87dff7
--- /dev/null
+++ b/res/layout/contact_tile_frequent_phone.xml
@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- Layout parameters are set programmatically. -->
+<view
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/contact_tile_frequent_phone"
+ class="com.android.contacts.common.list.ContactTilePhoneFrequentView"
+ android:focusable="true"
+ android:background="?android:attr/selectableItemBackground"
+ android:nextFocusLeft="@+id/contact_tile_quick">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" >
+
+ <com.android.contacts.common.widget.LayoutSuppressingQuickContactBadge
+ android:id="@id/contact_tile_quick"
+ android:layout_width="64dip"
+ android:layout_height="64dip"
+ android:layout_alignParentLeft="true"
+ android:layout_alignParentStart="true"
+ android:nextFocusRight="@id/contact_tile_frequent_phone"
+ android:scaleType="centerCrop"
+ android:focusable="true" />
+
+ <TextView
+ android:id="@+id/contact_tile_name"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="8dip"
+ android:layout_marginStart="8dip"
+ android:textAppearance="?android:attr/textAppearanceMedium"
+ android:layout_marginTop="8dip"
+ android:layout_toRightOf="@id/contact_tile_quick"
+ android:layout_toEndOf="@id/contact_tile_quick"
+ android:singleLine="true"
+ android:fadingEdge="horizontal"
+ android:fadingEdgeLength="3dip"
+ android:ellipsize="marquee"
+ android:textAlignment="viewStart" />
+
+ <LinearLayout
+ android:orientation="horizontal"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_below="@id/contact_tile_name"
+ android:layout_toRightOf="@id/contact_tile_quick"
+ android:layout_toEndOf="@id/contact_tile_quick"
+ android:gravity="center_vertical">
+
+ <TextView
+ android:id="@+id/contact_tile_phone_number"
+ android:layout_width="0dip"
+ android:layout_height="wrap_content"
+ android:layout_weight="?attr/list_item_data_width_weight"
+ android:textSize="14sp"
+ android:ellipsize="marquee"
+ android:textColor="@color/dialtacts_secondary_text_color"
+ android:layout_marginLeft="8dip"
+ android:layout_marginStart="8dip"
+ android:singleLine="true"
+ android:layout_gravity="bottom"
+ android:textDirection="ltr"
+ android:textAlignment="viewStart" />
+
+ <TextView
+ android:id="@+id/contact_tile_phone_type"
+ android:layout_width="0dip"
+ android:layout_height="wrap_content"
+ android:layout_weight="?attr/list_item_label_width_weight"
+ android:textSize="12sp"
+ android:ellipsize="marquee"
+ android:singleLine="true"
+ android:textAllCaps="true"
+ android:textColor="@color/dialtacts_secondary_text_color"
+ android:layout_marginLeft="8dip"
+ android:layout_marginStart="8dip"
+ android:gravity="end"
+ android:layout_gravity="bottom" />
+
+ </LinearLayout>
+
+ <View
+ android:id="@+id/contact_tile_horizontal_divider"
+ android:layout_width="match_parent"
+ android:layout_height="1px"
+ android:background="?android:attr/listDivider"
+ android:layout_below="@id/contact_tile_quick" />
+
+ </RelativeLayout>
+
+</view>
diff --git a/res/layout/contact_tile_starred.xml b/res/layout/contact_tile_starred.xml
new file mode 100644
index 0000000..777cc05
--- /dev/null
+++ b/res/layout/contact_tile_starred.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<view
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:ex="http://schemas.android.com/apk/res-auto"
+ class="com.android.contacts.common.list.ContactTileStarredView"
+ android:focusable="true"
+ android:background="?android:attr/selectableItemBackground">
+
+ <LinearLayout
+ android:id="@+id/contact_tile_push_state"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:paddingTop="24dp">
+ <view
+ android:id="@+id/contact_tile_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ ex:direction="widthToHeight"
+ ex:ratio="1.0"
+ class="com.android.contacts.common.widget.ProportionalLayout" >
+ <ImageView
+ android:id="@+id/contact_tile_image"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
+ </view>
+ <TextView
+ android:id="@+id/contact_tile_name"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingTop="7dp"
+ android:textColor="#202020"
+ android:textSize="@dimen/contact_browser_list_item_text_size"
+ android:singleLine="true"
+ android:fadingEdge="horizontal"
+ android:fadingEdgeLength="3dip"
+ android:ellipsize="marquee"
+ android:textAlignment="center"/>
+ </LinearLayout>
+</view>
diff --git a/res/layout/contact_tile_starred_quick_contact.xml b/res/layout/contact_tile_starred_quick_contact.xml
new file mode 100644
index 0000000..ecbe583
--- /dev/null
+++ b/res/layout/contact_tile_starred_quick_contact.xml
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<view
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:paddingBottom="1dip"
+ android:paddingRight="1dip"
+ android:paddingEnd="1dip"
+ android:background="@null"
+ class="com.android.contacts.common.list.ContactTileStarredView" >
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" >
+
+ <com.android.contacts.common.widget.LayoutSuppressingImageView
+ android:id="@+id/contact_tile_image"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:scaleType="centerCrop" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/contact_tile_shadowbox_height"
+ android:orientation="vertical"
+ android:background="@color/contact_tile_shadow_box_color"
+ android:layout_alignParentBottom="true"
+ android:gravity="center_vertical"
+ android:paddingLeft="8dip"
+ android:paddingRight="8dip"
+ android:paddingStart="8dip"
+ android:paddingEnd="8dip">
+
+ <TextView
+ android:id="@+id/contact_tile_name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textColor="@android:color/white"
+ android:textSize="16sp"
+ android:singleLine="true"
+ android:fadingEdge="horizontal"
+ android:fadingEdgeLength="3dip"
+ android:ellipsize="marquee"
+ android:textAlignment="viewStart" />
+
+ <TextView
+ android:id="@+id/contact_tile_status"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:textColor="@color/people_contact_tile_status_color"
+ android:singleLine="true"
+ android:drawablePadding="4dip"
+ android:fadingEdge="horizontal"
+ android:fadingEdgeLength="3dip"
+ android:layout_marginTop="-3dip"
+ android:ellipsize="marquee" />
+
+ </LinearLayout>
+
+ <QuickContactBadge
+ android:id="@+id/contact_tile_quick"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:focusable="true"
+ android:background="@null" />
+
+ </RelativeLayout>
+
+</view>
diff --git a/res/layout/custom_contact_list_filter_account.xml b/res/layout/custom_contact_list_filter_account.xml
new file mode 100644
index 0000000..c7a6cb5
--- /dev/null
+++ b/res/layout/custom_contact_list_filter_account.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2009 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:minHeight="?android:attr/listPreferredItemHeight"
+ android:gravity="center_vertical"
+ android:paddingLeft="@dimen/contact_filter_list_item_padding_start"
+ android:paddingRight="?android:attr/scrollbarSize"
+ android:paddingStart="@dimen/contact_filter_list_item_padding_start"
+ android:paddingEnd="?android:attr/scrollbarSize">
+
+ <RelativeLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginRight="6dip"
+ android:layout_marginEnd="6dip"
+ android:layout_marginTop="6dip"
+ android:layout_marginBottom="6dip"
+ android:layout_weight="1"
+ android:duplicateParentState="true">
+
+ <TextView
+ android:id="@android:id/text1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:singleLine="true"
+ android:ellipsize="marquee"
+ android:textAppearance="?android:attr/textAppearanceListItem"
+ android:duplicateParentState="true" />
+
+ <TextView
+ android:id="@android:id/text2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_below="@android:id/text1"
+ android:layout_alignLeft="@android:id/text1"
+ android:layout_alignStart="@android:id/text1"
+ android:maxLines="1"
+ android:textAppearance="?android:attr/textAppearanceListItemSecondary"
+ android:textColor="?android:attr/textColorTertiary"
+ android:duplicateParentState="true" />
+
+ </RelativeLayout>
+
+</LinearLayout>
diff --git a/res/layout/custom_contact_list_filter_group.xml b/res/layout/custom_contact_list_filter_group.xml
new file mode 100644
index 0000000..c67ce16
--- /dev/null
+++ b/res/layout/custom_contact_list_filter_group.xml
@@ -0,0 +1,90 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2009 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:minHeight="@dimen/contact_filter_list_item_height"
+ android:gravity="center_vertical"
+ android:orientation="vertical">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:minHeight="@dimen/contact_filter_list_item_height"
+ android:gravity="center_vertical"
+ android:paddingLeft="@dimen/contact_filter_list_item_padding_start"
+ android:paddingRight="?android:attr/scrollbarSize"
+ android:paddingStart="@dimen/contact_filter_list_item_padding_start"
+ android:paddingEnd="?android:attr/scrollbarSize">
+
+ <RelativeLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginRight="6dip"
+ android:layout_marginEnd="6dip"
+ android:layout_marginTop="6dip"
+ android:layout_marginBottom="6dip"
+ android:layout_weight="1"
+ android:duplicateParentState="true"
+ >
+
+ <TextView
+ android:id="@android:id/text1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:singleLine="true"
+ android:ellipsize="marquee"
+ android:textAppearance="?android:attr/textAppearanceListItem"
+ android:textColor="@color/account_filter_text_color"
+ android:duplicateParentState="true"
+ />
+
+ <TextView
+ android:id="@android:id/text2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_below="@android:id/text1"
+ android:layout_alignLeft="@android:id/text1"
+ android:layout_alignStart="@android:id/text1"
+ android:maxLines="2"
+ android:textAppearance="?android:attr/textAppearanceListItemSecondary"
+ android:textColor="@color/account_filter_text_color"
+ android:duplicateParentState="true"
+ />
+
+ </RelativeLayout>
+
+ <CheckBox
+ android:id="@android:id/checkbox"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:focusable="false"
+ android:clickable="false"
+ android:gravity="center_vertical"
+ android:orientation="vertical"
+ android:duplicateParentState="true"
+ />
+
+ </LinearLayout>
+
+ <View
+ android:id="@+id/adapter_divider_bottom"
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="@color/custom_filter_divider" />
+
+</LinearLayout>
diff --git a/res/layout/default_account_checkbox.xml b/res/layout/default_account_checkbox.xml
new file mode 100644
index 0000000..9a1a450
--- /dev/null
+++ b/res/layout/default_account_checkbox.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2014 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT 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:id="@+id/default_account_checkbox_layout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:padding="4dp"
+ android:orientation="vertical">
+ <CheckBox
+ android:id="@+id/default_account_checkbox_view"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingStart="15dip"
+ android:layout_marginLeft="13dip"
+ android:layout_marginBottom="20dip"
+ android:gravity="center"
+ android:textAlignment="viewStart"
+ android:text="@string/set_default_account"
+ android:textColor="@color/dialtacts_secondary_text_color"
+ />
+</LinearLayout>
\ No newline at end of file
diff --git a/res/layout/dialog_call_subject.xml b/res/layout/dialog_call_subject.xml
new file mode 100644
index 0000000..d6365c2
--- /dev/null
+++ b/res/layout/dialog_call_subject.xml
@@ -0,0 +1,159 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2015 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT 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:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@android:color/transparent"
+ android:id="@+id/call_subject_dialog"
+ android:orientation="vertical">
+
+ <RelativeLayout
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:layout_width="match_parent">
+
+ <!-- The call subject dialog will be centered in the space above the subject list. -->
+ <LinearLayout
+ android:id="@+id/dialog_view"
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:clickable="true"
+ android:theme="@android:style/Theme.Material.Light.Dialog"
+ android:elevation="16dp"
+ android:layout_centerInParent="true"
+ android:background="@drawable/dialog_background_material">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginStart="@dimen/call_subject_dialog_margin"
+ android:layout_marginEnd="@dimen/call_subject_dialog_margin"
+ android:layout_marginTop="@dimen/call_subject_dialog_margin">
+
+ <QuickContactBadge
+ android:id="@+id/contact_photo"
+ android:layout_width="@dimen/call_subject_dialog_contact_photo_size"
+ android:layout_height="@dimen/call_subject_dialog_contact_photo_size"
+ android:layout_gravity="top"
+ android:focusable="true"
+ android:layout_marginEnd="@dimen/call_subject_dialog_margin" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:gravity="center_vertical">
+
+ <TextView
+ android:id="@+id/name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textColor="@color/dialtacts_primary_text_color"
+ android:textSize="@dimen/call_subject_dialog_secondary_text_size"
+ android:singleLine="true" />
+
+ <TextView
+ android:id="@+id/number"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical"
+ android:layout_marginTop="@dimen/call_subject_dialog_between_line_margin"
+ android:textColor="@color/dialtacts_secondary_text_color"
+ android:textSize="@dimen/call_subject_dialog_secondary_text_size"
+ android:singleLine="true" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <EditText
+ android:id="@+id/call_subject"
+ android:hint="@string/call_subject_hint"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:layout_gravity="top"
+ android:textColor="@color/dialtacts_secondary_text_color"
+ android:textSize="@dimen/call_subject_dialog_secondary_text_size"
+ android:gravity="top"
+ android:background="@null"
+ android:layout_marginTop="@dimen/call_subject_dialog_edit_spacing"
+ android:layout_marginStart="@dimen/call_subject_dialog_margin"
+ android:layout_marginEnd="@dimen/call_subject_dialog_margin"
+ />
+
+ <TextView
+ android:id="@+id/character_limit"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textColor="@color/dialtacts_secondary_text_color"
+ android:textSize="@dimen/call_subject_dialog_secondary_text_size"
+ android:singleLine="true"
+ android:layout_marginStart="@dimen/call_subject_dialog_margin"
+ android:layout_marginEnd="@dimen/call_subject_dialog_margin"
+ android:layout_marginTop="@dimen/call_subject_dialog_margin"
+ android:layout_marginBottom="@dimen/call_subject_dialog_margin"/>
+
+ <View
+ android:layout_width="fill_parent"
+ android:layout_height="1dp"
+ android:background="@color/call_subject_divider"/>
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="@dimen/call_subject_dialog_margin"
+ android:layout_marginEnd="@dimen/call_subject_dialog_margin"
+ android:layout_marginTop="@dimen/call_subject_dialog_margin"
+ android:layout_marginBottom="@dimen/call_subject_dialog_margin">
+
+ <ImageView
+ android:id="@+id/history_button"
+ android:layout_width="25dp"
+ android:layout_height="25dp"
+ android:src="@drawable/ic_history_white_drawable_24dp"
+ android:tint="@color/call_subject_history_icon"
+ android:layout_alignParentStart="true"
+ android:layout_centerVertical="true" />
+
+ <TextView
+ android:id="@+id/send_and_call_button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/send_and_call_button"
+ android:textColor="@color/call_subject_button"
+ android:textSize="@dimen/call_subject_dialog_secondary_text_size"
+ android:singleLine="true"
+ android:layout_alignParentEnd="true"
+ android:layout_centerVertical="true" />
+
+ </RelativeLayout>
+ </LinearLayout>
+ </RelativeLayout>
+ <!-- The subject list is pinned to the bottom of the screen. -->
+ <ListView
+ android:id="@+id/subject_list"
+ android:layout_height="wrap_content"
+ android:layout_width="match_parent"
+ android:layout_below="@id/dialog_view"
+ android:background="@color/call_subject_history_background"
+ android:divider="@null"
+ android:elevation="8dp" />
+
+</LinearLayout>
\ No newline at end of file
diff --git a/res/layout/dialog_title.xml b/res/layout/dialog_title.xml
new file mode 100644
index 0000000..b88f47c
--- /dev/null
+++ b/res/layout/dialog_title.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2016 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<TextView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:paddingTop="18dp"
+ android:paddingStart="24dp"
+ android:paddingEnd="24dp"
+ android:textSize="20sp"
+ android:textColor="@color/contacts_text_color"
+ android:fontFamily="sans-serif-medium"
+ android:ellipsize="end"
+/>
\ No newline at end of file
diff --git a/res/layout/directory_header.xml b/res/layout/directory_header.xml
new file mode 100644
index 0000000..3e41aa7
--- /dev/null
+++ b/res/layout/directory_header.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2009 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- Layout used for list section separators. -->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ style="@style/DirectoryHeader"
+ android:id="@+id/directory_header"
+ android:background="?attr/contact_browser_background"
+ android:paddingLeft="@dimen/directory_header_left_padding"
+ android:paddingRight="?attr/list_item_padding_right"
+ android:paddingStart="@dimen/directory_header_left_padding"
+ android:paddingEnd="?attr/list_item_padding_right"
+ android:paddingTop="@dimen/directory_header_extra_top_padding"
+ android:paddingBottom="@dimen/directory_header_extra_bottom_padding"
+ android:minHeight="@dimen/list_section_divider_min_height"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" >
+ <TextView
+ android:id="@+id/label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textAppearance="@style/DirectoryHeaderStyle"
+ android:singleLine="true"
+ android:textAlignment="viewStart" />
+ <TextView
+ android:id="@+id/display_name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="8dp"
+ android:textAppearance="@style/DirectoryHeaderStyle"
+ android:singleLine="true"
+ android:textAlignment="viewStart" />
+ <TextView
+ android:id="@+id/count"
+ android:paddingTop="1dip"
+ android:layout_width="0dip"
+ android:layout_height="wrap_content"
+ android:gravity="end"
+ android:singleLine="true"
+ android:textAppearance="@style/DirectoryHeaderStyle" />
+</LinearLayout>
diff --git a/res/layout/licenses.xml b/res/layout/licenses.xml
new file mode 100644
index 0000000..66d4f46
--- /dev/null
+++ b/res/layout/licenses.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright 2015 Google Inc. All Rights Reserved. -->
+
+<WebView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/webview"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+</WebView>
\ No newline at end of file
diff --git a/res/layout/list_separator.xml b/res/layout/list_separator.xml
new file mode 100644
index 0000000..80abacb
--- /dev/null
+++ b/res/layout/list_separator.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2008 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<TextView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/title"
+ android:textColor="@color/frequently_contacted_title_color"
+ android:paddingLeft="16dip"
+ android:paddingStart="16dip"
+ android:paddingRight="16dip"
+ android:paddingEnd="16dip"
+ android:paddingBottom="15dip"
+ android:paddingTop="16dip"
+ android:textStyle="bold"
+ android:textSize="@dimen/frequently_contacted_title_text_size"/>
diff --git a/res/layout/search_bar_expanded.xml b/res/layout/search_bar_expanded.xml
new file mode 100644
index 0000000..ecadbd1
--- /dev/null
+++ b/res/layout/search_bar_expanded.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2014 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT 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:id="@+id/search_box_expanded"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:visibility="gone" >
+
+ <ImageButton
+ android:id="@+id/search_back_button"
+ android:layout_width="@dimen/search_box_icon_size"
+ android:layout_height="@dimen/search_box_icon_size"
+ android:layout_marginEnd="@dimen/search_box_navigation_icon_margin"
+ android:src="@drawable/ic_back_arrow"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:contentDescription="@string/action_menu_back_from_search"
+ android:tint="@color/actionbar_background_color" />
+
+ <android.widget.AutoCompleteTextView
+ android:id="@+id/search_view"
+ android:layout_width="0dp"
+ android:layout_height="@dimen/search_box_icon_size"
+ android:layout_weight="1"
+ android:layout_marginLeft="@dimen/search_box_text_left_margin"
+ android:textSize="@dimen/search_text_size"
+ android:fontFamily="@string/search_font_family"
+ android:textColor="@color/searchbox_text_color"
+ android:textColorHint="@color/searchbox_hint_text_color"
+ android:textCursorDrawable="@drawable/searchedittext_custom_cursor"
+ android:background="@null"
+ android:inputType="textFilter"
+ android:singleLine="true"
+ android:imeOptions="flagNoExtractUi" />
+
+ <ImageView
+ android:id="@+id/search_close_button"
+ android:layout_height="@dimen/search_box_close_icon_size"
+ android:layout_width="@dimen/search_box_close_icon_size"
+ android:padding="@dimen/search_box_close_icon_padding"
+ android:src="@drawable/ic_close_black_24dp"
+ android:clickable="true"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:contentDescription="@string/description_clear_search"
+ android:alpha="@dimen/close_icon_alpha" />
+
+</LinearLayout>
diff --git a/res/layout/select_account_list_item.xml b/res/layout/select_account_list_item.xml
new file mode 100644
index 0000000..0ba4336
--- /dev/null
+++ b/res/layout/select_account_list_item.xml
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2014 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- Layout of a single item in the InCallUI Account Chooser Dialog. -->
+<view class="com.android.contacts.common.widget.ActivityTouchLinearLayout"
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:orientation="horizontal"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:padding="8dp" >
+
+ <ImageView android:id="@+id/icon"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:scaleType="center" />
+
+ <LinearLayout
+ android:id="@+id/text"
+ android:gravity="start|center_vertical"
+ android:layout_marginLeft="8dp"
+ android:layout_width="0dp"
+ android:layout_weight="1"
+ android:layout_height="match_parent"
+ android:orientation="vertical" >
+ <TextView android:id="@+id/label"
+ android:textAppearance="?android:attr/textAppearanceMedium"
+ android:textColor="@color/dialtacts_primary_text_color"
+ android:includeFontPadding="false"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+ <TextView android:id="@+id/number"
+ android:textAppearance="?android:attr/textAppearanceSmall"
+ android:includeFontPadding="false"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:visibility="gone" />
+ </LinearLayout>
+
+</view>
diff --git a/res/layout/select_dialog_item.xml b/res/layout/select_dialog_item.xml
new file mode 100644
index 0000000..0c524fd
--- /dev/null
+++ b/res/layout/select_dialog_item.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2011 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!--
+ List item in the pop-up window that appears when tapping a contact's photo
+ in the contact editor. This is similar to the framework's select_dialog_item_material.xml layout
+ except the text appearance is medium and the padding is set to match the material spec.
+-->
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@android:id/text1"
+ android:layout_width="match_parent"
+ android:layout_height="48dp"
+ android:textAlignment="viewStart"
+ android:textColor="@color/contacts_text_color"
+ android:textSize="16sp"
+ android:gravity="center_vertical"
+ android:paddingStart="24dip"
+ android:paddingEnd="24dip"
+ android:ellipsize="marquee" />
\ No newline at end of file
diff --git a/res/layout/unread_count_tab.xml b/res/layout/unread_count_tab.xml
new file mode 100644
index 0000000..783f1c1
--- /dev/null
+++ b/res/layout/unread_count_tab.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2015 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT 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="@drawable/view_pager_tab_background">
+ <!-- The tab icon -->
+ <ImageView
+ android:id="@+id/icon"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_centerInParent="true" />
+ <TextView
+ android:id="@+id/count"
+ android:background="@drawable/unread_count_background"
+ android:layout_width="wrap_content"
+ android:layout_height="@dimen/tab_unread_count_background_size"
+ android:gravity="center"
+ android:minWidth="@dimen/tab_unread_count_background_size"
+ android:layout_marginStart="@dimen/tab_unread_count_margin_left"
+ android:layout_marginTop="@dimen/tab_unread_count_margin_top"
+ android:layout_toEndOf="@id/icon"
+ android:paddingLeft="@dimen/tab_unread_count_text_padding"
+ android:paddingRight="@dimen/tab_unread_count_text_padding"
+ android:textAlignment="center"
+ android:textSize="@dimen/tab_unread_count_text_size"
+ android:textColor="@color/tab_accent_color"
+ android:fontFamily="sans-serif-medium"
+ android:importantForAccessibility="no" />
+</RelativeLayout>
diff --git a/res/values-af/strings.xml b/res/values-af/strings.xml
index 8b73bad..945980d 100644
--- a/res/values-af/strings.xml
+++ b/res/values-af/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Stel verstek op"</string>
<string name="clear_default" msgid="7193185801596678067">"Vee verstek uit"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Teks gekopieer"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Gooi jou veranderings weg en hou op om te wysig?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Ignoreer veranderinge?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Gooi weg"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Hou aan wysig"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Kanselleer"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Deursoek kontakte"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Verwyder kontakte"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Meer oor"</string>
<string name="send_message" msgid="8938418965550543196">"Stuur boodskap"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Skep tans \'n persoonlike kopie..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Gister"</string>
<string name="tomorrow" msgid="6241969467795308581">"Môre"</string>
<string name="today" msgid="8041090779381781781">"Vandag"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Vandag om <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-am/strings.xml b/res/values-am/strings.xml
index 1bfc997..ff9d817 100644
--- a/res/values-am/strings.xml
+++ b/res/values-am/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"ነባሪ አዘጋጅ"</string>
<string name="clear_default" msgid="7193185801596678067">"ነባሪ አጽዳ"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"ፅሁፍ ገልብጧል"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"ለውጦችዎ ይወገዱ እና ማርትዕ ይቁም?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"ለውጦች ይወገዱ?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"አስወግድ"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"አርትዖቱን ቀጥል"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"ተወው"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"እውቅያዎችን ፈልግ"</string>
<string name="title_edit_group" msgid="8602752287270586734">"ዕውቂያዎችን ያስወግዱ"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"ስለ"</string>
<string name="send_message" msgid="8938418965550543196">"መልዕክት ላክ"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"የግል ቅጂ በመፍጠር ላይ..."</string>
- <string name="yesterday" msgid="6840858548955018569">"ትላንት"</string>
<string name="tomorrow" msgid="6241969467795308581">"ነገ"</string>
<string name="today" msgid="8041090779381781781">"ዛሬ"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"ዛሬ <xliff:g id="TIME_INTERVAL">%s</xliff:g> ላይ"</string>
diff --git a/res/values-ar/strings.xml b/res/values-ar/strings.xml
index 0df6033..e0ebf86 100644
--- a/res/values-ar/strings.xml
+++ b/res/values-ar/strings.xml
@@ -260,9 +260,9 @@
<string name="set_default" msgid="4417505153468300351">"تعيين كافتراضي"</string>
<string name="clear_default" msgid="7193185801596678067">"محو الإعدادات الافتراضية"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"تم نسخ النص"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"هل تريد تجاهل التغييرات ومغادرة التعديل؟"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"هل تريد إلغاء التغييرات؟"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"تجاهل"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"متابعة التعديلات"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"إلغاء"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"البحث في جهات الاتصال"</string>
<string name="title_edit_group" msgid="8602752287270586734">"إزالة جهات الاتصال"</string>
@@ -287,7 +287,6 @@
<string name="about_card_title" msgid="2920942314212825637">"حول"</string>
<string name="send_message" msgid="8938418965550543196">"إرسال رسالة"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"جارٍ إنشاء نسخة شخصية..."</string>
- <string name="yesterday" msgid="6840858548955018569">"أمس"</string>
<string name="tomorrow" msgid="6241969467795308581">"غدًا"</string>
<string name="today" msgid="8041090779381781781">"اليوم"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"اليوم في <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-az-rAZ/strings.xml b/res/values-az-rAZ/strings.xml
index 2557079..45579aa 100644
--- a/res/values-az-rAZ/strings.xml
+++ b/res/values-az-rAZ/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Defolt ayarlayın"</string>
<string name="clear_default" msgid="7193185801596678067">"Defoltu təmizləyin"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Mətn kopyalandı"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Dəyişiklikləriniz kənarlaşdırılsın və redaktə sonlandırılsın?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Dəyişikliklər ləğv edilsin?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Ləğv edin"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Redaktəyə davam edin"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Ləğv edin"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Kontakt axtarın"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Kontaktları silin"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Haqqında"</string>
<string name="send_message" msgid="8938418965550543196">"Mesaj göndərin"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Şəxsi nüsxə yaradılır..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Dünən"</string>
<string name="tomorrow" msgid="6241969467795308581">"Sabah"</string>
<string name="today" msgid="8041090779381781781">"Bu gün"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Bu gün saat <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-bg/strings.xml b/res/values-bg/strings.xml
index 6b0cb8d..13ee552 100644
--- a/res/values-bg/strings.xml
+++ b/res/values-bg/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Задаване като стандартна настройка"</string>
<string name="clear_default" msgid="7193185801596678067">"Изчистване на стандартната настройка"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Текстът бе копиран"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Искате ли да отхвърлите направените от вас промени и да излезете от редактирането?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Отхвърляне"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Продължаване с редактирането"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Търсене в контактите"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Премахване на контакти"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Информация"</string>
<string name="send_message" msgid="8938418965550543196">"Изпращане на съобщение"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Създава се лично копие..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Вчера"</string>
<string name="tomorrow" msgid="6241969467795308581">"Утре"</string>
<string name="today" msgid="8041090779381781781">"Днес"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Днес от <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-bn-rBD/strings.xml b/res/values-bn-rBD/strings.xml
index 21ddf31..648de98 100644
--- a/res/values-bn-rBD/strings.xml
+++ b/res/values-bn-rBD/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"ডিফল্ট সেট করুন"</string>
<string name="clear_default" msgid="7193185801596678067">"ডিফল্ট সাফ করুন"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"পাঠ্য অনুলিপি হয়েছে"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"আপনার পরিবর্তনগুলি বাতিল করতে এবং সম্পাদনা থেকে প্রস্থান করতে চান?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"পরিবর্তনগুলি বাতিল করবেন?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"বাতিল করুন"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"সম্পাদনা করা চালিয়ে যান"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"বাতিল করুন"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"পরিচিতিগুলি খুঁজুন"</string>
<string name="title_edit_group" msgid="8602752287270586734">"পরিচিতিগুলি সরান"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"সম্পর্কে"</string>
<string name="send_message" msgid="8938418965550543196">"বার্তা পাঠান"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"একটি ব্যক্তিগত অনুলিপি তৈরি করা হচ্ছে…"</string>
- <string name="yesterday" msgid="6840858548955018569">"গতকাল"</string>
<string name="tomorrow" msgid="6241969467795308581">"আগামীকাল"</string>
<string name="today" msgid="8041090779381781781">"আজ"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"আজ <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-ca/strings.xml b/res/values-ca/strings.xml
index 95dff95..cad9595 100644
--- a/res/values-ca/strings.xml
+++ b/res/values-ca/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Predeterminat"</string>
<string name="clear_default" msgid="7193185801596678067">"Esborra els valors predeterminats"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Text copiat"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Vols descartar els canvis i sortir del mode d\'edició?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Vols descartar els canvis?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Descarta"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Continua editant"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Cancel·la"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Cerca als contactes"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Suprimeix els contactes"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Quant a"</string>
<string name="send_message" msgid="8938418965550543196">"Envia un missatge"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"S\'està creant una còpia personal..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Ahir"</string>
<string name="tomorrow" msgid="6241969467795308581">"Demà"</string>
<string name="today" msgid="8041090779381781781">"Avui"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Avui a les <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-cs/strings.xml b/res/values-cs/strings.xml
index de11f48..b6cf011 100644
--- a/res/values-cs/strings.xml
+++ b/res/values-cs/strings.xml
@@ -246,9 +246,9 @@
<string name="set_default" msgid="4417505153468300351">"Výchozí nastavení"</string>
<string name="clear_default" msgid="7193185801596678067">"Vymazat výchozí nastavení"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Text zkopírován"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Zahodit změny a ukončit úpravy?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Zahodit změny?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Zrušit"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Pokračovat v úpravách"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Zrušit"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Vyhledejte kontakty"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Odebrat kontakty"</string>
@@ -273,7 +273,6 @@
<string name="about_card_title" msgid="2920942314212825637">"O kartě"</string>
<string name="send_message" msgid="8938418965550543196">"Odeslat zprávu"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Vytváření osobní kopie..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Včera"</string>
<string name="tomorrow" msgid="6241969467795308581">"Zítra"</string>
<string name="today" msgid="8041090779381781781">"Dnes"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Dnes v <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-da/strings.xml b/res/values-da/strings.xml
index 6a02ce4..20438a8 100644
--- a/res/values-da/strings.xml
+++ b/res/values-da/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Indstil standard"</string>
<string name="clear_default" msgid="7193185801596678067">"Ryd standarder"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Kopieret tekst"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Vil du kassere ændringerne og afslutte redigering?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Vil du kassere ændringerne?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Kassér"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Fortsæt redigering"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Annuller"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Søg i kontaktpersoner"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Fjern kontaktpersoner"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Om"</string>
<string name="send_message" msgid="8938418965550543196">"Send besked"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Opretter en privat kopi..."</string>
- <string name="yesterday" msgid="6840858548955018569">"I går"</string>
<string name="tomorrow" msgid="6241969467795308581">"I morgen"</string>
<string name="today" msgid="8041090779381781781">"I dag"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"I dag kl. <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-de/strings.xml b/res/values-de/strings.xml
index f2566fb..dc14b93 100644
--- a/res/values-de/strings.xml
+++ b/res/values-de/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Als Standard festlegen"</string>
<string name="clear_default" msgid="7193185801596678067">"Als Standard löschen"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Text kopiert"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Möchtest du die Änderungen verwerfen und den Bearbeitungsmodus beenden?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Verwerfen"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Bearbeitung fortsetzen"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Kontakte suchen"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Kontakte entfernen"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Info"</string>
<string name="send_message" msgid="8938418965550543196">"Nachricht senden"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Persönliche Kopie wird erstellt..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Gestern"</string>
<string name="tomorrow" msgid="6241969467795308581">"Morgen"</string>
<string name="today" msgid="8041090779381781781">"Heute"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Heute um <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-el/strings.xml b/res/values-el/strings.xml
index 15fa54f..0499bed 100644
--- a/res/values-el/strings.xml
+++ b/res/values-el/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Ορισμός ως προεπιλογή"</string>
<string name="clear_default" msgid="7193185801596678067">"Εκκαθάριση προεπιλεγμένων"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Το κείμενο αντιγράφηκε"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Να απορριφθούν οι αλλαγές που πραγματοποιήσατε και να τερματιστεί η επεξεργασία;"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Να απορριφθούν οι αλλαγές;"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Απόρριψη"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Συνέχεια επεξεργασίας"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Ακύρωση"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Αναζήτηση επαφών"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Κατάργηση επαφών"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Σχετικά με"</string>
<string name="send_message" msgid="8938418965550543196">"Αποστολή μηνύματος"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Δημιουργία προσωπικού αντιγράφου..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Χθες"</string>
<string name="tomorrow" msgid="6241969467795308581">"Αύριο"</string>
<string name="today" msgid="8041090779381781781">"Σήμερα"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Σήμερα στις <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-en-rAU/strings.xml b/res/values-en-rAU/strings.xml
index 72374d5..012cd31 100644
--- a/res/values-en-rAU/strings.xml
+++ b/res/values-en-rAU/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Set default"</string>
<string name="clear_default" msgid="7193185801596678067">"Clear default"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Text copied"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Discard your changes and quit editing?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Discard changes?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Discard"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Keep editing"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Cancel"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Search contacts"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Remove contacts"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"About"</string>
<string name="send_message" msgid="8938418965550543196">"Send message"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Creating a personal copy…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Yesterday"</string>
<string name="tomorrow" msgid="6241969467795308581">"Tomorrow"</string>
<string name="today" msgid="8041090779381781781">"Today"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Today at <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-en-rGB/strings.xml b/res/values-en-rGB/strings.xml
index 72374d5..012cd31 100644
--- a/res/values-en-rGB/strings.xml
+++ b/res/values-en-rGB/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Set default"</string>
<string name="clear_default" msgid="7193185801596678067">"Clear default"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Text copied"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Discard your changes and quit editing?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Discard changes?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Discard"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Keep editing"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Cancel"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Search contacts"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Remove contacts"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"About"</string>
<string name="send_message" msgid="8938418965550543196">"Send message"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Creating a personal copy…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Yesterday"</string>
<string name="tomorrow" msgid="6241969467795308581">"Tomorrow"</string>
<string name="today" msgid="8041090779381781781">"Today"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Today at <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-en-rIN/strings.xml b/res/values-en-rIN/strings.xml
index 72374d5..012cd31 100644
--- a/res/values-en-rIN/strings.xml
+++ b/res/values-en-rIN/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Set default"</string>
<string name="clear_default" msgid="7193185801596678067">"Clear default"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Text copied"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Discard your changes and quit editing?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Discard changes?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Discard"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Keep editing"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Cancel"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Search contacts"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Remove contacts"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"About"</string>
<string name="send_message" msgid="8938418965550543196">"Send message"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Creating a personal copy…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Yesterday"</string>
<string name="tomorrow" msgid="6241969467795308581">"Tomorrow"</string>
<string name="today" msgid="8041090779381781781">"Today"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Today at <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-es-rUS/strings.xml b/res/values-es-rUS/strings.xml
index 7805c5e..b7140f5 100644
--- a/res/values-es-rUS/strings.xml
+++ b/res/values-es-rUS/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Establecer como predeterminado"</string>
<string name="clear_default" msgid="7193185801596678067">"Eliminar predeterminado"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Texto copiado"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"¿Deseas descartar los cambios y salir del editor?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Descartar"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Seguir editando"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Buscar contactos"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Quitar contactos"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Información"</string>
<string name="send_message" msgid="8938418965550543196">"Enviar mensaje"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Creando una copia personal..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Ayer"</string>
<string name="tomorrow" msgid="6241969467795308581">"Mañana"</string>
<string name="today" msgid="8041090779381781781">"Hoy"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Hoy a la hora <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-es/strings.xml b/res/values-es/strings.xml
index 3461637..76c981a 100644
--- a/res/values-es/strings.xml
+++ b/res/values-es/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Establecer como predeterminado"</string>
<string name="clear_default" msgid="7193185801596678067">"Borrar predeterminado"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Texto copiado"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"¿Descartar los cambios y dejar de editar?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Descartar"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Seguir editando"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Buscar contactos"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Quitar contactos"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Información"</string>
<string name="send_message" msgid="8938418965550543196">"Enviar mensaje"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Creando una copia personal..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Ayer"</string>
<string name="tomorrow" msgid="6241969467795308581">"Mañana"</string>
<string name="today" msgid="8041090779381781781">"Hoy"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Hoy a las <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-et-rEE/strings.xml b/res/values-et-rEE/strings.xml
index 0799d56..adafcba 100644
--- a/res/values-et-rEE/strings.xml
+++ b/res/values-et-rEE/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Määra vaikeseadeks"</string>
<string name="clear_default" msgid="7193185801596678067">"Kustuta vaikeseaded"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Tekst on kopeeritud"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Kas soovite muudatustest loobuda ja muutmise lõpetada?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Loobu"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Jätka muutmist"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Otsige kontakte"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Kontaktide eemaldamine"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Teave"</string>
<string name="send_message" msgid="8938418965550543196">"Saada sõnum"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Isikliku koopia loomine ..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Eile"</string>
<string name="tomorrow" msgid="6241969467795308581">"Homme"</string>
<string name="today" msgid="8041090779381781781">"Täna"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Täna kell <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-eu-rES/strings.xml b/res/values-eu-rES/strings.xml
index 0b9d40e..b61aa9d 100644
--- a/res/values-eu-rES/strings.xml
+++ b/res/values-eu-rES/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Ezarri lehenetsi gisa"</string>
<string name="clear_default" msgid="7193185801596678067">"Garbitu metodo lehenetsia"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Testua kopiatu da"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Aldaketak baztertu eta editatzeko modutik irten nahi duzu?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Baztertu"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Jarraitu editatzen"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> (<xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>)"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Bilatu kontaktuetan"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Kendu kontaktuak"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Honi buruz"</string>
<string name="send_message" msgid="8938418965550543196">"Bidali mezua"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Kopia pertsonala sortzen…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Atzo"</string>
<string name="tomorrow" msgid="6241969467795308581">"Bihar"</string>
<string name="today" msgid="8041090779381781781">"Gaur"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Gaur (<xliff:g id="TIME_INTERVAL">%s</xliff:g>)"</string>
diff --git a/res/values-fa/strings.xml b/res/values-fa/strings.xml
index cf23fc4..b4ea335 100644
--- a/res/values-fa/strings.xml
+++ b/res/values-fa/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"تنظیم پیشفرض"</string>
<string name="clear_default" msgid="7193185801596678067">"پاک کردن پیش فرضها"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"متن کپی شده"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"از تغییراتتان صرفنظر میکنید و از ویرایش خارج میشوید؟"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"صرفنظر کردن"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"حفظ ویرایش"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"جستجوی مخاطبین"</string>
<string name="title_edit_group" msgid="8602752287270586734">"حذف مخاطبین"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"درباره"</string>
<string name="send_message" msgid="8938418965550543196">"ارسال پیام"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"ایجاد یک کپی شخصی..."</string>
- <string name="yesterday" msgid="6840858548955018569">"دیروز"</string>
<string name="tomorrow" msgid="6241969467795308581">"فردا"</string>
<string name="today" msgid="8041090779381781781">"امروز"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"امروز در <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-fi/strings.xml b/res/values-fi/strings.xml
index 48ae68c..c66387a 100644
--- a/res/values-fi/strings.xml
+++ b/res/values-fi/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Aseta oletukseksi"</string>
<string name="clear_default" msgid="7193185801596678067">"Poista oletus"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Teksti kopioitu"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Hylätäänkö muutokset ja lopetetaan muokkaaminen?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Hylkää"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Jatka muokkausta"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Hae yhteystiedoista"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Poista yhteystiedot"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Tietoja"</string>
<string name="send_message" msgid="8938418965550543196">"Lähetä viesti"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Luodaan kopio..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Eilen"</string>
<string name="tomorrow" msgid="6241969467795308581">"Huomenna"</string>
<string name="today" msgid="8041090779381781781">"Tänään"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Tänään klo <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-fr-rCA/strings.xml b/res/values-fr-rCA/strings.xml
index bb9a26e..75530f6 100644
--- a/res/values-fr-rCA/strings.xml
+++ b/res/values-fr-rCA/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Définir par défaut"</string>
<string name="clear_default" msgid="7193185801596678067">"Effacer les valeurs par défaut"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Texte copié."</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Annuler les modifications et quitter le mode d\'édition?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Ignorer"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Continuer les modifications"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Rechercher dans les contacts"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Supprimer les contacts"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"À propos"</string>
<string name="send_message" msgid="8938418965550543196">"Envoyer un message"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Création d\'une copie personnelle…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Hier"</string>
<string name="tomorrow" msgid="6241969467795308581">"Demain"</string>
<string name="today" msgid="8041090779381781781">"Aujourd\'hui"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Aujourd\'hui à <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-fr/strings.xml b/res/values-fr/strings.xml
index de8df9c..9e0ac6c 100644
--- a/res/values-fr/strings.xml
+++ b/res/values-fr/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Définir par défaut"</string>
<string name="clear_default" msgid="7193185801596678067">"Effacer les valeurs par défaut"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Texte copié"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Supprimer les modifications et quitter le mode d\'édition ?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Ignorer les modifications ?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Supprimer"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Poursuivre les modifications"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Annuler"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Rechercher des contacts"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Supprimer les contacts"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"À propos de"</string>
<string name="send_message" msgid="8938418965550543196">"Envoyer le message"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Création d\'une copie personnelle…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Hier"</string>
<string name="tomorrow" msgid="6241969467795308581">"Demain"</string>
<string name="today" msgid="8041090779381781781">"Aujourd\'hui"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Aujourd\'hui, <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-gl-rES/strings.xml b/res/values-gl-rES/strings.xml
index fca4cc1..bc3cd22 100644
--- a/res/values-gl-rES/strings.xml
+++ b/res/values-gl-rES/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Definir como predeterminado"</string>
<string name="clear_default" msgid="7193185801596678067">"Borrar valores predeterminados"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Texto copiado"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Queres descartar os teus cambios e deixar de editar?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Descartar"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Seguir editando"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Busca nos contactos"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Eliminar contactos"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Acerca de"</string>
<string name="send_message" msgid="8938418965550543196">"Enviar mensaxe"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Creando unha copia persoal..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Onte"</string>
<string name="tomorrow" msgid="6241969467795308581">"Mañá"</string>
<string name="today" msgid="8041090779381781781">"Hoxe"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Hoxe ás <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-gu-rIN/strings.xml b/res/values-gu-rIN/strings.xml
index 3d3fcc5..48245be 100644
--- a/res/values-gu-rIN/strings.xml
+++ b/res/values-gu-rIN/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"ડિફોલ્ટ સેટ કરો"</string>
<string name="clear_default" msgid="7193185801596678067">"ડિફોલ્ટ સાફ કરો"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"ટેક્સ્ટ કૉપિ કર્યો"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"તમારા ફેરફારોને નિકાળીને સંપાદન છોડી દઈએ?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"ફેરફારો નિકાળીએ?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"નિકાળો"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"સંપાદન ચાલુ રાખો"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"રદ કરો"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"સંપર્કો શોધો"</string>
<string name="title_edit_group" msgid="8602752287270586734">"સંપર્કો દૂર કરો"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"વિશે"</string>
<string name="send_message" msgid="8938418965550543196">"સંદેશ મોકલો"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"વ્યક્તિગત કૉપિ બનાવી રહ્યાં છે…"</string>
- <string name="yesterday" msgid="6840858548955018569">"ગઈ કાલે"</string>
<string name="tomorrow" msgid="6241969467795308581">"આવતીકાલે"</string>
<string name="today" msgid="8041090779381781781">"આજે"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"<xliff:g id="TIME_INTERVAL">%s</xliff:g> વાગ્યે આજે"</string>
diff --git a/res/values-hi/strings.xml b/res/values-hi/strings.xml
index 9ee58ff..329ca2b 100644
--- a/res/values-hi/strings.xml
+++ b/res/values-hi/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"सामान्य सेट करें"</string>
<string name="clear_default" msgid="7193185801596678067">"सामान्य साफ़ करें"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"लेख की प्रतिलिपि बनाई गई"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"अपने बदलावों को ख़ारिज करें और संपादन से बाहर निकलें?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"क्या परिवर्तनों को छोड़ना चाहते हैं?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"अभी नहीं"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"संपादित करते रहें"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"रद्द करें"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"संपर्क खोजें"</string>
<string name="title_edit_group" msgid="8602752287270586734">"संपर्क निकालें"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"संक्षिप्त विवरण"</string>
<string name="send_message" msgid="8938418965550543196">"संदेश भेजें"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"एक व्यक्तिगत प्रतिलिपि बना रहा है…"</string>
- <string name="yesterday" msgid="6840858548955018569">"कल"</string>
<string name="tomorrow" msgid="6241969467795308581">"कल"</string>
<string name="today" msgid="8041090779381781781">"आज"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"आज <xliff:g id="TIME_INTERVAL">%s</xliff:g> बजे"</string>
diff --git a/res/values-hr/strings.xml b/res/values-hr/strings.xml
index ca0d695..113f39a 100644
--- a/res/values-hr/strings.xml
+++ b/res/values-hr/strings.xml
@@ -239,9 +239,11 @@
<string name="set_default" msgid="4417505153468300351">"Postavi zadano"</string>
<string name="clear_default" msgid="7193185801596678067">"Izbriši zadano"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Tekst kopiran"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Želite li odbaciti promjene i prekinuti uređivanje?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Odbaci"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Nastavi uređivati"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Pretraživanje kontakata"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Uklanjanje kontakata"</string>
@@ -266,7 +268,6 @@
<string name="about_card_title" msgid="2920942314212825637">"O kartici"</string>
<string name="send_message" msgid="8938418965550543196">"Pošalji poruku"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Stvaranje osobne kopije..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Jučer"</string>
<string name="tomorrow" msgid="6241969467795308581">"Sutra"</string>
<string name="today" msgid="8041090779381781781">"Danas"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Danas u <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-hu/strings.xml b/res/values-hu/strings.xml
index 454e07f..526009b 100644
--- a/res/values-hu/strings.xml
+++ b/res/values-hu/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Beállítás alapértelmezettként"</string>
<string name="clear_default" msgid="7193185801596678067">"Alapértelmezés törlése"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Másolt szöveg"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Elveti a módosításokat, és kilép a szerkesztésből?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Elvetés"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Szerkesztés folytatása"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> -- <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Keresés a névjegyek között"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Névjegyek eltávolítása"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Névjegy"</string>
<string name="send_message" msgid="8938418965550543196">"Üzenet küldése"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Személyes másolat készítése..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Tegnap"</string>
<string name="tomorrow" msgid="6241969467795308581">"Holnap"</string>
<string name="today" msgid="8041090779381781781">"Ma"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Ma <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-hy-rAM/strings.xml b/res/values-hy-rAM/strings.xml
index 2c80ad2..5980d2c 100644
--- a/res/values-hy-rAM/strings.xml
+++ b/res/values-hy-rAM/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Սահմանել լռելյայն"</string>
<string name="clear_default" msgid="7193185801596678067">"Մաքրել լռելյայն"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Տեքսը պատճենված է"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Անտեսե՞լ փոփոխությունները և դադարեցնել խմբագրումը:"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Անտեսել"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Շարունակել խմբագրումը"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Որոնեք կոնտակտներ"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Կոնտակտների հեռացում"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Տեղեկատվություն"</string>
<string name="send_message" msgid="8938418965550543196">"Ուղարկել հաղորդագրություն"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Ստեղծվում է անձնական պատճենը..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Երեկ"</string>
<string name="tomorrow" msgid="6241969467795308581">"Վաղը"</string>
<string name="today" msgid="8041090779381781781">"Այսօր"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Այսօր՝ <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-in/strings.xml b/res/values-in/strings.xml
index 598d168..9df66c9 100644
--- a/res/values-in/strings.xml
+++ b/res/values-in/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Setel sebagai default"</string>
<string name="clear_default" msgid="7193185801596678067">"Hapus default"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Teks disalin"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Buang perubahan dan berhenti mengedit?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Buang"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Simpan pengeditan"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Telusuri kontak"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Hapus kontak"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Tentang"</string>
<string name="send_message" msgid="8938418965550543196">"Kirim pesan"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Membuat salinan pribadi..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Kemarin"</string>
<string name="tomorrow" msgid="6241969467795308581">"Besok"</string>
<string name="today" msgid="8041090779381781781">"Hari ini"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Hari ini pukul <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-is-rIS/strings.xml b/res/values-is-rIS/strings.xml
index 0bae040..01fd6d2 100644
--- a/res/values-is-rIS/strings.xml
+++ b/res/values-is-rIS/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Velja sem sjálfgefið"</string>
<string name="clear_default" msgid="7193185801596678067">"Hreinsa sjálfgefið"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Texti afritaður"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Viltu fleygja breytingum og hætta að breyta?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Fleygja"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Breyta áfram"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Leita í tengiliðum"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Fjarlægja tengiliði"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Um"</string>
<string name="send_message" msgid="8938418965550543196">"Senda skilaboð"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Býr til afrit til einkanota…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Í gær"</string>
<string name="tomorrow" msgid="6241969467795308581">"Á morgun"</string>
<string name="today" msgid="8041090779381781781">"Í dag"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Í dag klukkan <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-it/strings.xml b/res/values-it/strings.xml
index 78ea37b..91075b5 100644
--- a/res/values-it/strings.xml
+++ b/res/values-it/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Imposta come predefinito"</string>
<string name="clear_default" msgid="7193185801596678067">"Cancella impostazione predefinita"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Testo copiato"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Annullare le modifiche e uscire dalla modalità di modifica?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Ignorare le modifiche?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Ignora"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Continua la modifica"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Annulla"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Cerca contatti"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Rimuovi contatti"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Informazioni"</string>
<string name="send_message" msgid="8938418965550543196">"Invia messaggio"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Creazione di una copia personale..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Ieri"</string>
<string name="tomorrow" msgid="6241969467795308581">"Domani"</string>
<string name="today" msgid="8041090779381781781">"Oggi"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Oggi alle ore <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-iw/strings.xml b/res/values-iw/strings.xml
index ab62ba9..f5864ab 100644
--- a/res/values-iw/strings.xml
+++ b/res/values-iw/strings.xml
@@ -246,9 +246,9 @@
<string name="set_default" msgid="4417505153468300351">"קבע כברירת מחדל"</string>
<string name="clear_default" msgid="7193185801596678067">"נקה ברירת מחדל"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"טקסט שהועתק"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"האם להתעלם מהשינויים שביצעת ולהפסיק לערוך?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"האם לבטל את השינויים?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"מחק"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"המשך לערוך"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"ביטול"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"חיפוש אנשי קשר"</string>
<string name="title_edit_group" msgid="8602752287270586734">"הסרה של אנשי הקשר"</string>
@@ -273,7 +273,6 @@
<string name="about_card_title" msgid="2920942314212825637">"מידע כללי"</string>
<string name="send_message" msgid="8938418965550543196">"שלח הודעה"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"יוצר עותק אישי..."</string>
- <string name="yesterday" msgid="6840858548955018569">"אתמול"</string>
<string name="tomorrow" msgid="6241969467795308581">"מחר"</string>
<string name="today" msgid="8041090779381781781">"היום"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"היום ב-<xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-ja/donottranslate_config.xml b/res/values-ja/donottranslate_config.xml
new file mode 100644
index 0000000..ff8a8eb
--- /dev/null
+++ b/res/values-ja/donottranslate_config.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+** Copyright 2016, The Android Open Source Project
+**
+** Licensed under the Apache License, Version 2.0 (the "License");
+** you may not use this file except in compliance with the License.
+** You may obtain a copy of the License at
+**
+** http://www.apache.org/licenses/LICENSE-2.0
+**
+** Unless required by applicable law or agreed to in writing, software
+** distributed under the License is distributed on an "AS IS" BASIS,
+** WITHOUT 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>
+ <!-- If true, an option is shown in Display Options UI to choose a sort order -->
+ <bool name="config_sort_order_user_changeable">false</bool>
+
+ <!-- If true, the default sort order is primary (i.e. by given name) -->
+ <bool name="config_default_sort_order_primary">true</bool>
+
+ <!-- If true, an option is shown in Display Options UI to choose a name display order -->
+ <bool name="config_display_order_user_changeable">false</bool>
+
+ <!-- If true, the default sort order is primary (i.e. by given name) -->
+ <bool name="config_default_display_order_primary">true</bool>
+
+ <!-- If true, the order of name fields in the editor is primary (i.e. given name first) -->
+ <bool name="config_editor_field_order_primary">false</bool>
+
+ <!-- If true, phonetic name is included in the contact editor by default -->
+ <bool name="config_editor_include_phonetic_name">true</bool>
+</resources>
diff --git a/res/values-ja/strings.xml b/res/values-ja/strings.xml
index 1857827..6e43b9d 100644
--- a/res/values-ja/strings.xml
+++ b/res/values-ja/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"デフォルトに設定"</string>
<string name="clear_default" msgid="7193185801596678067">"デフォルトを解除"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"テキストをコピーしました"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"変更を破棄して編集を終了しますか?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"破棄"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"編集を続ける"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"連絡先を検索"</string>
<string name="title_edit_group" msgid="8602752287270586734">"連絡先の削除"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"概要"</string>
<string name="send_message" msgid="8938418965550543196">"メッセージの送信"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"個人用コピーを作成しています..."</string>
- <string name="yesterday" msgid="6840858548955018569">"昨日"</string>
<string name="tomorrow" msgid="6241969467795308581">"明日"</string>
<string name="today" msgid="8041090779381781781">"今日"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"今日の<xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-ka-rGE/strings.xml b/res/values-ka-rGE/strings.xml
index 66ed650..a8205ea 100644
--- a/res/values-ka-rGE/strings.xml
+++ b/res/values-ka-rGE/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"ნაგულისხმევად დაყენება"</string>
<string name="clear_default" msgid="7193185801596678067">"ნაგულისხმევის წაშლა"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"კოპირებული ტექსტი"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"გსურთ ცვლილებების გაუქმება და რედაქტირებიდან გასვლა?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"გაუქმება"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"რედაქტირების გაგრძელება"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"კონტაქტების ძიება"</string>
<string name="title_edit_group" msgid="8602752287270586734">"კონტაქტების წაშლა"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"შესახებ"</string>
<string name="send_message" msgid="8938418965550543196">"შეტყობინების გაგზავნა"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"პირადი ასლის შექმნა…"</string>
- <string name="yesterday" msgid="6840858548955018569">"გუშინ"</string>
<string name="tomorrow" msgid="6241969467795308581">"ხვალ"</string>
<string name="today" msgid="8041090779381781781">"დღეს"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"დღეს <xliff:g id="TIME_INTERVAL">%s</xliff:g>-ზე"</string>
diff --git a/res/values-kk-rKZ/strings.xml b/res/values-kk-rKZ/strings.xml
index 500727e..2079469 100644
--- a/res/values-kk-rKZ/strings.xml
+++ b/res/values-kk-rKZ/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Бастапқы ретінде орнату"</string>
<string name="clear_default" msgid="7193185801596678067">"Бастапқыны өшіру"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Мәтін көшірмесі жасалды"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Өзгертулерді алып тастап, өңдеуден шығу керек пе?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Өзгертулерді алып тастау қажет пе?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Алып тастау"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Өңдеуді жалғастыру"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Бас тарту"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Контактілерді іздеу"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Контактілерді жою"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Туралы"</string>
<string name="send_message" msgid="8938418965550543196">"Хабарды жіберу"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Жеке көшірме жасау…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Кеше"</string>
<string name="tomorrow" msgid="6241969467795308581">"Ертең"</string>
<string name="today" msgid="8041090779381781781">"Бүгін"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Бүгін, <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-km-rKH/strings.xml b/res/values-km-rKH/strings.xml
index c4d77fa..1897ec9 100644
--- a/res/values-km-rKH/strings.xml
+++ b/res/values-km-rKH/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"កំណត់លំនាំដើម"</string>
<string name="clear_default" msgid="7193185801596678067">"សម្អាតលំនាំដើម"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"បានចម្លងអត្ថបទ"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"បោះបង់ការប្ដូររបស់អ្នក ហើយបញ្ឈប់ការកែសម្រួលមែនទេ?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"បោះបង់ការផ្លាស់ប្ដូរដែរ ឬទេ?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"បោះបង់"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"នៅបន្តកែសម្រួល"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"បោះបង់"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"ស្វែងរកទំនាក់ទំនង"</string>
<string name="title_edit_group" msgid="8602752287270586734">"ដកទំនាក់ទំនងចេញ"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"អំពី"</string>
<string name="send_message" msgid="8938418965550543196">"ផ្ញើសារ"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"កំពុងបង្កើតច្បាប់ចម្លងផ្ទាល់ខ្លួន..."</string>
- <string name="yesterday" msgid="6840858548955018569">"ម្សិលមិញ"</string>
<string name="tomorrow" msgid="6241969467795308581">"ស្អែក"</string>
<string name="today" msgid="8041090779381781781">"ថ្ងៃនេះ"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"ថ្ងៃនេះនៅ <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-kn-rIN/strings.xml b/res/values-kn-rIN/strings.xml
index 50ea5ac..a6cb3a4 100644
--- a/res/values-kn-rIN/strings.xml
+++ b/res/values-kn-rIN/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"ಡೀಫಾಲ್ಟ್ ಹೊಂದಿಸಿ"</string>
<string name="clear_default" msgid="7193185801596678067">"ಡಿಫಾಲ್ಟ್ ತೆರವುಗೊಳಿಸಿ"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"ಪಠ್ಯವನ್ನು ನಕಲಿಸಲಾಗಿದೆ"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"ನಿಮ್ಮ ಬದಲಾವಣೆಗಳನ್ನು ತ್ಯಜಿಸಿ ಸಂಪಾದನೆಯನ್ನು ನಿರ್ಗಮಿಸುವುದೇ?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"ಬದಲಾವಣೆಗಳನ್ನು ತ್ಯಜಿಸುವುದೇ?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"ತ್ಯಜಿಸು"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"ಸಂಪಾದಿಸುತ್ತಿರಿ"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"ರದ್ದುಮಾಡು"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"ಸಂಪರ್ಕಗಳನ್ನು ಹುಡುಕಿ"</string>
<string name="title_edit_group" msgid="8602752287270586734">"ಸಂಪರ್ಕಗಳನ್ನು ತೆಗೆದುಹಾಕಿ"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"ಕುರಿತು"</string>
<string name="send_message" msgid="8938418965550543196">"ಸಂದೇಶ ಕಳುಹಿಸಿ"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"ವೈಯಕ್ತಿಕ ಪ್ರತಿಯನ್ನು ರಚಿಸಲಾಗುತ್ತಿದೆ…"</string>
- <string name="yesterday" msgid="6840858548955018569">"ನಿನ್ನೆ"</string>
<string name="tomorrow" msgid="6241969467795308581">"ನಾಳೆ"</string>
<string name="today" msgid="8041090779381781781">"ಇಂದು"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"<xliff:g id="TIME_INTERVAL">%s</xliff:g> ಕ್ಕೆ ಇಂದು"</string>
diff --git a/res/values-ko/donottranslate_config.xml b/res/values-ko/donottranslate_config.xml
new file mode 100644
index 0000000..14cdd4d
--- /dev/null
+++ b/res/values-ko/donottranslate_config.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+** Copyright 2016, The Android Open Source Project
+**
+** Licensed under the Apache License, Version 2.0 (the "License");
+** you may not use this file except in compliance with the License.
+** You may obtain a copy of the License at
+**
+** http://www.apache.org/licenses/LICENSE-2.0
+**
+** Unless required by applicable law or agreed to in writing, software
+** distributed under the License is distributed on an "AS IS" BASIS,
+** WITHOUT 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>
+ <!-- If true, an option is shown in Display Options UI to choose a sort order -->
+ <bool name="config_sort_order_user_changeable">false</bool>
+
+ <!-- If true, the default sort order is primary (i.e. by given name) -->
+ <bool name="config_default_sort_order_primary">false</bool>
+
+ <!-- If true, an option is shown in Display Options UI to choose a name display order -->
+ <bool name="config_display_order_user_changeable">false</bool>
+
+ <!-- If true, the default sort order is primary (i.e. by given name) -->
+ <bool name="config_default_display_order_primary">false</bool>
+
+ <!-- If true, the order of name fields in the editor is primary (i.e. given name first) -->
+ <bool name="config_editor_field_order_primary">false</bool>
+</resources>
diff --git a/res/values-ko/strings.xml b/res/values-ko/strings.xml
index 5c346de..7d87637 100644
--- a/res/values-ko/strings.xml
+++ b/res/values-ko/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"기본으로 설정"</string>
<string name="clear_default" msgid="7193185801596678067">"기본 설정 지우기"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"텍스트 복사됨"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"변경사항을 취소하고 수정을 중단하시겠습니까?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"취소"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"계속 수정"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"연락처 검색"</string>
<string name="title_edit_group" msgid="8602752287270586734">"연락처 삭제"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"정보"</string>
<string name="send_message" msgid="8938418965550543196">"메시지 보내기"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"개인 사본 작성 중..."</string>
- <string name="yesterday" msgid="6840858548955018569">"어제"</string>
<string name="tomorrow" msgid="6241969467795308581">"내일"</string>
<string name="today" msgid="8041090779381781781">"오늘"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"오늘 <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-ky-rKG/strings.xml b/res/values-ky-rKG/strings.xml
index 61c6594..725a533 100644
--- a/res/values-ky-rKG/strings.xml
+++ b/res/values-ky-rKG/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Негизги кылуу"</string>
<string name="clear_default" msgid="7193185801596678067">"Негизгини тазалоо"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"SMS көчүрүлдү"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Өзгөртүүлөр жарактан чыгарылып, түзөтүү жабылсынбы?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Жарактан чыгаруу"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Түзөтө берүү"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Байланыштардан издеп көрүңүз"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Байланыштарды алып салуу"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Жөнүндө"</string>
<string name="send_message" msgid="8938418965550543196">"Билдирүү жөнөтүү"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Жеке көчүрмөсүн түзүү…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Кечээ"</string>
<string name="tomorrow" msgid="6241969467795308581">"Эртең"</string>
<string name="today" msgid="8041090779381781781">"Бүгүн"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Бүгүн саат <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-land/integers.xml b/res/values-land/integers.xml
index 8bc7b04..4b26d4c 100644
--- a/res/values-land/integers.xml
+++ b/res/values-land/integers.xml
@@ -22,4 +22,9 @@
<!-- Top margin ratio for the image for empty account view -->
<integer name="empty_account_view_image_margin_divisor">6</integer>
+
+ <integer name="contact_tile_column_count_in_favorites">3</integer>
+
+ <!-- The number of characters in the snippet before we need to tokenize and ellipse. -->
+ <integer name="snippet_length_before_tokenize">60</integer>
</resources>
diff --git a/res/values-lo-rLA/strings.xml b/res/values-lo-rLA/strings.xml
index 12bb70b..e7c5dd7 100644
--- a/res/values-lo-rLA/strings.xml
+++ b/res/values-lo-rLA/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"ຕັ້ງຄ່າເລີ່ມຕົ້ນ"</string>
<string name="clear_default" msgid="7193185801596678067">"ລຶບຄ່າເລີ່ມຕົ້ນ"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"ສຳເນົາຂໍ້ຄວາມແລ້ວ"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"ຍົກເລີກການປ່ຽນແປງຂອງທ່ານ ແລະ ອອກຈາກການແກ້ໄຂບໍ?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"ຍົກເລີກການແກ້ໄຂບໍ່?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"ຍົກເລີກ"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"ສືບຕໍ່ແກ້ໄຂ"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"ຍົກເລີກ"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"ຊອກຫາລາຍຊື່ຜູ້ຕິດຕໍ່"</string>
<string name="title_edit_group" msgid="8602752287270586734">"ລຶບລາຍຊື່ຜູ້ຕິດຕໍ່"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"ກ່ຽວກັບ"</string>
<string name="send_message" msgid="8938418965550543196">"ສົ່ງຂໍ້ຄວາມ"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"ກຳລັງສ້າງສຳເນົາສ່ວນໂຕ..."</string>
- <string name="yesterday" msgid="6840858548955018569">"ມື້ວານນີ້"</string>
<string name="tomorrow" msgid="6241969467795308581">"ມື້ອື່ນ"</string>
<string name="today" msgid="8041090779381781781">"ມື້ນີ້"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"ມື້ນີ້ໃນເວລາ <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-lt/strings.xml b/res/values-lt/strings.xml
index 1e51eeb..6088bc9 100644
--- a/res/values-lt/strings.xml
+++ b/res/values-lt/strings.xml
@@ -246,9 +246,11 @@
<string name="set_default" msgid="4417505153468300351">"Nustatyti numatytuosius nustatymus"</string>
<string name="clear_default" msgid="7193185801596678067">"Išvalyti numatytuosius nustatymus"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Tekstas nukopijuotas"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Atmesti pakeitimus ir baigti redagavimą?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Atmesti"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Toliau redaguoti"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Ieškokite kontaktų"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Pašalinti kontaktus"</string>
@@ -273,7 +275,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Apie"</string>
<string name="send_message" msgid="8938418965550543196">"Siųsti pranešimą"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Kuriama asmeninė kopija..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Vakar"</string>
<string name="tomorrow" msgid="6241969467795308581">"Rytoj"</string>
<string name="today" msgid="8041090779381781781">"Šiandien"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Šiandien, <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-lv/strings.xml b/res/values-lv/strings.xml
index 0bee0f6..326a4e8 100644
--- a/res/values-lv/strings.xml
+++ b/res/values-lv/strings.xml
@@ -239,9 +239,11 @@
<string name="set_default" msgid="4417505153468300351">"Iestatīt kā noklusējumu"</string>
<string name="clear_default" msgid="7193185801596678067">"Notīrīt noklusējuma iestatījumus"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Teksts ir nokopēts"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Vai atmest veiktās izmaiņas un beigt rediģēšanu?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Atmest"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Turpināt rediģēšanu"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> — <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Meklēt kontaktpersonas"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Kontaktpersonu noņemšana"</string>
@@ -266,7 +268,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Par"</string>
<string name="send_message" msgid="8938418965550543196">"Sūtīt ziņojumu"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Notiek personīgā eksemplāra izveide..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Vakar"</string>
<string name="tomorrow" msgid="6241969467795308581">"Rīt"</string>
<string name="today" msgid="8041090779381781781">"Šodien"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Šodien plkst. <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-mk-rMK/strings.xml b/res/values-mk-rMK/strings.xml
index 8e34554..880317a 100644
--- a/res/values-mk-rMK/strings.xml
+++ b/res/values-mk-rMK/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Постави стандарден метод"</string>
<string name="clear_default" msgid="7193185801596678067">"Исчисти стандарден метод"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Текстот е копиран"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Дали да се отфрлат промените и да се прекине уредувањето?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Отфрли"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Продолжи со уредување"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Пребарајте контакти"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Отстрани контакти"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"За"</string>
<string name="send_message" msgid="8938418965550543196">"Испрати порака"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Се создава лична копија..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Вчера"</string>
<string name="tomorrow" msgid="6241969467795308581">"Утре"</string>
<string name="today" msgid="8041090779381781781">"Денес"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Денес во <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-ml-rIN/strings.xml b/res/values-ml-rIN/strings.xml
index 76c72ba..77af785 100644
--- a/res/values-ml-rIN/strings.xml
+++ b/res/values-ml-rIN/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"സ്ഥിരമായി സജ്ജമാക്കുക"</string>
<string name="clear_default" msgid="7193185801596678067">"സ്ഥിരമായത് മായ്ക്കുക"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"വാചകം പകർത്തി"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"നിങ്ങളുടെ മാറ്റങ്ങൾ തള്ളിക്കളയുകയും എഡിറ്റുചെയ്യൽ ഉപേക്ഷിക്കുകയും ചെയ്യണോ?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"മാറ്റങ്ങൾ നിരസിക്കണോ?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"തള്ളിക്കളയുക"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"എഡിറ്റുചെയ്യുന്നത് തുടരുക"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"റദ്ദാക്കൂ"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"കോണ്ടാക്റ്റുകള് തിരയുക"</string>
<string name="title_edit_group" msgid="8602752287270586734">"കോൺടാക്റ്റുകൾ നീക്കംചെയ്യുക"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"ആമുഖം"</string>
<string name="send_message" msgid="8938418965550543196">"സന്ദേശം അയയ്ക്കുക"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"ഒരു വ്യക്തിഗത പകർപ്പ് സൃഷ്ടിക്കുന്നു…"</string>
- <string name="yesterday" msgid="6840858548955018569">"ഇന്നലെ"</string>
<string name="tomorrow" msgid="6241969467795308581">"നാളെ"</string>
<string name="today" msgid="8041090779381781781">"ഇന്ന്"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"ഇന്ന് <xliff:g id="TIME_INTERVAL">%s</xliff:g> മണിയ്ക്ക്"</string>
diff --git a/res/values-mn-rMN/strings.xml b/res/values-mn-rMN/strings.xml
index 6396aa5..97a5ebf 100644
--- a/res/values-mn-rMN/strings.xml
+++ b/res/values-mn-rMN/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Үндсэн болгох"</string>
<string name="clear_default" msgid="7193185801596678067">"Үндсэнээс хасах"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Текст хуулагдав"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Таны өөрчлөлтийн устгал, засварыг болих уу?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Устгах"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Үргэлжлүүлэн засах"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Харилцагчдаас хайх"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Харилцагчдыг арилгах"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Тухай"</string>
<string name="send_message" msgid="8938418965550543196">"Зурвас илгээх"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Хувийн хуулбар үүсгэж байна…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Өчигдөр"</string>
<string name="tomorrow" msgid="6241969467795308581">"Маргааш"</string>
<string name="today" msgid="8041090779381781781">"Өнөөдөр"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Өнөөдөр <xliff:g id="TIME_INTERVAL">%s</xliff:g>-д"</string>
diff --git a/res/values-mr-rIN/strings.xml b/res/values-mr-rIN/strings.xml
index 4f03f81..0f62ed4 100644
--- a/res/values-mr-rIN/strings.xml
+++ b/res/values-mr-rIN/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"डीफॉल्ट म्हणून सेट करा"</string>
<string name="clear_default" msgid="7193185801596678067">"डीफॉल्ट साफ करा"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"मजकूर कॉपी केला"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"आपले बदल टाकून देऊन संपादन सोडायचे?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"टाकून द्या"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"संपादन करणे सुरु ठेवा"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"संपर्क शोधा"</string>
<string name="title_edit_group" msgid="8602752287270586734">"संपर्क काढा"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"विषयी"</string>
<string name="send_message" msgid="8938418965550543196">"संदेश पाठवा"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"वैयक्तिक प्रत तयार करीत आहे..."</string>
- <string name="yesterday" msgid="6840858548955018569">"काल"</string>
<string name="tomorrow" msgid="6241969467795308581">"उद्या"</string>
<string name="today" msgid="8041090779381781781">"आज"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"आज <xliff:g id="TIME_INTERVAL">%s</xliff:g> वाजता"</string>
diff --git a/res/values-ms-rMY/strings.xml b/res/values-ms-rMY/strings.xml
index 50703d5..c9a7f7d 100644
--- a/res/values-ms-rMY/strings.xml
+++ b/res/values-ms-rMY/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Tetapkan lalai"</string>
<string name="clear_default" msgid="7193185801596678067">"Kosongkan lalai"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Teks disalin"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Buang perubahan anda dan keluar daripada pengeditan?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Buang perubahan?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Buang"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Teruskan mengedit"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Batal"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Cari dalam kenalan"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Alih keluar kenalan"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Mengenai"</string>
<string name="send_message" msgid="8938418965550543196">"Hantar mesej"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Membuat salinan peribadi..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Semalam"</string>
<string name="tomorrow" msgid="6241969467795308581">"Esok"</string>
<string name="today" msgid="8041090779381781781">"Hari ini"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Hari ini pada <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-my-rMM/strings.xml b/res/values-my-rMM/strings.xml
index 53e6cbf..b50d7f0 100644
--- a/res/values-my-rMM/strings.xml
+++ b/res/values-my-rMM/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"ပုံသေအဖြစ် သတ်မှတ်ခြင်း"</string>
<string name="clear_default" msgid="7193185801596678067">"မူရင်းများကို ရှင်းလင်းခြင်း"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"စာသားကူးယူပြီး"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"သင့်ပြောင်းလဲမှုများကို စွန့်ပစ်ပြီး တည်းဖြတ်မှုကို ရပ်မလား။"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"စွန့်ပစ်ရန်"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"ဆက်လက်တည်းဖြတ်ပါ"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"အဆက်အသွယ်များရှာပါ"</string>
<string name="title_edit_group" msgid="8602752287270586734">"အဆက်အသွယ်များ ဖယ်ရှားရန်"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"အကြောင်း"</string>
<string name="send_message" msgid="8938418965550543196">"စာတို ပို့ရန်"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"မိမိအတွက် ကိုယ်ပိုင်ကော်ပီ ပြုလုပ်နေစဉ်…"</string>
- <string name="yesterday" msgid="6840858548955018569">"မနေ့က"</string>
<string name="tomorrow" msgid="6241969467795308581">"နက်ဖြန်"</string>
<string name="today" msgid="8041090779381781781">"ယနေ့"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"ယနေ့ <xliff:g id="TIME_INTERVAL">%s</xliff:g> တွင်"</string>
diff --git a/res/values-nb/strings.xml b/res/values-nb/strings.xml
index 71f2cdf..0b5d6f6 100644
--- a/res/values-nb/strings.xml
+++ b/res/values-nb/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Angi som standard"</string>
<string name="clear_default" msgid="7193185801596678067">"Fjern som standard"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Tekst kopiert"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Vil du forkaste endringene og avslutte endringsmodusen?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Forkast"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Fortsett å endre"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Søk etter kontakter"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Fjern kontakter"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Info"</string>
<string name="send_message" msgid="8938418965550543196">"Send melding"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Oppretter personlig kopi …"</string>
- <string name="yesterday" msgid="6840858548955018569">"I går"</string>
<string name="tomorrow" msgid="6241969467795308581">"I morgen"</string>
<string name="today" msgid="8041090779381781781">"I dag"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"I dag kl. <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-ne-rNP/strings.xml b/res/values-ne-rNP/strings.xml
index d435073..2222439 100644
--- a/res/values-ne-rNP/strings.xml
+++ b/res/values-ne-rNP/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"पूर्वनिर्धारित मिलाउनुहोस्"</string>
<string name="clear_default" msgid="7193185801596678067">"पूर्वनिर्धारित हटाउनुहोस्"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"पाठको प्रतिलिपि गरियो"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"तपाईंका परिवर्तनहरू खारेज गरी सम्पादनलाई छाड्ने हो?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"परिवर्तनहरू खारेज गर्ने हो?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"खारेज गर्नुहोस्"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"सम्पादन गरिरहनुहोस्"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"रद्द गर्नुहोस्"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"सम्पर्कहरू खोज्नुहोस्"</string>
<string name="title_edit_group" msgid="8602752287270586734">"सम्पर्कहरू हटाउनुहोस्"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"बारेमा"</string>
<string name="send_message" msgid="8938418965550543196">"सन्देश पठाउनुहोस्"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"एउटा व्यक्तिगत प्रतिलिपि बनाउँदै..."</string>
- <string name="yesterday" msgid="6840858548955018569">"हिजो"</string>
<string name="tomorrow" msgid="6241969467795308581">"भोलि"</string>
<string name="today" msgid="8041090779381781781">"आज"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"<xliff:g id="TIME_INTERVAL">%s</xliff:g>मा आज"</string>
diff --git a/res/values-nl/strings.xml b/res/values-nl/strings.xml
index ee1e7a2..1a35f88 100644
--- a/res/values-nl/strings.xml
+++ b/res/values-nl/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Standaard instellen"</string>
<string name="clear_default" msgid="7193185801596678067">"Standaardwaarden wissen"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Tekst gekopieerd"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Je wijzigingen weggooien en ophouden met bewerken?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Weggooien"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Blijven bewerken"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Contacten zoeken"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Contacten verwijderen"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Over"</string>
<string name="send_message" msgid="8938418965550543196">"Bericht verzenden"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Een persoonlijke kopie maken..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Gisteren"</string>
<string name="tomorrow" msgid="6241969467795308581">"Morgen"</string>
<string name="today" msgid="8041090779381781781">"Vandaag"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Vandaag om <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-pa-rIN/strings.xml b/res/values-pa-rIN/strings.xml
index 766187a..ca5fd10 100644
--- a/res/values-pa-rIN/strings.xml
+++ b/res/values-pa-rIN/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"ਡਿਫੌਲਟ ਸੈਟ ਕਰੋ"</string>
<string name="clear_default" msgid="7193185801596678067">"ਡਿਫੌਲਟ ਹਟਾਓ"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"ਟੈਕਸਟ ਕਾਪੀ ਕੀਤਾ"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"ਕੀ ਆਪਣੀਆਂ ਤਬਦੀਲੀਆਂ ਨੂੰ ਛੱਡਣਾ ਅਤੇ ਸੰਪਾਦਨ ਨੂੰ ਰੱਦ ਕਰਨਾ ਹੈ?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"ਕੀ ਤਬਦੀਲੀਆਂ ਖਾਰਜ ਕਰਨੀਆਂ ਹਨ?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"ਛੱਡੋ"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"ਸੋਧ ਕਰਦੇ ਰਹੋ"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"ਰੱਦ ਕਰੋ"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"ਸੰਪਰਕ ਖੋਜੋ"</string>
<string name="title_edit_group" msgid="8602752287270586734">"ਸੰਪਰਕ ਹਟਾਓ"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"ਇਸਦੇ ਬਾਰੇ"</string>
<string name="send_message" msgid="8938418965550543196">"ਸੁਨੇਹਾ ਭੇਜੋ"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"ਇੱਕ ਨਿੱਜੀ ਕਾਪੀ ਬਣਾ ਰਿਹਾ ਹੈ..."</string>
- <string name="yesterday" msgid="6840858548955018569">"ਕੱਲ੍ਹ"</string>
<string name="tomorrow" msgid="6241969467795308581">"ਕੱਲ੍ਹ ਨੂੰ"</string>
<string name="today" msgid="8041090779381781781">"ਅੱਜ"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"ਅੱਜ <xliff:g id="TIME_INTERVAL">%s</xliff:g> ਵਜੇ"</string>
diff --git a/res/values-pl/strings.xml b/res/values-pl/strings.xml
index e652f6e..92949a8 100644
--- a/res/values-pl/strings.xml
+++ b/res/values-pl/strings.xml
@@ -246,9 +246,9 @@
<string name="set_default" msgid="4417505153468300351">"Ustaw jako wartość domyślną"</string>
<string name="clear_default" msgid="7193185801596678067">"Wyczyść wartość domyślną"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Tekst skopiowany"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Odrzucić zmiany i zakończyć edycję?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Odrzucić zmiany?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Odrzuć"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Edytuj dalej"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Anuluj"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Szukaj kontaktów"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Usuń kontakty"</string>
@@ -273,7 +273,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Informacje"</string>
<string name="send_message" msgid="8938418965550543196">"Wyślij wiadomość"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Tworzenie kopii osobistej…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Wczoraj"</string>
<string name="tomorrow" msgid="6241969467795308581">"Jutro"</string>
<string name="today" msgid="8041090779381781781">"Dzisiaj"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Dzisiaj: <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-pt-rBR/strings.xml b/res/values-pt-rBR/strings.xml
index c57de4c..641745e 100644
--- a/res/values-pt-rBR/strings.xml
+++ b/res/values-pt-rBR/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Definir padrão"</string>
<string name="clear_default" msgid="7193185801596678067">"Limpar padrão"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Texto copiado"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Descartar as alterações e sair da edição?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Descartar"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Continuar edição"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Pesquisar contatos"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Remover contatos"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Sobre"</string>
<string name="send_message" msgid="8938418965550543196">"Enviar mensagem"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Criando uma cópia pessoal..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Ontem"</string>
<string name="tomorrow" msgid="6241969467795308581">"Amanhã"</string>
<string name="today" msgid="8041090779381781781">"Hoje"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Hoje, às <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-pt-rPT/strings.xml b/res/values-pt-rPT/strings.xml
index 4219e08..7b5b495 100644
--- a/res/values-pt-rPT/strings.xml
+++ b/res/values-pt-rPT/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Definir a predefinição"</string>
<string name="clear_default" msgid="7193185801596678067">"Limpar predefinição"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Texto copiado"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Pretende rejeitar as alterações e sair do editor?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Rejeitar alterações?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Rejeitar"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Continuar a editar"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Cancelar"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Pesquisar contactos"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Remover contactos"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Acerca de"</string>
<string name="send_message" msgid="8938418965550543196">"Enviar mensagem"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"A criar uma cópia pessoal"</string>
- <string name="yesterday" msgid="6840858548955018569">"Ontem"</string>
<string name="tomorrow" msgid="6241969467795308581">"Amanhã"</string>
<string name="today" msgid="8041090779381781781">"Hoje"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Hoje, às <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-pt/strings.xml b/res/values-pt/strings.xml
index c57de4c..641745e 100644
--- a/res/values-pt/strings.xml
+++ b/res/values-pt/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Definir padrão"</string>
<string name="clear_default" msgid="7193185801596678067">"Limpar padrão"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Texto copiado"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Descartar as alterações e sair da edição?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Descartar"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Continuar edição"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Pesquisar contatos"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Remover contatos"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Sobre"</string>
<string name="send_message" msgid="8938418965550543196">"Enviar mensagem"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Criando uma cópia pessoal..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Ontem"</string>
<string name="tomorrow" msgid="6241969467795308581">"Amanhã"</string>
<string name="today" msgid="8041090779381781781">"Hoje"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Hoje, às <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-ro/strings.xml b/res/values-ro/strings.xml
index 0ed0842..7b17040 100644
--- a/res/values-ro/strings.xml
+++ b/res/values-ro/strings.xml
@@ -239,9 +239,11 @@
<string name="set_default" msgid="4417505153468300351">"Setați ca prestabilit"</string>
<string name="clear_default" msgid="7193185801596678067">"Ștergeți datele prestabilite"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Text copiat"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Renunțați la modificări și părăsiți editarea?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Renunțați"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Editați în continuare"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Căutați în Agendă"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Eliminați intrările din Agendă"</string>
@@ -266,7 +268,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Despre"</string>
<string name="send_message" msgid="8938418965550543196">"Trimiteți mesajul"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Se creează o copie personală..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Ieri"</string>
<string name="tomorrow" msgid="6241969467795308581">"Mâine"</string>
<string name="today" msgid="8041090779381781781">"Astăzi"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Astăzi, la <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-ru/strings.xml b/res/values-ru/strings.xml
index e348274..1d54270 100644
--- a/res/values-ru/strings.xml
+++ b/res/values-ru/strings.xml
@@ -246,9 +246,9 @@
<string name="set_default" msgid="4417505153468300351">"Установить по умолчанию"</string>
<string name="clear_default" msgid="7193185801596678067">"Удалить настройки по умолчанию"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Текст скопирован"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Отменить изменения и завершить редактирование?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Отменить изменения?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Отменить"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Продолжить редактирование"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Отмена"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> (<xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>)"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Поиск контактов"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Удалить контакты"</string>
@@ -273,7 +273,6 @@
<string name="about_card_title" msgid="2920942314212825637">"О контакте"</string>
<string name="send_message" msgid="8938418965550543196">"Отправить сообщение"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Копирование..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Вчера"</string>
<string name="tomorrow" msgid="6241969467795308581">"Завтра"</string>
<string name="today" msgid="8041090779381781781">"Сегодня"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Сегодня, <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-si-rLK/strings.xml b/res/values-si-rLK/strings.xml
index 153d970..0bdfcfa 100644
--- a/res/values-si-rLK/strings.xml
+++ b/res/values-si-rLK/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"සුපුරුදු ලෙස සකසන්න"</string>
<string name="clear_default" msgid="7193185801596678067">"සුපුරුදු හිස් කරන්න"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"පෙළ පිටපත් කරන ලදී"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"ඔබේ වෙනස් කිරීම් ඉවත දමා සංස්කරණය කිරීමෙන් ඉවත් වන්නද?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"ඉවතලන්න"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"සංස්කරණය කරගෙන යන්න"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"සම්බන්ධතා සොයන්න"</string>
<string name="title_edit_group" msgid="8602752287270586734">"සම්බන්ධතා ඉවත් කරන්න"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"පිළිබඳ"</string>
<string name="send_message" msgid="8938418965550543196">"පණිවිඩය යවන්න"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"පුද්ගලික පිටපතක් නිර්මාණය කරමින්…"</string>
- <string name="yesterday" msgid="6840858548955018569">"ඊයේ"</string>
<string name="tomorrow" msgid="6241969467795308581">"හෙට"</string>
<string name="today" msgid="8041090779381781781">"අද"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"අද <xliff:g id="TIME_INTERVAL">%s</xliff:g> ට"</string>
diff --git a/res/values-sk/strings.xml b/res/values-sk/strings.xml
index cfd7f9c..6e0ff34 100644
--- a/res/values-sk/strings.xml
+++ b/res/values-sk/strings.xml
@@ -246,9 +246,11 @@
<string name="set_default" msgid="4417505153468300351">"Nastaviť ako predvolené"</string>
<string name="clear_default" msgid="7193185801596678067">"Vymazať predvolené nastavenia"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Text bol skopírovaný"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Zahodiť zmeny a ukončiť upravovanie?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Zahodiť"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Pokračovať v úprave"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Hľadať v kontaktoch"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Odstránenie kontaktov"</string>
@@ -273,7 +275,6 @@
<string name="about_card_title" msgid="2920942314212825637">"O karte"</string>
<string name="send_message" msgid="8938418965550543196">"Odoslať správu"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Prebieha vytváranie osobnej kópie..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Včera"</string>
<string name="tomorrow" msgid="6241969467795308581">"Zajtra"</string>
<string name="today" msgid="8041090779381781781">"Dnes"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Dnes o <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-sl/strings.xml b/res/values-sl/strings.xml
index fcaf9bf..fd31f28 100644
--- a/res/values-sl/strings.xml
+++ b/res/values-sl/strings.xml
@@ -246,9 +246,9 @@
<string name="set_default" msgid="4417505153468300351">"Nastavi za privzeto"</string>
<string name="clear_default" msgid="7193185801596678067">"Počisti privzeto"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Besedilo kopirano"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Želite zavreči spremembe in prenehati urejati?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Želite zavreči spremembe?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Zavrzi"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Nadaljevanje urejanja"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Prekliči"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Iščite med stiki"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Odstranitev stikov"</string>
@@ -273,7 +273,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Vizitka"</string>
<string name="send_message" msgid="8938418965550543196">"Pošlji sporočilo"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Ustvarjanje osebne kopije ..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Včeraj"</string>
<string name="tomorrow" msgid="6241969467795308581">"Jutri"</string>
<string name="today" msgid="8041090779381781781">"Danes"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Danes ob <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-sq-rAL/strings.xml b/res/values-sq-rAL/strings.xml
index 3095b66..9bab93b 100644
--- a/res/values-sq-rAL/strings.xml
+++ b/res/values-sq-rAL/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Vendos të parazgjedhurën"</string>
<string name="clear_default" msgid="7193185801596678067">"Pastro të paracaktuarin"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Teksti u kopjua"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Hiqi ndryshimet dhe mbylle redaktimin?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Hiq"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Vazhdo redaktimin"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Kërko kontaktet"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Hiq kontaktet"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Informacion rreth"</string>
<string name="send_message" msgid="8938418965550543196">"Dërgo mesazh"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Po krijon një kopje personale..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Dje"</string>
<string name="tomorrow" msgid="6241969467795308581">"Nesër"</string>
<string name="today" msgid="8041090779381781781">"Sot"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Sot në <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-sr/strings.xml b/res/values-sr/strings.xml
index 49adda9..5354b72 100644
--- a/res/values-sr/strings.xml
+++ b/res/values-sr/strings.xml
@@ -239,9 +239,9 @@
<string name="set_default" msgid="4417505153468300351">"Постави на подразумевано"</string>
<string name="clear_default" msgid="7193185801596678067">"Обриши подразумевану вредност"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Текст је копиран"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Желите ли да одбаците промене и прекинете са изменама?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Желите да одбаците промене?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Одбаци"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Настави изменe"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Откажи"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Претражите контакте"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Уклоните контакте"</string>
@@ -266,7 +266,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Основни подаци"</string>
<string name="send_message" msgid="8938418965550543196">"Пошаљи поруку"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Прављење личне копије..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Јуче"</string>
<string name="tomorrow" msgid="6241969467795308581">"Сутра"</string>
<string name="today" msgid="8041090779381781781">"Данас"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Данас у <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-sv/strings.xml b/res/values-sv/strings.xml
index 265c47c..2f9ca2a 100644
--- a/res/values-sv/strings.xml
+++ b/res/values-sv/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Ange standard"</string>
<string name="clear_default" msgid="7193185801596678067">"Rensa standardinställningar"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Texten har kopierats"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Vill du tar bort ändringarna och sluta redigera?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Ignorera"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Fortsätt redigera"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Sök efter kontakter"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Ta bort kontakter"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Om"</string>
<string name="send_message" msgid="8938418965550543196">"Skicka meddelande"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"En personlig kopia skapas ..."</string>
- <string name="yesterday" msgid="6840858548955018569">"I går"</string>
<string name="tomorrow" msgid="6241969467795308581">"I morgon"</string>
<string name="today" msgid="8041090779381781781">"I dag"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"I dag kl. <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-sw/strings.xml b/res/values-sw/strings.xml
index 4dd493d..bbfcf70 100644
--- a/res/values-sw/strings.xml
+++ b/res/values-sw/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Weka chaguo-msingi"</string>
<string name="clear_default" msgid="7193185801596678067">"Ondoa chaguo-msingi"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Maandishi yamenakiliwa"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Je, ungependa kutupa mabadiliko yako na uache kubadilisha?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Tupa"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Endelea kubadilisha"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Tafuta anwani"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Ondoa anwani"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Kuhusu"</string>
<string name="send_message" msgid="8938418965550543196">"Tuma ujumbe"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Inaunda nakala ya kibinafsi..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Jana"</string>
<string name="tomorrow" msgid="6241969467795308581">"Kesho"</string>
<string name="today" msgid="8041090779381781781">"Leo"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Leo saa <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-sw600dp-land/integers.xml b/res/values-sw600dp-land/integers.xml
index 1785443..b6fe15a 100644
--- a/res/values-sw600dp-land/integers.xml
+++ b/res/values-sw600dp-land/integers.xml
@@ -26,4 +26,9 @@
<!-- Top margin ratio for the image for empty account view -->
<integer name="empty_account_view_image_margin_divisor">5</integer>
+
+ <integer name="contact_tile_column_count_in_favorites">3</integer>
+
+ <!-- The number of characters in the snippet before we need to tokenize and ellipse. -->
+ <integer name="snippet_length_before_tokenize">20</integer>
</resources>
diff --git a/res/values-sw600dp/dimens.xml b/res/values-sw600dp/dimens.xml
index 6e4f808..6f6f4e1 100644
--- a/res/values-sw600dp/dimens.xml
+++ b/res/values-sw600dp/dimens.xml
@@ -59,4 +59,22 @@
<!-- Margin offset b/w the image top and app bar bottom for no account empty view -->
<dimen name="contacts_no_account_empty_image_offset">238dp</dimen>
+
+ <dimen name="detail_item_side_margin">0dip</dimen>
+
+ <dimen name="contact_browser_list_header_left_margin">@dimen/list_visible_scrollbar_padding</dimen>
+ <dimen name="contact_browser_list_header_right_margin">24dip</dimen>
+ <dimen name="contact_browser_list_top_margin">16dip</dimen>
+
+ <!-- End margin of the account filter header icon -->
+ <dimen name="contact_browser_list_header_icon_right_margin">22dp</dimen>
+ <dimen name="contact_browser_list_header_icon_right_margin_alt">24dp</dimen>
+
+ <dimen name="contact_filter_list_item_padding_start">24dp</dimen>
+ <dimen name="contact_filter_left_margin">16dp</dimen>
+
+ <!-- Right margin of the floating action button -->
+ <dimen name="floating_action_button_margin_right">32dp</dimen>
+ <!-- Bottom margin of the floating action button -->
+ <dimen name="floating_action_button_margin_bottom">32dp</dimen>
</resources>
diff --git a/res/values-sw600dp/integers.xml b/res/values-sw600dp/integers.xml
index 4eb0061..4b2d73f 100644
--- a/res/values-sw600dp/integers.xml
+++ b/res/values-sw600dp/integers.xml
@@ -3,4 +3,11 @@
<resources>
<!-- Top margin ratio for the image for empty group view -->
<integer name="empty_group_view_image_margin_divisor">4</integer>
-</resources>
\ No newline at end of file
+
+ <integer name="contact_tile_column_count_in_favorites">3</integer>
+
+ <!-- The number of characters in the snippet before we need to tokenize and ellipse. -->
+ <!-- Yikes, there is less space on a tablet! This makes the search experience rather
+ poor. Another reason to get rid of the exist tablet layout. -->
+ <integer name="snippet_length_before_tokenize">15</integer>
+</resources>
diff --git a/res/values-sw600dp/strings.xml b/res/values-sw600dp/strings.xml
new file mode 100644
index 0000000..c9946f9
--- /dev/null
+++ b/res/values-sw600dp/strings.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2012 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT 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 xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+
+ <!-- Title for data source when creating or editing a contact that doesn't
+ belong to a specific account. This contact will only exist on the phone
+ and will not be synced. [CHAR LIMIT=20] -->
+ <string name="account_phone">Device</string>
+
+ <!-- This error message shown when the user actually have no contact
+ (e.g. just after data-wiping), or, data providers of the contact list prohibits their
+ contacts from being exported to outside world via vcard exporter, etc. [CHAR LIMIT=NONE] -->
+ <string name="composer_has_no_exportable_contact">There are no exportable contacts. If you do have contacts on your tablet, some data providers may not allow the contacts to be exported from the tablet.</string>
+
+</resources>
diff --git a/res/values-sw720dp-land/integers.xml b/res/values-sw720dp-land/integers.xml
new file mode 100644
index 0000000..a83cdff
--- /dev/null
+++ b/res/values-sw720dp-land/integers.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2012 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT 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>
+ <integer name="contact_tile_column_count_in_favorites">4</integer>
+
+ <!-- The number of characters in the snippet before we need to tokenize and ellipse. -->
+ <integer name="snippet_length_before_tokenize">30</integer>
+</resources>
diff --git a/res/values-sw720dp/integers.xml b/res/values-sw720dp/integers.xml
index 1cc510e..93bbc68 100644
--- a/res/values-sw720dp/integers.xml
+++ b/res/values-sw720dp/integers.xml
@@ -20,4 +20,9 @@
<!-- Layout weight of the content column for tile favorites list, all contacts list, and
QuickContact -->
<integer name="contact_list_card_layout_weight">81</integer>
+
+ <integer name="contact_tile_column_count_in_favorites">2</integer>
+
+ <!-- The number of characters in the snippet before we need to tokenize and ellipse. -->
+ <integer name="snippet_length_before_tokenize">20</integer>
</resources>
diff --git a/res/values-ta-rIN/strings.xml b/res/values-ta-rIN/strings.xml
index 54a3f68..583b37a 100644
--- a/res/values-ta-rIN/strings.xml
+++ b/res/values-ta-rIN/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"இயல்புநிலையாக அமை"</string>
<string name="clear_default" msgid="7193185801596678067">"இயல்பை அழி"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"உரை நகலெடுக்கப்பட்டது"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"மாற்றங்களை நிராகரித்து, திருத்துவதிலிருந்து வெளியேறவா?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"நிராகரி"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"தொடர்ந்து திருத்து"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"தொடர்புகளில் தேடுக"</string>
<string name="title_edit_group" msgid="8602752287270586734">"தொடர்புகளை அகற்று"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"அறிமுகம்"</string>
<string name="send_message" msgid="8938418965550543196">"செய்தி அனுப்பு"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"தனிப்பட்ட நகலை உருவாக்குகிறது…"</string>
- <string name="yesterday" msgid="6840858548955018569">"நேற்று"</string>
<string name="tomorrow" msgid="6241969467795308581">"நாளை"</string>
<string name="today" msgid="8041090779381781781">"இன்று"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"இன்று <xliff:g id="TIME_INTERVAL">%s</xliff:g> மணிக்கு"</string>
diff --git a/res/values-te-rIN/strings.xml b/res/values-te-rIN/strings.xml
index 21fcf0c..b8741da 100644
--- a/res/values-te-rIN/strings.xml
+++ b/res/values-te-rIN/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"డిఫాల్ట్గా సెట్ చేయి"</string>
<string name="clear_default" msgid="7193185801596678067">"డిఫాల్ట్ను క్లియర్ చేయి"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"వచనం కాపీ చేయబడింది"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"మీ మార్పులను విస్మరించి, సవరణ నుండి నిష్క్రమించాలా?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"విస్మరించు"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"సవరణను కొనసాగించు"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"పరిచయాలను శోధించండి"</string>
<string name="title_edit_group" msgid="8602752287270586734">"పరిచయాలను తీసివేయండి"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"పరిచయం"</string>
<string name="send_message" msgid="8938418965550543196">"సందేశాన్ని పంపండి"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"వ్యక్తిగత కాపీని సృష్టిస్తోంది..."</string>
- <string name="yesterday" msgid="6840858548955018569">"నిన్న"</string>
<string name="tomorrow" msgid="6241969467795308581">"రేపు"</string>
<string name="today" msgid="8041090779381781781">"ఈ రోజు"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"ఈ రోజు <xliff:g id="TIME_INTERVAL">%s</xliff:g>కి"</string>
diff --git a/res/values-th/strings.xml b/res/values-th/strings.xml
index c55f8ce..3db43e2 100644
--- a/res/values-th/strings.xml
+++ b/res/values-th/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"ตั้งเป็นค่าเริ่มต้น"</string>
<string name="clear_default" msgid="7193185801596678067">"ล้างจากค่าเริ่มต้น"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"คัดลอกข้อความแล้ว"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"ยกเลิกการเปลี่ยนแปลงและออกจากการแก้ไขใช่ไหม"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"ยกเลิกการเปลี่ยนแปลงหรือไม่"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"ยกเลิก"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"แก้ไขต่อ"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"ยกเลิก"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"ค้นหารายชื่อติดต่อ"</string>
<string name="title_edit_group" msgid="8602752287270586734">"นำรายชื่อติดต่อออก"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"เกี่ยวกับ"</string>
<string name="send_message" msgid="8938418965550543196">"ส่งข้อความ"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"กำลังสร้างสำเนาส่วนบุคคล..."</string>
- <string name="yesterday" msgid="6840858548955018569">"เมื่อวานนี้"</string>
<string name="tomorrow" msgid="6241969467795308581">"พรุ่งนี้"</string>
<string name="today" msgid="8041090779381781781">"วันนี้"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"วันนี้เวลา <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-tl/strings.xml b/res/values-tl/strings.xml
index 5217780..4ad7eb3 100644
--- a/res/values-tl/strings.xml
+++ b/res/values-tl/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Itakda ang default"</string>
<string name="clear_default" msgid="7193185801596678067">"I-clear ang default"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Kinopya ang teksto"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Gusto mo bang i-discard ang iyong mga pagbabago at huminto sa pag-e-edit?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"I-discard"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Patuloy na mag-edit"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Maghanap ng mga contact"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Alisin ang mga contact"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Tungkol dito"</string>
<string name="send_message" msgid="8938418965550543196">"Magpadala ng mensahe"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Lumilikha ng personal na kopya…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Kahapon"</string>
<string name="tomorrow" msgid="6241969467795308581">"Bukas"</string>
<string name="today" msgid="8041090779381781781">"Ngayon"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Ngayong <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-tr/strings.xml b/res/values-tr/strings.xml
index eb98147..b414f70 100644
--- a/res/values-tr/strings.xml
+++ b/res/values-tr/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Varsayılan olarak ayarla"</string>
<string name="clear_default" msgid="7193185801596678067">"Varsayılanları temizle"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Metin kopyalandı"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Değişiklikleriniz yok sayılsın ve düzenlemeden çıkılsın mı?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Yok say"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Düzenlemeye devam et"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Kişilerde arayın"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Kişileri kaldır"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Hakkında"</string>
<string name="send_message" msgid="8938418965550543196">"İleti gönder"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Kişisel kopya oluşturuluyor..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Dün"</string>
<string name="tomorrow" msgid="6241969467795308581">"Yarın"</string>
<string name="today" msgid="8041090779381781781">"Bugün"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Bugün şu saatler arasında: <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-uk/strings.xml b/res/values-uk/strings.xml
index e5a0b14..856e335 100644
--- a/res/values-uk/strings.xml
+++ b/res/values-uk/strings.xml
@@ -246,9 +246,9 @@
<string name="set_default" msgid="4417505153468300351">"Установити за умовчанням"</string>
<string name="clear_default" msgid="7193185801596678067">"Очистити налаштування за умовчанням"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Текст скопійовано"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Відхилити зміни та закінчити редагування?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Відхилити зміни?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Відхилити"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Продовжити редагування"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Скасувати"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Пошук контактів"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Вилучити контакти"</string>
@@ -273,7 +273,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Інформація"</string>
<string name="send_message" msgid="8938418965550543196">"Надіслати повідомлення"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Створення особистої копії..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Учора"</string>
<string name="tomorrow" msgid="6241969467795308581">"Завтра"</string>
<string name="today" msgid="8041090779381781781">"Сьогодні"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Сьогодні о <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-ur-rPK/strings.xml b/res/values-ur-rPK/strings.xml
index adf405f..f4411a3 100644
--- a/res/values-ur-rPK/strings.xml
+++ b/res/values-ur-rPK/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"ڈیفالٹ طے کریں"</string>
<string name="clear_default" msgid="7193185801596678067">"ڈیفالٹ کو صاف کریں"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"متن کاپی ہوگیا"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"اپنی تبدیلیاں مسترد کریں اور ترمیم کرنا چھوڑ دیں؟"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"رد کریں"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"ترمیم کرنا جاری رکھیں"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"رابطے تلاش کریں"</string>
<string name="title_edit_group" msgid="8602752287270586734">"رابطے ہٹائیں"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"تفصیل"</string>
<string name="send_message" msgid="8938418965550543196">"پیغام بھیجیں"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"ایک ذاتی کاپی بنا رہا ہے…"</string>
- <string name="yesterday" msgid="6840858548955018569">"گزشتہ کل"</string>
<string name="tomorrow" msgid="6241969467795308581">"آئندہ کل"</string>
<string name="today" msgid="8041090779381781781">"ﺁﺝ"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"آج بوقت <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-uz-rUZ/strings.xml b/res/values-uz-rUZ/strings.xml
index d140126..c23ad88 100644
--- a/res/values-uz-rUZ/strings.xml
+++ b/res/values-uz-rUZ/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Standart sifatida o‘rnatish"</string>
<string name="clear_default" msgid="7193185801596678067">"Standartni tozalash"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Matndan nuxsa olindi"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"O‘zgarishlar bekor qilinib, chiqib ketilsinmi?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Bekor qilish"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Tahrirlashda davom etish"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Kontaktlarni qidirish"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Kontaktlarni olib tashlash"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Ma’lumot"</string>
<string name="send_message" msgid="8938418965550543196">"Xabar yuborish"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Shaxsiy nusxasi yaratilmoqda…"</string>
- <string name="yesterday" msgid="6840858548955018569">"Kecha"</string>
<string name="tomorrow" msgid="6241969467795308581">"Ertaga"</string>
<string name="today" msgid="8041090779381781781">"Bugun"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Bugun <xliff:g id="TIME_INTERVAL">%s</xliff:g> da"</string>
diff --git a/res/values-vi/strings.xml b/res/values-vi/strings.xml
index 5ffec07..0ebe839 100644
--- a/res/values-vi/strings.xml
+++ b/res/values-vi/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"Đặt mặc định"</string>
<string name="clear_default" msgid="7193185801596678067">"Xóa mặc định"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Đã sao chép văn bản"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Hủy các thay đổi của bạn và thoát chỉnh sửa?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Hủy"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Tiếp tục chỉnh sửa"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Tìm kiếm trong danh bạ"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Xóa liên hệ"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Giới thiệu"</string>
<string name="send_message" msgid="8938418965550543196">"Gửi tin nhắn"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Đang tạo bản sao cá nhân..."</string>
- <string name="yesterday" msgid="6840858548955018569">"Hôm qua"</string>
<string name="tomorrow" msgid="6241969467795308581">"Ngày mai"</string>
<string name="today" msgid="8041090779381781781">"Hôm nay"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Hôm nay lúc <xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-zh-rCN/donottranslate_config.xml b/res/values-zh-rCN/donottranslate_config.xml
new file mode 100644
index 0000000..b357856
--- /dev/null
+++ b/res/values-zh-rCN/donottranslate_config.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+** Copyright 2016, The Android Open Source Project
+**
+** Licensed under the Apache License, Version 2.0 (the "License");
+** you may not use this file except in compliance with the License.
+** You may obtain a copy of the License at
+**
+** http://www.apache.org/licenses/LICENSE-2.0
+**
+** Unless required by applicable law or agreed to in writing, software
+** distributed under the License is distributed on an "AS IS" BASIS,
+** WITHOUT 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>
+ <!-- If true, an option is shown in Display Options UI to choose a sort order -->
+ <bool name="config_sort_order_user_changeable">false</bool>
+
+ <!-- If true, the default sort order is primary (i.e. by given name) -->
+ <bool name="config_default_sort_order_primary">true</bool>
+
+ <!-- If true, an option is shown in Display Options UI to choose a name display order -->
+ <bool name="config_display_order_user_changeable">false</bool>
+
+ <!-- If true, the default sort order is primary (i.e. by given name) -->
+ <bool name="config_default_display_order_primary">true</bool>
+
+ <!-- If true, the order of name fields in the editor is primary (i.e. given name first) -->
+ <bool name="config_editor_field_order_primary">false</bool>
+</resources>
diff --git a/res/values-zh-rCN/strings.xml b/res/values-zh-rCN/strings.xml
index ffafef7..9b7e396 100644
--- a/res/values-zh-rCN/strings.xml
+++ b/res/values-zh-rCN/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"设置默认值"</string>
<string name="clear_default" msgid="7193185801596678067">"清除默认值"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"文本已复制"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"要舍弃您所做的更改并停止修改吗?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"舍弃"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"继续修改"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"搜索联系人"</string>
<string name="title_edit_group" msgid="8602752287270586734">"移除联系人"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"简介"</string>
<string name="send_message" msgid="8938418965550543196">"发送短信"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"正在创建个人副本..."</string>
- <string name="yesterday" msgid="6840858548955018569">"昨天"</string>
<string name="tomorrow" msgid="6241969467795308581">"明天"</string>
<string name="today" msgid="8041090779381781781">"今天"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"今天<xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-zh-rHK/strings.xml b/res/values-zh-rHK/strings.xml
index 03c4931..b95ad5d 100644
--- a/res/values-zh-rHK/strings.xml
+++ b/res/values-zh-rHK/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"設為預設"</string>
<string name="clear_default" msgid="7193185801596678067">"清除預設值"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"文字已複製"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"要捨棄變更並停止編輯嗎?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"捨棄"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"繼續編輯"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"搜尋聯絡人"</string>
<string name="title_edit_group" msgid="8602752287270586734">"移除聯絡人"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"關於"</string>
<string name="send_message" msgid="8938418965550543196">"傳送訊息"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"正在建立個人副本…"</string>
- <string name="yesterday" msgid="6840858548955018569">"昨天"</string>
<string name="tomorrow" msgid="6241969467795308581">"明天"</string>
<string name="today" msgid="8041090779381781781">"今天"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"今天<xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-zh-rTW/donottranslate_config.xml b/res/values-zh-rTW/donottranslate_config.xml
new file mode 100644
index 0000000..b357856
--- /dev/null
+++ b/res/values-zh-rTW/donottranslate_config.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/*
+** Copyright 2016, The Android Open Source Project
+**
+** Licensed under the Apache License, Version 2.0 (the "License");
+** you may not use this file except in compliance with the License.
+** You may obtain a copy of the License at
+**
+** http://www.apache.org/licenses/LICENSE-2.0
+**
+** Unless required by applicable law or agreed to in writing, software
+** distributed under the License is distributed on an "AS IS" BASIS,
+** WITHOUT 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>
+ <!-- If true, an option is shown in Display Options UI to choose a sort order -->
+ <bool name="config_sort_order_user_changeable">false</bool>
+
+ <!-- If true, the default sort order is primary (i.e. by given name) -->
+ <bool name="config_default_sort_order_primary">true</bool>
+
+ <!-- If true, an option is shown in Display Options UI to choose a name display order -->
+ <bool name="config_display_order_user_changeable">false</bool>
+
+ <!-- If true, the default sort order is primary (i.e. by given name) -->
+ <bool name="config_default_display_order_primary">true</bool>
+
+ <!-- If true, the order of name fields in the editor is primary (i.e. given name first) -->
+ <bool name="config_editor_field_order_primary">false</bool>
+</resources>
diff --git a/res/values-zh-rTW/strings.xml b/res/values-zh-rTW/strings.xml
index 58f3aac..fa17add 100644
--- a/res/values-zh-rTW/strings.xml
+++ b/res/values-zh-rTW/strings.xml
@@ -232,9 +232,11 @@
<string name="set_default" msgid="4417505153468300351">"設為預設值"</string>
<string name="clear_default" msgid="7193185801596678067">"清除預設值"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"文字已複製"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"確定要捨棄變更並結束編輯嗎?"</string>
+ <!-- no translation found for cancel_confirmation_dialog_message (9008214737653278989) -->
+ <skip />
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"捨棄"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"繼續編輯"</string>
+ <!-- no translation found for cancel_confirmation_dialog_keep_editing_button (3316573928085916146) -->
+ <skip />
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"搜尋聯絡人"</string>
<string name="title_edit_group" msgid="8602752287270586734">"移除聯絡人"</string>
@@ -259,7 +261,6 @@
<string name="about_card_title" msgid="2920942314212825637">"關於"</string>
<string name="send_message" msgid="8938418965550543196">"傳送簡訊"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"正在建立個人副本…"</string>
- <string name="yesterday" msgid="6840858548955018569">"昨天"</string>
<string name="tomorrow" msgid="6241969467795308581">"明天"</string>
<string name="today" msgid="8041090779381781781">"今天"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"今天<xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values-zu/strings.xml b/res/values-zu/strings.xml
index 81745fd..471b77a 100644
--- a/res/values-zu/strings.xml
+++ b/res/values-zu/strings.xml
@@ -232,9 +232,9 @@
<string name="set_default" msgid="4417505153468300351">"Hlela okuzenzakalelayo"</string>
<string name="clear_default" msgid="7193185801596678067">"Sula okuzenzakalelayo"</string>
<string name="toast_text_copied" msgid="5143776250008541719">"Umbhalo okopishiwe"</string>
- <string name="cancel_confirmation_dialog_message" msgid="5058226498605989285">"Lahla izinguquko zakho bese uyeke ukuhlela?"</string>
+ <string name="cancel_confirmation_dialog_message" msgid="9008214737653278989">"Lahla ushintsho?"</string>
<string name="cancel_confirmation_dialog_cancel_editing_button" msgid="3057023972074640671">"Lahla"</string>
- <string name="cancel_confirmation_dialog_keep_editing_button" msgid="7737724111972855348">"Qhubeka uhlela"</string>
+ <string name="cancel_confirmation_dialog_keep_editing_button" msgid="3316573928085916146">"Khansela"</string>
<string name="call_type_and_date" msgid="747163730039311423">"<xliff:g id="CALL_TYPE">%1$s</xliff:g> <xliff:g id="CALL_SHORT_DATE">%2$s</xliff:g>"</string>
<string name="enter_contact_name" msgid="4594274696120278368">"Sesha oxhumana nabo"</string>
<string name="title_edit_group" msgid="8602752287270586734">"Susa oxhumana nabo"</string>
@@ -259,7 +259,6 @@
<string name="about_card_title" msgid="2920942314212825637">"Mayelana"</string>
<string name="send_message" msgid="8938418965550543196">"Thumela umlayezo"</string>
<string name="toast_making_personal_copy" msgid="288549957278065542">"Idala ikhophi yomuntu siqu"</string>
- <string name="yesterday" msgid="6840858548955018569">"Izolo"</string>
<string name="tomorrow" msgid="6241969467795308581">"Kusasa"</string>
<string name="today" msgid="8041090779381781781">"Namhlanje"</string>
<string name="today_at_time_fmt" msgid="605665249491030460">"Namhlanje ngo-<xliff:g id="TIME_INTERVAL">%s</xliff:g>"</string>
diff --git a/res/values/animation_constants.xml b/res/values/animation_constants.xml
new file mode 100644
index 0000000..39f6ba6
--- /dev/null
+++ b/res/values/animation_constants.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2014 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT 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>
+ <integer name="floating_action_button_animation_duration">175</integer>
+</resources>
diff --git a/res/values/attrs.xml b/res/values/attrs.xml
index 86c46fc..e979611 100644
--- a/res/values/attrs.xml
+++ b/res/values/attrs.xml
@@ -49,4 +49,71 @@
<attr name="layout_widePaddingRight" format="dimension"/>
</declare-styleable>
+ <declare-styleable name="Theme">
+ <attr name="android:textColorSecondary" />
+ </declare-styleable>
+
+ <declare-styleable name="ContactsDataKind">
+ <!-- Mime-type handled by this mapping. -->
+ <attr name="android:mimeType" />
+ <!-- Icon used to represent data of this kind. -->
+ <attr name="android:icon" />
+ <!-- Column in data table that summarizes this data. -->
+ <attr name="android:summaryColumn" />
+ <!-- Column in data table that contains details for this data. -->
+ <attr name="android:detailColumn" />
+ <!-- Flag indicating that detail should be built from SocialProvider. -->
+ <attr name="android:detailSocialSummary" />
+ <!-- Resource representing the term "All Contacts" (e.g. "All Friends" or
+ "All connections"). Optional (Default is "All Contacts"). -->
+ <attr name="android:allContactsName" />
+ </declare-styleable>
+
+ <declare-styleable name="ContactListItemView">
+ <attr name="list_item_height" format="dimension"/>
+ <attr name="list_section_header_height" format="dimension"/>
+ <attr name="activated_background" format="reference"/>
+ <attr name="section_header_background" format="reference"/>
+ <attr name="list_item_padding_top" format="dimension"/>
+ <attr name="list_item_padding_right" format="dimension"/>
+ <attr name="list_item_padding_bottom" format="dimension"/>
+ <attr name="list_item_padding_left" format="dimension"/>
+ <attr name="list_item_gap_between_image_and_text" format="dimension"/>
+ <attr name="list_item_gap_between_indexer_and_image" format="dimension"/>
+ <attr name="list_item_gap_between_label_and_data" format="dimension"/>
+ <attr name="list_item_presence_icon_margin" format="dimension"/>
+ <attr name="list_item_presence_icon_size" format="dimension"/>
+ <attr name="list_item_photo_size" format="dimension"/>
+ <attr name="list_item_profile_photo_size" format="dimension"/>
+ <attr name="list_item_prefix_highlight_color" format="color"/>
+ <attr name="list_item_background_color" format="color"/>
+ <attr name="list_item_header_text_indent" format="dimension"/>
+ <attr name="list_item_header_text_color" format="color"/>
+ <attr name="list_item_header_text_size" format="dimension"/>
+ <attr name="list_item_header_height" format="dimension"/>
+ <attr name="list_item_name_text_color" format="color"/>
+ <attr name="list_item_name_text_size" format="dimension"/>
+ <attr name="list_item_text_indent" format="dimension"/>
+ <attr name="list_item_text_offset_top" format="dimension"/>
+ <attr name="list_item_avatar_offset_top" format="dimension"/>
+ <attr name="list_item_data_width_weight" format="integer"/>
+ <attr name="list_item_label_width_weight" format="integer"/>
+ <attr name="list_item_video_call_icon_size" format="dimension"/>
+ <attr name="list_item_video_call_icon_margin" format="dimension"/>
+ </declare-styleable>
+
+ <declare-styleable name="ContactBrowser">
+ <attr name="contact_browser_list_padding_left" format="dimension"/>
+ <attr name="contact_browser_list_padding_right" format="dimension"/>
+ <attr name="contact_browser_background" format="reference"/>
+ </declare-styleable>
+
+ <declare-styleable name="ProportionalLayout">
+ <attr name="direction" format="string"/>
+ <attr name="ratio" format="float"/>
+ </declare-styleable>
+
+ <declare-styleable name="Favorites">
+ <attr name="favorites_padding_bottom" format="dimension"/>
+ </declare-styleable>
</resources>
diff --git a/res/values/colors.xml b/res/values/colors.xml
index f5bf4cc..37ea0ba 100644
--- a/res/values/colors.xml
+++ b/res/values/colors.xml
@@ -83,4 +83,178 @@
<!-- Color of hamburger icon in promo -->
<color name="hamburger_feature_highlight_inner_color">#00ffffff</color>
+
+ <!-- Background color corresponding to the holo list 9-patch. -->
+ <color name="holo_list_background_color">#eeeeee</color>
+
+ <color name="focus_color">#44ff0000</color>
+
+ <!-- Color of ripples used for views with dark backgrounds -->
+ <color name="ripple_material_dark">#a0ffffff</color>
+
+ <!-- Divider color for header separator -->
+ <color name="primary_text_color">#363636</color>
+
+ <color name="secondary_text_color">@color/dialtacts_secondary_text_color</color>
+
+ <!-- Text color for section header. -->
+ <color name="section_header_text_color">@color/dialtacts_theme_color</color>
+
+ <!-- Divider color for header separator -->
+ <color name="main_header_separator_color">#AAAAAA</color>
+
+ <!-- Divider color for header separator -->
+ <color name="secondary_header_separator_color">#D0D0D0</color>
+
+ <!-- Color of the theme of the People app -->
+ <color name="people_app_theme_color">#363636</color>
+
+ <!-- Color of the theme of the Dialer app -->
+ <color name="dialtacts_theme_color">#0288d1</color>
+
+ <!-- Color of image view placeholder. -->
+ <color name="image_placeholder">#DDDDDD</color>
+
+ <!-- Primary text color in the Phone app -->
+ <color name="dialtacts_primary_text_color">#333333</color>
+
+ <!-- Secondary text color in the Phone app -->
+ <color name="dialtacts_secondary_text_color">#737373</color>
+
+ <!-- Color of the semi-transparent shadow box on contact tiles -->
+ <color name="contact_tile_shadow_box_color">#7F000000</color>
+
+ <!-- Color of the status message for starred contacts in the People app -->
+ <color name="people_contact_tile_status_color">#CCCCCC</color>
+
+ <color name="shortcut_overlay_text_background">#7f000000</color>
+
+ <color name="textColorIconOverlay">#fff</color>
+ <color name="textColorIconOverlayShadow">#000</color>
+
+ <!-- Background colors for LetterTileDrawables. This set of colors is a subset of
+ https://spec.googleplex.com/quantumpalette#extended which passes Google Accessibility
+ Requirements for the color in question on white with >= 3.0 contrast. We used
+ http://leaverou.github.io/contrast-ratio/#white-on-%23db4437 to double-check the contrast.
+
+ These colors are also used by MaterialColorMapUtils to generate primary activity colors.
+ -->
+ <array name="letter_tile_colors">
+ <item>#DB4437</item>
+ <item>#E91E63</item>
+ <item>#9C27B0</item>
+ <item>#673AB7</item>
+ <item>#3F51B5</item>
+ <item>#4285F4</item>
+ <item>#039BE5</item>
+ <item>#0097A7</item>
+ <item>#009688</item>
+ <item>#0F9D58</item>
+ <item>#689F38</item>
+ <item>#EF6C00</item>
+ <item>#FF5722</item>
+ <item>#757575</item>
+ </array>
+
+ <!-- Darker versions of letter_tile_colors, two shades darker. These colors are used
+ for settings secondary activity colors. -->
+ <array name="letter_tile_colors_dark">
+ <item>#C53929</item>
+ <item>#C2185B</item>
+ <item>#7B1FA2</item>
+ <item>#512DA8</item>
+ <item>#303F9F</item>
+ <item>#3367D6</item>
+ <item>#0277BD</item>
+ <item>#006064</item>
+ <item>#00796B</item>
+ <item>#0B8043</item>
+ <item>#33691E</item>
+ <item>#E65100</item>
+ <item>#E64A19</item>
+ <item>#424242</item>
+ </array>
+
+ <!-- The default color used for tinting photos when no color can be extracted via Palette,
+ this is Blue Grey 500 -->
+ <color name="quickcontact_default_photo_tint_color">#607D8B</color>
+ <!-- The default secondary color when no color can be extracted via Palette,
+ this is Blue Grey 700 -->
+ <color name="quickcontact_default_photo_tint_color_dark">#455A64</color>
+
+
+ <color name="letter_tile_default_color">#cccccc</color>
+
+ <color name="letter_tile_font_color">#ffffff</color>
+
+ <!-- Background color of action bars. Ensure this stays in sync with packages/Telephony
+ actionbar_background_color. -->
+ <color name="actionbar_background_color">#0fc6dc</color>
+ <!-- Color for icons in the actionbar -->
+ <color name="actionbar_icon_color">#ffffff</color>
+ <!-- Darker version of the actionbar color. Used for the status bar and navigation bar colors. -->
+ <color name="actionbar_background_color_dark">#008aa1</color>
+
+ <color name="tab_ripple_color">@color/tab_accent_color</color>
+ <color name="tab_accent_color">#ffffff</color>
+ <color name="tab_selected_underline_color">@color/tab_accent_color</color>
+ <color name="tab_unread_count_background_color">#700f4b70</color>
+
+ <!-- Color of the title to the Frequently Contacted section -->
+ <color name="frequently_contacted_title_color">@color/actionbar_background_color</color>
+
+ <!-- Color of action bar text. Ensure this stays in sync with packages/Telephony
+ phone_settings_actionbar_text_color-->
+ <color name="actionbar_text_color">#ffffff</color>
+ <!-- 54% black for icons -->
+ <color name="actionbar_icon_color_grey">#8C000000</color>
+ <!-- 87% black for actionbar text -->
+ <color name="actionbar_text_color_black">#DF000000</color>
+ <!-- Solid grey for status bar overlay-->
+ <color name="actionbar_color_grey_solid">#777777</color>
+ <color name="actionbar_unselected_text_color">#a6ffffff</color>
+
+ <!-- Text color of the search box text as entered by user -->
+ <color name="searchbox_text_color">#000000</color>
+ <!-- Background color of the search box -->
+ <color name="searchbox_background_color">#ffffff</color>
+
+ <color name="searchbox_hint_text_color">#66000000</color>
+ <color name="searchbox_icon_tint">@color/searchbox_hint_text_color</color>
+
+ <color name="search_shortcut_icon_color">@color/dialtacts_theme_color</color>
+
+ <!-- Color of the background of the contact detail and editor pages -->
+ <color name="background_primary">#f9f9f9</color>
+ <color name="contact_all_list_background_color">#FFFFFF</color>
+
+ <!-- Text color used for character counter when the max limit has been exceeded -->
+ <color name="call_subject_limit_exceeded">#d1041c</color>
+
+ <!-- Tint color for the call subject history icon. -->
+ <color name="call_subject_history_icon">#000000</color>
+
+ <!-- Divider line on the call subject dialog. -->
+ <color name="call_subject_divider">#d8d8d8</color>
+
+ <!-- Text color for the SEND & CALL button on the call subject dialog. -->
+ <color name="call_subject_button">#00c853</color>
+
+ <!-- Background color for the call subject history view. -->
+ <color name="call_subject_history_background">#ffffff</color>
+ <color name="search_video_call_icon_tint">@color/searchbox_hint_text_color</color>
+
+ <!-- Text color for an action in a snackbar. -->
+ <color name="snackbar_action_text">#40c4ff</color>
+ <!-- Background color for a snackbar. -->
+ <color name="snackbar_background">#333333</color>
+
+ <!-- Color of account/custom filters -->
+ <color name="account_filter_text_color">@color/actionbar_text_color_black</color>
+ <color name="custom_filter_divider">#dbdbdb</color>
+
+ <color name="material_star_pink">#f50057</color>
+
+ <!-- Primary text color in Contacts app -->
+ <color name="contacts_text_color">#333333</color>
</resources>
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index a73e449..d355021 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -321,4 +321,171 @@
<!-- Margin b/w add account button and import contacts button for no account empty view -->
<dimen name="contacts_no_account_buttons_margin">8dp</dimen>
+
+ <!-- Padding between the action bar's bottom edge and the first header
+ in contacts/group lists. -->
+ <dimen name="list_header_extra_top_padding">0dip</dimen>
+
+ <dimen name="list_section_divider_min_height">32dip</dimen>
+
+ <dimen name="directory_header_extra_top_padding">18dp</dimen>
+ <dimen name="directory_header_extra_bottom_padding">8dp</dimen>
+ <dimen name="directory_header_left_padding">16dp</dimen>
+
+ <!-- Horizontal padding in between contact tiles -->
+ <dimen name="contact_tile_divider_padding">23dip</dimen>
+ <!-- Horizontal whitespace (both padding and margin) before the first tile and after the last tile -->
+ <dimen name="contact_tile_start_end_whitespace">16dip</dimen>
+
+ <!-- Left and right padding for a contact detail item -->
+ <dimen name="detail_item_side_margin">16dip</dimen>
+
+ <!-- ContactTile Layouts -->
+ <!--
+ Use sp instead of dip so that the shadowbox heights can all scale uniformly
+ when the font size is scaled for accessibility purposes
+ -->
+ <dimen name="contact_tile_shadowbox_height">48sp</dimen>
+
+ <!-- For contact filter setting screens -->
+ <dimen name="contact_filter_left_margin">8dp</dimen>
+ <dimen name="contact_filter_right_margin">16dip</dimen>
+ <dimen name="contact_filter_item_min_height">48dip</dimen>
+ <dimen name="contact_filter_icon_size">32dip</dimen>
+ <dimen name="contact_filter_list_item_height">56dp</dimen>
+ <dimen name="contact_filter_list_item_padding_start">16dp</dimen>
+ <!-- contact_filter_indicator is the arrow in expandable list view -->
+ <dimen name="contact_filter_indicator_padding_start">6dp</dimen>
+ <dimen name="contact_filter_indicator_padding_end">46dp</dimen>
+ <dimen name="contact_filter_action_button_width">72dp</dimen>
+
+ <!-- Padding to be used between a visible scrollbar and the contact list -->
+ <dimen name="list_visible_scrollbar_padding">32dip</dimen>
+
+ <dimen name="contact_browser_list_header_icon_left_margin">16dp</dimen>
+ <dimen name="contact_browser_list_header_icon_right_margin">14dp</dimen>
+ <dimen name="contact_browser_list_header_icon_left_margin_alt">18dp</dimen>
+ <dimen name="contact_browser_list_header_icon_right_margin_alt">16dp</dimen>
+ <dimen name="contact_browser_list_header_left_margin">16dip</dimen>
+ <dimen name="contact_browser_list_header_right_margin">@dimen/list_visible_scrollbar_padding</dimen>
+ <dimen name="contact_browser_list_item_text_indent">8dip</dimen>
+ <dimen name="contact_browser_list_header_height">48dp</dimen>
+ <dimen name="contact_browser_list_header_icon_size">24dp</dimen>
+ <dimen name="contact_browser_list_header_icon_size_alt">20dp</dimen>
+ <dimen name="contact_browser_list_header_text_margin">10dp</dimen>
+ <!-- Width of a contact list item section header. -->
+ <dimen name="contact_list_section_header_width">56dp</dimen>
+
+ <!-- Size of the shortcut icon. 0dip means: use the system default -->
+ <dimen name="shortcut_icon_size">0dip</dimen>
+
+ <!-- Text size of shortcut icon overlay text -->
+ <dimen name="shortcut_overlay_text_size">12dp</dimen>
+
+ <!-- Extra vertical padding for darkened background behind shortcut icon overlay text -->
+ <dimen name="shortcut_overlay_text_background_padding">1dp</dimen>
+
+ <!-- Width of height of an icon from a third-party app in the networks section of the contact card. -->
+ <dimen name="detail_network_icon_size">40dp</dimen>
+
+ <!-- Empty message margins -->
+ <dimen name="empty_message_top_margin">48dip</dimen>
+
+ <!-- contact browser list margins -->
+ <dimen name="contact_browser_list_item_text_size">16sp</dimen>
+ <dimen name="contact_browser_list_item_photo_size">40dp</dimen>
+ <dimen name="contact_browser_list_item_gap_between_image_and_text">15dp</dimen>
+ <dimen name="contact_browser_list_item_gap_between_indexer_and_image">16dp</dimen>
+ <dimen name="contact_browser_list_top_margin">12dp</dimen>
+
+ <!-- Dimensions for "No contacts" string in PhoneFavoriteFragment for the All contacts
+ with phone numbers section
+ -->
+ <dimen name="contact_phone_list_empty_description_size">20sp</dimen>
+ <dimen name="contact_phone_list_empty_description_padding">10dip</dimen>
+
+ <!-- Dimensions for contact letter tiles -->
+ <dimen name="tile_letter_font_size">40dp</dimen>
+ <dimen name="tile_letter_font_size_small">20dp</dimen>
+ <dimen name="tile_divider_width">1dp</dimen>
+ <item name="letter_to_tile_ratio" type="dimen">67%</item>
+
+ <!-- Height of the floating action button -->
+ <dimen name="floating_action_button_height">56dp</dimen>
+ <!-- Width of the floating action button -->
+ <dimen name="floating_action_button_width">56dp</dimen>
+ <!-- Corner radius of the floating action button -->
+ <dimen name="floating_action_button_radius">28dp</dimen>
+ <!-- Z translation of the floating action button -->
+ <dimen name="floating_action_button_translation_z">8dp</dimen>
+ <!-- Padding to be applied to the bottom of lists to make space for the floating action
+ button -->
+ <dimen name="floating_action_button_list_bottom_padding">88dp</dimen>
+ <!-- Right margin of the floating action button -->
+ <dimen name="floating_action_button_margin_right">16dp</dimen>
+ <!-- Bottom margin of the floating action button -->
+ <dimen name="floating_action_button_margin_bottom">16dp</dimen>
+ <!-- Offset of bottom margin of the floating action button used when dialpad is up -->
+ <dimen name="floating_action_button_dialpad_margin_bottom_offset">4dp</dimen>
+
+ <!-- Height of the selection indicator of a tab. -->
+ <dimen name="tab_selected_underline_height">2dp</dimen>
+ <!-- Size of text in tabs. -->
+ <dimen name="tab_text_size">14sp</dimen>
+ <dimen name="tab_elevation">2dp</dimen>
+ <dimen name="tab_unread_count_background_size">16dp</dimen>
+ <dimen name="tab_unread_count_background_radius">2dp</dimen>
+ <dimen name="tab_unread_count_margin_left">10dp</dimen>
+ <dimen name="tab_unread_count_margin_top">2dp</dimen>
+ <dimen name="tab_unread_count_text_size">12sp</dimen>
+ <dimen name="tab_unread_count_text_padding">2dp</dimen>
+
+ <!-- Padding around the icon in the search box. -->
+ <dimen name="search_box_icon_margin">4dp</dimen>
+ <!-- Size of the icon (voice search, back arrow) in the search box. -->
+ <dimen name="search_box_icon_size">56dp</dimen>
+ <!-- Size of the close icon.-->
+ <dimen name="search_box_close_icon_size">56dp</dimen>
+ <!-- Padding around the close button. It's visible size without padding is 24dp. -->
+ <dimen name="search_box_close_icon_padding">16dp</dimen>
+ <!-- End margin of the back arrow icon in the search box -->
+ <dimen name="search_box_navigation_icon_margin">8dp</dimen>
+ <!-- Left margin of the text field in the search box. -->
+ <dimen name="search_box_text_left_margin">8dp</dimen>
+ <!-- Search box text size -->
+ <dimen name="search_text_size">16sp</dimen>
+
+ <item name="close_icon_alpha" format="float" type="dimen">0.54</item>
+
+ <!-- Size of the close icon in selection bar.-->
+ <dimen name="selection_bar_close_icon_size">56dp</dimen>
+
+ <!-- Top margin for the Frequently Contacted section title -->
+ <dimen name="frequently_contacted_title_top_margin_when_first_row">16dp</dimen>
+ <!-- Top margin for the Frequently Contacted section title, when the title is the first
+ item in the list -->
+ <dimen name="frequently_contacted_title_top_margin">57dp</dimen>
+
+ <dimen name="frequently_contacted_title_text_size">24sp</dimen>
+
+ <!-- Size of icon for contacts number shortcuts -->
+ <dimen name="search_shortcut_radius">40dp</dimen>
+
+ <dimen name="contact_list_card_elevation">2dp</dimen>
+
+ <!-- Padding used around the periphery of the call subject dialog, as well as in between the
+ items. -->
+ <dimen name="call_subject_dialog_margin">20dp</dimen>
+ <!-- Padding used between lines of text in the call subject dialog. -->
+ <dimen name="call_subject_dialog_between_line_margin">8dp</dimen>
+ <!-- Size of the contact photo in the call subject dialog. -->
+ <dimen name="call_subject_dialog_contact_photo_size">50dp</dimen>
+ <!-- Margin above the edit text in the call subject dialog. -->
+ <dimen name="call_subject_dialog_edit_spacing">60dp</dimen>
+ <!-- Size of primary text in the call subject dialog. -->
+ <dimen name="call_subject_dialog_primary_text_size">16sp</dimen>
+ <!-- Size of secondary text in the call subject dialog. -->
+ <dimen name="call_subject_dialog_secondary_text_size">14sp</dimen>
+ <!-- Row padding for call subject history items. -->
+ <dimen name="call_subject_history_item_padding">15dp</dimen>
</resources>
diff --git a/res/values/donottranslate_config.xml b/res/values/donottranslate_config.xml
index bfe7880..8668159 100644
--- a/res/values/donottranslate_config.xml
+++ b/res/values/donottranslate_config.xml
@@ -39,4 +39,81 @@
<!-- File Authority for AOSP Contacts files -->
<string name="contacts_file_provider_authority">com.android.contacts.files</string>
+ <!-- Flag indicating whether Contacts app is allowed to import contacts -->
+ <bool name="config_allow_import_from_vcf_file">true</bool>
+
+ <!-- If true, an option is shown in Display Options UI to choose a sort order -->
+ <bool name="config_sort_order_user_changeable">true</bool>
+
+ <!-- If true, the default sort order is primary (i.e. by given name) -->
+ <bool name="config_default_sort_order_primary">true</bool>
+
+ <!-- If true, an option is shown in Display Options UI to choose a name display order -->
+ <bool name="config_display_order_user_changeable">true</bool>
+
+ <!-- If true, the default sort order is primary (i.e. by given name) -->
+ <bool name="config_default_display_order_primary">true</bool>
+
+ <!-- If true, the order of name fields in the editor is primary (i.e. given name first) -->
+ <bool name="config_editor_field_order_primary">true</bool>
+
+ <!-- If true, an option is shown in Display Options UI to choose a default account -->
+ <bool name="config_default_account_user_changeable">true</bool>
+
+ <!-- Contacts preferences key for contact editor default account -->
+ <string name="contact_editor_default_account_key">ContactEditorUtils_default_account</string>
+
+ <!-- Contacts preferences key for contact editor anything saved -->
+ <string name="contact_editor_anything_saved_key">ContactEditorUtils_anything_saved</string>
+
+ <!-- The type of VCard for export. If you want to let the app emit vCard which is
+ specific to some vendor (like DoCoMo), specify this type (e.g. "docomo") -->
+ <string name="config_export_vcard_type" translatable="false">default</string>
+
+ <!-- The type of vcard for improt. If the vcard importer cannot guess the exact type
+ of a vCard type, the improter uses this type. -->
+ <string name="config_import_vcard_type" translatable="false">default</string>
+
+ <!-- Prefix of exported VCard file -->
+ <string name="config_export_file_prefix" translatable="false"></string>
+
+ <!-- Suffix of exported VCard file. Attached before an extension -->
+ <string name="config_export_file_suffix" translatable="false"></string>
+
+ <!-- Extension for exported VCard files -->
+ <string name="config_export_file_extension">vcf</string>
+
+ <!-- The filename that is suggested that users use when exporting vCards. Should include the .vcf extension. -->
+ <string name="exporting_vcard_filename" translatable="false">contacts.vcf</string>
+
+ <!-- Minimum number of exported VCard file index -->
+ <integer name="config_export_file_min_index">1</integer>
+
+ <!-- Maximum number of exported VCard file index -->
+ <integer name="config_export_file_max_index">99999</integer>
+
+ <!-- The list (separated by ',') of extensions should be checked in addition to
+ config_export_extension. e.g. If "aaa" is added to here and 00001.vcf and 00002.aaa
+ exist in a target directory, 00003.vcf becomes a next file name candidate.
+ Without this configuration, 00002.vcf becomes the candidate.-->
+ <string name="config_export_extensions_to_consider" translatable="false"></string>
+
+ <!-- If true, enable the "import contacts from SIM" feature if the device
+ has an appropriate SIM or ICC card.
+ Setting this flag to false in a resource overlay allows you to
+ entirely disable SIM import on a per-product basis. -->
+ <bool name="config_allow_sim_import">true</bool>
+
+ <!-- Flag indicating whether Contacts app is allowed to export contacts -->
+ <bool name="config_allow_export">true</bool>
+
+ <!-- Flag indicating whether Contacts app is allowed to share contacts with devices outside -->
+ <bool name="config_allow_share_contacts">true</bool>
+
+ <string name="pref_build_version_key">pref_build_version</string>
+ <string name="pref_open_source_licenses_key">pref_open_source_licenses</string>
+ <string name="pref_privacy_policy_key">pref_privacy_policy</string>
+ <string name="pref_terms_of_service_key">pref_terms_of_service</string>
+
+ <string name="star_sign">\u2605</string>
</resources>
diff --git a/res/values/ids.xml b/res/values/ids.xml
index f5649d9..0808496 100644
--- a/res/values/ids.xml
+++ b/res/values/ids.xml
@@ -52,4 +52,29 @@
<!-- Menu group ID for the contact filters -->
<item type="id" name="nav_filters_items" />
+
+ <!-- For vcard.ImportVCardActivity -->
+ <item type="id" name="dialog_cache_vcard"/>
+ <item type="id" name="dialog_error_with_message"/>
+
+ <!-- For vcard.CancelActivity -->
+ <item type="id" name="dialog_cancel_confirmation"/>
+ <item type="id" name="dialog_cancel_failed"/>
+
+ <!-- For ExportVCardActivity -->
+ <item type="id" name="dialog_exporting_vcard"/>
+ <item type="id" name="dialog_fail_to_export_with_reason"/>
+
+ <!-- For Debug Purpose -->
+ <item type="id" name="cliv_name_textview"/>
+ <item type="id" name="cliv_phoneticname_textview"/>
+ <item type="id" name="cliv_label_textview"/>
+ <item type="id" name="cliv_data_view"/>
+
+ <!-- For tag ids used by ContactPhotoManager to tag views with contact details -->
+ <item type="id" name="tag_display_name"/>
+ <item type="id" name="tag_identifier"/>
+ <item type="id" name="tag_contact_type"/>
+
+ <item type="id" name="menu_save"/>
</resources>
diff --git a/res/values/integers.xml b/res/values/integers.xml
index 60034e5..9ef7810 100644
--- a/res/values/integers.xml
+++ b/res/values/integers.xml
@@ -36,4 +36,27 @@
<!-- Top margin ratio for the image for empty contacts view-->
<integer name="contacts_no_account_empty_image_margin_divisor">2</integer>
+
+ <!-- Determines the number of columns in a ContactTileRow in the favorites tab -->
+ <integer name="contact_tile_column_count_in_favorites">2</integer>
+ <integer name="contact_tile_column_count_in_favorites_new">3</integer>
+
+ <!-- The number of characters in the snippet before we need to tokenize and ellipse. -->
+ <integer name="snippet_length_before_tokenize">30</integer>
+
+ <!-- Layout weight of space elements in contact list view.
+ Default to 0 to indicate no padding-->
+ <integer name="contact_list_space_layout_weight">0</integer>
+ <!-- Layout weight of card in contact list view.
+ Default to 0 to indicate no padding -->
+ <integer name="contact_list_card_layout_weight">0</integer>
+
+ <!-- Duration of the animations on the call subject dialog. -->
+ <integer name="call_subject_animation_duration">250</integer>
+
+ <!-- A big number to make sure "About contacts" always showing at the bottom of Settings.-->
+ <integer name="about_contacts_order_number">100</integer>
+
+ <!-- Duration of the animations when a contact list loads. -->
+ <integer name="lists_on_load_animation_duration">190</integer>
</resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index d6f8239..4fe3edc 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -751,8 +751,7 @@
<!-- Toast that appears when you are copying a directory contact into your personal contacts -->
<string name="toast_making_personal_copy">Creating a personal copy…</string>
- <!-- Timestamp string for interactions from yesterday. [CHAR LIMIT=40] -->
- <string name="yesterday">Yesterday</string>
+ <!-- Timestamp string for interactions from tomorrow. [CHAR LIMIT=40] -->
<string name="tomorrow">Tomorrow</string>
<!-- Timestamp string for interactions from today. [CHAR LIMIT=40] -->
<string name="today">Today</string>
@@ -964,4 +963,834 @@
<string name="hamburger_feature_highlight_header">Organize your list</string>
<!-- The body text for hamburger promo [CHAR LIMIT=200]-->
<string name="hamburger_feature_highlight_body">Clean up duplicates & group contacts by label</string>
+
+ <!-- Toast shown when text is copied to the clipboard [CHAR LIMIT=64] -->
+ <string name="toast_text_copied">Text copied</string>
+ <!-- Option displayed in context menu to copy long pressed item to clipboard [CHAR LIMIT=64] -->
+ <string name="copy_text">Copy to clipboard</string>
+
+ <!-- Action string for calling a custom phone number -->
+ <string name="call_custom">Call
+ <xliff:g id="custom">%s</xliff:g>
+ </string>
+ <!-- Action string for calling a home phone number -->
+ <string name="call_home">Call home</string>
+ <!-- Action string for calling a mobile phone number -->
+ <string name="call_mobile">Call mobile</string>
+ <!-- Action string for calling a work phone number -->
+ <string name="call_work">Call work</string>
+ <!-- Action string for calling a work fax phone number -->
+ <string name="call_fax_work">Call work fax</string>
+ <!-- Action string for calling a home fax phone number -->
+ <string name="call_fax_home">Call home fax</string>
+ <!-- Action string for calling a pager phone number -->
+ <string name="call_pager">Call pager</string>
+ <!-- Action string for calling an other phone number -->
+ <string name="call_other">Call</string>
+ <!-- Action string for calling a callback number -->
+ <string name="call_callback">Call callback</string>
+ <!-- Action string for calling a car phone number -->
+ <string name="call_car">Call car</string>
+ <!-- Action string for calling a company main phone number -->
+ <string name="call_company_main">Call company main</string>
+ <!-- Action string for calling a ISDN phone number -->
+ <string name="call_isdn">Call ISDN</string>
+ <!-- Action string for calling a main phone number -->
+ <string name="call_main">Call main</string>
+ <!-- Action string for calling an other fax phone number -->
+ <string name="call_other_fax">Call fax</string>
+ <!-- Action string for calling a radio phone number -->
+ <string name="call_radio">Call radio</string>
+ <!-- Action string for calling a Telex phone number -->
+ <string name="call_telex">Call telex</string>
+ <!-- Action string for calling a TTY/TDD phone number -->
+ <string name="call_tty_tdd">Call TTY/TDD</string>
+ <!-- Action string for calling a work mobile phone number -->
+ <string name="call_work_mobile">Call work mobile</string>
+ <!-- Action string for calling a work pager phone number -->
+ <string name="call_work_pager">Call work pager</string>
+ <!-- Action string for calling an assistant phone number -->
+ <string name="call_assistant">Call
+ <xliff:g id="assistant">%s</xliff:g>
+ </string>
+ <!-- Action string for calling a MMS phone number -->
+ <string name="call_mms">Call MMS</string>
+ <!-- Action string for calling a contact by shortcut -->
+ <string name="call_by_shortcut"><xliff:g id="contact_name">%s</xliff:g> (Call)</string>
+
+ <!-- Action string for sending an SMS to a custom phone number -->
+ <string name="sms_custom">Text
+ <xliff:g id="custom">%s</xliff:g>
+ </string>
+ <!-- Action string for sending an SMS to a home phone number -->
+ <string name="sms_home">Text home</string>
+ <!-- Action string for sending an SMS to a mobile phone number -->
+ <string name="sms_mobile">Text mobile</string>
+ <!-- Action string for sending an SMS to a work phone number -->
+ <string name="sms_work">Text work</string>
+ <!-- Action string for sending an SMS to a work fax phone number -->
+ <string name="sms_fax_work">Text work fax</string>
+ <!-- Action string for sending an SMS to a home fax phone number -->
+ <string name="sms_fax_home">Text home fax</string>
+ <!-- Action string for sending an SMS to a pager phone number -->
+ <string name="sms_pager">Text pager</string>
+ <!-- Action string for sending an SMS to an other phone number -->
+ <string name="sms_other">Text</string>
+ <!-- Action string for sending an SMS to a callback number -->
+ <string name="sms_callback">Text callback</string>
+ <!-- Action string for sending an SMS to a car phone number -->
+ <string name="sms_car">Text car</string>
+ <!-- Action string for sending an SMS to a company main phone number -->
+ <string name="sms_company_main">Text company main</string>
+ <!-- Action string for sending an SMS to a ISDN phone number -->
+ <string name="sms_isdn">Text ISDN</string>
+ <!-- Action string for sending an SMS to a main phone number -->
+ <string name="sms_main">Text main</string>
+ <!-- Action string for sending an SMS to an other fax phone number -->
+ <string name="sms_other_fax">Text fax</string>
+ <!-- Action string for sending an SMS to a radio phone number -->
+ <string name="sms_radio">Text radio</string>
+ <!-- Action string for sending an SMS to a Telex phone number -->
+ <string name="sms_telex">Text telex</string>
+ <!-- Action string for sending an SMS to a TTY/TDD phone number -->
+ <string name="sms_tty_tdd">Text TTY/TDD</string>
+ <!-- Action string for sending an SMS to a work mobile phone number -->
+ <string name="sms_work_mobile">Text work mobile</string>
+ <!-- Action string for sending an SMS to a work pager phone number -->
+ <string name="sms_work_pager">Text work pager</string>
+ <!-- Action string for sending an SMS to an assistant phone number -->
+ <string name="sms_assistant">Text
+ <xliff:g id="assistant">%s</xliff:g>
+ </string>
+ <!-- Action string for sending an SMS to a MMS phone number -->
+ <string name="sms_mms">Text MMS</string>
+ <!-- Action string for sending an SMS to a contact by shortcut -->
+ <string name="sms_by_shortcut"><xliff:g id="contact_name">%s</xliff:g> (Message)</string>
+
+ <!-- Description string for an action button to initiate a video call. -->
+ <string name="description_video_call">Make video call</string>
+
+ <!-- Title of the confirmation dialog for clearing frequents. [CHAR LIMIT=37] -->
+ <string name="clearFrequentsConfirmation_title">Clear frequently contacted?</string>
+
+ <!-- Confirmation dialog for clearing frequents. [CHAR LIMIT=NONE] -->
+ <string name="clearFrequentsConfirmation">You\'ll clear the frequently contacted list in the
+ Contacts and Phone apps, and force email apps to learn your addressing preferences from
+ scratch.
+ </string>
+
+ <!-- Title of the "Clearing frequently contacted" progress-dialog [CHAR LIMIT=35] -->
+ <string name="clearFrequentsProgress_title">Clearing frequently contacted\u2026</string>
+
+ <!-- Used to display as default status when the contact is available for chat [CHAR LIMIT=19] -->
+ <string name="status_available">Available</string>
+
+ <!-- Used to display as default status when the contact is away or idle for chat [CHAR LIMIT=19] -->
+ <string name="status_away">Away</string>
+
+ <!-- Used to display as default status when the contact is busy or Do not disturb for chat [CHAR LIMIT=19] -->
+ <string name="status_busy">Busy</string>
+
+ <!-- Directory partition name (also exists in contacts) -->
+ <string name="contactsList">Contacts</string>
+
+ <!-- The name of the invisible local contact directory -->
+ <string name="local_invisible_directory">Other</string>
+
+ <!-- The label in section header in the contact list for a contact directory [CHAR LIMIT=128] -->
+ <string name="directory_search_label">Directory</string>
+
+ <!-- The label in section header in the contact list for a work contact directory [CHAR LIMIT=128] -->
+ <string name="directory_search_label_work">Work directory</string>
+
+ <!-- The label in section header in the contact list for a local contacts [CHAR LIMIT=128] -->
+ <string name="local_search_label">All contacts</string>
+
+ <!-- Title shown in the search result activity of contacts app while searching. [CHAR LIMIT=20]
+ (also in contacts) -->
+ <string name="search_results_searching">Searching\u2026</string>
+
+ <!-- Displayed at the top of search results indicating that more contacts were found than shown [CHAR LIMIT=64] -->
+ <string name="foundTooManyContacts">More than <xliff:g id="count">%d</xliff:g> found.</string>
+
+ <!-- Displayed at the top of the contacts showing the zero total number of contacts found when "Only contacts with phones" not selected. [CHAR LIMIT=30]
+ (also in contacts) -->
+ <string name="listFoundAllContactsZero">No contacts</string>
+
+ <!-- Displayed at the top of the contacts showing the total number of contacts found when typing search query -->
+ <plurals name="searchFoundContacts">
+ <item quantity="one">1 found</item>
+ <item quantity="other"><xliff:g id="count">%d</xliff:g> found</item>
+ </plurals>
+
+ <!-- String describing the text for photo of a contact in a contacts list.
+
+ Note: AccessibilityServices use this attribute to announce what the view represents.
+ This is especially valuable for views without textual representation like ImageView.
+ -->
+ <string name="description_quick_contact_for">Quick contact for <xliff:g id="name">%1$s</xliff:g></string>
+
+ <!-- Shown as the display name for a person when the name is missing or unknown. [CHAR LIMIT=18]-->
+ <string name="missing_name">(No name)</string>
+
+ <!-- The text displayed on the divider for the Favorites tab in People app indicating that items below it are frequently contacted [CHAR LIMIT = 39] -->
+ <string name="favoritesFrequentContacted">Frequently contacted</string>
+
+ <!-- String describing a contact picture that introduces users to the contact detail screen.
+
+ Used by AccessibilityService to announce the purpose of the button.
+
+ [CHAR LIMIT=NONE]
+ -->
+ <string name="description_view_contact_detail" msgid="2795575601596468581">View contact</string>
+
+ <!-- Contact list filter selection indicating that the list shows all contacts with phone numbers [CHAR LIMIT=64] -->
+ <string name="list_filter_phones">All contacts with phone numbers</string>
+
+ <!-- Contact list filter selection indicating that the list shows all work contacts with phone numbers [CHAR LIMIT=64] -->
+ <string name="list_filter_phones_work">Work profile contacts</string>
+
+ <!-- Button to view the updates from the current group on the group detail page [CHAR LIMIT=25] -->
+ <string name="view_updates_from_group">View updates</string>
+
+ <!-- Title for data source when creating or editing a contact that doesn't
+ belong to a specific account. This contact will only exist on the phone
+ and will not be synced. [CHAR LIMIT=20] -->
+ <string name="account_phone">Device</string>
+
+ <!-- Header that expands to list all name types when editing a structured name of a contact
+ [CHAR LIMIT=20] -->
+ <string name="nameLabelsGroup">Name</string>
+
+ <!-- Header that expands to list all nickname types when editing a nickname of a contact
+ [CHAR LIMIT=20] -->
+ <string name="nicknameLabelsGroup">Nickname</string>
+
+ <!-- Field title for the full name of a contact [CHAR LIMIT=64]-->
+ <string name="full_name">Name</string>
+ <!-- Field title for the given name of a contact -->
+ <string name="name_given">First name</string>
+ <!-- Field title for the family name of a contact -->
+ <string name="name_family">Last name</string>
+ <!-- Field title for the prefix name of a contact -->
+ <string name="name_prefix">Name prefix</string>
+ <!-- Field title for the middle name of a contact -->
+ <string name="name_middle">Middle name</string>
+ <!-- Field title for the suffix name of a contact -->
+ <string name="name_suffix">Name suffix</string>
+
+ <!-- Field title for the phonetic name of a contact [CHAR LIMIT=64]-->
+ <string name="name_phonetic">Phonetic name</string>
+
+ <!-- Field title for the phonetic given name of a contact -->
+ <string name="name_phonetic_given">Phonetic first name</string>
+ <!-- Field title for the phonetic middle name of a contact -->
+ <string name="name_phonetic_middle">Phonetic middle name</string>
+ <!-- Field title for the phonetic family name of a contact -->
+ <string name="name_phonetic_family">Phonetic last name</string>
+
+ <!-- Header that expands to list all of the types of phone numbers when editing or creating a
+ phone number for a contact [CHAR LIMIT=20] -->
+ <string name="phoneLabelsGroup">Phone</string>
+
+ <!-- Header that expands to list all of the types of email addresses when editing or creating
+ an email address for a contact [CHAR LIMIT=20] -->
+ <string name="emailLabelsGroup">Email</string>
+
+ <!-- Header that expands to list all of the types of postal addresses when editing or creating
+ an postal address for a contact [CHAR LIMIT=20] -->
+ <string name="postalLabelsGroup">Address</string>
+
+ <!-- Header that expands to list all of the types of IM account when editing or creating an IM
+ account for a contact [CHAR LIMIT=20] -->
+ <string name="imLabelsGroup">IM</string>
+
+ <!-- Header that expands to list all organization types when editing an organization of a
+ contact [CHAR LIMIT=20] -->
+ <string name="organizationLabelsGroup">Organization</string>
+
+ <!-- Header for the list of all relationships for a contact [CHAR LIMIT=20] -->
+ <string name="relationLabelsGroup">Relationship</string>
+
+ <!-- Header that expands to list all event types when editing an event of a contact
+ [CHAR LIMIT=20] -->
+ <string name="eventLabelsGroup">Special date</string>
+
+ <!-- Generic action string for text messaging a contact. Used by AccessibilityService to
+ announce the purpose of the view. [CHAR LIMIT=NONE] -->
+ <string name="sms">Text message</string>
+
+ <!-- Field title for the full postal address of a contact [CHAR LIMIT=64]-->
+ <string name="postal_address">Address</string>
+
+ <!-- Hint text for the organization name when editing -->
+ <string name="ghostData_company">Company</string>
+
+ <!-- Hint text for the organization title when editing -->
+ <string name="ghostData_title">Title</string>
+
+ <!-- The label describing the Notes field of a contact. This field allows free form text entry
+ about a contact -->
+ <string name="label_notes">Notes</string>
+
+ <!-- The label describing the SIP address field of a contact. [CHAR LIMIT=20] -->
+ <string name="label_sip_address">SIP</string>
+
+ <!-- Header that expands to list all website types when editing a website of a contact
+ [CHAR LIMIT=20] -->
+ <string name="websiteLabelsGroup">Website</string>
+
+ <!-- Header for the list of all labels for a contact [CHAR LIMIT=20] -->
+ <string name="groupsLabel">Labels</string>
+
+ <!-- Action string for sending an email to a home email address -->
+ <string name="email_home">Email home</string>
+ <!-- Action string for sending an email to a mobile email address -->
+ <string name="email_mobile">Email mobile</string>
+ <!-- Action string for sending an email to a work email address -->
+ <string name="email_work">Email work</string>
+ <!-- Action string for sending an email to an other email address -->
+ <string name="email_other">Email</string>
+ <!-- Action string for sending an email to a custom email address -->
+ <string name="email_custom">Email <xliff:g id="custom">%s</xliff:g></string>
+
+ <!-- Generic action string for sending an email -->
+ <string name="email">Email</string>
+
+ <!-- Field title for the street of a structured postal address of a contact -->
+ <string name="postal_street">Street</string>
+ <!-- Field title for the PO box of a structured postal address of a contact -->
+ <string name="postal_pobox">PO box</string>
+ <!-- Field title for the neighborhood of a structured postal address of a contact -->
+ <string name="postal_neighborhood">Neighborhood</string>
+ <!-- Field title for the city of a structured postal address of a contact -->
+ <string name="postal_city">City</string>
+ <!-- Field title for the region, or state, of a structured postal address of a contact -->
+ <string name="postal_region">State</string>
+ <!-- Field title for the postal code of a structured postal address of a contact -->
+ <string name="postal_postcode">ZIP code</string>
+ <!-- Field title for the country of a structured postal address of a contact -->
+ <string name="postal_country">Country</string>
+
+ <!-- Action string for viewing a home postal address -->
+ <string name="map_home">View home address</string>
+ <!-- Action string for viewing a work postal address -->
+ <string name="map_work">View work address</string>
+ <!-- Action string for viewing an other postal address -->
+ <string name="map_other">View address</string>
+ <!-- Action string for viewing a custom postal address -->
+ <string name="map_custom">View <xliff:g id="custom">%s</xliff:g> address</string>
+
+ <!-- Action string for starting an IM chat with the AIM protocol -->
+ <string name="chat_aim">Chat using AIM</string>
+ <!-- Action string for starting an IM chat with the MSN or Windows Live protocol -->
+ <string name="chat_msn">Chat using Windows Live</string>
+ <!-- Action string for starting an IM chat with the Yahoo protocol -->
+ <string name="chat_yahoo">Chat using Yahoo</string>
+ <!-- Action string for starting an IM chat with the Skype protocol -->
+ <string name="chat_skype">Chat using Skype</string>
+ <!-- Action string for starting an IM chat with the QQ protocol -->
+ <string name="chat_qq">Chat using QQ</string>
+ <!-- Action string for starting an IM chat with the Google Talk protocol -->
+ <string name="chat_gtalk">Chat using Google Talk</string>
+ <!-- Action string for starting an IM chat with the ICQ protocol -->
+ <string name="chat_icq">Chat using ICQ</string>
+ <!-- Action string for starting an IM chat with the Jabber protocol -->
+ <string name="chat_jabber">Chat using Jabber</string>
+
+ <!-- Generic action string for starting an IM chat -->
+ <string name="chat">Chat</string>
+
+ <!-- String describing the Contact Editor Minus button
+
+ Used by AccessibilityService to announce the purpose of the button.
+
+ [CHAR LIMIT=NONE]
+ -->
+ <string name="description_minus_button">delete</string>
+
+ <!-- Content description for the expand or collapse name fields button.
+ Clicking this button causes the name editor to toggle between showing
+ a single field where the entire name is edited at once, or multiple
+ fields corresponding to each part of the name (Name Prefix, First Name,
+ Middle Name, Last Name, Name Suffix).
+ [CHAR LIMIT=NONE] -->
+ <string name="expand_collapse_name_fields_description">Expand or collapse name fields</string>
+
+ <!-- Content description for the expand or collapse phonetic name fields button. [CHAR LIMIT=100] -->
+ <string name="expand_collapse_phonetic_name_fields_description">Expand or collapse phonetic
+ name fields</string>
+
+ <!-- Contact list filter label indicating that the list is showing all available accounts [CHAR LIMIT=64] -->
+ <string name="list_filter_all_accounts">All contacts</string>
+
+ <!-- Contact list filter label indicating that the list is showing all starred contacts [CHAR LIMIT=64] -->
+ <string name="list_filter_all_starred">Starred</string>
+
+ <!-- Contact list filter selection indicating that the list shows groups chosen by the user [CHAR LIMIT=64] -->
+ <string name="list_filter_customize">Customize</string>
+
+ <!-- Contact list filter selection indicating that the list shows only the selected contact [CHAR LIMIT=64] -->
+ <string name="list_filter_single">Contact</string>
+
+ <!-- List title for a special contacts group that covers all contacts. [CHAR LIMIT=25] -->
+ <string name="display_ungrouped">All other contacts</string>
+
+ <!-- List title for a special contacts group that covers all contacts that aren't members of any other group. [CHAR LIMIT=25] -->
+ <string name="display_all_contacts">All contacts</string>
+
+ <!-- Menu item to remove a contacts sync group. [CHAR LIMIT=40] -->
+ <string name="menu_sync_remove">Remove sync group</string>
+
+ <!-- Menu item to add a contacts sync group. [CHAR LIMIT=40] -->
+ <string name="dialog_sync_add">Add sync group</string>
+
+ <!-- Text displayed in the sync groups footer view for unknown sync groups. [CHAR LIMIT=40 -->
+ <string name="display_more_groups">More groups\u2026</string>
+
+ <!-- Warning message given to users just before they remove a currently syncing
+ group that would also cause all ungrouped contacts to stop syncing. [CHAR LIMIT=NONE] -->
+ <string name="display_warn_remove_ungrouped">Removing \"<xliff:g id="group" example="Starred">%s</xliff:g>\" from sync will also remove any ungrouped contacts from sync.</string>
+
+ <!-- Displayed in a spinner dialog as user changes to display options are saved -->
+ <string name="savingDisplayGroups">Saving display options\u2026</string>
+
+ <!-- Menu item to indicate you are done editing a contact and want to save the changes you've made -->
+ <string name="menu_done">Done</string>
+
+ <!-- Menu item to indicate you want to cancel the current editing process and NOT save the changes you've made [CHAR LIMIT=12] -->
+ <string name="menu_doNotSave">Cancel</string>
+
+ <!-- Displayed at the top of the contacts showing single contact. [CHAR LIMIT=50] -->
+ <string name="listCustomView">Customized view</string>
+
+ <!-- Message asking user to select an account to save contacts imported from vcard or SIM card [CHAR LIMIT=64] -->
+ <string name="dialog_new_contact_account">Save imported contacts to:</string>
+
+ <!-- Action string for selecting SIM for importing contacts -->
+ <string name="import_from_sim">Import from SIM card</string>
+
+ <!-- Action string for selecting a SIM subscription for importing contacts -->
+ <string name="import_from_sim_summary">Import from SIM <xliff:g id="sim_name">^1</xliff:g> - <xliff:g id="sim_number">^2</xliff:g></string>
+
+ <!-- Action string for selecting a SIM subscription for importing contacts, without a phone number -->
+ <string name="import_from_sim_summary_no_number">Import from SIM <xliff:g id="sim_name">%1$s</xliff:g></string>
+
+ <!-- Action string for selecting a .vcf file to import contacts from [CHAR LIMIT=30] -->
+ <string name="import_from_vcf_file" product="default">Import from .vcf file</string>
+
+ <!-- Message shown in a Dialog confirming a user's cancel request toward existing vCard import.
+ The argument is file name for the vCard import the user wants to cancel.
+ [CHAR LIMIT=128] -->
+ <string name="cancel_import_confirmation_message">Cancel import of <xliff:g id="filename" example="import.vcf">%s</xliff:g>?</string>
+
+ <!-- Message shown in a Dialog confirming a user's cancel request toward existing vCard export.
+ The argument is file name for the vCard export the user wants to cancel.
+ [CHAR LIMIT=128] -->
+ <string name="cancel_export_confirmation_message">Cancel export of <xliff:g id="filename" example="export.vcf">%s</xliff:g>?</string>
+
+ <!-- Title shown in a Dialog telling users cancel vCard import/export operation is failed. [CHAR LIMIT=40] -->
+ <string name="cancel_vcard_import_or_export_failed">Couldn\'t cancel vCard import/export</string>
+
+ <!-- The failed reason which should not be shown but it may in some buggy condition. [CHAR LIMIT=40] -->
+ <string name="fail_reason_unknown">Unknown error.</string>
+
+ <!-- The failed reason shown when vCard importer/exporter could not open the file
+ specified by a user. The file name should be in the message. [CHAR LIMIT=NONE] -->
+ <string name="fail_reason_could_not_open_file">Couldn\'t open \"<xliff:g id="file_name">%s</xliff:g>\": <xliff:g id="exact_reason">%s</xliff:g>.</string>
+
+ <!-- The failed reason shown when contacts exporter fails to be initialized.
+ Some exact reason must follow this. [CHAR LIMIT=NONE]-->
+ <string name="fail_reason_could_not_initialize_exporter">Couldn\'t start the exporter: \"<xliff:g id="exact_reason">%s</xliff:g>\".</string>
+
+ <!-- The failed reason shown when there's no contact which is allowed to be exported.
+ Note that user may have contacts data but all of them are probably not allowed to be
+ exported because of security/permission reasons. [CHAR LIMIT=NONE] -->
+ <string name="fail_reason_no_exportable_contact">There is no exportable contact.</string>
+
+ <!-- The user doesn't have all permissions required to use the current screen. So
+ close the current screen and show the user this message. -->
+ <string name="missing_required_permission">You have disabled a required permission.</string>
+
+ <!-- The failed reason shown when some error happend during contacts export.
+ Some exact reason must follow this. [CHAR LIMIT=NONE] -->
+ <string name="fail_reason_error_occurred_during_export">An error occurred during export: \"<xliff:g id="exact_reason">%s</xliff:g>\".</string>
+
+ <!-- The failed reason shown when the given file name is too long for the system.
+ The length limit of each file is different in each Android device, so we don't need to
+ mention it here. [CHAR LIMIT=NONE] -->
+ <string name="fail_reason_too_long_filename">Required filename is too long (\"<xliff:g id="filename">%s</xliff:g>\").</string>
+
+ <!-- The failed reason shown when Contacts app (especially vCard importer/exporter)
+ emitted some I/O error. Exact reason will be appended by the system. [CHAR LIMIT=NONE] -->
+ <string name="fail_reason_io_error">I/O error</string>
+
+ <!-- Failure reason show when Contacts app (especially vCard importer) encountered
+ low memory problem and could not proceed its import procedure. [CHAR LIMIT=NONE] -->
+ <string name="fail_reason_low_memory_during_import">Not enough memory. The file may be too large.</string>
+
+ <!-- The failed reason shown when vCard parser was not able to be parsed by the current vCard
+ implementation. This might happen even when the input vCard is completely valid, though
+ we believe it is rather rare in the actual world. [CHAR LIMIT=NONE] -->
+ <string name="fail_reason_vcard_parse_error">Couldn\'t parse vCard for an unexpected reason.</string>
+
+ <!-- The failed reason shown when vCard importer doesn't support the format.
+ This may be shown when the vCard is corrupted [CHAR LIMIT=40] -->
+ <string name="fail_reason_not_supported">The format isn\'t supported.</string>
+
+ <!-- Fail reason shown when vCard importer failed to look over meta information stored in vCard file(s). -->
+ <string name="fail_reason_failed_to_collect_vcard_meta_info">Couldn\'t collect meta information of given vCard file(s).</string>
+
+ <!-- The failed reason shown when the import of some of vCard files failed during multiple vCard
+ files import. It includes the case where all files were failed to be imported. -->
+ <string name="fail_reason_failed_to_read_files">One or more files couldn\'t be imported (%s).</string>
+
+ <!-- The title shown when exporting vCard is successfuly finished [CHAR LIMIT=40] -->
+ <string name="exporting_vcard_finished_title">Finished exporting <xliff:g id="filename" example="export.vcf">%s</xliff:g>.</string>
+
+ <!-- The title shown when exporting vCard has finished successfully but the destination filename could not be resolved. [CHAR LIMIT=NONE] -->
+ <string name="exporting_vcard_finished_title_fallback">Finished exporting contacts.</string>
+
+ <!-- The toast message shown when exporting vCard has finished and vCards are ready to be shared [CHAR LIMIT=150]-->
+ <string name="exporting_vcard_finished_toast">Finished exporting contacts, click the notification to share contacts.</string>
+
+ <!-- The message on notification shown when exporting vCard has finished and vCards are ready to be shared [CHAR LIMIT=60]-->
+ <string name="touch_to_share_contacts">Tap to share contacts.</string>
+
+ <!-- The title shown when exporting vCard is canceled (probably by a user)
+ The argument is file name the user canceled importing.
+ [CHAR LIMIT=40] -->
+ <string name="exporting_vcard_canceled_title">Exporting <xliff:g id="filename" example="export.vcf">%s</xliff:g> canceled.</string>
+
+ <!-- Dialog title shown when the application is exporting contact data outside. [CHAR LIMIT=NONE] -->
+ <string name="exporting_contact_list_title">Exporting contact data</string>
+
+ <!-- Message shown when the application is exporting contact data outside -->
+ <string name="exporting_contact_list_message">Contact data is being exported.</string>
+
+ <!-- The error reason the vCard composer "may" emit when database is corrupted or
+ something is going wrong. Usually users should not see this text. [CHAR LIMIT=NONE] -->
+ <string name="composer_failed_to_get_database_infomation">Couldn\'t get database information.</string>
+
+ <!-- This error message shown when the user actually have no contact
+ (e.g. just after data-wiping), or, data providers of the contact list prohibits their
+ contacts from being exported to outside world via vcard exporter, etc. [CHAR LIMIT=NONE] -->
+ <string name="composer_has_no_exportable_contact">There are no exportable contacts. If you do have contacts on your phone, some data providers may not allow the contacts to be exported from the phone.</string>
+
+ <!-- The error reason the vCard composer may emit when vCard composer is not initialized
+ even when needed.
+ Users should not usually see this error message. [CHAR LIMIT=NONE] -->
+ <string name="composer_not_initialized">The vCard composer didn\'t start properly.</string>
+
+ <!-- Dialog title shown when exporting Contact data failed. [CHAR LIMIT=20] -->
+ <string name="exporting_contact_failed_title">Couldn\'t export</string>
+
+ <!-- Dialog message shown when exporting Contact data failed. [CHAR LIMIT=NONE] -->
+ <string name="exporting_contact_failed_message">The contact data wasn\'t exported.\nReason: \"<xliff:g id="fail_reason">%s</xliff:g>\"</string>
+
+ <!-- Description shown when importing vCard data.
+ The argument is the name of a contact which is being read.
+ [CHAR LIMIT=20] -->
+ <string name="importing_vcard_description">Importing <xliff:g id="name" example="Joe Due">%s</xliff:g></string>
+
+ <!-- Dialog title shown when reading vCard data failed [CHAR LIMIT=40] -->
+ <string name="reading_vcard_failed_title">Couldn\'t read vCard data</string>
+
+ <!-- The title shown when reading vCard is canceled (probably by a user)
+ [CHAR LIMIT=40] -->
+ <string name="reading_vcard_canceled_title">Reading vCard data canceled</string>
+
+ <!-- The title shown when reading vCard finished
+ The argument is file name the user imported.
+ [CHAR LIMIT=40] -->
+ <string name="importing_vcard_finished_title">Finished importing vCard <xliff:g id="filename" example="import.vcf">%s</xliff:g></string>
+
+ <!-- The title shown when importing vCard is canceled (probably by a user)
+ The argument is file name the user canceled importing.
+ [CHAR LIMIT=40] -->
+ <string name="importing_vcard_canceled_title">Importing <xliff:g id="filename" example="import.vcf">%s</xliff:g> canceled</string>
+
+ <!-- The message shown when vCard import request is accepted. The system may start that work soon, or do it later
+ when there are already other import/export requests.
+ The argument is file name the user imported.
+ [CHAR LIMIT=40] -->
+ <string name="vcard_import_will_start_message"><xliff:g id="filename" example="import.vcf">%s</xliff:g> will be imported shortly.</string>
+ <!-- The message shown when vCard import request is accepted. The system may start that work soon, or do it later when there are already other import/export requests.
+ "The file" is what a user selected for importing.
+ [CHAR LIMIT=40] -->
+ <string name="vcard_import_will_start_message_with_default_name">The file will be imported shortly.</string>
+ <!-- The message shown when a given vCard import request is rejected by the system. [CHAR LIMIT=NONE] -->
+ <string name="vcard_import_request_rejected_message">vCard import request was rejected. Try again later.</string>
+ <!-- The message shown when vCard export request is accepted. The system may start that work soon, or do it later
+ when there are already other import/export requests.
+ The argument is file name the user exported.
+ [CHAR LIMIT=40] -->
+ <string name="vcard_export_will_start_message"><xliff:g id="filename" example="import.vcf">%s</xliff:g> will be exported shortly.</string>
+
+ <!-- The message shown when a vCard export request is accepted but the destination filename could not be resolved. [CHAR LIMIT=NONE] -->
+ <string name="vcard_export_will_start_message_fallback">The file will be exported shortly.</string>
+
+ <!-- The message shown when a vCard export request is accepted and contacts will be exported shortly. [CHAR LIMIT=70]-->
+ <string name="contacts_export_will_start_message">Contacts will be exported shortly.</string>
+
+ <!-- The message shown when a given vCard export request is rejected by the system. [CHAR LIMIT=NONE] -->
+ <string name="vcard_export_request_rejected_message">vCard export request was rejected. Try again later.</string>
+ <!-- Used when file name is unknown in vCard processing. It typically happens
+ when the file is given outside the Contacts app. [CHAR LIMIT=30] -->
+ <string name="vcard_unknown_filename">contact</string>
+
+ <!-- The message shown when vCard importer is caching files to be imported into local temporary
+ data storage. [CHAR LIMIT=NONE] -->
+ <string name="caching_vcard_message">Caching vCard(s) to local temporary storage. The actual import will start soon.</string>
+
+ <!-- Message used when vCard import has failed. [CHAR LIMIT=40] -->
+ <string name="vcard_import_failed">Couldn\'t import vCard.</string>
+
+ <!-- The "file name" displayed for vCards received directly via NFC [CHAR LIMIT=16] -->
+ <string name="nfc_vcard_file_name">Contact received over NFC</string>
+
+ <!-- Dialog title shown when a user confirms whether he/she export Contact data. [CHAR LIMIT=32] -->
+ <string name="confirm_export_title">Export contacts?</string>
+
+ <!-- The title shown when vCard importer is caching files to be imported into local temporary
+ data storage. [CHAR LIMIT=40] -->
+ <string name="caching_vcard_title">Caching</string>
+
+ <!-- The message shown while importing vCard(s).
+ First argument is current index of contacts to be imported.
+ Second argument is the total number of contacts.
+ Third argument is the name of a contact which is being read.
+ [CHAR LIMIT=20] -->
+ <string name="progress_notifier_message">Importing <xliff:g id="current_number">%s</xliff:g>/<xliff:g id="total_number">%s</xliff:g>: <xliff:g id="name" example="Joe Due">%s</xliff:g></string>
+
+ <!-- Action that exports all contacts to a user selected destination. [CHAR LIMIT=25] -->
+ <string name="export_to_vcf_file" product="default">Export to .vcf file</string>
+
+ <!-- Contact preferences related strings -->
+
+ <!-- Label of the "sort by" display option -->
+ <string name="display_options_sort_list_by">Sort by</string>
+
+ <!-- An allowable value for the "sort list by" contact display option -->
+ <string name="display_options_sort_by_given_name">First name</string>
+
+ <!-- An allowable value for the "sort list by" contact display option -->
+ <string name="display_options_sort_by_family_name">Last name</string>
+
+ <!-- Label of the "name format" display option [CHAR LIMIT=64]-->
+ <string name="display_options_view_names_as">Name format</string>
+
+ <!-- An allowable value for the "view names as" contact display option -->
+ <string name="display_options_view_given_name_first">First name first</string>
+
+ <!-- An allowable value for the "view names as" contact display option -->
+ <string name="display_options_view_family_name_first">Last name first</string>
+
+ <!--Lable of the "Accounts" in settings [CHAR LIMIT=30]-->
+ <string name="settings_accounts">Accounts</string>
+
+ <!--Label of the "default account" setting option to set default editor account. [CHAR LIMIT=80]-->
+ <string name="default_editor_account">Default account for new contacts</string>
+
+ <!--Label of the "Sync contact metadata" setting option to set sync account for Lychee. [CHAR LIMIT=80]-->
+ <string name="sync_contact_metadata_title">Sync contact metadata [DOGFOOD]</string>
+
+ <!--Label of the "Sync contact metadata" setting dialog to set sync account for Lychee. [CHAR LIMIT=80]-->
+ <string name="sync_contact_metadata_dialog_title">Sync contact metadata</string>
+
+ <!-- Title of my info preference, showing the name of user's personal profile [CHAR LIMIT=30]-->
+ <string name="settings_my_info_title">My info</string>
+
+ <!-- Displayed below my info for user to set up the user's personal profile entry [CHAR LIMIT=64] -->
+ <string name="set_up_profile">Set up your profile</string>
+
+ <!-- Label of the "About" setting -->
+ <string name="setting_about">About Contacts</string>
+
+ <!-- Title of the settings activity [CHAR LIMIT=64] -->
+ <string name="activity_title_settings">Settings</string>
+
+ <!-- Action that shares visible contacts -->
+ <string name="share_visible_contacts">Share visible contacts</string>
+
+ <!-- A framework exception (ie, transaction too large) can be thrown while attempting to share all visible contacts. If so, show this toast. -->
+ <string name="share_visible_contacts_failure">Failed to share visible contacts.</string>
+
+ <!-- Action that shares favorite contacts [CHAR LIMIT=40]-->
+ <string name="share_favorite_contacts">Share favorite contacts</string>
+
+ <!-- Action that shares contacts [CHAR LIMIT=30]-->
+ <string name="share_contacts">Share all contacts</string>
+
+ <!-- A framework exception can be thrown while attempting to share all contacts. If so, show this toast. [CHAR LIMIT=40]-->
+ <string name="share_contacts_failure">Failed to share contacts.</string>
+
+ <!-- Dialog title when selecting the bulk operation to perform from a list. [CHAR LIMIT=36] -->
+ <string name="dialog_import_export">Import/export contacts</string>
+
+ <!-- Dialog title when importing contacts from an external source. [CHAR LIMIT=36] -->
+ <string name="dialog_import">Import contacts</string>
+
+ <!-- Toast indicating that sharing a contact has failed. [CHAR LIMIT=NONE] -->
+ <string name="share_error">This contact can\'t be shared.</string>
+
+ <!-- Toast indicating that no visible contact to share [CHAR LIMIT=NONE] -->
+ <string name="no_contact_to_share">There are no contacts to share.</string>
+
+ <!-- Menu item to search contacts -->
+ <string name="menu_search">Search</string>
+
+ <!-- The menu item to filter the list of contacts displayed -->
+ <string name="menu_contacts_filter">Contacts to display</string>
+
+ <!-- Title of the activity that allows the uesr to filter the list of contacts displayed according to account [CHAR LIMIT=25] -->
+ <string name="activity_title_contacts_filter">Contacts to display</string>
+
+ <!-- Title of the activity that allows the user to customize filtering of contact list [CHAR LIMIT=128] -->
+ <string name="custom_list_filter">Define customized view</string>
+
+ <!-- Menu item to save changes to custom filter. [CHAR LIMIT=15] -->
+ <string name="menu_custom_filter_save">Save</string>
+
+ <!-- Query hint displayed inside the search field [CHAR LIMIT=64] -->
+ <string name="hint_findContacts">Search contacts</string>
+
+ <!-- The description text for the favorites tab.
+
+ Note: AccessibilityServices use this attribute to announce what the view represents.
+ This is especially valuable for views without textual representation like ImageView.
+
+ [CHAR LIMIT=NONE] -->
+ <string name="contactsFavoritesLabel">Favorites</string>
+
+ <!-- Displayed at the top of the contacts showing the zero total number of contacts visible when "All contacts" is selected [CHAR LIMIT=64]-->
+ <string name="listTotalAllContactsZero">No contacts.</string>
+
+ <!-- The menu item to clear frequents [CHAR LIMIT=30] -->
+ <string name="menu_clear_frequents">Clear frequents</string>
+
+ <!-- Menu item to select SIM card -->
+ <string name="menu_select_sim">Select SIM card</string>
+
+ <!-- The menu item to open the list of accounts. [CHAR LIMIT=60]-->
+ <string name="menu_accounts">Manage accounts</string>
+
+ <!-- The menu item to bulk import or bulk export contacts from SIM card or SD card. [CHAR LIMIT=30]-->
+ <string name="menu_import_export">Import/export</string>
+
+ <!-- The menu item to open blocked numbers activity [CHAR LIMIT=60]-->
+ <string name="menu_blocked_numbers">Blocked numbers</string>
+
+ <!-- The font-family to use for tab text.
+ Do not translate. -->
+ <string name="tab_font_family">sans-serif</string>
+
+ <!-- Attribution of a contact status update, when the time of update is unknown -->
+ <string name="contact_status_update_attribution">via <xliff:g id="source" example="Google Talk">%1$s</xliff:g></string>
+
+ <!-- Attribution of a contact status update, when the time of update is known -->
+ <string name="contact_status_update_attribution_with_date"><xliff:g id="date" example="3 hours ago">%1$s</xliff:g> via <xliff:g id="source" example="Google Talk">%2$s</xliff:g></string>
+
+ <!-- Font family used when drawing letters for letter tile avatars.
+ Do not translate. -->
+ <string name="letter_tile_letter_font_family">sans-serif-medium</string>
+
+ <!-- Content description for the fake action menu up button as used
+ inside search. [CHAR LIMIT=NONE] -->
+ <string name="action_menu_back_from_search">stop searching</string>
+
+ <!-- String describing the icon used to clear the search field -->
+ <string name="description_clear_search">Clear search</string>
+
+ <!-- The font-family to use for the text inside the searchbox.
+ Do not translate. -->
+ <string name="search_font_family">sans-serif</string>
+
+ <!-- The title of the preference section that allows users to configure how they want their
+ contacts to be displayed. [CHAR LIMIT=128] -->
+ <string name="settings_contact_display_options_title">Contact display options</string>
+
+ <!-- Title for Select Account Dialog [CHAR LIMIT=30] -->
+ <string name="select_account_dialog_title">Account</string>
+
+ <!-- Label for the check box to toggle default sim card setting [CHAR LIMIT=35]-->
+ <string name="set_default_account">Always use this for calls</string>
+
+ <!-- Title for dialog to select Phone Account for outgoing call. [CHAR LIMIT=40] -->
+ <string name="select_phone_account_for_calls">Call with</string>
+
+ <!-- String used for actions in the dialer call log and the quick contact card to initiate
+ a call to an individual. The user is prompted to enter a note which is sent along with
+ the call (e.g. a call subject). [CHAR LIMIT=40] -->
+ <string name="call_with_a_note">Call with a note</string>
+
+ <!-- Hint text shown in the call subject dialog. [CHAR LIMIT=255] -->
+ <string name="call_subject_hint">Type a note to send with call ...</string>
+
+ <!-- Button used to start a new call with the user entered subject. [CHAR LIMIT=32] -->
+ <string name="send_and_call_button">SEND & CALL</string>
+
+ <!-- String used to represent the total number of characters entered for a call subject,
+ compared to the character limit. Example: 2 / 64 -->
+ <string name="call_subject_limit"><xliff:g id="count" example="4">%1$s</xliff:g> / <xliff:g id="limit" example="64">%2$s</xliff:g></string>
+
+ <!-- String used to build a phone number bype and phone number string.
+ Example: Mobile • 650-555-1212 -->
+ <string name="call_subject_type_and_number"><xliff:g id="type" example="Mobile">%1$s</xliff:g> • <xliff:g id="number" example="(650) 555-1212">%2$s</xliff:g></string>
+
+ <!-- String format to describe the number of unread items in a tab.
+
+ Note: AccessibilityServices use this attribute to announce what the view represents.
+ This is especially valuable for views without textual representation like ImageView.
+ -->
+ <plurals name="tab_title_with_unread_items">
+ <item quantity="one">
+ <xliff:g id="title">%1$s</xliff:g>. <xliff:g id="count">%2$d</xliff:g> unread item.
+ </item>
+ <item quantity="other">
+ <xliff:g id="title">%1$s</xliff:g>. <xliff:g id="count">%2$d</xliff:g> unread items.
+ </item>
+ </plurals>
+
+ <!-- Build version title in About preference. [CHAR LIMIT=40]-->
+ <string name="about_build_version">Build version</string>
+
+ <!-- Open source licenses title in About preference. [CHAR LIMIT=60] -->
+ <string name="about_open_source_licenses">Open source licenses</string>
+
+ <!-- Open source licenses summary in About preference. [CHAR LIMIT=NONE] -->
+ <string name="about_open_source_licenses_summary">License details for open source software</string>
+
+ <!-- Privacy policy title in About preference. [CHAR LIMIT=40]-->
+ <string name="about_privacy_policy">Privacy policy</string>
+
+ <!-- Terms of service title in about preference. [CHAR LIMIT=60]-->
+ <string name="about_terms_of_service">Terms of service</string>
+
+ <!-- Title for the activity that displays licenses for open source libraries. [CHAR LIMIT=100]-->
+ <string name="activity_title_licenses">Open source licenses</string>
+
+ <!-- Toast message showing when failed to open the url. [CHAR LIMIT=100]-->
+ <string name="url_open_error_toast">Failed to open the url.</string>
+
+ <!-- Content description of entries (including that radio button is checked) in contact
+ accounts list filter. For example: Google abc@gmail.com checked, etc [CHAR LIMIT=30]-->
+ <string name="account_filter_view_checked"><xliff:g id="account_info">%s</xliff:g> checked</string>
+
+ <!-- Content description of entries (including that the radio button is not checked) in contact
+ accounts list filter. For example: Google abc@gmail.com not checked, etc [CHAR LIMIT=30]-->
+ <string name="account_filter_view_not_checked"><xliff:g id="account_info">%s</xliff:g> not checked</string>
+
+ <!-- Description string for an action button to initiate a video call from search results.
+ Note: AccessibilityServices use this attribute to announce what the view represents.
+ This is especially valuable for views without textual representation like ImageView.
+
+ [CHAR LIMIT=NONE]-->
+ <string name="description_search_video_call">Place video call</string>
+
+ <!-- Content description of delete contact button [CHAR LIMIT=30]-->
+ <string name="description_delete_contact">Delete</string>
+
+ <!-- Content description for (...) in no name header [CHAR LIMIT=30]-->
+ <string name="description_no_name_header">Ellipsis</string>
</resources>
diff --git a/res/values/styles.xml b/res/values/styles.xml
index 9c6ace4..9ff882d 100644
--- a/res/values/styles.xml
+++ b/res/values/styles.xml
@@ -434,4 +434,98 @@
<item name="android:fontFamily">sans-serif-medium</item>
<item name="android:textAllCaps">true</item>
</style>
+ <style name="DirectoryHeader">
+ <item name="android:background">@android:color/transparent</item>
+ </style>
+
+ <style name="SectionHeaderStyle" parent="@android:style/TextAppearance.Large">
+ <item name="android:textSize">16sp</item>
+ <item name="android:textAllCaps">true</item>
+ <item name="android:textColor">@color/section_header_text_color</item>
+ <item name="android:textStyle">bold</item>
+ </style>
+
+ <style name="DirectoryHeaderStyle" parent="@android:style/TextAppearance.Small">
+ <item name="android:textSize">14sp</item>
+ <item name="android:textColor">@color/dialtacts_secondary_text_color</item>
+ <item name="android:fontFamily">sans-serif-medium</item>
+ </style>
+
+ <!-- TextView style used for headers.
+
+This is similar to ?android:attr/listSeparatorTextView but uses different
+background and text color. See also android:style/Widget.Holo.TextView.ListSeparator
+(which is private, so we cannot specify it as a parent style). -->
+ <style name="ContactListSeparatorTextViewStyle">
+ <item name="android:layout_width">match_parent</item>
+ <item name="android:layout_height">wrap_content</item>
+ <!-- See comments for @dimen/list_section_divider_min_height -->
+ <item name="android:minHeight">@dimen/list_section_divider_min_height</item>
+ <item name="android:textAppearance">@style/DirectoryHeaderStyle</item>
+ <item name="android:gravity">center_vertical</item>
+ <item name="android:paddingLeft">8dip</item>
+ <item name="android:paddingStart">8dip</item>
+ <item name="android:paddingTop">4dip</item>
+ <item name="android:paddingBottom">4dip</item>
+ <item name="android:ellipsize">end</item>
+ <item name="android:singleLine">true</item>
+ <item name="android:textAllCaps">true</item>
+ </style>
+
+ <style name="TextAppearanceMedium" parent="@android:style/TextAppearance.Medium">
+ <item name="android:textSize">16sp</item>
+ <item name="android:textColor">#000000</item>
+ </style>
+
+ <style name="TextAppearanceSmall" parent="@android:style/TextAppearance.Small">
+ <item name="android:textSize">14sp</item>
+ <item name="android:textColor">#737373</item>
+ </style>
+
+ <style name="ListViewStyle" parent="@android:style/Widget.Material.Light.ListView">
+ <item name="android:overScrollMode">always</item>
+ </style>
+
+ <style name="ContactListFilterTheme" parent="@android:Theme.Holo.Light">
+ <item name="android:listViewStyle">@style/ListViewStyle</item>
+ <item name="android:actionButtonStyle">@style/FilterActionButtonStyle</item>
+ </style>
+
+ <!-- Adding padding to action button doesn't move it to left, we increase the button width to
+ make margin between the button and screen edge 16dp -->
+ <style name="FilterActionButtonStyle" parent="@android:Widget.ActionButton">
+ <item name="android:minWidth">@dimen/contact_filter_action_button_width</item>
+ <item name="android:textColor">@color/actionbar_text_color</item>
+ </style>
+
+ <style name="CustomContactListFilterView" parent="ContactListFilterTheme">
+ <item name="android:layout_width">match_parent</item>
+ <item name="android:layout_height">match_parent</item>
+ </style>
+
+ <style name="BackgroundOnlyTheme" parent="@android:style/Theme.Material.Light">
+ <item name="android:windowBackground">@null</item>
+ <item name="android:windowContentOverlay">@null</item>
+ <item name="android:windowAnimationStyle">@null</item>
+ <item name="android:windowNoTitle">true</item>
+ <!-- Activities that use this theme are background activities without obvious displays.
+ However, some also have dialogs. Therefore, it doesn't make sense to set this true.-->
+ <item name="android:windowNoDisplay">false</item>
+ <item name="android:windowIsFloating">true</item>
+ </style>
+
+ <style name="Theme.CallSubjectDialogTheme" parent="@android:style/Theme.Material.Light.Dialog">
+ <item name="android:layout_width">match_parent</item>
+ <item name="android:layout_height">match_parent</item>
+
+ <!-- No backgrounds, titles or window float -->
+ <item name="android:windowIsTranslucent">true</item>
+ <item name="android:windowNoTitle">true</item>
+ <item name="android:windowFullscreen">false</item>
+ <item name="android:windowIsFloating">true</item>
+ <item name="android:windowBackground">@android:color/transparent</item>
+ <item name="android:windowDrawsSystemBarBackgrounds">false</item>
+ <item name="android:windowContentOverlay">@null</item>
+ <item name="android:windowElevation">0dp</item>
+ </style>
</resources>
diff --git a/res/xml/preference_about.xml b/res/xml/preference_about.xml
new file mode 100644
index 0000000..a109db6
--- /dev/null
+++ b/res/xml/preference_about.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2016 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
+ <Preference
+ android:icon="@null"
+ android:key="@string/pref_build_version_key"
+ android:title="@string/about_build_version"/>
+
+ <Preference
+ android:icon="@null"
+ android:key="@string/pref_open_source_licenses_key"
+ android:title="@string/about_open_source_licenses"
+ android:summary="@string/about_open_source_licenses_summary"/>
+
+ <Preference
+ android:icon="@null"
+ android:key="@string/pref_privacy_policy_key"
+ android:title="@string/about_privacy_policy">
+ </Preference>
+
+ <Preference
+ android:icon="@null"
+ android:key="@string/pref_terms_of_service_key"
+ android:title="@string/about_terms_of_service">
+ </Preference>
+</PreferenceScreen>
diff --git a/res/xml/preference_display_options.xml b/res/xml/preference_display_options.xml
new file mode 100644
index 0000000..f7a6514
--- /dev/null
+++ b/res/xml/preference_display_options.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2010 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
+ <Preference
+ android:icon="@null"
+ android:key="myInfo"
+ android:title="@string/settings_my_info_title"/>
+
+ <Preference
+ android:icon="@null"
+ android:key="accounts"
+ android:title="@string/settings_accounts">
+ </Preference>
+
+ <com.android.contacts.common.preference.DefaultAccountPreference
+ android:icon="@null"
+ android:key="defaultAccount"
+ android:title="@string/default_editor_account"
+ android:dialogTitle="@string/default_editor_account" />
+
+ <Preference
+ android:icon="@null"
+ android:key="customContactsFilter"
+ android:title="@string/menu_contacts_filter"/>
+
+ <com.android.contacts.common.preference.SortOrderPreference
+ android:icon="@null"
+ android:key="sortOrder"
+ android:title="@string/display_options_sort_list_by"
+ android:dialogTitle="@string/display_options_sort_list_by" />
+
+ <com.android.contacts.common.preference.DisplayOrderPreference
+ android:icon="@null"
+ android:key="displayOrder"
+ android:title="@string/display_options_view_names_as"
+ android:dialogTitle="@string/display_options_view_names_as" />
+
+ <Preference
+ android:icon="@null"
+ android:key="importExport"
+ android:title="@string/menu_import_export"/>
+
+ <Preference
+ android:icon="@null"
+ android:key="blockedNumbers"
+ android:title="@string/menu_blocked_numbers" />
+
+ <Preference
+ android:icon="@null"
+ android:key="about"
+ android:title="@string/setting_about"
+ android:order="@integer/about_contacts_order_number"/>
+</PreferenceScreen>
diff --git a/src-bind/com/android/commonbind/ObjectFactory.java b/src-bind/com/android/commonbind/ObjectFactory.java
new file mode 100644
index 0000000..3bbd52d
--- /dev/null
+++ b/src-bind/com/android/commonbind/ObjectFactory.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.android.contacts.commonbind;
+
+import com.android.contacts.common.logging.Logger;
+import com.android.contacts.common.preference.PreferenceManager;
+
+import android.content.Context;
+
+/**
+ * Creates default bindings for overlays.
+ */
+public class ObjectFactory {
+
+ public static Logger getLogger() {
+ return null;
+ }
+
+ public static PreferenceManager getPreferenceManager(Context context) { return null; }
+}
diff --git a/src-bind/com/android/commonbind/analytics/AnalyticsUtil.java b/src-bind/com/android/commonbind/analytics/AnalyticsUtil.java
new file mode 100644
index 0000000..84420b6
--- /dev/null
+++ b/src-bind/com/android/commonbind/analytics/AnalyticsUtil.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.contacts.commonbind.analytics;
+
+import android.app.Activity;
+import android.app.Application;
+import android.app.Fragment;
+import android.text.TextUtils;
+
+public class AnalyticsUtil {
+
+ /**
+ * Initialize this class and setup automatic activity tracking.
+ */
+ public static void initialize(Application application) { }
+
+ /**
+ * Log a screen view for {@param fragment}.
+ */
+ public static void sendScreenView(Fragment fragment) {}
+
+ public static void sendScreenView(Fragment fragment, Activity activity) {}
+
+ public static void sendScreenView(Fragment fragment, Activity activity, String tag) {}
+
+ public static void sendScreenView(String fragmentName, Activity activity, String tag) {}
+
+ /**
+ * Logs a event to the analytics server.
+ *
+ * @param application The application the tracker is stored in.
+ * @param category The category for the event.
+ * @param action The event action.
+ * @param label The event label.
+ * @param value The value associated with the event.
+ */
+ public static void sendEvent(Application application, String category, String action,
+ String label, long value) { }
+}
\ No newline at end of file
diff --git a/src-bind/com/android/commonbind/experiments/Flags.java b/src-bind/com/android/commonbind/experiments/Flags.java
new file mode 100644
index 0000000..875712f
--- /dev/null
+++ b/src-bind/com/android/commonbind/experiments/Flags.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+package com.android.contacts.commonbind.experiments;
+
+import android.content.Context;
+
+import com.android.contacts.common.Experiments;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Provides getters for experiment flags.
+ * This stub class is designed to be overwritten by an overlay.
+ */
+public final class Flags {
+
+ private static Flags sInstance;
+
+ private Map<String,Boolean> mMap;
+
+ public static Flags getInstance(Context context) {
+ if (sInstance == null) {
+ sInstance = new Flags();
+ }
+ return sInstance;
+ }
+
+ private Flags() {
+ mMap = new HashMap<>();
+ }
+
+ public boolean getBoolean(String flagName) {
+ return mMap.containsKey(flagName) ? mMap.get(flagName) : false;
+ }
+}
diff --git a/src-bind/com/android/commonbind/util/UserAgentGenerator.java b/src-bind/com/android/commonbind/util/UserAgentGenerator.java
new file mode 100644
index 0000000..13bcaca
--- /dev/null
+++ b/src-bind/com/android/commonbind/util/UserAgentGenerator.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.commonbind.util;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+
+/**
+ * Generates a user agent string for the application.
+ */
+public class UserAgentGenerator {
+ /**
+ * Builds a user agent string for the current application. No default implementation.
+ *
+ * @param context The context.
+ * @return The user agent string.
+ */
+ public static String getUserAgent(Context context) {
+ return null;
+ }
+}
diff --git a/src/com/android/contactsbind/Assistants.java b/src-bind/com/android/contactsbind/Assistants.java
similarity index 100%
rename from src/com/android/contactsbind/Assistants.java
rename to src-bind/com/android/contactsbind/Assistants.java
diff --git a/src/com/android/contactsbind/HelpUtils.java b/src-bind/com/android/contactsbind/HelpUtils.java
similarity index 100%
rename from src/com/android/contactsbind/HelpUtils.java
rename to src-bind/com/android/contactsbind/HelpUtils.java
diff --git a/src/com/android/contacts/activities/ActionBarAdapter.java b/src/com/android/contacts/activities/ActionBarAdapter.java
index eaf027d..e78c355 100644
--- a/src/com/android/contacts/activities/ActionBarAdapter.java
+++ b/src/com/android/contacts/activities/ActionBarAdapter.java
@@ -16,6 +16,7 @@
package com.android.contacts.activities;
+import android.animation.ArgbEvaluator;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.Context;
@@ -35,6 +36,7 @@
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
+import android.view.Window;
import android.view.inputmethod.InputMethodManager;
import android.widget.FrameLayout;
import android.widget.ImageView;
@@ -122,6 +124,8 @@
private FeatureHighlight mHamburgerFeatureHighlight;
+ private ValueAnimator mStatusBarAnimator;
+
public interface TabState {
public static int ALL = 0;
@@ -451,8 +455,6 @@
}
private void update(boolean skipAnimation) {
- updateStatusBarColor();
-
updateOverflowButtonColor();
final boolean isSelectionModeChanging
@@ -463,6 +465,8 @@
= (mSearchContainer.getParent() == null) == mSearchMode;
final boolean isTabHeightChanging = isSearchModeChanging || isSelectionModeChanging;
+ updateStatusBarColor(isSelectionModeChanging && !isSearchModeChanging);
+
// When skipAnimation=true, it is possible that we will switch from search mode
// to selection mode directly. So we need to remove the undesired container in addition
// to adding the desired container.
@@ -594,18 +598,46 @@
textView.setText(title);
}
- private void updateStatusBarColor() {
+ private void updateStatusBarColor(boolean shouldAnimate) {
if (!CompatUtils.isLollipopCompatible()) {
return; // we can't change the status bar color prior to Lollipop
}
+
if (mSelectionMode) {
- final int cabStatusBarColor = mActivity.getResources().getColor(
- R.color.contextual_selection_bar_status_bar_color);
- mActivity.getWindow().setStatusBarColor(cabStatusBarColor);
+ final int cabStatusBarColor =ContextCompat.getColor(
+ mActivity, R.color.contextual_selection_bar_status_bar_color);
+ runStatusBarAnimation(/* colorTo */ cabStatusBarColor);
} else {
final int normalStatusBarColor = ContextCompat.getColor(
mActivity, R.color.primary_color_dark);
- mActivity.getWindow().setStatusBarColor(normalStatusBarColor);
+ if (shouldAnimate) {
+ runStatusBarAnimation(/* colorTo */ normalStatusBarColor);
+ } else {
+ mActivity.getWindow().setStatusBarColor(normalStatusBarColor);
+ }
+ }
+ }
+
+ private void runStatusBarAnimation(int colorTo) {
+ final Window window = mActivity.getWindow();
+ if (window.getStatusBarColor() != colorTo) {
+ // Cancel running animation.
+ if (mStatusBarAnimator != null && mStatusBarAnimator.isRunning()) {
+ mStatusBarAnimator.cancel();
+ }
+ final int from = window.getStatusBarColor();
+ // Set up mStatusBarAnimator and run animation.
+ mStatusBarAnimator = ValueAnimator.ofObject(new ArgbEvaluator(), from, colorTo);
+ mStatusBarAnimator.addUpdateListener(
+ new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animator) {
+ window.setStatusBarColor((Integer) animator.getAnimatedValue());
+ }
+ });
+ mStatusBarAnimator.setDuration(mActionBarAnimationDuration);
+ mStatusBarAnimator.setStartDelay(0);
+ mStatusBarAnimator.start();
}
}
diff --git a/src/com/android/contacts/common/CallUtil.java b/src/com/android/contacts/common/CallUtil.java
new file mode 100644
index 0000000..88fca92
--- /dev/null
+++ b/src/com/android/contacts/common/CallUtil.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common;
+
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.compat.PhoneAccountSdkCompat;
+import com.android.contacts.common.util.PermissionsUtil;
+import com.android.contacts.common.util.PhoneNumberHelper;
+import com.android.phone.common.PhoneConstants;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.telecom.PhoneAccount;
+import android.telecom.PhoneAccountHandle;
+import android.telecom.TelecomManager;
+import android.telecom.VideoProfile;
+import android.text.TextUtils;
+
+import java.util.List;
+
+/**
+ * Utilities related to calls that can be used by non system apps. These
+ * use {@link Intent#ACTION_CALL} instead of ACTION_CALL_PRIVILEGED.
+ *
+ * The privileged version of this util exists inside Dialer.
+ */
+public class CallUtil {
+
+ /**
+ * Indicates that the video calling is not available.
+ */
+ public static final int VIDEO_CALLING_DISABLED = 0;
+
+ /**
+ * Indicates that video calling is enabled, regardless of presence status.
+ */
+ public static final int VIDEO_CALLING_ENABLED = 1;
+
+ /**
+ * Indicates that video calling is enabled, but the availability of video call affordances is
+ * determined by the presence status associated with contacts.
+ */
+ public static final int VIDEO_CALLING_PRESENCE = 2;
+
+ /**
+ * Return an Intent for making a phone call. Scheme (e.g. tel, sip) will be determined
+ * automatically.
+ */
+ public static Intent getCallWithSubjectIntent(String number,
+ PhoneAccountHandle phoneAccountHandle, String callSubject) {
+
+ final Intent intent = getCallIntent(getCallUri(number));
+ intent.putExtra(TelecomManager.EXTRA_CALL_SUBJECT, callSubject);
+ if (phoneAccountHandle != null) {
+ intent.putExtra(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccountHandle);
+ }
+ return intent;
+ }
+
+ /**
+ * Return an Intent for making a phone call. Scheme (e.g. tel, sip) will be determined
+ * automatically.
+ */
+ public static Intent getCallIntent(String number) {
+ return getCallIntent(getCallUri(number));
+ }
+
+ /**
+ * Return an Intent for making a phone call. A given Uri will be used as is (without any
+ * sanity check).
+ */
+ public static Intent getCallIntent(Uri uri) {
+ return new Intent(Intent.ACTION_CALL, uri);
+ }
+
+ /**
+ * A variant of {@link #getCallIntent} for starting a video call.
+ */
+ public static Intent getVideoCallIntent(String number, String callOrigin) {
+ final Intent intent = new Intent(Intent.ACTION_CALL, getCallUri(number));
+ intent.putExtra(TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE,
+ VideoProfile.STATE_BIDIRECTIONAL);
+ if (!TextUtils.isEmpty(callOrigin)) {
+ intent.putExtra(PhoneConstants.EXTRA_CALL_ORIGIN, callOrigin);
+ }
+ return intent;
+ }
+
+ /**
+ * Return Uri with an appropriate scheme, accepting both SIP and usual phone call
+ * numbers.
+ */
+ public static Uri getCallUri(String number) {
+ if (PhoneNumberHelper.isUriNumber(number)) {
+ return Uri.fromParts(PhoneAccount.SCHEME_SIP, number, null);
+ }
+ return Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null);
+ }
+
+ /**
+ * @return Uri that directly dials a user's voicemail inbox.
+ */
+ public static Uri getVoicemailUri() {
+ return Uri.fromParts(PhoneAccount.SCHEME_VOICEMAIL, "", null);
+ }
+
+ /**
+ * Determines if video calling is available, and if so whether presence checking is available
+ * as well.
+ *
+ * Returns a bitmask with {@link #VIDEO_CALLING_ENABLED} to indicate that video calling is
+ * available, and {@link #VIDEO_CALLING_PRESENCE} if presence indication is also available.
+ *
+ * @param context The context
+ * @return A bit-mask describing the current video capabilities.
+ */
+ public static int getVideoCallingAvailability(Context context) {
+ if (!PermissionsUtil.hasPermission(context, android.Manifest.permission.READ_PHONE_STATE)
+ || !CompatUtils.isVideoCompatible()) {
+ return VIDEO_CALLING_DISABLED;
+ }
+ TelecomManager telecommMgr = (TelecomManager)
+ context.getSystemService(Context.TELECOM_SERVICE);
+ if (telecommMgr == null) {
+ return VIDEO_CALLING_DISABLED;
+ }
+
+ List<PhoneAccountHandle> accountHandles = telecommMgr.getCallCapablePhoneAccounts();
+ for (PhoneAccountHandle accountHandle : accountHandles) {
+ PhoneAccount account = telecommMgr.getPhoneAccount(accountHandle);
+ if (account != null) {
+ if (account.hasCapabilities(PhoneAccount.CAPABILITY_VIDEO_CALLING)) {
+ // Builds prior to N do not have presence support.
+ if (!CompatUtils.isVideoPresenceCompatible()) {
+ return VIDEO_CALLING_ENABLED;
+ }
+
+ int videoCapabilities = VIDEO_CALLING_ENABLED;
+ if (account.hasCapabilities(
+ PhoneAccountSdkCompat.CAPABILITY_VIDEO_CALLING_RELIES_ON_PRESENCE)) {
+ videoCapabilities |= VIDEO_CALLING_PRESENCE;
+ }
+ return videoCapabilities;
+ }
+ }
+ }
+ return VIDEO_CALLING_DISABLED;
+ }
+
+ /**
+ * Determines if one of the call capable phone accounts defined supports video calling.
+ *
+ * @param context The context.
+ * @return {@code true} if one of the call capable phone accounts supports video calling,
+ * {@code false} otherwise.
+ */
+ public static boolean isVideoEnabled(Context context) {
+ return (getVideoCallingAvailability(context) & VIDEO_CALLING_ENABLED) != 0;
+ }
+
+ /**
+ * Determines if one of the call capable phone accounts defined supports calling with a subject
+ * specified.
+ *
+ * @param context The context.
+ * @return {@code true} if one of the call capable phone accounts supports calling with a
+ * subject specified, {@code false} otherwise.
+ */
+ public static boolean isCallWithSubjectSupported(Context context) {
+ if (!PermissionsUtil.hasPermission(context, android.Manifest.permission.READ_PHONE_STATE)
+ || !CompatUtils.isCallSubjectCompatible()) {
+ return false;
+ }
+ TelecomManager telecommMgr = (TelecomManager)
+ context.getSystemService(Context.TELECOM_SERVICE);
+ if (telecommMgr == null) {
+ return false;
+ }
+
+ List<PhoneAccountHandle> accountHandles = telecommMgr.getCallCapablePhoneAccounts();
+ for (PhoneAccountHandle accountHandle : accountHandles) {
+ PhoneAccount account = telecommMgr.getPhoneAccount(accountHandle);
+ if (account != null && account.hasCapabilities(PhoneAccount.CAPABILITY_CALL_SUBJECT)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/com/android/contacts/common/ClipboardUtils.java b/src/com/android/contacts/common/ClipboardUtils.java
new file mode 100644
index 0000000..27af963
--- /dev/null
+++ b/src/com/android/contacts/common/ClipboardUtils.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.text.TextUtils;
+import android.widget.Toast;
+
+public class ClipboardUtils {
+ private static final String TAG = "ClipboardUtils";
+
+ private ClipboardUtils() { }
+
+ /**
+ * Copy a text to clipboard.
+ *
+ * @param context Context
+ * @param label Label to show to the user describing this clip.
+ * @param text Text to copy.
+ * @param showToast If {@code true}, a toast is shown to the user.
+ */
+ public static void copyText(Context context, CharSequence label, CharSequence text,
+ boolean showToast) {
+ if (TextUtils.isEmpty(text)) return;
+
+ ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(
+ Context.CLIPBOARD_SERVICE);
+ ClipData clipData = ClipData.newPlainText(label == null ? "" : label, text);
+ clipboardManager.setPrimaryClip(clipData);
+
+ if (showToast) {
+ String toastText = context.getString(R.string.toast_text_copied);
+ Toast.makeText(context, toastText, Toast.LENGTH_SHORT).show();
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/Collapser.java b/src/com/android/contacts/common/Collapser.java
new file mode 100644
index 0000000..1ab63c5
--- /dev/null
+++ b/src/com/android/contacts/common/Collapser.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common;
+
+import android.content.Context;
+
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Class used for collapsing data items into groups of similar items. The data items that should be
+ * collapsible should implement the Collapsible interface. The class also contains a utility
+ * function that takes an ArrayList of items and returns a list of the same items collapsed into
+ * groups.
+ */
+public final class Collapser {
+
+ /*
+ * This utility class cannot be instantiated.
+ */
+ private Collapser() {}
+
+ /*
+ * The Collapser uses an n^2 algorithm so we don't want it to run on
+ * lists beyond a certain size. This specifies the maximum size to collapse.
+ */
+ private static final int MAX_LISTSIZE_TO_COLLAPSE = 20;
+
+ /*
+ * Interface implemented by data types that can be collapsed into groups of similar data. This
+ * can be used for example to collapse similar contact data items into a single item.
+ */
+ public interface Collapsible<T> {
+ public void collapseWith(T t);
+ public boolean shouldCollapseWith(T t, Context context);
+
+ }
+
+ /**
+ * Collapses a list of Collapsible items into a list of collapsed items. Items are collapsed
+ * if {@link Collapsible#shouldCollapseWith(Object)} returns true, and are collapsed
+ * through the {@Link Collapsible#collapseWith(Object)} function implemented by the data item.
+ *
+ * @param list List of Objects of type <T extends Collapsible<T>> to be collapsed.
+ */
+ public static <T extends Collapsible<T>> void collapseList(List<T> list, Context context) {
+
+ int listSize = list.size();
+ // The algorithm below is n^2 so don't run on long lists
+ if (listSize > MAX_LISTSIZE_TO_COLLAPSE) {
+ return;
+ }
+
+ for (int i = 0; i < listSize; i++) {
+ T iItem = list.get(i);
+ if (iItem != null) {
+ for (int j = i + 1; j < listSize; j++) {
+ T jItem = list.get(j);
+ if (jItem != null) {
+ if (iItem.shouldCollapseWith(jItem, context)) {
+ iItem.collapseWith(jItem);
+ list.set(j, null);
+ } else if (jItem.shouldCollapseWith(iItem, context)) {
+ jItem.collapseWith(iItem);
+ list.set(i, null);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Remove the null items
+ Iterator<T> itr = list.iterator();
+ while (itr.hasNext()) {
+ if (itr.next() == null) {
+ itr.remove();
+ }
+ }
+
+ }
+}
diff --git a/src/com/android/contacts/common/ContactPhotoManager.java b/src/com/android/contacts/common/ContactPhotoManager.java
new file mode 100644
index 0000000..81b6b06
--- /dev/null
+++ b/src/com/android/contacts/common/ContactPhotoManager.java
@@ -0,0 +1,1720 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common;
+
+import android.app.ActivityManager;
+import android.content.ComponentCallbacks2;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Paint.Style;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.TransitionDrawable;
+import android.media.ThumbnailUtils;
+import android.net.TrafficStats;
+import android.net.Uri;
+import android.net.Uri.Builder;
+import android.os.Handler;
+import android.os.Handler.Callback;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Contacts.Photo;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Directory;
+import android.support.v4.graphics.drawable.RoundedBitmapDrawable;
+import android.support.v4.graphics.drawable.RoundedBitmapDrawableFactory;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.LruCache;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import com.android.contacts.common.lettertiles.LetterTileDrawable;
+import com.android.contacts.common.util.BitmapUtil;
+import com.android.contacts.common.util.PermissionsUtil;
+import com.android.contacts.common.util.TrafficStatsTags;
+import com.android.contacts.common.util.UriUtils;
+import com.android.contacts.commonbind.util.UserAgentGenerator;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.ref.Reference;
+import java.lang.ref.SoftReference;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Asynchronously loads contact photos and maintains a cache of photos.
+ */
+public abstract class ContactPhotoManager implements ComponentCallbacks2 {
+ static final String TAG = "ContactPhotoManager";
+ static final boolean DEBUG = false; // Don't submit with true
+ static final boolean DEBUG_SIZES = false; // Don't submit with true
+
+ /** Contact type constants used for default letter images */
+ public static final int TYPE_PERSON = LetterTileDrawable.TYPE_PERSON;
+ public static final int TYPE_BUSINESS = LetterTileDrawable.TYPE_BUSINESS;
+ public static final int TYPE_VOICEMAIL = LetterTileDrawable.TYPE_VOICEMAIL;
+ public static final int TYPE_DEFAULT = LetterTileDrawable.TYPE_DEFAULT;
+
+ /** Scale and offset default constants used for default letter images */
+ public static final float SCALE_DEFAULT = 1.0f;
+ public static final float OFFSET_DEFAULT = 0.0f;
+
+ public static final boolean IS_CIRCULAR_DEFAULT = false;
+
+ /** Uri-related constants used for default letter images */
+ private static final String DISPLAY_NAME_PARAM_KEY = "display_name";
+ private static final String IDENTIFIER_PARAM_KEY = "identifier";
+ private static final String CONTACT_TYPE_PARAM_KEY = "contact_type";
+ private static final String SCALE_PARAM_KEY = "scale";
+ private static final String OFFSET_PARAM_KEY = "offset";
+ private static final String IS_CIRCULAR_PARAM_KEY = "is_circular";
+ private static final String DEFAULT_IMAGE_URI_SCHEME = "defaultimage";
+ private static final Uri DEFAULT_IMAGE_URI = Uri.parse(DEFAULT_IMAGE_URI_SCHEME + "://");
+
+ // Static field used to cache the default letter avatar drawable that is created
+ // using a null {@link DefaultImageRequest}
+ private static Drawable sDefaultLetterAvatar = null;
+
+ private static ContactPhotoManager sInstance;
+
+ /**
+ * Given a {@link DefaultImageRequest}, returns a {@link Drawable}, that when drawn, will
+ * draw a letter tile avatar based on the request parameters defined in the
+ * {@link DefaultImageRequest}.
+ */
+ public static Drawable getDefaultAvatarDrawableForContact(Resources resources, boolean hires,
+ DefaultImageRequest defaultImageRequest) {
+ if (defaultImageRequest == null) {
+ if (sDefaultLetterAvatar == null) {
+ // Cache and return the letter tile drawable that is created by a null request,
+ // so that it doesn't have to be recreated every time it is requested again.
+ sDefaultLetterAvatar = LetterTileDefaultImageProvider.getDefaultImageForContact(
+ resources, null);
+ }
+ return sDefaultLetterAvatar;
+ }
+ return LetterTileDefaultImageProvider.getDefaultImageForContact(resources,
+ defaultImageRequest);
+ }
+
+ /**
+ * Given a {@link DefaultImageRequest}, returns an Uri that can be used to request a
+ * letter tile avatar when passed to the {@link ContactPhotoManager}. The internal
+ * implementation of this uri is not guaranteed to remain the same across application
+ * versions, so the actual uri should never be persisted in long-term storage and reused.
+ *
+ * @param request A {@link DefaultImageRequest} object with the fields configured
+ * to return a
+ * @return A Uri that when later passed to the {@link ContactPhotoManager} via
+ * {@link #loadPhoto(ImageView, Uri, int, boolean, DefaultImageRequest)}, can be
+ * used to request a default contact image, drawn as a letter tile using the
+ * parameters as configured in the provided {@link DefaultImageRequest}
+ */
+ public static Uri getDefaultAvatarUriForContact(DefaultImageRequest request) {
+ final Builder builder = DEFAULT_IMAGE_URI.buildUpon();
+ if (request != null) {
+ if (!TextUtils.isEmpty(request.displayName)) {
+ builder.appendQueryParameter(DISPLAY_NAME_PARAM_KEY, request.displayName);
+ }
+ if (!TextUtils.isEmpty(request.identifier)) {
+ builder.appendQueryParameter(IDENTIFIER_PARAM_KEY, request.identifier);
+ }
+ if (request.contactType != TYPE_DEFAULT) {
+ builder.appendQueryParameter(CONTACT_TYPE_PARAM_KEY,
+ String.valueOf(request.contactType));
+ }
+ if (request.scale != SCALE_DEFAULT) {
+ builder.appendQueryParameter(SCALE_PARAM_KEY, String.valueOf(request.scale));
+ }
+ if (request.offset != OFFSET_DEFAULT) {
+ builder.appendQueryParameter(OFFSET_PARAM_KEY, String.valueOf(request.offset));
+ }
+ if (request.isCircular != IS_CIRCULAR_DEFAULT) {
+ builder.appendQueryParameter(IS_CIRCULAR_PARAM_KEY,
+ String.valueOf(request.isCircular));
+ }
+
+ }
+ return builder.build();
+ }
+
+ /**
+ * Adds a business contact type encoded fragment to the URL. Used to ensure photo URLS
+ * from Nearby Places can be identified as business photo URLs rather than URLs for personal
+ * contact photos.
+ *
+ * @param photoUrl The photo URL to modify.
+ * @return URL with the contact type parameter added and set to TYPE_BUSINESS.
+ */
+ public static String appendBusinessContactType(String photoUrl) {
+ Uri uri = Uri.parse(photoUrl);
+ Builder builder = uri.buildUpon();
+ builder.encodedFragment(String.valueOf(TYPE_BUSINESS));
+ return builder.build().toString();
+ }
+
+ /**
+ * Removes the contact type information stored in the photo URI encoded fragment.
+ *
+ * @param photoUri The photo URI to remove the contact type from.
+ * @return The photo URI with contact type removed.
+ */
+ public static Uri removeContactType(Uri photoUri) {
+ String encodedFragment = photoUri.getEncodedFragment();
+ if (!TextUtils.isEmpty(encodedFragment)) {
+ Builder builder = photoUri.buildUpon();
+ builder.encodedFragment(null);
+ return builder.build();
+ }
+ return photoUri;
+ }
+
+ /**
+ * Inspects a photo URI to determine if the photo URI represents a business.
+ *
+ * @param photoUri The URI to inspect.
+ * @return Whether the URI represents a business photo or not.
+ */
+ public static boolean isBusinessContactUri(Uri photoUri) {
+ if (photoUri == null) {
+ return false;
+ }
+
+ String encodedFragment = photoUri.getEncodedFragment();
+ return !TextUtils.isEmpty(encodedFragment)
+ && encodedFragment.equals(String.valueOf(TYPE_BUSINESS));
+ }
+
+ protected static DefaultImageRequest getDefaultImageRequestFromUri(Uri uri) {
+ final DefaultImageRequest request = new DefaultImageRequest(
+ uri.getQueryParameter(DISPLAY_NAME_PARAM_KEY),
+ uri.getQueryParameter(IDENTIFIER_PARAM_KEY), false);
+ try {
+ String contactType = uri.getQueryParameter(CONTACT_TYPE_PARAM_KEY);
+ if (!TextUtils.isEmpty(contactType)) {
+ request.contactType = Integer.valueOf(contactType);
+ }
+
+ String scale = uri.getQueryParameter(SCALE_PARAM_KEY);
+ if (!TextUtils.isEmpty(scale)) {
+ request.scale = Float.valueOf(scale);
+ }
+
+ String offset = uri.getQueryParameter(OFFSET_PARAM_KEY);
+ if (!TextUtils.isEmpty(offset)) {
+ request.offset = Float.valueOf(offset);
+ }
+
+ String isCircular = uri.getQueryParameter(IS_CIRCULAR_PARAM_KEY);
+ if (!TextUtils.isEmpty(isCircular)) {
+ request.isCircular = Boolean.valueOf(isCircular);
+ }
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Invalid DefaultImageRequest image parameters provided, ignoring and using "
+ + "defaults.");
+ }
+
+ return request;
+ }
+
+ protected boolean isDefaultImageUri(Uri uri) {
+ return DEFAULT_IMAGE_URI_SCHEME.equals(uri.getScheme());
+ }
+
+ /**
+ * Contains fields used to contain contact details and other user-defined settings that might
+ * be used by the ContactPhotoManager to generate a default contact image. This contact image
+ * takes the form of a letter or bitmap drawn on top of a colored tile.
+ */
+ public static class DefaultImageRequest {
+ /**
+ * The contact's display name. The display name is used to
+ */
+ public String displayName;
+
+ /**
+ * A unique and deterministic string that can be used to identify this contact. This is
+ * usually the contact's lookup key, but other contact details can be used as well,
+ * especially for non-local or temporary contacts that might not have a lookup key. This
+ * is used to determine the color of the tile.
+ */
+ public String identifier;
+
+ /**
+ * The type of this contact. This contact type may be used to decide the kind of
+ * image to use in the case where a unique letter cannot be generated from the contact's
+ * display name and identifier. See:
+ * {@link #TYPE_PERSON}
+ * {@link #TYPE_BUSINESS}
+ * {@link #TYPE_PERSON}
+ * {@link #TYPE_DEFAULT}
+ */
+ public int contactType = TYPE_DEFAULT;
+
+ /**
+ * The amount to scale the letter or bitmap to, as a ratio of its default size (from a
+ * range of 0.0f to 2.0f). The default value is 1.0f.
+ */
+ public float scale = SCALE_DEFAULT;
+
+ /**
+ * The amount to vertically offset the letter or image to within the tile.
+ * The provided offset must be within the range of -0.5f to 0.5f.
+ * If set to -0.5f, the letter will be shifted upwards by 0.5 times the height of the canvas
+ * it is being drawn on, which means it will be drawn with the center of the letter starting
+ * at the top edge of the canvas.
+ * If set to 0.5f, the letter will be shifted downwards by 0.5 times the height of the
+ * canvas it is being drawn on, which means it will be drawn with the center of the letter
+ * starting at the bottom edge of the canvas.
+ * The default is 0.0f, which means the letter is drawn in the exact vertical center of
+ * the tile.
+ */
+ public float offset = OFFSET_DEFAULT;
+
+ /**
+ * Whether or not to draw the default image as a circle, instead of as a square/rectangle.
+ */
+ public boolean isCircular = false;
+
+ /**
+ * Used to indicate that a drawable that represents a contact without any contact details
+ * should be returned.
+ */
+ public static DefaultImageRequest EMPTY_DEFAULT_IMAGE_REQUEST = new DefaultImageRequest();
+
+ /**
+ * Used to indicate that a drawable that represents a business without a business photo
+ * should be returned.
+ */
+ public static DefaultImageRequest EMPTY_DEFAULT_BUSINESS_IMAGE_REQUEST =
+ new DefaultImageRequest(null, null, TYPE_BUSINESS, false);
+
+ /**
+ * Used to indicate that a circular drawable that represents a contact without any contact
+ * details should be returned.
+ */
+ public static DefaultImageRequest EMPTY_CIRCULAR_DEFAULT_IMAGE_REQUEST =
+ new DefaultImageRequest(null, null, true);
+
+ /**
+ * Used to indicate that a circular drawable that represents a business without a business
+ * photo should be returned.
+ */
+ public static DefaultImageRequest EMPTY_CIRCULAR_BUSINESS_IMAGE_REQUEST =
+ new DefaultImageRequest(null, null, TYPE_BUSINESS, true);
+
+ public DefaultImageRequest() {
+ }
+
+ public DefaultImageRequest(String displayName, String identifier, boolean isCircular) {
+ this(displayName, identifier, TYPE_DEFAULT, SCALE_DEFAULT, OFFSET_DEFAULT, isCircular);
+ }
+
+ public DefaultImageRequest(String displayName, String identifier, int contactType,
+ boolean isCircular) {
+ this(displayName, identifier, contactType, SCALE_DEFAULT, OFFSET_DEFAULT, isCircular);
+ }
+
+ public DefaultImageRequest(String displayName, String identifier, int contactType,
+ float scale, float offset, boolean isCircular) {
+ this.displayName = displayName;
+ this.identifier = identifier;
+ this.contactType = contactType;
+ this.scale = scale;
+ this.offset = offset;
+ this.isCircular = isCircular;
+ }
+ }
+
+ public static abstract class DefaultImageProvider {
+ /**
+ * Applies the default avatar to the ImageView. Extent is an indicator for the size (width
+ * or height). If darkTheme is set, the avatar is one that looks better on dark background
+ *
+ * @param defaultImageRequest {@link DefaultImageRequest} object that specifies how a
+ * default letter tile avatar should be drawn.
+ */
+ public abstract void applyDefaultImage(ImageView view, int extent, boolean darkTheme,
+ DefaultImageRequest defaultImageRequest);
+ }
+
+ /**
+ * A default image provider that applies a letter tile consisting of a colored background
+ * and a letter in the foreground as the default image for a contact. The color of the
+ * background and the type of letter is decided based on the contact's details.
+ */
+ private static class LetterTileDefaultImageProvider extends DefaultImageProvider {
+ @Override
+ public void applyDefaultImage(ImageView view, int extent, boolean darkTheme,
+ DefaultImageRequest defaultImageRequest) {
+ final Drawable drawable = getDefaultImageForContact(view.getResources(),
+ defaultImageRequest);
+ view.setImageDrawable(drawable);
+ }
+
+ public static Drawable getDefaultImageForContact(Resources resources,
+ DefaultImageRequest defaultImageRequest) {
+ final LetterTileDrawable drawable = new LetterTileDrawable(resources);
+ if (defaultImageRequest != null) {
+ // If the contact identifier is null or empty, fallback to the
+ // displayName. In that case, use {@code null} for the contact's
+ // display name so that a default bitmap will be used instead of a
+ // letter
+ if (TextUtils.isEmpty(defaultImageRequest.identifier)) {
+ drawable.setLetterAndColorFromContactDetails(null,
+ defaultImageRequest.displayName);
+ } else {
+ drawable.setLetterAndColorFromContactDetails(defaultImageRequest.displayName,
+ defaultImageRequest.identifier);
+ }
+ drawable.setContactType(defaultImageRequest.contactType);
+ drawable.setScale(defaultImageRequest.scale);
+ drawable.setOffset(defaultImageRequest.offset);
+ drawable.setIsCircular(defaultImageRequest.isCircular);
+ }
+ return drawable;
+ }
+ }
+
+ private static class BlankDefaultImageProvider extends DefaultImageProvider {
+ private static Drawable sDrawable;
+
+ @Override
+ public void applyDefaultImage(ImageView view, int extent, boolean darkTheme,
+ DefaultImageRequest defaultImageRequest) {
+ if (sDrawable == null) {
+ Context context = view.getContext();
+ sDrawable = new ColorDrawable(context.getResources().getColor(
+ R.color.image_placeholder));
+ }
+ view.setImageDrawable(sDrawable);
+ }
+ }
+
+ public static DefaultImageProvider DEFAULT_AVATAR = new LetterTileDefaultImageProvider();
+
+ public static final DefaultImageProvider DEFAULT_BLANK = new BlankDefaultImageProvider();
+
+ public static ContactPhotoManager getInstance(Context context) {
+ if (sInstance == null) {
+ Context applicationContext = context.getApplicationContext();
+ sInstance = createContactPhotoManager(applicationContext);
+ applicationContext.registerComponentCallbacks(sInstance);
+ if (PermissionsUtil.hasContactsPermissions(context)) {
+ sInstance.preloadPhotosInBackground();
+ }
+ }
+ return sInstance;
+ }
+
+ public static synchronized ContactPhotoManager createContactPhotoManager(Context context) {
+ return new ContactPhotoManagerImpl(context);
+ }
+
+ @VisibleForTesting
+ public static void injectContactPhotoManagerForTesting(ContactPhotoManager photoManager) {
+ sInstance = photoManager;
+ }
+
+ /**
+ * Load thumbnail image into the supplied image view. If the photo is already cached,
+ * it is displayed immediately. Otherwise a request is sent to load the photo
+ * from the database.
+ */
+ public abstract void loadThumbnail(ImageView view, long photoId, boolean darkTheme,
+ boolean isCircular, DefaultImageRequest defaultImageRequest,
+ DefaultImageProvider defaultProvider);
+
+ /**
+ * Calls {@link #loadThumbnail(ImageView, long, boolean, DefaultImageRequest,
+ * DefaultImageProvider)} using the {@link DefaultImageProvider} {@link #DEFAULT_AVATAR}.
+ */
+ public final void loadThumbnail(ImageView view, long photoId, boolean darkTheme,
+ boolean isCircular, DefaultImageRequest defaultImageRequest) {
+ loadThumbnail(view, photoId, darkTheme, isCircular, defaultImageRequest, DEFAULT_AVATAR);
+ }
+
+
+ /**
+ * Load photo into the supplied image view. If the photo is already cached,
+ * it is displayed immediately. Otherwise a request is sent to load the photo
+ * from the location specified by the URI.
+ *
+ * @param view The target view
+ * @param photoUri The uri of the photo to load
+ * @param requestedExtent Specifies an approximate Max(width, height) of the targetView.
+ * This is useful if the source image can be a lot bigger that the target, so that the decoding
+ * is done using efficient sampling. If requestedExtent is specified, no sampling of the image
+ * is performed
+ * @param darkTheme Whether the background is dark. This is used for default avatars
+ * @param defaultImageRequest {@link DefaultImageRequest} object that specifies how a default
+ * letter tile avatar should be drawn.
+ * @param defaultProvider The provider of default avatars (this is used if photoUri doesn't
+ * refer to an existing image)
+ */
+ public abstract void loadPhoto(ImageView view, Uri photoUri, int requestedExtent,
+ boolean darkTheme, boolean isCircular, DefaultImageRequest defaultImageRequest,
+ DefaultImageProvider defaultProvider);
+
+ /**
+ * Calls {@link #loadPhoto(ImageView, Uri, int, boolean, DefaultImageRequest,
+ * DefaultImageProvider)} with {@link #DEFAULT_AVATAR} and {@code null} display names and
+ * lookup keys.
+ *
+ * @param defaultImageRequest {@link DefaultImageRequest} object that specifies how a default
+ * letter tile avatar should be drawn.
+ */
+ public final void loadPhoto(ImageView view, Uri photoUri, int requestedExtent,
+ boolean darkTheme, boolean isCircular, DefaultImageRequest defaultImageRequest) {
+ loadPhoto(view, photoUri, requestedExtent, darkTheme, isCircular,
+ defaultImageRequest, DEFAULT_AVATAR);
+ }
+
+ /**
+ * Calls {@link #loadPhoto(ImageView, Uri, boolean, boolean, DefaultImageRequest,
+ * DefaultImageProvider)} with {@link #DEFAULT_AVATAR} and with the assumption, that
+ * the image is a thumbnail.
+ *
+ * @param defaultImageRequest {@link DefaultImageRequest} object that specifies how a default
+ * letter tile avatar should be drawn.
+ */
+ public final void loadDirectoryPhoto(ImageView view, Uri photoUri, boolean darkTheme,
+ boolean isCircular, DefaultImageRequest defaultImageRequest) {
+ loadPhoto(view, photoUri, -1, darkTheme, isCircular, defaultImageRequest, DEFAULT_AVATAR);
+ }
+
+ /**
+ * Remove photo from the supplied image view. This also cancels current pending load request
+ * inside this photo manager.
+ */
+ public abstract void removePhoto(ImageView view);
+
+ /**
+ * Cancels all pending requests to load photos asynchronously.
+ */
+ public abstract void cancelPendingRequests(View fragmentRootView);
+
+ /**
+ * Temporarily stops loading photos from the database.
+ */
+ public abstract void pause();
+
+ /**
+ * Resumes loading photos from the database.
+ */
+ public abstract void resume();
+
+ /**
+ * Marks all cached photos for reloading. We can continue using cache but should
+ * also make sure the photos haven't changed in the background and notify the views
+ * if so.
+ */
+ public abstract void refreshCache();
+
+ /**
+ * Stores the given bitmap directly in the LRU bitmap cache.
+ * @param photoUri The URI of the photo (for future requests).
+ * @param bitmap The bitmap.
+ * @param photoBytes The bytes that were parsed to create the bitmap.
+ */
+ public abstract void cacheBitmap(Uri photoUri, Bitmap bitmap, byte[] photoBytes);
+
+ /**
+ * Initiates a background process that over time will fill up cache with
+ * preload photos.
+ */
+ public abstract void preloadPhotosInBackground();
+
+ // ComponentCallbacks2
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ }
+
+ // ComponentCallbacks2
+ @Override
+ public void onLowMemory() {
+ }
+
+ // ComponentCallbacks2
+ @Override
+ public void onTrimMemory(int level) {
+ }
+}
+
+class ContactPhotoManagerImpl extends ContactPhotoManager implements Callback {
+ private static final String LOADER_THREAD_NAME = "ContactPhotoLoader";
+
+ private static final int FADE_TRANSITION_DURATION = 200;
+
+ /**
+ * 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_PHOTOS_LOADED = 2;
+
+ private static final String[] EMPTY_STRING_ARRAY = new String[0];
+
+ private static final String[] COLUMNS = new String[] { Photo._ID, Photo.PHOTO };
+
+ /**
+ * Dummy object used to indicate that a bitmap for a given key could not be stored in the
+ * cache.
+ */
+ private static final BitmapHolder BITMAP_UNAVAILABLE;
+
+ static {
+ BITMAP_UNAVAILABLE = new BitmapHolder(new byte[0], 0);
+ BITMAP_UNAVAILABLE.bitmapRef = new SoftReference<Bitmap>(null);
+ }
+
+ /**
+ * Maintains the state of a particular photo.
+ */
+ private static class BitmapHolder {
+ final byte[] bytes;
+ final int originalSmallerExtent;
+
+ volatile boolean fresh;
+ Bitmap bitmap;
+ Reference<Bitmap> bitmapRef;
+ int decodedSampleSize;
+
+ public BitmapHolder(byte[] bytes, int originalSmallerExtent) {
+ this.bytes = bytes;
+ this.fresh = true;
+ this.originalSmallerExtent = originalSmallerExtent;
+ }
+ }
+
+ private final Context mContext;
+
+ /**
+ * An LRU cache for bitmap holders. The cache contains bytes for photos just
+ * as they come from the database. Each holder has a soft reference to the
+ * actual bitmap.
+ */
+ private final LruCache<Object, BitmapHolder> mBitmapHolderCache;
+
+ /**
+ * {@code true} if ALL entries in {@link #mBitmapHolderCache} are NOT fresh.
+ */
+ private volatile boolean mBitmapHolderCacheAllUnfresh = true;
+
+ /**
+ * Cache size threshold at which bitmaps will not be preloaded.
+ */
+ private final int mBitmapHolderCacheRedZoneBytes;
+
+ /**
+ * Level 2 LRU cache for bitmaps. This is a smaller cache that holds
+ * the most recently used bitmaps to save time on decoding
+ * them from bytes (the bytes are stored in {@link #mBitmapHolderCache}.
+ */
+ private final LruCache<Object, Bitmap> mBitmapCache;
+
+ /**
+ * A map from ImageView to the corresponding photo ID or uri, encapsulated in a request.
+ * The request may swapped out before the photo loading request is started.
+ */
+ private final ConcurrentHashMap<ImageView, Request> mPendingRequests =
+ new ConcurrentHashMap<ImageView, Request>();
+
+ /**
+ * Handler for messages sent to the UI thread.
+ */
+ private final Handler mMainThreadHandler = new Handler(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;
+
+ /** Cache size for {@link #mBitmapHolderCache} for devices with "large" RAM. */
+ private static final int HOLDER_CACHE_SIZE = 2000000;
+
+ /** Cache size for {@link #mBitmapCache} for devices with "large" RAM. */
+ private static final int BITMAP_CACHE_SIZE = 36864 * 48; // 1728K
+
+ /** Height/width of a thumbnail image */
+ private static int mThumbnailSize;
+
+ /** For debug: How many times we had to reload cached photo for a stale entry */
+ private final AtomicInteger mStaleCacheOverwrite = new AtomicInteger();
+
+ /** For debug: How many times we had to reload cached photo for a fresh entry. Should be 0. */
+ private final AtomicInteger mFreshCacheOverwrite = new AtomicInteger();
+
+ /**
+ * The user agent string to use when loading URI based photos.
+ */
+ private String mUserAgent;
+
+ public ContactPhotoManagerImpl(Context context) {
+ mContext = context;
+
+ final ActivityManager am = ((ActivityManager) context.getSystemService(
+ Context.ACTIVITY_SERVICE));
+
+ final float cacheSizeAdjustment = (am.isLowRamDevice()) ? 0.5f : 1.0f;
+
+ final int bitmapCacheSize = (int) (cacheSizeAdjustment * BITMAP_CACHE_SIZE);
+ mBitmapCache = new LruCache<Object, Bitmap>(bitmapCacheSize) {
+ @Override protected int sizeOf(Object key, Bitmap value) {
+ return value.getByteCount();
+ }
+
+ @Override protected void entryRemoved(
+ boolean evicted, Object key, Bitmap oldValue, Bitmap newValue) {
+ if (DEBUG) dumpStats();
+ }
+ };
+ final int holderCacheSize = (int) (cacheSizeAdjustment * HOLDER_CACHE_SIZE);
+ mBitmapHolderCache = new LruCache<Object, BitmapHolder>(holderCacheSize) {
+ @Override protected int sizeOf(Object key, BitmapHolder value) {
+ return value.bytes != null ? value.bytes.length : 0;
+ }
+
+ @Override protected void entryRemoved(
+ boolean evicted, Object key, BitmapHolder oldValue, BitmapHolder newValue) {
+ if (DEBUG) dumpStats();
+ }
+ };
+ mBitmapHolderCacheRedZoneBytes = (int) (holderCacheSize * 0.75);
+ Log.i(TAG, "Cache adj: " + cacheSizeAdjustment);
+ if (DEBUG) {
+ Log.d(TAG, "Cache size: " + btk(mBitmapHolderCache.maxSize())
+ + " + " + btk(mBitmapCache.maxSize()));
+ }
+
+ mThumbnailSize = context.getResources().getDimensionPixelSize(
+ R.dimen.contact_browser_list_item_photo_size);
+
+ // Get a user agent string to use for URI photo requests.
+ mUserAgent = UserAgentGenerator.getUserAgent(context);
+ if (mUserAgent == null) {
+ mUserAgent = "";
+ }
+ }
+
+ /** Converts bytes to K bytes, rounding up. Used only for debug log. */
+ private static String btk(int bytes) {
+ return ((bytes + 1023) / 1024) + "K";
+ }
+
+ private static final int safeDiv(int dividend, int divisor) {
+ return (divisor == 0) ? 0 : (dividend / divisor);
+ }
+
+ /**
+ * Dump cache stats on logcat.
+ */
+ private void dumpStats() {
+ if (!DEBUG) return;
+ {
+ int numHolders = 0;
+ int rawBytes = 0;
+ int bitmapBytes = 0;
+ int numBitmaps = 0;
+ for (BitmapHolder h : mBitmapHolderCache.snapshot().values()) {
+ numHolders++;
+ if (h.bytes != null) {
+ rawBytes += h.bytes.length;
+ }
+ Bitmap b = h.bitmapRef != null ? h.bitmapRef.get() : null;
+ if (b != null) {
+ numBitmaps++;
+ bitmapBytes += b.getByteCount();
+ }
+ }
+ Log.d(TAG, "L1: " + btk(rawBytes) + " + " + btk(bitmapBytes) + " = "
+ + btk(rawBytes + bitmapBytes) + ", " + numHolders + " holders, "
+ + numBitmaps + " bitmaps, avg: "
+ + btk(safeDiv(rawBytes, numHolders))
+ + "," + btk(safeDiv(bitmapBytes,numBitmaps)));
+ Log.d(TAG, "L1 Stats: " + mBitmapHolderCache.toString()
+ + ", overwrite: fresh=" + mFreshCacheOverwrite.get()
+ + " stale=" + mStaleCacheOverwrite.get());
+ }
+
+ {
+ int numBitmaps = 0;
+ int bitmapBytes = 0;
+ for (Bitmap b : mBitmapCache.snapshot().values()) {
+ numBitmaps++;
+ bitmapBytes += b.getByteCount();
+ }
+ Log.d(TAG, "L2: " + btk(bitmapBytes) + ", " + numBitmaps + " bitmaps"
+ + ", avg: " + btk(safeDiv(bitmapBytes, numBitmaps)));
+ // We don't get from L2 cache, so L2 stats is meaningless.
+ }
+ }
+
+ @Override
+ public void onTrimMemory(int level) {
+ if (DEBUG) Log.d(TAG, "onTrimMemory: " + level);
+ if (level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
+ // Clear the caches. Note all pending requests will be removed too.
+ clear();
+ }
+ }
+
+ @Override
+ public void preloadPhotosInBackground() {
+ ensureLoaderThread();
+ mLoaderThread.requestPreloading();
+ }
+
+ @Override
+ public void loadThumbnail(ImageView view, long photoId, boolean darkTheme, boolean isCircular,
+ DefaultImageRequest defaultImageRequest, DefaultImageProvider defaultProvider) {
+ if (photoId == 0) {
+ // No photo is needed
+ defaultProvider.applyDefaultImage(view, -1, darkTheme, defaultImageRequest);
+ mPendingRequests.remove(view);
+ } else {
+ if (DEBUG) Log.d(TAG, "loadPhoto request: " + photoId);
+ loadPhotoByIdOrUri(view, Request.createFromThumbnailId(photoId, darkTheme, isCircular,
+ defaultProvider));
+ }
+ }
+
+ @Override
+ public void loadPhoto(ImageView view, Uri photoUri, int requestedExtent, boolean darkTheme,
+ boolean isCircular, DefaultImageRequest defaultImageRequest,
+ DefaultImageProvider defaultProvider) {
+ if (photoUri == null) {
+ // No photo is needed
+ defaultProvider.applyDefaultImage(view, requestedExtent, darkTheme,
+ defaultImageRequest);
+ mPendingRequests.remove(view);
+ } else {
+ if (DEBUG) Log.d(TAG, "loadPhoto request: " + photoUri);
+ if (isDefaultImageUri(photoUri)) {
+ createAndApplyDefaultImageForUri(view, photoUri, requestedExtent, darkTheme,
+ isCircular, defaultProvider);
+ } else {
+ loadPhotoByIdOrUri(view, Request.createFromUri(photoUri, requestedExtent,
+ darkTheme, isCircular, defaultProvider));
+ }
+ }
+ }
+
+ private void createAndApplyDefaultImageForUri(ImageView view, Uri uri, int requestedExtent,
+ boolean darkTheme, boolean isCircular, DefaultImageProvider defaultProvider) {
+ DefaultImageRequest request = getDefaultImageRequestFromUri(uri);
+ request.isCircular = isCircular;
+ defaultProvider.applyDefaultImage(view, requestedExtent, darkTheme, request);
+ }
+
+ private void loadPhotoByIdOrUri(ImageView view, Request request) {
+ boolean loaded = loadCachedPhoto(view, request, false);
+ if (loaded) {
+ mPendingRequests.remove(view);
+ } else {
+ mPendingRequests.put(view, request);
+ if (!mPaused) {
+ // Send a request to start loading photos
+ requestLoading();
+ }
+ }
+ }
+
+ @Override
+ public void removePhoto(ImageView view) {
+ view.setImageDrawable(null);
+ mPendingRequests.remove(view);
+ }
+
+
+ /**
+ * Cancels pending requests to load photos asynchronously for views inside
+ * {@param fragmentRootView}. If {@param fragmentRootView} is null, cancels all requests.
+ */
+ @Override
+ public void cancelPendingRequests(View fragmentRootView) {
+ if (fragmentRootView == null) {
+ mPendingRequests.clear();
+ return;
+ }
+ final Iterator<Entry<ImageView, Request>> iterator = mPendingRequests.entrySet().iterator();
+ while (iterator.hasNext()) {
+ final ImageView imageView = iterator.next().getKey();
+ // If an ImageView is orphaned (currently scrap) or a child of fragmentRootView, then
+ // we can safely remove its request.
+ if (imageView.getParent() == null || isChildView(fragmentRootView, imageView)) {
+ iterator.remove();
+ }
+ }
+ }
+
+ private static boolean isChildView(View parent, View potentialChild) {
+ return potentialChild.getParent() != null && (potentialChild.getParent() == parent || (
+ potentialChild.getParent() instanceof ViewGroup && isChildView(parent,
+ (ViewGroup) potentialChild.getParent())));
+ }
+
+ @Override
+ public void refreshCache() {
+ if (mBitmapHolderCacheAllUnfresh) {
+ if (DEBUG) Log.d(TAG, "refreshCache -- no fresh entries.");
+ return;
+ }
+ if (DEBUG) Log.d(TAG, "refreshCache");
+ mBitmapHolderCacheAllUnfresh = true;
+ for (BitmapHolder holder : mBitmapHolderCache.snapshot().values()) {
+ if (holder != BITMAP_UNAVAILABLE) {
+ holder.fresh = false;
+ }
+ }
+ }
+
+ /**
+ * 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 loadCachedPhoto(ImageView view, Request request, boolean fadeIn) {
+ BitmapHolder holder = mBitmapHolderCache.get(request.getKey());
+ if (holder == null) {
+ // The bitmap has not been loaded ==> show default avatar
+ request.applyDefaultImage(view, request.mIsCircular);
+ return false;
+ }
+
+ if (holder.bytes == null) {
+ request.applyDefaultImage(view, request.mIsCircular);
+ return holder.fresh;
+ }
+
+ Bitmap cachedBitmap = holder.bitmapRef == null ? null : holder.bitmapRef.get();
+ if (cachedBitmap == null) {
+ if (holder.bytes.length < 8 * 1024) {
+ // Small thumbnails are usually quick to inflate. Let's do that on the UI thread
+ inflateBitmap(holder, request.getRequestedExtent());
+ cachedBitmap = holder.bitmap;
+ if (cachedBitmap == null) return false;
+ } else {
+ // This is bigger data. Let's send that back to the Loader so that we can
+ // inflate this in the background
+ request.applyDefaultImage(view, request.mIsCircular);
+ return false;
+ }
+ }
+
+ final Drawable previousDrawable = view.getDrawable();
+ if (fadeIn && previousDrawable != null) {
+ final Drawable[] layers = new Drawable[2];
+ // Prevent cascade of TransitionDrawables.
+ if (previousDrawable instanceof TransitionDrawable) {
+ final TransitionDrawable previousTransitionDrawable =
+ (TransitionDrawable) previousDrawable;
+ layers[0] = previousTransitionDrawable.getDrawable(
+ previousTransitionDrawable.getNumberOfLayers() - 1);
+ } else {
+ layers[0] = previousDrawable;
+ }
+ layers[1] = getDrawableForBitmap(mContext.getResources(), cachedBitmap, request);
+ TransitionDrawable drawable = new TransitionDrawable(layers);
+ view.setImageDrawable(drawable);
+ drawable.startTransition(FADE_TRANSITION_DURATION);
+ } else {
+ view.setImageDrawable(
+ getDrawableForBitmap(mContext.getResources(), cachedBitmap, request));
+ }
+
+ // Put the bitmap in the LRU cache. But only do this for images that are small enough
+ // (we require that at least six of those can be cached at the same time)
+ if (cachedBitmap.getByteCount() < mBitmapCache.maxSize() / 6) {
+ mBitmapCache.put(request.getKey(), cachedBitmap);
+ }
+
+ // Soften the reference
+ holder.bitmap = null;
+
+ return holder.fresh;
+ }
+
+ /**
+ * Given a bitmap, returns a drawable that is configured to display the bitmap based on the
+ * specified request.
+ */
+ private Drawable getDrawableForBitmap(Resources resources, Bitmap bitmap, Request request) {
+ if (request.mIsCircular) {
+ final RoundedBitmapDrawable drawable =
+ RoundedBitmapDrawableFactory.create(resources, bitmap);
+ drawable.setAntiAlias(true);
+ drawable.setCornerRadius(bitmap.getHeight() / 2);
+ return drawable;
+ } else {
+ return new BitmapDrawable(resources, bitmap);
+ }
+ }
+
+ /**
+ * If necessary, decodes bytes stored in the holder to Bitmap. As long as the
+ * bitmap is held either by {@link #mBitmapCache} or by a soft reference in
+ * the holder, it will not be necessary to decode the bitmap.
+ */
+ private static void inflateBitmap(BitmapHolder holder, int requestedExtent) {
+ final int sampleSize =
+ BitmapUtil.findOptimalSampleSize(holder.originalSmallerExtent, requestedExtent);
+ byte[] bytes = holder.bytes;
+ if (bytes == null || bytes.length == 0) {
+ return;
+ }
+
+ if (sampleSize == holder.decodedSampleSize) {
+ // 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.bitmapRef != null) {
+ holder.bitmap = holder.bitmapRef.get();
+ if (holder.bitmap != null) {
+ return;
+ }
+ }
+ }
+
+ try {
+ Bitmap bitmap = BitmapUtil.decodeBitmapFromBytes(bytes, sampleSize);
+
+ // TODO: As a temporary workaround while framework support is being added to
+ // clip non-square bitmaps into a perfect circle, manually crop the bitmap into
+ // into a square if it will be displayed as a thumbnail so that it can be cropped
+ // into a circle.
+ final int height = bitmap.getHeight();
+ final int width = bitmap.getWidth();
+
+ // The smaller dimension of a scaled bitmap can range from anywhere from 0 to just
+ // below twice the length of a thumbnail image due to the way we calculate the optimal
+ // sample size.
+ if (height != width && Math.min(height, width) <= mThumbnailSize * 2) {
+ final int dimension = Math.min(height, width);
+ bitmap = ThumbnailUtils.extractThumbnail(bitmap, dimension, dimension);
+ }
+ // make bitmap mutable and draw size onto it
+ if (DEBUG_SIZES) {
+ Bitmap original = bitmap;
+ bitmap = bitmap.copy(bitmap.getConfig(), true);
+ original.recycle();
+ Canvas canvas = new Canvas(bitmap);
+ Paint paint = new Paint();
+ paint.setTextSize(16);
+ paint.setColor(Color.BLUE);
+ paint.setStyle(Style.FILL);
+ canvas.drawRect(0.0f, 0.0f, 50.0f, 20.0f, paint);
+ paint.setColor(Color.WHITE);
+ paint.setAntiAlias(true);
+ canvas.drawText(bitmap.getWidth() + "/" + sampleSize, 0, 15, paint);
+ }
+
+ holder.decodedSampleSize = sampleSize;
+ holder.bitmap = bitmap;
+ holder.bitmapRef = new SoftReference<Bitmap>(bitmap);
+ if (DEBUG) {
+ Log.d(TAG, "inflateBitmap " + btk(bytes.length) + " -> "
+ + bitmap.getWidth() + "x" + bitmap.getHeight()
+ + ", " + btk(bitmap.getByteCount()));
+ }
+ } catch (OutOfMemoryError e) {
+ // Do nothing - the photo will appear to be missing
+ }
+ }
+
+ public void clear() {
+ if (DEBUG) Log.d(TAG, "clear");
+ mPendingRequests.clear();
+ mBitmapHolderCache.evictAll();
+ mBitmapCache.evictAll();
+ }
+
+ @Override
+ public void pause() {
+ mPaused = true;
+ }
+
+ @Override
+ public void resume() {
+ mPaused = false;
+ if (DEBUG) dumpStats();
+ 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_PHOTOS_LOADED: {
+ if (!mPaused) {
+ processLoadedImages();
+ }
+ if (DEBUG) dumpStats();
+ return true;
+ }
+ }
+ return 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() {
+ final Iterator<Entry<ImageView, Request>> iterator = mPendingRequests.entrySet().iterator();
+ while (iterator.hasNext()) {
+ final Entry<ImageView, Request> entry = iterator.next();
+ // TODO: Temporarily disable contact photo fading in, until issues with
+ // RoundedBitmapDrawables overlapping the default image drawables are resolved.
+ final boolean loaded = loadCachedPhoto(entry.getKey(), entry.getValue(), false);
+ if (loaded) {
+ iterator.remove();
+ }
+ }
+
+ softenCache();
+
+ if (!mPendingRequests.isEmpty()) {
+ requestLoading();
+ }
+ }
+
+ /**
+ * Removes strong references to loaded bitmaps to allow them to be garbage collected
+ * if needed. Some of the bitmaps will still be retained by {@link #mBitmapCache}.
+ */
+ private void softenCache() {
+ for (BitmapHolder holder : mBitmapHolderCache.snapshot().values()) {
+ holder.bitmap = null;
+ }
+ }
+
+ /**
+ * Stores the supplied bitmap in cache.
+ */
+ private void cacheBitmap(Object key, byte[] bytes, boolean preloading, int requestedExtent) {
+ if (DEBUG) {
+ BitmapHolder prev = mBitmapHolderCache.get(key);
+ if (prev != null && prev.bytes != null) {
+ Log.d(TAG, "Overwriting cache: key=" + key + (prev.fresh ? " FRESH" : " stale"));
+ if (prev.fresh) {
+ mFreshCacheOverwrite.incrementAndGet();
+ } else {
+ mStaleCacheOverwrite.incrementAndGet();
+ }
+ }
+ Log.d(TAG, "Caching data: key=" + key + ", " +
+ (bytes == null ? "<null>" : btk(bytes.length)));
+ }
+ BitmapHolder holder = new BitmapHolder(bytes,
+ bytes == null ? -1 : BitmapUtil.getSmallerExtentFromBytes(bytes));
+
+ // Unless this image is being preloaded, decode it right away while
+ // we are still on the background thread.
+ if (!preloading) {
+ inflateBitmap(holder, requestedExtent);
+ }
+
+ if (bytes != null) {
+ mBitmapHolderCache.put(key, holder);
+ if (mBitmapHolderCache.get(key) != holder) {
+ Log.w(TAG, "Bitmap too big to fit in cache.");
+ mBitmapHolderCache.put(key, BITMAP_UNAVAILABLE);
+ }
+ } else {
+ mBitmapHolderCache.put(key, BITMAP_UNAVAILABLE);
+ }
+
+ mBitmapHolderCacheAllUnfresh = false;
+ }
+
+ @Override
+ public void cacheBitmap(Uri photoUri, Bitmap bitmap, byte[] photoBytes) {
+ final int smallerExtent = Math.min(bitmap.getWidth(), bitmap.getHeight());
+ // We can pretend here that the extent of the photo was the size that we originally
+ // requested
+ Request request = Request.createFromUri(photoUri, smallerExtent, false /* darkTheme */,
+ false /* isCircular */ , DEFAULT_AVATAR);
+ BitmapHolder holder = new BitmapHolder(photoBytes, smallerExtent);
+ holder.bitmapRef = new SoftReference<Bitmap>(bitmap);
+ mBitmapHolderCache.put(request.getKey(), holder);
+ mBitmapHolderCacheAllUnfresh = false;
+ mBitmapCache.put(request.getKey(), bitmap);
+ }
+
+ /**
+ * Populates an array of photo IDs that need to be loaded. Also decodes bitmaps that we have
+ * already loaded
+ */
+ private void obtainPhotoIdsAndUrisToLoad(Set<Long> photoIds,
+ Set<String> photoIdsAsStrings, Set<Request> uris) {
+ photoIds.clear();
+ photoIdsAsStrings.clear();
+ uris.clear();
+
+ boolean jpegsDecoded = false;
+
+ /*
+ * 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<Request> iterator = mPendingRequests.values().iterator();
+ while (iterator.hasNext()) {
+ Request request = iterator.next();
+ final BitmapHolder holder = mBitmapHolderCache.get(request.getKey());
+ if (holder == BITMAP_UNAVAILABLE) {
+ continue;
+ }
+ if (holder != null && holder.bytes != null && holder.fresh &&
+ (holder.bitmapRef == null || holder.bitmapRef.get() == null)) {
+ // This was previously loaded but we don't currently have the inflated Bitmap
+ inflateBitmap(holder, request.getRequestedExtent());
+ jpegsDecoded = true;
+ } else {
+ if (holder == null || !holder.fresh) {
+ if (request.isUriRequest()) {
+ uris.add(request);
+ } else {
+ photoIds.add(request.getId());
+ photoIdsAsStrings.add(String.valueOf(request.mId));
+ }
+ }
+ }
+ }
+
+ if (jpegsDecoded) mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED);
+ }
+
+ /**
+ * The thread that performs loading of photos from the database.
+ */
+ private class LoaderThread extends HandlerThread implements Callback {
+ private static final int BUFFER_SIZE = 1024*16;
+ private static final int MESSAGE_PRELOAD_PHOTOS = 0;
+ private static final int MESSAGE_LOAD_PHOTOS = 1;
+
+ /**
+ * A pause between preload batches that yields to the UI thread.
+ */
+ private static final int PHOTO_PRELOAD_DELAY = 1000;
+
+ /**
+ * Number of photos to preload per batch.
+ */
+ private static final int PRELOAD_BATCH = 25;
+
+ /**
+ * Maximum number of photos to preload. If the cache size is 2Mb and
+ * the expected average size of a photo is 4kb, then this number should be 2Mb/4kb = 500.
+ */
+ private static final int MAX_PHOTOS_TO_PRELOAD = 100;
+
+ private final ContentResolver mResolver;
+ private final StringBuilder mStringBuilder = new StringBuilder();
+ private final Set<Long> mPhotoIds = Sets.newHashSet();
+ private final Set<String> mPhotoIdsAsStrings = Sets.newHashSet();
+ private final Set<Request> mPhotoUris = Sets.newHashSet();
+ private final List<Long> mPreloadPhotoIds = Lists.newArrayList();
+
+ private Handler mLoaderThreadHandler;
+ private byte mBuffer[];
+
+ 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);
+ mResolver = resolver;
+ }
+
+ public void ensureHandler() {
+ if (mLoaderThreadHandler == null) {
+ mLoaderThreadHandler = new Handler(getLooper(), this);
+ }
+ }
+
+ /**
+ * 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 requestPreloading() {
+ if (mPreloadStatus == PRELOAD_STATUS_DONE) {
+ return;
+ }
+
+ ensureHandler();
+ if (mLoaderThreadHandler.hasMessages(MESSAGE_LOAD_PHOTOS)) {
+ return;
+ }
+
+ mLoaderThreadHandler.sendEmptyMessageDelayed(
+ MESSAGE_PRELOAD_PHOTOS, PHOTO_PRELOAD_DELAY);
+ }
+
+ /**
+ * Sends a message to this thread to load requested photos. Cancels a preloading
+ * request, if any: we don't want preloading to impede loading of the photos
+ * we need to display now.
+ */
+ public void requestLoading() {
+ ensureHandler();
+ mLoaderThreadHandler.removeMessages(MESSAGE_PRELOAD_PHOTOS);
+ mLoaderThreadHandler.sendEmptyMessage(MESSAGE_LOAD_PHOTOS);
+ }
+
+ /**
+ * Receives the above message, loads photos and then sends a message
+ * to the main thread to process them.
+ */
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MESSAGE_PRELOAD_PHOTOS:
+ preloadPhotosInBackground();
+ break;
+ case MESSAGE_LOAD_PHOTOS:
+ loadPhotosInBackground();
+ break;
+ }
+ return true;
+ }
+
+ /**
+ * 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 preloadPhotosInBackground() {
+ if (mPreloadStatus == PRELOAD_STATUS_DONE) {
+ return;
+ }
+
+ if (mPreloadStatus == PRELOAD_STATUS_NOT_STARTED) {
+ queryPhotosForPreload();
+ if (mPreloadPhotoIds.isEmpty()) {
+ mPreloadStatus = PRELOAD_STATUS_DONE;
+ } else {
+ mPreloadStatus = PRELOAD_STATUS_IN_PROGRESS;
+ }
+ requestPreloading();
+ return;
+ }
+
+ if (mBitmapHolderCache.size() > mBitmapHolderCacheRedZoneBytes) {
+ mPreloadStatus = PRELOAD_STATUS_DONE;
+ return;
+ }
+
+ mPhotoIds.clear();
+ mPhotoIdsAsStrings.clear();
+
+ int count = 0;
+ int preloadSize = mPreloadPhotoIds.size();
+ while(preloadSize > 0 && mPhotoIds.size() < PRELOAD_BATCH) {
+ preloadSize--;
+ count++;
+ Long photoId = mPreloadPhotoIds.get(preloadSize);
+ mPhotoIds.add(photoId);
+ mPhotoIdsAsStrings.add(photoId.toString());
+ mPreloadPhotoIds.remove(preloadSize);
+ }
+
+ loadThumbnails(true);
+
+ if (preloadSize == 0) {
+ mPreloadStatus = PRELOAD_STATUS_DONE;
+ }
+
+ Log.v(TAG, "Preloaded " + count + " photos. Cached bytes: "
+ + mBitmapHolderCache.size());
+
+ requestPreloading();
+ }
+
+ private void queryPhotosForPreload() {
+ Cursor cursor = null;
+ try {
+ Uri uri = Contacts.CONTENT_URI.buildUpon().appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT))
+ .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
+ String.valueOf(MAX_PHOTOS_TO_PRELOAD))
+ .build();
+ cursor = mResolver.query(uri, new String[] { Contacts.PHOTO_ID },
+ Contacts.PHOTO_ID + " NOT NULL AND " + Contacts.PHOTO_ID + "!=0",
+ null,
+ Contacts.STARRED + " DESC, " + Contacts.LAST_TIME_CONTACTED + " DESC");
+
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ // Insert them in reverse order, because we will be taking
+ // them from the end of the list for loading.
+ mPreloadPhotoIds.add(0, cursor.getLong(0));
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ }
+
+ private void loadPhotosInBackground() {
+ if (!PermissionsUtil.hasPermission(mContext,
+ android.Manifest.permission.READ_CONTACTS)) {
+ return;
+ }
+ obtainPhotoIdsAndUrisToLoad(mPhotoIds, mPhotoIdsAsStrings, mPhotoUris);
+ loadThumbnails(false);
+ loadUriBasedPhotos();
+ requestPreloading();
+ }
+
+ /** Loads thumbnail photos with ids */
+ private void loadThumbnails(boolean preloading) {
+ if (mPhotoIds.isEmpty()) {
+ 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) {
+ for (Long id : mPhotoIds) {
+ mPreloadPhotoIds.remove(id);
+ }
+ if (mPreloadPhotoIds.isEmpty()) {
+ mPreloadStatus = PRELOAD_STATUS_DONE;
+ }
+ }
+
+ mStringBuilder.setLength(0);
+ mStringBuilder.append(Photo._ID + " IN(");
+ for (int i = 0; i < mPhotoIds.size(); i++) {
+ if (i != 0) {
+ mStringBuilder.append(',');
+ }
+ mStringBuilder.append('?');
+ }
+ mStringBuilder.append(')');
+
+ Cursor cursor = null;
+ try {
+ if (DEBUG) Log.d(TAG, "Loading " + TextUtils.join(",", mPhotoIdsAsStrings));
+ cursor = mResolver.query(Data.CONTENT_URI,
+ COLUMNS,
+ mStringBuilder.toString(),
+ mPhotoIdsAsStrings.toArray(EMPTY_STRING_ARRAY),
+ null);
+
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ Long id = cursor.getLong(0);
+ byte[] bytes = cursor.getBlob(1);
+ cacheBitmap(id, bytes, preloading, -1);
+ mPhotoIds.remove(id);
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ // Remaining photos were not found in the contacts database (but might be in profile).
+ for (Long id : mPhotoIds) {
+ if (ContactsContract.isProfileId(id)) {
+ Cursor profileCursor = null;
+ try {
+ profileCursor = mResolver.query(
+ ContentUris.withAppendedId(Data.CONTENT_URI, id),
+ COLUMNS, null, null, null);
+ if (profileCursor != null && profileCursor.moveToFirst()) {
+ cacheBitmap(profileCursor.getLong(0), profileCursor.getBlob(1),
+ preloading, -1);
+ } else {
+ // Couldn't load a photo this way either.
+ cacheBitmap(id, null, preloading, -1);
+ }
+ } finally {
+ if (profileCursor != null) {
+ profileCursor.close();
+ }
+ }
+ } else {
+ // Not a profile photo and not found - mark the cache accordingly
+ cacheBitmap(id, null, preloading, -1);
+ }
+ }
+
+ mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED);
+ }
+
+ /**
+ * Loads photos referenced with Uris. Those can be remote thumbnails
+ * (from directory searches), display photos etc
+ */
+ private void loadUriBasedPhotos() {
+ for (Request uriRequest : mPhotoUris) {
+ // Keep the original URI and use this to key into the cache. Failure to do so will
+ // result in an image being continually reloaded into cache if the original URI
+ // has a contact type encodedFragment (eg nearby places business photo URLs).
+ Uri originalUri = uriRequest.getUri();
+
+ // Strip off the "contact type" we added to the URI to ensure it was identifiable as
+ // a business photo -- there is no need to pass this on to the server.
+ Uri uri = ContactPhotoManager.removeContactType(originalUri);
+
+ if (mBuffer == null) {
+ mBuffer = new byte[BUFFER_SIZE];
+ }
+ try {
+ if (DEBUG) Log.d(TAG, "Loading " + uri);
+ final String scheme = uri.getScheme();
+ InputStream is = null;
+ if (scheme.equals("http") || scheme.equals("https")) {
+ TrafficStats.setThreadStatsTag(TrafficStatsTags.CONTACT_PHOTO_DOWNLOAD_TAG);
+ final HttpURLConnection connection =
+ (HttpURLConnection) new URL(uri.toString()).openConnection();
+
+ // Include the user agent if it is specified.
+ if (!TextUtils.isEmpty(mUserAgent)) {
+ connection.setRequestProperty("User-Agent", mUserAgent);
+ }
+ try {
+ is = connection.getInputStream();
+ } catch (IOException e) {
+ connection.disconnect();
+ is = null;
+ }
+ TrafficStats.clearThreadStatsTag();
+ } else {
+ is = mResolver.openInputStream(uri);
+ }
+ if (is != null) {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try {
+ int size;
+ while ((size = is.read(mBuffer)) != -1) {
+ baos.write(mBuffer, 0, size);
+ }
+ } finally {
+ is.close();
+ }
+ cacheBitmap(originalUri, baos.toByteArray(), false,
+ uriRequest.getRequestedExtent());
+ mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED);
+ } else {
+ Log.v(TAG, "Cannot load photo " + uri);
+ cacheBitmap(originalUri, null, false, uriRequest.getRequestedExtent());
+ }
+ } catch (final Exception | OutOfMemoryError ex) {
+ Log.v(TAG, "Cannot load photo " + uri, ex);
+ cacheBitmap(originalUri, null, false, uriRequest.getRequestedExtent());
+ }
+ }
+ }
+ }
+
+ /**
+ * A holder for either a Uri or an id and a flag whether this was requested for the dark or
+ * light theme
+ */
+ private static final class Request {
+ private final long mId;
+ private final Uri mUri;
+ private final boolean mDarkTheme;
+ private final int mRequestedExtent;
+ private final DefaultImageProvider mDefaultProvider;
+ /**
+ * Whether or not the contact photo is to be displayed as a circle
+ */
+ private final boolean mIsCircular;
+
+ private Request(long id, Uri uri, int requestedExtent, boolean darkTheme,
+ boolean isCircular, DefaultImageProvider defaultProvider) {
+ mId = id;
+ mUri = uri;
+ mDarkTheme = darkTheme;
+ mIsCircular = isCircular;
+ mRequestedExtent = requestedExtent;
+ mDefaultProvider = defaultProvider;
+ }
+
+ public static Request createFromThumbnailId(long id, boolean darkTheme, boolean isCircular,
+ DefaultImageProvider defaultProvider) {
+ return new Request(id, null /* no URI */, -1, darkTheme, isCircular, defaultProvider);
+ }
+
+ public static Request createFromUri(Uri uri, int requestedExtent, boolean darkTheme,
+ boolean isCircular, DefaultImageProvider defaultProvider) {
+ return new Request(0 /* no ID */, uri, requestedExtent, darkTheme, isCircular,
+ defaultProvider);
+ }
+
+ public boolean isUriRequest() {
+ return mUri != null;
+ }
+
+ public Uri getUri() {
+ return mUri;
+ }
+
+ public long getId() {
+ return mId;
+ }
+
+ public int getRequestedExtent() {
+ return mRequestedExtent;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + (int) (mId ^ (mId >>> 32));
+ result = prime * result + mRequestedExtent;
+ result = prime * result + ((mUri == null) ? 0 : mUri.hashCode());
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (obj == null) return false;
+ if (getClass() != obj.getClass()) return false;
+ final Request that = (Request) obj;
+ if (mId != that.mId) return false;
+ if (mRequestedExtent != that.mRequestedExtent) return false;
+ if (!UriUtils.areEqual(mUri, that.mUri)) return false;
+ // Don't compare equality of mDarkTheme because it is only used in the default contact
+ // photo case. When the contact does have a photo, the contact photo is the same
+ // regardless of mDarkTheme, so we shouldn't need to put the photo request on the queue
+ // twice.
+ return true;
+ }
+
+ public Object getKey() {
+ return mUri == null ? mId : mUri;
+ }
+
+ /**
+ * Applies the default image to the current view. If the request is URI-based, looks for
+ * the contact type encoded fragment to determine if this is a request for a business photo,
+ * in which case we will load the default business photo.
+ *
+ * @param view The current image view to apply the image to.
+ * @param isCircular Whether the image is circular or not.
+ */
+ public void applyDefaultImage(ImageView view, boolean isCircular) {
+ final DefaultImageRequest request;
+
+ if (isCircular) {
+ request = ContactPhotoManager.isBusinessContactUri(mUri)
+ ? DefaultImageRequest.EMPTY_CIRCULAR_BUSINESS_IMAGE_REQUEST
+ : DefaultImageRequest.EMPTY_CIRCULAR_DEFAULT_IMAGE_REQUEST;
+ } else {
+ request = ContactPhotoManager.isBusinessContactUri(mUri)
+ ? DefaultImageRequest.EMPTY_DEFAULT_BUSINESS_IMAGE_REQUEST
+ : DefaultImageRequest.EMPTY_DEFAULT_IMAGE_REQUEST;
+ }
+ mDefaultProvider.applyDefaultImage(view, mRequestedExtent, mDarkTheme, request);
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/ContactPresenceIconUtil.java b/src/com/android/contacts/common/ContactPresenceIconUtil.java
new file mode 100644
index 0000000..2f4c9ee
--- /dev/null
+++ b/src/com/android/contacts/common/ContactPresenceIconUtil.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.provider.ContactsContract.StatusUpdates;
+
+/**
+ * Define the contact present show policy in Contacts
+ */
+public class ContactPresenceIconUtil {
+ /**
+ * Get the presence icon resource according the status.
+ *
+ * @return null means don't show the status icon.
+ */
+ public static Drawable getPresenceIcon (Context context, int status) {
+ // We don't show the offline status in Contacts
+ switch(status) {
+ case StatusUpdates.AVAILABLE:
+ case StatusUpdates.IDLE:
+ case StatusUpdates.AWAY:
+ case StatusUpdates.DO_NOT_DISTURB:
+ case StatusUpdates.INVISIBLE:
+ return context.getResources().getDrawable(
+ StatusUpdates.getPresenceIconResourceId(status));
+ case StatusUpdates.OFFLINE:
+ // The undefined status is treated as OFFLINE in getPresenceIconResourceId();
+ default:
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/ContactStatusUtil.java b/src/com/android/contacts/common/ContactStatusUtil.java
new file mode 100644
index 0000000..a7d1925
--- /dev/null
+++ b/src/com/android/contacts/common/ContactStatusUtil.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.provider.ContactsContract.StatusUpdates;
+
+/**
+ * Provides static function to get default contact status message.
+ */
+public class ContactStatusUtil {
+
+ private static final String TAG = "ContactStatusUtil";
+
+ public static String getStatusString(Context context, int presence) {
+ Resources resources = context.getResources();
+ switch (presence) {
+ case StatusUpdates.AVAILABLE:
+ return resources.getString(R.string.status_available);
+ case StatusUpdates.IDLE:
+ case StatusUpdates.AWAY:
+ return resources.getString(R.string.status_away);
+ case StatusUpdates.DO_NOT_DISTURB:
+ return resources.getString(R.string.status_busy);
+ case StatusUpdates.OFFLINE:
+ case StatusUpdates.INVISIBLE:
+ default:
+ return null;
+ }
+ }
+
+}
diff --git a/src/com/android/contacts/common/ContactTileLoaderFactory.java b/src/com/android/contacts/common/ContactTileLoaderFactory.java
new file mode 100644
index 0000000..f75950e
--- /dev/null
+++ b/src/com/android/contacts/common/ContactTileLoaderFactory.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+package com.android.contacts.common;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import android.content.Context;
+import android.content.CursorLoader;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.Contacts;
+
+/**
+ * Used to create {@link CursorLoader}s to load different groups of
+ * {@link com.android.contacts.list.ContactTileView}.
+ */
+public final class ContactTileLoaderFactory {
+
+ public final static int CONTACT_ID = 0;
+ public final static int DISPLAY_NAME = 1;
+ public final static int STARRED = 2;
+ public final static int PHOTO_URI = 3;
+ public final static int LOOKUP_KEY = 4;
+ public final static int CONTACT_PRESENCE = 5;
+ public final static int CONTACT_STATUS = 6;
+
+ // Only used for StrequentPhoneOnlyLoader
+ public final static int PHONE_NUMBER = 5;
+ public final static int PHONE_NUMBER_TYPE = 6;
+ public final static int PHONE_NUMBER_LABEL = 7;
+ public final static int IS_DEFAULT_NUMBER = 8;
+ public final static int PINNED = 9;
+ // The _ID field returned for strequent items actually contains data._id instead of
+ // contacts._id because the query is performed on the data table. In order to obtain the
+ // contact id for strequent items, we thus have to use Phone.contact_id instead.
+ public final static int CONTACT_ID_FOR_DATA = 10;
+ public final static int DISPLAY_NAME_ALTERNATIVE = 11;
+
+ private static final String[] COLUMNS = new String[] {
+ Contacts._ID, // ..........................................0
+ Contacts.DISPLAY_NAME, // .................................1
+ Contacts.STARRED, // ......................................2
+ Contacts.PHOTO_URI, // ....................................3
+ Contacts.LOOKUP_KEY, // ...................................4
+ Contacts.CONTACT_PRESENCE, // .............................5
+ Contacts.CONTACT_STATUS, // ...............................6
+ };
+
+ /**
+ * Projection used for the {@link Contacts#CONTENT_STREQUENT_URI}
+ * query when {@link ContactsContract#STREQUENT_PHONE_ONLY} flag
+ * is set to true. The main difference is the lack of presence
+ * and status data and the addition of phone number and label.
+ */
+ @VisibleForTesting
+ public static final String[] COLUMNS_PHONE_ONLY = new String[] {
+ Contacts._ID, // ..........................................0
+ Contacts.DISPLAY_NAME_PRIMARY, // .........................1
+ Contacts.STARRED, // ......................................2
+ Contacts.PHOTO_URI, // ....................................3
+ Contacts.LOOKUP_KEY, // ...................................4
+ Phone.NUMBER, // ..........................................5
+ Phone.TYPE, // ............................................6
+ Phone.LABEL, // ...........................................7
+ Phone.IS_SUPER_PRIMARY, //.................................8
+ Contacts.PINNED, // .......................................9
+ Phone.CONTACT_ID, //.......................................10
+ Contacts.DISPLAY_NAME_ALTERNATIVE, // .....................11
+ };
+
+ private static final String STARRED_ORDER = Contacts.DISPLAY_NAME+" COLLATE NOCASE ASC";
+
+ public static CursorLoader createStrequentLoader(Context context) {
+ return new CursorLoader(context, Contacts.CONTENT_STREQUENT_URI, COLUMNS, null, null,
+ STARRED_ORDER);
+ }
+
+ public static CursorLoader createStrequentPhoneOnlyLoader(Context context) {
+ Uri uri = Contacts.CONTENT_STREQUENT_URI.buildUpon()
+ .appendQueryParameter(ContactsContract.STREQUENT_PHONE_ONLY, "true").build();
+
+ return new CursorLoader(context, uri, COLUMNS_PHONE_ONLY, null, null, null);
+ }
+
+ public static CursorLoader createStarredLoader(Context context) {
+ return new CursorLoader(context, Contacts.CONTENT_URI, COLUMNS, Contacts.STARRED + "=?",
+ new String[]{"1"}, STARRED_ORDER);
+ }
+
+ public static CursorLoader createFrequentLoader(Context context) {
+ return new CursorLoader(context, Contacts.CONTENT_FREQUENT_URI, COLUMNS,
+ Contacts.STARRED + "=?", new String[]{"0"}, null);
+ }
+}
diff --git a/src/com/android/contacts/common/ContactsUtils.java b/src/com/android/contacts/common/ContactsUtils.java
new file mode 100644
index 0000000..38fdbf2
--- /dev/null
+++ b/src/com/android/contacts/common/ContactsUtils.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common;
+
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.support.annotation.IntDef;
+import android.provider.ContactsContract.DisplayPhoto;
+import android.text.TextUtils;
+import android.util.Pair;
+
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.model.dataitem.ImDataItem;
+import com.android.contacts.common.testing.NeededForTesting;
+import com.android.contacts.common.compat.ContactsCompat;
+import com.android.contacts.common.compat.DirectoryCompat;
+import com.android.contacts.common.model.AccountTypeManager;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.List;
+
+public class ContactsUtils {
+ private static final String TAG = "ContactsUtils";
+
+ // Telecomm related schemes are in CallUtil
+ public static final String SCHEME_IMTO = "imto";
+ public static final String SCHEME_MAILTO = "mailto";
+ public static final String SCHEME_SMSTO = "smsto";
+
+ private static final int DEFAULT_THUMBNAIL_SIZE = 96;
+
+ private static int sThumbnailSize = -1;
+
+ public static final boolean FLAG_N_FEATURE = Build.VERSION.SDK_INT >= 24;
+
+ // TODO find a proper place for the canonical version of these
+ public interface ProviderNames {
+ String YAHOO = "Yahoo";
+ String GTALK = "GTalk";
+ String MSN = "MSN";
+ String ICQ = "ICQ";
+ String AIM = "AIM";
+ String XMPP = "XMPP";
+ String JABBER = "JABBER";
+ String SKYPE = "SKYPE";
+ String QQ = "QQ";
+ }
+
+ /**
+ * This looks up the provider name defined in
+ * ProviderNames from the predefined IM protocol id.
+ * This is used for interacting with the IM application.
+ *
+ * @param protocol the protocol ID
+ * @return the provider name the IM app uses for the given protocol, or null if no
+ * provider is defined for the given protocol
+ * @hide
+ */
+ public static String lookupProviderNameFromId(int protocol) {
+ switch (protocol) {
+ case Im.PROTOCOL_GOOGLE_TALK:
+ return ProviderNames.GTALK;
+ case Im.PROTOCOL_AIM:
+ return ProviderNames.AIM;
+ case Im.PROTOCOL_MSN:
+ return ProviderNames.MSN;
+ case Im.PROTOCOL_YAHOO:
+ return ProviderNames.YAHOO;
+ case Im.PROTOCOL_ICQ:
+ return ProviderNames.ICQ;
+ case Im.PROTOCOL_JABBER:
+ return ProviderNames.JABBER;
+ case Im.PROTOCOL_SKYPE:
+ return ProviderNames.SKYPE;
+ case Im.PROTOCOL_QQ:
+ return ProviderNames.QQ;
+ }
+ return null;
+ }
+
+
+ public static final long USER_TYPE_CURRENT = 0;
+ public static final long USER_TYPE_WORK = 1;
+
+ /**
+ * UserType indicates the user type of the contact. If the contact is from Work User (Work
+ * Profile in Android Multi-User System), it's {@link #USER_TYPE_WORK}, otherwise,
+ * {@link #USER_TYPE_CURRENT}. Please note that current user can be in work profile, where the
+ * dialer is running inside Work Profile.
+ */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({USER_TYPE_CURRENT, USER_TYPE_WORK})
+ public @interface UserType {}
+
+ /**
+ * Test if the given {@link CharSequence} contains any graphic characters,
+ * first checking {@link TextUtils#isEmpty(CharSequence)} to handle null.
+ */
+ public static boolean isGraphic(CharSequence str) {
+ return !TextUtils.isEmpty(str) && TextUtils.isGraphic(str);
+ }
+
+ /**
+ * Returns true if two objects are considered equal. Two null references are equal here.
+ */
+ @NeededForTesting
+ public static boolean areObjectsEqual(Object a, Object b) {
+ return a == b || (a != null && a.equals(b));
+ }
+
+ /**
+ * Returns true if two {@link Intent}s are both null, or have the same action.
+ */
+ public static final boolean areIntentActionEqual(Intent a, Intent b) {
+ if (a == b) {
+ return true;
+ }
+ if (a == null || b == null) {
+ return false;
+ }
+ return TextUtils.equals(a.getAction(), b.getAction());
+ }
+
+ public static boolean areGroupWritableAccountsAvailable(Context context) {
+ final List<AccountWithDataSet> accounts =
+ AccountTypeManager.getInstance(context).getGroupWritableAccounts();
+ return !accounts.isEmpty();
+ }
+
+ /**
+ * Returns the size (width and height) of thumbnail pictures as configured in the provider. This
+ * can safely be called from the UI thread, as the provider can serve this without performing
+ * a database access
+ */
+ public static int getThumbnailSize(Context context) {
+ if (sThumbnailSize == -1) {
+ final Cursor c = context.getContentResolver().query(
+ DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI,
+ new String[] { DisplayPhoto.THUMBNAIL_MAX_DIM }, null, null, null);
+ if (c != null) {
+ try {
+ if (c.moveToFirst()) {
+ sThumbnailSize = c.getInt(0);
+ }
+ } finally {
+ c.close();
+ }
+ }
+ }
+ return sThumbnailSize != -1 ? sThumbnailSize : DEFAULT_THUMBNAIL_SIZE;
+ }
+
+ private static Intent getCustomImIntent(ImDataItem im, int protocol) {
+ String host = im.getCustomProtocol();
+ final String data = im.getData();
+ if (TextUtils.isEmpty(data)) {
+ return null;
+ }
+ if (protocol != Im.PROTOCOL_CUSTOM) {
+ // Try bringing in a well-known host for specific protocols
+ host = ContactsUtils.lookupProviderNameFromId(protocol);
+ }
+ if (TextUtils.isEmpty(host)) {
+ return null;
+ }
+ final String authority = host.toLowerCase();
+ final Uri imUri = new Uri.Builder().scheme(SCHEME_IMTO).authority(
+ authority).appendPath(data).build();
+ final Intent intent = new Intent(Intent.ACTION_SENDTO, imUri);
+ return intent;
+ }
+
+ /**
+ * Returns the proper Intent for an ImDatItem. If available, a secondary intent is stored
+ * in the second Pair slot
+ */
+ public static Pair<Intent, Intent> buildImIntent(Context context, ImDataItem im) {
+ Intent intent = null;
+ Intent secondaryIntent = null;
+ final boolean isEmail = im.isCreatedFromEmail();
+
+ if (!isEmail && !im.isProtocolValid()) {
+ return new Pair<>(null, null);
+ }
+
+ final String data = im.getData();
+ if (TextUtils.isEmpty(data)) {
+ return new Pair<>(null, null);
+ }
+
+ final int protocol = isEmail ? Im.PROTOCOL_GOOGLE_TALK : im.getProtocol();
+
+ if (protocol == Im.PROTOCOL_GOOGLE_TALK) {
+ final int chatCapability = im.getChatCapability();
+ if ((chatCapability & Im.CAPABILITY_HAS_CAMERA) != 0) {
+ intent = new Intent(Intent.ACTION_SENDTO,
+ Uri.parse("xmpp:" + data + "?message"));
+ secondaryIntent = new Intent(Intent.ACTION_SENDTO,
+ Uri.parse("xmpp:" + data + "?call"));
+ } else if ((chatCapability & Im.CAPABILITY_HAS_VOICE) != 0) {
+ // Allow Talking and Texting
+ intent =
+ new Intent(Intent.ACTION_SENDTO, Uri.parse("xmpp:" + data + "?message"));
+ secondaryIntent =
+ new Intent(Intent.ACTION_SENDTO, Uri.parse("xmpp:" + data + "?call"));
+ } else {
+ intent =
+ new Intent(Intent.ACTION_SENDTO, Uri.parse("xmpp:" + data + "?message"));
+ }
+ } else {
+ // Build an IM Intent
+ intent = getCustomImIntent(im, protocol);
+ }
+ return new Pair<>(intent, secondaryIntent);
+ }
+
+ /**
+ * Determine UserType from directory id and contact id.
+ *
+ * 3 types of query
+ *
+ * 1. 2 profile query: content://com.android.contacts/phone_lookup_enterprise/1234567890
+ * personal and work contact are mixed into one cursor. no directory id. contact_id indicates if
+ * it's work contact
+ *
+ * 2. work local query:
+ * content://com.android.contacts/phone_lookup_enterprise/1234567890?directory=1000000000
+ * either directory_id or contact_id is enough to identify work contact
+ *
+ * 3. work remote query:
+ * content://com.android.contacts/phone_lookup_enterprise/1234567890?directory=1000000003
+ * contact_id is random. only directory_id is available
+ *
+ * Summary: If directory_id is not null, always use directory_id to identify work contact.
+ * (which is the case here) Otherwise, use contact_id.
+ *
+ * @param directoryId directory id of ContactsProvider query
+ * @param contactId contact id
+ * @return UserType indicates the user type of the contact. A directory id or contact id larger
+ * than a thredshold indicates that the contact is stored in Work Profile, but not in
+ * current user. It's a contract by ContactsProvider and check by
+ * Contacts.isEnterpriseDirectoryId and Contacts.isEnterpriseContactId. Currently, only
+ * 2 kinds of users can be detected from the directoryId and contactId as
+ * ContactsProvider can only access current and work user's contacts
+ */
+ public static @UserType long determineUserType(Long directoryId, Long contactId) {
+ // First check directory id
+ if (directoryId != null) {
+ return DirectoryCompat.isEnterpriseDirectoryId(directoryId) ? USER_TYPE_WORK
+ : USER_TYPE_CURRENT;
+ }
+ // Only check contact id if directory id is null
+ if (contactId != null && contactId != 0L
+ && ContactsCompat.isEnterpriseContactId(contactId)) {
+ return USER_TYPE_WORK;
+ } else {
+ return USER_TYPE_CURRENT;
+ }
+
+ }
+}
diff --git a/src/com/android/contacts/common/Experiments.java b/src/com/android/contacts/common/Experiments.java
new file mode 100644
index 0000000..c811e27
--- /dev/null
+++ b/src/com/android/contacts/common/Experiments.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+package com.android.contacts.common;
+
+/**
+ * Experiment flag names.
+ */
+public final class Experiments {
+
+ private Experiments() {
+ }
+}
diff --git a/src/com/android/contacts/common/GeoUtil.java b/src/com/android/contacts/common/GeoUtil.java
new file mode 100644
index 0000000..cd0139b
--- /dev/null
+++ b/src/com/android/contacts/common/GeoUtil.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common;
+
+import android.app.Application;
+import android.content.Context;
+
+import com.android.contacts.common.location.CountryDetector;
+
+import com.google.i18n.phonenumbers.geocoding.PhoneNumberOfflineGeocoder;
+import com.google.i18n.phonenumbers.NumberParseException;
+import com.google.i18n.phonenumbers.PhoneNumberUtil;
+import com.google.i18n.phonenumbers.Phonenumber;
+
+import java.util.Locale;
+
+/**
+ * Static methods related to Geo.
+ */
+public class GeoUtil {
+
+ /**
+ * Returns the country code of the country the user is currently in. Before calling this
+ * method, make sure that {@link CountryDetector#initialize(Context)} has already been called
+ * in {@link Application#onCreate()}.
+ * @return The ISO 3166-1 two letters country code of the country the user
+ * is in.
+ */
+ public static String getCurrentCountryIso(Context context) {
+ // The {@link CountryDetector} should never return null so this is safe to return as-is.
+ return CountryDetector.getInstance(context).getCurrentCountryIso();
+ }
+
+ public static String getGeocodedLocationFor(Context context, String phoneNumber) {
+ final PhoneNumberOfflineGeocoder geocoder = PhoneNumberOfflineGeocoder.getInstance();
+ final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance();
+ try {
+ final Phonenumber.PhoneNumber structuredPhoneNumber =
+ phoneNumberUtil.parse(phoneNumber, getCurrentCountryIso(context));
+ final Locale locale = context.getResources().getConfiguration().locale;
+ return geocoder.getDescriptionForNumber(structuredPhoneNumber, locale);
+ } catch (NumberParseException e) {
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/GroupMetaData.java b/src/com/android/contacts/common/GroupMetaData.java
new file mode 100644
index 0000000..fa86ae2
--- /dev/null
+++ b/src/com/android/contacts/common/GroupMetaData.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+package com.android.contacts.common;
+
+/**
+ * Meta-data for a contact group. We load all groups associated with the contact's
+ * constituent accounts.
+ */
+public final class GroupMetaData {
+ private String mAccountName;
+ private String mAccountType;
+ private String mDataSet;
+ private long mGroupId;
+ private String mTitle;
+ private boolean mDefaultGroup;
+ private boolean mFavorites;
+
+ public GroupMetaData(String accountName, String accountType, String dataSet, long groupId,
+ String title, boolean defaultGroup, boolean favorites) {
+ this.mAccountName = accountName;
+ this.mAccountType = accountType;
+ this.mDataSet = dataSet;
+ this.mGroupId = groupId;
+ this.mTitle = title;
+ this.mDefaultGroup = defaultGroup;
+ this.mFavorites = favorites;
+ }
+
+ public String getAccountName() {
+ return mAccountName;
+ }
+
+ public String getAccountType() {
+ return mAccountType;
+ }
+
+ public String getDataSet() {
+ return mDataSet;
+ }
+
+ public long getGroupId() {
+ return mGroupId;
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public boolean isDefaultGroup() {
+ return mDefaultGroup;
+ }
+
+ public boolean isFavorites() {
+ return mFavorites;
+ }
+}
\ No newline at end of file
diff --git a/src/com/android/contacts/common/MoreContactUtils.java b/src/com/android/contacts/common/MoreContactUtils.java
new file mode 100644
index 0000000..9b9f800
--- /dev/null
+++ b/src/com/android/contacts/common/MoreContactUtils.java
@@ -0,0 +1,243 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common;
+
+import com.google.i18n.phonenumbers.NumberParseException;
+import com.google.i18n.phonenumbers.PhoneNumberUtil;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.telephony.PhoneNumberUtils;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.TextView;
+
+import com.android.contacts.common.model.account.AccountType;
+
+/**
+ * Shared static contact utility methods.
+ */
+public class MoreContactUtils {
+
+ private static final String WAIT_SYMBOL_AS_STRING = String.valueOf(PhoneNumberUtils.WAIT);
+
+ /**
+ * Returns true if two data with mimetypes which represent values in contact entries are
+ * considered equal for collapsing in the GUI. For caller-id, use
+ * {@link android.telephony.PhoneNumberUtils#compare(android.content.Context, String, String)}
+ * instead
+ */
+ public static boolean shouldCollapse(CharSequence mimetype1, CharSequence data1,
+ CharSequence mimetype2, CharSequence data2) {
+ // different mimetypes? don't collapse
+ if (!TextUtils.equals(mimetype1, mimetype2)) return false;
+
+ // exact same string? good, bail out early
+ if (TextUtils.equals(data1, data2)) return true;
+
+ // so if either is null, these two must be different
+ if (data1 == null || data2 == null) return false;
+
+ // if this is not about phone numbers, we know this is not a match (of course, some
+ // mimetypes could have more sophisticated matching is the future, e.g. addresses)
+ if (!TextUtils.equals(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE,
+ mimetype1)) {
+ return false;
+ }
+
+ return shouldCollapsePhoneNumbers(data1.toString(), data2.toString());
+ }
+
+ // TODO: Move this to PhoneDataItem.shouldCollapse override
+ private static boolean shouldCollapsePhoneNumbers(String number1, String number2) {
+ // Work around to address b/20724444. We want to distinguish between #555, *555 and 555.
+ // This makes no attempt to distinguish between 555 and 55*5, since 55*5 is an improbable
+ // number. PhoneNumberUtil already distinguishes between 555 and 55#5.
+ if (number1.contains("#") != number2.contains("#")
+ || number1.contains("*") != number2.contains("*")) {
+ return false;
+ }
+
+ // Now do the full phone number thing. split into parts, separated by waiting symbol
+ // and compare them individually
+ final String[] dataParts1 = number1.split(WAIT_SYMBOL_AS_STRING);
+ final String[] dataParts2 = number2.split(WAIT_SYMBOL_AS_STRING);
+ if (dataParts1.length != dataParts2.length) return false;
+ final PhoneNumberUtil util = PhoneNumberUtil.getInstance();
+ for (int i = 0; i < dataParts1.length; i++) {
+ // Match phone numbers represented by keypad letters, in which case prefer the
+ // phone number with letters.
+ final String dataPart1 = PhoneNumberUtils.convertKeypadLettersToDigits(dataParts1[i]);
+ final String dataPart2 = dataParts2[i];
+
+ // substrings equal? shortcut, don't parse
+ if (TextUtils.equals(dataPart1, dataPart2)) continue;
+
+ // do a full parse of the numbers
+ final PhoneNumberUtil.MatchType result = util.isNumberMatch(dataPart1, dataPart2);
+ switch (result) {
+ case NOT_A_NUMBER:
+ // don't understand the numbers? let's play it safe
+ return false;
+ case NO_MATCH:
+ return false;
+ case EXACT_MATCH:
+ break;
+ case NSN_MATCH:
+ try {
+ // For NANP phone numbers, match when one has +1 and the other does not.
+ // In this case, prefer the +1 version.
+ if (util.parse(dataPart1, null).getCountryCode() == 1) {
+ // At this point, the numbers can be either case 1 or 2 below....
+ //
+ // case 1)
+ // +14155551212 <--- country code 1
+ // 14155551212 <--- 1 is trunk prefix, not country code
+ //
+ // and
+ //
+ // case 2)
+ // +14155551212
+ // 4155551212
+ //
+ // From b/7519057, case 2 needs to be equal. But also that bug, case 3
+ // below should not be equal.
+ //
+ // case 3)
+ // 14155551212
+ // 4155551212
+ //
+ // So in order to make sure transitive equality is valid, case 1 cannot
+ // be equal. Otherwise, transitive equality breaks and the following
+ // would all be collapsed:
+ // 4155551212 |
+ // 14155551212 |----> +14155551212
+ // +14155551212 |
+ //
+ // With transitive equality, the collapsed values should be:
+ // 4155551212 | 14155551212
+ // 14155551212 |----> +14155551212
+ // +14155551212 |
+
+ // Distinguish between case 1 and 2 by checking for trunk prefix '1'
+ // at the start of number 2.
+ if (dataPart2.trim().charAt(0) == '1') {
+ // case 1
+ return false;
+ }
+ break;
+ }
+ } catch (NumberParseException e) {
+ // This is the case where the first number does not have a country code.
+ // examples:
+ // (123) 456-7890 & 123-456-7890 (collapse)
+ // 0049 (8092) 1234 & +49/80921234 (unit test says do not collapse)
+
+ // Check the second number. If it also does not have a country code, then
+ // we should collapse. If it has a country code, then it's a different
+ // number and we should not collapse (this conclusion is based on an
+ // existing unit test).
+ try {
+ util.parse(dataPart2, null);
+ } catch (NumberParseException e2) {
+ // Number 2 also does not have a country. Collapse.
+ break;
+ }
+ }
+ return false;
+ case SHORT_NSN_MATCH:
+ return false;
+ default:
+ throw new IllegalStateException("Unknown result value from phone number " +
+ "library");
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Returns the {@link android.graphics.Rect} with left, top, right, and bottom coordinates
+ * that are equivalent to the given {@link android.view.View}'s bounds. This is equivalent to
+ * how the target {@link android.graphics.Rect} is calculated in
+ * {@link android.provider.ContactsContract.QuickContact#showQuickContact}.
+ */
+ public static Rect getTargetRectFromView(View view) {
+ final int[] pos = new int[2];
+ view.getLocationOnScreen(pos);
+
+ final Rect rect = new Rect();
+ rect.left = pos[0];
+ rect.top = pos[1];
+ rect.right = pos[0] + view.getWidth();
+ rect.bottom = pos[1] + view.getHeight();
+ return rect;
+ }
+
+ /**
+ * Returns a header view based on the R.layout.list_separator, where the
+ * containing {@link android.widget.TextView} is set using the given textResourceId.
+ */
+ public static TextView createHeaderView(Context context, int textResourceId) {
+ final TextView textView = (TextView) View.inflate(context, R.layout.list_separator, null);
+ textView.setText(context.getString(textResourceId));
+ return textView;
+ }
+
+ /**
+ * Set the top padding on the header view dynamically, based on whether the header is in
+ * the first row or not.
+ */
+ public static void setHeaderViewBottomPadding(Context context, TextView textView,
+ boolean isFirstRow) {
+ final int topPadding;
+ if (isFirstRow) {
+ topPadding = (int) context.getResources().getDimension(
+ R.dimen.frequently_contacted_title_top_margin_when_first_row);
+ } else {
+ topPadding = (int) context.getResources().getDimension(
+ R.dimen.frequently_contacted_title_top_margin);
+ }
+ textView.setPaddingRelative(textView.getPaddingStart(), topPadding,
+ textView.getPaddingEnd(), textView.getPaddingBottom());
+ }
+
+
+ /**
+ * Returns the intent to launch for the given invitable account type and contact lookup URI.
+ * This will return null if the account type is not invitable (i.e. there is no
+ * {@link AccountType#getInviteContactActivityClassName()} or
+ * {@link AccountType#syncAdapterPackageName}).
+ */
+ public static Intent getInvitableIntent(AccountType accountType, Uri lookupUri) {
+ String syncAdapterPackageName = accountType.syncAdapterPackageName;
+ String className = accountType.getInviteContactActivityClassName();
+ if (TextUtils.isEmpty(syncAdapterPackageName) || TextUtils.isEmpty(className)) {
+ return null;
+ }
+ Intent intent = new Intent();
+ intent.setClassName(syncAdapterPackageName, className);
+
+ intent.setAction(ContactsContract.Intents.INVITE_CONTACT);
+
+ // Data is the lookup URI.
+ intent.setData(lookupUri);
+ return intent;
+ }
+}
diff --git a/src/com/android/contacts/common/activity/AppCompatTransactionSafeActivity.java b/src/com/android/contacts/common/activity/AppCompatTransactionSafeActivity.java
new file mode 100644
index 0000000..e70a9fd
--- /dev/null
+++ b/src/com/android/contacts/common/activity/AppCompatTransactionSafeActivity.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.activity;
+
+import android.os.Bundle;
+import android.support.v7.app.AppCompatActivity;
+
+/**
+ * A common superclass that keeps track of whether an {@link AppCompatActivity} has saved its state
+ * yet or not, copied from {@link com.android.contacts.common.activity.TransactionSafeActivity},
+ * which will be deprecated after Kitkat backporting is done.
+ */
+public abstract class AppCompatTransactionSafeActivity extends AppCompatActivity {
+
+ private boolean mIsSafeToCommitTransactions;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mIsSafeToCommitTransactions = true;
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ mIsSafeToCommitTransactions = true;
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mIsSafeToCommitTransactions = true;
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ mIsSafeToCommitTransactions = false;
+ }
+
+ /**
+ * Returns true if it is safe to commit {@link FragmentTransaction}s at this time, based on
+ * whether {@link FragmentActivity#onSaveInstanceState} has been called or not.
+ *
+ * Make sure that the current activity calls into
+ * {@link super.onSaveInstanceState(Bundle outState)} (if that method is overridden),
+ * so the flag is properly set.
+ */
+ public boolean isSafeToCommitTransactions() {
+ return mIsSafeToCommitTransactions;
+ }
+}
diff --git a/src/com/android/contacts/common/activity/LicenseActivity.java b/src/com/android/contacts/common/activity/LicenseActivity.java
new file mode 100644
index 0000000..71bcd84
--- /dev/null
+++ b/src/com/android/contacts/common/activity/LicenseActivity.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.activity;
+
+import com.android.contacts.common.R;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.webkit.WebView;
+
+/**
+ * Displays the licenses for all open source libraries.
+ */
+public class LicenseActivity extends Activity {
+ private static final String LICENSE_FILE = "file:///android_asset/licenses.html";
+ private WebView mWebView;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.licenses);
+ mWebView = (WebView) findViewById(R.id.webview);
+ mWebView.loadUrl(LICENSE_FILE);
+ final ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP);
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ mWebView.destroy();
+ super.onDestroy();
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ finish();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/src/com/android/contacts/common/activity/RequestDesiredPermissionsActivity.java b/src/com/android/contacts/common/activity/RequestDesiredPermissionsActivity.java
new file mode 100644
index 0000000..8098fdf
--- /dev/null
+++ b/src/com/android/contacts/common/activity/RequestDesiredPermissionsActivity.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.activity;
+
+import android.Manifest.permission;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Requests permissions that are not absolutely required by the calling Activity;
+ * if permissions are denied, the calling Activity is still restarted.
+ *
+ * Activities that have a set of permissions that must be granted in order for the Activity to
+ * function propertly should call
+ * {@link RequestPermissionsActivity#startPermissionActivity(Activity, String[], Class)}
+ * before calling {@link RequestDesiredPermissionsActivity#startPermissionActivity(Activity)}.
+ */
+public class RequestDesiredPermissionsActivity extends RequestPermissionsActivityBase {
+
+ private static String[] sDesiredPermissions;
+
+ @Override
+ protected String[] getPermissions() {
+ return getPermissions(getPackageManager());
+ }
+
+ /**
+ * If any desired permission that Contacts app needs are missing, open an Activity
+ * to prompt user for these permissions. After that calling activity is restarted
+ * and in the second run permission check is skipped.
+ *
+ * This is designed to be called inside {@link android.app.Activity#onCreate}
+ */
+ public static boolean startPermissionActivity(Activity activity) {
+ final Bundle extras = activity.getIntent().getExtras();
+ if (extras != null && extras.getBoolean(EXTRA_STARTED_PERMISSIONS_ACTIVITY, false)) {
+ return false;
+ }
+ return startPermissionActivity(activity,
+ getPermissions(activity.getPackageManager()),
+ RequestDesiredPermissionsActivity.class);
+ }
+
+ private static String[] getPermissions(PackageManager packageManager) {
+ if (sDesiredPermissions == null) {
+ final List<String> permissions = new ArrayList<>();
+ // Calendar group
+ permissions.add(permission.READ_CALENDAR);
+
+ if (packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) {
+ // SMS group
+ permissions.add(permission.READ_SMS);
+ }
+ sDesiredPermissions = permissions.toArray(new String[0]);
+ }
+ return sDesiredPermissions;
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ int requestCode, String permissions[], int[] grantResults) {
+ mPreviousActivityIntent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(mPreviousActivityIntent);
+ overridePendingTransition(0, 0);
+
+ finish();
+ overridePendingTransition(0, 0);
+ }
+}
\ No newline at end of file
diff --git a/src/com/android/contacts/common/activity/RequestImportVCardPermissionsActivity.java b/src/com/android/contacts/common/activity/RequestImportVCardPermissionsActivity.java
new file mode 100644
index 0000000..df98d12
--- /dev/null
+++ b/src/com/android/contacts/common/activity/RequestImportVCardPermissionsActivity.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.activity;
+
+import android.Manifest.permission;
+import android.app.Activity;
+
+/**
+ * Activity that requests permissions needed for ImportVCardActivity.
+ */
+public class RequestImportVCardPermissionsActivity extends RequestPermissionsActivity {
+
+ private static final String[] REQUIRED_PERMISSIONS = new String[] {
+ // Contacts group
+ permission.GET_ACCOUNTS,
+ permission.READ_CONTACTS,
+ permission.WRITE_CONTACTS,
+ // Storage group
+ permission.READ_EXTERNAL_STORAGE,
+ };
+
+ @Override
+ protected String[] getPermissions() {
+ return REQUIRED_PERMISSIONS;
+ }
+
+ /**
+ * If any permissions the Contacts app needs are missing, open an Activity
+ * to prompt the user for these permissions. Moreover, finish the current activity.
+ *
+ * This is designed to be called inside {@link android.app.Activity#onCreate}
+ */
+ public static boolean startPermissionActivity(Activity activity) {
+ return startPermissionActivity(activity, REQUIRED_PERMISSIONS,
+ RequestImportVCardPermissionsActivity.class);
+ }
+}
\ No newline at end of file
diff --git a/src/com/android/contacts/common/activity/RequestPermissionsActivity.java b/src/com/android/contacts/common/activity/RequestPermissionsActivity.java
new file mode 100644
index 0000000..126cd64
--- /dev/null
+++ b/src/com/android/contacts/common/activity/RequestPermissionsActivity.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.activity;
+
+import com.android.contacts.common.R;
+
+import android.Manifest.permission;
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Activity that requests permissions needed for activities exported from Contacts.
+ */
+public class RequestPermissionsActivity extends RequestPermissionsActivityBase {
+
+ private static String[] sRequiredPermissions;
+
+ @Override
+ protected String[] getPermissions() {
+ return getPermissions(getPackageManager());
+ }
+
+ public static boolean startPermissionActivity(Activity activity) {
+ return startPermissionActivity(activity,
+ getPermissions(activity.getPackageManager()),
+ RequestPermissionsActivity.class);
+ }
+
+ private static String[] getPermissions(PackageManager packageManager) {
+ if (sRequiredPermissions == null) {
+ final List<String> permissions = new ArrayList<>();
+ // Contacts group
+ permissions.add(permission.GET_ACCOUNTS);
+ permissions.add(permission.READ_CONTACTS);
+ permissions.add(permission.WRITE_CONTACTS);
+
+ if (packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) {
+ // Phone group
+ // These are only used in a few places such as QuickContactActivity and
+ // ImportExportDialogFragment. We work around missing this permission when
+ // telephony is not available on the device (i.e. on tablets).
+ permissions.add(permission.CALL_PHONE);
+ permissions.add(permission.READ_CALL_LOG);
+ permissions.add(permission.READ_PHONE_STATE);
+ }
+ sRequiredPermissions = permissions.toArray(new String[0]);
+ }
+ return sRequiredPermissions;
+ }
+
+ @Override
+ public void onRequestPermissionsResult(
+ int requestCode, String permissions[], int[] grantResults) {
+ if (permissions != null && permissions.length > 0
+ && isAllGranted(permissions, grantResults)) {
+ mPreviousActivityIntent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(mPreviousActivityIntent);
+ finish();
+ overridePendingTransition(0, 0);
+ } else {
+ Toast.makeText(this, R.string.missing_required_permission, Toast.LENGTH_SHORT).show();
+ finish();
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/activity/RequestPermissionsActivityBase.java b/src/com/android/contacts/common/activity/RequestPermissionsActivityBase.java
new file mode 100644
index 0000000..999c545
--- /dev/null
+++ b/src/com/android/contacts/common/activity/RequestPermissionsActivityBase.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.activity;
+
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.util.PermissionsUtil;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.os.Trace;
+import android.support.v4.app.ActivityCompat;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+
+/**
+ * Activity that asks the user for all {@link #getPermissions} if any are missing.
+ *
+ * NOTE: As a result of b/22095159, this can behave oddly in the case where the final permission
+ * you are requesting causes an application restart.
+ */
+public abstract class RequestPermissionsActivityBase extends Activity
+ implements ActivityCompat.OnRequestPermissionsResultCallback {
+
+ public static final String PREVIOUS_ACTIVITY_INTENT = "previous_intent";
+
+ /** Whether the permissions activity was already started. */
+ protected static final String EXTRA_STARTED_PERMISSIONS_ACTIVITY =
+ "started_permissions_activity";
+
+ private static final int PERMISSIONS_REQUEST_ALL_PERMISSIONS = 1;
+
+ /**
+ * @return list of permissions that are needed in order for {@link #PREVIOUS_ACTIVITY_INTENT}
+ * to operate. You only need to return a single permission per permission group you care about.
+ */
+ protected abstract String[] getPermissions();
+
+ protected Intent mPreviousActivityIntent;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mPreviousActivityIntent = (Intent) getIntent().getExtras().get(PREVIOUS_ACTIVITY_INTENT);
+
+ // Only start a requestPermissions() flow when first starting this activity the first time.
+ // The process is likely to be restarted during the permission flow (necessary to enable
+ // permissions) so this is important to track.
+ if (savedInstanceState == null) {
+ requestPermissions();
+ }
+ }
+
+ /**
+ * If any permissions the Contacts app needs are missing, open an Activity
+ * to prompt the user for these permissions. Moreover, finish the current activity.
+ *
+ * This is designed to be called inside {@link android.app.Activity#onCreate}
+ */
+ protected static boolean startPermissionActivity(Activity activity,
+ String[] requiredPermissions, Class<?> newActivityClass) {
+ if (!hasPermissions(activity, requiredPermissions)) {
+ final Intent intent = new Intent(activity, newActivityClass);
+ activity.getIntent().putExtra(EXTRA_STARTED_PERMISSIONS_ACTIVITY, true);
+ intent.putExtra(PREVIOUS_ACTIVITY_INTENT, activity.getIntent());
+ activity.startActivity(intent);
+ activity.finish();
+ return true;
+ }
+
+ // Account type initialization must be delayed until the Contacts permission group
+ // has been granted (since GET_ACCOUNTS) falls under that groups. Previously it
+ // was initialized in ContactApplication which would cause problems as
+ // AccountManager.getAccounts would return an empty array. See b/22690336
+ AccountTypeManager.getInstance(activity);
+
+ return false;
+ }
+
+ protected boolean isAllGranted(String permissions[], int[] grantResult) {
+ for (int i = 0; i < permissions.length; i++) {
+ if (grantResult[i] != PackageManager.PERMISSION_GRANTED
+ && isPermissionRequired(permissions[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean isPermissionRequired(String p) {
+ return Arrays.asList(getPermissions()).contains(p);
+ }
+
+ private void requestPermissions() {
+ Trace.beginSection("requestPermissions");
+ try {
+ // Construct a list of missing permissions
+ final ArrayList<String> unsatisfiedPermissions = new ArrayList<>();
+ for (String permission : getPermissions()) {
+ if (!PermissionsUtil.hasPermission(this, permission)) {
+ unsatisfiedPermissions.add(permission);
+ }
+ }
+ if (unsatisfiedPermissions.size() == 0) {
+ throw new RuntimeException("Request permission activity was called even"
+ + " though all permissions are satisfied.");
+ }
+ ActivityCompat.requestPermissions(
+ this,
+ unsatisfiedPermissions.toArray(new String[unsatisfiedPermissions.size()]),
+ PERMISSIONS_REQUEST_ALL_PERMISSIONS);
+ } finally {
+ Trace.endSection();
+ }
+ }
+
+ protected static boolean hasPermissions(Context context, String[] permissions) {
+ Trace.beginSection("hasPermission");
+ try {
+ for (String permission : permissions) {
+ if (!PermissionsUtil.hasPermission(context, permission)) {
+ return false;
+ }
+ }
+ return true;
+ } finally {
+ Trace.endSection();
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/activity/TransactionSafeActivity.java b/src/com/android/contacts/common/activity/TransactionSafeActivity.java
new file mode 100644
index 0000000..6c2e4fe
--- /dev/null
+++ b/src/com/android/contacts/common/activity/TransactionSafeActivity.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.activity;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+/**
+ * A common superclass that keeps track of whether an {@link Activity} has saved its state yet or
+ * not.
+ */
+public abstract class TransactionSafeActivity extends Activity {
+
+ private boolean mIsSafeToCommitTransactions;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mIsSafeToCommitTransactions = true;
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ mIsSafeToCommitTransactions = true;
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mIsSafeToCommitTransactions = true;
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ mIsSafeToCommitTransactions = false;
+ }
+
+ /**
+ * Returns true if it is safe to commit {@link FragmentTransaction}s at this time, based on
+ * whether {@link Activity#onSaveInstanceState} has been called or not.
+ *
+ * Make sure that the current activity calls into
+ * {@link super.onSaveInstanceState(Bundle outState)} (if that method is overridden),
+ * so the flag is properly set.
+ */
+ public boolean isSafeToCommitTransactions() {
+ return mIsSafeToCommitTransactions;
+ }
+}
diff --git a/src/com/android/contacts/common/compat/CallSdkCompat.java b/src/com/android/contacts/common/compat/CallSdkCompat.java
new file mode 100644
index 0000000..fd06d85
--- /dev/null
+++ b/src/com/android/contacts/common/compat/CallSdkCompat.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.telecom.Call;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+public class CallSdkCompat {
+ public static class Details {
+ // TODO: This property is hidden in the N release; replace with actual when the API is
+ // made public.
+ public static final int PROPERTY_IS_EXTERNAL_CALL = 0x00000040;
+ public static final int PROPERTY_ENTERPRISE_CALL = Call.Details.PROPERTY_ENTERPRISE_CALL;
+ // TODO: This capability is hidden in the N release; replace with actual when the API is
+ // made public.
+ public static final int CAPABILITY_CAN_PULL_CALL = 0x00800000;
+ public static final int CAPABILITY_CANNOT_DOWNGRADE_VIDEO_TO_AUDIO =
+ Call.Details.CAPABILITY_CANNOT_DOWNGRADE_VIDEO_TO_AUDIO;
+ }
+
+ /**
+ * TODO: This API is hidden in the N release; replace the implementation with a call to the
+ * actual once it is made public.
+ */
+ public static void pullExternalCall(Call call) {
+ if (!CompatUtils.isNCompatible()) {
+ return;
+ }
+ Class<?> callClass = Call.class;
+ try {
+ Method pullExternalCallMethod = callClass.getDeclaredMethod("pullExternalCall");
+ pullExternalCallMethod.invoke(call);
+ } catch (NoSuchMethodException e) {
+ // Ignore requests to pull call if there is a problem.
+ } catch (InvocationTargetException e) {
+ // Ignore requests to pull call if there is a problem.
+ } catch (IllegalAccessException e) {
+ // Ignore requests to pull call if there is a problem.
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/compat/CallableCompat.java b/src/com/android/contacts/common/compat/CallableCompat.java
new file mode 100644
index 0000000..d25d4be
--- /dev/null
+++ b/src/com/android/contacts/common/compat/CallableCompat.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.net.Uri;
+import android.provider.ContactsContract.CommonDataKinds.Callable;
+
+import com.android.contacts.common.ContactsUtils;
+
+public class CallableCompat {
+
+ // TODO: Use N APIs
+ private static final Uri ENTERPRISE_CONTENT_FILTER_URI =
+ Uri.withAppendedPath(Callable.CONTENT_URI, "filter_enterprise");
+
+ public static Uri getContentFilterUri() {
+ if (ContactsUtils.FLAG_N_FEATURE) {
+ return ENTERPRISE_CONTENT_FILTER_URI;
+ }
+ return Callable.CONTENT_FILTER_URI;
+ }
+}
diff --git a/src/com/android/contacts/common/compat/CompatUtils.java b/src/com/android/contacts/common/compat/CompatUtils.java
new file mode 100644
index 0000000..567f183
--- /dev/null
+++ b/src/com/android/contacts/common/compat/CompatUtils.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.compat;
+
+import android.os.Build;
+import android.os.Build.VERSION;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.contacts.common.model.CPOWrapper;
+
+import java.lang.reflect.InvocationTargetException;
+
+public final class CompatUtils {
+
+ private static final String TAG = CompatUtils.class.getSimpleName();
+
+ /**
+ * These 4 variables are copied from ContentProviderOperation for compatibility.
+ */
+ public final static int TYPE_INSERT = 1;
+
+ public final static int TYPE_UPDATE = 2;
+
+ public final static int TYPE_DELETE = 3;
+
+ public final static int TYPE_ASSERT = 4;
+
+ /**
+ * Returns whether the operation in CPOWrapper is of TYPE_INSERT;
+ */
+ public static boolean isInsertCompat(CPOWrapper cpoWrapper) {
+ if (SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.M) >= Build.VERSION_CODES.M) {
+ return cpoWrapper.getOperation().isInsert();
+ }
+ return (cpoWrapper.getType() == TYPE_INSERT);
+ }
+
+ /**
+ * Returns whether the operation in CPOWrapper is of TYPE_UPDATE;
+ */
+ public static boolean isUpdateCompat(CPOWrapper cpoWrapper) {
+ if (SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.M) >= Build.VERSION_CODES.M) {
+ return cpoWrapper.getOperation().isUpdate();
+ }
+ return (cpoWrapper.getType() == TYPE_UPDATE);
+ }
+
+ /**
+ * Returns whether the operation in CPOWrapper is of TYPE_DELETE;
+ */
+ public static boolean isDeleteCompat(CPOWrapper cpoWrapper) {
+ if (SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.M) >= Build.VERSION_CODES.M) {
+ return cpoWrapper.getOperation().isDelete();
+ }
+ return (cpoWrapper.getType() == TYPE_DELETE);
+ }
+ /**
+ * Returns whether the operation in CPOWrapper is of TYPE_ASSERT;
+ */
+ public static boolean isAssertQueryCompat(CPOWrapper cpoWrapper) {
+ if (SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.M) >= Build.VERSION_CODES.M) {
+ return cpoWrapper.getOperation().isAssertQuery();
+ }
+ return (cpoWrapper.getType() == TYPE_ASSERT);
+ }
+
+ /**
+ * PrioritizedMimeType is added in API level 23.
+ */
+ public static boolean hasPrioritizedMimeType() {
+ return SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.M)
+ >= Build.VERSION_CODES.M;
+ }
+
+ /**
+ * Determines if this version is compatible with multi-SIM and the phone account APIs. Can also
+ * force the version to be lower through SdkVersionOverride.
+ *
+ * @return {@code true} if multi-SIM capability is available, {@code false} otherwise.
+ */
+ public static boolean isMSIMCompatible() {
+ return SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.LOLLIPOP)
+ >= Build.VERSION_CODES.LOLLIPOP_MR1;
+ }
+
+ /**
+ * Determines if this version is compatible with video calling. Can also force the version to be
+ * lower through SdkVersionOverride.
+ *
+ * @return {@code true} if video calling is allowed, {@code false} otherwise.
+ */
+ public static boolean isVideoCompatible() {
+ return SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.LOLLIPOP)
+ >= Build.VERSION_CODES.M;
+ }
+
+ /**
+ * Determines if this version is capable of using presence checking for video calling. Support
+ * for video call presence indication is added in SDK 24.
+ *
+ * @return {@code true} if video presence checking is allowed, {@code false} otherwise.
+ */
+ public static boolean isVideoPresenceCompatible() {
+ return SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.M)
+ > Build.VERSION_CODES.M;
+ }
+
+ /**
+ * Determines if this version is compatible with call subject. Can also force the version to be
+ * lower through SdkVersionOverride.
+ *
+ * @return {@code true} if call subject is a feature on this device, {@code false} otherwise.
+ */
+ public static boolean isCallSubjectCompatible() {
+ return SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.LOLLIPOP)
+ >= Build.VERSION_CODES.M;
+ }
+
+ /**
+ * Determines if this version is compatible with a default dialer. Can also force the version to
+ * be lower through {@link SdkVersionOverride}.
+ *
+ * @return {@code true} if default dialer is a feature on this device, {@code false} otherwise.
+ */
+ public static boolean isDefaultDialerCompatible() {
+ return isMarshmallowCompatible();
+ }
+
+ /**
+ * Determines if this version is compatible with Lollipop Mr1-specific APIs. Can also force the
+ * version to be lower through SdkVersionOverride.
+ *
+ * @return {@code true} if runtime sdk is compatible with Lollipop MR1, {@code false} otherwise.
+ */
+ public static boolean isLollipopMr1Compatible() {
+ return SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.LOLLIPOP_MR1)
+ >= Build.VERSION_CODES.LOLLIPOP_MR1;
+ }
+
+ /**
+ * Determines if this version is compatible with Marshmallow-specific APIs. Can also force the
+ * version to be lower through SdkVersionOverride.
+ *
+ * @return {@code true} if runtime sdk is compatible with Marshmallow, {@code false} otherwise.
+ */
+ public static boolean isMarshmallowCompatible() {
+ return SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.LOLLIPOP)
+ >= Build.VERSION_CODES.M;
+ }
+
+ /**
+ * Determines if this version is compatible with N-specific APIs.
+ *
+ * @return {@code true} if runtime sdk is compatible with N and the app is built with N, {@code
+ * false} otherwise.
+ */
+ public static boolean isNCompatible() {
+ return VERSION.SDK_INT >= 24;
+ }
+
+ /**
+ * Determines if the given class is available. Can be used to check if system apis exist at
+ * runtime.
+ *
+ * @param className the name of the class to look for.
+ * @return {@code true} if the given class is available, {@code false} otherwise or if className
+ * is empty.
+ */
+ public static boolean isClassAvailable(@Nullable String className) {
+ if (TextUtils.isEmpty(className)) {
+ return false;
+ }
+ try {
+ Class.forName(className);
+ return true;
+ } catch (ClassNotFoundException e) {
+ return false;
+ } catch (Throwable t) {
+ Log.e(TAG, "Unexpected exception when checking if class:" + className + " exists at "
+ + "runtime", t);
+ return false;
+ }
+ }
+
+ /**
+ * Determines if the given class's method is available to call. Can be used to check if system
+ * apis exist at runtime.
+ *
+ * @param className the name of the class to look for
+ * @param methodName the name of the method to look for
+ * @param parameterTypes the needed parameter types for the method to look for
+ * @return {@code true} if the given class is available, {@code false} otherwise or if className
+ * or methodName are empty.
+ */
+ public static boolean isMethodAvailable(@Nullable String className, @Nullable String methodName,
+ Class<?>... parameterTypes) {
+ if (TextUtils.isEmpty(className) || TextUtils.isEmpty(methodName)) {
+ return false;
+ }
+
+ try {
+ Class.forName(className).getMethod(methodName, parameterTypes);
+ return true;
+ } catch (ClassNotFoundException | NoSuchMethodException e) {
+ Log.v(TAG, "Could not find method: " + className + "#" + methodName);
+ return false;
+ } catch (Throwable t) {
+ Log.e(TAG, "Unexpected exception when checking if method: " + className + "#"
+ + methodName + " exists at runtime", t);
+ return false;
+ }
+ }
+
+ /**
+ * Invokes a given class's method using reflection. Can be used to call system apis that exist
+ * at runtime but not in the SDK.
+ *
+ * @param instance The instance of the class to invoke the method on.
+ * @param methodName The name of the method to invoke.
+ * @param parameterTypes The needed parameter types for the method.
+ * @param parameters The parameter values to pass into the method.
+ * @return The result of the invocation or {@code null} if instance or methodName are empty, or
+ * if the reflection fails.
+ */
+ @Nullable
+ public static Object invokeMethod(@Nullable Object instance, @Nullable String methodName,
+ Class<?>[] parameterTypes, Object[] parameters) {
+ if (instance == null || TextUtils.isEmpty(methodName)) {
+ return null;
+ }
+
+ String className = instance.getClass().getName();
+ try {
+ return Class.forName(className).getMethod(methodName, parameterTypes)
+ .invoke(instance, parameters);
+ } catch (ClassNotFoundException | NoSuchMethodException | IllegalArgumentException
+ | IllegalAccessException | InvocationTargetException e) {
+ Log.v(TAG, "Could not invoke method: " + className + "#" + methodName);
+ return null;
+ } catch (Throwable t) {
+ Log.e(TAG, "Unexpected exception when invoking method: " + className
+ + "#" + methodName + " at runtime", t);
+ return null;
+ }
+ }
+
+ /**
+ * Determines if this version is compatible with Lollipop-specific APIs. Can also force the
+ * version to be lower through SdkVersionOverride.
+ *
+ * @return {@code true} if call subject is a feature on this device, {@code false} otherwise.
+ */
+ public static boolean isLollipopCompatible() {
+ return SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.LOLLIPOP)
+ >= Build.VERSION_CODES.LOLLIPOP;
+ }
+}
diff --git a/src/com/android/contacts/common/compat/ContactsCompat.java b/src/com/android/contacts/common/compat/ContactsCompat.java
new file mode 100644
index 0000000..5a5e46a
--- /dev/null
+++ b/src/com/android/contacts/common/compat/ContactsCompat.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+
+import com.android.contacts.common.ContactsUtils;
+
+/**
+ * Compatibility class for {@link ContactsContract.Contacts}
+ */
+public class ContactsCompat {
+ /**
+ * Not instantiable.
+ */
+ private ContactsCompat() {
+ }
+
+ // TODO: Use N APIs
+ private static final Uri ENTERPRISE_CONTENT_FILTER_URI =
+ Uri.withAppendedPath(Contacts.CONTENT_URI, "filter_enterprise");
+
+ // Copied from ContactsContract.Contacts#ENTERPRISE_CONTACT_ID_BASE, which is hidden.
+ private static final long ENTERPRISE_CONTACT_ID_BASE = 1000000000;
+
+ public static Uri getContentUri() {
+ if (ContactsUtils.FLAG_N_FEATURE) {
+ return ENTERPRISE_CONTENT_FILTER_URI;
+ }
+ return Contacts.CONTENT_FILTER_URI;
+ }
+
+ /**
+ * Return {@code true} if a contact ID is from the contacts provider on the enterprise profile.
+ */
+ public static boolean isEnterpriseContactId(long contactId) {
+ if (CompatUtils.isLollipopCompatible()) {
+ return Contacts.isEnterpriseContactId(contactId);
+ } else {
+ // copied from ContactsContract.Contacts.isEnterpriseContactId
+ return (contactId >= ENTERPRISE_CONTACT_ID_BASE) &&
+ (contactId < ContactsContract.Profile.MIN_ID);
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/compat/DirectoryCompat.java b/src/com/android/contacts/common/compat/DirectoryCompat.java
new file mode 100644
index 0000000..682e14c
--- /dev/null
+++ b/src/com/android/contacts/common/compat/DirectoryCompat.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Directory;
+
+import com.android.contacts.common.ContactsUtils;
+
+public class DirectoryCompat {
+
+ public static Uri getContentUri() {
+ if (ContactsUtils.FLAG_N_FEATURE) {
+ return DirectorySdkCompat.ENTERPRISE_CONTENT_URI;
+ }
+ return Directory.CONTENT_URI;
+ }
+
+ public static boolean isInvisibleDirectory(long directoryId) {
+ if (ContactsUtils.FLAG_N_FEATURE) {
+ return (directoryId == Directory.LOCAL_INVISIBLE
+ || directoryId == DirectorySdkCompat.ENTERPRISE_LOCAL_INVISIBLE);
+ }
+ return directoryId == Directory.LOCAL_INVISIBLE;
+ }
+
+ public static boolean isRemoteDirectoryId(long directoryId) {
+ if (ContactsUtils.FLAG_N_FEATURE) {
+ return DirectorySdkCompat.isRemoteDirectoryId(directoryId);
+ }
+ return !(directoryId == Directory.DEFAULT || directoryId == Directory.LOCAL_INVISIBLE);
+ }
+
+ public static boolean isEnterpriseDirectoryId(long directoryId) {
+ return ContactsUtils.FLAG_N_FEATURE
+ ? DirectorySdkCompat.isEnterpriseDirectoryId(directoryId)
+ : false;
+ }
+}
diff --git a/src/com/android/contacts/common/compat/DirectorySdkCompat.java b/src/com/android/contacts/common/compat/DirectorySdkCompat.java
new file mode 100644
index 0000000..b919466
--- /dev/null
+++ b/src/com/android/contacts/common/compat/DirectorySdkCompat.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.net.Uri;
+import android.provider.ContactsContract.Directory;
+
+public class DirectorySdkCompat {
+
+ private static final String TAG = "DirectorySdkCompat";
+
+ public static final Uri ENTERPRISE_CONTENT_URI = Directory.ENTERPRISE_CONTENT_URI;
+ public static final long ENTERPRISE_LOCAL_DEFAULT = Directory.ENTERPRISE_DEFAULT;
+ public static final long ENTERPRISE_LOCAL_INVISIBLE = Directory.ENTERPRISE_LOCAL_INVISIBLE;
+
+ public static boolean isRemoteDirectoryId(long directoryId) {
+ return CompatUtils.isNCompatible() ? Directory.isRemoteDirectoryId(directoryId) : false;
+ }
+
+ public static boolean isEnterpriseDirectoryId(long directoryId) {
+ return CompatUtils.isNCompatible() ? Directory.isEnterpriseDirectoryId(directoryId) : false;
+ }
+}
diff --git a/src/com/android/contacts/common/compat/EventCompat.java b/src/com/android/contacts/common/compat/EventCompat.java
new file mode 100644
index 0000000..f37aeff
--- /dev/null
+++ b/src/com/android/contacts/common/compat/EventCompat.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.content.res.Resources;
+import android.provider.ContactsContract.CommonDataKinds.BaseTypes;
+import android.provider.ContactsContract.CommonDataKinds.Event;
+import android.text.TextUtils;
+
+/**
+ * Compatibility class for {@link Event}
+ */
+public class EventCompat {
+ /**
+ * Not instantiable.
+ */
+ private EventCompat() {
+ }
+
+ /**
+ * Return a {@link CharSequence} that best describes the given type, possibly substituting
+ * the given label value for TYPE_CUSTOM.
+ */
+ public static CharSequence getTypeLabel(Resources res, int type, CharSequence label) {
+ if (CompatUtils.isLollipopCompatible()) {
+ return Event.getTypeLabel(res, type, label);
+ } else {
+ return getTypeLabelInternal(res, type, label);
+ }
+ }
+
+ /**
+ * The method was added in API level 21, and below is the implementation copied from
+ * {@link Event#getTypeLabel(Resources, int, CharSequence)}
+ */
+ private static CharSequence getTypeLabelInternal(Resources res, int type, CharSequence label) {
+ if (type == BaseTypes.TYPE_CUSTOM && !TextUtils.isEmpty(label)) {
+ return label;
+ } else {
+ return res.getText(Event.getTypeResource(type));
+ }
+ }
+
+}
diff --git a/src/com/android/contacts/common/compat/MetadataSyncEnabledCompat.java b/src/com/android/contacts/common/compat/MetadataSyncEnabledCompat.java
new file mode 100644
index 0000000..4a9650f
--- /dev/null
+++ b/src/com/android/contacts/common/compat/MetadataSyncEnabledCompat.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.content.Context;
+import android.provider.Settings;
+
+public class MetadataSyncEnabledCompat {
+ public static boolean isMetadataSyncEnabled(Context context) {
+ return CompatUtils.isNCompatible()
+ ? (Settings.Global.getInt(context.getContentResolver(),
+ Settings.Global.CONTACT_METADATA_SYNC_ENABLED, 0) == 1)
+ : false;
+ }
+}
\ No newline at end of file
diff --git a/src/com/android/contacts/common/compat/MultiWindowCompat.java b/src/com/android/contacts/common/compat/MultiWindowCompat.java
new file mode 100644
index 0000000..6641279
--- /dev/null
+++ b/src/com/android/contacts/common/compat/MultiWindowCompat.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.app.Activity;
+
+public class MultiWindowCompat {
+ /**
+ * Returns true if the activity is currently in multi-window mode.
+ */
+ public static boolean isInMultiWindowMode(Activity activity) {
+ return CompatUtils.isNCompatible() ? activity.isInMultiWindowMode() : false;
+ }
+}
\ No newline at end of file
diff --git a/src/com/android/contacts/common/compat/PhoneAccountCompat.java b/src/com/android/contacts/common/compat/PhoneAccountCompat.java
new file mode 100644
index 0000000..00f4211
--- /dev/null
+++ b/src/com/android/contacts/common/compat/PhoneAccountCompat.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+package com.android.contacts.common.compat;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
+import android.support.annotation.Nullable;
+import android.telecom.PhoneAccount;
+import android.util.Log;
+
+/**
+ * Compatiblity class for {@link android.telecom.PhoneAccount}
+ */
+public class PhoneAccountCompat {
+
+ private static final String TAG = PhoneAccountCompat.class.getSimpleName();
+
+ /**
+ * Gets the {@link Icon} associated with the given {@link PhoneAccount}
+ *
+ * @param phoneAccount the PhoneAccount from which to retrieve the Icon
+ * @return the Icon, or null
+ */
+ @Nullable
+ public static Icon getIcon(@Nullable PhoneAccount phoneAccount) {
+ if (phoneAccount == null) {
+ return null;
+ }
+
+ if (CompatUtils.isMarshmallowCompatible()) {
+ return phoneAccount.getIcon();
+ }
+
+ return null;
+ }
+
+ /**
+ * Builds and returns an icon {@code Drawable} to represent this {@code PhoneAccount} in a user
+ * interface.
+ *
+ * @param phoneAccount the PhoneAccount from which to build the icon.
+ * @param context A {@code Context} to use for loading Drawables.
+ *
+ * @return An icon for this PhoneAccount, or null
+ */
+ @Nullable
+ public static Drawable createIconDrawable(@Nullable PhoneAccount phoneAccount,
+ @Nullable Context context) {
+ if (phoneAccount == null || context == null) {
+ return null;
+ }
+
+ if (CompatUtils.isMarshmallowCompatible()) {
+ return createIconDrawableMarshmallow(phoneAccount, context);
+ }
+
+ if (CompatUtils.isLollipopMr1Compatible()) {
+ return createIconDrawableLollipopMr1(phoneAccount, context);
+ }
+ return null;
+ }
+
+ @Nullable
+ private static Drawable createIconDrawableMarshmallow(PhoneAccount phoneAccount,
+ Context context) {
+ Icon accountIcon = getIcon(phoneAccount);
+ if (accountIcon == null) {
+ return null;
+ }
+ return accountIcon.loadDrawable(context);
+ }
+
+ @Nullable
+ private static Drawable createIconDrawableLollipopMr1(PhoneAccount phoneAccount,
+ Context context) {
+ try {
+ return (Drawable) PhoneAccount.class.getMethod("createIconDrawable", Context.class)
+ .invoke(phoneAccount, context);
+ } catch (ReflectiveOperationException e) {
+ return null;
+ } catch (Throwable t) {
+ Log.e(TAG, "Unexpected exception when attempting to call "
+ + "android.telecom.PhoneAccount#createIconDrawable", t);
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/compat/PhoneAccountSdkCompat.java b/src/com/android/contacts/common/compat/PhoneAccountSdkCompat.java
new file mode 100644
index 0000000..5cbf617
--- /dev/null
+++ b/src/com/android/contacts/common/compat/PhoneAccountSdkCompat.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.os.Bundle;
+import android.telecom.PhoneAccount;
+
+public class PhoneAccountSdkCompat {
+
+ private static final String TAG = "PhoneAccountSdkCompat";
+
+ public static final String EXTRA_CALL_SUBJECT_MAX_LENGTH =
+ PhoneAccount.EXTRA_CALL_SUBJECT_MAX_LENGTH;
+ public static final String EXTRA_CALL_SUBJECT_CHARACTER_ENCODING =
+ PhoneAccount.EXTRA_CALL_SUBJECT_CHARACTER_ENCODING;
+
+ public static final int CAPABILITY_VIDEO_CALLING_RELIES_ON_PRESENCE =
+ PhoneAccount.CAPABILITY_VIDEO_CALLING_RELIES_ON_PRESENCE;
+
+ public static Bundle getExtras(PhoneAccount account) {
+ return CompatUtils.isNCompatible() ? account.getExtras() : null;
+ }
+}
diff --git a/src/com/android/contacts/common/compat/PhoneCompat.java b/src/com/android/contacts/common/compat/PhoneCompat.java
new file mode 100644
index 0000000..5277761
--- /dev/null
+++ b/src/com/android/contacts/common/compat/PhoneCompat.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.net.Uri;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+
+import com.android.contacts.common.ContactsUtils;
+
+public class PhoneCompat {
+
+ // TODO: Use N APIs
+ private static final Uri ENTERPRISE_CONTENT_FILTER_URI =
+ Uri.withAppendedPath(Phone.CONTENT_URI, "filter_enterprise");
+
+ public static Uri getContentFilterUri() {
+ if (ContactsUtils.FLAG_N_FEATURE) {
+ return ENTERPRISE_CONTENT_FILTER_URI;
+ }
+ return Phone.CONTENT_FILTER_URI;
+ }
+}
diff --git a/src/com/android/contacts/common/compat/PhoneLookupSdkCompat.java b/src/com/android/contacts/common/compat/PhoneLookupSdkCompat.java
new file mode 100644
index 0000000..0c0a898
--- /dev/null
+++ b/src/com/android/contacts/common/compat/PhoneLookupSdkCompat.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.provider.ContactsContract;
+
+public class PhoneLookupSdkCompat {
+ public static final String CONTACT_ID = ContactsContract.PhoneLookup.CONTACT_ID;
+}
diff --git a/src/com/android/contacts/common/compat/PhoneNumberFormattingTextWatcherCompat.java b/src/com/android/contacts/common/compat/PhoneNumberFormattingTextWatcherCompat.java
new file mode 100644
index 0000000..42b604e
--- /dev/null
+++ b/src/com/android/contacts/common/compat/PhoneNumberFormattingTextWatcherCompat.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.compat;
+
+import android.telephony.PhoneNumberFormattingTextWatcher;
+
+public class PhoneNumberFormattingTextWatcherCompat {
+ public static PhoneNumberFormattingTextWatcher newInstance(String countryCode) {
+ if (CompatUtils.isLollipopCompatible()) {
+ return new PhoneNumberFormattingTextWatcher(countryCode);
+ }
+ return new PhoneNumberFormattingTextWatcher();
+ }
+}
diff --git a/src/com/android/contacts/common/compat/PhoneNumberUtilsCompat.java b/src/com/android/contacts/common/compat/PhoneNumberUtilsCompat.java
new file mode 100644
index 0000000..9a8fa41
--- /dev/null
+++ b/src/com/android/contacts/common/compat/PhoneNumberUtilsCompat.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import com.google.i18n.phonenumbers.NumberParseException;
+import com.google.i18n.phonenumbers.PhoneNumberUtil;
+import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;
+
+import android.telephony.PhoneNumberUtils;
+import android.text.Spannable;
+import android.text.TextUtils;
+import android.text.style.TtsSpan;
+
+/**
+ * This class contains static utility methods extracted from PhoneNumberUtils, and the
+ * methods were added in API level 23. In this way, we could enable the corresponding functionality
+ * for pre-M devices. We need maintain this class and keep it synced with PhoneNumberUtils.
+ * Another thing to keep in mind is that we use com.google.i18n rather than com.android.i18n in
+ * here, so we need make sure the application behavior is preserved.
+ */
+public class PhoneNumberUtilsCompat {
+ /**
+ * Not instantiable.
+ */
+ private PhoneNumberUtilsCompat() {}
+
+ public static String normalizeNumber(String phoneNumber) {
+ if (CompatUtils.isLollipopCompatible()) {
+ return PhoneNumberUtils.normalizeNumber(phoneNumber);
+ } else {
+ return normalizeNumberInternal(phoneNumber);
+ }
+ }
+
+ /**
+ * Implementation copied from {@link PhoneNumberUtils#normalizeNumber}
+ */
+ private static String normalizeNumberInternal(String phoneNumber) {
+ if (TextUtils.isEmpty(phoneNumber)) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ int len = phoneNumber.length();
+ for (int i = 0; i < len; i++) {
+ char c = phoneNumber.charAt(i);
+ // Character.digit() supports ASCII and Unicode digits (fullwidth, Arabic-Indic, etc.)
+ int digit = Character.digit(c, 10);
+ if (digit != -1) {
+ sb.append(digit);
+ } else if (sb.length() == 0 && c == '+') {
+ sb.append(c);
+ } else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
+ return normalizeNumber(PhoneNumberUtils.convertKeypadLettersToDigits(phoneNumber));
+ }
+ }
+ return sb.toString();
+ }
+
+ public static String formatNumber(
+ String phoneNumber, String phoneNumberE164, String defaultCountryIso) {
+ if (CompatUtils.isLollipopCompatible()) {
+ return PhoneNumberUtils.formatNumber(phoneNumber, phoneNumberE164, defaultCountryIso);
+ } else {
+ // This method was deprecated in API level 21, so it's only used on pre-L SDKs.
+ return PhoneNumberUtils.formatNumber(phoneNumber);
+ }
+ }
+
+ public static CharSequence createTtsSpannable(CharSequence phoneNumber) {
+ if (CompatUtils.isMarshmallowCompatible()) {
+ return PhoneNumberUtils.createTtsSpannable(phoneNumber);
+ } else {
+ return createTtsSpannableInternal(phoneNumber);
+ }
+ }
+
+ public static TtsSpan createTtsSpan(String phoneNumber) {
+ if (CompatUtils.isMarshmallowCompatible()) {
+ return PhoneNumberUtils.createTtsSpan(phoneNumber);
+ } else if (CompatUtils.isLollipopCompatible()) {
+ return createTtsSpanLollipop(phoneNumber);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Copied from {@link PhoneNumberUtils#createTtsSpannable}
+ */
+ private static CharSequence createTtsSpannableInternal(CharSequence phoneNumber) {
+ if (phoneNumber == null) {
+ return null;
+ }
+ Spannable spannable = Spannable.Factory.getInstance().newSpannable(phoneNumber);
+ addTtsSpanInternal(spannable, 0, spannable.length());
+ return spannable;
+ }
+
+ /**
+ * Compat method for addTtsSpan, see {@link PhoneNumberUtils#addTtsSpan}
+ */
+ public static void addTtsSpan(Spannable s, int start, int endExclusive) {
+ if (CompatUtils.isMarshmallowCompatible()) {
+ PhoneNumberUtils.addTtsSpan(s, start, endExclusive);
+ } else {
+ addTtsSpanInternal(s, start, endExclusive);
+ }
+ }
+
+ /**
+ * Copied from {@link PhoneNumberUtils#addTtsSpan}
+ */
+ private static void addTtsSpanInternal(Spannable s, int start, int endExclusive) {
+ s.setSpan(createTtsSpan(s.subSequence(start, endExclusive).toString()),
+ start,
+ endExclusive,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ /**
+ * Copied from {@link PhoneNumberUtils#createTtsSpan}
+ */
+ private static TtsSpan createTtsSpanLollipop(String phoneNumberString) {
+ if (phoneNumberString == null) {
+ return null;
+ }
+
+ // Parse the phone number
+ final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance();
+ PhoneNumber phoneNumber = null;
+ try {
+ // Don't supply a defaultRegion so this fails for non-international numbers because
+ // we don't want to TalkBalk to read a country code (e.g. +1) if it is not already
+ // present
+ phoneNumber = phoneNumberUtil.parse(phoneNumberString, /* defaultRegion */ null);
+ } catch (NumberParseException ignored) {
+ }
+
+ // Build a telephone tts span
+ final TtsSpan.TelephoneBuilder builder = new TtsSpan.TelephoneBuilder();
+ if (phoneNumber == null) {
+ // Strip separators otherwise TalkBack will be silent
+ // (this behavior was observed with TalkBalk 4.0.2 from their alpha channel)
+ builder.setNumberParts(splitAtNonNumerics(phoneNumberString));
+ } else {
+ if (phoneNumber.hasCountryCode()) {
+ builder.setCountryCode(Integer.toString(phoneNumber.getCountryCode()));
+ }
+ builder.setNumberParts(Long.toString(phoneNumber.getNationalNumber()));
+ }
+ return builder.build();
+ }
+
+
+
+ /**
+ * Split a phone number using spaces, ignoring anything that is not a digit
+ * @param number A {@code CharSequence} before splitting, e.g., "+20(123)-456#"
+ * @return A {@code String} after splitting, e.g., "20 123 456".
+ */
+ private static String splitAtNonNumerics(CharSequence number) {
+ StringBuilder sb = new StringBuilder(number.length());
+ for (int i = 0; i < number.length(); i++) {
+ sb.append(PhoneNumberUtils.isISODigit(number.charAt(i))
+ ? number.charAt(i)
+ : " ");
+ }
+ // It is very important to remove extra spaces. At time of writing, any leading or trailing
+ // spaces, or any sequence of more than one space, will confuse TalkBack and cause the TTS
+ // span to be non-functional!
+ return sb.toString().replaceAll(" +", " ").trim();
+ }
+
+}
diff --git a/src/com/android/contacts/common/compat/ProviderStatusCompat.java b/src/com/android/contacts/common/compat/ProviderStatusCompat.java
new file mode 100644
index 0000000..84338de
--- /dev/null
+++ b/src/com/android/contacts/common/compat/ProviderStatusCompat.java
@@ -0,0 +1,70 @@
+
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.os.Build;
+import android.provider.ContactsContract.ProviderStatus;
+
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.compat.SdkVersionOverride;
+
+/**
+ * This class contains constants from the pre-M version of ContactsContract.ProviderStatus class
+ * and also the mappings between pre-M constants and M constants for compatibility purpose,
+ * because ProviderStatus class constant names and values changed and the class became visible in
+ * API level 23.
+ */
+public class ProviderStatusCompat {
+ /**
+ * Not instantiable.
+ */
+ private ProviderStatusCompat() {
+ }
+
+ public static final boolean USE_CURRENT_VERSION = CompatUtils.isMarshmallowCompatible();
+
+ public static final int STATUS_EMPTY = USE_CURRENT_VERSION ?
+ ProviderStatus.STATUS_EMPTY : ProviderStatusCompat.STATUS_NO_ACCOUNTS_NO_CONTACTS;
+
+ public static final int STATUS_BUSY = USE_CURRENT_VERSION ?
+ ProviderStatus.STATUS_BUSY : ProviderStatusCompat.STATUS_UPGRADING;
+
+ /**
+ * Default status of the provider, using the actual constant to guard against errors
+ */
+ public static final int STATUS_NORMAL = ProviderStatus.STATUS_NORMAL;
+
+ /**
+ * The following three constants are from pre-M.
+ *
+ * The status used when the provider is in the process of upgrading. Contacts
+ * are temporarily unaccessible.
+ */
+ private static final int STATUS_UPGRADING = 1;
+
+ /**
+ * The status used during a locale change.
+ */
+ public static final int STATUS_CHANGING_LOCALE = 3;
+
+ /**
+ * The status that indicates that there are no accounts and no contacts
+ * on the device.
+ */
+ private static final int STATUS_NO_ACCOUNTS_NO_CONTACTS = 4;
+}
diff --git a/src/com/android/contacts/common/compat/SdkVersionOverride.java b/src/com/android/contacts/common/compat/SdkVersionOverride.java
new file mode 100644
index 0000000..ebde623
--- /dev/null
+++ b/src/com/android/contacts/common/compat/SdkVersionOverride.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.compat;
+
+import android.os.Build.VERSION;
+
+/**
+ * Class used to override the current sdk version to test specific branches of compatibility
+ * logic. When such branching occurs, use {@link #getSdkVersion(int)} rather than explicitly
+ * calling {@link VERSION#SDK_INT}. This allows the sdk version to be forced to a specific value.
+ */
+public class SdkVersionOverride {
+
+ /**
+ * Flag used to determine if override sdk versions are returned.
+ */
+ private static final boolean ALLOW_OVERRIDE_VERSION = false;
+
+ private SdkVersionOverride() {}
+
+ /**
+ * Gets the sdk version
+ *
+ * @param overrideVersion the version to attempt using
+ * @return overrideVersion if the {@link #ALLOW_OVERRIDE_VERSION} flag is set to {@code true},
+ * otherwise the current version
+ */
+ public static int getSdkVersion(int overrideVersion) {
+ return ALLOW_OVERRIDE_VERSION ? overrideVersion : VERSION.SDK_INT;
+ }
+}
diff --git a/src/com/android/contacts/common/compat/TelecomManagerUtil.java b/src/com/android/contacts/common/compat/TelecomManagerUtil.java
new file mode 100644
index 0000000..30c541c
--- /dev/null
+++ b/src/com/android/contacts/common/compat/TelecomManagerUtil.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+package com.android.contacts.common.compat;
+
+import android.content.Intent;
+import android.telecom.TelecomManager;
+
+/**
+ * Utility class for TelecomManager
+ */
+public class TelecomManagerUtil {
+ /**
+ * Creates {@link Intent} to launch the activity to manage blocked numbers.
+ */
+ public static Intent createManageBlockedNumbersIntent(TelecomManager tm) {
+ return CompatUtils.isNCompatible() ? tm.createManageBlockedNumbersIntent() : null;
+ }
+}
diff --git a/src/com/android/contacts/common/compat/TelephonyManagerCompat.java b/src/com/android/contacts/common/compat/TelephonyManagerCompat.java
new file mode 100644
index 0000000..ec7907f
--- /dev/null
+++ b/src/com/android/contacts/common/compat/TelephonyManagerCompat.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.net.Uri;
+import android.support.annotation.Nullable;
+import android.telecom.PhoneAccountHandle;
+import android.telephony.TelephonyManager;
+
+import com.android.contacts.common.ContactsUtils;
+
+public class TelephonyManagerCompat {
+ public static final String TELEPHONY_MANAGER_CLASS = "android.telephony.TelephonyManager";
+
+ /**
+ * @param telephonyManager The telephony manager instance to use for method calls.
+ * @return true if the current device is "voice capable".
+ * <p>
+ * "Voice capable" means that this device supports circuit-switched
+ * (i.e. voice) phone calls over the telephony network, and is allowed
+ * to display the in-call UI while a cellular voice call is active.
+ * This will be false on "data only" devices which can't make voice
+ * calls and don't support any in-call UI.
+ * <p>
+ * Note: the meaning of this flag is subtly different from the
+ * PackageManager.FEATURE_TELEPHONY system feature, which is available
+ * on any device with a telephony radio, even if the device is
+ * data-only.
+ */
+ public static boolean isVoiceCapable(@Nullable TelephonyManager telephonyManager) {
+ if (telephonyManager == null) {
+ return false;
+ }
+ if (CompatUtils.isLollipopMr1Compatible()
+ || CompatUtils.isMethodAvailable(TELEPHONY_MANAGER_CLASS, "isVoiceCapable")) {
+ // isVoiceCapable was unhidden in L-MR1
+ return telephonyManager.isVoiceCapable();
+ }
+ final int phoneType = telephonyManager.getPhoneType();
+ return phoneType == TelephonyManager.PHONE_TYPE_CDMA ||
+ phoneType == TelephonyManager.PHONE_TYPE_GSM;
+ }
+
+ /**
+ * Returns the number of phones available.
+ * Returns 1 for Single standby mode (Single SIM functionality)
+ * Returns 2 for Dual standby mode.(Dual SIM functionality)
+ *
+ * Returns 1 if the method or telephonyManager is not available.
+ *
+ * @param telephonyManager The telephony manager instance to use for method calls.
+ */
+ public static int getPhoneCount(@Nullable TelephonyManager telephonyManager) {
+ if (telephonyManager == null) {
+ return 1;
+ }
+ if (CompatUtils.isMarshmallowCompatible() || CompatUtils
+ .isMethodAvailable(TELEPHONY_MANAGER_CLASS, "getPhoneCount")) {
+ return telephonyManager.getPhoneCount();
+ }
+ return 1;
+ }
+
+ /**
+ * Returns the unique device ID of a subscription, for example, the IMEI for
+ * GSM and the MEID for CDMA phones. Return null if device ID is not available.
+ *
+ * <p>Requires Permission:
+ * {@link android.Manifest.permission#READ_PHONE_STATE READ_PHONE_STATE}
+ *
+ * @param telephonyManager The telephony manager instance to use for method calls.
+ * @param slotId of which deviceID is returned
+ */
+ public static String getDeviceId(@Nullable TelephonyManager telephonyManager, int slotId) {
+ if (telephonyManager == null) {
+ return null;
+ }
+ if (CompatUtils.isMarshmallowCompatible()
+ || CompatUtils.isMethodAvailable(TELEPHONY_MANAGER_CLASS,
+ "getDeviceId", Integer.class)) {
+ return telephonyManager.getDeviceId(slotId);
+ }
+ return null;
+ }
+
+ /**
+ * Whether the phone supports TTY mode.
+ *
+ * @param telephonyManager The telephony manager instance to use for method calls.
+ * @return {@code true} if the device supports TTY mode, and {@code false} otherwise.
+ */
+
+ public static boolean isTtyModeSupported(@Nullable TelephonyManager telephonyManager) {
+ if (telephonyManager == null) {
+ return false;
+ }
+ if (CompatUtils.isMarshmallowCompatible()
+ || CompatUtils.isMethodAvailable(TELEPHONY_MANAGER_CLASS, "isTtyModeSupported")) {
+ return telephonyManager.isTtyModeSupported();
+ }
+ return false;
+ }
+
+ /**
+ * Whether the phone supports hearing aid compatibility.
+ *
+ * @param telephonyManager The telephony manager instance to use for method calls.
+ * @return {@code true} if the device supports hearing aid compatibility, and {@code false}
+ * otherwise.
+ */
+ public static boolean isHearingAidCompatibilitySupported(
+ @Nullable TelephonyManager telephonyManager) {
+ if (telephonyManager == null) {
+ return false;
+ }
+ if (CompatUtils.isMarshmallowCompatible()
+ || CompatUtils.isMethodAvailable(TELEPHONY_MANAGER_CLASS,
+ "isHearingAidCompatibilitySupported")) {
+ return telephonyManager.isHearingAidCompatibilitySupported();
+ }
+ return false;
+ }
+
+ /**
+ * Returns the URI for the per-account voicemail ringtone set in Phone settings.
+ *
+ * @param telephonyManager The telephony manager instance to use for method calls.
+ * @param accountHandle The handle for the {@link android.telecom.PhoneAccount} for which to
+ * retrieve the voicemail ringtone.
+ * @return The URI for the ringtone to play when receiving a voicemail from a specific
+ * PhoneAccount.
+ */
+ @Nullable
+ public static Uri getVoicemailRingtoneUri(TelephonyManager telephonyManager,
+ PhoneAccountHandle accountHandle) {
+ if (!CompatUtils.isNCompatible()) {
+ return null;
+ }
+ return TelephonyManagerSdkCompat
+ .getVoicemailRingtoneUri(telephonyManager, accountHandle);
+ }
+
+ /**
+ * Returns whether vibration is set for voicemail notification in Phone settings.
+ *
+ * @param telephonyManager The telephony manager instance to use for method calls.
+ * @param accountHandle The handle for the {@link android.telecom.PhoneAccount} for which to
+ * retrieve the voicemail vibration setting.
+ * @return {@code true} if the vibration is set for this PhoneAccount, {@code false} otherwise.
+ */
+ public static boolean isVoicemailVibrationEnabled(TelephonyManager telephonyManager,
+ PhoneAccountHandle accountHandle) {
+ if (!CompatUtils.isNCompatible()) {
+ return true;
+ }
+ return TelephonyManagerSdkCompat
+ .isVoicemailVibrationEnabled(telephonyManager, accountHandle);
+ }
+}
diff --git a/src/com/android/contacts/common/compat/TelephonyManagerSdkCompat.java b/src/com/android/contacts/common/compat/TelephonyManagerSdkCompat.java
new file mode 100644
index 0000000..acabfdf
--- /dev/null
+++ b/src/com/android/contacts/common/compat/TelephonyManagerSdkCompat.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.compat;
+
+import android.net.Uri;
+import android.telecom.PhoneAccountHandle;
+import android.telephony.TelephonyManager;
+
+/**
+ * On N and above, this will look up voicemail notification settings from Telephony.
+ */
+public class TelephonyManagerSdkCompat {
+ public static Uri getVoicemailRingtoneUri(TelephonyManager telephonyManager,
+ PhoneAccountHandle accountHandle) {
+ return CompatUtils.isNCompatible()
+ ? telephonyManager.getVoicemailRingtoneUri(accountHandle) : null;
+ }
+
+ public static boolean isVoicemailVibrationEnabled(TelephonyManager telephonyManager,
+ PhoneAccountHandle accountHandle) {
+ return CompatUtils.isNCompatible()
+ ? telephonyManager.isVoicemailVibrationEnabled(accountHandle) : false;
+ }
+}
diff --git a/src/com/android/contacts/common/compat/TelephonyThreadsCompat.java b/src/com/android/contacts/common/compat/TelephonyThreadsCompat.java
new file mode 100644
index 0000000..d9642c7
--- /dev/null
+++ b/src/com/android/contacts/common/compat/TelephonyThreadsCompat.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.compat;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.BaseColumns;
+import android.provider.Telephony;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.Patterns;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * This class contains static utility methods and variables extracted from Telephony and
+ * SqliteWrapper, and the methods were made visible in API level 23. In this way, we could
+ * enable the corresponding functionality for pre-M devices. We need maintain this class and keep
+ * it synced with Telephony and SqliteWrapper.
+ */
+public class TelephonyThreadsCompat {
+ /**
+ * Not instantiable.
+ */
+ private TelephonyThreadsCompat() {}
+
+ private static final String TAG = "TelephonyThreadsCompat";
+
+ public static long getOrCreateThreadId(Context context, String recipient) {
+ if (SdkVersionOverride.getSdkVersion(Build.VERSION_CODES.M) >= Build.VERSION_CODES.M) {
+ return Telephony.Threads.getOrCreateThreadId(context, recipient);
+ } else {
+ return getOrCreateThreadIdInternal(context, recipient);
+ }
+ }
+
+ // Below is code copied from Telephony and SqliteWrapper
+ /**
+ * Private {@code content://} style URL for this table. Used by
+ * {@link #getOrCreateThreadId(Context, Set)}.
+ */
+ private static final Uri THREAD_ID_CONTENT_URI = Uri.parse("content://mms-sms/threadID");
+
+ private static final String[] ID_PROJECTION = { BaseColumns._ID };
+
+ /**
+ * Regex pattern for names and email addresses.
+ * <ul>
+ * <li><em>mailbox</em> = {@code name-addr}</li>
+ * <li><em>name-addr</em> = {@code [display-name] angle-addr}</li>
+ * <li><em>angle-addr</em> = {@code [CFWS] "<" addr-spec ">" [CFWS]}</li>
+ * </ul>
+ */
+ private static final Pattern NAME_ADDR_EMAIL_PATTERN =
+ Pattern.compile("\\s*(\"[^\"]*\"|[^<>\"]+)\\s*<([^<>]+)>\\s*");
+
+ /**
+ * Copied from {@link Telephony.Threads#getOrCreateThreadId(Context, String)}
+ */
+ private static long getOrCreateThreadIdInternal(Context context, String recipient) {
+ Set<String> recipients = new HashSet<String>();
+
+ recipients.add(recipient);
+ return getOrCreateThreadIdInternal(context, recipients);
+ }
+
+ /**
+ * Given the recipients list and subject of an unsaved message,
+ * return its thread ID. If the message starts a new thread,
+ * allocate a new thread ID. Otherwise, use the appropriate
+ * existing thread ID.
+ *
+ * <p>Find the thread ID of the same set of recipients (in any order,
+ * without any additions). If one is found, return it. Otherwise,
+ * return a unique thread ID.</p>
+ */
+ private static long getOrCreateThreadIdInternal(Context context, Set<String> recipients) {
+ Uri.Builder uriBuilder = THREAD_ID_CONTENT_URI.buildUpon();
+
+ for (String recipient : recipients) {
+ if (isEmailAddress(recipient)) {
+ recipient = extractAddrSpec(recipient);
+ }
+
+ uriBuilder.appendQueryParameter("recipient", recipient);
+ }
+
+ Uri uri = uriBuilder.build();
+
+ Cursor cursor = query(
+ context.getContentResolver(), uri, ID_PROJECTION, null, null, null);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ return cursor.getLong(0);
+ } else {
+ Log.e(TAG, "getOrCreateThreadId returned no rows!");
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+ Log.e(TAG, "getOrCreateThreadId failed with uri " + uri.toString());
+ throw new IllegalArgumentException("Unable to find or allocate a thread ID.");
+ }
+
+ /**
+ * Copied from {@link SqliteWrapper#query}
+ */
+ private static Cursor query(ContentResolver resolver, Uri uri, String[] projection,
+ String selection, String[] selectionArgs, String sortOrder) {
+ try {
+ return resolver.query(uri, projection, selection, selectionArgs, sortOrder);
+ } catch (Exception e) {
+ Log.e(TAG, "Catch an exception when query: ", e);
+ return null;
+ }
+ }
+
+ /**
+ * Is the specified address an email address?
+ *
+ * @param address the input address to test
+ * @return true if address is an email address; false otherwise.
+ */
+ private static boolean isEmailAddress(String address) {
+ if (TextUtils.isEmpty(address)) {
+ return false;
+ }
+
+ String s = extractAddrSpec(address);
+ Matcher match = Patterns.EMAIL_ADDRESS.matcher(s);
+ return match.matches();
+ }
+
+ /**
+ * Helper method to extract email address from address string.
+ */
+ private static String extractAddrSpec(String address) {
+ Matcher match = NAME_ADDR_EMAIL_PATTERN.matcher(address);
+
+ if (match.matches()) {
+ return match.group(2);
+ }
+ return address;
+ }
+}
diff --git a/src/com/android/contacts/common/compat/telecom/TelecomManagerCompat.java b/src/com/android/contacts/common/compat/telecom/TelecomManagerCompat.java
new file mode 100644
index 0000000..6292b7f
--- /dev/null
+++ b/src/com/android/contacts/common/compat/telecom/TelecomManagerCompat.java
@@ -0,0 +1,282 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.compat.telecom;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.Context;
+import android.net.Uri;
+import android.support.annotation.Nullable;
+import android.telecom.PhoneAccount;
+import android.telecom.PhoneAccountHandle;
+import android.telecom.TelecomManager;
+import android.telephony.PhoneNumberUtils;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+
+import com.android.contacts.common.compat.CompatUtils;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Compatibility class for {@link android.telecom.TelecomManager}.
+ */
+public class TelecomManagerCompat {
+ public static final String TELECOM_MANAGER_CLASS = "android.telecom.TelecomManager";
+ /**
+ * Places a new outgoing call to the provided address using the system telecom service with
+ * the specified intent.
+ *
+ * @param activity {@link Activity} used to start another activity for the given intent
+ * @param telecomManager the {@link TelecomManager} used to place a call, if possible
+ * @param intent the intent for the call
+ */
+ public static void placeCall(@Nullable Activity activity,
+ @Nullable TelecomManager telecomManager, @Nullable Intent intent) {
+ if (activity == null || telecomManager == null || intent == null) {
+ return;
+ }
+ if (CompatUtils.isMarshmallowCompatible()) {
+ telecomManager.placeCall(intent.getData(), intent.getExtras());
+ return;
+ }
+ activity.startActivityForResult(intent, 0);
+ }
+
+ /**
+ * Get the URI for running an adn query.
+ *
+ * @param telecomManager the {@link TelecomManager} used for method calls, if possible.
+ * @param accountHandle The handle for the account to derive an adn query URI for or
+ * {@code null} to return a URI which will use the default account.
+ * @return The URI (with the content:// scheme) specific to the specified {@link PhoneAccount}
+ * for the the content retrieve.
+ */
+ public static Uri getAdnUriForPhoneAccount(@Nullable TelecomManager telecomManager,
+ PhoneAccountHandle accountHandle) {
+ if (telecomManager != null && (CompatUtils.isMarshmallowCompatible()
+ || CompatUtils.isMethodAvailable(TELECOM_MANAGER_CLASS, "getAdnUriForPhoneAccount",
+ PhoneAccountHandle.class))) {
+ return telecomManager.getAdnUriForPhoneAccount(accountHandle);
+ }
+ return Uri.parse("content://icc/adn");
+ }
+
+ /**
+ * Returns a list of {@link PhoneAccountHandle}s which can be used to make and receive phone
+ * calls. The returned list includes only those accounts which have been explicitly enabled
+ * by the user.
+ *
+ * @param telecomManager the {@link TelecomManager} used for method calls, if possible.
+ * @return A list of PhoneAccountHandle objects.
+ */
+ public static List<PhoneAccountHandle> getCallCapablePhoneAccounts(
+ @Nullable TelecomManager telecomManager) {
+ if (telecomManager != null && (CompatUtils.isMarshmallowCompatible()
+ || CompatUtils.isMethodAvailable(TELECOM_MANAGER_CLASS,
+ "getCallCapablePhoneAccounts"))) {
+ return telecomManager.getCallCapablePhoneAccounts();
+ }
+ return new ArrayList<>();
+ }
+
+ /**
+ * Used to determine the currently selected default dialer package.
+ *
+ * @param telecomManager the {@link TelecomManager} used for method calls, if possible.
+ * @return package name for the default dialer package or null if no package has been
+ * selected as the default dialer.
+ */
+ @Nullable
+ public static String getDefaultDialerPackage(@Nullable TelecomManager telecomManager) {
+ if (telecomManager != null && CompatUtils.isDefaultDialerCompatible()) {
+ return telecomManager.getDefaultDialerPackage();
+ }
+ return null;
+ }
+
+ /**
+ * Return the {@link PhoneAccount} which will be used to place outgoing calls to addresses with
+ * the specified {@code uriScheme}. This PhoneAccount will always be a member of the
+ * list which is returned from invoking {@link TelecomManager#getCallCapablePhoneAccounts()}.
+ * The specific account returned depends on the following priorities:
+ *
+ * 1. If the user-selected default PhoneAccount supports the specified scheme, it will
+ * be returned.
+ * 2. If there exists only one PhoneAccount that supports the specified scheme, it
+ * will be returned.
+ *
+ * If no PhoneAccount fits the criteria above, this method will return {@code null}.
+ *
+ * @param telecomManager the {@link TelecomManager} used for method calls, if possible.
+ * @param uriScheme The URI scheme.
+ * @return The {@link PhoneAccountHandle} corresponding to the account to be used.
+ */
+ @Nullable
+ public static PhoneAccountHandle getDefaultOutgoingPhoneAccount(
+ @Nullable TelecomManager telecomManager, @Nullable String uriScheme) {
+ if (telecomManager != null && (CompatUtils.isMarshmallowCompatible()
+ || CompatUtils.isMethodAvailable(TELECOM_MANAGER_CLASS,
+ "getDefaultOutgoingPhoneAccount", String.class))) {
+ return telecomManager.getDefaultOutgoingPhoneAccount(uriScheme);
+ }
+ return null;
+ }
+
+ /**
+ * Return the line 1 phone number for given phone account.
+ *
+ * @param telecomManager the {@link TelecomManager} to use in the event that
+ * {@link TelecomManager#getLine1Number(PhoneAccountHandle)} is available
+ * @param telephonyManager the {@link TelephonyManager} to use if TelecomManager#getLine1Number
+ * is unavailable
+ * @param phoneAccountHandle the phoneAccountHandle upon which to check the line one number
+ * @return the line one number
+ */
+ @Nullable
+ public static String getLine1Number(@Nullable TelecomManager telecomManager,
+ @Nullable TelephonyManager telephonyManager,
+ @Nullable PhoneAccountHandle phoneAccountHandle) {
+ if (telecomManager != null && CompatUtils.isMarshmallowCompatible()) {
+ return telecomManager.getLine1Number(phoneAccountHandle);
+ }
+ if (telephonyManager != null) {
+ return telephonyManager.getLine1Number();
+ }
+ return null;
+ }
+
+ /**
+ * Return whether a given phone number is the configured voicemail number for a
+ * particular phone account.
+ *
+ * @param telecomManager the {@link TelecomManager} to use for checking the number.
+ * @param accountHandle The handle for the account to check the voicemail number against
+ * @param number The number to look up.
+ */
+ public static boolean isVoiceMailNumber(@Nullable TelecomManager telecomManager,
+ @Nullable PhoneAccountHandle accountHandle, @Nullable String number) {
+ if (telecomManager != null && (CompatUtils.isMarshmallowCompatible()
+ || CompatUtils.isMethodAvailable(TELECOM_MANAGER_CLASS, "isVoiceMailNumber",
+ PhoneAccountHandle.class, String.class))) {
+ return telecomManager.isVoiceMailNumber(accountHandle, number);
+ }
+ return PhoneNumberUtils.isVoiceMailNumber(number);
+ }
+
+ /**
+ * Return the {@link PhoneAccount} for a specified {@link PhoneAccountHandle}. Object includes
+ * resources which can be used in a user interface.
+ *
+ * @param telecomManager the {@link TelecomManager} used for method calls, if possible.
+ * @param account The {@link PhoneAccountHandle}.
+ * @return The {@link PhoneAccount} object or null if it doesn't exist.
+ */
+ @Nullable
+ public static PhoneAccount getPhoneAccount(@Nullable TelecomManager telecomManager,
+ @Nullable PhoneAccountHandle accountHandle) {
+ if (telecomManager != null && (CompatUtils.isMethodAvailable(
+ TELECOM_MANAGER_CLASS, "getPhoneAccount", PhoneAccountHandle.class))) {
+ return telecomManager.getPhoneAccount(accountHandle);
+ }
+ return null;
+ }
+
+ /**
+ * Return the voicemail number for a given phone account.
+ *
+ * @param telecomManager The {@link TelecomManager} object to use for retrieving the voicemail
+ * number if accountHandle is specified.
+ * @param telephonyManager The {@link TelephonyManager} object to use for retrieving the
+ * voicemail number if accountHandle is null.
+ * @param accountHandle The handle for the phone account.
+ * @return The voicemail number for the phone account, and {@code null} if one has not been
+ * configured.
+ */
+ @Nullable
+ public static String getVoiceMailNumber(@Nullable TelecomManager telecomManager,
+ @Nullable TelephonyManager telephonyManager,
+ @Nullable PhoneAccountHandle accountHandle) {
+ if (telecomManager != null && (CompatUtils.isMethodAvailable(
+ TELECOM_MANAGER_CLASS, "getVoiceMailNumber", PhoneAccountHandle.class))) {
+ return telecomManager.getVoiceMailNumber(accountHandle);
+ } else if (telephonyManager != null){
+ return telephonyManager.getVoiceMailNumber();
+ }
+ return null;
+ }
+
+ /**
+ * Processes the specified dial string as an MMI code.
+ * MMI codes are any sequence of characters entered into the dialpad that contain a "*" or "#".
+ * Some of these sequences launch special behavior through handled by Telephony.
+ *
+ * @param telecomManager The {@link TelecomManager} object to use for handling MMI.
+ * @param dialString The digits to dial.
+ * @return {@code true} if the digits were processed as an MMI code, {@code false} otherwise.
+ */
+ public static boolean handleMmi(@Nullable TelecomManager telecomManager,
+ @Nullable String dialString, @Nullable PhoneAccountHandle accountHandle) {
+ if (telecomManager == null || TextUtils.isEmpty(dialString)) {
+ return false;
+ }
+ if (CompatUtils.isMarshmallowCompatible()) {
+ return telecomManager.handleMmi(dialString, accountHandle);
+ }
+
+ Object handleMmiResult = CompatUtils.invokeMethod(
+ telecomManager,
+ "handleMmi",
+ new Class<?>[] {PhoneAccountHandle.class, String.class},
+ new Object[] {accountHandle, dialString});
+ if (handleMmiResult != null) {
+ return (boolean) handleMmiResult;
+ }
+
+ return telecomManager.handleMmi(dialString);
+ }
+
+ /**
+ * Silences the ringer if a ringing call exists. Noop if {@link TelecomManager#silenceRinger()}
+ * is unavailable.
+ *
+ * @param telecomManager the TelecomManager to use to silence the ringer.
+ */
+ public static void silenceRinger(@Nullable TelecomManager telecomManager) {
+ if (telecomManager != null && (CompatUtils.isMarshmallowCompatible() || CompatUtils
+ .isMethodAvailable(TELECOM_MANAGER_CLASS, "silenceRinger"))) {
+ telecomManager.silenceRinger();
+ }
+ }
+
+ /**
+ * Returns the current SIM call manager. Apps must be prepared for this method to return null,
+ * indicating that there currently exists no registered SIM call manager.
+ *
+ * @param telecomManager the {@link TelecomManager} to use to fetch the SIM call manager.
+ * @return The phone account handle of the current sim call manager.
+ */
+ @Nullable
+ public static PhoneAccountHandle getSimCallManager(TelecomManager telecomManager) {
+ if (telecomManager != null && (CompatUtils.isMarshmallowCompatible() || CompatUtils
+ .isMethodAvailable(TELECOM_MANAGER_CLASS, "getSimCallManager"))) {
+ return telecomManager.getSimCallManager();
+ }
+ return null;
+ }
+}
diff --git a/src/com/android/contacts/common/database/ContactUpdateUtils.java b/src/com/android/contacts/common/database/ContactUpdateUtils.java
new file mode 100644
index 0000000..1bd08aa
--- /dev/null
+++ b/src/com/android/contacts/common/database/ContactUpdateUtils.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.database;
+
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.provider.ContactsContract;
+import android.util.Log;
+
+/**
+ * Static methods to update contact information.
+ */
+public class ContactUpdateUtils {
+
+ private static final String TAG = ContactUpdateUtils.class.getSimpleName();
+
+ public static void setSuperPrimary(Context context, long dataId) {
+ if (dataId == -1) {
+ Log.e(TAG, "Invalid arguments for setSuperPrimary request");
+ return;
+ }
+
+ // Update the primary values in the data record.
+ ContentValues values = new ContentValues(2);
+ values.put(ContactsContract.Data.IS_SUPER_PRIMARY, 1);
+ values.put(ContactsContract.Data.IS_PRIMARY, 1);
+
+ context.getContentResolver().update(
+ ContentUris.withAppendedId(ContactsContract.Data.CONTENT_URI, dataId),
+ values, null, null);
+ }
+}
diff --git a/src/com/android/contacts/common/database/EmptyCursor.java b/src/com/android/contacts/common/database/EmptyCursor.java
new file mode 100644
index 0000000..ad00eff
--- /dev/null
+++ b/src/com/android/contacts/common/database/EmptyCursor.java
@@ -0,0 +1,84 @@
+/*
+* Copyright (C) 2012 The Android Open Source Project
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License
+*/
+
+package com.android.contacts.common.database;
+
+import android.database.AbstractCursor;
+import android.database.CursorIndexOutOfBoundsException;
+
+/**
+ * A cursor that is empty.
+ * <p>
+ * If you want an empty cursor, this class is better than a MatrixCursor because it has less
+ * overhead.
+ */
+final public class EmptyCursor extends AbstractCursor {
+
+ private String[] mColumns;
+
+ public EmptyCursor(String[] columns) {
+ this.mColumns = columns;
+ }
+
+ @Override
+ public int getCount() {
+ return 0;
+ }
+
+ @Override
+ public String[] getColumnNames() {
+ return mColumns;
+ }
+
+ @Override
+ public String getString(int column) {
+ throw cursorException();
+ }
+
+ @Override
+ public short getShort(int column) {
+ throw cursorException();
+ }
+
+ @Override
+ public int getInt(int column) {
+ throw cursorException();
+ }
+
+ @Override
+ public long getLong(int column) {
+ throw cursorException();
+ }
+
+ @Override
+ public float getFloat(int column) {
+ throw cursorException();
+ }
+
+ @Override
+ public double getDouble(int column) {
+ throw cursorException();
+ }
+
+ @Override
+ public boolean isNull(int column) {
+ throw cursorException();
+ }
+
+ private CursorIndexOutOfBoundsException cursorException() {
+ return new CursorIndexOutOfBoundsException("Operation not permitted on an empty cursor.");
+ }
+}
diff --git a/src/com/android/contacts/common/database/NoNullCursorAsyncQueryHandler.java b/src/com/android/contacts/common/database/NoNullCursorAsyncQueryHandler.java
new file mode 100644
index 0000000..aefc0fd
--- /dev/null
+++ b/src/com/android/contacts/common/database/NoNullCursorAsyncQueryHandler.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.database;
+
+import android.content.AsyncQueryHandler;
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.net.Uri;
+
+/**
+ * An {@AsyncQueryHandler} that will never return a null cursor.
+ * <p>
+ * Instead, will return a {@link Cursor} with 0 records.
+ */
+public abstract class NoNullCursorAsyncQueryHandler extends AsyncQueryHandler {
+
+ public NoNullCursorAsyncQueryHandler(ContentResolver cr) {
+ super(cr);
+ }
+
+ @Override
+ public void startQuery(int token, Object cookie, Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String orderBy) {
+ final CookieWithProjection projectionCookie = new CookieWithProjection(cookie, projection);
+ super.startQuery(token, projectionCookie, uri, projection, selection, selectionArgs,
+ orderBy);
+ }
+
+ @Override
+ protected final void onQueryComplete(int token, Object cookie, Cursor cursor) {
+ CookieWithProjection projectionCookie = (CookieWithProjection) cookie;
+
+ super.onQueryComplete(token, projectionCookie.originalCookie, cursor);
+
+ if (cursor == null) {
+ cursor = new EmptyCursor(projectionCookie.projection);
+ }
+ onNotNullableQueryComplete(token, projectionCookie.originalCookie, cursor);
+ }
+
+ protected abstract void onNotNullableQueryComplete(int token, Object cookie, Cursor cursor);
+
+ /**
+ * Class to add projection to an existing cookie.
+ */
+ private static class CookieWithProjection {
+ public final Object originalCookie;
+ public final String[] projection;
+
+ public CookieWithProjection(Object cookie, String[] projection) {
+ this.originalCookie = cookie;
+ this.projection = projection;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/dialog/CallSubjectDialog.java b/src/com/android/contacts/common/dialog/CallSubjectDialog.java
new file mode 100644
index 0000000..a17c4fc
--- /dev/null
+++ b/src/com/android/contacts/common/dialog/CallSubjectDialog.java
@@ -0,0 +1,623 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.dialog;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.telecom.PhoneAccount;
+import android.telecom.PhoneAccountHandle;
+import android.telecom.TelecomManager;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.EditText;
+import android.widget.ListView;
+import android.widget.QuickContactBadge;
+import android.widget.TextView;
+
+import com.android.contacts.common.CallUtil;
+import com.android.contacts.common.ContactPhotoManager;
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.compat.PhoneAccountSdkCompat;
+import com.android.contacts.common.compat.telecom.TelecomManagerCompat;
+import com.android.contacts.common.util.UriUtils;
+import com.android.phone.common.animation.AnimUtils;
+
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implements a dialog which prompts for a call subject for an outgoing call. The dialog includes
+ * a pop up list of historical call subjects.
+ */
+public class CallSubjectDialog extends Activity {
+ private static final String TAG = "CallSubjectDialog";
+ private static final int CALL_SUBJECT_LIMIT = 16;
+ private static final int CALL_SUBJECT_HISTORY_SIZE = 5;
+
+ private static final int REQUEST_SUBJECT = 1001;
+
+ public static final String PREF_KEY_SUBJECT_HISTORY_COUNT = "subject_history_count";
+ public static final String PREF_KEY_SUBJECT_HISTORY_ITEM = "subject_history_item";
+
+ /**
+ * Activity intent argument bundle keys:
+ */
+ public static final String ARG_PHOTO_ID = "PHOTO_ID";
+ public static final String ARG_PHOTO_URI = "PHOTO_URI";
+ public static final String ARG_CONTACT_URI = "CONTACT_URI";
+ public static final String ARG_NAME_OR_NUMBER = "NAME_OR_NUMBER";
+ public static final String ARG_IS_BUSINESS = "IS_BUSINESS";
+ public static final String ARG_NUMBER = "NUMBER";
+ public static final String ARG_DISPLAY_NUMBER = "DISPLAY_NUMBER";
+ public static final String ARG_NUMBER_LABEL = "NUMBER_LABEL";
+ public static final String ARG_PHONE_ACCOUNT_HANDLE = "PHONE_ACCOUNT_HANDLE";
+
+ private int mAnimationDuration;
+ private Charset mMessageEncoding;
+ private View mBackgroundView;
+ private View mDialogView;
+ private QuickContactBadge mContactPhoto;
+ private TextView mNameView;
+ private TextView mNumberView;
+ private EditText mCallSubjectView;
+ private TextView mCharacterLimitView;
+ private View mHistoryButton;
+ private View mSendAndCallButton;
+ private ListView mSubjectList;
+
+ private int mLimit = CALL_SUBJECT_LIMIT;
+ private int mPhotoSize;
+ private SharedPreferences mPrefs;
+ private List<String> mSubjectHistory;
+
+ private long mPhotoID;
+ private Uri mPhotoUri;
+ private Uri mContactUri;
+ private String mNameOrNumber;
+ private boolean mIsBusiness;
+ private String mNumber;
+ private String mDisplayNumber;
+ private String mNumberLabel;
+ private PhoneAccountHandle mPhoneAccountHandle;
+
+ /**
+ * Handles changes to the text in the subject box. Ensures the character limit is updated.
+ */
+ private final TextWatcher mTextWatcher = new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ // no-op
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ updateCharacterLimit();
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ // no-op
+ }
+ };
+
+ /**
+ * Click listener which handles user clicks outside of the dialog.
+ */
+ private View.OnClickListener mBackgroundListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ finish();
+ }
+ };
+
+ /**
+ * Handles displaying the list of past call subjects.
+ */
+ private final View.OnClickListener mHistoryOnClickListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ hideSoftKeyboard(CallSubjectDialog.this, mCallSubjectView);
+ showCallHistory(mSubjectList.getVisibility() == View.GONE);
+ }
+ };
+
+ /**
+ * Handles starting a call with a call subject specified.
+ */
+ private final View.OnClickListener mSendAndCallOnClickListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ String subject = mCallSubjectView.getText().toString();
+ Intent intent = CallUtil.getCallWithSubjectIntent(mNumber, mPhoneAccountHandle,
+ subject);
+
+ TelecomManagerCompat.placeCall(
+ CallSubjectDialog.this,
+ (TelecomManager) getSystemService(Context.TELECOM_SERVICE),
+ intent);
+
+ mSubjectHistory.add(subject);
+ saveSubjectHistory(mSubjectHistory);
+ finish();
+ }
+ };
+
+ /**
+ * Handles auto-hiding the call history when user clicks in the call subject field to give it
+ * focus.
+ */
+ private final View.OnClickListener mCallSubjectClickListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mSubjectList.getVisibility() == View.VISIBLE) {
+ showCallHistory(false);
+ }
+ }
+ };
+
+ /**
+ * Item click listener which handles user clicks on the items in the list view. Dismisses
+ * the activity, returning the subject to the caller and closing the activity with the
+ * {@link Activity#RESULT_OK} result code.
+ */
+ private AdapterView.OnItemClickListener mItemClickListener =
+ new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> arg0, View view, int position, long arg3) {
+ mCallSubjectView.setText(mSubjectHistory.get(position));
+ showCallHistory(false);
+ }
+ };
+
+ /**
+ * Show the call subject dialog given a phone number to dial (e.g. from the dialpad).
+ *
+ * @param activity The activity.
+ * @param number The number to dial.
+ */
+ public static void start(Activity activity, String number) {
+ start(activity,
+ -1 /* photoId */,
+ null /* photoUri */,
+ null /* contactUri */,
+ number /* nameOrNumber */,
+ false /* isBusiness */,
+ number /* number */,
+ null /* displayNumber */,
+ null /* numberLabel */,
+ null /* phoneAccountHandle */);
+ }
+
+ /**
+ * Creates a call subject dialog.
+ *
+ * @param activity The current activity.
+ * @param photoId The photo ID (used to populate contact photo).
+ * @param photoUri The photo Uri (used to populate contact photo).
+ * @param contactUri The Contact URI (used so quick contact can be invoked from contact photo).
+ * @param nameOrNumber The name or number of the callee.
+ * @param isBusiness {@code true} if a business is being called (used for contact photo).
+ * @param number The raw number to dial.
+ * @param displayNumber The number to dial, formatted for display.
+ * @param numberLabel The label for the number (if from a contact).
+ * @param phoneAccountHandle The phone account handle.
+ */
+ public static void start(Activity activity, long photoId, Uri photoUri, Uri contactUri,
+ String nameOrNumber, boolean isBusiness, String number, String displayNumber,
+ String numberLabel, PhoneAccountHandle phoneAccountHandle) {
+ Bundle arguments = new Bundle();
+ arguments.putLong(ARG_PHOTO_ID, photoId);
+ arguments.putParcelable(ARG_PHOTO_URI, photoUri);
+ arguments.putParcelable(ARG_CONTACT_URI, contactUri);
+ arguments.putString(ARG_NAME_OR_NUMBER, nameOrNumber);
+ arguments.putBoolean(ARG_IS_BUSINESS, isBusiness);
+ arguments.putString(ARG_NUMBER, number);
+ arguments.putString(ARG_DISPLAY_NUMBER, displayNumber);
+ arguments.putString(ARG_NUMBER_LABEL, numberLabel);
+ arguments.putParcelable(ARG_PHONE_ACCOUNT_HANDLE, phoneAccountHandle);
+ start(activity, arguments);
+ }
+
+ /**
+ * Shows the call subject dialog given a Bundle containing all the arguments required to
+ * display the dialog (e.g. from Quick Contacts).
+ *
+ * @param activity The activity.
+ * @param arguments The arguments bundle.
+ */
+ public static void start(Activity activity, Bundle arguments) {
+ Intent intent = new Intent(activity, CallSubjectDialog.class);
+ intent.putExtras(arguments);
+ activity.startActivity(intent);
+ }
+
+ /**
+ * Creates the dialog, inflating the layout and populating it with the name and phone number.
+ *
+ * @param savedInstanceState The last saved instance state of the Fragment,
+ * or null if this is a freshly created Fragment.
+ *
+ * @return Dialog instance.
+ */
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mAnimationDuration = getResources().getInteger(R.integer.call_subject_animation_duration);
+ mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
+ mPhotoSize = getResources().getDimensionPixelSize(
+ R.dimen.call_subject_dialog_contact_photo_size);
+ readArguments();
+ loadConfiguration();
+ mSubjectHistory = loadSubjectHistory(mPrefs);
+
+ setContentView(R.layout.dialog_call_subject);
+ getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT);
+ mBackgroundView = findViewById(R.id.call_subject_dialog);
+ mBackgroundView.setOnClickListener(mBackgroundListener);
+ mDialogView = findViewById(R.id.dialog_view);
+ mContactPhoto = (QuickContactBadge) findViewById(R.id.contact_photo);
+ mNameView = (TextView) findViewById(R.id.name);
+ mNumberView = (TextView) findViewById(R.id.number);
+ mCallSubjectView = (EditText) findViewById(R.id.call_subject);
+ mCallSubjectView.addTextChangedListener(mTextWatcher);
+ mCallSubjectView.setOnClickListener(mCallSubjectClickListener);
+ InputFilter[] filters = new InputFilter[1];
+ filters[0] = new InputFilter.LengthFilter(mLimit);
+ mCallSubjectView.setFilters(filters);
+ mCharacterLimitView = (TextView) findViewById(R.id.character_limit);
+ mHistoryButton = findViewById(R.id.history_button);
+ mHistoryButton.setOnClickListener(mHistoryOnClickListener);
+ mHistoryButton.setVisibility(mSubjectHistory.isEmpty() ? View.GONE : View.VISIBLE);
+ mSendAndCallButton = findViewById(R.id.send_and_call_button);
+ mSendAndCallButton.setOnClickListener(mSendAndCallOnClickListener);
+ mSubjectList = (ListView) findViewById(R.id.subject_list);
+ mSubjectList.setOnItemClickListener(mItemClickListener);
+ mSubjectList.setVisibility(View.GONE);
+
+ updateContactInfo();
+ updateCharacterLimit();
+ }
+
+ /**
+ * Populates the contact info fields based on the current contact information.
+ */
+ private void updateContactInfo() {
+ if (mContactUri != null) {
+ setPhoto(mPhotoID, mPhotoUri, mContactUri, mNameOrNumber, mIsBusiness);
+ } else {
+ mContactPhoto.setVisibility(View.GONE);
+ }
+ mNameView.setText(mNameOrNumber);
+ if (!TextUtils.isEmpty(mNumberLabel) && !TextUtils.isEmpty(mDisplayNumber)) {
+ mNumberView.setVisibility(View.VISIBLE);
+ mNumberView.setText(getString(R.string.call_subject_type_and_number,
+ mNumberLabel, mDisplayNumber));
+ } else {
+ mNumberView.setVisibility(View.GONE);
+ mNumberView.setText(null);
+ }
+ }
+
+ /**
+ * Reads arguments from the fragment arguments and populates the necessary instance variables.
+ */
+ private void readArguments() {
+ Bundle arguments = getIntent().getExtras();
+ if (arguments == null) {
+ Log.e(TAG, "Arguments cannot be null.");
+ return;
+ }
+ mPhotoID = arguments.getLong(ARG_PHOTO_ID);
+ mPhotoUri = arguments.getParcelable(ARG_PHOTO_URI);
+ mContactUri = arguments.getParcelable(ARG_CONTACT_URI);
+ mNameOrNumber = arguments.getString(ARG_NAME_OR_NUMBER);
+ mIsBusiness = arguments.getBoolean(ARG_IS_BUSINESS);
+ mNumber = arguments.getString(ARG_NUMBER);
+ mDisplayNumber = arguments.getString(ARG_DISPLAY_NUMBER);
+ mNumberLabel = arguments.getString(ARG_NUMBER_LABEL);
+ mPhoneAccountHandle = arguments.getParcelable(ARG_PHONE_ACCOUNT_HANDLE);
+ }
+
+ /**
+ * Updates the character limit display, coloring the text RED when the limit is reached or
+ * exceeded.
+ */
+ private void updateCharacterLimit() {
+ String subjectText = mCallSubjectView.getText().toString();
+ final int length;
+
+ // If a message encoding is specified, use that to count bytes in the message.
+ if (mMessageEncoding != null) {
+ length = subjectText.getBytes(mMessageEncoding).length;
+ } else {
+ // No message encoding specified, so just count characters entered.
+ length = subjectText.length();
+ }
+
+ mCharacterLimitView.setText(
+ getString(R.string.call_subject_limit, length, mLimit));
+ if (length >= mLimit) {
+ mCharacterLimitView.setTextColor(getResources().getColor(
+ R.color.call_subject_limit_exceeded));
+ } else {
+ mCharacterLimitView.setTextColor(getResources().getColor(
+ R.color.dialtacts_secondary_text_color));
+ }
+ }
+
+ /**
+ * Sets the photo on the quick contact photo.
+ *
+ * @param photoId
+ * @param photoUri
+ * @param contactUri
+ * @param displayName
+ * @param isBusiness
+ */
+ private void setPhoto(long photoId, Uri photoUri, Uri contactUri, String displayName,
+ boolean isBusiness) {
+ mContactPhoto.assignContactUri(contactUri);
+ if (CompatUtils.isLollipopCompatible()) {
+ mContactPhoto.setOverlay(null);
+ }
+
+ int contactType;
+ if (isBusiness) {
+ contactType = ContactPhotoManager.TYPE_BUSINESS;
+ } else {
+ contactType = ContactPhotoManager.TYPE_DEFAULT;
+ }
+
+ String lookupKey = null;
+ if (contactUri != null) {
+ lookupKey = UriUtils.getLookupKeyFromUri(contactUri);
+ }
+
+ ContactPhotoManager.DefaultImageRequest
+ request = new ContactPhotoManager.DefaultImageRequest(
+ displayName, lookupKey, contactType, true /* isCircular */);
+
+ if (photoId == 0 && photoUri != null) {
+ ContactPhotoManager.getInstance(this).loadPhoto(mContactPhoto, photoUri,
+ mPhotoSize, false /* darkTheme */, true /* isCircular */, request);
+ } else {
+ ContactPhotoManager.getInstance(this).loadThumbnail(mContactPhoto, photoId,
+ false /* darkTheme */, true /* isCircular */, request);
+ }
+ }
+
+ /**
+ * Loads the subject history from shared preferences.
+ *
+ * @param prefs Shared preferences.
+ * @return List of subject history strings.
+ */
+ public static List<String> loadSubjectHistory(SharedPreferences prefs) {
+ int historySize = prefs.getInt(PREF_KEY_SUBJECT_HISTORY_COUNT, 0);
+ List<String> subjects = new ArrayList(historySize);
+
+ for (int ix = 0 ; ix < historySize; ix++) {
+ String historyItem = prefs.getString(PREF_KEY_SUBJECT_HISTORY_ITEM + ix, null);
+ if (!TextUtils.isEmpty(historyItem)) {
+ subjects.add(historyItem);
+ }
+ }
+
+ return subjects;
+ }
+
+ /**
+ * Saves the subject history list to shared prefs, removing older items so that there are only
+ * {@link #CALL_SUBJECT_HISTORY_SIZE} items at most.
+ *
+ * @param history The history.
+ */
+ private void saveSubjectHistory(List<String> history) {
+ // Remove oldest subject(s).
+ while (history.size() > CALL_SUBJECT_HISTORY_SIZE) {
+ history.remove(0);
+ }
+
+ SharedPreferences.Editor editor = mPrefs.edit();
+ int historyCount = 0;
+ for (String subject : history) {
+ if (!TextUtils.isEmpty(subject)) {
+ editor.putString(PREF_KEY_SUBJECT_HISTORY_ITEM + historyCount,
+ subject);
+ historyCount++;
+ }
+ }
+ editor.putInt(PREF_KEY_SUBJECT_HISTORY_COUNT, historyCount);
+ editor.apply();
+ }
+
+ /**
+ * Hide software keyboard for the given {@link View}.
+ */
+ public void hideSoftKeyboard(Context context, View view) {
+ InputMethodManager imm = (InputMethodManager) context.getSystemService(
+ Context.INPUT_METHOD_SERVICE);
+ if (imm != null) {
+ imm.hideSoftInputFromWindow(view.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS);
+ }
+ }
+
+ /**
+ * Hides or shows the call history list.
+ *
+ * @param show {@code true} if the call history should be shown, {@code false} otherwise.
+ */
+ private void showCallHistory(final boolean show) {
+ // Bail early if the visibility has not changed.
+ if ((show && mSubjectList.getVisibility() == View.VISIBLE) ||
+ (!show && mSubjectList.getVisibility() == View.GONE)) {
+ return;
+ }
+
+ final int dialogStartingBottom = mDialogView.getBottom();
+ if (show) {
+ // Showing the subject list; bind the list of history items to the list and show it.
+ ArrayAdapter<String> adapter = new ArrayAdapter<String>(CallSubjectDialog.this,
+ R.layout.call_subject_history_list_item, mSubjectHistory);
+ mSubjectList.setAdapter(adapter);
+ mSubjectList.setVisibility(View.VISIBLE);
+ } else {
+ // Hiding the subject list.
+ mSubjectList.setVisibility(View.GONE);
+ }
+
+ // Use a ViewTreeObserver so that we can animate between the pre-layout and post-layout
+ // states.
+ final ViewTreeObserver observer = mBackgroundView.getViewTreeObserver();
+ observer.addOnPreDrawListener(
+ new ViewTreeObserver.OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ // We don't want to continue getting called.
+ if (observer.isAlive()) {
+ observer.removeOnPreDrawListener(this);
+ }
+
+ // Determine the amount the dialog has shifted due to the relayout.
+ int shiftAmount = dialogStartingBottom - mDialogView.getBottom();
+
+ // If the dialog needs to be shifted, do that now.
+ if (shiftAmount != 0) {
+ // Start animation in translated state and animate to translationY 0.
+ mDialogView.setTranslationY(shiftAmount);
+ mDialogView.animate()
+ .translationY(0)
+ .setInterpolator(AnimUtils.EASE_OUT_EASE_IN)
+ .setDuration(mAnimationDuration)
+ .start();
+ }
+
+ if (show) {
+ // Show the subhect list.
+ mSubjectList.setTranslationY(mSubjectList.getHeight());
+
+ mSubjectList.animate()
+ .translationY(0)
+ .setInterpolator(AnimUtils.EASE_OUT_EASE_IN)
+ .setDuration(mAnimationDuration)
+ .setListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ super.onAnimationEnd(animation);
+ }
+
+ @Override
+ public void onAnimationStart(Animator animation) {
+ super.onAnimationStart(animation);
+ mSubjectList.setVisibility(View.VISIBLE);
+ }
+ })
+ .start();
+ } else {
+ // Hide the subject list.
+ mSubjectList.setTranslationY(0);
+
+ mSubjectList.animate()
+ .translationY(mSubjectList.getHeight())
+ .setInterpolator(AnimUtils.EASE_OUT_EASE_IN)
+ .setDuration(mAnimationDuration)
+ .setListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ super.onAnimationEnd(animation);
+ mSubjectList.setVisibility(View.GONE);
+ }
+
+ @Override
+ public void onAnimationStart(Animator animation) {
+ super.onAnimationStart(animation);
+ }
+ })
+ .start();
+ }
+ return true;
+ }
+ }
+ );
+ }
+
+ /**
+ * Loads the message encoding and maximum message length from the phone account extras for the
+ * current phone account.
+ */
+ private void loadConfiguration() {
+ // Only attempt to load configuration from the phone account extras if the SDK is N or
+ // later. If we've got a prior SDK the default encoding and message length will suffice.
+ int sdk = android.os.Build.VERSION.SDK_INT;
+ if(sdk <= android.os.Build.VERSION_CODES.M) {
+ return;
+ }
+
+ if (mPhoneAccountHandle == null) {
+ return;
+ }
+
+ TelecomManager telecomManager =
+ (TelecomManager) getSystemService(Context.TELECOM_SERVICE);
+ final PhoneAccount account = telecomManager.getPhoneAccount(mPhoneAccountHandle);
+
+ Bundle phoneAccountExtras = PhoneAccountSdkCompat.getExtras(account);
+ if (phoneAccountExtras == null) {
+ return;
+ }
+
+ // Get limit, if provided; otherwise default to existing value.
+ mLimit = phoneAccountExtras
+ .getInt(PhoneAccountSdkCompat.EXTRA_CALL_SUBJECT_MAX_LENGTH, mLimit);
+
+ // Get charset; default to none (e.g. count characters 1:1).
+ String charsetName = phoneAccountExtras.getString(
+ PhoneAccountSdkCompat.EXTRA_CALL_SUBJECT_CHARACTER_ENCODING);
+
+ if (!TextUtils.isEmpty(charsetName)) {
+ try {
+ mMessageEncoding = Charset.forName(charsetName);
+ } catch (java.nio.charset.UnsupportedCharsetException uce) {
+ // Character set was invalid; log warning and fallback to none.
+ Log.w(TAG, "Invalid charset: " + charsetName);
+ mMessageEncoding = null;
+ }
+ } else {
+ // No character set specified, so count characters 1:1.
+ mMessageEncoding = null;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/dialog/ClearFrequentsDialog.java b/src/com/android/contacts/common/dialog/ClearFrequentsDialog.java
new file mode 100644
index 0000000..2fab3e1
--- /dev/null
+++ b/src/com/android/contacts/common/dialog/ClearFrequentsDialog.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.dialog;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.app.FragmentManager;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.provider.ContactsContract;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.util.PermissionsUtil;
+
+/**
+ * Dialog that clears the frequently contacted list after confirming with the user.
+ */
+public class ClearFrequentsDialog extends DialogFragment {
+ /** Preferred way to show this dialog */
+ public static void show(FragmentManager fragmentManager) {
+ ClearFrequentsDialog dialog = new ClearFrequentsDialog();
+ dialog.show(fragmentManager, "clearFrequents");
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final Context context = getActivity().getApplicationContext();
+ final ContentResolver resolver = getActivity().getContentResolver();
+ final OnClickListener okListener = new OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (!PermissionsUtil.hasContactsPermissions(context)) {
+ return;
+ }
+ final IndeterminateProgressDialog progressDialog = IndeterminateProgressDialog.show(
+ getFragmentManager(), getString(R.string.clearFrequentsProgress_title),
+ null, 500);
+ final AsyncTask<Void, Void, Void> task = new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ resolver.delete(ContactsContract.DataUsageFeedback.DELETE_USAGE_URI,
+ null, null);
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ progressDialog.dismiss();
+ }
+ };
+ task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+ };
+ return new AlertDialog.Builder(getActivity())
+ .setTitle(R.string.clearFrequentsConfirmation_title)
+ .setMessage(R.string.clearFrequentsConfirmation)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(android.R.string.ok, okListener)
+ .setCancelable(true)
+ .create();
+ }
+}
diff --git a/src/com/android/contacts/common/dialog/IndeterminateProgressDialog.java b/src/com/android/contacts/common/dialog/IndeterminateProgressDialog.java
new file mode 100644
index 0000000..2fe059f
--- /dev/null
+++ b/src/com/android/contacts/common/dialog/IndeterminateProgressDialog.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.dialog;
+
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.app.FragmentManager;
+import android.app.ProgressDialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.os.Handler;
+
+/**
+ * Indeterminate progress dialog wrapped up in a DialogFragment to work even when the device
+ * orientation is changed. Currently, only supports adding a title and/or message to the progress
+ * dialog. There is an additional parameter of the minimum amount of time to display the progress
+ * dialog even after a call to dismiss the dialog {@link #dismiss()} or
+ * {@link #dismissAllowingStateLoss()}.
+ * <p>
+ * To create and show the progress dialog, use
+ * {@link #show(FragmentManager, CharSequence, CharSequence, long)} and retain the reference to the
+ * IndeterminateProgressDialog instance.
+ * <p>
+ * To dismiss the dialog, use {@link #dismiss()} or {@link #dismissAllowingStateLoss()} on the
+ * instance. The instance returned by
+ * {@link #show(FragmentManager, CharSequence, CharSequence, long)} is guaranteed to be valid
+ * after a device orientation change because the {@link #setRetainInstance(boolean)} is called
+ * internally with true.
+ */
+public class IndeterminateProgressDialog extends DialogFragment {
+ private static final String TAG = IndeterminateProgressDialog.class.getSimpleName();
+
+ private CharSequence mTitle;
+ private CharSequence mMessage;
+ private long mMinDisplayTime;
+ private long mShowTime = 0;
+ private boolean mActivityReady = false;
+ private Dialog mOldDialog;
+ private final Handler mHandler = new Handler();
+ private boolean mCalledSuperDismiss = false;
+ private boolean mAllowStateLoss;
+ private final Runnable mDismisser = new Runnable() {
+ @Override
+ public void run() {
+ superDismiss();
+ }
+ };
+
+ /**
+ * Creates and shows an indeterminate progress dialog. Once the progress dialog is shown, it
+ * will be shown for at least the minDisplayTime (in milliseconds), so that the progress dialog
+ * does not flash in and out to quickly.
+ */
+ public static IndeterminateProgressDialog show(FragmentManager fragmentManager,
+ CharSequence title, CharSequence message, long minDisplayTime) {
+ IndeterminateProgressDialog dialogFragment = new IndeterminateProgressDialog();
+ dialogFragment.mTitle = title;
+ dialogFragment.mMessage = message;
+ dialogFragment.mMinDisplayTime = minDisplayTime;
+ dialogFragment.show(fragmentManager, TAG);
+ dialogFragment.mShowTime = System.currentTimeMillis();
+ dialogFragment.setCancelable(false);
+
+ return dialogFragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setRetainInstance(true);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ // Create the progress dialog and set its properties
+ final ProgressDialog dialog = new ProgressDialog(getActivity());
+ dialog.setIndeterminate(true);
+ dialog.setIndeterminateDrawable(null);
+ dialog.setTitle(mTitle);
+ dialog.setMessage(mMessage);
+
+ return dialog;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ mActivityReady = true;
+
+ // Check if superDismiss() had been called before. This can happen if in a long
+ // running operation, the user hits the home button and closes this fragment's activity.
+ // Upon returning, we want to dismiss this progress dialog fragment.
+ if (mCalledSuperDismiss) {
+ superDismiss();
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ mActivityReady = false;
+ }
+
+ /**
+ * There is a race condition that is not handled properly by the DialogFragment class.
+ * If we don't check that this onDismiss callback isn't for the old progress dialog from before
+ * the device orientation change, then this will cause the newly created dialog after the
+ * orientation change to be dismissed immediately.
+ */
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ if (mOldDialog != null && mOldDialog == dialog) {
+ // This is the callback from the old progress dialog that was already dismissed before
+ // the device orientation change, so just ignore it.
+ return;
+ }
+ super.onDismiss(dialog);
+ }
+
+ /**
+ * Save the old dialog that is about to get destroyed in case this is due to a change
+ * in device orientation. This will allow us to intercept the callback to
+ * {@link #onDismiss(DialogInterface)} in case the callback happens after a new progress dialog
+ * instance was created.
+ */
+ @Override
+ public void onDestroyView() {
+ mOldDialog = getDialog();
+ super.onDestroyView();
+ }
+
+ /**
+ * This tells the progress dialog to dismiss itself after guaranteeing to be shown for the
+ * specified time in {@link #show(FragmentManager, CharSequence, CharSequence, long)}.
+ */
+ @Override
+ public void dismiss() {
+ mAllowStateLoss = false;
+ dismissWhenReady();
+ }
+
+ /**
+ * This tells the progress dialog to dismiss itself (with state loss) after guaranteeing to be
+ * shown for the specified time in
+ * {@link #show(FragmentManager, CharSequence, CharSequence, long)}.
+ */
+ @Override
+ public void dismissAllowingStateLoss() {
+ mAllowStateLoss = true;
+ dismissWhenReady();
+ }
+
+ /**
+ * Tells the progress dialog to dismiss itself after guaranteeing that the dialog had been
+ * showing for at least the minimum display time as set in
+ * {@link #show(FragmentManager, CharSequence, CharSequence, long)}.
+ */
+ private void dismissWhenReady() {
+ // Compute how long the dialog has been showing
+ final long shownTime = System.currentTimeMillis() - mShowTime;
+ if (shownTime >= mMinDisplayTime) {
+ // dismiss immediately
+ mHandler.post(mDismisser);
+ } else {
+ // Need to wait some more, so compute the amount of time to sleep.
+ final long sleepTime = mMinDisplayTime - shownTime;
+ mHandler.postDelayed(mDismisser, sleepTime);
+ }
+ }
+
+ /**
+ * Actually dismiss the dialog fragment.
+ */
+ private void superDismiss() {
+ mCalledSuperDismiss = true;
+ if (mActivityReady) {
+ // The fragment is either in onStart or past it, but has not gotten to onStop yet.
+ // It is safe to dismiss this dialog fragment.
+ if (mAllowStateLoss) {
+ super.dismissAllowingStateLoss();
+ } else {
+ super.dismiss();
+ }
+ }
+ // If mActivityReady is false, then this dialog fragment has already passed the onStop
+ // state. This can happen if the user hit the 'home' button before this dialog fragment was
+ // dismissed or if there is a configuration change.
+ // In the event that this dialog fragment is re-attached and reaches onStart (e.g.,
+ // because the user returns to this fragment's activity or the device configuration change
+ // has re-attached this dialog fragment), because the mCalledSuperDismiss flag was set to
+ // true, this dialog fragment will be dismissed within onStart. So, there's nothing else
+ // that needs to be done.
+ }
+}
diff --git a/src/com/android/contacts/common/editor/SelectAccountDialogFragment.java b/src/com/android/contacts/common/editor/SelectAccountDialogFragment.java
new file mode 100644
index 0000000..96da89a
--- /dev/null
+++ b/src/com/android/contacts/common/editor/SelectAccountDialogFragment.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.editor;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.TextView;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.util.AccountsListAdapter;
+import com.android.contacts.common.util.AccountsListAdapter.AccountListFilter;
+
+/**
+ * Shows a dialog asking the user which account to chose.
+ *
+ * The result is passed to {@code targetFragment} passed to {@link #show}.
+ */
+public final class SelectAccountDialogFragment extends DialogFragment {
+ public static final String TAG = "SelectAccountDialogFragment";
+
+ private static final String KEY_TITLE_RES_ID = "title_res_id";
+ private static final String KEY_LIST_FILTER = "list_filter";
+ private static final String KEY_EXTRA_ARGS = "extra_args";
+
+ public SelectAccountDialogFragment() { // All fragments must have a public default constructor.
+ }
+
+ /**
+ * Show the dialog.
+ *
+ * @param fragmentManager {@link FragmentManager}.
+ * @param targetFragment {@link Fragment} that implements {@link Listener}.
+ * @param titleResourceId resource ID to use as the title.
+ * @param accountListFilter account filter.
+ * @param extraArgs Extra arguments, which will later be passed to
+ * {@link Listener#onAccountChosen}. {@code null} will be converted to
+ * {@link Bundle#EMPTY}.
+ */
+ public static <F extends Fragment & Listener> void show(FragmentManager fragmentManager,
+ F targetFragment, int titleResourceId,
+ AccountListFilter accountListFilter, Bundle extraArgs) {
+ show(fragmentManager, targetFragment, titleResourceId, accountListFilter, extraArgs,
+ /* tag */ null);
+ }
+
+ public static <F extends Fragment & Listener> void show(FragmentManager fragmentManager,
+ F targetFragment, int titleResourceId,
+ AccountListFilter accountListFilter, Bundle extraArgs, String tag) {
+ final Bundle args = new Bundle();
+ args.putInt(KEY_TITLE_RES_ID, titleResourceId);
+ args.putSerializable(KEY_LIST_FILTER, accountListFilter);
+ args.putBundle(KEY_EXTRA_ARGS, (extraArgs == null) ? Bundle.EMPTY : extraArgs);
+
+ final SelectAccountDialogFragment instance = new SelectAccountDialogFragment();
+ instance.setArguments(args);
+ if (targetFragment != null) {
+ instance.setTargetFragment(targetFragment, 0);
+ }
+ instance.show(fragmentManager, tag);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ final Bundle args = getArguments();
+
+ final AccountListFilter filter = (AccountListFilter) args.getSerializable(KEY_LIST_FILTER);
+ final AccountsListAdapter accountAdapter = new AccountsListAdapter(builder.getContext(),
+ filter);
+ accountAdapter.setCustomLayout(R.layout.account_selector_list_item_condensed);
+
+ final DialogInterface.OnClickListener clickListener =
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.dismiss();
+
+ onAccountSelected(accountAdapter.getItem(which));
+ }
+ };
+
+ final TextView title = (TextView) View.inflate(getActivity(), R.layout.dialog_title, null);
+ title.setText(args.getInt(KEY_TITLE_RES_ID));
+ builder.setCustomTitle(title);
+ builder.setSingleChoiceItems(accountAdapter, 0, clickListener);
+ final AlertDialog result = builder.create();
+ return result;
+ }
+
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ super.onCancel(dialog);
+ final Listener listener = getListener();
+ if (listener != null) {
+ listener.onAccountSelectorCancelled();
+ }
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle b) {
+ setTargetFragment(null, -1);
+ super.onSaveInstanceState(b);
+ }
+
+ /**
+ * Calls {@link Listener#onAccountChosen} of {@code targetFragment}.
+ */
+ private void onAccountSelected(AccountWithDataSet account) {
+ final Listener listener = getListener();
+ if (listener != null) {
+ listener.onAccountChosen(account, getArguments().getBundle(KEY_EXTRA_ARGS));
+ }
+ }
+
+ private Listener getListener() {
+ Listener listener = null;
+ final Fragment targetFragment = getTargetFragment();
+ if (targetFragment == null) {
+ final Activity activity = getActivity();
+ if (activity != null && activity instanceof Listener) {
+ listener = (Listener) activity;
+ }
+ } else if (targetFragment instanceof Listener) {
+ listener = (Listener) targetFragment;
+ }
+ return listener;
+ }
+
+ public interface Listener {
+ void onAccountChosen(AccountWithDataSet account, Bundle extraArgs);
+ void onAccountSelectorCancelled();
+ }
+}
diff --git a/src/com/android/contacts/common/extensions/ExtendedPhoneDirectoriesManager.java b/src/com/android/contacts/common/extensions/ExtendedPhoneDirectoriesManager.java
new file mode 100644
index 0000000..eb25934
--- /dev/null
+++ b/src/com/android/contacts/common/extensions/ExtendedPhoneDirectoriesManager.java
@@ -0,0 +1,26 @@
+// Copyright 2013 Google Inc. All Rights Reserved.
+
+package com.android.contacts.common.extensions;
+
+import android.content.Context;
+
+import com.android.contacts.common.list.DirectoryPartition;
+
+import java.util.List;
+
+/**
+ * An interface for adding extended phone directories to
+ * {@link com.android.contacts.common.list.PhoneNumberListAdapter}.
+ * An app that wishes to add custom phone directories should implement this class and advertise it
+ * in assets/contacts_extensions.properties. {@link ExtensionsFactory} will load the implementation
+ * and the extended directories will be added by
+ * {@link com.android.contacts.common.list.PhoneNumberListAdapter}.
+ */
+public interface ExtendedPhoneDirectoriesManager {
+
+ /**
+ * Return a list of extended directories to add. May return null if no directories are to be
+ * added.
+ */
+ List<DirectoryPartition> getExtendedDirectories(Context context);
+}
diff --git a/src/com/android/contacts/common/extensions/ExtensionsFactory.java b/src/com/android/contacts/common/extensions/ExtensionsFactory.java
new file mode 100644
index 0000000..d52429e
--- /dev/null
+++ b/src/com/android/contacts/common/extensions/ExtensionsFactory.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.extensions;
+
+import android.content.Context;
+import android.util.Log;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+
+
+/*
+ * A framework for adding extensions to Dialer. This class reads a property file from
+ * assets/contacts_extensions.properties and loads extension classes that an app has defined. If
+ * an extension class was not defined, null is returned.
+ */
+public class ExtensionsFactory {
+
+ private static String TAG = "ExtensionsFactory";
+
+ // Config filename for mappings of various class names to their custom
+ // implementations.
+ private static final String EXTENSIONS_PROPERTIES = "contacts_extensions.properties";
+
+ private static final String EXTENDED_PHONE_DIRECTORIES_KEY = "extendedPhoneDirectories";
+
+ private static Properties sProperties = null;
+ private static ExtendedPhoneDirectoriesManager mExtendedPhoneDirectoriesManager = null;
+
+ public static void init(Context context) {
+ if (sProperties != null) {
+ return;
+ }
+ try {
+ final InputStream fileStream = context.getAssets().open(EXTENSIONS_PROPERTIES);
+ sProperties = new Properties();
+ sProperties.load(fileStream);
+ fileStream.close();
+
+ final String className = sProperties.getProperty(EXTENDED_PHONE_DIRECTORIES_KEY);
+ if (className != null) {
+ mExtendedPhoneDirectoriesManager = createInstance(className);
+ } else {
+ Log.d(TAG, EXTENDED_PHONE_DIRECTORIES_KEY + " not found in properties file.");
+ }
+
+ } catch (FileNotFoundException e) {
+ // No custom extensions. Ignore.
+ Log.d(TAG, "No custom extensions.");
+ } catch (IOException e) {
+ Log.d(TAG, e.toString());
+ }
+ }
+
+ private static <T> T createInstance(String className) {
+ try {
+ Class<?> c = Class.forName(className);
+ //noinspection unchecked
+ return (T) c.newInstance();
+ } catch (ClassNotFoundException e) {
+ Log.e(TAG, className + ": unable to create instance.", e);
+ } catch (IllegalAccessException e) {
+ Log.e(TAG, className + ": unable to create instance.", e);
+ } catch (InstantiationException e) {
+ Log.e(TAG, className + ": unable to create instance.", e);
+ }
+ return null;
+ }
+
+ public static ExtendedPhoneDirectoriesManager getExtendedPhoneDirectoriesManager() {
+ return mExtendedPhoneDirectoriesManager;
+ }
+}
diff --git a/src/com/android/contacts/common/format/FormatUtils.java b/src/com/android/contacts/common/format/FormatUtils.java
new file mode 100644
index 0000000..376ff13
--- /dev/null
+++ b/src/com/android/contacts/common/format/FormatUtils.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.format;
+
+import android.database.CharArrayBuffer;
+import android.graphics.Typeface;
+import android.text.SpannableString;
+import android.text.style.StyleSpan;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.Arrays;
+
+/**
+ * Assorted utility methods related to text formatting in Contacts.
+ */
+public class FormatUtils {
+
+ /**
+ * Finds the earliest point in buffer1 at which the first part of buffer2 matches. For example,
+ * overlapPoint("abcd", "cdef") == 2.
+ */
+ public static int overlapPoint(CharArrayBuffer buffer1, CharArrayBuffer buffer2) {
+ if (buffer1 == null || buffer2 == null) {
+ return -1;
+ }
+ return overlapPoint(Arrays.copyOfRange(buffer1.data, 0, buffer1.sizeCopied),
+ Arrays.copyOfRange(buffer2.data, 0, buffer2.sizeCopied));
+ }
+
+ /**
+ * Finds the earliest point in string1 at which the first part of string2 matches. For example,
+ * overlapPoint("abcd", "cdef") == 2.
+ */
+ @VisibleForTesting
+ public static int overlapPoint(String string1, String string2) {
+ if (string1 == null || string2 == null) {
+ return -1;
+ }
+ return overlapPoint(string1.toCharArray(), string2.toCharArray());
+ }
+
+ /**
+ * Finds the earliest point in array1 at which the first part of array2 matches. For example,
+ * overlapPoint("abcd", "cdef") == 2.
+ */
+ public static int overlapPoint(char[] array1, char[] array2) {
+ if (array1 == null || array2 == null) {
+ return -1;
+ }
+ int count1 = array1.length;
+ int count2 = array2.length;
+
+ // Ignore matching tails of the two arrays.
+ while (count1 > 0 && count2 > 0 && array1[count1 - 1] == array2[count2 - 1]) {
+ count1--;
+ count2--;
+ }
+
+ int size = count2;
+ for (int i = 0; i < count1; i++) {
+ if (i + size > count1) {
+ size = count1 - i;
+ }
+ int j;
+ for (j = 0; j < size; j++) {
+ if (array1[i+j] != array2[j]) {
+ break;
+ }
+ }
+ if (j == size) {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ /**
+ * Applies the given style to a range of the input CharSequence.
+ * @param style The style to apply (see the style constants in {@link Typeface}).
+ * @param input The CharSequence to style.
+ * @param start Starting index of the range to style (will be clamped to be a minimum of 0).
+ * @param end Ending index of the range to style (will be clamped to a maximum of the input
+ * length).
+ * @param flags Bitmask for configuring behavior of the span. See {@link android.text.Spanned}.
+ * @return The styled CharSequence.
+ */
+ public static CharSequence applyStyleToSpan(int style, CharSequence input, int start, int end,
+ int flags) {
+ // Enforce bounds of the char sequence.
+ start = Math.max(0, start);
+ end = Math.min(input.length(), end);
+ SpannableString text = new SpannableString(input);
+ text.setSpan(new StyleSpan(style), start, end, flags);
+ return text;
+ }
+
+ @VisibleForTesting
+ public static void copyToCharArrayBuffer(String text, CharArrayBuffer buffer) {
+ if (text != null) {
+ char[] data = buffer.data;
+ if (data == null || data.length < text.length()) {
+ buffer.data = text.toCharArray();
+ } else {
+ text.getChars(0, text.length(), data, 0);
+ }
+ buffer.sizeCopied = text.length();
+ } else {
+ buffer.sizeCopied = 0;
+ }
+ }
+
+ /** Returns a String that represents the content of the given {@link CharArrayBuffer}. */
+ @VisibleForTesting
+ public static String charArrayBufferToString(CharArrayBuffer buffer) {
+ return new String(buffer.data, 0, buffer.sizeCopied);
+ }
+
+ /**
+ * Finds the index of the first word that starts with the given prefix.
+ * <p>
+ * If not found, returns -1.
+ *
+ * @param text the text in which to search for the prefix
+ * @param prefix the text to find, in upper case letters
+ */
+ public static int indexOfWordPrefix(CharSequence text, String prefix) {
+ if (prefix == null || text == null) {
+ return -1;
+ }
+
+ int textLength = text.length();
+ int prefixLength = prefix.length();
+
+ if (prefixLength == 0 || textLength < prefixLength) {
+ return -1;
+ }
+
+ int i = 0;
+ while (i < textLength) {
+ // Skip non-word characters
+ while (i < textLength && !Character.isLetterOrDigit(text.charAt(i))) {
+ i++;
+ }
+
+ if (i + prefixLength > textLength) {
+ return -1;
+ }
+
+ // Compare the prefixes
+ int j;
+ for (j = 0; j < prefixLength; j++) {
+ if (Character.toUpperCase(text.charAt(i + j)) != prefix.charAt(j)) {
+ break;
+ }
+ }
+ if (j == prefixLength) {
+ return i;
+ }
+
+ // Skip this word
+ while (i < textLength && Character.isLetterOrDigit(text.charAt(i))) {
+ i++;
+ }
+ }
+
+ return -1;
+ }
+
+}
diff --git a/src/com/android/contacts/common/format/SpannedTestUtils.java b/src/com/android/contacts/common/format/SpannedTestUtils.java
new file mode 100644
index 0000000..463d7a8
--- /dev/null
+++ b/src/com/android/contacts/common/format/SpannedTestUtils.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.format;
+
+import android.test.suitebuilder.annotation.SmallTest;
+import android.text.Html;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.style.StyleSpan;
+import android.widget.TextView;
+
+import junit.framework.Assert;
+
+/**
+ * Utility class to check the value of spanned text in text views.
+ */
+@SmallTest
+public class SpannedTestUtils {
+ /**
+ * Checks that the text contained in the text view matches the given HTML text.
+ *
+ * @param expectedHtmlText the expected text to be in the text view
+ * @param textView the text view from which to get the text
+ */
+ public static void checkHtmlText(String expectedHtmlText, TextView textView) {
+ String actualHtmlText = Html.toHtml((Spanned) textView.getText());
+ if (TextUtils.isEmpty(expectedHtmlText)) {
+ // If the text is empty, it does not add the <p></p> bits to it.
+ Assert.assertEquals("", actualHtmlText);
+ } else {
+ Assert.assertEquals("<p dir=ltr>" + expectedHtmlText + "</p>\n", actualHtmlText);
+ }
+ }
+
+
+ /**
+ * Assert span exists in the correct location.
+ *
+ * @param seq The spannable string to check.
+ * @param start The starting index.
+ * @param end The ending index.
+ */
+ public static void assertPrefixSpan(CharSequence seq, int start, int end) {
+ Assert.assertTrue(seq instanceof Spanned);
+ Spanned spannable = (Spanned) seq;
+
+ if (start > 0) {
+ Assert.assertEquals(0, getNumForegroundColorSpansBetween(spannable, 0, start - 1));
+ }
+ Assert.assertEquals(1, getNumForegroundColorSpansBetween(spannable, start, end));
+ Assert.assertEquals(0, getNumForegroundColorSpansBetween(spannable, end + 1,
+ spannable.length() - 1));
+ }
+
+ private static int getNumForegroundColorSpansBetween(Spanned value, int start, int end) {
+ return value.getSpans(start, end, StyleSpan.class).length;
+ }
+
+ /**
+ * Asserts that the given character sequence is not a Spanned object and text is correct.
+ *
+ * @param seq The sequence to check.
+ * @param expected The expected text.
+ */
+ public static void assertNotSpanned(CharSequence seq, String expected) {
+ Assert.assertFalse(seq instanceof Spanned);
+ Assert.assertEquals(expected, seq);
+ }
+
+ public static int getNextTransition(SpannableString seq, int start) {
+ return seq.nextSpanTransition(start, seq.length(), StyleSpan.class);
+ }
+}
diff --git a/src/com/android/contacts/common/format/TextHighlighter.java b/src/com/android/contacts/common/format/TextHighlighter.java
new file mode 100644
index 0000000..496dcda
--- /dev/null
+++ b/src/com/android/contacts/common/format/TextHighlighter.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.format;
+
+import android.graphics.Typeface;
+import android.text.SpannableString;
+import android.text.style.CharacterStyle;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StyleSpan;
+import android.widget.TextView;
+
+import com.google.common.base.Preconditions;
+
+/**
+ * Highlights the text in a text field.
+ */
+public class TextHighlighter {
+ private final String TAG = TextHighlighter.class.getSimpleName();
+ private final static boolean DEBUG = false;
+
+ private int mTextStyle;
+
+ private CharacterStyle mTextStyleSpan;
+
+ public TextHighlighter(int textStyle) {
+ mTextStyle = textStyle;
+ mTextStyleSpan = getStyleSpan();
+ }
+
+ /**
+ * Sets the text on the given text view, highlighting the word that matches the given prefix.
+ *
+ * @param view the view on which to set the text
+ * @param text the string to use as the text
+ * @param prefix the prefix to look for
+ */
+ public void setPrefixText(TextView view, String text, String prefix) {
+ view.setText(applyPrefixHighlight(text, prefix));
+ }
+
+ private CharacterStyle getStyleSpan() {
+ return new StyleSpan(mTextStyle);
+ }
+
+ /**
+ * Applies highlight span to the text.
+ * @param text Text sequence to be highlighted.
+ * @param start Start position of the highlight sequence.
+ * @param end End position of the highlight sequence.
+ */
+ public void applyMaskingHighlight(SpannableString text, int start, int end) {
+ /** Sets text color of the masked locations to be highlighted. */
+ text.setSpan(getStyleSpan(), start, end, 0);
+ }
+
+ /**
+ * Returns a CharSequence which highlights the given prefix if found in the given text.
+ *
+ * @param text the text to which to apply the highlight
+ * @param prefix the prefix to look for
+ */
+ public CharSequence applyPrefixHighlight(CharSequence text, String prefix) {
+ if (prefix == null) {
+ return text;
+ }
+
+ // Skip non-word characters at the beginning of prefix.
+ int prefixStart = 0;
+ while (prefixStart < prefix.length() &&
+ !Character.isLetterOrDigit(prefix.charAt(prefixStart))) {
+ prefixStart++;
+ }
+ final String trimmedPrefix = prefix.substring(prefixStart);
+
+ int index = FormatUtils.indexOfWordPrefix(text, trimmedPrefix);
+ if (index != -1) {
+ final SpannableString result = new SpannableString(text);
+ result.setSpan(mTextStyleSpan, index, index + trimmedPrefix.length(), 0 /* flags */);
+ return result;
+ } else {
+ return text;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/interactions/ImportExportDialogFragment.java b/src/com/android/contacts/common/interactions/ImportExportDialogFragment.java
new file mode 100644
index 0000000..60f983d
--- /dev/null
+++ b/src/com/android/contacts/common/interactions/ImportExportDialogFragment.java
@@ -0,0 +1,360 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.interactions;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.app.FragmentManager;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.ContactsContract.Contacts;
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.compat.PhoneNumberUtilsCompat;
+import com.android.contacts.common.editor.SelectAccountDialogFragment;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.util.AccountSelectionUtil;
+import com.android.contacts.common.util.AccountsListAdapter.AccountListFilter;
+import com.android.contacts.common.util.ImplicitIntentsUtil;
+import com.android.contacts.common.vcard.ExportVCardActivity;
+import com.android.contacts.common.vcard.VCardCommonArguments;
+import com.android.contacts.common.vcard.ShareVCardActivity;
+import com.android.contacts.commonbind.analytics.AnalyticsUtil;
+
+import java.util.List;
+
+/**
+ * An dialog invoked to import/export contacts.
+ */
+public class ImportExportDialogFragment extends DialogFragment
+ implements SelectAccountDialogFragment.Listener {
+ public static final String TAG = "ImportExportDialogFragment";
+
+ public static final int EXPORT_MODE_FAVORITES = 0;
+ public static final int EXPORT_MODE_ALL_CONTACTS = 1;
+ public static final int EXPORT_MODE_DEFAULT = -1;
+
+ private static final String KEY_RES_ID = "resourceId";
+ private static final String KEY_SUBSCRIPTION_ID = "subscriptionId";
+ private static final String ARG_CONTACTS_ARE_AVAILABLE = "CONTACTS_ARE_AVAILABLE";
+
+ private static int mExportMode = EXPORT_MODE_DEFAULT;
+
+ private final String[] LOOKUP_PROJECTION = new String[] {
+ Contacts.LOOKUP_KEY
+ };
+
+ private SubscriptionManager mSubscriptionManager;
+
+ /** Preferred way to show this dialog */
+ public static void show(FragmentManager fragmentManager, boolean contactsAreAvailable,
+ Class callingActivity, int exportMode) {
+ final ImportExportDialogFragment fragment = new ImportExportDialogFragment();
+ Bundle args = new Bundle();
+ args.putBoolean(ARG_CONTACTS_ARE_AVAILABLE, contactsAreAvailable);
+ args.putString(VCardCommonArguments.ARG_CALLING_ACTIVITY, callingActivity.getName());
+ fragment.setArguments(args);
+ fragment.show(fragmentManager, ImportExportDialogFragment.TAG);
+ mExportMode = exportMode;
+ }
+
+ @Override
+ public Context getContext() {
+ return getActivity();
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ // Wrap our context to inflate list items using the correct theme
+ final Resources res = getActivity().getResources();
+ final LayoutInflater dialogInflater = (LayoutInflater)getActivity()
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ final boolean contactsAreAvailable = getArguments().getBoolean(ARG_CONTACTS_ARE_AVAILABLE);
+ final String callingActivity = getArguments().getString(
+ VCardCommonArguments.ARG_CALLING_ACTIVITY);
+
+ // Adapter that shows a list of string resources
+ final ArrayAdapter<AdapterEntry> adapter = new ArrayAdapter<AdapterEntry>(getActivity(),
+ R.layout.select_dialog_item) {
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final TextView result = (TextView)(convertView != null ? convertView :
+ dialogInflater.inflate(R.layout.select_dialog_item, parent, false));
+
+ result.setText(getItem(position).mLabel);
+ return result;
+ }
+ };
+
+ final TelephonyManager manager =
+ (TelephonyManager) getActivity().getSystemService(Context.TELEPHONY_SERVICE);
+ if (res.getBoolean(R.bool.config_allow_import_from_vcf_file)) {
+ adapter.add(new AdapterEntry(getString(R.string.import_from_vcf_file),
+ R.string.import_from_vcf_file));
+ }
+
+ if (CompatUtils.isMSIMCompatible()) {
+ mSubscriptionManager = SubscriptionManager.from(getActivity());
+ if (manager != null && res.getBoolean(R.bool.config_allow_sim_import)) {
+ List<SubscriptionInfo> subInfoRecords = null;
+ try {
+ subInfoRecords = mSubscriptionManager.getActiveSubscriptionInfoList();
+ } catch (SecurityException e) {
+ Log.w(TAG, "SecurityException thrown, lack permission for"
+ + " getActiveSubscriptionInfoList", e);
+ }
+ if (subInfoRecords != null) {
+ if (subInfoRecords.size() == 1) {
+ adapter.add(new AdapterEntry(getString(R.string.import_from_sim),
+ R.string.import_from_sim, subInfoRecords.get(0).getSubscriptionId()));
+ } else {
+ for (SubscriptionInfo record : subInfoRecords) {
+ adapter.add(new AdapterEntry(getSubDescription(record),
+ R.string.import_from_sim, record.getSubscriptionId()));
+ }
+ }
+ }
+ }
+ } else {
+ if (manager != null && manager.hasIccCard()
+ && res.getBoolean(R.bool.config_allow_sim_import)) {
+ adapter.add(new AdapterEntry(getString(R.string.import_from_sim),
+ R.string.import_from_sim, -1));
+ }
+ }
+
+ if (res.getBoolean(R.bool.config_allow_export)) {
+ if (contactsAreAvailable) {
+ adapter.add(new AdapterEntry(getString(R.string.export_to_vcf_file),
+ R.string.export_to_vcf_file));
+ }
+ }
+ if (res.getBoolean(R.bool.config_allow_share_contacts) && contactsAreAvailable) {
+ if (mExportMode == EXPORT_MODE_FAVORITES) {
+ // share favorite and frequently contacted contacts from Favorites tab
+ adapter.add(new AdapterEntry(getString(R.string.share_favorite_contacts),
+ R.string.share_contacts));
+ } else {
+ // share "all" contacts (in groups selected in "Customize") from All tab for now
+ // TODO: change the string to share_visible_contacts if implemented
+ adapter.add(new AdapterEntry(getString(R.string.share_contacts),
+ R.string.share_contacts));
+ }
+ }
+
+ final DialogInterface.OnClickListener clickListener =
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ boolean dismissDialog;
+ final int resId = adapter.getItem(which).mChoiceResourceId;
+ if (resId == R.string.import_from_sim || resId == R.string.import_from_vcf_file) {
+ dismissDialog = handleImportRequest(resId,
+ adapter.getItem(which).mSubscriptionId);
+ } else if (resId == R.string.export_to_vcf_file) {
+ dismissDialog = true;
+ final Intent exportIntent = new Intent(
+ getActivity(), ExportVCardActivity.class);
+ exportIntent.putExtra(VCardCommonArguments.ARG_CALLING_ACTIVITY,
+ callingActivity);
+ getActivity().startActivity(exportIntent);
+ } else if (resId == R.string.share_contacts) {
+ dismissDialog = true;
+ if (mExportMode == EXPORT_MODE_FAVORITES) {
+ doShareFavoriteContacts();
+ } else { // EXPORT_MODE_ALL_CONTACTS
+ final Intent exportIntent = new Intent(
+ getActivity(), ShareVCardActivity.class);
+ exportIntent.putExtra(VCardCommonArguments.ARG_CALLING_ACTIVITY,
+ callingActivity);
+ getActivity().startActivity(exportIntent);
+ }
+ } else {
+ dismissDialog = true;
+ Log.e(TAG, "Unexpected resource: "
+ + getActivity().getResources().getResourceEntryName(resId));
+ }
+ if (dismissDialog) {
+ dialog.dismiss();
+ }
+ }
+ };
+ final TextView title = (TextView) View.inflate(getActivity(), R.layout.dialog_title, null);
+ title.setText(contactsAreAvailable
+ ? R.string.dialog_import_export
+ : R.string.dialog_import);
+ return new AlertDialog.Builder(getActivity())
+ .setCustomTitle(title)
+ .setSingleChoiceItems(adapter, -1, clickListener)
+ .create();
+ }
+
+ private void doShareFavoriteContacts() {
+ try{
+ final Cursor cursor = getActivity().getContentResolver().query(
+ Contacts.CONTENT_STREQUENT_URI, LOOKUP_PROJECTION, null, null,
+ Contacts.DISPLAY_NAME + " COLLATE NOCASE ASC");
+ if (cursor != null) {
+ try {
+ if (!cursor.moveToFirst()) {
+ Toast.makeText(getActivity(), R.string.no_contact_to_share,
+ Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ // Build multi-vcard Uri for sharing
+ final StringBuilder uriListBuilder = new StringBuilder();
+ int index = 0;
+ do {
+ if (index != 0)
+ uriListBuilder.append(':');
+ uriListBuilder.append(cursor.getString(0));
+ index++;
+ } while (cursor.moveToNext());
+ final Uri uri = Uri.withAppendedPath(
+ Contacts.CONTENT_MULTI_VCARD_URI,
+ Uri.encode(uriListBuilder.toString()));
+
+ final Intent intent = new Intent(Intent.ACTION_SEND);
+ intent.setType(Contacts.CONTENT_VCARD_TYPE);
+ intent.putExtra(Intent.EXTRA_STREAM, uri);
+ ImplicitIntentsUtil.startActivityOutsideApp(getActivity(), intent);
+ } finally {
+ cursor.close();
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Sharing contacts failed", e);
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(getContext(), R.string.share_contacts_failure,
+ Toast.LENGTH_SHORT).show();
+ }
+ });
+ }
+ }
+
+ /**
+ * Handle "import from SIM" and "import from SD".
+ *
+ * @return {@code true} if the dialog show be closed. {@code false} otherwise.
+ */
+ private boolean handleImportRequest(int resId, int subscriptionId) {
+ // There are three possibilities:
+ // - more than one accounts -> ask the user
+ // - just one account -> use the account without asking the user
+ // - no account -> use phone-local storage without asking the user
+ final AccountTypeManager accountTypes = AccountTypeManager.getInstance(getActivity());
+ final List<AccountWithDataSet> accountList = accountTypes.getAccounts(true);
+ final int size = accountList.size();
+ if (size > 1) {
+ // Send over to the account selector
+ final Bundle args = new Bundle();
+ args.putInt(KEY_RES_ID, resId);
+ args.putInt(KEY_SUBSCRIPTION_ID, subscriptionId);
+ SelectAccountDialogFragment.show(
+ getFragmentManager(), this,
+ R.string.dialog_new_contact_account,
+ AccountListFilter.ACCOUNTS_CONTACT_WRITABLE, args);
+
+ // In this case, because this DialogFragment is used as a target fragment to
+ // SelectAccountDialogFragment, we can't close it yet. We close the dialog when
+ // we get a callback from it.
+ return false;
+ }
+
+ AccountSelectionUtil.doImport(getActivity(), resId,
+ (size == 1 ? accountList.get(0) : null),
+ (CompatUtils.isMSIMCompatible() ? subscriptionId : -1));
+ return true; // Close the dialog.
+ }
+
+ /**
+ * Called when an account is selected on {@link SelectAccountDialogFragment}.
+ */
+ @Override
+ public void onAccountChosen(AccountWithDataSet account, Bundle extraArgs) {
+ AccountSelectionUtil.doImport(getActivity(), extraArgs.getInt(KEY_RES_ID),
+ account, extraArgs.getInt(KEY_SUBSCRIPTION_ID));
+
+ // At this point the dialog is still showing (which is why we can use getActivity() above)
+ // So close it.
+ dismiss();
+ }
+
+ @Override
+ public void onAccountSelectorCancelled() {
+ // See onAccountChosen() -- at this point the dialog is still showing. Close it.
+ dismiss();
+ }
+
+ private CharSequence getSubDescription(SubscriptionInfo record) {
+ CharSequence name = record.getDisplayName();
+ if (TextUtils.isEmpty(record.getNumber())) {
+ // Don't include the phone number in the description, since we don't know the number.
+ return getString(R.string.import_from_sim_summary_no_number, name);
+ }
+ return TextUtils.expandTemplate(
+ getString(R.string.import_from_sim_summary),
+ name,
+ PhoneNumberUtilsCompat.createTtsSpannable(record.getNumber()));
+ }
+
+ private static class AdapterEntry {
+ public final CharSequence mLabel;
+ public final int mChoiceResourceId;
+ public final int mSubscriptionId;
+
+ public AdapterEntry(CharSequence label, int resId, int subId) {
+ mLabel = label;
+ mChoiceResourceId = resId;
+ mSubscriptionId = subId;
+ }
+
+ public AdapterEntry(String label, int resId) {
+ // Store a nonsense value for mSubscriptionId. If this constructor is used,
+ // the mSubscriptionId value should not be read later.
+ this(label, resId, /* subId = */ -1);
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/interactions/TouchPointManager.java b/src/com/android/contacts/common/interactions/TouchPointManager.java
new file mode 100644
index 0000000..4c38e22
--- /dev/null
+++ b/src/com/android/contacts/common/interactions/TouchPointManager.java
@@ -0,0 +1,46 @@
+package com.android.contacts.common.interactions;
+
+import android.graphics.Point;
+
+/**
+ * Singleton class to keep track of where the user last touched the screen.
+ *
+ * Used to pass on to the InCallUI for animation.
+ */
+public class TouchPointManager {
+ public static final String TOUCH_POINT = "touchPoint";
+
+ private static TouchPointManager sInstance = new TouchPointManager();
+
+ private Point mPoint = new Point();
+
+ /**
+ * Private constructor. Instance should only be acquired through getInstance().
+ */
+ private TouchPointManager() {
+ }
+
+ public static TouchPointManager getInstance() {
+ return sInstance;
+ }
+
+ public Point getPoint() {
+ return mPoint;
+ }
+
+ public void setPoint(int x, int y) {
+ mPoint.set(x, y);
+ }
+
+ /**
+ * When a point is initialized, its value is (0,0). Since it is highly unlikely a user will
+ * touch at that exact point, if the point in TouchPointManager is (0,0), it is safe to assume
+ * that the TouchPointManager has not yet collected a touch.
+ *
+ * @return True if there is a valid point saved. Define a valid point as any point that is
+ * not (0,0).
+ */
+ public boolean hasValidPoint() {
+ return mPoint.x != 0 || mPoint.y != 0;
+ }
+}
diff --git a/src/com/android/contacts/common/lettertiles/LetterTileDrawable.java b/src/com/android/contacts/common/lettertiles/LetterTileDrawable.java
new file mode 100644
index 0000000..d1f1811
--- /dev/null
+++ b/src/com/android/contacts/common/lettertiles/LetterTileDrawable.java
@@ -0,0 +1,287 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.lettertiles;
+
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.Paint.Align;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
+import android.text.TextUtils;
+
+import com.android.contacts.common.R;
+
+import junit.framework.Assert;
+
+/**
+ * A drawable that encapsulates all the functionality needed to display a letter tile to
+ * represent a contact image.
+ */
+public class LetterTileDrawable extends Drawable {
+
+ private final String TAG = LetterTileDrawable.class.getSimpleName();
+
+ private final Paint mPaint;
+
+ /** Letter tile */
+ private static TypedArray sColors;
+ private static int sDefaultColor;
+ private static int sTileFontColor;
+ private static float sLetterToTileRatio;
+ private static Bitmap DEFAULT_PERSON_AVATAR;
+ private static Bitmap DEFAULT_BUSINESS_AVATAR;
+ private static Bitmap DEFAULT_VOICEMAIL_AVATAR;
+
+ /** Reusable components to avoid new allocations */
+ private static final Paint sPaint = new Paint();
+ private static final Rect sRect = new Rect();
+ private static final char[] sFirstChar = new char[1];
+
+ /** Contact type constants */
+ public static final int TYPE_PERSON = 1;
+ public static final int TYPE_BUSINESS = 2;
+ public static final int TYPE_VOICEMAIL = 3;
+ public static final int TYPE_DEFAULT = TYPE_PERSON;
+
+ /** 54% opacity */
+ private static final int ALPHA = 138;
+
+ private int mContactType = TYPE_DEFAULT;
+ private float mScale = 1.0f;
+ private float mOffset = 0.0f;
+ private boolean mIsCircle = false;
+
+ private int mColor;
+ private Character mLetter = null;
+
+ public LetterTileDrawable(final Resources res) {
+ if (sColors == null) {
+ sColors = res.obtainTypedArray(R.array.letter_tile_colors);
+ sDefaultColor = res.getColor(R.color.letter_tile_default_color);
+ sTileFontColor = res.getColor(R.color.letter_tile_font_color);
+ sLetterToTileRatio = res.getFraction(R.dimen.letter_to_tile_ratio, 1, 1);
+ DEFAULT_PERSON_AVATAR = BitmapFactory.decodeResource(res,
+ R.drawable.ic_person_avatar);
+ DEFAULT_BUSINESS_AVATAR = BitmapFactory.decodeResource(res,
+ R.drawable.ic_business_white_120dp);
+ DEFAULT_VOICEMAIL_AVATAR = BitmapFactory.decodeResource(res,
+ R.drawable.ic_voicemail_avatar);
+ sPaint.setTypeface(Typeface.create(
+ res.getString(R.string.letter_tile_letter_font_family), Typeface.NORMAL));
+ sPaint.setTextAlign(Align.CENTER);
+ sPaint.setAntiAlias(true);
+ }
+ mPaint = new Paint();
+ mPaint.setFilterBitmap(true);
+ mPaint.setDither(true);
+ mColor = sDefaultColor;
+ }
+
+ @Override
+ public void draw(final Canvas canvas) {
+ final Rect bounds = getBounds();
+ if (!isVisible() || bounds.isEmpty()) {
+ return;
+ }
+ // Draw letter tile.
+ drawLetterTile(canvas);
+ }
+
+ /**
+ * Draw the bitmap onto the canvas at the current bounds taking into account the current scale.
+ */
+ private void drawBitmap(final Bitmap bitmap, final int width, final int height,
+ final Canvas canvas) {
+ // The bitmap should be drawn in the middle of the canvas without changing its width to
+ // height ratio.
+ final Rect destRect = copyBounds();
+
+ // Crop the destination bounds into a square, scaled and offset as appropriate
+ final int halfLength = (int) (mScale * Math.min(destRect.width(), destRect.height()) / 2);
+
+ destRect.set(destRect.centerX() - halfLength,
+ (int) (destRect.centerY() - halfLength + mOffset * destRect.height()),
+ destRect.centerX() + halfLength,
+ (int) (destRect.centerY() + halfLength + mOffset * destRect.height()));
+
+ // Source rectangle remains the entire bounds of the source bitmap.
+ sRect.set(0, 0, width, height);
+
+ sPaint.setTextAlign(Align.CENTER);
+ sPaint.setAntiAlias(true);
+ sPaint.setAlpha(ALPHA);
+
+ canvas.drawBitmap(bitmap, sRect, destRect, sPaint);
+ }
+
+ private void drawLetterTile(final Canvas canvas) {
+ // Draw background color.
+ sPaint.setColor(mColor);
+
+ sPaint.setAlpha(mPaint.getAlpha());
+ final Rect bounds = getBounds();
+ final int minDimension = Math.min(bounds.width(), bounds.height());
+
+ if (mIsCircle) {
+ canvas.drawCircle(bounds.centerX(), bounds.centerY(), minDimension / 2, sPaint);
+ } else {
+ canvas.drawRect(bounds, sPaint);
+ }
+
+ // Draw letter/digit only if the first character is an english letter or there's a override
+
+ if (mLetter != null) {
+ // Draw letter or digit.
+ sFirstChar[0] = mLetter;
+
+ // Scale text by canvas bounds and user selected scaling factor
+ sPaint.setTextSize(mScale * sLetterToTileRatio * minDimension);
+ sPaint.getTextBounds(sFirstChar, 0, 1, sRect);
+ sPaint.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL));
+ sPaint.setColor(sTileFontColor);
+ sPaint.setAlpha(ALPHA);
+
+ // Draw the letter in the canvas, vertically shifted up or down by the user-defined
+ // offset
+ canvas.drawText(sFirstChar, 0, 1, bounds.centerX(),
+ bounds.centerY() + mOffset * bounds.height() - sRect.exactCenterY(),
+ sPaint);
+ } else {
+ // Draw the default image if there is no letter/digit to be drawn
+ final Bitmap bitmap = getBitmapForContactType(mContactType);
+ drawBitmap(bitmap, bitmap.getWidth(), bitmap.getHeight(),
+ canvas);
+ }
+ }
+
+ public int getColor() {
+ return mColor;
+ }
+
+ /**
+ * Returns a deterministic color based on the provided contact identifier string.
+ */
+ private int pickColor(final String identifier) {
+ if (TextUtils.isEmpty(identifier) || mContactType == TYPE_VOICEMAIL) {
+ return sDefaultColor;
+ }
+ // String.hashCode() implementation is not supposed to change across java versions, so
+ // this should guarantee the same email address always maps to the same color.
+ // The email should already have been normalized by the ContactRequest.
+ final int color = Math.abs(identifier.hashCode()) % sColors.length();
+ return sColors.getColor(color, sDefaultColor);
+ }
+
+ private static Bitmap getBitmapForContactType(int contactType) {
+ switch (contactType) {
+ case TYPE_PERSON:
+ return DEFAULT_PERSON_AVATAR;
+ case TYPE_BUSINESS:
+ return DEFAULT_BUSINESS_AVATAR;
+ case TYPE_VOICEMAIL:
+ return DEFAULT_VOICEMAIL_AVATAR;
+ default:
+ return DEFAULT_PERSON_AVATAR;
+ }
+ }
+
+ private static boolean isEnglishLetter(final char c) {
+ return ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z');
+ }
+
+ @Override
+ public void setAlpha(final int alpha) {
+ mPaint.setAlpha(alpha);
+ }
+
+ @Override
+ public void setColorFilter(final ColorFilter cf) {
+ mPaint.setColorFilter(cf);
+ }
+
+ @Override
+ public int getOpacity() {
+ return android.graphics.PixelFormat.OPAQUE;
+ }
+
+ /**
+ * Scale the drawn letter tile to a ratio of its default size
+ *
+ * @param scale The ratio the letter tile should be scaled to as a percentage of its default
+ * size, from a scale of 0 to 2.0f. The default is 1.0f.
+ */
+ public LetterTileDrawable setScale(float scale) {
+ mScale = scale;
+ return this;
+ }
+
+ /**
+ * Assigns the vertical offset of the position of the letter tile to the ContactDrawable
+ *
+ * @param offset The provided offset must be within the range of -0.5f to 0.5f.
+ * If set to -0.5f, the letter will be shifted upwards by 0.5 times the height of the canvas
+ * it is being drawn on, which means it will be drawn with the center of the letter starting
+ * at the top edge of the canvas.
+ * If set to 0.5f, the letter will be shifted downwards by 0.5 times the height of the canvas
+ * it is being drawn on, which means it will be drawn with the center of the letter starting
+ * at the bottom edge of the canvas.
+ * The default is 0.0f.
+ */
+ public LetterTileDrawable setOffset(float offset) {
+ Assert.assertTrue(offset >= -0.5f && offset <= 0.5f);
+ mOffset = offset;
+ return this;
+ }
+
+ public LetterTileDrawable setLetter(Character letter){
+ mLetter = letter;
+ return this;
+ }
+
+ public LetterTileDrawable setColor(int color){
+ mColor = color;
+ return this;
+ }
+
+ public LetterTileDrawable setLetterAndColorFromContactDetails(final String displayName,
+ final String identifier) {
+ if (displayName != null && displayName.length() > 0
+ && isEnglishLetter(displayName.charAt(0))) {
+ mLetter = Character.toUpperCase(displayName.charAt(0));
+ }else{
+ mLetter = null;
+ }
+ mColor = pickColor(identifier);
+ return this;
+ }
+
+ public LetterTileDrawable setContactType(int contactType) {
+ mContactType = contactType;
+ return this;
+ }
+
+ public LetterTileDrawable setIsCircular(boolean isCircle) {
+ mIsCircle = isCircle;
+ return this;
+ }
+}
diff --git a/src/com/android/contacts/common/list/AccountFilterActivity.java b/src/com/android/contacts/common/list/AccountFilterActivity.java
new file mode 100644
index 0000000..bed6977
--- /dev/null
+++ b/src/com/android/contacts/common/list/AccountFilterActivity.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.BaseAdapter;
+import android.widget.ListView;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.AccountTypeManager;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shows a list of all available accounts, letting the user select under which account to view
+ * contacts.
+ */
+public class AccountFilterActivity extends Activity implements AdapterView.OnItemClickListener {
+
+ private static final int SUBACTIVITY_CUSTOMIZE_FILTER = 0;
+
+ public static final String EXTRA_CONTACT_LIST_FILTER = "contactListFilter";
+
+ private ListView mListView;
+
+ // The default contact list type, it should be either FILTER_TYPE_ALL_ACCOUNTS or
+ // FILTER_TYPE_CUSTOM, since those are the only two options we give the user.
+ private int mCurrentFilterType;
+
+ private ContactListFilterView mCustomFilterView; // the "Customize" filter
+
+ private boolean mIsCustomFilterViewSelected;
+
+ @Override
+ protected void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+ setContentView(R.layout.contact_list_filter);
+
+ mListView = (ListView) findViewById(android.R.id.list);
+ mListView.setOnItemClickListener(this);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+
+ mCurrentFilterType = ContactListFilterController.getInstance(this).isCustomFilterPersisted()
+ ? ContactListFilter.FILTER_TYPE_CUSTOM
+ : ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS;
+
+ // We don't need to use AccountFilterUtil.FilterLoader since we only want to show
+ // the "All contacts" and "Customize" options.
+ final List<ContactListFilter> filters = new ArrayList<>();
+ filters.add(ContactListFilter.createFilterWithType(
+ ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS));
+ filters.add(ContactListFilter.createFilterWithType(
+ ContactListFilter.FILTER_TYPE_CUSTOM));
+ mListView.setAdapter(new FilterListAdapter(this, filters, mCurrentFilterType));
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ final ContactListFilterView listFilterView = (ContactListFilterView) view;
+ final ContactListFilter filter = (ContactListFilter) view.getTag();
+ if (filter == null) return; // Just in case
+ if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) {
+ mCustomFilterView = listFilterView;
+ mIsCustomFilterViewSelected = listFilterView.isChecked();
+ final Intent intent = new Intent(this,
+ CustomContactListFilterActivity.class);
+ listFilterView.setActivated(true);
+ // Switching activity has the highest priority. So when we open another activity, the
+ // announcement that indicates an account is checked will be interrupted. This is the
+ // way to overcome -- View.announceForAccessibility(CharSequence text);
+ listFilterView.announceForAccessibility(listFilterView.generateContentDescription());
+ startActivityForResult(intent, SUBACTIVITY_CUSTOMIZE_FILTER);
+ } else {
+ listFilterView.setActivated(true);
+ listFilterView.announceForAccessibility(listFilterView.generateContentDescription());
+ final Intent intent = new Intent();
+ intent.putExtra(EXTRA_CONTACT_LIST_FILTER, filter);
+ setResult(Activity.RESULT_OK, intent);
+ finish();
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (resultCode == Activity.RESULT_CANCELED && mCustomFilterView != null &&
+ !mIsCustomFilterViewSelected) {
+ mCustomFilterView.setActivated(false);
+ return;
+ }
+
+ if (resultCode != Activity.RESULT_OK) {
+ return;
+ }
+
+ switch (requestCode) {
+ case SUBACTIVITY_CUSTOMIZE_FILTER: {
+ final Intent intent = new Intent();
+ ContactListFilter filter = ContactListFilter.createFilterWithType(
+ ContactListFilter.FILTER_TYPE_CUSTOM);
+ intent.putExtra(EXTRA_CONTACT_LIST_FILTER, filter);
+ setResult(Activity.RESULT_OK, intent);
+ finish();
+ break;
+ }
+ }
+ }
+
+ private static class FilterListAdapter extends BaseAdapter {
+ private final List<ContactListFilter> mFilters;
+ private final LayoutInflater mLayoutInflater;
+ private final AccountTypeManager mAccountTypes;
+ private final int mCurrentFilter;
+
+ public FilterListAdapter(
+ Context context, List<ContactListFilter> filters, int current) {
+ mLayoutInflater = (LayoutInflater) context.getSystemService
+ (Context.LAYOUT_INFLATER_SERVICE);
+ mFilters = filters;
+ mCurrentFilter = current;
+ mAccountTypes = AccountTypeManager.getInstance(context);
+ }
+
+ @Override
+ public int getCount() {
+ return mFilters.size();
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public ContactListFilter getItem(int position) {
+ return mFilters.get(position);
+ }
+
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final ContactListFilterView view;
+ if (convertView != null) {
+ view = (ContactListFilterView) convertView;
+ } else {
+ view = (ContactListFilterView) mLayoutInflater.inflate(
+ R.layout.contact_list_filter_item, parent, false);
+ }
+ view.setSingleAccount(mFilters.size() == 1);
+ final ContactListFilter filter = mFilters.get(position);
+ view.setContactListFilter(filter);
+ view.bindView(mAccountTypes);
+ view.setTag(filter);
+ view.setActivated(filter.filterType == mCurrentFilter);
+ return view;
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ // We have two logical "up" Activities: People and Phone.
+ // Instead of having one static "up" direction, behave like back as an
+ // exceptional case.
+ onBackPressed();
+ return true;
+ default:
+ break;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/src/com/android/contacts/common/list/AutoScrollListView.java b/src/com/android/contacts/common/list/AutoScrollListView.java
new file mode 100644
index 0000000..ae7ca17
--- /dev/null
+++ b/src/com/android/contacts/common/list/AutoScrollListView.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.ListView;
+
+/**
+ * A ListView that can be asked to scroll (smoothly or otherwise) to a specific
+ * position. This class takes advantage of similar functionality that exists
+ * in {@link ListView} and enhances it.
+ */
+public class AutoScrollListView extends ListView {
+
+ /**
+ * Position the element at about 1/3 of the list height
+ */
+ private static final float PREFERRED_SELECTION_OFFSET_FROM_TOP = 0.33f;
+
+ private int mRequestedScrollPosition = -1;
+ private boolean mSmoothScrollRequested;
+
+ public AutoScrollListView(Context context) {
+ super(context);
+ }
+
+ public AutoScrollListView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public AutoScrollListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ }
+
+ /**
+ * Brings the specified position to view by optionally performing a jump-scroll maneuver:
+ * first it jumps to some position near the one requested and then does a smooth
+ * scroll to the requested position. This creates an impression of full smooth
+ * scrolling without actually traversing the entire list. If smooth scrolling is
+ * not requested, instantly positions the requested item at a preferred offset.
+ */
+ public void requestPositionToScreen(int position, boolean smoothScroll) {
+ mRequestedScrollPosition = position;
+ mSmoothScrollRequested = smoothScroll;
+ requestLayout();
+ }
+
+ @Override
+ protected void layoutChildren() {
+ super.layoutChildren();
+ if (mRequestedScrollPosition == -1) {
+ return;
+ }
+
+ final int position = mRequestedScrollPosition;
+ mRequestedScrollPosition = -1;
+
+ int firstPosition = getFirstVisiblePosition() + 1;
+ int lastPosition = getLastVisiblePosition();
+ if (position >= firstPosition && position <= lastPosition) {
+ return; // Already on screen
+ }
+
+ final int offset = (int) (getHeight() * PREFERRED_SELECTION_OFFSET_FROM_TOP);
+ if (!mSmoothScrollRequested) {
+ setSelectionFromTop(position, offset);
+
+ // Since we have changed the scrolling position, we need to redo child layout
+ // Calling "requestLayout" in the middle of a layout pass has no effect,
+ // so we call layoutChildren explicitly
+ super.layoutChildren();
+
+ } else {
+ // We will first position the list a couple of screens before or after
+ // the new selection and then scroll smoothly to it.
+ int twoScreens = (lastPosition - firstPosition) * 2;
+ int preliminaryPosition;
+ if (position < firstPosition) {
+ preliminaryPosition = position + twoScreens;
+ if (preliminaryPosition >= getCount()) {
+ preliminaryPosition = getCount() - 1;
+ }
+ if (preliminaryPosition < firstPosition) {
+ setSelection(preliminaryPosition);
+ super.layoutChildren();
+ }
+ } else {
+ preliminaryPosition = position - twoScreens;
+ if (preliminaryPosition < 0) {
+ preliminaryPosition = 0;
+ }
+ if (preliminaryPosition > lastPosition) {
+ setSelection(preliminaryPosition);
+ super.layoutChildren();
+ }
+ }
+
+
+ smoothScrollToPositionFromTop(position, offset);
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactEntry.java b/src/com/android/contacts/common/list/ContactEntry.java
new file mode 100644
index 0000000..a29a8d8
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactEntry.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.provider.ContactsContract.PinnedPositions;
+import android.text.TextUtils;
+
+import com.android.contacts.common.preference.ContactsPreferences;
+
+/**
+ * Class to hold contact information
+ */
+public class ContactEntry {
+
+ private static final int UNSET_DISPLAY_ORDER_PREFERENCE = -1;
+
+ /**
+ * Primary name for a Contact
+ */
+ public String namePrimary;
+ /**
+ * Alternative name for a Contact, e.g. last name first
+ */
+ public String nameAlternative;
+ /**
+ * The user's preference on name display order, last name first or first time first.
+ * {@see ContactsPreferences}
+ */
+ public int nameDisplayOrder = UNSET_DISPLAY_ORDER_PREFERENCE;
+
+ public String status;
+ public String phoneLabel;
+ public String phoneNumber;
+ public Uri photoUri;
+ public Uri lookupUri;
+ public String lookupKey;
+ public Drawable presenceIcon;
+ public long id;
+ public int pinned = PinnedPositions.UNPINNED;
+ public boolean isFavorite = false;
+ public boolean isDefaultNumber = false;
+
+ public static final ContactEntry BLANK_ENTRY = new ContactEntry();
+
+ public String getPreferredDisplayName() {
+ if (nameDisplayOrder == UNSET_DISPLAY_ORDER_PREFERENCE
+ || nameDisplayOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY
+ || TextUtils.isEmpty(nameAlternative)) {
+ return namePrimary;
+ }
+ return nameAlternative;
+ }
+}
\ No newline at end of file
diff --git a/src/com/android/contacts/common/list/ContactEntryListAdapter.java b/src/com/android/contacts/common/list/ContactEntryListAdapter.java
new file mode 100644
index 0000000..1ac8fd5
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactEntryListAdapter.java
@@ -0,0 +1,787 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.content.CursorLoader;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Directory;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.QuickContactBadge;
+import android.widget.SectionIndexer;
+import android.widget.TextView;
+
+import com.android.contacts.common.ContactPhotoManager;
+import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest;
+import com.android.contacts.common.ContactsUtils;
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.compat.DirectoryCompat;
+import com.android.contacts.common.util.SearchUtil;
+
+import java.util.HashSet;
+
+/**
+ * Common base class for various contact-related lists, e.g. contact list, phone number list
+ * etc.
+ */
+public abstract class ContactEntryListAdapter extends IndexerListAdapter {
+
+ private static final String TAG = "ContactEntryListAdapter";
+
+ /**
+ * Indicates whether the {@link Directory#LOCAL_INVISIBLE} directory should
+ * be included in the search.
+ */
+ public static final boolean LOCAL_INVISIBLE_DIRECTORY_ENABLED = false;
+
+ private int mDisplayOrder;
+ private int mSortOrder;
+
+ private boolean mDisplayPhotos;
+ private boolean mCircularPhotos = true;
+ private boolean mQuickContactEnabled;
+ private boolean mAdjustSelectionBoundsEnabled;
+
+ /**
+ * indicates if contact queries include favorites
+ */
+ private boolean mIncludeFavorites;
+
+ private int mNumberOfFavorites;
+
+ /**
+ * The root view of the fragment that this adapter is associated with.
+ */
+ private View mFragmentRootView;
+
+ private ContactPhotoManager mPhotoLoader;
+
+ private String mQueryString;
+ private String mUpperCaseQueryString;
+ private boolean mSearchMode;
+ private int mDirectorySearchMode;
+ private int mDirectoryResultLimit = Integer.MAX_VALUE;
+
+ private boolean mEmptyListEnabled = true;
+
+ private boolean mSelectionVisible;
+
+ private ContactListFilter mFilter;
+ private boolean mDarkTheme = false;
+
+ /** Resource used to provide header-text for default filter. */
+ private CharSequence mDefaultFilterHeaderText;
+
+ public ContactEntryListAdapter(Context context) {
+ super(context);
+ setDefaultFilterHeaderText(R.string.local_search_label);
+ addPartitions();
+ }
+
+ /**
+ * @param fragmentRootView Root view of the fragment. This is used to restrict the scope of
+ * image loading requests that get cancelled on cursor changes.
+ */
+ protected void setFragmentRootView(View fragmentRootView) {
+ mFragmentRootView = fragmentRootView;
+ }
+
+ protected void setDefaultFilterHeaderText(int resourceId) {
+ mDefaultFilterHeaderText = getContext().getResources().getText(resourceId);
+ }
+
+ @Override
+ protected ContactListItemView newView(
+ Context context, int partition, Cursor cursor, int position, ViewGroup parent) {
+ final ContactListItemView view = new ContactListItemView(context, null);
+ view.setIsSectionHeaderEnabled(isSectionHeaderDisplayEnabled());
+ view.setAdjustSelectionBoundsEnabled(isAdjustSelectionBoundsEnabled());
+ return view;
+ }
+
+ @Override
+ protected void bindView(View itemView, int partition, Cursor cursor, int position) {
+ final ContactListItemView view = (ContactListItemView) itemView;
+ view.setIsSectionHeaderEnabled(isSectionHeaderDisplayEnabled());
+ bindWorkProfileIcon(view, partition);
+ }
+
+ @Override
+ protected View createPinnedSectionHeaderView(Context context, ViewGroup parent) {
+ return new ContactListPinnedHeaderView(context, null, parent);
+ }
+
+ @Override
+ protected void setPinnedSectionTitle(View pinnedHeaderView, String title) {
+ ((ContactListPinnedHeaderView) pinnedHeaderView).setSectionHeaderTitle(title);
+ }
+
+ protected void addPartitions() {
+ addPartition(createDefaultDirectoryPartition());
+ }
+
+ protected DirectoryPartition createDefaultDirectoryPartition() {
+ DirectoryPartition partition = new DirectoryPartition(true, true);
+ partition.setDirectoryId(Directory.DEFAULT);
+ partition.setDirectoryType(getContext().getString(R.string.contactsList));
+ partition.setPriorityDirectory(true);
+ partition.setPhotoSupported(true);
+ partition.setLabel(mDefaultFilterHeaderText.toString());
+ return partition;
+ }
+
+ /**
+ * Remove all directories after the default directory. This is typically used when contacts
+ * list screens are asked to exit the search mode and thus need to remove all remote directory
+ * results for the search.
+ *
+ * This code assumes that the default directory and directories before that should not be
+ * deleted (e.g. Join screen has "suggested contacts" directory before the default director,
+ * and we should not remove the directory).
+ */
+ public void removeDirectoriesAfterDefault() {
+ final int partitionCount = getPartitionCount();
+ for (int i = partitionCount - 1; i >= 0; i--) {
+ final Partition partition = getPartition(i);
+ if ((partition instanceof DirectoryPartition)
+ && ((DirectoryPartition) partition).getDirectoryId() == Directory.DEFAULT) {
+ break;
+ } else {
+ removePartition(i);
+ }
+ }
+ }
+
+ protected int getPartitionByDirectoryId(long id) {
+ int count = getPartitionCount();
+ for (int i = 0; i < count; i++) {
+ Partition partition = getPartition(i);
+ if (partition instanceof DirectoryPartition) {
+ if (((DirectoryPartition)partition).getDirectoryId() == id) {
+ return i;
+ }
+ }
+ }
+ return -1;
+ }
+
+ protected DirectoryPartition getDirectoryById(long id) {
+ int count = getPartitionCount();
+ for (int i = 0; i < count; i++) {
+ Partition partition = getPartition(i);
+ if (partition instanceof DirectoryPartition) {
+ final DirectoryPartition directoryPartition = (DirectoryPartition) partition;
+ if (directoryPartition.getDirectoryId() == id) {
+ return directoryPartition;
+ }
+ }
+ }
+ return null;
+ }
+
+ public abstract String getContactDisplayName(int position);
+ public abstract void configureLoader(CursorLoader loader, long directoryId);
+
+ /**
+ * Marks all partitions as "loading"
+ */
+ public void onDataReload() {
+ boolean notify = false;
+ int count = getPartitionCount();
+ for (int i = 0; i < count; i++) {
+ Partition partition = getPartition(i);
+ if (partition instanceof DirectoryPartition) {
+ DirectoryPartition directoryPartition = (DirectoryPartition)partition;
+ if (!directoryPartition.isLoading()) {
+ notify = true;
+ }
+ directoryPartition.setStatus(DirectoryPartition.STATUS_NOT_LOADED);
+ }
+ }
+ if (notify) {
+ notifyDataSetChanged();
+ }
+ }
+
+ @Override
+ public void clearPartitions() {
+ int count = getPartitionCount();
+ for (int i = 0; i < count; i++) {
+ Partition partition = getPartition(i);
+ if (partition instanceof DirectoryPartition) {
+ DirectoryPartition directoryPartition = (DirectoryPartition)partition;
+ directoryPartition.setStatus(DirectoryPartition.STATUS_NOT_LOADED);
+ }
+ }
+ super.clearPartitions();
+ }
+
+ public boolean isSearchMode() {
+ return mSearchMode;
+ }
+
+ public void setSearchMode(boolean flag) {
+ mSearchMode = flag;
+ }
+
+ public String getQueryString() {
+ return mQueryString;
+ }
+
+ public void setQueryString(String queryString) {
+ mQueryString = queryString;
+ if (TextUtils.isEmpty(queryString)) {
+ mUpperCaseQueryString = null;
+ } else {
+ mUpperCaseQueryString = SearchUtil
+ .cleanStartAndEndOfSearchQuery(queryString.toUpperCase()) ;
+ }
+ }
+
+ public String getUpperCaseQueryString() {
+ return mUpperCaseQueryString;
+ }
+
+ public int getDirectorySearchMode() {
+ return mDirectorySearchMode;
+ }
+
+ public void setDirectorySearchMode(int mode) {
+ mDirectorySearchMode = mode;
+ }
+
+ public int getDirectoryResultLimit() {
+ return mDirectoryResultLimit;
+ }
+
+ public int getDirectoryResultLimit(DirectoryPartition directoryPartition) {
+ final int limit = directoryPartition.getResultLimit();
+ return limit == DirectoryPartition.RESULT_LIMIT_DEFAULT ? mDirectoryResultLimit : limit;
+ }
+
+ public void setDirectoryResultLimit(int limit) {
+ this.mDirectoryResultLimit = limit;
+ }
+
+ public int getContactNameDisplayOrder() {
+ return mDisplayOrder;
+ }
+
+ public void setContactNameDisplayOrder(int displayOrder) {
+ mDisplayOrder = displayOrder;
+ }
+
+ public int getSortOrder() {
+ return mSortOrder;
+ }
+
+ public void setSortOrder(int sortOrder) {
+ mSortOrder = sortOrder;
+ }
+
+ public void setPhotoLoader(ContactPhotoManager photoLoader) {
+ mPhotoLoader = photoLoader;
+ }
+
+ protected ContactPhotoManager getPhotoLoader() {
+ return mPhotoLoader;
+ }
+
+ public boolean getDisplayPhotos() {
+ return mDisplayPhotos;
+ }
+
+ public void setDisplayPhotos(boolean displayPhotos) {
+ mDisplayPhotos = displayPhotos;
+ }
+
+ public boolean getCircularPhotos() {
+ return mCircularPhotos;
+ }
+
+ public void setCircularPhotos(boolean circularPhotos) {
+ mCircularPhotos = circularPhotos;
+ }
+
+ public boolean isEmptyListEnabled() {
+ return mEmptyListEnabled;
+ }
+
+ public void setEmptyListEnabled(boolean flag) {
+ mEmptyListEnabled = flag;
+ }
+
+ public boolean isSelectionVisible() {
+ return mSelectionVisible;
+ }
+
+ public void setSelectionVisible(boolean flag) {
+ this.mSelectionVisible = flag;
+ }
+
+ public boolean isQuickContactEnabled() {
+ return mQuickContactEnabled;
+ }
+
+ public void setQuickContactEnabled(boolean quickContactEnabled) {
+ mQuickContactEnabled = quickContactEnabled;
+ }
+
+ public boolean isAdjustSelectionBoundsEnabled() {
+ return mAdjustSelectionBoundsEnabled;
+ }
+
+ public void setAdjustSelectionBoundsEnabled(boolean enabled) {
+ mAdjustSelectionBoundsEnabled = enabled;
+ }
+
+ public boolean shouldIncludeFavorites() {
+ return mIncludeFavorites;
+ }
+
+ public void setIncludeFavorites(boolean includeFavorites) {
+ mIncludeFavorites = includeFavorites;
+ }
+
+ public void setFavoritesSectionHeader(int numberOfFavorites) {
+ if (mIncludeFavorites) {
+ mNumberOfFavorites = numberOfFavorites;
+ setSectionHeader(R.string.star_sign, numberOfFavorites);
+ }
+ }
+
+ public int getNumberOfFavorites() {
+ return mNumberOfFavorites;
+ }
+
+ private void setSectionHeader(int resId, int numberOfItems) {
+ SectionIndexer indexer = getIndexer();
+ if (indexer != null) {
+ ((ContactsSectionIndexer) indexer).setProfileAndFavoritesHeader(
+ getContext().getString(resId), numberOfItems);
+ }
+ }
+
+ public void setDarkTheme(boolean value) {
+ mDarkTheme = value;
+ }
+
+ /**
+ * Updates partitions according to the directory meta-data contained in the supplied
+ * cursor.
+ */
+ public void changeDirectories(Cursor cursor) {
+ if (cursor.getCount() == 0) {
+ // Directory table must have at least local directory, without which this adapter will
+ // enter very weird state.
+ Log.e(TAG, "Directory search loader returned an empty cursor, which implies we have " +
+ "no directory entries.", new RuntimeException());
+ return;
+ }
+ HashSet<Long> directoryIds = new HashSet<Long>();
+
+ int idColumnIndex = cursor.getColumnIndex(Directory._ID);
+ int directoryTypeColumnIndex = cursor.getColumnIndex(DirectoryListLoader.DIRECTORY_TYPE);
+ int displayNameColumnIndex = cursor.getColumnIndex(Directory.DISPLAY_NAME);
+ int photoSupportColumnIndex = cursor.getColumnIndex(Directory.PHOTO_SUPPORT);
+
+ // TODO preserve the order of partition to match those of the cursor
+ // Phase I: add new directories
+ cursor.moveToPosition(-1);
+ while (cursor.moveToNext()) {
+ long id = cursor.getLong(idColumnIndex);
+ directoryIds.add(id);
+ if (getPartitionByDirectoryId(id) == -1) {
+ DirectoryPartition partition = new DirectoryPartition(false, true);
+ partition.setDirectoryId(id);
+ if (DirectoryCompat.isRemoteDirectoryId(id)) {
+ if (DirectoryCompat.isEnterpriseDirectoryId(id)) {
+ partition.setLabel(mContext.getString(R.string.directory_search_label_work));
+ } else {
+ partition.setLabel(mContext.getString(R.string.directory_search_label));
+ }
+ } else {
+ if (DirectoryCompat.isEnterpriseDirectoryId(id)) {
+ partition.setLabel(mContext.getString(R.string.list_filter_phones_work));
+ } else {
+ partition.setLabel(mDefaultFilterHeaderText.toString());
+ }
+ }
+ partition.setDirectoryType(cursor.getString(directoryTypeColumnIndex));
+ partition.setDisplayName(cursor.getString(displayNameColumnIndex));
+ int photoSupport = cursor.getInt(photoSupportColumnIndex);
+ partition.setPhotoSupported(photoSupport == Directory.PHOTO_SUPPORT_THUMBNAIL_ONLY
+ || photoSupport == Directory.PHOTO_SUPPORT_FULL);
+ addPartition(partition);
+ }
+ }
+
+ // Phase II: remove deleted directories
+ int count = getPartitionCount();
+ for (int i = count; --i >= 0; ) {
+ Partition partition = getPartition(i);
+ if (partition instanceof DirectoryPartition) {
+ long id = ((DirectoryPartition)partition).getDirectoryId();
+ if (!directoryIds.contains(id)) {
+ removePartition(i);
+ }
+ }
+ }
+
+ invalidate();
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void changeCursor(int partitionIndex, Cursor cursor) {
+ if (partitionIndex >= getPartitionCount()) {
+ // There is no partition for this data
+ return;
+ }
+
+ Partition partition = getPartition(partitionIndex);
+ if (partition instanceof DirectoryPartition) {
+ ((DirectoryPartition)partition).setStatus(DirectoryPartition.STATUS_LOADED);
+ }
+
+ if (mDisplayPhotos && mPhotoLoader != null && isPhotoSupported(partitionIndex)) {
+ mPhotoLoader.refreshCache();
+ }
+
+ super.changeCursor(partitionIndex, cursor);
+
+ if (isSectionHeaderDisplayEnabled() && partitionIndex == getIndexedPartition()) {
+ updateIndexer(cursor);
+ }
+
+ // When the cursor changes, cancel any pending asynchronous photo loads.
+ mPhotoLoader.cancelPendingRequests(mFragmentRootView);
+ }
+
+ public void changeCursor(Cursor cursor) {
+ changeCursor(0, cursor);
+ }
+
+ /**
+ * Updates the indexer, which is used to produce section headers.
+ */
+ private void updateIndexer(Cursor cursor) {
+ if (cursor == null || cursor.isClosed()) {
+ setIndexer(null);
+ return;
+ }
+
+ Bundle bundle = cursor.getExtras();
+ if (bundle.containsKey(Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES) &&
+ bundle.containsKey(Contacts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS)) {
+ String sections[] =
+ bundle.getStringArray(Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES);
+ int counts[] = bundle.getIntArray(
+ Contacts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS);
+
+ if (getExtraStartingSection()) {
+ // Insert an additional unnamed section at the top of the list.
+ String allSections[] = new String[sections.length + 1];
+ int allCounts[] = new int[counts.length + 1];
+ for (int i = 0; i < sections.length; i++) {
+ allSections[i + 1] = sections[i];
+ allCounts[i + 1] = counts[i];
+ }
+ allCounts[0] = 1;
+ allSections[0] = "";
+ setIndexer(new ContactsSectionIndexer(allSections, allCounts));
+ } else {
+ setIndexer(new ContactsSectionIndexer(sections, counts));
+ }
+ } else {
+ setIndexer(null);
+ }
+ }
+
+ protected boolean getExtraStartingSection() {
+ return false;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ // We need a separate view type for each item type, plus another one for
+ // each type with header, plus one for "other".
+ return getItemViewTypeCount() * 2 + 1;
+ }
+
+ @Override
+ public int getItemViewType(int partitionIndex, int position) {
+ int type = super.getItemViewType(partitionIndex, position);
+ if (isSectionHeaderDisplayEnabled() && partitionIndex == getIndexedPartition()) {
+ Placement placement = getItemPlacementInSection(position);
+ return placement.firstInSection ? type : getItemViewTypeCount() + type;
+ } else {
+ return type;
+ }
+ }
+
+ @Override
+ public boolean isEmpty() {
+ // TODO
+// if (contactsListActivity.mProviderStatus != ProviderStatus.STATUS_NORMAL) {
+// return true;
+// }
+
+ if (!mEmptyListEnabled) {
+ return false;
+ } else if (isSearchMode()) {
+ return TextUtils.isEmpty(getQueryString());
+ } else {
+ return super.isEmpty();
+ }
+ }
+
+ public boolean isLoading() {
+ int count = getPartitionCount();
+ for (int i = 0; i < count; i++) {
+ Partition partition = getPartition(i);
+ if (partition instanceof DirectoryPartition
+ && ((DirectoryPartition) partition).isLoading()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public boolean areAllPartitionsEmpty() {
+ int count = getPartitionCount();
+ for (int i = 0; i < count; i++) {
+ if (!isPartitionEmpty(i)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Changes visibility parameters for the default directory partition.
+ */
+ public void configureDefaultPartition(boolean showIfEmpty, boolean hasHeader) {
+ int defaultPartitionIndex = -1;
+ int count = getPartitionCount();
+ for (int i = 0; i < count; i++) {
+ Partition partition = getPartition(i);
+ if (partition instanceof DirectoryPartition &&
+ ((DirectoryPartition)partition).getDirectoryId() == Directory.DEFAULT) {
+ defaultPartitionIndex = i;
+ break;
+ }
+ }
+ if (defaultPartitionIndex != -1) {
+ setShowIfEmpty(defaultPartitionIndex, showIfEmpty);
+ setHasHeader(defaultPartitionIndex, hasHeader);
+ }
+ }
+
+ @Override
+ protected View newHeaderView(Context context, int partition, Cursor cursor,
+ ViewGroup parent) {
+ LayoutInflater inflater = LayoutInflater.from(context);
+ View view = inflater.inflate(R.layout.directory_header, parent, false);
+ if (!getPinnedPartitionHeadersEnabled()) {
+ // If the headers are unpinned, there is no need for their background
+ // color to be non-transparent. Setting this transparent reduces maintenance for
+ // non-pinned headers. We don't need to bother synchronizing the activity's
+ // background color with the header background color.
+ view.setBackground(null);
+ }
+ return view;
+ }
+
+ protected void bindWorkProfileIcon(final ContactListItemView view, int partitionId) {
+ final Partition partition = getPartition(partitionId);
+ if (partition instanceof DirectoryPartition) {
+ final DirectoryPartition directoryPartition = (DirectoryPartition) partition;
+ final long directoryId = directoryPartition.getDirectoryId();
+ final long userType = ContactsUtils.determineUserType(directoryId, null);
+ view.setWorkProfileIconEnabled(userType == ContactsUtils.USER_TYPE_WORK);
+ }
+ }
+
+ @Override
+ protected void bindHeaderView(View view, int partitionIndex, Cursor cursor) {
+ Partition partition = getPartition(partitionIndex);
+ if (!(partition instanceof DirectoryPartition)) {
+ return;
+ }
+
+ DirectoryPartition directoryPartition = (DirectoryPartition)partition;
+ long directoryId = directoryPartition.getDirectoryId();
+ TextView labelTextView = (TextView)view.findViewById(R.id.label);
+ TextView displayNameTextView = (TextView)view.findViewById(R.id.display_name);
+ labelTextView.setText(directoryPartition.getLabel());
+ if (!DirectoryCompat.isRemoteDirectoryId(directoryId)) {
+ displayNameTextView.setText(null);
+ } else {
+ String directoryName = directoryPartition.getDisplayName();
+ String displayName = !TextUtils.isEmpty(directoryName)
+ ? directoryName
+ : directoryPartition.getDirectoryType();
+ displayNameTextView.setText(displayName);
+ }
+
+ final Resources res = getContext().getResources();
+ final int headerPaddingTop = partitionIndex == 1 && getPartition(0).isEmpty()?
+ 0 : res.getDimensionPixelOffset(R.dimen.directory_header_extra_top_padding);
+ // There should be no extra padding at the top of the first directory header
+ view.setPaddingRelative(view.getPaddingStart(), headerPaddingTop, view.getPaddingEnd(),
+ view.getPaddingBottom());
+ }
+
+ // Default implementation simply returns number of rows in the cursor.
+ // Broken out into its own routine so can be overridden by child classes
+ // for eg number of unique contacts for a phone list.
+ protected int getResultCount(Cursor cursor) {
+ return cursor == null ? 0 : cursor.getCount();
+ }
+
+ // TODO: fix PluralRules to handle zero correctly and use Resources.getQuantityText directly
+ public String getQuantityText(int count, int zeroResourceId, int pluralResourceId) {
+ if (count == 0) {
+ return getContext().getString(zeroResourceId);
+ } else {
+ String format = getContext().getResources()
+ .getQuantityText(pluralResourceId, count).toString();
+ return String.format(format, count);
+ }
+ }
+
+ public boolean isPhotoSupported(int partitionIndex) {
+ Partition partition = getPartition(partitionIndex);
+ if (partition instanceof DirectoryPartition) {
+ return ((DirectoryPartition) partition).isPhotoSupported();
+ }
+ return true;
+ }
+
+ /**
+ * Returns the currently selected filter.
+ */
+ public ContactListFilter getFilter() {
+ return mFilter;
+ }
+
+ public void setFilter(ContactListFilter filter) {
+ mFilter = filter;
+ }
+
+ // TODO: move sharable logic (bindXX() methods) to here with extra arguments
+
+ /**
+ * Loads the photo for the quick contact view and assigns the contact uri.
+ * @param photoIdColumn Index of the photo id column
+ * @param photoUriColumn Index of the photo uri column. Optional: Can be -1
+ * @param contactIdColumn Index of the contact id column
+ * @param lookUpKeyColumn Index of the lookup key column
+ * @param displayNameColumn Index of the display name column
+ */
+ protected void bindQuickContact(final ContactListItemView view, int partitionIndex,
+ Cursor cursor, int photoIdColumn, int photoUriColumn, int contactIdColumn,
+ int lookUpKeyColumn, int displayNameColumn) {
+ long photoId = 0;
+ if (!cursor.isNull(photoIdColumn)) {
+ photoId = cursor.getLong(photoIdColumn);
+ }
+
+ QuickContactBadge quickContact = view.getQuickContact();
+ quickContact.assignContactUri(
+ getContactUri(partitionIndex, cursor, contactIdColumn, lookUpKeyColumn));
+ if (CompatUtils.hasPrioritizedMimeType()) {
+ // The Contacts app never uses the QuickContactBadge. Therefore, it is safe to assume
+ // that only Dialer will use this QuickContact badge. This means prioritizing the phone
+ // mimetype here is reasonable.
+ quickContact.setPrioritizedMimeType(Phone.CONTENT_ITEM_TYPE);
+ }
+
+ if (photoId != 0 || photoUriColumn == -1) {
+ getPhotoLoader().loadThumbnail(quickContact, photoId, mDarkTheme, mCircularPhotos,
+ null);
+ } else {
+ final String photoUriString = cursor.getString(photoUriColumn);
+ final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString);
+ DefaultImageRequest request = null;
+ if (photoUri == null) {
+ request = getDefaultImageRequestFromCursor(cursor, displayNameColumn,
+ lookUpKeyColumn);
+ }
+ getPhotoLoader().loadPhoto(quickContact, photoUri, -1, mDarkTheme, mCircularPhotos,
+ request);
+ }
+
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ // Whenever bindViewId() is called, the values passed into setId() are stable or
+ // stable-ish. For example, when one contact is modified we don't expect a second
+ // contact's Contact._ID values to change.
+ return true;
+ }
+
+ protected void bindViewId(final ContactListItemView view, Cursor cursor, int idColumn) {
+ // Set a semi-stable id, so that talkback won't get confused when the list gets
+ // refreshed. There is little harm in inserting the same ID twice.
+ long contactId = cursor.getLong(idColumn);
+ view.setId((int) (contactId % Integer.MAX_VALUE));
+
+ }
+
+ protected Uri getContactUri(int partitionIndex, Cursor cursor,
+ int contactIdColumn, int lookUpKeyColumn) {
+ long contactId = cursor.getLong(contactIdColumn);
+ String lookupKey = cursor.getString(lookUpKeyColumn);
+ long directoryId = ((DirectoryPartition)getPartition(partitionIndex)).getDirectoryId();
+ Uri uri = Contacts.getLookupUri(contactId, lookupKey);
+ if (uri != null && directoryId != Directory.DEFAULT) {
+ uri = uri.buildUpon().appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)).build();
+ }
+ return uri;
+ }
+
+ /**
+ * Retrieves the lookup key and display name from a cursor, and returns a
+ * {@link DefaultImageRequest} containing these contact details
+ *
+ * @param cursor Contacts cursor positioned at the current row to retrieve contact details for
+ * @param displayNameColumn Column index of the display name
+ * @param lookupKeyColumn Column index of the lookup key
+ * @return {@link DefaultImageRequest} with the displayName and identifier fields set to the
+ * display name and lookup key of the contact.
+ */
+ public DefaultImageRequest getDefaultImageRequestFromCursor(Cursor cursor,
+ int displayNameColumn, int lookupKeyColumn) {
+ final String displayName = cursor.getString(displayNameColumn);
+ final String lookupKey = cursor.getString(lookupKeyColumn);
+ return new DefaultImageRequest(displayName, lookupKey, mCircularPhotos);
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactEntryListFragment.java b/src/com/android/contacts/common/list/ContactEntryListFragment.java
new file mode 100644
index 0000000..0c72d68
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactEntryListFragment.java
@@ -0,0 +1,987 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.app.LoaderManager;
+import android.app.LoaderManager.LoaderCallbacks;
+import android.content.Context;
+import android.content.CursorLoader;
+import android.content.Intent;
+import android.content.Loader;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.os.Parcelable;
+import android.provider.ContactsContract.Directory;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.OnFocusChangeListener;
+import android.view.View.OnTouchListener;
+import android.view.ViewGroup;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.AbsListView;
+import android.widget.AbsListView.OnScrollListener;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.AdapterView.OnItemLongClickListener;
+import android.widget.ListView;
+
+import com.android.common.widget.CompositeCursorAdapter.Partition;
+import com.android.contacts.common.ContactPhotoManager;
+import com.android.contacts.common.logging.ListEvent.ActionType;
+import com.android.contacts.common.logging.Logger;
+import com.android.contacts.common.preference.ContactsPreferences;
+
+import java.util.Locale;
+
+/**
+ * Common base class for various contact-related list fragments.
+ */
+public abstract class ContactEntryListFragment<T extends ContactEntryListAdapter>
+ extends Fragment
+ implements OnItemClickListener, OnScrollListener, OnFocusChangeListener, OnTouchListener,
+ OnItemLongClickListener, LoaderCallbacks<Cursor> {
+ private static final String TAG = "ContactEntryListFragment";
+
+ // TODO: Make this protected. This should not be used from the PeopleActivity but
+ // instead use the new startActivityWithResultFromFragment API
+ public static final int ACTIVITY_REQUEST_CODE_PICKER = 1;
+
+ private static final String KEY_LIST_STATE = "liststate";
+ private static final String KEY_SECTION_HEADER_DISPLAY_ENABLED = "sectionHeaderDisplayEnabled";
+ private static final String KEY_PHOTO_LOADER_ENABLED = "photoLoaderEnabled";
+ private static final String KEY_QUICK_CONTACT_ENABLED = "quickContactEnabled";
+ private static final String KEY_ADJUST_SELECTION_BOUNDS_ENABLED =
+ "adjustSelectionBoundsEnabled";
+ private static final String KEY_SEARCH_MODE = "searchMode";
+ private static final String KEY_DISPLAY_DIRECTORY_HEADER = "displayDirectoryHeader";
+ private static final String KEY_VISIBLE_SCROLLBAR_ENABLED = "visibleScrollbarEnabled";
+ private static final String KEY_SCROLLBAR_POSITION = "scrollbarPosition";
+ private static final String KEY_QUERY_STRING = "queryString";
+ private static final String KEY_DIRECTORY_SEARCH_MODE = "directorySearchMode";
+ private static final String KEY_SELECTION_VISIBLE = "selectionVisible";
+ private static final String KEY_REQUEST = "request";
+ private static final String KEY_DARK_THEME = "darkTheme";
+ private static final String KEY_LEGACY_COMPATIBILITY = "legacyCompatibility";
+ private static final String KEY_DIRECTORY_RESULT_LIMIT = "directoryResultLimit";
+ private static final String KEY_LOGS_LIST_EVENTS = "logsListEvents";
+ private static final String KEY_DATA_LOADED = "dataLoaded";
+
+ private static final String DIRECTORY_ID_ARG_KEY = "directoryId";
+
+ private static final int DIRECTORY_LOADER_ID = -1;
+
+ private static final int DIRECTORY_SEARCH_DELAY_MILLIS = 300;
+ private static final int DIRECTORY_SEARCH_MESSAGE = 1;
+
+ private static final int DEFAULT_DIRECTORY_RESULT_LIMIT = 20;
+
+ private boolean mSectionHeaderDisplayEnabled;
+ private boolean mPhotoLoaderEnabled;
+ private boolean mQuickContactEnabled = true;
+ private boolean mAdjustSelectionBoundsEnabled = true;
+ private boolean mIncludeFavorites;
+ private boolean mSearchMode;
+ private boolean mDisplayDirectoryHeader = true;
+ private boolean mVisibleScrollbarEnabled;
+ private boolean mShowEmptyListForEmptyQuery;
+ private int mVerticalScrollbarPosition = getDefaultVerticalScrollbarPosition();
+ private String mQueryString;
+ private int mDirectorySearchMode = DirectoryListLoader.SEARCH_MODE_NONE;
+ private boolean mSelectionVisible;
+ private boolean mLegacyCompatibility;
+ // Whether we should log list LOAD events. It may be modified when list filter is changed.
+ private boolean mLogListEvents = true;
+ // Whether data has been loaded ever. It will stay true once it's set to true in the lifecycle.
+ // We use this flag to log LOAD events when the activity/fragment is initialized.
+ private boolean mDataLoaded;
+
+ private boolean mEnabled = true;
+
+ private T mAdapter;
+ private View mView;
+ private ListView mListView;
+
+ /**
+ * Used to save the scrolling state of the list when the fragment is not recreated.
+ */
+ private int mListViewTopIndex;
+ private int mListViewTopOffset;
+
+ /**
+ * Used for keeping track of the scroll state of the list.
+ */
+ private Parcelable mListState;
+
+ /**
+ * The type of the contacts list.
+ */
+ private int mListType;
+
+ private int mDisplayOrder;
+ private int mSortOrder;
+ private int mDirectoryResultLimit = DEFAULT_DIRECTORY_RESULT_LIMIT;
+
+ private ContactPhotoManager mPhotoManager;
+ private ContactsPreferences mContactsPrefs;
+
+ private boolean mForceLoad;
+
+ private boolean mDarkTheme;
+
+ private static final int STATUS_NOT_LOADED = 0;
+ private static final int STATUS_LOADING = 1;
+ private static final int STATUS_LOADED = 2;
+
+ private int mDirectoryListStatus = STATUS_NOT_LOADED;
+
+ /**
+ * Indicates whether we are doing the initial complete load of data (false) or
+ * a refresh caused by a change notification (true)
+ */
+ private boolean mLoadPriorityDirectoriesOnly;
+
+ private Context mContext;
+
+ private LoaderManager mLoaderManager;
+
+ private Handler mDelayedDirectorySearchHandler = new Handler() {
+ @Override
+ public void handleMessage(Message msg) {
+ if (msg.what == DIRECTORY_SEARCH_MESSAGE) {
+ loadDirectoryPartition(msg.arg1, (DirectoryPartition) msg.obj);
+ }
+ }
+ };
+ private int defaultVerticalScrollbarPosition;
+
+ protected abstract View inflateView(LayoutInflater inflater, ViewGroup container);
+ protected abstract T createListAdapter();
+
+ /**
+ * @param position Please note that the position is already adjusted for
+ * header views, so "0" means the first list item below header
+ * views.
+ */
+ protected abstract void onItemClick(int position, long id);
+
+ /**
+ * @param position Please note that the position is already adjusted for
+ * header views, so "0" means the first list item below header
+ * views.
+ */
+ protected boolean onItemLongClick(int position, long id) {
+ return false;
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ setContext(activity);
+ setLoaderManager(super.getLoaderManager());
+ }
+
+ /**
+ * Sets a context for the fragment in the unit test environment.
+ */
+ public void setContext(Context context) {
+ mContext = context;
+ configurePhotoLoader();
+ }
+
+ public Context getContext() {
+ return mContext;
+ }
+
+ public void setEnabled(boolean enabled) {
+ if (mEnabled != enabled) {
+ mEnabled = enabled;
+ if (mAdapter != null) {
+ if (mEnabled) {
+ reloadData();
+ } else {
+ mAdapter.clearPartitions();
+ }
+ }
+ }
+ }
+
+ /**
+ * Overrides a loader manager for use in unit tests.
+ */
+ public void setLoaderManager(LoaderManager loaderManager) {
+ mLoaderManager = loaderManager;
+ }
+
+ @Override
+ public LoaderManager getLoaderManager() {
+ return mLoaderManager;
+ }
+
+ public T getAdapter() {
+ return mAdapter;
+ }
+
+ @Override
+ public View getView() {
+ return mView;
+ }
+
+ public ListView getListView() {
+ return mListView;
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putBoolean(KEY_SECTION_HEADER_DISPLAY_ENABLED, mSectionHeaderDisplayEnabled);
+ outState.putBoolean(KEY_PHOTO_LOADER_ENABLED, mPhotoLoaderEnabled);
+ outState.putBoolean(KEY_QUICK_CONTACT_ENABLED, mQuickContactEnabled);
+ outState.putBoolean(KEY_ADJUST_SELECTION_BOUNDS_ENABLED, mAdjustSelectionBoundsEnabled);
+ outState.putBoolean(KEY_SEARCH_MODE, mSearchMode);
+ outState.putBoolean(KEY_DISPLAY_DIRECTORY_HEADER, mDisplayDirectoryHeader);
+ outState.putBoolean(KEY_VISIBLE_SCROLLBAR_ENABLED, mVisibleScrollbarEnabled);
+ outState.putInt(KEY_SCROLLBAR_POSITION, mVerticalScrollbarPosition);
+ outState.putInt(KEY_DIRECTORY_SEARCH_MODE, mDirectorySearchMode);
+ outState.putBoolean(KEY_SELECTION_VISIBLE, mSelectionVisible);
+ outState.putBoolean(KEY_LEGACY_COMPATIBILITY, mLegacyCompatibility);
+ outState.putString(KEY_QUERY_STRING, mQueryString);
+ outState.putInt(KEY_DIRECTORY_RESULT_LIMIT, mDirectoryResultLimit);
+ outState.putBoolean(KEY_DARK_THEME, mDarkTheme);
+ outState.putBoolean(KEY_LOGS_LIST_EVENTS, mLogListEvents);
+ outState.putBoolean(KEY_DATA_LOADED, mDataLoaded);
+
+ if (mListView != null) {
+ outState.putParcelable(KEY_LIST_STATE, mListView.onSaveInstanceState());
+ }
+ }
+
+ @Override
+ public void onCreate(Bundle savedState) {
+ super.onCreate(savedState);
+ restoreSavedState(savedState);
+ mAdapter = createListAdapter();
+ mContactsPrefs = new ContactsPreferences(mContext);
+ }
+
+ public void restoreSavedState(Bundle savedState) {
+ if (savedState == null) {
+ return;
+ }
+
+ mSectionHeaderDisplayEnabled = savedState.getBoolean(KEY_SECTION_HEADER_DISPLAY_ENABLED);
+ mPhotoLoaderEnabled = savedState.getBoolean(KEY_PHOTO_LOADER_ENABLED);
+ mQuickContactEnabled = savedState.getBoolean(KEY_QUICK_CONTACT_ENABLED);
+ mAdjustSelectionBoundsEnabled = savedState.getBoolean(KEY_ADJUST_SELECTION_BOUNDS_ENABLED);
+ mSearchMode = savedState.getBoolean(KEY_SEARCH_MODE);
+ mDisplayDirectoryHeader = savedState.getBoolean(KEY_DISPLAY_DIRECTORY_HEADER);
+ mVisibleScrollbarEnabled = savedState.getBoolean(KEY_VISIBLE_SCROLLBAR_ENABLED);
+ mVerticalScrollbarPosition = savedState.getInt(KEY_SCROLLBAR_POSITION);
+ mDirectorySearchMode = savedState.getInt(KEY_DIRECTORY_SEARCH_MODE);
+ mSelectionVisible = savedState.getBoolean(KEY_SELECTION_VISIBLE);
+ mLegacyCompatibility = savedState.getBoolean(KEY_LEGACY_COMPATIBILITY);
+ mQueryString = savedState.getString(KEY_QUERY_STRING);
+ mDirectoryResultLimit = savedState.getInt(KEY_DIRECTORY_RESULT_LIMIT);
+ mDarkTheme = savedState.getBoolean(KEY_DARK_THEME);
+
+ // Retrieve list state. This will be applied in onLoadFinished
+ mListState = savedState.getParcelable(KEY_LIST_STATE);
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+
+ mContactsPrefs.registerChangeListener(mPreferencesChangeListener);
+
+ mForceLoad = loadPreferences();
+
+ mDirectoryListStatus = STATUS_NOT_LOADED;
+ mLoadPriorityDirectoriesOnly = true;
+
+ startLoading();
+ }
+
+ protected void startLoading() {
+ if (mAdapter == null) {
+ // The method was called before the fragment was started
+ return;
+ }
+
+ configureAdapter();
+ int partitionCount = mAdapter.getPartitionCount();
+ for (int i = 0; i < partitionCount; i++) {
+ Partition partition = mAdapter.getPartition(i);
+ if (partition instanceof DirectoryPartition) {
+ DirectoryPartition directoryPartition = (DirectoryPartition)partition;
+ if (directoryPartition.getStatus() == DirectoryPartition.STATUS_NOT_LOADED) {
+ if (directoryPartition.isPriorityDirectory() || !mLoadPriorityDirectoriesOnly) {
+ startLoadingDirectoryPartition(i);
+ }
+ }
+ } else {
+ getLoaderManager().initLoader(i, null, this);
+ }
+ }
+
+ // Next time this method is called, we should start loading non-priority directories
+ mLoadPriorityDirectoriesOnly = false;
+ }
+
+ @Override
+ public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+ if (id == DIRECTORY_LOADER_ID) {
+ DirectoryListLoader loader = new DirectoryListLoader(mContext);
+ loader.setDirectorySearchMode(mAdapter.getDirectorySearchMode());
+ loader.setLocalInvisibleDirectoryEnabled(
+ ContactEntryListAdapter.LOCAL_INVISIBLE_DIRECTORY_ENABLED);
+ return loader;
+ } else {
+ CursorLoader loader = createCursorLoader(mContext);
+ long directoryId = args != null && args.containsKey(DIRECTORY_ID_ARG_KEY)
+ ? args.getLong(DIRECTORY_ID_ARG_KEY)
+ : Directory.DEFAULT;
+ mAdapter.configureLoader(loader, directoryId);
+ return loader;
+ }
+ }
+
+ public CursorLoader createCursorLoader(Context context) {
+ return new CursorLoader(context, null, null, null, null, null) {
+ @Override
+ protected Cursor onLoadInBackground() {
+ try {
+ return super.onLoadInBackground();
+ } catch (RuntimeException e) {
+ // We don't even know what the projection should be, so no point trying to
+ // return an empty MatrixCursor with the correct projection here.
+ Log.w(TAG, "RuntimeException while trying to query ContactsProvider.");
+ return null;
+ }
+ }
+ };
+ }
+
+ private void startLoadingDirectoryPartition(int partitionIndex) {
+ DirectoryPartition partition = (DirectoryPartition)mAdapter.getPartition(partitionIndex);
+ partition.setStatus(DirectoryPartition.STATUS_LOADING);
+ long directoryId = partition.getDirectoryId();
+ if (mForceLoad) {
+ if (directoryId == Directory.DEFAULT) {
+ loadDirectoryPartition(partitionIndex, partition);
+ } else {
+ loadDirectoryPartitionDelayed(partitionIndex, partition);
+ }
+ } else {
+ Bundle args = new Bundle();
+ args.putLong(DIRECTORY_ID_ARG_KEY, directoryId);
+ getLoaderManager().initLoader(partitionIndex, args, this);
+ }
+ }
+
+ /**
+ * Queues up a delayed request to search the specified directory. Since
+ * directory search will likely introduce a lot of network traffic, we want
+ * to wait for a pause in the user's typing before sending a directory request.
+ */
+ private void loadDirectoryPartitionDelayed(int partitionIndex, DirectoryPartition partition) {
+ mDelayedDirectorySearchHandler.removeMessages(DIRECTORY_SEARCH_MESSAGE, partition);
+ Message msg = mDelayedDirectorySearchHandler.obtainMessage(
+ DIRECTORY_SEARCH_MESSAGE, partitionIndex, 0, partition);
+ mDelayedDirectorySearchHandler.sendMessageDelayed(msg, DIRECTORY_SEARCH_DELAY_MILLIS);
+ }
+
+ /**
+ * Loads the directory partition.
+ */
+ protected void loadDirectoryPartition(int partitionIndex, DirectoryPartition partition) {
+ Bundle args = new Bundle();
+ args.putLong(DIRECTORY_ID_ARG_KEY, partition.getDirectoryId());
+ getLoaderManager().restartLoader(partitionIndex, args, this);
+ }
+
+ /**
+ * Cancels all queued directory loading requests.
+ */
+ private void removePendingDirectorySearchRequests() {
+ mDelayedDirectorySearchHandler.removeMessages(DIRECTORY_SEARCH_MESSAGE);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
+ if (!mEnabled) {
+ return;
+ }
+
+ getListView().setVisibility(View.VISIBLE);
+ getView().setVisibility(View.VISIBLE);
+
+ int loaderId = loader.getId();
+ if (loaderId == DIRECTORY_LOADER_ID) {
+ mDirectoryListStatus = STATUS_LOADED;
+ mAdapter.changeDirectories(data);
+ startLoading();
+ } else {
+ onPartitionLoaded(loaderId, data);
+ if (isSearchMode()) {
+ int directorySearchMode = getDirectorySearchMode();
+ if (directorySearchMode != DirectoryListLoader.SEARCH_MODE_NONE) {
+ if (mDirectoryListStatus == STATUS_NOT_LOADED) {
+ mDirectoryListStatus = STATUS_LOADING;
+ getLoaderManager().initLoader(DIRECTORY_LOADER_ID, null, this);
+ } else {
+ startLoading();
+ }
+ }
+ } else {
+ maybeLogListEvent();
+ mDirectoryListStatus = STATUS_NOT_LOADED;
+ getLoaderManager().destroyLoader(DIRECTORY_LOADER_ID);
+ }
+ }
+ }
+
+ protected void maybeLogListEvent() {
+ if (!mDataLoaded || mLogListEvents) {
+ Logger.logListEvent(ActionType.LOAD, getListType(), getAdapter().getCount(),
+ /* clickedIndex */ -1, /* numSelected */ 0);
+ mLogListEvents = false;
+ mDataLoaded = true;
+ }
+ }
+
+ public void onLoaderReset(Loader<Cursor> loader) {
+ }
+
+ protected void onPartitionLoaded(int partitionIndex, Cursor data) {
+ if (partitionIndex >= mAdapter.getPartitionCount()) {
+ // When we get unsolicited data, ignore it. This could happen
+ // when we are switching from search mode to the default mode.
+ return;
+ }
+
+ mAdapter.changeCursor(partitionIndex, data);
+ setListHeader();
+
+ if (!isLoading()) {
+ completeRestoreInstanceState();
+ }
+ }
+
+ public boolean isLoading() {
+ if (mAdapter != null && mAdapter.isLoading()) {
+ return true;
+ }
+
+ if (isLoadingDirectoryList()) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public boolean isLoadingDirectoryList() {
+ return isSearchMode() && getDirectorySearchMode() != DirectoryListLoader.SEARCH_MODE_NONE
+ && (mDirectoryListStatus == STATUS_NOT_LOADED
+ || mDirectoryListStatus == STATUS_LOADING);
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ mContactsPrefs.unregisterChangeListener();
+ mAdapter.clearPartitions();
+ }
+
+ protected void reloadData() {
+ removePendingDirectorySearchRequests();
+ mAdapter.onDataReload();
+ mLoadPriorityDirectoriesOnly = true;
+ mForceLoad = true;
+ startLoading();
+ }
+
+ /**
+ * Shows a view at the top of the list.
+ */
+ protected void setListHeader() {}
+
+ /**
+ * Provides logic that dismisses this fragment. The default implementation
+ * does nothing.
+ */
+ protected void finish() {
+ }
+
+ public void setSectionHeaderDisplayEnabled(boolean flag) {
+ if (mSectionHeaderDisplayEnabled != flag) {
+ mSectionHeaderDisplayEnabled = flag;
+ if (mAdapter != null) {
+ mAdapter.setSectionHeaderDisplayEnabled(flag);
+ }
+ configureVerticalScrollbar();
+ }
+ }
+
+ public boolean isSectionHeaderDisplayEnabled() {
+ return mSectionHeaderDisplayEnabled;
+ }
+
+ public void setVisibleScrollbarEnabled(boolean flag) {
+ if (mVisibleScrollbarEnabled != flag) {
+ mVisibleScrollbarEnabled = flag;
+ configureVerticalScrollbar();
+ }
+ }
+
+ public boolean isVisibleScrollbarEnabled() {
+ return mVisibleScrollbarEnabled;
+ }
+
+ public void setVerticalScrollbarPosition(int position) {
+ if (mVerticalScrollbarPosition != position) {
+ mVerticalScrollbarPosition = position;
+ configureVerticalScrollbar();
+ }
+ }
+
+ private void configureVerticalScrollbar() {
+ boolean hasScrollbar = isVisibleScrollbarEnabled() && isSectionHeaderDisplayEnabled();
+
+ if (mListView != null) {
+ mListView.setFastScrollEnabled(hasScrollbar);
+ mListView.setVerticalScrollbarPosition(mVerticalScrollbarPosition);
+ mListView.setScrollBarStyle(ListView.SCROLLBARS_OUTSIDE_OVERLAY);
+ }
+ }
+
+ public void setPhotoLoaderEnabled(boolean flag) {
+ mPhotoLoaderEnabled = flag;
+ configurePhotoLoader();
+ }
+
+ public boolean isPhotoLoaderEnabled() {
+ return mPhotoLoaderEnabled;
+ }
+
+ /**
+ * Returns true if the list is supposed to visually highlight the selected item.
+ */
+ public boolean isSelectionVisible() {
+ return mSelectionVisible;
+ }
+
+ public void setSelectionVisible(boolean flag) {
+ this.mSelectionVisible = flag;
+ }
+
+ public void setQuickContactEnabled(boolean flag) {
+ this.mQuickContactEnabled = flag;
+ }
+
+ public void setAdjustSelectionBoundsEnabled(boolean flag) {
+ mAdjustSelectionBoundsEnabled = flag;
+ }
+
+ public void setIncludeFavorites(boolean flag) {
+ mIncludeFavorites = flag;
+ if (mAdapter != null) {
+ mAdapter.setIncludeFavorites(flag);
+ }
+ }
+
+ public void setDisplayDirectoryHeader(boolean flag) {
+ mDisplayDirectoryHeader = flag;
+ }
+
+ /**
+ * Enter/exit search mode. This is method is tightly related to the current query, and should
+ * only be called by {@link #setQueryString}.
+ *
+ * Also note this method doesn't call {@link #reloadData()}; {@link #setQueryString} does it.
+ */
+ protected void setSearchMode(boolean flag) {
+ if (mSearchMode != flag) {
+ mSearchMode = flag;
+ setSectionHeaderDisplayEnabled(!mSearchMode);
+
+ if (!flag) {
+ mDirectoryListStatus = STATUS_NOT_LOADED;
+ getLoaderManager().destroyLoader(DIRECTORY_LOADER_ID);
+ }
+
+ if (mAdapter != null) {
+ mAdapter.setSearchMode(flag);
+
+ mAdapter.clearPartitions();
+ if (!flag) {
+ // If we are switching from search to regular display, remove all directory
+ // partitions after default one, assuming they are remote directories which
+ // should be cleaned up on exiting the search mode.
+ mAdapter.removeDirectoriesAfterDefault();
+ }
+ mAdapter.configureDefaultPartition(false, shouldDisplayDirectoryHeader());
+ }
+
+ if (mListView != null) {
+ mListView.setFastScrollEnabled(!flag);
+ }
+ }
+ }
+
+ /**
+ * When not in search mode, directory header should always be hidden.
+ * When in search mode, directory header should be displayed when mDisplayDirectoryHeader is
+ * set to true. (mDisplayDirectoryHeader default value is true)
+ */
+ private boolean shouldDisplayDirectoryHeader() {
+ if (!mSearchMode) {
+ return false;
+ }
+ return mDisplayDirectoryHeader;
+ }
+
+ public final boolean isSearchMode() {
+ return mSearchMode;
+ }
+
+ public final String getQueryString() {
+ return mQueryString;
+ }
+
+ // TODO: the paramter delaySelection is not in use, and let's remove it.
+ public void setQueryString(String queryString, boolean delaySelection) {
+ if (!TextUtils.equals(mQueryString, queryString)) {
+ if (mShowEmptyListForEmptyQuery && mAdapter != null && mListView != null) {
+ if (TextUtils.isEmpty(mQueryString)) {
+ // Restore the adapter if the query used to be empty.
+ mListView.setAdapter(mAdapter);
+ } else if (TextUtils.isEmpty(queryString)) {
+ // Instantly clear the list view if the new query is empty.
+ mListView.setAdapter(null);
+ }
+ }
+
+ mQueryString = queryString;
+ setSearchMode(!TextUtils.isEmpty(mQueryString) || mShowEmptyListForEmptyQuery);
+
+ if (mAdapter != null) {
+ mAdapter.setQueryString(queryString);
+ reloadData();
+ }
+ }
+ }
+
+ public void setShowEmptyListForNullQuery(boolean show) {
+ mShowEmptyListForEmptyQuery = show;
+ }
+
+ public int getDirectoryLoaderId() {
+ return DIRECTORY_LOADER_ID;
+ }
+
+ public int getDirectorySearchMode() {
+ return mDirectorySearchMode;
+ }
+
+ public void setDirectorySearchMode(int mode) {
+ mDirectorySearchMode = mode;
+ }
+
+ public boolean isLegacyCompatibilityMode() {
+ return mLegacyCompatibility;
+ }
+
+ public void setLegacyCompatibilityMode(boolean flag) {
+ mLegacyCompatibility = flag;
+ }
+
+ protected int getContactNameDisplayOrder() {
+ return mDisplayOrder;
+ }
+
+ protected void setContactNameDisplayOrder(int displayOrder) {
+ mDisplayOrder = displayOrder;
+ if (mAdapter != null) {
+ mAdapter.setContactNameDisplayOrder(displayOrder);
+ }
+ }
+
+ public int getSortOrder() {
+ return mSortOrder;
+ }
+
+ public void setSortOrder(int sortOrder) {
+ mSortOrder = sortOrder;
+ if (mAdapter != null) {
+ mAdapter.setSortOrder(sortOrder);
+ }
+ }
+
+ public void setDirectoryResultLimit(int limit) {
+ mDirectoryResultLimit = limit;
+ }
+
+ protected boolean loadPreferences() {
+ boolean changed = false;
+ if (getContactNameDisplayOrder() != mContactsPrefs.getDisplayOrder()) {
+ setContactNameDisplayOrder(mContactsPrefs.getDisplayOrder());
+ changed = true;
+ }
+
+ if (getSortOrder() != mContactsPrefs.getSortOrder()) {
+ setSortOrder(mContactsPrefs.getSortOrder());
+ changed = true;
+ }
+
+ return changed;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ onCreateView(inflater, container);
+
+ boolean searchMode = isSearchMode();
+ mAdapter.setSearchMode(searchMode);
+ mAdapter.configureDefaultPartition(false, shouldDisplayDirectoryHeader());
+ mAdapter.setPhotoLoader(mPhotoManager);
+ mListView.setAdapter(mAdapter);
+
+ if (!isSearchMode()) {
+ mListView.setFocusableInTouchMode(true);
+ mListView.requestFocus();
+ }
+
+ if (savedInstanceState != null) {
+ mLogListEvents = savedInstanceState.getBoolean(KEY_LOGS_LIST_EVENTS, true);
+ mDataLoaded = savedInstanceState.getBoolean(KEY_DATA_LOADED, false);
+ }
+
+ return mView;
+ }
+
+ protected void onCreateView(LayoutInflater inflater, ViewGroup container) {
+ mView = inflateView(inflater, container);
+
+ mListView = (ListView)mView.findViewById(android.R.id.list);
+ if (mListView == null) {
+ throw new RuntimeException(
+ "Your content must have a ListView whose id attribute is " +
+ "'android.R.id.list'");
+ }
+
+ View emptyView = mView.findViewById(android.R.id.empty);
+ if (emptyView != null) {
+ mListView.setEmptyView(emptyView);
+ }
+
+ mListView.setOnItemClickListener(this);
+ mListView.setOnItemLongClickListener(this);
+ mListView.setOnFocusChangeListener(this);
+ mListView.setOnTouchListener(this);
+ mListView.setFastScrollEnabled(!isSearchMode());
+
+ // Tell list view to not show dividers. We'll do it ourself so that we can *not* show
+ // them when an A-Z headers is visible.
+ mListView.setDividerHeight(0);
+
+ // We manually save/restore the listview state
+ mListView.setSaveEnabled(false);
+
+ configureVerticalScrollbar();
+ configurePhotoLoader();
+
+ getAdapter().setFragmentRootView(getView());
+ }
+
+ protected void configurePhotoLoader() {
+ if (isPhotoLoaderEnabled() && mContext != null) {
+ if (mPhotoManager == null) {
+ mPhotoManager = ContactPhotoManager.getInstance(mContext);
+ }
+ if (mListView != null) {
+ mListView.setOnScrollListener(this);
+ }
+ if (mAdapter != null) {
+ mAdapter.setPhotoLoader(mPhotoManager);
+ }
+ }
+ }
+
+ protected void configureAdapter() {
+ if (mAdapter == null) {
+ return;
+ }
+
+ mAdapter.setQuickContactEnabled(mQuickContactEnabled);
+ mAdapter.setAdjustSelectionBoundsEnabled(mAdjustSelectionBoundsEnabled);
+ mAdapter.setIncludeFavorites(mIncludeFavorites);
+ mAdapter.setQueryString(mQueryString);
+ mAdapter.setDirectorySearchMode(mDirectorySearchMode);
+ mAdapter.setPinnedPartitionHeadersEnabled(false);
+ mAdapter.setContactNameDisplayOrder(mDisplayOrder);
+ mAdapter.setSortOrder(mSortOrder);
+ mAdapter.setSectionHeaderDisplayEnabled(mSectionHeaderDisplayEnabled);
+ mAdapter.setSelectionVisible(mSelectionVisible);
+ mAdapter.setDirectoryResultLimit(mDirectoryResultLimit);
+ mAdapter.setDarkTheme(mDarkTheme);
+ }
+
+ @Override
+ public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
+ int totalItemCount) {
+ }
+
+ @Override
+ public void onScrollStateChanged(AbsListView view, int scrollState) {
+ if (scrollState == OnScrollListener.SCROLL_STATE_FLING) {
+ mPhotoManager.pause();
+ } else if (isPhotoLoaderEnabled()) {
+ mPhotoManager.resume();
+ }
+ }
+
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ hideSoftKeyboard();
+
+ int adjPosition = position - mListView.getHeaderViewsCount();
+ if (adjPosition >= 0) {
+ onItemClick(adjPosition, id);
+ }
+ }
+
+ @Override
+ public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
+ int adjPosition = position - mListView.getHeaderViewsCount();
+
+ if (adjPosition >= 0) {
+ return onItemLongClick(adjPosition, id);
+ }
+ return false;
+ }
+
+ private void hideSoftKeyboard() {
+ // Hide soft keyboard, if visible
+ InputMethodManager inputMethodManager = (InputMethodManager)
+ mContext.getSystemService(Context.INPUT_METHOD_SERVICE);
+ inputMethodManager.hideSoftInputFromWindow(mListView.getWindowToken(), 0);
+ }
+
+ /**
+ * Dismisses the soft keyboard when the list takes focus.
+ */
+ @Override
+ public void onFocusChange(View view, boolean hasFocus) {
+ if (view == mListView && hasFocus) {
+ hideSoftKeyboard();
+ }
+ }
+
+ /**
+ * Dismisses the soft keyboard when the list is touched.
+ */
+ @Override
+ public boolean onTouch(View view, MotionEvent event) {
+ if (view == mListView) {
+ hideSoftKeyboard();
+ }
+ return false;
+ }
+
+ @Override
+ public void onPause() {
+ // Save the scrolling state of the list view
+ mListViewTopIndex = mListView.getFirstVisiblePosition();
+ View v = mListView.getChildAt(0);
+ mListViewTopOffset = (v == null) ? 0 : (v.getTop() - mListView.getPaddingTop());
+
+ super.onPause();
+ removePendingDirectorySearchRequests();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ // Restore the selection of the list view. See b/19982820.
+ // This has to be done manually because if the list view has its emptyView set,
+ // the scrolling state will be reset when clearPartitions() is called on the adapter.
+ mListView.setSelectionFromTop(mListViewTopIndex, mListViewTopOffset);
+ }
+
+ /**
+ * Restore the list state after the adapter is populated.
+ */
+ protected void completeRestoreInstanceState() {
+ if (mListState != null) {
+ mListView.onRestoreInstanceState(mListState);
+ mListState = null;
+ }
+ }
+
+ public void setDarkTheme(boolean value) {
+ mDarkTheme = value;
+ if (mAdapter != null) mAdapter.setDarkTheme(value);
+ }
+
+ /**
+ * Processes a result returned by the contact picker.
+ */
+ public void onPickerResult(Intent data) {
+ throw new UnsupportedOperationException("Picker result handler is not implemented.");
+ }
+
+ private ContactsPreferences.ChangeListener mPreferencesChangeListener =
+ new ContactsPreferences.ChangeListener() {
+ @Override
+ public void onChange() {
+ loadPreferences();
+ reloadData();
+ }
+ };
+
+ private int getDefaultVerticalScrollbarPosition() {
+ final Locale locale = Locale.getDefault();
+ final int layoutDirection = TextUtils.getLayoutDirectionFromLocale(locale);
+ switch (layoutDirection) {
+ case View.LAYOUT_DIRECTION_RTL:
+ return View.SCROLLBAR_POSITION_LEFT;
+ case View.LAYOUT_DIRECTION_LTR:
+ default:
+ return View.SCROLLBAR_POSITION_RIGHT;
+ }
+ }
+
+ public void setListType(int listType) {
+ mListType = listType;
+ }
+
+ public int getListType() {
+ return mListType;
+ }
+
+ public void setLogListEvents(boolean logListEvents) {
+ mLogListEvents = logListEvents;
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactListAdapter.java b/src/com/android/contacts/common/list/ContactListAdapter.java
new file mode 100644
index 0000000..677aa46
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactListAdapter.java
@@ -0,0 +1,444 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Directory;
+import android.provider.ContactsContract.SearchSnippets;
+import android.text.TextUtils;
+import android.view.ViewGroup;
+import android.widget.ListView;
+
+import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest;
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.ContactsCompat;
+import com.android.contacts.common.preference.ContactsPreferences;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * A cursor adapter for the {@link ContactsContract.Contacts#CONTENT_TYPE} content type.
+ */
+public abstract class ContactListAdapter extends MultiSelectEntryContactListAdapter {
+
+ public static class ContactQuery {
+ private static final String[] CONTACT_PROJECTION_PRIMARY = new String[] {
+ Contacts._ID, // 0
+ Contacts.DISPLAY_NAME_PRIMARY, // 1
+ Contacts.CONTACT_PRESENCE, // 2
+ Contacts.CONTACT_STATUS, // 3
+ Contacts.PHOTO_ID, // 4
+ Contacts.PHOTO_THUMBNAIL_URI, // 5
+ Contacts.LOOKUP_KEY, // 6
+ Contacts.PHONETIC_NAME, // 7
+ Contacts.STARRED, // 9
+ };
+
+ private static final String[] CONTACT_PROJECTION_ALTERNATIVE = new String[] {
+ Contacts._ID, // 0
+ Contacts.DISPLAY_NAME_ALTERNATIVE, // 1
+ Contacts.CONTACT_PRESENCE, // 2
+ Contacts.CONTACT_STATUS, // 3
+ Contacts.PHOTO_ID, // 4
+ Contacts.PHOTO_THUMBNAIL_URI, // 5
+ Contacts.LOOKUP_KEY, // 6
+ Contacts.PHONETIC_NAME, // 7
+ Contacts.STARRED, // 8
+ };
+
+ private static final String[] FILTER_PROJECTION_PRIMARY = new String[] {
+ Contacts._ID, // 0
+ Contacts.DISPLAY_NAME_PRIMARY, // 1
+ Contacts.CONTACT_PRESENCE, // 2
+ Contacts.CONTACT_STATUS, // 3
+ Contacts.PHOTO_ID, // 4
+ Contacts.PHOTO_THUMBNAIL_URI, // 5
+ Contacts.LOOKUP_KEY, // 6
+ Contacts.PHONETIC_NAME, // 7
+ Contacts.STARRED, // 8
+ SearchSnippets.SNIPPET, // 9
+ };
+
+ private static final String[] FILTER_PROJECTION_ALTERNATIVE = new String[] {
+ Contacts._ID, // 0
+ Contacts.DISPLAY_NAME_ALTERNATIVE, // 1
+ Contacts.CONTACT_PRESENCE, // 2
+ Contacts.CONTACT_STATUS, // 3
+ Contacts.PHOTO_ID, // 4
+ Contacts.PHOTO_THUMBNAIL_URI, // 5
+ Contacts.LOOKUP_KEY, // 6
+ Contacts.PHONETIC_NAME, // 7
+ Contacts.STARRED, // 8
+ SearchSnippets.SNIPPET, // 9
+ };
+
+ public static final int CONTACT_ID = 0;
+ public static final int CONTACT_DISPLAY_NAME = 1;
+ public static final int CONTACT_PRESENCE_STATUS = 2;
+ public static final int CONTACT_CONTACT_STATUS = 3;
+ public static final int CONTACT_PHOTO_ID = 4;
+ public static final int CONTACT_PHOTO_URI = 5;
+ public static final int CONTACT_LOOKUP_KEY = 6;
+ public static final int CONTACT_PHONETIC_NAME = 7;
+ public static final int CONTACT_STARRED = 8;
+ public static final int CONTACT_SNIPPET = 9;
+ }
+
+ private CharSequence mUnknownNameText;
+
+ private long mSelectedContactDirectoryId;
+ private String mSelectedContactLookupKey;
+ private long mSelectedContactId;
+ private ContactListItemView.PhotoPosition mPhotoPosition;
+
+ public ContactListAdapter(Context context) {
+ super(context, ContactQuery.CONTACT_ID);
+
+ mUnknownNameText = context.getText(R.string.missing_name);
+ }
+
+ public void setPhotoPosition(ContactListItemView.PhotoPosition photoPosition) {
+ mPhotoPosition = photoPosition;
+ }
+
+ public ContactListItemView.PhotoPosition getPhotoPosition() {
+ return mPhotoPosition;
+ }
+
+ public CharSequence getUnknownNameText() {
+ return mUnknownNameText;
+ }
+
+ public long getSelectedContactDirectoryId() {
+ return mSelectedContactDirectoryId;
+ }
+
+ public String getSelectedContactLookupKey() {
+ return mSelectedContactLookupKey;
+ }
+
+ public long getSelectedContactId() {
+ return mSelectedContactId;
+ }
+
+ public void setSelectedContact(long selectedDirectoryId, String lookupKey, long contactId) {
+ mSelectedContactDirectoryId = selectedDirectoryId;
+ mSelectedContactLookupKey = lookupKey;
+ mSelectedContactId = contactId;
+ }
+
+ protected static Uri buildSectionIndexerUri(Uri uri) {
+ return uri.buildUpon()
+ .appendQueryParameter(Contacts.EXTRA_ADDRESS_BOOK_INDEX, "true").build();
+ }
+
+ @Override
+ public String getContactDisplayName(int position) {
+ return ((Cursor) getItem(position)).getString(ContactQuery.CONTACT_DISPLAY_NAME);
+ }
+
+ /**
+ * Builds the {@link Contacts#CONTENT_LOOKUP_URI} for the given
+ * {@link ListView} position.
+ */
+ public Uri getContactUri(int position) {
+ int partitionIndex = getPartitionForPosition(position);
+ Cursor item = (Cursor)getItem(position);
+ return item != null ? getContactUri(partitionIndex, item) : null;
+ }
+
+ public Uri getContactUri(int partitionIndex, Cursor cursor) {
+ long contactId = cursor.getLong(ContactQuery.CONTACT_ID);
+ String lookupKey = cursor.getString(ContactQuery.CONTACT_LOOKUP_KEY);
+ Uri uri = Contacts.getLookupUri(contactId, lookupKey);
+ long directoryId = ((DirectoryPartition)getPartition(partitionIndex)).getDirectoryId();
+ if (uri != null && directoryId != Directory.DEFAULT) {
+ uri = uri.buildUpon().appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)).build();
+ }
+ return uri;
+ }
+
+ /**
+ * Returns the {@link Contacts#_ID} for the given {@link ListView} position.
+ */
+ public long getContactId(int position) {
+ final Cursor cursor = (Cursor) getItem(position);
+ return cursor == null ? -1 : cursor.getLong(ContactQuery.CONTACT_ID);
+ }
+
+ public boolean isEnterpriseContact(int position) {
+ final Cursor cursor = (Cursor) getItem(position);
+ if (cursor != null) {
+ final long contactId = cursor.getLong(ContactQuery.CONTACT_ID);
+ return ContactsCompat.isEnterpriseContactId(contactId);
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if the specified contact is selected in the list. For a
+ * contact to be shown as selected, we need both the directory and and the
+ * lookup key to be the same. We are paying no attention to the contactId,
+ * because it is volatile, especially in the case of directories.
+ */
+ public boolean isSelectedContact(int partitionIndex, Cursor cursor) {
+ long directoryId = ((DirectoryPartition)getPartition(partitionIndex)).getDirectoryId();
+ if (getSelectedContactDirectoryId() != directoryId) {
+ return false;
+ }
+ String lookupKey = getSelectedContactLookupKey();
+ if (lookupKey != null && TextUtils.equals(lookupKey,
+ cursor.getString(ContactQuery.CONTACT_LOOKUP_KEY))) {
+ return true;
+ }
+
+ return directoryId != Directory.DEFAULT && directoryId != Directory.LOCAL_INVISIBLE
+ && getSelectedContactId() == cursor.getLong(ContactQuery.CONTACT_ID);
+ }
+
+ @Override
+ protected ContactListItemView newView(
+ Context context, int partition, Cursor cursor, int position, ViewGroup parent) {
+ ContactListItemView view = super.newView(context, partition, cursor, position, parent);
+ view.setUnknownNameText(mUnknownNameText);
+ view.setQuickContactEnabled(isQuickContactEnabled());
+ view.setAdjustSelectionBoundsEnabled(isAdjustSelectionBoundsEnabled());
+ view.setActivatedStateSupported(isSelectionVisible());
+ if (mPhotoPosition != null) {
+ view.setPhotoPosition(mPhotoPosition);
+ }
+ return view;
+ }
+
+ protected void bindSectionHeaderAndDivider(ContactListItemView view, int position,
+ Cursor cursor) {
+ view.setIsSectionHeaderEnabled(isSectionHeaderDisplayEnabled());
+ if (isSectionHeaderDisplayEnabled()) {
+ Placement placement = getItemPlacementInSection(position);
+ view.setSectionHeader(placement.sectionHeader);
+ } else {
+ view.setSectionHeader(null);
+ }
+ }
+
+ protected void bindPhoto(final ContactListItemView view, int partitionIndex, Cursor cursor) {
+ if (!isPhotoSupported(partitionIndex)) {
+ view.removePhotoView();
+ return;
+ }
+
+ // Set the photo, if available
+ long photoId = 0;
+ if (!cursor.isNull(ContactQuery.CONTACT_PHOTO_ID)) {
+ photoId = cursor.getLong(ContactQuery.CONTACT_PHOTO_ID);
+ }
+
+ if (photoId != 0) {
+ getPhotoLoader().loadThumbnail(view.getPhotoView(), photoId, false,
+ getCircularPhotos(), null);
+ } else {
+ final String photoUriString = cursor.getString(ContactQuery.CONTACT_PHOTO_URI);
+ final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString);
+ DefaultImageRequest request = null;
+ if (photoUri == null) {
+ request = getDefaultImageRequestFromCursor(cursor,
+ ContactQuery.CONTACT_DISPLAY_NAME,
+ ContactQuery.CONTACT_LOOKUP_KEY);
+ }
+ getPhotoLoader().loadDirectoryPhoto(view.getPhotoView(), photoUri, false,
+ getCircularPhotos(), request);
+ }
+ }
+
+ protected void bindNameAndViewId(final ContactListItemView view, Cursor cursor) {
+ view.showDisplayName(
+ cursor, ContactQuery.CONTACT_DISPLAY_NAME, getContactNameDisplayOrder());
+ // Note: we don't show phonetic any more (See issue 5265330)
+
+ bindViewId(view, cursor, ContactQuery.CONTACT_ID);
+ }
+
+ protected void bindPresenceAndStatusMessage(final ContactListItemView view, Cursor cursor) {
+ view.showPresenceAndStatusMessage(cursor, ContactQuery.CONTACT_PRESENCE_STATUS,
+ ContactQuery.CONTACT_CONTACT_STATUS);
+ }
+
+ protected void bindSearchSnippet(final ContactListItemView view, Cursor cursor) {
+ view.showSnippet(cursor, ContactQuery.CONTACT_SNIPPET);
+ }
+
+ public int getSelectedContactPosition() {
+ if (mSelectedContactLookupKey == null && mSelectedContactId == 0) {
+ return -1;
+ }
+
+ Cursor cursor = null;
+ int partitionIndex = -1;
+ int partitionCount = getPartitionCount();
+ for (int i = 0; i < partitionCount; i++) {
+ DirectoryPartition partition = (DirectoryPartition) getPartition(i);
+ if (partition.getDirectoryId() == mSelectedContactDirectoryId) {
+ partitionIndex = i;
+ break;
+ }
+ }
+ if (partitionIndex == -1) {
+ return -1;
+ }
+
+ cursor = getCursor(partitionIndex);
+ if (cursor == null) {
+ return -1;
+ }
+
+ cursor.moveToPosition(-1); // Reset cursor
+ int offset = -1;
+ while (cursor.moveToNext()) {
+ if (mSelectedContactLookupKey != null) {
+ String lookupKey = cursor.getString(ContactQuery.CONTACT_LOOKUP_KEY);
+ if (mSelectedContactLookupKey.equals(lookupKey)) {
+ offset = cursor.getPosition();
+ break;
+ }
+ }
+ if (mSelectedContactId != 0 && (mSelectedContactDirectoryId == Directory.DEFAULT
+ || mSelectedContactDirectoryId == Directory.LOCAL_INVISIBLE)) {
+ long contactId = cursor.getLong(ContactQuery.CONTACT_ID);
+ if (contactId == mSelectedContactId) {
+ offset = cursor.getPosition();
+ break;
+ }
+ }
+ }
+ if (offset == -1) {
+ return -1;
+ }
+
+ int position = getPositionForPartition(partitionIndex) + offset;
+ if (hasHeader(partitionIndex)) {
+ position++;
+ }
+ return position;
+ }
+
+ public boolean hasValidSelection() {
+ return getSelectedContactPosition() != -1;
+ }
+
+ public Uri getFirstContactUri() {
+ int partitionCount = getPartitionCount();
+ for (int i = 0; i < partitionCount; i++) {
+ DirectoryPartition partition = (DirectoryPartition) getPartition(i);
+ if (partition.isLoading()) {
+ continue;
+ }
+
+ Cursor cursor = getCursor(i);
+ if (cursor == null) {
+ continue;
+ }
+
+ if (!cursor.moveToFirst()) {
+ continue;
+ }
+
+ return getContactUri(i, cursor);
+ }
+
+ return null;
+ }
+
+ @Override
+ public void changeCursor(int partitionIndex, Cursor cursor) {
+ super.changeCursor(partitionIndex, cursor);
+
+ if (cursor == null || !cursor.moveToFirst()) {
+ return;
+ }
+
+ if (shouldIncludeFavorites()) {
+ if (cursor.getInt(ContactQuery.CONTACT_STARRED) == 1) {
+ final Set<Integer> favorites = new HashSet<>();
+ favorites.add(cursor.getInt(ContactQuery.CONTACT_ID));
+ while (cursor != null && cursor.moveToNext()) {
+ if (cursor.getInt(ContactQuery.CONTACT_STARRED) != 1
+ || favorites.contains(cursor.getInt(ContactQuery.CONTACT_ID))) {
+ break;
+ }
+ favorites.add(cursor.getInt(ContactQuery.CONTACT_ID));
+ }
+ setFavoritesSectionHeader(favorites.size());
+ }
+ }
+ }
+
+ /**
+ * @return Projection useful for children.
+ */
+ protected final String[] getProjection(boolean forSearch) {
+ final int sortOrder = getContactNameDisplayOrder();
+ if (forSearch) {
+ if (sortOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY) {
+ return ContactQuery.FILTER_PROJECTION_PRIMARY;
+ } else {
+ return ContactQuery.FILTER_PROJECTION_ALTERNATIVE;
+ }
+ } else {
+ if (sortOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY) {
+ return ContactQuery.CONTACT_PROJECTION_PRIMARY;
+ } else {
+ return ContactQuery.CONTACT_PROJECTION_ALTERNATIVE;
+ }
+ }
+ }
+
+ /**
+ * @return Projection from Data that is useful for children.
+ */
+ protected final String[] getDataProjectionForContacts(boolean forSearch) {
+ final int sortOrder = getContactNameDisplayOrder();
+ if (forSearch) {
+ if (sortOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY) {
+ return replaceFirstString(ContactQuery.FILTER_PROJECTION_PRIMARY);
+ } else {
+ return replaceFirstString(ContactQuery.FILTER_PROJECTION_ALTERNATIVE);
+ }
+ } else {
+ if (sortOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY) {
+ return replaceFirstString(ContactQuery.CONTACT_PROJECTION_PRIMARY);
+ } else {
+ return replaceFirstString(ContactQuery.CONTACT_PROJECTION_ALTERNATIVE);
+ }
+ }
+ }
+
+ /**
+ * @param sourceProjection
+ * @return Replace the first String of sourceProjection with Data.CONTACT_ID.
+ */
+ private String[] replaceFirstString(String[] sourceProjection) {
+ String[] result = sourceProjection.clone();
+ result[0] = Data.CONTACT_ID;
+ return result;
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactListFilter.java b/src/com/android/contacts/common/list/ContactListFilter.java
new file mode 100644
index 0000000..6d60a82
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactListFilter.java
@@ -0,0 +1,367 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.content.SharedPreferences;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.ContactsContract.RawContacts;
+import android.text.TextUtils;
+
+import com.android.contacts.common.logging.ListEvent;
+
+/**
+ * Contact list filter parameters.
+ */
+public final class ContactListFilter implements Comparable<ContactListFilter>, Parcelable {
+
+ public static final int FILTER_TYPE_DEFAULT = -1;
+ public static final int FILTER_TYPE_ALL_ACCOUNTS = -2;
+ public static final int FILTER_TYPE_CUSTOM = -3;
+ public static final int FILTER_TYPE_STARRED = -4;
+ public static final int FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY = -5;
+ public static final int FILTER_TYPE_SINGLE_CONTACT = -6;
+ public static final int FILTER_TYPE_GROUP_MEMBERS = -7;
+ public static final int FILTER_TYPE_DEVICE_CONTACTS = -8;
+
+ public static final int FILTER_TYPE_ACCOUNT = 0;
+
+ /**
+ * Obsolete filter which had been used in Honeycomb. This may be stored in
+ * {@link SharedPreferences}, but should be replaced with ALL filter when it is found.
+ *
+ * TODO: "group" filter and relevant variables are all obsolete. Remove them.
+ */
+ private static final int FILTER_TYPE_GROUP = 1;
+
+ private static final String KEY_FILTER_TYPE = "filter.type";
+ private static final String KEY_ACCOUNT_NAME = "filter.accountName";
+ private static final String KEY_ACCOUNT_TYPE = "filter.accountType";
+ private static final String KEY_DATA_SET = "filter.dataSet";
+
+ public final int filterType;
+ public final String accountType;
+ public final String accountName;
+ public final String dataSet;
+ public final Drawable icon;
+ private String mId;
+
+ public ContactListFilter(int filterType, String accountType, String accountName, String dataSet,
+ Drawable icon) {
+ this.filterType = filterType;
+ this.accountType = accountType;
+ this.accountName = accountName;
+ this.dataSet = dataSet;
+ this.icon = icon;
+ }
+
+ public static ContactListFilter createFilterWithType(int filterType) {
+ return new ContactListFilter(filterType, null, null, null, null);
+ }
+
+ public static ContactListFilter createAccountFilter(String accountType, String accountName,
+ String dataSet, Drawable icon) {
+ return new ContactListFilter(ContactListFilter.FILTER_TYPE_ACCOUNT, accountType,
+ accountName, dataSet, icon);
+ }
+
+ public static ContactListFilter createGroupMembersFilter(String accountType, String accountName,
+ String dataSet) {
+ return new ContactListFilter(ContactListFilter.FILTER_TYPE_GROUP_MEMBERS, accountType,
+ accountName, dataSet, /* icon */ null);
+ }
+
+ public static ContactListFilter createDeviceContactsFilter(Drawable icon) {
+ return new ContactListFilter(ContactListFilter.FILTER_TYPE_DEVICE_CONTACTS,
+ /* accountType= */ null, /* accountName= */ null, /* dataSet= */ null, icon);
+ }
+
+ /**
+ * Whether the given {@link ContactListFilter} has a filter type that should be displayed as
+ * the default contacts list view.
+ */
+ public boolean isContactsFilterType() {
+ return filterType == ContactListFilter.FILTER_TYPE_DEFAULT
+ || filterType == ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS
+ || filterType == ContactListFilter.FILTER_TYPE_CUSTOM;
+ }
+
+ /** Returns the {@link ListEvent.ListType} for the type of this filter. */
+ public int toListType() {
+ switch (filterType) {
+ case FILTER_TYPE_DEFAULT:
+ // Fall through
+ case FILTER_TYPE_ALL_ACCOUNTS:
+ return ListEvent.ListType.ALL_CONTACTS;
+ case FILTER_TYPE_CUSTOM:
+ return ListEvent.ListType.CUSTOM;
+ case FILTER_TYPE_STARRED:
+ return ListEvent.ListType.STARRED;
+ case FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY:
+ return ListEvent.ListType.PHONE_NUMBERS;
+ case FILTER_TYPE_SINGLE_CONTACT:
+ return ListEvent.ListType.SINGLE_CONTACT;
+ case FILTER_TYPE_ACCOUNT:
+ return ListEvent.ListType.ACCOUNT;
+ case FILTER_TYPE_GROUP_MEMBERS:
+ return ListEvent.ListType.GROUP;
+ case FILTER_TYPE_DEVICE_CONTACTS:
+ return ListEvent.ListType.DEVICE;
+ }
+ return ListEvent.ListType.UNKNOWN_LIST;
+ }
+
+
+ /**
+ * Returns true if this filter is based on data and may become invalid over time.
+ */
+ public boolean isValidationRequired() {
+ return filterType == FILTER_TYPE_ACCOUNT;
+ }
+
+ @Override
+ public String toString() {
+ switch (filterType) {
+ case FILTER_TYPE_DEFAULT:
+ return "default";
+ case FILTER_TYPE_ALL_ACCOUNTS:
+ return "all_accounts";
+ case FILTER_TYPE_CUSTOM:
+ return "custom";
+ case FILTER_TYPE_STARRED:
+ return "starred";
+ case FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY:
+ return "with_phones";
+ case FILTER_TYPE_SINGLE_CONTACT:
+ return "single";
+ case FILTER_TYPE_ACCOUNT:
+ return "account: " + accountType + (dataSet != null ? "/" + dataSet : "")
+ + " " + accountName;
+ case FILTER_TYPE_GROUP_MEMBERS:
+ return "group_members";
+ case FILTER_TYPE_DEVICE_CONTACTS:
+ return "device_contacts";
+ }
+ return super.toString();
+ }
+
+ @Override
+ public int compareTo(ContactListFilter another) {
+ int res = accountName.compareTo(another.accountName);
+ if (res != 0) {
+ return res;
+ }
+
+ res = accountType.compareTo(another.accountType);
+ if (res != 0) {
+ return res;
+ }
+
+ return filterType - another.filterType;
+ }
+
+ @Override
+ public int hashCode() {
+ int code = filterType;
+ if (accountType != null) {
+ code = code * 31 + accountType.hashCode();
+ code = code * 31 + accountName.hashCode();
+ }
+ if (dataSet != null) {
+ code = code * 31 + dataSet.hashCode();
+ }
+ return code;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+
+ if (!(other instanceof ContactListFilter)) {
+ return false;
+ }
+
+ ContactListFilter otherFilter = (ContactListFilter) other;
+ if (filterType != otherFilter.filterType
+ || !TextUtils.equals(accountName, otherFilter.accountName)
+ || !TextUtils.equals(accountType, otherFilter.accountType)
+ || !TextUtils.equals(dataSet, otherFilter.dataSet)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Store the given {@link ContactListFilter} to preferences. If the requested filter is
+ * of type {@link #FILTER_TYPE_SINGLE_CONTACT} then do not save it to preferences because
+ * it is a temporary state.
+ */
+ public static void storeToPreferences(SharedPreferences prefs, ContactListFilter filter) {
+ if (filter != null && filter.filterType == FILTER_TYPE_SINGLE_CONTACT) {
+ return;
+ }
+ prefs.edit()
+ .putInt(KEY_FILTER_TYPE, filter == null ? FILTER_TYPE_DEFAULT : filter.filterType)
+ .putString(KEY_ACCOUNT_NAME, filter == null ? null : filter.accountName)
+ .putString(KEY_ACCOUNT_TYPE, filter == null ? null : filter.accountType)
+ .putString(KEY_DATA_SET, filter == null ? null : filter.dataSet)
+ .apply();
+ }
+
+ /**
+ * Try to obtain ContactListFilter object saved in SharedPreference.
+ * If there's no info there, return ALL filter instead.
+ */
+ public static ContactListFilter restoreDefaultPreferences(SharedPreferences prefs) {
+ ContactListFilter filter = restoreFromPreferences(prefs);
+ if (filter == null) {
+ filter = ContactListFilter.createFilterWithType(FILTER_TYPE_ALL_ACCOUNTS);
+ }
+ // "Group" filter is obsolete and thus is not exposed anymore. The "single contact mode"
+ // should also not be stored in preferences anymore since it is a temporary state.
+ if (filter.filterType == FILTER_TYPE_GROUP ||
+ filter.filterType == FILTER_TYPE_SINGLE_CONTACT) {
+ filter = ContactListFilter.createFilterWithType(FILTER_TYPE_ALL_ACCOUNTS);
+ }
+ return filter;
+ }
+
+ private static ContactListFilter restoreFromPreferences(SharedPreferences prefs) {
+ int filterType = prefs.getInt(KEY_FILTER_TYPE, FILTER_TYPE_DEFAULT);
+ if (filterType == FILTER_TYPE_DEFAULT) {
+ return null;
+ }
+
+ String accountName = prefs.getString(KEY_ACCOUNT_NAME, null);
+ String accountType = prefs.getString(KEY_ACCOUNT_TYPE, null);
+ String dataSet = prefs.getString(KEY_DATA_SET, null);
+ return new ContactListFilter(filterType, accountType, accountName, dataSet, null);
+ }
+
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(filterType);
+ dest.writeString(accountName);
+ dest.writeString(accountType);
+ dest.writeString(dataSet);
+ }
+
+ public static final Parcelable.Creator<ContactListFilter> CREATOR =
+ new Parcelable.Creator<ContactListFilter>() {
+ @Override
+ public ContactListFilter createFromParcel(Parcel source) {
+ int filterType = source.readInt();
+ String accountName = source.readString();
+ String accountType = source.readString();
+ String dataSet = source.readString();
+ return new ContactListFilter(filterType, accountType, accountName, dataSet, null);
+ }
+
+ @Override
+ public ContactListFilter[] newArray(int size) {
+ return new ContactListFilter[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ /**
+ * Returns a string that can be used as a stable persistent identifier for this filter.
+ */
+ public String getId() {
+ if (mId == null) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(filterType);
+ if (accountType != null) {
+ sb.append('-').append(accountType);
+ }
+ if (dataSet != null) {
+ sb.append('/').append(dataSet);
+ }
+ if (accountName != null) {
+ sb.append('-').append(accountName.replace('-', '_'));
+ }
+ mId = sb.toString();
+ }
+ return mId;
+ }
+
+ /**
+ * Adds the account query parameters to the given {@code uriBuilder}.
+ *
+ * @throws IllegalStateException if the filter type is not {@link #FILTER_TYPE_ACCOUNT}.
+ */
+ public Uri.Builder addAccountQueryParameterToUrl(Uri.Builder uriBuilder) {
+ if (filterType != FILTER_TYPE_ACCOUNT
+ && filterType != FILTER_TYPE_GROUP_MEMBERS) {
+ throw new IllegalStateException(
+ "filterType must be FILTER_TYPE_ACCOUNT or FILER_TYPE_GROUP_MEMBERS");
+ }
+ uriBuilder.appendQueryParameter(RawContacts.ACCOUNT_NAME, accountName);
+ uriBuilder.appendQueryParameter(RawContacts.ACCOUNT_TYPE, accountType);
+ if (!TextUtils.isEmpty(dataSet)) {
+ uriBuilder.appendQueryParameter(RawContacts.DATA_SET, dataSet);
+ }
+ return uriBuilder;
+ }
+
+ public String toDebugString() {
+ final StringBuilder builder = new StringBuilder();
+ builder.append("[filter type: " + filterType + " (" + filterTypeToString(filterType) + ")");
+ if (filterType == FILTER_TYPE_ACCOUNT) {
+ builder.append(", accountType: " + accountType)
+ .append(", accountName: " + accountName)
+ .append(", dataSet: " + dataSet);
+ }
+ builder.append(", icon: " + icon + "]");
+ return builder.toString();
+ }
+
+ public static final String filterTypeToString(int filterType) {
+ switch (filterType) {
+ case FILTER_TYPE_DEFAULT:
+ return "FILTER_TYPE_DEFAULT";
+ case FILTER_TYPE_ALL_ACCOUNTS:
+ return "FILTER_TYPE_ALL_ACCOUNTS";
+ case FILTER_TYPE_CUSTOM:
+ return "FILTER_TYPE_CUSTOM";
+ case FILTER_TYPE_STARRED:
+ return "FILTER_TYPE_STARRED";
+ case FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY:
+ return "FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY";
+ case FILTER_TYPE_SINGLE_CONTACT:
+ return "FILTER_TYPE_SINGLE_CONTACT";
+ case FILTER_TYPE_ACCOUNT:
+ return "FILTER_TYPE_ACCOUNT";
+ case FILTER_TYPE_GROUP_MEMBERS:
+ return "FILTER_TYPE_GROUP_MEMBERS";
+ case FILTER_TYPE_DEVICE_CONTACTS:
+ return "FILTER_TYPE_DEVICE_CONTACTS";
+ default:
+ return "(unknown)";
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactListFilterController.java b/src/com/android/contacts/common/list/ContactListFilterController.java
new file mode 100644
index 0000000..48d36ed
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactListFilterController.java
@@ -0,0 +1,214 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+
+import com.android.contacts.common.logging.ListEvent;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Manages {@link ContactListFilter}. All methods must be called from UI thread.
+ */
+public abstract class ContactListFilterController {
+
+ // singleton to cache the filter controller
+ private static ContactListFilterControllerImpl sFilterController = null;
+
+ public interface ContactListFilterListener {
+ void onContactListFilterChanged();
+ }
+
+ public static ContactListFilterController getInstance(Context context) {
+ // We may need to synchronize this in the future if background task will call this.
+ if (sFilterController == null) {
+ sFilterController = new ContactListFilterControllerImpl(context);
+ }
+ return sFilterController;
+ }
+
+ public abstract void addListener(ContactListFilterListener listener);
+
+ public abstract void removeListener(ContactListFilterListener listener);
+
+ /**
+ * Return the currently-active filter.
+ */
+ public abstract ContactListFilter getFilter();
+
+ public abstract int getFilterListType();
+
+ /** Whether the persisted filter is a custom filter. */
+ public abstract boolean isCustomFilterPersisted();
+
+ /** Returns the persisted filter. */
+ public abstract ContactListFilter getPersistedFilter();
+
+ /**
+ * @param filter the filter
+ * @param persistent True when the given filter should be saved soon. False when the filter
+ * should not be saved. The latter case may happen when some Intent requires a certain type of
+ * UI (e.g. single contact) temporarily.
+ */
+ public abstract void setContactListFilter(ContactListFilter filter, boolean persistent);
+
+ public abstract void selectCustomFilter();
+
+ /**
+ * Checks if the current filter is valid and reset the filter if not. It may happen when
+ * an account is removed while the filter points to the account with
+ * {@link ContactListFilter#FILTER_TYPE_ACCOUNT} type, for example. It may also happen if
+ * the current filter is {@link ContactListFilter#FILTER_TYPE_SINGLE_CONTACT}, in
+ * which case, we should switch to the last saved filter in {@link SharedPreferences}.
+ */
+ public abstract void checkFilterValidity(boolean notifyListeners);
+}
+
+/**
+ * Stores the {@link ContactListFilter} selected by the user and saves it to
+ * {@link SharedPreferences} if necessary.
+ */
+class ContactListFilterControllerImpl extends ContactListFilterController {
+ private final Context mContext;
+ private final List<ContactListFilterListener> mListeners =
+ new ArrayList<ContactListFilterListener>();
+ private ContactListFilter mFilter;
+
+ public ContactListFilterControllerImpl(Context context) {
+ mContext = context.getApplicationContext();
+ mFilter = ContactListFilter.restoreDefaultPreferences(getSharedPreferences());
+ checkFilterValidity(true /* notify listeners */);
+ }
+
+ @Override
+ public void addListener(ContactListFilterListener listener) {
+ mListeners.add(listener);
+ }
+
+ @Override
+ public void removeListener(ContactListFilterListener listener) {
+ mListeners.remove(listener);
+ }
+
+ @Override
+ public ContactListFilter getFilter() {
+ return mFilter;
+ }
+
+ @Override
+ public int getFilterListType() {
+ return mFilter == null ? ListEvent.ListType.UNKNOWN_LIST : mFilter.toListType();
+ }
+
+ @Override
+ public boolean isCustomFilterPersisted() {
+ final ContactListFilter filter = getPersistedFilter();
+ return filter != null && filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM;
+ }
+
+ @Override
+ public ContactListFilter getPersistedFilter() {
+ return ContactListFilter.restoreDefaultPreferences(getSharedPreferences());
+ }
+
+ private SharedPreferences getSharedPreferences() {
+ return PreferenceManager.getDefaultSharedPreferences(mContext);
+ }
+
+ @Override
+ public void setContactListFilter(ContactListFilter filter, boolean persistent) {
+ setContactListFilter(filter, persistent, /* notifyListeners */ true);
+ }
+
+ private void setContactListFilter(ContactListFilter filter, boolean persistent,
+ boolean notifyListeners) {
+ if (!filter.equals(mFilter)) {
+ mFilter = filter;
+ if (persistent) {
+ ContactListFilter.storeToPreferences(getSharedPreferences(), mFilter);
+ }
+ if (notifyListeners && !mListeners.isEmpty()) {
+ notifyContactListFilterChanged();
+ }
+ }
+ }
+
+ @Override
+ public void selectCustomFilter() {
+ setContactListFilter(ContactListFilter.createFilterWithType(
+ ContactListFilter.FILTER_TYPE_CUSTOM), /* persistent */ true);
+ }
+
+ private void notifyContactListFilterChanged() {
+ for (ContactListFilterListener listener : mListeners) {
+ listener.onContactListFilterChanged();
+ }
+ }
+
+ @Override
+ public void checkFilterValidity(boolean notifyListeners) {
+ if (mFilter == null) {
+ return;
+ }
+
+ switch (mFilter.filterType) {
+ case ContactListFilter.FILTER_TYPE_SINGLE_CONTACT:
+ setContactListFilter(
+ ContactListFilter.restoreDefaultPreferences(getSharedPreferences()),
+ false, notifyListeners);
+ break;
+ case ContactListFilter.FILTER_TYPE_ACCOUNT:
+ if (!filterAccountExists()) {
+ // The current account filter points to invalid account. Use "all" filter
+ // instead.
+ setContactListFilter(ContactListFilter.createFilterWithType(
+ ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS), true, notifyListeners);
+ }
+ break;
+ case ContactListFilter.FILTER_TYPE_DEVICE_CONTACTS:
+ if (!localAccountExists()) {
+ setContactListFilter(ContactListFilter.createFilterWithType(
+ ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS), true, notifyListeners);
+ }
+ break;
+ }
+ }
+
+ /**
+ * @return true if the Account for the current filter exists.
+ */
+ private boolean filterAccountExists() {
+ final AccountTypeManager accountTypeManager = AccountTypeManager.getInstance(mContext);
+ final AccountWithDataSet filterAccount = new AccountWithDataSet(
+ mFilter.accountName, mFilter.accountType, mFilter.dataSet);
+ return accountTypeManager.contains(filterAccount, /* contactWritableOnly */ false);
+ }
+
+ /**
+ * @return true if the local account still exists.
+ */
+ private boolean localAccountExists() {
+ final AccountTypeManager accountTypeManager = AccountTypeManager.getInstance(mContext);
+ final AccountWithDataSet localAccount = AccountWithDataSet.getLocalAccount();
+ return accountTypeManager.contains(localAccount, /* contactWritableOnly */ false);
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactListFilterView.java b/src/com/android/contacts/common/list/ContactListFilterView.java
new file mode 100644
index 0000000..76e43aa
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactListFilterView.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.RadioButton;
+import android.widget.TextView;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountType;
+
+/**
+ * Contact list filter parameters.
+ */
+public class ContactListFilterView extends LinearLayout {
+
+ private static final String TAG = ContactListFilterView.class.getSimpleName();
+
+ private ImageView mIcon;
+ private TextView mAccountType;
+ private TextView mAccountUserName;
+ private RadioButton mRadioButton;
+ private ContactListFilter mFilter;
+ private boolean mSingleAccount;
+
+ public ContactListFilterView(Context context) {
+ super(context);
+ }
+
+ public ContactListFilterView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public void setContactListFilter(ContactListFilter filter) {
+ mFilter = filter;
+ }
+
+ public ContactListFilter getContactListFilter() {
+ return mFilter;
+ }
+
+ public void setSingleAccount(boolean flag) {
+ this.mSingleAccount = flag;
+ }
+
+ @Override
+ public void setActivated(boolean activated) {
+ super.setActivated(activated);
+ if (mRadioButton != null) {
+ mRadioButton.setChecked(activated);
+ } else {
+ // We're guarding against null-pointer exceptions,
+ // but otherwise this code is not expected to work
+ // properly if the button hasn't been initialized.
+ Log.wtf(TAG, "radio-button cannot be activated because it is null");
+ }
+ setContentDescription(generateContentDescription());
+ }
+
+ public boolean isChecked() {
+ return mRadioButton.isChecked();
+ }
+
+ public void bindView(AccountTypeManager accountTypes) {
+ if (mAccountType == null) {
+ mIcon = (ImageView) findViewById(R.id.icon);
+ mAccountType = (TextView) findViewById(R.id.accountType);
+ mAccountUserName = (TextView) findViewById(R.id.accountUserName);
+ mRadioButton = (RadioButton) findViewById(R.id.radioButton);
+ mRadioButton.setChecked(isActivated());
+ }
+
+ if (mFilter == null) {
+ mAccountType.setText(R.string.contactsList);
+ return;
+ }
+
+ mAccountUserName.setVisibility(View.GONE);
+ switch (mFilter.filterType) {
+ case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS: {
+ bindView(0, R.string.list_filter_all_accounts);
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_STARRED: {
+ bindView(R.drawable.ic_menu_star_holo_light, R.string.list_filter_all_starred);
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_CUSTOM: {
+ bindView(0, R.string.list_filter_customize);
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: {
+ bindView(0, R.string.list_filter_phones);
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_SINGLE_CONTACT: {
+ bindView(0, R.string.list_filter_single);
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_ACCOUNT: {
+ mAccountUserName.setVisibility(View.VISIBLE);
+ mIcon.setVisibility(View.VISIBLE);
+ if (mFilter.icon != null) {
+ mIcon.setImageDrawable(mFilter.icon);
+ } else {
+ mIcon.setImageResource(R.drawable.unknown_source);
+ }
+ final AccountType accountType =
+ accountTypes.getAccountType(mFilter.accountType, mFilter.dataSet);
+ mAccountUserName.setText(mFilter.accountName);
+ mAccountType.setText(accountType.getDisplayLabel(getContext()));
+ break;
+ }
+ }
+ setContentDescription(generateContentDescription());
+ }
+
+ private void bindView(int iconResource, int textResource) {
+ if (iconResource != 0) {
+ mIcon.setVisibility(View.VISIBLE);
+ mIcon.setImageResource(iconResource);
+ } else {
+ mIcon.setVisibility(View.GONE);
+ }
+
+ mAccountType.setText(textResource);
+ }
+
+ String generateContentDescription() {
+ final StringBuilder sb = new StringBuilder();
+ if (!TextUtils.isEmpty(mAccountType.getText())) {
+ sb.append(mAccountType.getText());
+ }
+ if (!TextUtils.isEmpty(mAccountUserName.getText())) {
+ if (sb.length() > 0) {
+ sb.append(" ");
+ }
+ sb.append(mAccountUserName.getText());
+ }
+ return getContext().getString(isActivated() ? R.string.account_filter_view_checked :
+ R.string.account_filter_view_not_checked, sb.toString());
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactListItemView.java b/src/com/android/contacts/common/list/ContactListItemView.java
new file mode 100644
index 0000000..bb28858
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactListItemView.java
@@ -0,0 +1,1890 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.database.CharArrayBuffer;
+import android.database.Cursor;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.SearchSnippets;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.graphics.drawable.DrawableCompat;
+import android.support.v7.widget.AppCompatCheckBox;
+import android.support.v7.widget.AppCompatImageButton;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.TextUtils.TruncateAt;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AbsListView.SelectionBoundsAdjuster;
+import android.widget.ImageView;
+import android.widget.ImageView.ScaleType;
+import android.widget.QuickContactBadge;
+import android.widget.TextView;
+
+import com.android.contacts.common.ContactPresenceIconUtil;
+import com.android.contacts.common.ContactStatusUtil;
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.compat.PhoneNumberUtilsCompat;
+import com.android.contacts.common.format.TextHighlighter;
+import com.android.contacts.common.util.ContactDisplayUtils;
+import com.android.contacts.common.util.SearchUtil;
+import com.android.contacts.common.util.ViewUtil;
+
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A custom view for an item in the contact list.
+ * The view contains the contact's photo, a set of text views (for name, status, etc...) and
+ * icons for presence and call.
+ * The view uses no XML file for layout and all the measurements and layouts are done
+ * in the onMeasure and onLayout methods.
+ *
+ * The layout puts the contact's photo on the right side of the view, the call icon (if present)
+ * to the left of the photo, the text lines are aligned to the left and the presence icon (if
+ * present) is set to the left of the status line.
+ *
+ * The layout also supports a header (used as a header of a group of contacts) that is above the
+ * contact's data and a divider between contact view.
+ */
+
+public class ContactListItemView extends ViewGroup
+ implements SelectionBoundsAdjuster {
+
+ private static final String TAG = "ContactListItemView";
+
+ // Style values for layout and appearance
+ // The initialized values are defaults if none is provided through xml.
+ private int mPreferredHeight = 0;
+ private int mGapBetweenImageAndText = 0;
+ private int mGapBetweenIndexerAndImage = 0;
+ private int mGapBetweenLabelAndData = 0;
+ private int mPresenceIconMargin = 4;
+ private int mPresenceIconSize = 16;
+ private int mTextIndent = 0;
+ private int mTextOffsetTop;
+ private int mAvatarOffsetTop;
+ private int mNameTextViewTextSize;
+ private int mHeaderWidth;
+ private Drawable mActivatedBackgroundDrawable;
+ private int mVideoCallIconSize = 32;
+ private int mVideoCallIconMargin = 16;
+ private int mGapFromScrollBar = 20;
+
+ // Set in onLayout. Represent left and right position of the View on the screen.
+ private int mLeftOffset;
+ private int mRightOffset;
+
+ /**
+ * Used with {@link #mLabelView}, specifying the width ratio between label and data.
+ */
+ private int mLabelViewWidthWeight = 3;
+ /**
+ * Used with {@link #mDataView}, specifying the width ratio between label and data.
+ */
+ private int mDataViewWidthWeight = 5;
+
+ protected static class HighlightSequence {
+ private final int start;
+ private final int end;
+
+ HighlightSequence(int start, int end) {
+ this.start = start;
+ this.end = end;
+ }
+ }
+
+ private ArrayList<HighlightSequence> mNameHighlightSequence;
+ private ArrayList<HighlightSequence> mNumberHighlightSequence;
+
+ // Highlighting prefix for names.
+ private String mHighlightedPrefix;
+
+ /**
+ * Used to notify listeners when a video call icon is clicked.
+ */
+ private PhoneNumberListAdapter.Listener mPhoneNumberListAdapterListener;
+
+ /**
+ * Indicates whether to show the "video call" icon, used to initiate a video call.
+ */
+ private boolean mShowVideoCallIcon = false;
+
+ /**
+ * Indicates whether the view should leave room for the "video call" icon.
+ */
+ private boolean mSupportVideoCallIcon = false;
+
+ /**
+ * Where to put contact photo. This affects the other Views' layout or look-and-feel.
+ *
+ * TODO: replace enum with int constants
+ */
+ public enum PhotoPosition {
+ LEFT,
+ RIGHT
+ }
+
+ static public final PhotoPosition getDefaultPhotoPosition(boolean opposite) {
+ final Locale locale = Locale.getDefault();
+ final int layoutDirection = TextUtils.getLayoutDirectionFromLocale(locale);
+ switch (layoutDirection) {
+ case View.LAYOUT_DIRECTION_RTL:
+ return (opposite ? PhotoPosition.LEFT : PhotoPosition.RIGHT);
+ case View.LAYOUT_DIRECTION_LTR:
+ default:
+ return (opposite ? PhotoPosition.RIGHT : PhotoPosition.LEFT);
+ }
+ }
+
+ private PhotoPosition mPhotoPosition = getDefaultPhotoPosition(false /* normal/non opposite */);
+
+ // Header layout data
+ private View mHeaderView;
+ private boolean mIsSectionHeaderEnabled;
+
+ // The views inside the contact view
+ private boolean mQuickContactEnabled = true;
+ private QuickContactBadge mQuickContact;
+ private ImageView mPhotoView;
+ private TextView mNameTextView;
+ private TextView mPhoneticNameTextView;
+ private TextView mLabelView;
+ private TextView mDataView;
+ private TextView mSnippetView;
+ private TextView mStatusView;
+ private ImageView mPresenceIcon;
+ private AppCompatCheckBox mCheckBox;
+ private AppCompatImageButton mDeleteImageButton;
+ private ImageView mVideoCallIcon;
+ private ImageView mWorkProfileIcon;
+
+ private ColorStateList mSecondaryTextColor;
+
+ private int mDefaultPhotoViewSize = 0;
+ /**
+ * Can be effective even when {@link #mPhotoView} is null, as we want to have horizontal padding
+ * to align other data in this View.
+ */
+ private int mPhotoViewWidth;
+ /**
+ * Can be effective even when {@link #mPhotoView} is null, as we want to have vertical padding.
+ */
+ private int mPhotoViewHeight;
+
+ /**
+ * Only effective when {@link #mPhotoView} is null.
+ * When true all the Views on the right side of the photo should have horizontal padding on
+ * those left assuming there is a photo.
+ */
+ private boolean mKeepHorizontalPaddingForPhotoView;
+ /**
+ * Only effective when {@link #mPhotoView} is null.
+ */
+ private boolean mKeepVerticalPaddingForPhotoView;
+
+ /**
+ * True when {@link #mPhotoViewWidth} and {@link #mPhotoViewHeight} are ready for being used.
+ * False indicates those values should be updated before being used in position calculation.
+ */
+ private boolean mPhotoViewWidthAndHeightAreReady = false;
+
+ private int mNameTextViewHeight;
+ private int mNameTextViewTextColor = Color.BLACK;
+ private int mPhoneticNameTextViewHeight;
+ private int mLabelViewHeight;
+ private int mDataViewHeight;
+ private int mSnippetTextViewHeight;
+ private int mStatusTextViewHeight;
+ private int mCheckBoxHeight;
+ private int mCheckBoxWidth;
+ private int mDeleteImageButtonHeight;
+ private int mDeleteImageButtonWidth;
+
+ // Holds Math.max(mLabelTextViewHeight, mDataViewHeight), assuming Label and Data share the
+ // same row.
+ private int mLabelAndDataViewMaxHeight;
+
+ // TODO: some TextView fields are using CharArrayBuffer while some are not. Determine which is
+ // more efficient for each case or in general, and simplify the whole implementation.
+ // Note: if we're sure MARQUEE will be used every time, there's no reason to use
+ // CharArrayBuffer, since MARQUEE requires Span and thus we need to copy characters inside the
+ // buffer to Spannable once, while CharArrayBuffer is for directly applying char array to
+ // TextView without any modification.
+ private final CharArrayBuffer mDataBuffer = new CharArrayBuffer(128);
+ private final CharArrayBuffer mPhoneticNameBuffer = new CharArrayBuffer(128);
+
+ private boolean mActivatedStateSupported;
+ private boolean mAdjustSelectionBoundsEnabled = true;
+
+ private Rect mBoundsWithoutHeader = new Rect();
+
+ /** A helper used to highlight a prefix in a text field. */
+ private final TextHighlighter mTextHighlighter;
+ private CharSequence mUnknownNameText;
+ private int mPosition;
+
+ public ContactListItemView(Context context) {
+ super(context);
+
+ mTextHighlighter = new TextHighlighter(Typeface.BOLD);
+ mNameHighlightSequence = new ArrayList<HighlightSequence>();
+ mNumberHighlightSequence = new ArrayList<HighlightSequence>();
+ }
+
+ public ContactListItemView(Context context, AttributeSet attrs, boolean supportVideoCallIcon) {
+ this(context, attrs);
+
+ mSupportVideoCallIcon = supportVideoCallIcon;
+ }
+
+ public ContactListItemView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ TypedArray a;
+
+ if (R.styleable.ContactListItemView != null) {
+ // Read all style values
+ a = getContext().obtainStyledAttributes(attrs, R.styleable.ContactListItemView);
+ mPreferredHeight = a.getDimensionPixelSize(
+ R.styleable.ContactListItemView_list_item_height, mPreferredHeight);
+ mActivatedBackgroundDrawable = a.getDrawable(
+ R.styleable.ContactListItemView_activated_background);
+
+ mGapBetweenImageAndText = a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_gap_between_image_and_text,
+ mGapBetweenImageAndText);
+ mGapBetweenIndexerAndImage = a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_gap_between_indexer_and_image,
+ mGapBetweenIndexerAndImage);
+ mGapBetweenLabelAndData = a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_gap_between_label_and_data,
+ mGapBetweenLabelAndData);
+ mPresenceIconMargin = a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_presence_icon_margin,
+ mPresenceIconMargin);
+ mPresenceIconSize = a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_presence_icon_size,
+ mPresenceIconSize);
+ mDefaultPhotoViewSize = a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_photo_size, mDefaultPhotoViewSize);
+ mTextIndent = a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_text_indent, mTextIndent);
+ mTextOffsetTop = a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_text_offset_top, mTextOffsetTop);
+ mAvatarOffsetTop = a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_avatar_offset_top, mAvatarOffsetTop);
+ mDataViewWidthWeight = a.getInteger(
+ R.styleable.ContactListItemView_list_item_data_width_weight,
+ mDataViewWidthWeight);
+ mLabelViewWidthWeight = a.getInteger(
+ R.styleable.ContactListItemView_list_item_label_width_weight,
+ mLabelViewWidthWeight);
+ mNameTextViewTextColor = a.getColor(
+ R.styleable.ContactListItemView_list_item_name_text_color,
+ mNameTextViewTextColor);
+ mNameTextViewTextSize = (int) a.getDimension(
+ R.styleable.ContactListItemView_list_item_name_text_size,
+ (int) getResources().getDimension(R.dimen.contact_browser_list_item_text_size));
+ mVideoCallIconSize = a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_video_call_icon_size,
+ mVideoCallIconSize);
+ mVideoCallIconMargin = a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_video_call_icon_margin,
+ mVideoCallIconMargin);
+
+
+ setPaddingRelative(
+ a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_padding_left, 0),
+ a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_padding_top, 0),
+ a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_padding_right, 0),
+ a.getDimensionPixelOffset(
+ R.styleable.ContactListItemView_list_item_padding_bottom, 0));
+
+ a.recycle();
+ }
+
+ mTextHighlighter = new TextHighlighter(Typeface.BOLD);
+
+ if (R.styleable.Theme != null) {
+ a = getContext().obtainStyledAttributes(R.styleable.Theme);
+ mSecondaryTextColor = a.getColorStateList(R.styleable.Theme_android_textColorSecondary);
+ a.recycle();
+ }
+
+ mHeaderWidth =
+ getResources().getDimensionPixelSize(R.dimen.contact_list_section_header_width);
+
+ if (mActivatedBackgroundDrawable != null) {
+ mActivatedBackgroundDrawable.setCallback(this);
+ }
+
+ mNameHighlightSequence = new ArrayList<HighlightSequence>();
+ mNumberHighlightSequence = new ArrayList<HighlightSequence>();
+
+ setLayoutDirection(View.LAYOUT_DIRECTION_LOCALE);
+ }
+
+ public void setUnknownNameText(CharSequence unknownNameText) {
+ mUnknownNameText = unknownNameText;
+ }
+
+ public void setQuickContactEnabled(boolean flag) {
+ mQuickContactEnabled = flag;
+ }
+
+ /**
+ * Sets whether the video calling icon is shown. For the video calling icon to be shown,
+ * {@link #mSupportVideoCallIcon} must be {@code true}.
+ *
+ * @param showVideoCallIcon {@code true} if the video calling icon is shown, {@code false}
+ * otherwise.
+ * @param listener Listener to notify when the video calling icon is clicked.
+ * @param position The position in the adapater of the video calling icon.
+ */
+ public void setShowVideoCallIcon(boolean showVideoCallIcon,
+ PhoneNumberListAdapter.Listener listener, int position) {
+ mShowVideoCallIcon = showVideoCallIcon;
+ mPhoneNumberListAdapterListener = listener;
+ mPosition = position;
+
+ if (mShowVideoCallIcon) {
+ if (mVideoCallIcon == null) {
+ mVideoCallIcon = new ImageView(getContext());
+ addView(mVideoCallIcon);
+ }
+ mVideoCallIcon.setContentDescription(getContext().getString(
+ R.string.description_search_video_call));
+ mVideoCallIcon.setImageResource(R.drawable.ic_search_video_call);
+ mVideoCallIcon.setScaleType(ScaleType.CENTER);
+ mVideoCallIcon.setVisibility(View.VISIBLE);
+ mVideoCallIcon.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ // Inform the adapter that the video calling icon was clicked.
+ if (mPhoneNumberListAdapterListener != null) {
+ mPhoneNumberListAdapterListener.onVideoCallIconClicked(mPosition);
+ }
+ }
+ });
+ } else {
+ if (mVideoCallIcon != null) {
+ mVideoCallIcon.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ /**
+ * Sets whether the view supports a video calling icon. This is independent of whether the view
+ * is actually showing an icon. Support for the video calling icon ensures that the layout
+ * leaves space for the video icon, should it be shown.
+ *
+ * @param supportVideoCallIcon {@code true} if the video call icon is supported, {@code false}
+ * otherwise.
+ */
+ public void setSupportVideoCallIcon(boolean supportVideoCallIcon) {
+ mSupportVideoCallIcon = supportVideoCallIcon;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ // We will match parent's width and wrap content vertically, but make sure
+ // height is no less than listPreferredItemHeight.
+ final int specWidth = resolveSize(0, widthMeasureSpec);
+ final int preferredHeight = mPreferredHeight;
+
+ mNameTextViewHeight = 0;
+ mPhoneticNameTextViewHeight = 0;
+ mLabelViewHeight = 0;
+ mDataViewHeight = 0;
+ mLabelAndDataViewMaxHeight = 0;
+ mSnippetTextViewHeight = 0;
+ mStatusTextViewHeight = 0;
+ mCheckBoxWidth = 0;
+ mCheckBoxHeight = 0;
+ mDeleteImageButtonWidth = 0;
+ mDeleteImageButtonHeight = 0;
+
+ ensurePhotoViewSize();
+
+ // Width each TextView is able to use.
+ int effectiveWidth;
+ // All the other Views will honor the photo, so available width for them may be shrunk.
+ if (mPhotoViewWidth > 0 || mKeepHorizontalPaddingForPhotoView) {
+ effectiveWidth = specWidth - getPaddingLeft() - getPaddingRight()
+ - (mPhotoViewWidth + mGapBetweenImageAndText);
+ } else {
+ effectiveWidth = specWidth - getPaddingLeft() - getPaddingRight();
+ }
+
+ if (mIsSectionHeaderEnabled) {
+ effectiveWidth -= mHeaderWidth + mGapBetweenIndexerAndImage;
+ }
+
+ if (mSupportVideoCallIcon) {
+ effectiveWidth -= (mVideoCallIconSize + mVideoCallIconMargin);
+ }
+
+ // Go over all visible text views and measure actual width of each of them.
+ // Also calculate their heights to get the total height for this entire view.
+
+ if (isVisible(mCheckBox)) {
+ mCheckBox.measure(
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ mCheckBoxWidth = mCheckBox.getMeasuredWidth();
+ mCheckBoxHeight = mCheckBox.getMeasuredHeight();
+ effectiveWidth -= mCheckBoxWidth + mGapBetweenImageAndText;
+ }
+
+ if (isVisible(mDeleteImageButton)) {
+ mDeleteImageButton.measure(
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ mDeleteImageButtonWidth = mDeleteImageButton.getMeasuredWidth();
+ mDeleteImageButtonHeight = mDeleteImageButton.getMeasuredHeight();
+ effectiveWidth -= mDeleteImageButtonWidth + mGapBetweenImageAndText;
+ }
+
+ if (isVisible(mNameTextView)) {
+ // Calculate width for name text - this parallels similar measurement in onLayout.
+ int nameTextWidth = effectiveWidth;
+ if (mPhotoPosition != PhotoPosition.LEFT) {
+ nameTextWidth -= mTextIndent;
+ }
+ mNameTextView.measure(
+ MeasureSpec.makeMeasureSpec(nameTextWidth, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ mNameTextViewHeight = mNameTextView.getMeasuredHeight();
+ }
+
+ if (isVisible(mPhoneticNameTextView)) {
+ mPhoneticNameTextView.measure(
+ MeasureSpec.makeMeasureSpec(effectiveWidth, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ mPhoneticNameTextViewHeight = mPhoneticNameTextView.getMeasuredHeight();
+ }
+
+ // If both data (phone number/email address) and label (type like "MOBILE") are quite long,
+ // we should ellipsize both using appropriate ratio.
+ final int dataWidth;
+ final int labelWidth;
+ if (isVisible(mDataView)) {
+ if (isVisible(mLabelView)) {
+ final int totalWidth = effectiveWidth - mGapBetweenLabelAndData;
+ dataWidth = ((totalWidth * mDataViewWidthWeight)
+ / (mDataViewWidthWeight + mLabelViewWidthWeight));
+ labelWidth = ((totalWidth * mLabelViewWidthWeight) /
+ (mDataViewWidthWeight + mLabelViewWidthWeight));
+ } else {
+ dataWidth = effectiveWidth;
+ labelWidth = 0;
+ }
+ } else {
+ dataWidth = 0;
+ if (isVisible(mLabelView)) {
+ labelWidth = effectiveWidth;
+ } else {
+ labelWidth = 0;
+ }
+ }
+
+ if (isVisible(mDataView)) {
+ mDataView.measure(MeasureSpec.makeMeasureSpec(dataWidth, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ mDataViewHeight = mDataView.getMeasuredHeight();
+ }
+
+ if (isVisible(mLabelView)) {
+ mLabelView.measure(MeasureSpec.makeMeasureSpec(labelWidth, MeasureSpec.AT_MOST),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ mLabelViewHeight = mLabelView.getMeasuredHeight();
+ }
+ mLabelAndDataViewMaxHeight = Math.max(mLabelViewHeight, mDataViewHeight);
+
+ if (isVisible(mSnippetView)) {
+ mSnippetView.measure(
+ MeasureSpec.makeMeasureSpec(effectiveWidth, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ mSnippetTextViewHeight = mSnippetView.getMeasuredHeight();
+ }
+
+ // Status view height is the biggest of the text view and the presence icon
+ if (isVisible(mPresenceIcon)) {
+ mPresenceIcon.measure(
+ MeasureSpec.makeMeasureSpec(mPresenceIconSize, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(mPresenceIconSize, MeasureSpec.EXACTLY));
+ mStatusTextViewHeight = mPresenceIcon.getMeasuredHeight();
+ }
+
+ if (mSupportVideoCallIcon && isVisible(mVideoCallIcon)) {
+ mVideoCallIcon.measure(
+ MeasureSpec.makeMeasureSpec(mVideoCallIconSize, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(mVideoCallIconSize, MeasureSpec.EXACTLY));
+ }
+
+ if (isVisible(mWorkProfileIcon)) {
+ mWorkProfileIcon.measure(
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ mNameTextViewHeight =
+ Math.max(mNameTextViewHeight, mWorkProfileIcon.getMeasuredHeight());
+ }
+
+ if (isVisible(mStatusView)) {
+ // Presence and status are in a same row, so status will be affected by icon size.
+ final int statusWidth;
+ if (isVisible(mPresenceIcon)) {
+ statusWidth = (effectiveWidth - mPresenceIcon.getMeasuredWidth()
+ - mPresenceIconMargin);
+ } else {
+ statusWidth = effectiveWidth;
+ }
+ mStatusView.measure(MeasureSpec.makeMeasureSpec(statusWidth, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ mStatusTextViewHeight =
+ Math.max(mStatusTextViewHeight, mStatusView.getMeasuredHeight());
+ }
+
+ // Calculate height including padding.
+ int height = (mNameTextViewHeight + mPhoneticNameTextViewHeight +
+ mLabelAndDataViewMaxHeight +
+ mSnippetTextViewHeight + mStatusTextViewHeight);
+
+ // Make sure the height is at least as high as the photo
+ height = Math.max(height, mPhotoViewHeight + getPaddingBottom() + getPaddingTop());
+
+ // Make sure height is at least the preferred height
+ height = Math.max(height, preferredHeight);
+
+ // Measure the header if it is visible.
+ if (mHeaderView != null && mHeaderView.getVisibility() == VISIBLE) {
+ mHeaderView.measure(
+ MeasureSpec.makeMeasureSpec(mHeaderWidth, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ }
+
+ setMeasuredDimension(specWidth, height);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ final int height = bottom - top;
+ final int width = right - left;
+
+ // Determine the vertical bounds by laying out the header first.
+ int topBound = 0;
+ int bottomBound = height;
+ int leftBound = getPaddingLeft();
+ int rightBound = width - getPaddingRight();
+
+ final boolean isLayoutRtl = ViewUtil.isViewLayoutRtl(this);
+
+ // Put the section header on the left side of the contact view.
+ if (mIsSectionHeaderEnabled) {
+ if (mHeaderView != null) {
+ int headerHeight = mHeaderView.getMeasuredHeight();
+ int headerTopBound = (bottomBound + topBound - headerHeight) / 2 + mTextOffsetTop;
+
+ mHeaderView.layout(
+ isLayoutRtl ? rightBound - mHeaderWidth : leftBound,
+ headerTopBound,
+ isLayoutRtl ? rightBound : leftBound + mHeaderWidth,
+ headerTopBound + headerHeight);
+ }
+ if (isLayoutRtl) {
+ rightBound -= mHeaderWidth;
+ } else {
+ leftBound += mHeaderWidth;
+ }
+ }
+
+ mBoundsWithoutHeader.set(left + leftBound, topBound, left + rightBound, bottomBound);
+ mLeftOffset = left + leftBound;
+ mRightOffset = left + rightBound;
+ if (isLayoutRtl) {
+ rightBound -= mGapBetweenIndexerAndImage;
+ } else {
+ leftBound += mGapBetweenIndexerAndImage;
+ }
+
+ if (mActivatedStateSupported && isActivated()) {
+ mActivatedBackgroundDrawable.setBounds(mBoundsWithoutHeader);
+ }
+
+ if (isVisible(mCheckBox)) {
+ final int photoTop = topBound + (bottomBound - topBound - mCheckBoxHeight) / 2;
+ if (mPhotoPosition == PhotoPosition.LEFT) {
+ mCheckBox.layout(rightBound - mGapFromScrollBar - mCheckBoxWidth,
+ photoTop,
+ rightBound - mGapFromScrollBar,
+ photoTop + mCheckBoxHeight);
+ } else {
+ mCheckBox.layout(leftBound + mGapFromScrollBar,
+ photoTop,
+ leftBound + mGapFromScrollBar + mCheckBoxWidth,
+ photoTop + mCheckBoxHeight);
+ }
+ }
+
+ if (isVisible(mDeleteImageButton)) {
+ final int photoTop = topBound + (bottomBound - topBound - mDeleteImageButtonHeight) / 2;
+ final int mDeleteImageButtonSize = mDeleteImageButtonHeight > mDeleteImageButtonWidth
+ ? mDeleteImageButtonHeight : mDeleteImageButtonWidth;
+ if (mPhotoPosition == PhotoPosition.LEFT) {
+ mDeleteImageButton.layout(rightBound - mDeleteImageButtonSize,
+ photoTop,
+ rightBound,
+ photoTop + mDeleteImageButtonSize);
+ } else {
+ mDeleteImageButton.layout(leftBound,
+ photoTop,
+ leftBound + mDeleteImageButtonSize,
+ photoTop + mDeleteImageButtonSize);
+ }
+ }
+
+ final View photoView = mQuickContact != null ? mQuickContact : mPhotoView;
+ if (mPhotoPosition == PhotoPosition.LEFT) {
+ // Photo is the left most view. All the other Views should on the right of the photo.
+ if (photoView != null) {
+ // Center the photo vertically
+ final int photoTop = topBound + (bottomBound - topBound - mPhotoViewHeight) / 2
+ + mAvatarOffsetTop;
+ photoView.layout(
+ leftBound,
+ photoTop,
+ leftBound + mPhotoViewWidth,
+ photoTop + mPhotoViewHeight);
+ leftBound += mPhotoViewWidth + mGapBetweenImageAndText;
+ } else if (mKeepHorizontalPaddingForPhotoView) {
+ // Draw nothing but keep the padding.
+ leftBound += mPhotoViewWidth + mGapBetweenImageAndText;
+ }
+ } else {
+ // Photo is the right most view. Right bound should be adjusted that way.
+ if (photoView != null) {
+ // Center the photo vertically
+ final int photoTop = topBound + (bottomBound - topBound - mPhotoViewHeight) / 2
+ + mAvatarOffsetTop;
+ photoView.layout(
+ rightBound - mPhotoViewWidth,
+ photoTop,
+ rightBound,
+ photoTop + mPhotoViewHeight);
+ rightBound -= (mPhotoViewWidth + mGapBetweenImageAndText);
+ } else if (mKeepHorizontalPaddingForPhotoView) {
+ // Draw nothing but keep the padding.
+ rightBound -= (mPhotoViewWidth + mGapBetweenImageAndText);
+ }
+
+ // Add indent between left-most padding and texts.
+ leftBound += mTextIndent;
+ }
+
+ if (mSupportVideoCallIcon) {
+ // Place the video call button at the end of the list (e.g. take into account RTL mode).
+ if (isVisible(mVideoCallIcon)) {
+ // Center the video icon vertically
+ final int videoIconTop = topBound +
+ (bottomBound - topBound - mVideoCallIconSize) / 2;
+
+ if (!isLayoutRtl) {
+ // When photo is on left, video icon is placed on the right edge.
+ mVideoCallIcon.layout(rightBound - mVideoCallIconSize,
+ videoIconTop,
+ rightBound,
+ videoIconTop + mVideoCallIconSize);
+ } else {
+ // When photo is on right, video icon is placed on the left edge.
+ mVideoCallIcon.layout(leftBound,
+ videoIconTop,
+ leftBound + mVideoCallIconSize,
+ videoIconTop + mVideoCallIconSize);
+ }
+ }
+
+ if (mPhotoPosition == PhotoPosition.LEFT) {
+ rightBound -= (mVideoCallIconSize + mVideoCallIconMargin);
+ } else {
+ leftBound += mVideoCallIconSize + mVideoCallIconMargin;
+ }
+ }
+
+
+ // Center text vertically, then apply the top offset.
+ final int totalTextHeight = mNameTextViewHeight + mPhoneticNameTextViewHeight +
+ mLabelAndDataViewMaxHeight + mSnippetTextViewHeight + mStatusTextViewHeight;
+ int textTopBound = (bottomBound + topBound - totalTextHeight) / 2 + mTextOffsetTop;
+
+ // Work Profile icon align top
+ int workProfileIconWidth = 0;
+ if (isVisible(mWorkProfileIcon)) {
+ workProfileIconWidth = mWorkProfileIcon.getMeasuredWidth();
+ final int distanceFromEnd = mCheckBoxWidth > 0
+ ? mCheckBoxWidth + mGapBetweenImageAndText : 0;
+ if (mPhotoPosition == PhotoPosition.LEFT) {
+ // When photo is on left, label is placed on the right edge of the list item.
+ mWorkProfileIcon.layout(rightBound - workProfileIconWidth - distanceFromEnd,
+ textTopBound,
+ rightBound - distanceFromEnd,
+ textTopBound + mNameTextViewHeight);
+ } else {
+ // When photo is on right, label is placed on the left of data view.
+ mWorkProfileIcon.layout(leftBound + distanceFromEnd,
+ textTopBound,
+ leftBound + workProfileIconWidth + distanceFromEnd,
+ textTopBound + mNameTextViewHeight);
+ }
+ }
+
+ // Layout all text view and presence icon
+ // Put name TextView first
+ if (isVisible(mNameTextView)) {
+ final int distanceFromEnd = workProfileIconWidth
+ + (mCheckBoxWidth > 0 ? mCheckBoxWidth + mGapBetweenImageAndText : 0);
+ if (mPhotoPosition == PhotoPosition.LEFT) {
+ mNameTextView.layout(leftBound,
+ textTopBound,
+ rightBound - distanceFromEnd,
+ textTopBound + mNameTextViewHeight);
+ } else {
+ mNameTextView.layout(leftBound + distanceFromEnd,
+ textTopBound,
+ rightBound,
+ textTopBound + mNameTextViewHeight);
+ }
+ }
+
+ if (isVisible(mNameTextView) || isVisible(mWorkProfileIcon)) {
+ textTopBound += mNameTextViewHeight;
+ }
+
+ // Presence and status
+ if (isLayoutRtl) {
+ int statusRightBound = rightBound;
+ if (isVisible(mPresenceIcon)) {
+ int iconWidth = mPresenceIcon.getMeasuredWidth();
+ mPresenceIcon.layout(
+ rightBound - iconWidth,
+ textTopBound,
+ rightBound,
+ textTopBound + mStatusTextViewHeight);
+ statusRightBound -= (iconWidth + mPresenceIconMargin);
+ }
+
+ if (isVisible(mStatusView)) {
+ mStatusView.layout(leftBound,
+ textTopBound,
+ statusRightBound,
+ textTopBound + mStatusTextViewHeight);
+ }
+ } else {
+ int statusLeftBound = leftBound;
+ if (isVisible(mPresenceIcon)) {
+ int iconWidth = mPresenceIcon.getMeasuredWidth();
+ mPresenceIcon.layout(
+ leftBound,
+ textTopBound,
+ leftBound + iconWidth,
+ textTopBound + mStatusTextViewHeight);
+ statusLeftBound += (iconWidth + mPresenceIconMargin);
+ }
+
+ if (isVisible(mStatusView)) {
+ mStatusView.layout(statusLeftBound,
+ textTopBound,
+ rightBound,
+ textTopBound + mStatusTextViewHeight);
+ }
+ }
+
+ if (isVisible(mStatusView) || isVisible(mPresenceIcon)) {
+ textTopBound += mStatusTextViewHeight;
+ }
+
+ // Rest of text views
+ int dataLeftBound = leftBound;
+ if (isVisible(mPhoneticNameTextView)) {
+ mPhoneticNameTextView.layout(leftBound,
+ textTopBound,
+ rightBound,
+ textTopBound + mPhoneticNameTextViewHeight);
+ textTopBound += mPhoneticNameTextViewHeight;
+ }
+
+ // Label and Data align bottom.
+ if (isVisible(mLabelView)) {
+ if (!isLayoutRtl) {
+ mLabelView.layout(dataLeftBound,
+ textTopBound + mLabelAndDataViewMaxHeight - mLabelViewHeight,
+ rightBound,
+ textTopBound + mLabelAndDataViewMaxHeight);
+ dataLeftBound += mLabelView.getMeasuredWidth() + mGapBetweenLabelAndData;
+ } else {
+ dataLeftBound = leftBound + mLabelView.getMeasuredWidth();
+ mLabelView.layout(rightBound - mLabelView.getMeasuredWidth(),
+ textTopBound + mLabelAndDataViewMaxHeight - mLabelViewHeight,
+ rightBound,
+ textTopBound + mLabelAndDataViewMaxHeight);
+ rightBound -= (mLabelView.getMeasuredWidth() + mGapBetweenLabelAndData);
+ }
+ }
+
+ if (isVisible(mDataView)) {
+ if (!isLayoutRtl) {
+ mDataView.layout(dataLeftBound,
+ textTopBound + mLabelAndDataViewMaxHeight - mDataViewHeight,
+ rightBound,
+ textTopBound + mLabelAndDataViewMaxHeight);
+ } else {
+ mDataView.layout(rightBound - mDataView.getMeasuredWidth(),
+ textTopBound + mLabelAndDataViewMaxHeight - mDataViewHeight,
+ rightBound,
+ textTopBound + mLabelAndDataViewMaxHeight);
+ }
+ }
+ if (isVisible(mLabelView) || isVisible(mDataView)) {
+ textTopBound += mLabelAndDataViewMaxHeight;
+ }
+
+ if (isVisible(mSnippetView)) {
+ mSnippetView.layout(leftBound,
+ textTopBound,
+ rightBound,
+ textTopBound + mSnippetTextViewHeight);
+ }
+ }
+
+ @Override
+ public void adjustListItemSelectionBounds(Rect bounds) {
+ if (mAdjustSelectionBoundsEnabled) {
+ bounds.top += mBoundsWithoutHeader.top;
+ bounds.bottom = bounds.top + mBoundsWithoutHeader.height();
+ bounds.left = mBoundsWithoutHeader.left;
+ bounds.right = mBoundsWithoutHeader.right;
+ }
+ }
+
+ protected boolean isVisible(View view) {
+ return view != null && view.getVisibility() == View.VISIBLE;
+ }
+
+ /**
+ * Extracts width and height from the style
+ */
+ private void ensurePhotoViewSize() {
+ if (!mPhotoViewWidthAndHeightAreReady) {
+ mPhotoViewWidth = mPhotoViewHeight = getDefaultPhotoViewSize();
+ if (!mQuickContactEnabled && mPhotoView == null) {
+ if (!mKeepHorizontalPaddingForPhotoView) {
+ mPhotoViewWidth = 0;
+ }
+ if (!mKeepVerticalPaddingForPhotoView) {
+ mPhotoViewHeight = 0;
+ }
+ }
+
+ mPhotoViewWidthAndHeightAreReady = true;
+ }
+ }
+
+ protected int getDefaultPhotoViewSize() {
+ return mDefaultPhotoViewSize;
+ }
+
+ /**
+ * Gets a LayoutParam that corresponds to the default photo size.
+ *
+ * @return A new LayoutParam.
+ */
+ private LayoutParams getDefaultPhotoLayoutParams() {
+ LayoutParams params = generateDefaultLayoutParams();
+ params.width = getDefaultPhotoViewSize();
+ params.height = params.width;
+ return params;
+ }
+
+ @Override
+ protected void drawableStateChanged() {
+ super.drawableStateChanged();
+ if (mActivatedStateSupported) {
+ mActivatedBackgroundDrawable.setState(getDrawableState());
+ }
+ }
+
+ @Override
+ protected boolean verifyDrawable(Drawable who) {
+ return who == mActivatedBackgroundDrawable || super.verifyDrawable(who);
+ }
+
+ @Override
+ public void jumpDrawablesToCurrentState() {
+ super.jumpDrawablesToCurrentState();
+ if (mActivatedStateSupported) {
+ mActivatedBackgroundDrawable.jumpToCurrentState();
+ }
+ }
+
+ @Override
+ public void dispatchDraw(Canvas canvas) {
+ if (mActivatedStateSupported && isActivated()) {
+ mActivatedBackgroundDrawable.draw(canvas);
+ }
+
+ super.dispatchDraw(canvas);
+ }
+
+ /**
+ * Sets section header or makes it invisible if the title is null.
+ */
+ public void setSectionHeader(String title) {
+ if (!TextUtils.isEmpty(title)) {
+ if (TextUtils.equals(getContext().getString(R.string.star_sign), title)) {
+ if (mHeaderView == null) {
+ addStarImageHeader();
+ } else if (mHeaderView instanceof TextView) {
+ removeView(mHeaderView);
+ addStarImageHeader();
+ } else {
+ mHeaderView.setVisibility(View.VISIBLE);
+ }
+ } else {
+ if (mHeaderView == null) {
+ addTextHeader(title);
+ } else if (mHeaderView instanceof ImageView) {
+ removeView(mHeaderView);
+ addTextHeader(title);
+ } else {
+ updateHeaderText((TextView) mHeaderView, title);
+ }
+ }
+ } else if (mHeaderView != null) {
+ mHeaderView.setVisibility(View.GONE);
+ }
+ }
+
+ private void addTextHeader(String title) {
+ mHeaderView = new TextView(getContext());
+ final TextView headerTextView = (TextView) mHeaderView;
+ headerTextView.setTextAppearance(getContext(), R.style.SectionHeaderStyle);
+ headerTextView.setGravity(Gravity.CENTER_HORIZONTAL);
+ updateHeaderText(headerTextView, title);
+ addView(headerTextView);
+ }
+
+ private void updateHeaderText(TextView headerTextView, String title) {
+ setMarqueeText(headerTextView, title);
+ headerTextView.setAllCaps(true);
+ if (ContactsSectionIndexer.BLANK_HEADER_STRING.equals(title)) {
+ headerTextView.setContentDescription(
+ getContext().getString(R.string.description_no_name_header));
+ } else {
+ headerTextView.setContentDescription(title);
+ }
+ headerTextView.setVisibility(View.VISIBLE);
+ }
+
+ private void addStarImageHeader() {
+ mHeaderView = new ImageView(getContext());
+ final ImageView headerImageView = (ImageView) mHeaderView;
+ headerImageView.setImageDrawable(
+ getResources().getDrawable(R.drawable.ic_material_star, getContext().getTheme()));
+ headerImageView.setImageTintList(ColorStateList.valueOf(getResources()
+ .getColor(R.color.material_star_pink)));
+ headerImageView.setContentDescription(
+ getContext().getString(R.string.list_filter_all_starred));
+ headerImageView.setVisibility(View.VISIBLE);
+ addView(headerImageView);
+ }
+
+ public void setIsSectionHeaderEnabled(boolean isSectionHeaderEnabled) {
+ mIsSectionHeaderEnabled = isSectionHeaderEnabled;
+ }
+
+ /**
+ * Returns the quick contact badge, creating it if necessary.
+ */
+ public QuickContactBadge getQuickContact() {
+ if (!mQuickContactEnabled) {
+ throw new IllegalStateException("QuickContact is disabled for this view");
+ }
+ if (mQuickContact == null) {
+ mQuickContact = new QuickContactBadge(getContext());
+ if (CompatUtils.isLollipopCompatible()) {
+ mQuickContact.setOverlay(null);
+ }
+ mQuickContact.setLayoutParams(getDefaultPhotoLayoutParams());
+ if (mNameTextView != null) {
+ mQuickContact.setContentDescription(getContext().getString(
+ R.string.description_quick_contact_for, mNameTextView.getText()));
+ }
+
+ addView(mQuickContact);
+ mPhotoViewWidthAndHeightAreReady = false;
+ }
+ return mQuickContact;
+ }
+
+ /**
+ * Returns the photo view, creating it if necessary.
+ */
+ public ImageView getPhotoView() {
+ if (mPhotoView == null) {
+ mPhotoView = new ImageView(getContext());
+ mPhotoView.setLayoutParams(getDefaultPhotoLayoutParams());
+ // Quick contact style used above will set a background - remove it
+ mPhotoView.setBackground(null);
+ addView(mPhotoView);
+ mPhotoViewWidthAndHeightAreReady = false;
+ }
+ return mPhotoView;
+ }
+
+ /**
+ * Removes the photo view.
+ */
+ public void removePhotoView() {
+ removePhotoView(false, true);
+ }
+
+ /**
+ * Removes the photo view.
+ *
+ * @param keepHorizontalPadding True means data on the right side will have
+ * padding on left, pretending there is still a photo view.
+ * @param keepVerticalPadding True means the View will have some height
+ * enough for accommodating a photo view.
+ */
+ public void removePhotoView(boolean keepHorizontalPadding, boolean keepVerticalPadding) {
+ mPhotoViewWidthAndHeightAreReady = false;
+ mKeepHorizontalPaddingForPhotoView = keepHorizontalPadding;
+ mKeepVerticalPaddingForPhotoView = keepVerticalPadding;
+ if (mPhotoView != null) {
+ removeView(mPhotoView);
+ mPhotoView = null;
+ }
+ if (mQuickContact != null) {
+ removeView(mQuickContact);
+ mQuickContact = null;
+ }
+ }
+
+ /**
+ * Sets a word prefix that will be highlighted if encountered in fields like
+ * name and search snippet. This will disable the mask highlighting for names.
+ * <p>
+ * NOTE: must be all upper-case
+ */
+ public void setHighlightedPrefix(String upperCasePrefix) {
+ mHighlightedPrefix = upperCasePrefix;
+ }
+
+ /**
+ * Clears previously set highlight sequences for the view.
+ */
+ public void clearHighlightSequences() {
+ mNameHighlightSequence.clear();
+ mNumberHighlightSequence.clear();
+ mHighlightedPrefix = null;
+ }
+
+ /**
+ * Adds a highlight sequence to the name highlighter.
+ * @param start The start position of the highlight sequence.
+ * @param end The end position of the highlight sequence.
+ */
+ public void addNameHighlightSequence(int start, int end) {
+ mNameHighlightSequence.add(new HighlightSequence(start, end));
+ }
+
+ /**
+ * Adds a highlight sequence to the number highlighter.
+ * @param start The start position of the highlight sequence.
+ * @param end The end position of the highlight sequence.
+ */
+ public void addNumberHighlightSequence(int start, int end) {
+ mNumberHighlightSequence.add(new HighlightSequence(start, end));
+ }
+
+ /**
+ * Returns the text view for the contact name, creating it if necessary.
+ */
+ public TextView getNameTextView() {
+ if (mNameTextView == null) {
+ mNameTextView = new TextView(getContext());
+ mNameTextView.setSingleLine(true);
+ mNameTextView.setEllipsize(getTextEllipsis());
+ mNameTextView.setTextColor(mNameTextViewTextColor);
+ mNameTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mNameTextViewTextSize);
+ // Manually call setActivated() since this view may be added after the first
+ // setActivated() call toward this whole item view.
+ mNameTextView.setActivated(isActivated());
+ mNameTextView.setGravity(Gravity.CENTER_VERTICAL);
+ mNameTextView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START);
+ mNameTextView.setId(R.id.cliv_name_textview);
+ if (CompatUtils.isLollipopCompatible()) {
+ mNameTextView.setElegantTextHeight(false);
+ }
+ addView(mNameTextView);
+ }
+ return mNameTextView;
+ }
+
+ /**
+ * Adds or updates a text view for the phonetic name.
+ */
+ public void setPhoneticName(char[] text, int size) {
+ if (text == null || size == 0) {
+ if (mPhoneticNameTextView != null) {
+ mPhoneticNameTextView.setVisibility(View.GONE);
+ }
+ } else {
+ getPhoneticNameTextView();
+ setMarqueeText(mPhoneticNameTextView, text, size);
+ mPhoneticNameTextView.setVisibility(VISIBLE);
+ }
+ }
+
+ /**
+ * Returns the text view for the phonetic name, creating it if necessary.
+ */
+ public TextView getPhoneticNameTextView() {
+ if (mPhoneticNameTextView == null) {
+ mPhoneticNameTextView = new TextView(getContext());
+ mPhoneticNameTextView.setSingleLine(true);
+ mPhoneticNameTextView.setEllipsize(getTextEllipsis());
+ mPhoneticNameTextView.setTextAppearance(getContext(), android.R.style.TextAppearance_Small);
+ mPhoneticNameTextView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START);
+ mPhoneticNameTextView.setTypeface(mPhoneticNameTextView.getTypeface(), Typeface.BOLD);
+ mPhoneticNameTextView.setActivated(isActivated());
+ mPhoneticNameTextView.setId(R.id.cliv_phoneticname_textview);
+ addView(mPhoneticNameTextView);
+ }
+ return mPhoneticNameTextView;
+ }
+
+ /**
+ * Adds or updates a text view for the data label.
+ */
+ public void setLabel(CharSequence text) {
+ if (TextUtils.isEmpty(text)) {
+ if (mLabelView != null) {
+ mLabelView.setVisibility(View.GONE);
+ }
+ } else {
+ getLabelView();
+ setMarqueeText(mLabelView, text);
+ mLabelView.setVisibility(VISIBLE);
+ }
+ }
+
+ /**
+ * Returns the text view for the data label, creating it if necessary.
+ */
+ public TextView getLabelView() {
+ if (mLabelView == null) {
+ mLabelView = new TextView(getContext());
+ mLabelView.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT,
+ LayoutParams.WRAP_CONTENT));
+
+ mLabelView.setSingleLine(true);
+ mLabelView.setEllipsize(getTextEllipsis());
+ mLabelView.setTextAppearance(getContext(), R.style.TextAppearanceSmall);
+ if (mPhotoPosition == PhotoPosition.LEFT) {
+ mLabelView.setAllCaps(true);
+ } else {
+ mLabelView.setTypeface(mLabelView.getTypeface(), Typeface.BOLD);
+ }
+ mLabelView.setActivated(isActivated());
+ mLabelView.setId(R.id.cliv_label_textview);
+ addView(mLabelView);
+ }
+ return mLabelView;
+ }
+
+ /**
+ * Adds or updates a text view for the data element.
+ */
+ public void setData(char[] text, int size) {
+ if (text == null || size == 0) {
+ if (mDataView != null) {
+ mDataView.setVisibility(View.GONE);
+ }
+ } else {
+ getDataView();
+ setMarqueeText(mDataView, text, size);
+ mDataView.setVisibility(VISIBLE);
+ }
+ }
+
+ /**
+ * Sets phone number for a list item. This takes care of number highlighting if the highlight
+ * mask exists.
+ */
+ public void setPhoneNumber(String text, String countryIso) {
+ if (text == null) {
+ if (mDataView != null) {
+ mDataView.setVisibility(View.GONE);
+ }
+ } else {
+ getDataView();
+
+ // TODO: Format number using PhoneNumberUtils.formatNumber before assigning it to
+ // mDataView. Make sure that determination of the highlight sequences are done only
+ // after number formatting.
+
+ // Sets phone number texts for display after highlighting it, if applicable.
+ // CharSequence textToSet = text;
+ final SpannableString textToSet = new SpannableString(text);
+
+ if (mNumberHighlightSequence.size() != 0) {
+ final HighlightSequence highlightSequence = mNumberHighlightSequence.get(0);
+ mTextHighlighter.applyMaskingHighlight(textToSet, highlightSequence.start,
+ highlightSequence.end);
+ }
+
+ setMarqueeText(mDataView, textToSet);
+ mDataView.setVisibility(VISIBLE);
+
+ // We have a phone number as "mDataView" so make it always LTR and VIEW_START
+ mDataView.setTextDirection(View.TEXT_DIRECTION_LTR);
+ mDataView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START);
+ }
+ }
+
+ private void setMarqueeText(TextView textView, char[] text, int size) {
+ if (getTextEllipsis() == TruncateAt.MARQUEE) {
+ setMarqueeText(textView, new String(text, 0, size));
+ } else {
+ textView.setText(text, 0, size);
+ }
+ }
+
+ private void setMarqueeText(TextView textView, CharSequence text) {
+ if (getTextEllipsis() == TruncateAt.MARQUEE) {
+ // To show MARQUEE correctly (with END effect during non-active state), we need
+ // to build Spanned with MARQUEE in addition to TextView's ellipsize setting.
+ final SpannableString spannable = new SpannableString(text);
+ spannable.setSpan(TruncateAt.MARQUEE, 0, spannable.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ textView.setText(spannable);
+ } else {
+ textView.setText(text);
+ }
+ }
+
+ /**
+ * Returns the {@link AppCompatCheckBox} view, creating it if necessary.
+ */
+ public AppCompatCheckBox getCheckBox() {
+ if (mCheckBox == null) {
+ mCheckBox = new AppCompatCheckBox(getContext());
+ // Make non-focusable, so the rest of the ContactListItemView can be clicked.
+ mCheckBox.setFocusable(false);
+ addView(mCheckBox);
+ }
+ return mCheckBox;
+ }
+
+ /**
+ * Returns the {@link AppCompatImageButton} delete button, creating it if necessary.
+ */
+ public AppCompatImageButton getDeleteImageButton(
+ final MultiSelectEntryContactListAdapter.DeleteContactListener listener,
+ final int position) {
+ if (mDeleteImageButton == null) {
+ mDeleteImageButton = new AppCompatImageButton(getContext());
+ mDeleteImageButton.setImageResource(R.drawable.ic_cancel_black_24dp);
+ mDeleteImageButton.setScaleType(ScaleType.CENTER);
+ mDeleteImageButton.setBackgroundColor(Color.TRANSPARENT);
+ mDeleteImageButton.setContentDescription(
+ getResources().getString(R.string.description_delete_contact));
+ if (CompatUtils. isLollipopCompatible()) {
+ final TypedValue typedValue = new TypedValue();
+ getContext().getTheme().resolveAttribute(
+ android.R.attr.selectableItemBackgroundBorderless, typedValue, true);
+ mDeleteImageButton.setBackgroundResource(typedValue.resourceId);
+ }
+ addView(mDeleteImageButton);
+ }
+ // Reset onClickListener because after reloading the view, position might be changed.
+ mDeleteImageButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ // Inform the adapter that delete icon was clicked.
+ if (listener != null) {
+ listener.onContactDeleteClicked(position);
+ }
+ }
+ });
+ return mDeleteImageButton;
+ }
+
+ /**
+ * Returns the text view for the data text, creating it if necessary.
+ */
+ public TextView getDataView() {
+ if (mDataView == null) {
+ mDataView = new TextView(getContext());
+ mDataView.setSingleLine(true);
+ mDataView.setEllipsize(getTextEllipsis());
+ mDataView.setTextAppearance(getContext(), R.style.TextAppearanceSmall);
+ mDataView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START);
+ mDataView.setActivated(isActivated());
+ mDataView.setId(R.id.cliv_data_view);
+ if (CompatUtils.isLollipopCompatible()) {
+ mDataView.setElegantTextHeight(false);
+ }
+ addView(mDataView);
+ }
+ return mDataView;
+ }
+
+ /**
+ * Adds or updates a text view for the search snippet.
+ */
+ public void setSnippet(String text) {
+ if (TextUtils.isEmpty(text)) {
+ if (mSnippetView != null) {
+ mSnippetView.setVisibility(View.GONE);
+ }
+ } else {
+ mTextHighlighter.setPrefixText(getSnippetView(), text, mHighlightedPrefix);
+ mSnippetView.setVisibility(VISIBLE);
+ if (ContactDisplayUtils.isPossiblePhoneNumber(text)) {
+ // Give the text-to-speech engine a hint that it's a phone number
+ mSnippetView.setContentDescription(
+ PhoneNumberUtilsCompat.createTtsSpannable(text));
+ } else {
+ mSnippetView.setContentDescription(null);
+ }
+ }
+ }
+
+ /**
+ * Returns the text view for the search snippet, creating it if necessary.
+ */
+ public TextView getSnippetView() {
+ if (mSnippetView == null) {
+ mSnippetView = new TextView(getContext());
+ mSnippetView.setSingleLine(true);
+ mSnippetView.setEllipsize(getTextEllipsis());
+ mSnippetView.setTextAppearance(getContext(), android.R.style.TextAppearance_Small);
+ mSnippetView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START);
+ mSnippetView.setActivated(isActivated());
+ addView(mSnippetView);
+ }
+ return mSnippetView;
+ }
+
+ /**
+ * Returns the text view for the status, creating it if necessary.
+ */
+ public TextView getStatusView() {
+ if (mStatusView == null) {
+ mStatusView = new TextView(getContext());
+ mStatusView.setSingleLine(true);
+ mStatusView.setEllipsize(getTextEllipsis());
+ mStatusView.setTextAppearance(getContext(), android.R.style.TextAppearance_Small);
+ mStatusView.setTextColor(mSecondaryTextColor);
+ mStatusView.setActivated(isActivated());
+ mStatusView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START);
+ addView(mStatusView);
+ }
+ return mStatusView;
+ }
+
+ /**
+ * Adds or updates a text view for the status.
+ */
+ public void setStatus(CharSequence text) {
+ if (TextUtils.isEmpty(text)) {
+ if (mStatusView != null) {
+ mStatusView.setVisibility(View.GONE);
+ }
+ } else {
+ getStatusView();
+ setMarqueeText(mStatusView, text);
+ mStatusView.setVisibility(VISIBLE);
+ }
+ }
+
+ /**
+ * Adds or updates the presence icon view.
+ */
+ public void setPresence(Drawable icon) {
+ if (icon != null) {
+ if (mPresenceIcon == null) {
+ mPresenceIcon = new ImageView(getContext());
+ addView(mPresenceIcon);
+ }
+ mPresenceIcon.setImageDrawable(icon);
+ mPresenceIcon.setScaleType(ScaleType.CENTER);
+ mPresenceIcon.setVisibility(View.VISIBLE);
+ } else {
+ if (mPresenceIcon != null) {
+ mPresenceIcon.setVisibility(View.GONE);
+ }
+ }
+ }
+
+ /**
+ * Set to display work profile icon or not
+ *
+ * @param enabled set to display work profile icon or not
+ */
+ public void setWorkProfileIconEnabled(boolean enabled) {
+ if (mWorkProfileIcon != null) {
+ mWorkProfileIcon.setVisibility(enabled ? View.VISIBLE : View.GONE);
+ } else if (enabled) {
+ mWorkProfileIcon = new ImageView(getContext());
+ addView(mWorkProfileIcon);
+ mWorkProfileIcon.setImageResource(R.drawable.ic_work_profile);
+ mWorkProfileIcon.setScaleType(ScaleType.CENTER_INSIDE);
+ mWorkProfileIcon.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private TruncateAt getTextEllipsis() {
+ return TruncateAt.MARQUEE;
+ }
+
+ public void showDisplayName(Cursor cursor, int nameColumnIndex, int displayOrder) {
+ CharSequence name = cursor.getString(nameColumnIndex);
+ setDisplayName(name);
+
+ // Since the quick contact content description is derived from the display name and there is
+ // no guarantee that when the quick contact is initialized the display name is already set,
+ // do it here too.
+ if (mQuickContact != null) {
+ mQuickContact.setContentDescription(getContext().getString(
+ R.string.description_quick_contact_for, mNameTextView.getText()));
+ }
+ }
+
+ public void setDisplayName(CharSequence name, boolean highlight) {
+ if (!TextUtils.isEmpty(name) && highlight) {
+ clearHighlightSequences();
+ addNameHighlightSequence(0, name.length());
+ }
+ setDisplayName(name);
+ }
+
+ public void setDisplayName(CharSequence name) {
+ if (!TextUtils.isEmpty(name)) {
+ // Chooses the available highlighting method for highlighting.
+ if (mHighlightedPrefix != null) {
+ name = mTextHighlighter.applyPrefixHighlight(name, mHighlightedPrefix);
+ } else if (mNameHighlightSequence.size() != 0) {
+ final SpannableString spannableName = new SpannableString(name);
+ for (HighlightSequence highlightSequence : mNameHighlightSequence) {
+ mTextHighlighter.applyMaskingHighlight(spannableName, highlightSequence.start,
+ highlightSequence.end);
+ }
+ name = spannableName;
+ }
+ } else {
+ name = mUnknownNameText;
+ }
+ setMarqueeText(getNameTextView(), name);
+
+ if (ContactDisplayUtils.isPossiblePhoneNumber(name)) {
+ // Give the text-to-speech engine a hint that it's a phone number
+ mNameTextView.setTextDirection(View.TEXT_DIRECTION_LTR);
+ mNameTextView.setContentDescription(
+ PhoneNumberUtilsCompat.createTtsSpannable(name.toString()));
+ } else {
+ // Remove span tags of highlighting for talkback to avoid reading highlighting and rest
+ // of the name into two separate parts.
+ mNameTextView.setContentDescription(name.toString());
+ }
+ }
+
+ public void hideCheckBox() {
+ if (mCheckBox != null) {
+ removeView(mCheckBox);
+ mCheckBox = null;
+ }
+ }
+
+ public void hideDeleteImageButton() {
+ if (mDeleteImageButton != null) {
+ removeView(mDeleteImageButton);
+ mDeleteImageButton = null;
+ }
+ }
+
+ public void hideDisplayName() {
+ if (mNameTextView != null) {
+ removeView(mNameTextView);
+ mNameTextView = null;
+ }
+ }
+
+ public void showPhoneticName(Cursor cursor, int phoneticNameColumnIndex) {
+ cursor.copyStringToBuffer(phoneticNameColumnIndex, mPhoneticNameBuffer);
+ int phoneticNameSize = mPhoneticNameBuffer.sizeCopied;
+ if (phoneticNameSize != 0) {
+ setPhoneticName(mPhoneticNameBuffer.data, phoneticNameSize);
+ } else {
+ setPhoneticName(null, 0);
+ }
+ }
+
+ public void hidePhoneticName() {
+ if (mPhoneticNameTextView != null) {
+ removeView(mPhoneticNameTextView);
+ mPhoneticNameTextView = null;
+ }
+ }
+
+ /**
+ * Sets the proper icon (star or presence or nothing) and/or status message.
+ */
+ public void showPresenceAndStatusMessage(Cursor cursor, int presenceColumnIndex,
+ int contactStatusColumnIndex) {
+ Drawable icon = null;
+ int presence = 0;
+ if (!cursor.isNull(presenceColumnIndex)) {
+ presence = cursor.getInt(presenceColumnIndex);
+ icon = ContactPresenceIconUtil.getPresenceIcon(getContext(), presence);
+ }
+ setPresence(icon);
+
+ String statusMessage = null;
+ if (contactStatusColumnIndex != 0 && !cursor.isNull(contactStatusColumnIndex)) {
+ statusMessage = cursor.getString(contactStatusColumnIndex);
+ }
+ // If there is no status message from the contact, but there was a presence value, then use
+ // the default status message string
+ if (statusMessage == null && presence != 0) {
+ statusMessage = ContactStatusUtil.getStatusString(getContext(), presence);
+ }
+ setStatus(statusMessage);
+ }
+
+ /**
+ * Shows search snippet for email and phone number matches.
+ */
+ public void showSnippet(Cursor cursor, String query, int snippetColumn) {
+ // TODO: this does not properly handle phone numbers with control characters
+ // For example if the phone number is 444-5555, the search query 4445 will match the
+ // number since we normalize it before querying CP2 but the snippet will fail since
+ // the portion to be highlighted is 444-5 not 4445.
+ final String snippet = cursor.getString(snippetColumn);
+ if (snippet == null) {
+ setSnippet(null);
+ return;
+ }
+ final String displayName = cursor.getColumnIndex(Contacts.DISPLAY_NAME) >= 0
+ ? cursor.getString(cursor.getColumnIndex(Contacts.DISPLAY_NAME)) : null;
+ if (snippet.equals(displayName)) {
+ // If the snippet exactly matches the display name (i.e. the phone number or email
+ // address is being used as the display name) then no snippet is necessary
+ setSnippet(null);
+ return;
+ }
+ // Show the snippet with the part of the query that matched it
+ setSnippet(updateSnippet(snippet, query, displayName));
+ }
+
+ /**
+ * Shows search snippet.
+ */
+ public void showSnippet(Cursor cursor, int summarySnippetColumnIndex) {
+ if (cursor.getColumnCount() <= summarySnippetColumnIndex
+ || !SearchSnippets.SNIPPET.equals(cursor.getColumnName(summarySnippetColumnIndex))) {
+ setSnippet(null);
+ return;
+ }
+
+ String snippet = cursor.getString(summarySnippetColumnIndex);
+
+ // Do client side snippeting if provider didn't do it
+ final Bundle extras = cursor.getExtras();
+ if (extras.getBoolean(ContactsContract.DEFERRED_SNIPPETING)) {
+
+ final String query = extras.getString(ContactsContract.DEFERRED_SNIPPETING_QUERY);
+
+ String displayName = null;
+ int displayNameIndex = cursor.getColumnIndex(Contacts.DISPLAY_NAME);
+ if (displayNameIndex >= 0) {
+ displayName = cursor.getString(displayNameIndex);
+ }
+
+ snippet = updateSnippet(snippet, query, displayName);
+
+ } else {
+ if (snippet != null) {
+ int from = 0;
+ int to = snippet.length();
+ int start = snippet.indexOf(DefaultContactListAdapter.SNIPPET_START_MATCH);
+ if (start == -1) {
+ snippet = null;
+ } else {
+ int firstNl = snippet.lastIndexOf('\n', start);
+ if (firstNl != -1) {
+ from = firstNl + 1;
+ }
+ int end = snippet.lastIndexOf(DefaultContactListAdapter.SNIPPET_END_MATCH);
+ if (end != -1) {
+ int lastNl = snippet.indexOf('\n', end);
+ if (lastNl != -1) {
+ to = lastNl;
+ }
+ }
+
+ StringBuilder sb = new StringBuilder();
+ for (int i = from; i < to; i++) {
+ char c = snippet.charAt(i);
+ if (c != DefaultContactListAdapter.SNIPPET_START_MATCH &&
+ c != DefaultContactListAdapter.SNIPPET_END_MATCH) {
+ sb.append(c);
+ }
+ }
+ snippet = sb.toString();
+ }
+ }
+ }
+
+ setSnippet(snippet);
+ }
+
+ /**
+ * Used for deferred snippets from the database. The contents come back as large strings which
+ * need to be extracted for display.
+ *
+ * @param snippet The snippet from the database.
+ * @param query The search query substring.
+ * @param displayName The contact display name.
+ * @return The proper snippet to display.
+ */
+ private String updateSnippet(String snippet, String query, String displayName) {
+
+ if (TextUtils.isEmpty(snippet) || TextUtils.isEmpty(query)) {
+ return null;
+ }
+ query = SearchUtil.cleanStartAndEndOfSearchQuery(query.toLowerCase());
+
+ // If the display name already contains the query term, return empty - snippets should
+ // not be needed in that case.
+ if (!TextUtils.isEmpty(displayName)) {
+ final String lowerDisplayName = displayName.toLowerCase();
+ final List<String> nameTokens = split(lowerDisplayName);
+ for (String nameToken : nameTokens) {
+ if (nameToken.startsWith(query)) {
+ return null;
+ }
+ }
+ }
+
+ // The snippet may contain multiple data lines.
+ // Show the first line that matches the query.
+ final SearchUtil.MatchedLine matched = SearchUtil.findMatchingLine(snippet, query);
+
+ if (matched != null && matched.line != null) {
+ // Tokenize for long strings since the match may be at the end of it.
+ // Skip this part for short strings since the whole string will be displayed.
+ // Most contact strings are short so the snippetize method will be called infrequently.
+ final int lengthThreshold = getResources().getInteger(
+ R.integer.snippet_length_before_tokenize);
+ if (matched.line.length() > lengthThreshold) {
+ return snippetize(matched.line, matched.startIndex, lengthThreshold);
+ } else {
+ return matched.line;
+ }
+ }
+
+ // No match found.
+ return null;
+ }
+
+ private String snippetize(String line, int matchIndex, int maxLength) {
+ // Show up to maxLength characters. But we only show full tokens so show the last full token
+ // up to maxLength characters. So as many starting tokens as possible before trying ending
+ // tokens.
+ int remainingLength = maxLength;
+ int tempRemainingLength = remainingLength;
+
+ // Start the end token after the matched query.
+ int index = matchIndex;
+ int endTokenIndex = index;
+
+ // Find the match token first.
+ while (index < line.length()) {
+ if (!Character.isLetterOrDigit(line.charAt(index))) {
+ endTokenIndex = index;
+ remainingLength = tempRemainingLength;
+ break;
+ }
+ tempRemainingLength--;
+ index++;
+ }
+
+ // Find as much content before the match.
+ index = matchIndex - 1;
+ tempRemainingLength = remainingLength;
+ int startTokenIndex = matchIndex;
+ while (index > -1 && tempRemainingLength > 0) {
+ if (!Character.isLetterOrDigit(line.charAt(index))) {
+ startTokenIndex = index;
+ remainingLength = tempRemainingLength;
+ }
+ tempRemainingLength--;
+ index--;
+ }
+
+ index = endTokenIndex;
+ tempRemainingLength = remainingLength;
+ // Find remaining content at after match.
+ while (index < line.length() && tempRemainingLength > 0) {
+ if (!Character.isLetterOrDigit(line.charAt(index))) {
+ endTokenIndex = index;
+ }
+ tempRemainingLength--;
+ index++;
+ }
+ // Append ellipse if there is content before or after.
+ final StringBuilder sb = new StringBuilder();
+ if (startTokenIndex > 0) {
+ sb.append("...");
+ }
+ sb.append(line.substring(startTokenIndex, endTokenIndex));
+ if (endTokenIndex < line.length()) {
+ sb.append("...");
+ }
+ return sb.toString();
+ }
+
+ private static final Pattern SPLIT_PATTERN = Pattern.compile(
+ "([\\w-\\.]+)@((?:[\\w]+\\.)+)([a-zA-Z]{2,4})|[\\w]+");
+
+ /**
+ * Helper method for splitting a string into tokens. The lists passed in are populated with
+ * the
+ * tokens and offsets into the content of each token. The tokenization function parses e-mail
+ * addresses as a single token; otherwise it splits on any non-alphanumeric character.
+ *
+ * @param content Content to split.
+ * @return List of token strings.
+ */
+ private static List<String> split(String content) {
+ final Matcher matcher = SPLIT_PATTERN.matcher(content);
+ final ArrayList<String> tokens = Lists.newArrayList();
+ while (matcher.find()) {
+ tokens.add(matcher.group());
+ }
+ return tokens;
+ }
+
+ /**
+ * Shows data element.
+ */
+ public void showData(Cursor cursor, int dataColumnIndex) {
+ cursor.copyStringToBuffer(dataColumnIndex, mDataBuffer);
+ setData(mDataBuffer.data, mDataBuffer.sizeCopied);
+ }
+
+ public void setActivatedStateSupported(boolean flag) {
+ this.mActivatedStateSupported = flag;
+ }
+
+ public void setAdjustSelectionBoundsEnabled(boolean enabled) {
+ mAdjustSelectionBoundsEnabled = enabled;
+ }
+
+ @Override
+ public void requestLayout() {
+ // We will assume that once measured this will not need to resize
+ // itself, so there is no need to pass the layout request to the parent
+ // view (ListView).
+ forceLayout();
+ }
+
+ public void setPhotoPosition(PhotoPosition photoPosition) {
+ mPhotoPosition = photoPosition;
+ }
+
+ public PhotoPosition getPhotoPosition() {
+ return mPhotoPosition;
+ }
+
+ /**
+ * Set drawable resources directly for the drawable resource of the photo view.
+ *
+ * @param drawableId Id of drawable resource.
+ */
+ public void setDrawableResource(int drawableId) {
+ ImageView photo = getPhotoView();
+ photo.setScaleType(ImageView.ScaleType.CENTER);
+ final Drawable drawable = ContextCompat.getDrawable(getContext(), drawableId);
+ final int iconColor =
+ ContextCompat.getColor(getContext(), R.color.search_shortcut_icon_color);
+ if (CompatUtils.isLollipopCompatible()) {
+ photo.setImageDrawable(drawable);
+ photo.setImageTintList(ColorStateList.valueOf(iconColor));
+ } else {
+ final Drawable drawableWrapper = DrawableCompat.wrap(drawable).mutate();
+ DrawableCompat.setTint(drawableWrapper, iconColor);
+ photo.setImageDrawable(drawableWrapper);
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ final float x = event.getX();
+ final float y = event.getY();
+ // If the touch event's coordinates are not within the view's header, then delegate
+ // to super.onTouchEvent so that regular view behavior is preserved. Otherwise, consume
+ // and ignore the touch event.
+ if (mBoundsWithoutHeader.contains((int) x, (int) y) || !pointIsInView(x, y)) {
+ return super.onTouchEvent(event);
+ } else {
+ return true;
+ }
+ }
+
+ private final boolean pointIsInView(float localX, float localY) {
+ return localX >= mLeftOffset && localX < mRightOffset
+ && localY >= 0 && localY < (getBottom() - getTop());
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactListPinnedHeaderView.java b/src/com/android/contacts/common/list/ContactListPinnedHeaderView.java
new file mode 100644
index 0000000..6e8e738
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactListPinnedHeaderView.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewParent;
+import android.widget.LinearLayout.LayoutParams;
+import android.widget.TextView;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.util.ViewUtil;
+
+/**
+ * A custom view for the pinned section header shown at the top of the contact list.
+ */
+public class ContactListPinnedHeaderView extends TextView {
+
+ public ContactListPinnedHeaderView(Context context, AttributeSet attrs, View parent) {
+ super(context, attrs);
+
+ if (R.styleable.ContactListItemView == null) {
+ return;
+ }
+ TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.ContactListItemView);
+ int backgroundColor = a.getColor(
+ R.styleable.ContactListItemView_list_item_background_color, Color.WHITE);
+ int textOffsetTop = a.getDimensionPixelSize(
+ R.styleable.ContactListItemView_list_item_text_offset_top, 0);
+ int paddingStartOffset = a.getDimensionPixelSize(
+ R.styleable.ContactListItemView_list_item_padding_left, 0);
+ int textWidth = getResources().getDimensionPixelSize(
+ R.dimen.contact_list_section_header_width);
+ int widthIncludingPadding = paddingStartOffset + textWidth;
+ a.recycle();
+
+ setBackgroundColor(backgroundColor);
+ setTextAppearance(getContext(), R.style.SectionHeaderStyle);
+ setLayoutParams(new LayoutParams(widthIncludingPadding, LayoutParams.WRAP_CONTENT));
+ setLayoutDirection(parent.getLayoutDirection());
+ setGravity(Gravity.CENTER_VERTICAL | Gravity.CENTER_HORIZONTAL);
+
+ // Apply text top offset. Multiply by two, because we are implementing this by padding for a
+ // vertically centered view, rather than adjusting the position directly via a layout.
+ setPaddingRelative(
+ getPaddingStart() + paddingStartOffset,
+ getPaddingTop() + (textOffsetTop * 2),
+ getPaddingEnd(),
+ getPaddingBottom());
+ }
+
+ /**
+ * Sets section header or makes it invisible if the title is null.
+ */
+ public void setSectionHeaderTitle(String title) {
+ if (!TextUtils.isEmpty(title)) {
+ setText(title);
+ setVisibility(View.VISIBLE);
+ } else {
+ setVisibility(View.GONE);
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactTileAdapter.java b/src/com/android/contacts/common/list/ContactTileAdapter.java
new file mode 100644
index 0000000..6ce4efb
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactTileAdapter.java
@@ -0,0 +1,632 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.provider.ContactsContract.Contacts;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import com.android.contacts.common.ContactPhotoManager;
+import com.android.contacts.common.ContactPresenceIconUtil;
+import com.android.contacts.common.ContactStatusUtil;
+import com.android.contacts.common.ContactTileLoaderFactory;
+import com.android.contacts.common.MoreContactUtils;
+import com.android.contacts.common.R;
+import com.android.contacts.common.util.ViewUtil;
+
+import java.util.ArrayList;
+
+/**
+ * Arranges contacts favorites according to provided {@link DisplayType}.
+ * Also allows for a configurable number of columns and {@link DisplayType}
+ */
+public class ContactTileAdapter extends BaseAdapter {
+ private static final String TAG = ContactTileAdapter.class.getSimpleName();
+
+ private DisplayType mDisplayType;
+ private ContactTileView.Listener mListener;
+ private Context mContext;
+ private Resources mResources;
+ protected Cursor mContactCursor = null;
+ private ContactPhotoManager mPhotoManager;
+ protected int mNumFrequents;
+
+ /**
+ * Index of the first NON starred contact in the {@link Cursor}
+ * Only valid when {@link DisplayType#STREQUENT} is true
+ */
+ private int mDividerPosition;
+ protected int mColumnCount;
+ private int mStarredIndex;
+
+ protected int mIdIndex;
+ protected int mLookupIndex;
+ protected int mPhotoUriIndex;
+ protected int mNameIndex;
+ protected int mPresenceIndex;
+ protected int mStatusIndex;
+
+ private boolean mIsQuickContactEnabled = false;
+ private final int mPaddingInPixels;
+ private final int mWhitespaceStartEnd;
+
+ /**
+ * Configures the adapter to filter and display contacts using different view types.
+ * TODO: Create Uris to support getting Starred_only and Frequent_only cursors.
+ */
+ public enum DisplayType {
+ /**
+ * Displays a mixed view type of starred and frequent contacts
+ */
+ STREQUENT,
+
+ /**
+ * Display only starred contacts
+ */
+ STARRED_ONLY,
+
+ /**
+ * Display only most frequently contacted
+ */
+ FREQUENT_ONLY,
+
+ /**
+ * Display all contacts from a group in the cursor
+ */
+ GROUP_MEMBERS
+ }
+
+ public ContactTileAdapter(Context context, ContactTileView.Listener listener, int numCols,
+ DisplayType displayType) {
+ mListener = listener;
+ mContext = context;
+ mResources = context.getResources();
+ mColumnCount = (displayType == DisplayType.FREQUENT_ONLY ? 1 : numCols);
+ mDisplayType = displayType;
+ mNumFrequents = 0;
+
+ // Converting padding in dips to padding in pixels
+ mPaddingInPixels = mContext.getResources()
+ .getDimensionPixelSize(R.dimen.contact_tile_divider_padding);
+ mWhitespaceStartEnd = mContext.getResources()
+ .getDimensionPixelSize(R.dimen.contact_tile_start_end_whitespace);
+
+ bindColumnIndices();
+ }
+
+ public void setPhotoLoader(ContactPhotoManager photoLoader) {
+ mPhotoManager = photoLoader;
+ }
+
+ public void setColumnCount(int columnCount) {
+ mColumnCount = columnCount;
+ }
+
+ public void setDisplayType(DisplayType displayType) {
+ mDisplayType = displayType;
+ }
+
+ public void enableQuickContact(boolean enableQuickContact) {
+ mIsQuickContactEnabled = enableQuickContact;
+ }
+
+ /**
+ * Sets the column indices for expected {@link Cursor}
+ * based on {@link DisplayType}.
+ */
+ protected void bindColumnIndices() {
+ mIdIndex = ContactTileLoaderFactory.CONTACT_ID;
+ mLookupIndex = ContactTileLoaderFactory.LOOKUP_KEY;
+ mPhotoUriIndex = ContactTileLoaderFactory.PHOTO_URI;
+ mNameIndex = ContactTileLoaderFactory.DISPLAY_NAME;
+ mStarredIndex = ContactTileLoaderFactory.STARRED;
+ mPresenceIndex = ContactTileLoaderFactory.CONTACT_PRESENCE;
+ mStatusIndex = ContactTileLoaderFactory.CONTACT_STATUS;
+ }
+
+ private static boolean cursorIsValid(Cursor cursor) {
+ return cursor != null && !cursor.isClosed();
+ }
+
+ /**
+ * Gets the number of frequents from the passed in cursor.
+ *
+ * This methods is needed so the GroupMemberTileAdapter can override this.
+ *
+ * @param cursor The cursor to get number of frequents from.
+ */
+ protected void saveNumFrequentsFromCursor(Cursor cursor) {
+
+ // count the number of frequents
+ switch (mDisplayType) {
+ case STARRED_ONLY:
+ mNumFrequents = 0;
+ break;
+ case STREQUENT:
+ mNumFrequents = cursorIsValid(cursor) ?
+ cursor.getCount() - mDividerPosition : 0;
+ break;
+ case FREQUENT_ONLY:
+ mNumFrequents = cursorIsValid(cursor) ? cursor.getCount() : 0;
+ break;
+ default:
+ throw new IllegalArgumentException("Unrecognized DisplayType " + mDisplayType);
+ }
+ }
+
+ /**
+ * Creates {@link ContactTileView}s for each item in {@link Cursor}.
+ *
+ * Else use {@link ContactTileLoaderFactory}
+ */
+ public void setContactCursor(Cursor cursor) {
+ mContactCursor = cursor;
+ mDividerPosition = getDividerPosition(cursor);
+
+ saveNumFrequentsFromCursor(cursor);
+
+ // cause a refresh of any views that rely on this data
+ notifyDataSetChanged();
+ }
+
+ /**
+ * Iterates over the {@link Cursor}
+ * Returns position of the first NON Starred Contact
+ * Returns -1 if {@link DisplayType#STARRED_ONLY}
+ * Returns 0 if {@link DisplayType#FREQUENT_ONLY}
+ */
+ protected int getDividerPosition(Cursor cursor) {
+ switch (mDisplayType) {
+ case STREQUENT:
+ if (!cursorIsValid(cursor)) {
+ return 0;
+ }
+ cursor.moveToPosition(-1);
+ while (cursor.moveToNext()) {
+ if (cursor.getInt(mStarredIndex) == 0) {
+ return cursor.getPosition();
+ }
+ }
+
+ // There are not NON Starred contacts in cursor
+ // Set divider positon to end
+ return cursor.getCount();
+ case STARRED_ONLY:
+ // There is no divider
+ return -1;
+ case FREQUENT_ONLY:
+ // Divider is first
+ return 0;
+ default:
+ throw new IllegalStateException("Unrecognized DisplayType " + mDisplayType);
+ }
+ }
+
+ protected ContactEntry createContactEntryFromCursor(Cursor cursor, int position) {
+ // If the loader was canceled we will be given a null cursor.
+ // In that case, show an empty list of contacts.
+ if (!cursorIsValid(cursor) || cursor.getCount() <= position) {
+ return null;
+ }
+
+ cursor.moveToPosition(position);
+ long id = cursor.getLong(mIdIndex);
+ String photoUri = cursor.getString(mPhotoUriIndex);
+ String lookupKey = cursor.getString(mLookupIndex);
+
+ ContactEntry contact = new ContactEntry();
+ String name = cursor.getString(mNameIndex);
+ contact.namePrimary = (name != null) ? name : mResources.getString(R.string.missing_name);
+ contact.status = cursor.getString(mStatusIndex);
+ contact.photoUri = (photoUri != null ? Uri.parse(photoUri) : null);
+ contact.lookupKey = lookupKey;
+ contact.lookupUri = ContentUris.withAppendedId(
+ Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey), id);
+ contact.isFavorite = cursor.getInt(mStarredIndex) > 0;
+
+ // Set presence icon and status message
+ Drawable icon = null;
+ int presence = 0;
+ if (!cursor.isNull(mPresenceIndex)) {
+ presence = cursor.getInt(mPresenceIndex);
+ icon = ContactPresenceIconUtil.getPresenceIcon(mContext, presence);
+ }
+ contact.presenceIcon = icon;
+
+ String statusMessage = null;
+ if (mStatusIndex != 0 && !cursor.isNull(mStatusIndex)) {
+ statusMessage = cursor.getString(mStatusIndex);
+ }
+ // If there is no status message from the contact, but there was a presence value,
+ // then use the default status message string
+ if (statusMessage == null && presence != 0) {
+ statusMessage = ContactStatusUtil.getStatusString(mContext, presence);
+ }
+ contact.status = statusMessage;
+
+ return contact;
+ }
+
+ /**
+ * Returns the number of frequents that will be displayed in the list.
+ */
+ public int getNumFrequents() {
+ return mNumFrequents;
+ }
+
+ @Override
+ public int getCount() {
+ if (!cursorIsValid(mContactCursor)) {
+ return 0;
+ }
+
+ switch (mDisplayType) {
+ case STARRED_ONLY:
+ return getRowCount(mContactCursor.getCount());
+ case STREQUENT:
+ // Takes numbers of rows the Starred Contacts Occupy
+ int starredRowCount = getRowCount(mDividerPosition);
+
+ // Compute the frequent row count which is 1 plus the number of frequents
+ // (to account for the divider) or 0 if there are no frequents.
+ int frequentRowCount = mNumFrequents == 0 ? 0 : mNumFrequents + 1;
+
+ // Return the number of starred plus frequent rows
+ return starredRowCount + frequentRowCount;
+ case FREQUENT_ONLY:
+ // Number of frequent contacts
+ return mContactCursor.getCount();
+ default:
+ throw new IllegalArgumentException("Unrecognized DisplayType " + mDisplayType);
+ }
+ }
+
+ /**
+ * Returns the number of rows required to show the provided number of entries
+ * with the current number of columns.
+ */
+ protected int getRowCount(int entryCount) {
+ return entryCount == 0 ? 0 : ((entryCount - 1) / mColumnCount) + 1;
+ }
+
+ public int getColumnCount() {
+ return mColumnCount;
+ }
+
+ /**
+ * Returns an ArrayList of the {@link ContactEntry}s that are to appear
+ * on the row for the given position.
+ */
+ @Override
+ public ArrayList<ContactEntry> getItem(int position) {
+ ArrayList<ContactEntry> resultList = new ArrayList<ContactEntry>(mColumnCount);
+ int contactIndex = position * mColumnCount;
+
+ switch (mDisplayType) {
+ case FREQUENT_ONLY:
+ resultList.add(createContactEntryFromCursor(mContactCursor, position));
+ break;
+ case STARRED_ONLY:
+ for (int columnCounter = 0; columnCounter < mColumnCount; columnCounter++) {
+ resultList.add(createContactEntryFromCursor(mContactCursor, contactIndex));
+ contactIndex++;
+ }
+ break;
+ case STREQUENT:
+ if (position < getRowCount(mDividerPosition)) {
+ for (int columnCounter = 0; columnCounter < mColumnCount &&
+ contactIndex != mDividerPosition; columnCounter++) {
+ resultList.add(createContactEntryFromCursor(mContactCursor, contactIndex));
+ contactIndex++;
+ }
+ } else {
+ /*
+ * Current position minus how many rows are before the divider and
+ * Minus 1 for the divider itself provides the relative index of the frequent
+ * contact being displayed. Then add the dividerPostion to give the offset
+ * into the contacts cursor to get the absoulte index.
+ */
+ contactIndex = position - getRowCount(mDividerPosition) - 1 + mDividerPosition;
+ resultList.add(createContactEntryFromCursor(mContactCursor, contactIndex));
+ }
+ break;
+ default:
+ throw new IllegalStateException("Unrecognized DisplayType " + mDisplayType);
+ }
+ return resultList;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ // As we show several selectable items for each ListView row,
+ // we can not determine a stable id. But as we don't rely on ListView's selection,
+ // this should not be a problem.
+ return position;
+ }
+
+ @Override
+ public boolean areAllItemsEnabled() {
+ return (mDisplayType != DisplayType.STREQUENT);
+ }
+
+ @Override
+ public boolean isEnabled(int position) {
+ return position != getRowCount(mDividerPosition);
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ int itemViewType = getItemViewType(position);
+
+ if (itemViewType == ViewTypes.DIVIDER) {
+ // Checking For Divider First so not to cast convertView
+ final TextView textView = (TextView) (convertView == null ? getDivider() : convertView);
+ setDividerPadding(textView, position == 0);
+ return textView;
+ }
+
+ ContactTileRow contactTileRowView = (ContactTileRow) convertView;
+ ArrayList<ContactEntry> contactList = getItem(position);
+
+ if (contactTileRowView == null) {
+ // Creating new row if needed
+ contactTileRowView = new ContactTileRow(mContext, itemViewType);
+ }
+
+ contactTileRowView.configureRow(contactList, position == getCount() - 1);
+ return contactTileRowView;
+ }
+
+ /**
+ * Divider uses a list_seperator.xml along with text to denote
+ * the most frequently contacted contacts.
+ */
+ private TextView getDivider() {
+ return MoreContactUtils.createHeaderView(mContext, R.string.favoritesFrequentContacted);
+ }
+
+ private void setDividerPadding(TextView headerTextView, boolean isFirstRow) {
+ MoreContactUtils.setHeaderViewBottomPadding(mContext, headerTextView, isFirstRow);
+ }
+
+ private int getLayoutResourceId(int viewType) {
+ switch (viewType) {
+ case ViewTypes.STARRED:
+ return mIsQuickContactEnabled ?
+ R.layout.contact_tile_starred_quick_contact : R.layout.contact_tile_starred;
+ case ViewTypes.FREQUENT:
+ return R.layout.contact_tile_frequent;
+ default:
+ throw new IllegalArgumentException("Unrecognized viewType " + viewType);
+ }
+ }
+ @Override
+ public int getViewTypeCount() {
+ return ViewTypes.COUNT;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ /*
+ * Returns view type based on {@link DisplayType}.
+ * {@link DisplayType#STARRED_ONLY} and {@link DisplayType#GROUP_MEMBERS}
+ * are {@link ViewTypes#STARRED}.
+ * {@link DisplayType#FREQUENT_ONLY} is {@link ViewTypes#FREQUENT}.
+ * {@link DisplayType#STREQUENT} mixes both {@link ViewTypes}
+ * and also adds in {@link ViewTypes#DIVIDER}.
+ */
+ switch (mDisplayType) {
+ case STREQUENT:
+ if (position < getRowCount(mDividerPosition)) {
+ return ViewTypes.STARRED;
+ } else if (position == getRowCount(mDividerPosition)) {
+ return ViewTypes.DIVIDER;
+ } else {
+ return ViewTypes.FREQUENT;
+ }
+ case STARRED_ONLY:
+ return ViewTypes.STARRED;
+ case FREQUENT_ONLY:
+ return ViewTypes.FREQUENT;
+ default:
+ throw new IllegalStateException("Unrecognized DisplayType " + mDisplayType);
+ }
+ }
+
+ /**
+ * Returns the "frequent header" position. Only available when STREQUENT or
+ * STREQUENT_PHONE_ONLY is used for its display type.
+ */
+ public int getFrequentHeaderPosition() {
+ return getRowCount(mDividerPosition);
+ }
+
+ /**
+ * Acts as a row item composed of {@link ContactTileView}
+ *
+ * TODO: FREQUENT doesn't really need it. Just let {@link #getView} return
+ */
+ private class ContactTileRow extends FrameLayout {
+ private int mItemViewType;
+ private int mLayoutResId;
+
+ public ContactTileRow(Context context, int itemViewType) {
+ super(context);
+ mItemViewType = itemViewType;
+ mLayoutResId = getLayoutResourceId(mItemViewType);
+
+ // Remove row (but not children) from accessibility node tree.
+ setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
+ }
+
+ /**
+ * Configures the row to add {@link ContactEntry}s information to the views
+ */
+ public void configureRow(ArrayList<ContactEntry> list, boolean isLastRow) {
+ int columnCount = mItemViewType == ViewTypes.FREQUENT ? 1 : mColumnCount;
+
+ // Adding tiles to row and filling in contact information
+ for (int columnCounter = 0; columnCounter < columnCount; columnCounter++) {
+ ContactEntry entry =
+ columnCounter < list.size() ? list.get(columnCounter) : null;
+ addTileFromEntry(entry, columnCounter, isLastRow);
+ }
+ }
+
+ private void addTileFromEntry(ContactEntry entry, int childIndex, boolean isLastRow) {
+ final ContactTileView contactTile;
+
+ if (getChildCount() <= childIndex) {
+ contactTile = (ContactTileView) inflate(mContext, mLayoutResId, null);
+ // Note: the layoutparam set here is only actually used for FREQUENT.
+ // We override onMeasure() for STARRED and we don't care the layout param there.
+ Resources resources = mContext.getResources();
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.setMargins(
+ mWhitespaceStartEnd,
+ 0,
+ mWhitespaceStartEnd,
+ 0);
+ contactTile.setLayoutParams(params);
+ contactTile.setPhotoManager(mPhotoManager);
+ contactTile.setListener(mListener);
+ addView(contactTile);
+ } else {
+ contactTile = (ContactTileView) getChildAt(childIndex);
+ }
+ contactTile.loadFromContact(entry);
+
+ switch (mItemViewType) {
+ case ViewTypes.STARRED:
+ // Set padding between tiles. Divide mPaddingInPixels between left and right
+ // tiles as evenly as possible.
+ contactTile.setPaddingRelative(
+ (mPaddingInPixels + 1) / 2, 0,
+ mPaddingInPixels
+ / 2, 0);
+ break;
+ case ViewTypes.FREQUENT:
+ contactTile.setHorizontalDividerVisibility(
+ isLastRow ? View.GONE : View.VISIBLE);
+ break;
+ default:
+ break;
+ }
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ switch (mItemViewType) {
+ case ViewTypes.STARRED:
+ onLayoutForTiles();
+ return;
+ default:
+ super.onLayout(changed, left, top, right, bottom);
+ return;
+ }
+ }
+
+ private void onLayoutForTiles() {
+ final int count = getChildCount();
+
+ // Amount of margin needed on the left is based on difference between offset and padding
+ int childLeft = mWhitespaceStartEnd - (mPaddingInPixels + 1) / 2;
+
+ // Just line up children horizontally.
+ for (int i = 0; i < count; i++) {
+ final int rtlAdjustedIndex = ViewUtil.isViewLayoutRtl(this) ? count - i - 1 : i;
+ final View child = getChildAt(rtlAdjustedIndex);
+
+ // Note MeasuredWidth includes the padding.
+ final int childWidth = child.getMeasuredWidth();
+ child.layout(childLeft, 0, childLeft + childWidth, child.getMeasuredHeight());
+ childLeft += childWidth;
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ switch (mItemViewType) {
+ case ViewTypes.STARRED:
+ onMeasureForTiles(widthMeasureSpec);
+ return;
+ default:
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ return;
+ }
+ }
+
+ private void onMeasureForTiles(int widthMeasureSpec) {
+ final int width = MeasureSpec.getSize(widthMeasureSpec);
+
+ final int childCount = getChildCount();
+ if (childCount == 0) {
+ // Just in case...
+ setMeasuredDimension(width, 0);
+ return;
+ }
+
+ // 1. Calculate image size.
+ // = ([total width] - [total whitespace]) / [child count]
+ //
+ // 2. Set it to width/height of each children.
+ // If we have a remainder, some tiles will have 1 pixel larger width than its height.
+ //
+ // 3. Set the dimensions of itself.
+ // Let width = given width.
+ // Let height = wrap content.
+
+ final int totalWhitespaceInPixels = (mColumnCount - 1) * mPaddingInPixels
+ + mWhitespaceStartEnd * 2;
+
+ // Preferred width / height for images (excluding the padding).
+ // The actual width may be 1 pixel larger than this if we have a remainder.
+ final int imageSize = (width - totalWhitespaceInPixels) / mColumnCount;
+ final int remainder = width - (imageSize * mColumnCount) - totalWhitespaceInPixels;
+
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ final int childWidth = imageSize + child.getPaddingRight() + child.getPaddingLeft()
+ // Compensate for the remainder
+ + (i < remainder ? 1 : 0);
+
+ child.measure(
+ MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
+ );
+ }
+ setMeasuredDimension(width, getChildAt(0).getMeasuredHeight());
+ }
+ }
+
+ protected static class ViewTypes {
+ public static final int COUNT = 4;
+ public static final int STARRED = 0;
+ public static final int DIVIDER = 1;
+ public static final int FREQUENT = 2;
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactTileFrequentView.java b/src/com/android/contacts/common/list/ContactTileFrequentView.java
new file mode 100644
index 0000000..7dcd0a1
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactTileFrequentView.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+import com.android.contacts.common.util.ViewUtil;
+
+/**
+ * A {@link com.android.contacts.common.list.ContactTileView} that is used for most frequently contacted in the People app
+ */
+public class ContactTileFrequentView extends ContactTileView {
+ public ContactTileFrequentView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected boolean isDarkTheme() {
+ return false;
+ }
+
+ @Override
+ protected int getApproximateImageSize() {
+ return ViewUtil.getConstantPreLayoutWidth(getPhotoView());
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactTilePhoneFrequentView.java b/src/com/android/contacts/common/list/ContactTilePhoneFrequentView.java
new file mode 100644
index 0000000..aec93ab
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactTilePhoneFrequentView.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.View;
+
+import com.android.contacts.common.MoreContactUtils;
+import com.android.contacts.common.util.ViewUtil;
+
+/**
+ * A dark version of the {@link com.android.contacts.common.list.ContactTileView} that is used in Dialtacts
+ * for frequently called contacts. Slightly different behavior from superclass...
+ * when you tap it, you want to call the frequently-called number for the
+ * contact, even if that is not the default number for that contact.
+ */
+public class ContactTilePhoneFrequentView extends ContactTileView {
+ private String mPhoneNumberString;
+
+ public ContactTilePhoneFrequentView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected boolean isDarkTheme() {
+ return true;
+ }
+
+ @Override
+ protected int getApproximateImageSize() {
+ return ViewUtil.getConstantPreLayoutWidth(getQuickContact());
+ }
+
+ @Override
+ public void loadFromContact(ContactEntry entry) {
+ super.loadFromContact(entry);
+ mPhoneNumberString = null; // ... in case we're reusing the view
+ if (entry != null) {
+ // Grab the phone-number to call directly... see {@link onClick()}
+ mPhoneNumberString = entry.phoneNumber;
+ }
+ }
+
+ @Override
+ protected OnClickListener createClickListener() {
+ return new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mListener == null) return;
+ if (TextUtils.isEmpty(mPhoneNumberString)) {
+ // Copy "superclass" implementation
+ mListener.onContactSelected(getLookupUri(), MoreContactUtils
+ .getTargetRectFromView(ContactTilePhoneFrequentView.this));
+ } else {
+ // When you tap a frequently-called contact, you want to
+ // call them at the number that you usually talk to them
+ // at (i.e. the one displayed in the UI), regardless of
+ // whether that's their default number.
+ mListener.onCallNumberDirectly(mPhoneNumberString);
+ }
+ }
+ };
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactTileStarredView.java b/src/com/android/contacts/common/list/ContactTileStarredView.java
new file mode 100644
index 0000000..59ef81e
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactTileStarredView.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import com.android.contacts.common.ContactPhotoManager;
+import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+/**
+ * A {@link ContactTileStarredView} displays the contact's picture overlayed with their name
+ * in a square. The actual dimensions are set by
+ * {@link com.android.contacts.common.list.ContactTileAdapter.ContactTileRow}.
+ */
+public class ContactTileStarredView extends ContactTileView {
+
+ /**
+ * The photo manager should display the default image/letter at 80% of its normal size.
+ */
+ private static final float DEFAULT_IMAGE_LETTER_SCALE = 0.8f;
+
+ public ContactTileStarredView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected boolean isDarkTheme() {
+ return false;
+ }
+
+ @Override
+ protected int getApproximateImageSize() {
+ // The picture is the full size of the tile (minus some padding, but we can be generous)
+ return mListener.getApproximateTileWidth();
+ }
+
+ @Override
+ protected DefaultImageRequest getDefaultImageRequest(String displayName, String lookupKey) {
+ return new DefaultImageRequest(displayName, lookupKey, ContactPhotoManager.TYPE_DEFAULT,
+ DEFAULT_IMAGE_LETTER_SCALE, /* offset = */ 0, /* isCircular = */ true);
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactTileView.java b/src/com/android/contacts/common/list/ContactTileView.java
new file mode 100644
index 0000000..172d720
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactTileView.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.QuickContactBadge;
+import android.widget.TextView;
+
+import com.android.contacts.common.ContactPhotoManager;
+import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest;
+import com.android.contacts.common.MoreContactUtils;
+import com.android.contacts.common.R;
+
+/**
+ * A ContactTile displays a contact's picture and name
+ */
+public abstract class ContactTileView extends FrameLayout {
+ private final static String TAG = ContactTileView.class.getSimpleName();
+
+ private Uri mLookupUri;
+ private ImageView mPhoto;
+ private QuickContactBadge mQuickContact;
+ private TextView mName;
+ private TextView mStatus;
+ private TextView mPhoneLabel;
+ private TextView mPhoneNumber;
+ private ContactPhotoManager mPhotoManager = null;
+ private View mPushState;
+ private View mHorizontalDivider;
+ protected Listener mListener;
+
+ public ContactTileView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mName = (TextView) findViewById(R.id.contact_tile_name);
+
+ mQuickContact = (QuickContactBadge) findViewById(R.id.contact_tile_quick);
+ mPhoto = (ImageView) findViewById(R.id.contact_tile_image);
+ mStatus = (TextView) findViewById(R.id.contact_tile_status);
+ mPhoneLabel = (TextView) findViewById(R.id.contact_tile_phone_type);
+ mPhoneNumber = (TextView) findViewById(R.id.contact_tile_phone_number);
+ mPushState = findViewById(R.id.contact_tile_push_state);
+ mHorizontalDivider = findViewById(R.id.contact_tile_horizontal_divider);
+
+ OnClickListener listener = createClickListener();
+ setOnClickListener(listener);
+ }
+
+ protected OnClickListener createClickListener() {
+ return new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (mListener == null) return;
+ mListener.onContactSelected(
+ getLookupUri(),
+ MoreContactUtils.getTargetRectFromView(ContactTileView.this));
+ }
+ };
+ }
+
+ public void setPhotoManager(ContactPhotoManager photoManager) {
+ mPhotoManager = photoManager;
+ }
+
+ /**
+ * Populates the data members to be displayed from the
+ * fields in {@link com.android.contacts.common.list.ContactEntry}
+ */
+ public void loadFromContact(ContactEntry entry) {
+
+ if (entry != null) {
+ mName.setText(getNameForView(entry));
+ mLookupUri = entry.lookupUri;
+
+ if (mStatus != null) {
+ if (entry.status == null) {
+ mStatus.setVisibility(View.GONE);
+ } else {
+ mStatus.setText(entry.status);
+ mStatus.setCompoundDrawablesWithIntrinsicBounds(entry.presenceIcon,
+ null, null, null);
+ mStatus.setVisibility(View.VISIBLE);
+ }
+ }
+
+ if (mPhoneLabel != null) {
+ if (TextUtils.isEmpty(entry.phoneLabel)) {
+ mPhoneLabel.setVisibility(View.GONE);
+ } else {
+ mPhoneLabel.setVisibility(View.VISIBLE);
+ mPhoneLabel.setText(entry.phoneLabel);
+ }
+ }
+
+ if (mPhoneNumber != null) {
+ // TODO: Format number correctly
+ mPhoneNumber.setText(entry.phoneNumber);
+ }
+
+ setVisibility(View.VISIBLE);
+
+ if (mPhotoManager != null) {
+ DefaultImageRequest request = getDefaultImageRequest(entry.namePrimary,
+ entry.lookupKey);
+ configureViewForImage(entry.photoUri == null);
+ if (mPhoto != null) {
+ mPhotoManager.loadPhoto(mPhoto, entry.photoUri, getApproximateImageSize(),
+ isDarkTheme(), isContactPhotoCircular(), request);
+
+ if (mQuickContact != null) {
+ mQuickContact.assignContactUri(mLookupUri);
+ }
+ } else if (mQuickContact != null) {
+ mQuickContact.assignContactUri(mLookupUri);
+ mPhotoManager.loadPhoto(mQuickContact, entry.photoUri,
+ getApproximateImageSize(), isDarkTheme(), isContactPhotoCircular(),
+ request);
+ }
+ } else {
+ Log.w(TAG, "contactPhotoManager not set");
+ }
+
+ if (mPushState != null) {
+ mPushState.setContentDescription(entry.namePrimary);
+ } else if (mQuickContact != null) {
+ mQuickContact.setContentDescription(entry.namePrimary);
+ }
+ } else {
+ setVisibility(View.INVISIBLE);
+ }
+ }
+
+ public void setListener(Listener listener) {
+ mListener = listener;
+ }
+
+ public void setHorizontalDividerVisibility(int visibility) {
+ if (mHorizontalDivider != null) mHorizontalDivider.setVisibility(visibility);
+ }
+
+ public Uri getLookupUri() {
+ return mLookupUri;
+ }
+
+ protected QuickContactBadge getQuickContact() {
+ return mQuickContact;
+ }
+
+ protected View getPhotoView() {
+ return mPhoto;
+ }
+
+ /**
+ * Returns the string that should actually be displayed as the contact's name. Subclasses
+ * can override this to return formatted versions of the name - i.e. first name only.
+ */
+ protected String getNameForView(ContactEntry contactEntry) {
+ return contactEntry.namePrimary;
+ }
+
+ /**
+ * Implemented by subclasses to estimate the size of the picture. This can return -1 if only
+ * a thumbnail is shown anyway
+ */
+ protected abstract int getApproximateImageSize();
+
+ protected abstract boolean isDarkTheme();
+
+ /**
+ * Implemented by subclasses to reconfigure the view's layout and subviews, based on whether
+ * or not the contact has a user-defined photo.
+ *
+ * @param isDefaultImage True if the contact does not have a user-defined contact photo
+ * (which means a default contact image will be applied by the {@link ContactPhotoManager}
+ */
+ protected void configureViewForImage(boolean isDefaultImage) {
+ // No-op by default.
+ }
+
+ /**
+ * Implemented by subclasses to allow them to return a {@link DefaultImageRequest} with the
+ * various image parameters defined to match their own layouts.
+ *
+ * @param displayName The display name of the contact
+ * @param lookupKey The lookup key of the contact
+ * @return A {@link DefaultImageRequest} object with each field configured by the subclass
+ * as desired, or {@code null}.
+ */
+ protected DefaultImageRequest getDefaultImageRequest(String displayName, String lookupKey) {
+ return new DefaultImageRequest(displayName, lookupKey, isContactPhotoCircular());
+ }
+
+ /**
+ * Whether contact photo should be displayed as a circular image. Implemented by subclasses
+ * so they can change which drawables to fetch.
+ */
+ protected boolean isContactPhotoCircular() {
+ return true;
+ }
+
+ public interface Listener {
+ /**
+ * Notification that the contact was selected; no specific action is dictated.
+ */
+ void onContactSelected(Uri contactLookupUri, Rect viewRect);
+ /**
+ * Notification that the specified number is to be called.
+ */
+ void onCallNumberDirectly(String phoneNumber);
+ /**
+ * @return The width of each tile. This doesn't have to be a precise number (e.g. paddings
+ * can be ignored), but is used to load the correct picture size from the database
+ */
+ int getApproximateTileWidth();
+ }
+}
diff --git a/src/com/android/contacts/common/list/ContactsSectionIndexer.java b/src/com/android/contacts/common/list/ContactsSectionIndexer.java
new file mode 100644
index 0000000..db64010
--- /dev/null
+++ b/src/com/android/contacts/common/list/ContactsSectionIndexer.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.text.TextUtils;
+import android.widget.SectionIndexer;
+
+import java.util.Arrays;
+
+/**
+ * A section indexer that is configured with precomputed section titles and
+ * their respective counts.
+ */
+public class ContactsSectionIndexer implements SectionIndexer {
+
+ protected static final String BLANK_HEADER_STRING = "\u2026"; // ellipsis
+
+ private String[] mSections;
+ private int[] mPositions;
+ private int mCount;
+
+ /**
+ * Constructor.
+ *
+ * @param sections a non-null array
+ * @param counts a non-null array of the same size as <code>sections</code>
+ */
+ public ContactsSectionIndexer(String[] sections, int[] counts) {
+ if (sections == null || counts == null) {
+ throw new NullPointerException();
+ }
+
+ if (sections.length != counts.length) {
+ throw new IllegalArgumentException(
+ "The sections and counts arrays must have the same length");
+ }
+
+ // TODO process sections/counts based on current locale and/or specific section titles
+
+ this.mSections = sections;
+ mPositions = new int[counts.length];
+ int position = 0;
+ for (int i = 0; i < counts.length; i++) {
+ if (TextUtils.isEmpty(mSections[i])) {
+ mSections[i] = BLANK_HEADER_STRING;
+ } else if (!mSections[i].equals(BLANK_HEADER_STRING)) {
+ mSections[i] = mSections[i].trim();
+ }
+
+ mPositions[i] = position;
+ position += counts[i];
+ }
+ mCount = position;
+ }
+
+ public Object[] getSections() {
+ return mSections;
+ }
+
+ public int[] getPositions() {
+ return mPositions;
+ }
+
+ public int getPositionForSection(int section) {
+ if (section < 0 || section >= mSections.length) {
+ return -1;
+ }
+
+ return mPositions[section];
+ }
+
+ public int getSectionForPosition(int position) {
+ if (position < 0 || position >= mCount) {
+ return -1;
+ }
+
+ int index = Arrays.binarySearch(mPositions, position);
+
+ /*
+ * Consider this example: section positions are 0, 3, 5; the supplied
+ * position is 4. The section corresponding to position 4 starts at
+ * position 3, so the expected return value is 1. Binary search will not
+ * find 4 in the array and thus will return -insertPosition-1, i.e. -3.
+ * To get from that number to the expected value of 1 we need to negate
+ * and subtract 2.
+ */
+ return index >= 0 ? index : -index - 2;
+ }
+
+ public void setProfileAndFavoritesHeader(String header, int numberOfItemsToAdd) {
+ if (mSections != null) {
+ // Don't do anything if the header is already set properly.
+ if (mSections.length > 0 && header.equals(mSections[0])) {
+ return;
+ }
+
+ // Since the section indexer isn't aware of the profile at the top, we need to add a
+ // special section at the top for it and shift everything else down.
+ String[] tempSections = new String[mSections.length + 1];
+ int[] tempPositions = new int[mPositions.length + 1];
+ tempSections[0] = header;
+ tempPositions[0] = 0;
+ for (int i = 1; i <= mPositions.length; i++) {
+ tempSections[i] = mSections[i - 1];
+ tempPositions[i] = mPositions[i - 1] + numberOfItemsToAdd;
+ }
+ mSections = tempSections;
+ mPositions = tempPositions;
+ mCount = mCount + numberOfItemsToAdd;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/list/CustomContactListFilterActivity.java b/src/com/android/contacts/common/list/CustomContactListFilterActivity.java
new file mode 100644
index 0000000..04337b8
--- /dev/null
+++ b/src/com/android/contacts/common/list/CustomContactListFilterActivity.java
@@ -0,0 +1,948 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.app.ActionBar;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.LoaderManager.LoaderCallbacks;
+import android.app.ProgressDialog;
+import android.content.AsyncTaskLoader;
+import android.content.ContentProviderOperation;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.Loader;
+import android.content.OperationApplicationException;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.RemoteException;
+import android.preference.PreferenceManager;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Groups;
+import android.provider.ContactsContract.Settings;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.MenuItem.OnMenuItemClickListener;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseExpandableListAdapter;
+import android.widget.CheckBox;
+import android.widget.ExpandableListAdapter;
+import android.widget.ExpandableListView;
+import android.widget.ExpandableListView.ExpandableListContextMenuInfo;
+import android.widget.TextView;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.ValuesDelta;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.model.account.GoogleAccountType;
+import com.android.contacts.common.util.EmptyService;
+import com.android.contacts.common.util.LocalizedNameResolver;
+import com.android.contacts.common.util.WeakAsyncTask;
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
+
+/**
+ * Shows a list of all available {@link Groups} available, letting the user
+ * select which ones they want to be visible.
+ */
+public class CustomContactListFilterActivity extends Activity implements
+ ExpandableListView.OnChildClickListener,
+ LoaderCallbacks<CustomContactListFilterActivity.AccountSet> {
+ private static final String TAG = "CustomContactListFilterActivity";
+
+ private static final int ACCOUNT_SET_LOADER_ID = 1;
+
+ private ExpandableListView mList;
+ private DisplayAdapter mAdapter;
+
+ private SharedPreferences mPrefs;
+
+ @Override
+ protected void onCreate(Bundle icicle) {
+ super.onCreate(icicle);
+ setContentView(R.layout.contact_list_filter_custom);
+
+ mList = (ExpandableListView) findViewById(android.R.id.list);
+ mList.setOnChildClickListener(this);
+ mList.setHeaderDividersEnabled(true);
+ mList.setChildDivider(new ColorDrawable(Color.TRANSPARENT));
+
+ mList.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
+ @Override
+ public void onLayoutChange(final View v, final int left, final int top, final int right,
+ final int bottom, final int oldLeft, final int oldTop, final int oldRight,
+ final int oldBottom) {
+ mList.setIndicatorBounds(
+ mList.getWidth() - getResources().getDimensionPixelSize(
+ R.dimen.contact_filter_indicator_padding_end),
+ mList.getWidth() - getResources().getDimensionPixelSize(
+ R.dimen.contact_filter_indicator_padding_start));
+ }
+ });
+
+ mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
+ mAdapter = new DisplayAdapter(this);
+
+ mList.setOnCreateContextMenuListener(this);
+
+ mList.setAdapter(mAdapter);
+
+ ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ // android.R.id.home will be triggered in onOptionsItemSelected()
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ }
+ }
+
+ public static class CustomFilterConfigurationLoader extends AsyncTaskLoader<AccountSet> {
+
+ private AccountSet mAccountSet;
+
+ public CustomFilterConfigurationLoader(Context context) {
+ super(context);
+ }
+
+ @Override
+ public AccountSet loadInBackground() {
+ Context context = getContext();
+ final AccountTypeManager accountTypes = AccountTypeManager.getInstance(context);
+ final ContentResolver resolver = context.getContentResolver();
+
+ final AccountSet accounts = new AccountSet();
+ for (AccountWithDataSet account : accountTypes.getAccounts(false)) {
+ final AccountType accountType = accountTypes.getAccountTypeForAccount(account);
+ if (accountType.isExtension() && !account.hasData(context)) {
+ // Extension with no data -- skip.
+ continue;
+ }
+
+ AccountDisplay accountDisplay =
+ new AccountDisplay(resolver, account.name, account.type, account.dataSet);
+
+ final Uri.Builder groupsUri = Groups.CONTENT_URI.buildUpon()
+ .appendQueryParameter(Groups.ACCOUNT_NAME, account.name)
+ .appendQueryParameter(Groups.ACCOUNT_TYPE, account.type);
+ if (account.dataSet != null) {
+ groupsUri.appendQueryParameter(Groups.DATA_SET, account.dataSet).build();
+ }
+ final Cursor cursor = resolver.query(groupsUri.build(), null, null, null, null);
+ if (cursor == null) {
+ continue;
+ }
+ android.content.EntityIterator iterator =
+ ContactsContract.Groups.newEntityIterator(cursor);
+ try {
+ boolean hasGroups = false;
+
+ // Create entries for each known group
+ while (iterator.hasNext()) {
+ final ContentValues values = iterator.next().getEntityValues();
+ final GroupDelta group = GroupDelta.fromBefore(values);
+ accountDisplay.addGroup(group);
+ hasGroups = true;
+ }
+ // Create single entry handling ungrouped status
+ accountDisplay.mUngrouped =
+ GroupDelta.fromSettings(resolver, account.name, account.type,
+ account.dataSet, hasGroups);
+ accountDisplay.addGroup(accountDisplay.mUngrouped);
+ } finally {
+ iterator.close();
+ }
+
+ accounts.add(accountDisplay);
+ }
+
+ return accounts;
+ }
+
+ @Override
+ public void deliverResult(AccountSet cursor) {
+ if (isReset()) {
+ return;
+ }
+
+ mAccountSet = cursor;
+
+ if (isStarted()) {
+ super.deliverResult(cursor);
+ }
+ }
+
+ @Override
+ protected void onStartLoading() {
+ if (mAccountSet != null) {
+ deliverResult(mAccountSet);
+ }
+ if (takeContentChanged() || mAccountSet == null) {
+ forceLoad();
+ }
+ }
+
+ @Override
+ protected void onStopLoading() {
+ cancelLoad();
+ }
+
+ @Override
+ protected void onReset() {
+ super.onReset();
+ onStopLoading();
+ mAccountSet = null;
+ }
+ }
+
+ @Override
+ protected void onStart() {
+ getLoaderManager().initLoader(ACCOUNT_SET_LOADER_ID, null, this);
+ super.onStart();
+ }
+
+ @Override
+ public Loader<AccountSet> onCreateLoader(int id, Bundle args) {
+ return new CustomFilterConfigurationLoader(this);
+ }
+
+ @Override
+ public void onLoadFinished(Loader<AccountSet> loader, AccountSet data) {
+ mAdapter.setAccounts(data);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<AccountSet> loader) {
+ mAdapter.setAccounts(null);
+ }
+
+ private static final int DEFAULT_SHOULD_SYNC = 1;
+ private static final int DEFAULT_VISIBLE = 0;
+
+ /**
+ * Entry holding any changes to {@link Groups} or {@link Settings} rows,
+ * such as {@link Groups#SHOULD_SYNC} or {@link Groups#GROUP_VISIBLE}.
+ */
+ protected static class GroupDelta extends ValuesDelta {
+ private boolean mUngrouped = false;
+ private boolean mAccountHasGroups;
+
+ private GroupDelta() {
+ super();
+ }
+
+ /**
+ * Build {@link GroupDelta} from the {@link Settings} row for the given
+ * {@link Settings#ACCOUNT_NAME}, {@link Settings#ACCOUNT_TYPE}, and
+ * {@link Settings#DATA_SET}.
+ */
+ public static GroupDelta fromSettings(ContentResolver resolver, String accountName,
+ String accountType, String dataSet, boolean accountHasGroups) {
+ final Uri.Builder settingsUri = Settings.CONTENT_URI.buildUpon()
+ .appendQueryParameter(Settings.ACCOUNT_NAME, accountName)
+ .appendQueryParameter(Settings.ACCOUNT_TYPE, accountType);
+ if (dataSet != null) {
+ settingsUri.appendQueryParameter(Settings.DATA_SET, dataSet);
+ }
+ final Cursor cursor = resolver.query(settingsUri.build(), new String[] {
+ Settings.SHOULD_SYNC, Settings.UNGROUPED_VISIBLE
+ }, null, null, null);
+
+ try {
+ final ContentValues values = new ContentValues();
+ values.put(Settings.ACCOUNT_NAME, accountName);
+ values.put(Settings.ACCOUNT_TYPE, accountType);
+ values.put(Settings.DATA_SET, dataSet);
+
+ if (cursor != null && cursor.moveToFirst()) {
+ // Read existing values when present
+ values.put(Settings.SHOULD_SYNC, cursor.getInt(0));
+ values.put(Settings.UNGROUPED_VISIBLE, cursor.getInt(1));
+ return fromBefore(values).setUngrouped(accountHasGroups);
+ } else {
+ // Nothing found, so treat as create
+ values.put(Settings.SHOULD_SYNC, DEFAULT_SHOULD_SYNC);
+ values.put(Settings.UNGROUPED_VISIBLE, DEFAULT_VISIBLE);
+ return fromAfter(values).setUngrouped(accountHasGroups);
+ }
+ } finally {
+ if (cursor != null) cursor.close();
+ }
+ }
+
+ public static GroupDelta fromBefore(ContentValues before) {
+ final GroupDelta entry = new GroupDelta();
+ entry.mBefore = before;
+ entry.mAfter = new ContentValues();
+ return entry;
+ }
+
+ public static GroupDelta fromAfter(ContentValues after) {
+ final GroupDelta entry = new GroupDelta();
+ entry.mBefore = null;
+ entry.mAfter = after;
+ return entry;
+ }
+
+ protected GroupDelta setUngrouped(boolean accountHasGroups) {
+ mUngrouped = true;
+ mAccountHasGroups = accountHasGroups;
+ return this;
+ }
+
+ @Override
+ public boolean beforeExists() {
+ return mBefore != null;
+ }
+
+ public boolean getShouldSync() {
+ return getAsInteger(mUngrouped ? Settings.SHOULD_SYNC : Groups.SHOULD_SYNC,
+ DEFAULT_SHOULD_SYNC) != 0;
+ }
+
+ public boolean getVisible() {
+ return getAsInteger(mUngrouped ? Settings.UNGROUPED_VISIBLE : Groups.GROUP_VISIBLE,
+ DEFAULT_VISIBLE) != 0;
+ }
+
+ public void putShouldSync(boolean shouldSync) {
+ put(mUngrouped ? Settings.SHOULD_SYNC : Groups.SHOULD_SYNC, shouldSync ? 1 : 0);
+ }
+
+ public void putVisible(boolean visible) {
+ put(mUngrouped ? Settings.UNGROUPED_VISIBLE : Groups.GROUP_VISIBLE, visible ? 1 : 0);
+ }
+
+ private String getAccountType() {
+ return (mBefore == null ? mAfter : mBefore).getAsString(Settings.ACCOUNT_TYPE);
+ }
+
+ public CharSequence getTitle(Context context) {
+ if (mUngrouped) {
+ final String customAllContactsName =
+ LocalizedNameResolver.getAllContactsName(context, getAccountType());
+ if (customAllContactsName != null) {
+ return customAllContactsName;
+ }
+ if (mAccountHasGroups) {
+ return context.getText(R.string.display_ungrouped);
+ } else {
+ return context.getText(R.string.display_all_contacts);
+ }
+ } else {
+ final Integer titleRes = getAsInteger(Groups.TITLE_RES);
+ if (titleRes != null) {
+ final String packageName = getAsString(Groups.RES_PACKAGE);
+ return context.getPackageManager().getText(packageName, titleRes, null);
+ } else {
+ return getAsString(Groups.TITLE);
+ }
+ }
+ }
+
+ /**
+ * Build a possible {@link ContentProviderOperation} to persist any
+ * changes to the {@link Groups} or {@link Settings} row described by
+ * this {@link GroupDelta}.
+ */
+ public ContentProviderOperation buildDiff() {
+ if (isInsert()) {
+ // Only allow inserts for Settings
+ if (mUngrouped) {
+ mAfter.remove(mIdColumn);
+ return ContentProviderOperation.newInsert(Settings.CONTENT_URI)
+ .withValues(mAfter)
+ .build();
+ }
+ else {
+ throw new IllegalStateException("Unexpected diff");
+ }
+ } else if (isUpdate()) {
+ if (mUngrouped) {
+ String accountName = this.getAsString(Settings.ACCOUNT_NAME);
+ String accountType = this.getAsString(Settings.ACCOUNT_TYPE);
+ String dataSet = this.getAsString(Settings.DATA_SET);
+ StringBuilder selection = new StringBuilder(Settings.ACCOUNT_NAME + "=? AND "
+ + Settings.ACCOUNT_TYPE + "=?");
+ String[] selectionArgs;
+ if (dataSet == null) {
+ selection.append(" AND " + Settings.DATA_SET + " IS NULL");
+ selectionArgs = new String[] {accountName, accountType};
+ } else {
+ selection.append(" AND " + Settings.DATA_SET + "=?");
+ selectionArgs = new String[] {accountName, accountType, dataSet};
+ }
+ return ContentProviderOperation.newUpdate(Settings.CONTENT_URI)
+ .withSelection(selection.toString(), selectionArgs)
+ .withValues(mAfter)
+ .build();
+ } else {
+ return ContentProviderOperation.newUpdate(
+ addCallerIsSyncAdapterParameter(Groups.CONTENT_URI))
+ .withSelection(Groups._ID + "=" + this.getId(), null)
+ .withValues(mAfter)
+ .build();
+ }
+ } else {
+ return null;
+ }
+ }
+ }
+
+ private static Uri addCallerIsSyncAdapterParameter(Uri uri) {
+ return uri.buildUpon()
+ .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
+ .build();
+ }
+
+ /**
+ * {@link Comparator} to sort by {@link Groups#_ID}.
+ */
+ private static Comparator<GroupDelta> sIdComparator = new Comparator<GroupDelta>() {
+ public int compare(GroupDelta object1, GroupDelta object2) {
+ final Long id1 = object1.getId();
+ final Long id2 = object2.getId();
+ if (id1 == null && id2 == null) {
+ return 0;
+ } else if (id1 == null) {
+ return -1;
+ } else if (id2 == null) {
+ return 1;
+ } else if (id1 < id2) {
+ return -1;
+ } else if (id1 > id2) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+ };
+
+ /**
+ * Set of all {@link AccountDisplay} entries, one for each source.
+ */
+ protected static class AccountSet extends ArrayList<AccountDisplay> {
+ public ArrayList<ContentProviderOperation> buildDiff() {
+ final ArrayList<ContentProviderOperation> diff = Lists.newArrayList();
+ for (AccountDisplay account : this) {
+ account.buildDiff(diff);
+ }
+ return diff;
+ }
+ }
+
+ /**
+ * {@link GroupDelta} details for a single {@link AccountWithDataSet}, usually shown as
+ * children under a single expandable group.
+ */
+ protected static class AccountDisplay {
+ public final String mName;
+ public final String mType;
+ public final String mDataSet;
+
+ public GroupDelta mUngrouped;
+ public ArrayList<GroupDelta> mSyncedGroups = Lists.newArrayList();
+ public ArrayList<GroupDelta> mUnsyncedGroups = Lists.newArrayList();
+
+ /**
+ * Build an {@link AccountDisplay} covering all {@link Groups} under the
+ * given {@link AccountWithDataSet}.
+ */
+ public AccountDisplay(ContentResolver resolver, String accountName, String accountType,
+ String dataSet) {
+ mName = accountName;
+ mType = accountType;
+ mDataSet = dataSet;
+ }
+
+ /**
+ * Add the given {@link GroupDelta} internally, filing based on its
+ * {@link GroupDelta#getShouldSync()} status.
+ */
+ private void addGroup(GroupDelta group) {
+ if (group.getShouldSync()) {
+ mSyncedGroups.add(group);
+ } else {
+ mUnsyncedGroups.add(group);
+ }
+ }
+
+ /**
+ * Set the {@link GroupDelta#putShouldSync(boolean)} value for all
+ * children {@link GroupDelta} rows.
+ */
+ public void setShouldSync(boolean shouldSync) {
+ final Iterator<GroupDelta> oppositeChildren = shouldSync ?
+ mUnsyncedGroups.iterator() : mSyncedGroups.iterator();
+ while (oppositeChildren.hasNext()) {
+ final GroupDelta child = oppositeChildren.next();
+ setShouldSync(child, shouldSync, false);
+ oppositeChildren.remove();
+ }
+ }
+
+ public void setShouldSync(GroupDelta child, boolean shouldSync) {
+ setShouldSync(child, shouldSync, true);
+ }
+
+ /**
+ * Set {@link GroupDelta#putShouldSync(boolean)}, and file internally
+ * based on updated state.
+ */
+ public void setShouldSync(GroupDelta child, boolean shouldSync, boolean attemptRemove) {
+ child.putShouldSync(shouldSync);
+ if (shouldSync) {
+ if (attemptRemove) {
+ mUnsyncedGroups.remove(child);
+ }
+ mSyncedGroups.add(child);
+ Collections.sort(mSyncedGroups, sIdComparator);
+ } else {
+ if (attemptRemove) {
+ mSyncedGroups.remove(child);
+ }
+ mUnsyncedGroups.add(child);
+ }
+ }
+
+ /**
+ * Build set of {@link ContentProviderOperation} to persist any user
+ * changes to {@link GroupDelta} rows under this {@link AccountWithDataSet}.
+ */
+ public void buildDiff(ArrayList<ContentProviderOperation> diff) {
+ for (GroupDelta group : mSyncedGroups) {
+ final ContentProviderOperation oper = group.buildDiff();
+ if (oper != null) diff.add(oper);
+ }
+ for (GroupDelta group : mUnsyncedGroups) {
+ final ContentProviderOperation oper = group.buildDiff();
+ if (oper != null) diff.add(oper);
+ }
+ }
+ }
+
+ /**
+ * {@link ExpandableListAdapter} that shows {@link GroupDelta} settings,
+ * grouped by {@link AccountWithDataSet} type. Shows footer row when any groups are
+ * unsynced, as determined through {@link AccountDisplay#mUnsyncedGroups}.
+ */
+ protected static class DisplayAdapter extends BaseExpandableListAdapter {
+ private Context mContext;
+ private LayoutInflater mInflater;
+ private AccountTypeManager mAccountTypes;
+ private AccountSet mAccounts;
+
+ private boolean mChildWithPhones = false;
+
+ public DisplayAdapter(Context context) {
+ mContext = context;
+ mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ mAccountTypes = AccountTypeManager.getInstance(context);
+ }
+
+ public void setAccounts(AccountSet accounts) {
+ mAccounts = accounts;
+ notifyDataSetChanged();
+ }
+
+ /**
+ * In group descriptions, show the number of contacts with phone
+ * numbers, in addition to the total contacts.
+ */
+ public void setChildDescripWithPhones(boolean withPhones) {
+ mChildWithPhones = withPhones;
+ }
+
+ @Override
+ public View getGroupView(int groupPosition, boolean isExpanded, View convertView,
+ ViewGroup parent) {
+ if (convertView == null) {
+ convertView = mInflater.inflate(
+ R.layout.custom_contact_list_filter_account, parent, false);
+ }
+
+ final TextView text1 = (TextView)convertView.findViewById(android.R.id.text1);
+ final TextView text2 = (TextView)convertView.findViewById(android.R.id.text2);
+
+ final AccountDisplay account = (AccountDisplay)this.getGroup(groupPosition);
+
+ final AccountType accountType = mAccountTypes.getAccountType(
+ account.mType, account.mDataSet);
+
+ text1.setText(account.mName);
+ text1.setVisibility(account.mName == null ? View.GONE : View.VISIBLE);
+ text2.setText(accountType.getDisplayLabel(mContext));
+
+ final int textColor = mContext.getResources().getColor(isExpanded
+ ? R.color.dialtacts_theme_color
+ : R.color.account_filter_text_color);
+ text1.setTextColor(textColor);
+ text2.setTextColor(textColor);
+
+ return convertView;
+ }
+
+ @Override
+ public View getChildView(int groupPosition, int childPosition, boolean isLastChild,
+ View convertView, ViewGroup parent) {
+ if (convertView == null) {
+ convertView = mInflater.inflate(
+ R.layout.custom_contact_list_filter_group, parent, false);
+ }
+
+ final TextView text1 = (TextView)convertView.findViewById(android.R.id.text1);
+ final TextView text2 = (TextView)convertView.findViewById(android.R.id.text2);
+ final CheckBox checkbox = (CheckBox)convertView.findViewById(android.R.id.checkbox);
+
+ final AccountDisplay account = mAccounts.get(groupPosition);
+ final GroupDelta child = (GroupDelta)this.getChild(groupPosition, childPosition);
+ if (child != null) {
+ // Handle normal group, with title and checkbox
+ final boolean groupVisible = child.getVisible();
+ checkbox.setVisibility(View.VISIBLE);
+ checkbox.setChecked(groupVisible);
+
+ final CharSequence groupTitle = child.getTitle(mContext);
+ text1.setText(groupTitle);
+ text2.setVisibility(View.GONE);
+ } else {
+ // When unknown child, this is "more" footer view
+ checkbox.setVisibility(View.GONE);
+ text1.setText(R.string.display_more_groups);
+ text2.setVisibility(View.GONE);
+ }
+
+ // Show divider at bottom only for the last child.
+ final View dividerBottom = convertView.findViewById(R.id.adapter_divider_bottom);
+ dividerBottom.setVisibility(isLastChild ? View.VISIBLE : View.GONE);
+
+ return convertView;
+ }
+
+ @Override
+ public Object getChild(int groupPosition, int childPosition) {
+ final AccountDisplay account = mAccounts.get(groupPosition);
+ final boolean validChild = childPosition >= 0
+ && childPosition < account.mSyncedGroups.size();
+ if (validChild) {
+ return account.mSyncedGroups.get(childPosition);
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public long getChildId(int groupPosition, int childPosition) {
+ final GroupDelta child = (GroupDelta)getChild(groupPosition, childPosition);
+ if (child != null) {
+ final Long childId = child.getId();
+ return childId != null ? childId : Long.MIN_VALUE;
+ } else {
+ return Long.MIN_VALUE;
+ }
+ }
+
+ @Override
+ public int getChildrenCount(int groupPosition) {
+ // Count is any synced groups, plus possible footer
+ final AccountDisplay account = mAccounts.get(groupPosition);
+ final boolean anyHidden = account.mUnsyncedGroups.size() > 0;
+ return account.mSyncedGroups.size() + (anyHidden ? 1 : 0);
+ }
+
+ @Override
+ public Object getGroup(int groupPosition) {
+ return mAccounts.get(groupPosition);
+ }
+
+ @Override
+ public int getGroupCount() {
+ if (mAccounts == null) {
+ return 0;
+ }
+ return mAccounts.size();
+ }
+
+ @Override
+ public long getGroupId(int groupPosition) {
+ return groupPosition;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public boolean isChildSelectable(int groupPosition, int childPosition) {
+ return true;
+ }
+ }
+
+ /**
+ * Handle any clicks on {@link ExpandableListAdapter} children, which
+ * usually mean toggling its visible state.
+ */
+ @Override
+ public boolean onChildClick(ExpandableListView parent, View view, int groupPosition,
+ int childPosition, long id) {
+ final CheckBox checkbox = (CheckBox)view.findViewById(android.R.id.checkbox);
+
+ final AccountDisplay account = (AccountDisplay)mAdapter.getGroup(groupPosition);
+ final GroupDelta child = (GroupDelta)mAdapter.getChild(groupPosition, childPosition);
+ if (child != null) {
+ checkbox.toggle();
+ child.putVisible(checkbox.isChecked());
+ } else {
+ // Open context menu for bringing back unsynced
+ this.openContextMenu(view);
+ }
+ return true;
+ }
+
+ // TODO: move these definitions to framework constants when we begin
+ // defining this mode through <sync-adapter> tags
+ private static final int SYNC_MODE_UNSUPPORTED = 0;
+ private static final int SYNC_MODE_UNGROUPED = 1;
+ private static final int SYNC_MODE_EVERYTHING = 2;
+
+ protected int getSyncMode(AccountDisplay account) {
+ // TODO: read sync mode through <sync-adapter> definition
+ if (GoogleAccountType.ACCOUNT_TYPE.equals(account.mType) && account.mDataSet == null) {
+ return SYNC_MODE_EVERYTHING;
+ } else {
+ return SYNC_MODE_UNSUPPORTED;
+ }
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View view,
+ ContextMenu.ContextMenuInfo menuInfo) {
+ super.onCreateContextMenu(menu, view, menuInfo);
+
+ // Bail if not working with expandable long-press, or if not child
+ if (!(menuInfo instanceof ExpandableListContextMenuInfo)) return;
+
+ final ExpandableListContextMenuInfo info = (ExpandableListContextMenuInfo) menuInfo;
+ final int groupPosition = ExpandableListView.getPackedPositionGroup(info.packedPosition);
+ final int childPosition = ExpandableListView.getPackedPositionChild(info.packedPosition);
+
+ // Skip long-press on expandable parents
+ if (childPosition == -1) return;
+
+ final AccountDisplay account = (AccountDisplay)mAdapter.getGroup(groupPosition);
+ final GroupDelta child = (GroupDelta)mAdapter.getChild(groupPosition, childPosition);
+
+ // Ignore when selective syncing unsupported
+ final int syncMode = getSyncMode(account);
+ if (syncMode == SYNC_MODE_UNSUPPORTED) return;
+
+ if (child != null) {
+ showRemoveSync(menu, account, child, syncMode);
+ } else {
+ showAddSync(menu, account, syncMode);
+ }
+ }
+
+ protected void showRemoveSync(ContextMenu menu, final AccountDisplay account,
+ final GroupDelta child, final int syncMode) {
+ final CharSequence title = child.getTitle(this);
+
+ menu.setHeaderTitle(title);
+ menu.add(R.string.menu_sync_remove).setOnMenuItemClickListener(
+ new OnMenuItemClickListener() {
+ public boolean onMenuItemClick(MenuItem item) {
+ handleRemoveSync(account, child, syncMode, title);
+ return true;
+ }
+ });
+ }
+
+ protected void handleRemoveSync(final AccountDisplay account, final GroupDelta child,
+ final int syncMode, CharSequence title) {
+ final boolean shouldSyncUngrouped = account.mUngrouped.getShouldSync();
+ if (syncMode == SYNC_MODE_EVERYTHING && shouldSyncUngrouped
+ && !child.equals(account.mUngrouped)) {
+ // Warn before removing this group when it would cause ungrouped to stop syncing
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ final CharSequence removeMessage = this.getString(
+ R.string.display_warn_remove_ungrouped, title);
+ builder.setTitle(R.string.menu_sync_remove);
+ builder.setMessage(removeMessage);
+ builder.setNegativeButton(android.R.string.cancel, null);
+ builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface dialog, int which) {
+ // Mark both this group and ungrouped to stop syncing
+ account.setShouldSync(account.mUngrouped, false);
+ account.setShouldSync(child, false);
+ mAdapter.notifyDataSetChanged();
+ }
+ });
+ builder.show();
+ } else {
+ // Mark this group to not sync
+ account.setShouldSync(child, false);
+ mAdapter.notifyDataSetChanged();
+ }
+ }
+
+ protected void showAddSync(ContextMenu menu, final AccountDisplay account, final int syncMode) {
+ menu.setHeaderTitle(R.string.dialog_sync_add);
+
+ // Create item for each available, unsynced group
+ for (final GroupDelta child : account.mUnsyncedGroups) {
+ if (!child.getShouldSync()) {
+ final CharSequence title = child.getTitle(this);
+ menu.add(title).setOnMenuItemClickListener(new OnMenuItemClickListener() {
+ public boolean onMenuItemClick(MenuItem item) {
+ // Adding specific group for syncing
+ if (child.mUngrouped && syncMode == SYNC_MODE_EVERYTHING) {
+ account.setShouldSync(true);
+ } else {
+ account.setShouldSync(child, true);
+ }
+ mAdapter.notifyDataSetChanged();
+ return true;
+ }
+ });
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private void doSaveAction() {
+ if (mAdapter == null || mAdapter.mAccounts == null) {
+ finish();
+ return;
+ }
+
+ setResult(RESULT_OK);
+
+ final ArrayList<ContentProviderOperation> diff = mAdapter.mAccounts.buildDiff();
+ if (diff.isEmpty()) {
+ finish();
+ return;
+ }
+
+ new UpdateTask(this).execute(diff);
+ }
+
+ /**
+ * Background task that persists changes to {@link Groups#GROUP_VISIBLE},
+ * showing spinner dialog to user while updating.
+ */
+ public static class UpdateTask extends
+ WeakAsyncTask<ArrayList<ContentProviderOperation>, Void, Void, Activity> {
+ private ProgressDialog mProgress;
+
+ public UpdateTask(Activity target) {
+ super(target);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void onPreExecute(Activity target) {
+ final Context context = target;
+
+ mProgress = ProgressDialog.show(
+ context, null, context.getText(R.string.savingDisplayGroups));
+
+ // Before starting this task, start an empty service to protect our
+ // process from being reclaimed by the system.
+ context.startService(new Intent(context, EmptyService.class));
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected Void doInBackground(
+ Activity target, ArrayList<ContentProviderOperation>... params) {
+ final Context context = target;
+ final ContentValues values = new ContentValues();
+ final ContentResolver resolver = context.getContentResolver();
+
+ try {
+ final ArrayList<ContentProviderOperation> diff = params[0];
+ resolver.applyBatch(ContactsContract.AUTHORITY, diff);
+ } catch (RemoteException e) {
+ Log.e(TAG, "Problem saving display groups", e);
+ } catch (OperationApplicationException e) {
+ Log.e(TAG, "Problem saving display groups", e);
+ }
+
+ return null;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void onPostExecute(Activity target, Void result) {
+ final Context context = target;
+
+ try {
+ mProgress.dismiss();
+ } catch (Exception e) {
+ Log.e(TAG, "Error dismissing progress dialog", e);
+ }
+
+ target.finish();
+
+ // Stop the service that was protecting us
+ context.stopService(new Intent(context, EmptyService.class));
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+
+ final MenuItem menuItem = menu.add(Menu.NONE, R.id.menu_save, Menu.NONE,
+ R.string.menu_custom_filter_save);
+ menuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
+
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ // Pretend cancel.
+ setResult(Activity.RESULT_CANCELED);
+ finish();
+ return true;
+ case R.id.menu_save:
+ this.doSaveAction();
+ return true;
+ default:
+ break;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+}
diff --git a/src/com/android/contacts/common/list/DefaultContactListAdapter.java b/src/com/android/contacts/common/list/DefaultContactListAdapter.java
new file mode 100644
index 0000000..994fe1a
--- /dev/null
+++ b/src/com/android/contacts/common/list/DefaultContactListAdapter.java
@@ -0,0 +1,297 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.CursorLoader;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.net.Uri;
+import android.net.Uri.Builder;
+import android.preference.PreferenceManager;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Directory;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.ContactsContract.SearchSnippets;
+import android.text.TextUtils;
+import android.view.View;
+
+import com.android.contacts.common.Experiments;
+import com.android.contacts.common.compat.ContactsCompat;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.preference.ContactsPreferences;
+import com.android.contacts.commonbind.experiments.Flags;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A cursor adapter for the {@link ContactsContract.Contacts#CONTENT_TYPE} content type.
+ */
+public class DefaultContactListAdapter extends ContactListAdapter {
+
+ public static final char SNIPPET_START_MATCH = '[';
+ public static final char SNIPPET_END_MATCH = ']';
+
+ // Contacts contacted within the last 3 days (in seconds)
+ private static final long LAST_TIME_USED_3_DAYS_SEC = 3L * 24 * 60 * 60;
+
+ // Contacts contacted within the last 7 days (in seconds)
+ private static final long LAST_TIME_USED_7_DAYS_SEC = 7L * 24 * 60 * 60;
+
+ // Contacts contacted within the last 14 days (in seconds)
+ private static final long LAST_TIME_USED_14_DAYS_SEC = 14L * 24 * 60 * 60;
+
+ // Contacts contacted within the last 30 days (in seconds)
+ private static final long LAST_TIME_USED_30_DAYS_SEC = 30L * 24 * 60 * 60;
+
+ private static final String TIME_SINCE_LAST_USED_SEC =
+ "(strftime('%s', 'now') - " + Contacts.LAST_TIME_CONTACTED + "/1000)";
+
+ private static final String STREQUENT_SORT =
+ "(CASE WHEN " + TIME_SINCE_LAST_USED_SEC + " < " + LAST_TIME_USED_3_DAYS_SEC +
+ " THEN 0 " +
+ " WHEN " + TIME_SINCE_LAST_USED_SEC + " < " + LAST_TIME_USED_7_DAYS_SEC +
+ " THEN 1 " +
+ " WHEN " + TIME_SINCE_LAST_USED_SEC + " < " + LAST_TIME_USED_14_DAYS_SEC +
+ " THEN 2 " +
+ " WHEN " + TIME_SINCE_LAST_USED_SEC + " < " + LAST_TIME_USED_30_DAYS_SEC +
+ " THEN 3 " +
+ " ELSE 4 END), " +
+ Contacts.TIMES_CONTACTED + " DESC, " +
+ Contacts.STARRED + " DESC";
+
+ public DefaultContactListAdapter(Context context) {
+ super(context);
+ }
+
+ @Override
+ public void configureLoader(CursorLoader loader, long directoryId) {
+ if (loader instanceof FavoritesAndContactsLoader) {
+ ((FavoritesAndContactsLoader) loader).setLoadFavorites(shouldIncludeFavorites());
+ }
+
+ String sortOrder = null;
+ if (isSearchMode()) {
+ String query = getQueryString();
+ if (query == null) query = "";
+ query = query.trim();
+ if (TextUtils.isEmpty(query)) {
+ // Regardless of the directory, we don't want anything returned,
+ // so let's just send a "nothing" query to the local directory.
+ loader.setUri(Contacts.CONTENT_URI);
+ loader.setProjection(getProjection(false));
+ loader.setSelection("0");
+ } else if (isGroupMembersFilter()) {
+ final ContactListFilter filter = getFilter();
+ configureUri(loader, directoryId, filter);
+ // TODO: This is not the normal type to filter URI so we load the non-search
+ // projection. Consider creating a specific group member adapter to make it more
+ // clear.
+ loader.setProjection(getProjection(/* forSearch */ false));
+ loader.setSelection(
+ Contacts.DISPLAY_NAME_PRIMARY + " LIKE ?1 OR " +
+ Contacts.DISPLAY_NAME_ALTERNATIVE + " LIKE ?1");
+ final String[] args = new String[1];
+ args[0] = query + "%";
+ loader.setSelectionArgs(args);
+ } else {
+ final Builder builder = ContactsCompat.getContentUri().buildUpon();
+ appendSearchParameters(builder, query, directoryId);
+ loader.setUri(builder.build());
+ loader.setProjection(getProjection(true));
+ sortOrder = STREQUENT_SORT;
+ }
+ } else {
+ final ContactListFilter filter = getFilter();
+ configureUri(loader, directoryId, filter);
+ if (filter != null
+ && filter.filterType == ContactListFilter.FILTER_TYPE_DEVICE_CONTACTS) {
+ loader.setProjection(getDataProjectionForContacts(false));
+ } else {
+ loader.setProjection(getProjection(false));
+ }
+ configureSelection(loader, directoryId, filter);
+ }
+
+ if (getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY) {
+ if (sortOrder == null) {
+ sortOrder = Contacts.SORT_KEY_PRIMARY;
+ } else {
+ sortOrder += ", " + Contacts.SORT_KEY_PRIMARY;
+ }
+ } else {
+ if (sortOrder == null) {
+ sortOrder = Contacts.SORT_KEY_ALTERNATIVE;
+ } else {
+ sortOrder += ", " + Contacts.SORT_KEY_ALTERNATIVE;
+ }
+ }
+ loader.setSortOrder(sortOrder);
+ }
+
+ private boolean isGroupMembersFilter() {
+ final ContactListFilter filter = getFilter();
+ return filter != null && filter.filterType == ContactListFilter.FILTER_TYPE_GROUP_MEMBERS;
+ }
+
+ private void appendSearchParameters(Builder builder, String query, long directoryId) {
+ builder.appendPath(query); // Builder will encode the query
+ builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
+ String.valueOf(directoryId));
+ if (directoryId != Directory.DEFAULT && directoryId != Directory.LOCAL_INVISIBLE) {
+ builder.appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
+ String.valueOf(getDirectoryResultLimit(getDirectoryById(directoryId))));
+ }
+ builder.appendQueryParameter(SearchSnippets.DEFERRED_SNIPPETING_KEY, "1");
+ }
+
+ protected void configureUri(CursorLoader loader, long directoryId, ContactListFilter filter) {
+ Uri uri = Contacts.CONTENT_URI;
+ if (filter != null) {
+ if (filter.filterType == ContactListFilter.FILTER_TYPE_SINGLE_CONTACT) {
+ String lookupKey = getSelectedContactLookupKey();
+ if (lookupKey != null) {
+ uri = Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey);
+ } else {
+ uri = ContentUris.withAppendedId(Contacts.CONTENT_URI, getSelectedContactId());
+ }
+ } else if (filter.filterType == ContactListFilter.FILTER_TYPE_DEVICE_CONTACTS) {
+ uri = Data.CONTENT_URI;
+ }
+ }
+
+ if (directoryId == Directory.DEFAULT && isSectionHeaderDisplayEnabled()) {
+ uri = ContactListAdapter.buildSectionIndexerUri(uri);
+ }
+
+ // The "All accounts" filter is the same as the entire contents of Directory.DEFAULT
+ if (filter != null
+ && filter.filterType != ContactListFilter.FILTER_TYPE_CUSTOM
+ && filter.filterType != ContactListFilter.FILTER_TYPE_SINGLE_CONTACT) {
+ final Uri.Builder builder = uri.buildUpon();
+ builder.appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT));
+ if (filter.filterType == ContactListFilter.FILTER_TYPE_ACCOUNT
+ || filter.filterType == ContactListFilter.FILTER_TYPE_GROUP_MEMBERS) {
+ filter.addAccountQueryParameterToUrl(builder);
+ }
+ uri = builder.build();
+ }
+
+ loader.setUri(uri);
+ }
+
+ private void configureSelection(
+ CursorLoader loader, long directoryId, ContactListFilter filter) {
+ if (filter == null) {
+ return;
+ }
+
+ if (directoryId != Directory.DEFAULT) {
+ return;
+ }
+
+ StringBuilder selection = new StringBuilder();
+ List<String> selectionArgs = new ArrayList<String>();
+
+ switch (filter.filterType) {
+ case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS: {
+ // We have already added directory=0 to the URI, which takes care of this
+ // filter
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_SINGLE_CONTACT: {
+ // We have already added the lookup key to the URI, which takes care of this
+ // filter
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_STARRED: {
+ selection.append(Contacts.STARRED + "!=0");
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: {
+ selection.append(Contacts.HAS_PHONE_NUMBER + "=1");
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_CUSTOM: {
+ selection.append(Contacts.IN_VISIBLE_GROUP + "=1");
+ if (isCustomFilterForPhoneNumbersOnly()) {
+ selection.append(" AND " + Contacts.HAS_PHONE_NUMBER + "=1");
+ }
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_ACCOUNT: {
+ // We use query parameters for account filter, so no selection to add here.
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_GROUP_MEMBERS: {
+ // TODO(wjang): check if we need it
+ // selection.append(Contacts.IN_VISIBLE_GROUP + "=1");
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_DEVICE_CONTACTS: {
+ selection.append(AccountWithDataSet.LOCAL_ACCOUNT_SELECTION);
+ break;
+ }
+ }
+ loader.setSelection(selection.toString());
+ loader.setSelectionArgs(selectionArgs.toArray(new String[0]));
+ }
+
+ @Override
+ protected void bindView(View itemView, int partition, Cursor cursor, int position) {
+ super.bindView(itemView, partition, cursor, position);
+ final ContactListItemView view = (ContactListItemView)itemView;
+
+ view.setHighlightedPrefix(isSearchMode() ? getUpperCaseQueryString() : null);
+
+ if (isSelectionVisible()) {
+ view.setActivated(isSelectedContact(partition, cursor));
+ }
+
+ bindSectionHeaderAndDivider(view, position, cursor);
+
+ if (isQuickContactEnabled()) {
+ bindQuickContact(view, partition, cursor, ContactQuery.CONTACT_PHOTO_ID,
+ ContactQuery.CONTACT_PHOTO_URI, ContactQuery.CONTACT_ID,
+ ContactQuery.CONTACT_LOOKUP_KEY, ContactQuery.CONTACT_DISPLAY_NAME);
+ } else {
+ if (getDisplayPhotos()) {
+ bindPhoto(view, partition, cursor);
+ }
+ }
+
+ bindNameAndViewId(view, cursor);
+ bindPresenceAndStatusMessage(view, cursor);
+
+ if (isSearchMode()) {
+ bindSearchSnippet(view, cursor);
+ } else {
+ view.setSnippet(null);
+ }
+ }
+
+ private boolean isCustomFilterForPhoneNumbersOnly() {
+ // TODO: this flag should not be stored in shared prefs. It needs to be in the db.
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext());
+ return prefs.getBoolean(ContactsPreferences.PREF_DISPLAY_ONLY_PHONES,
+ ContactsPreferences.PREF_DISPLAY_ONLY_PHONES_DEFAULT);
+ }
+}
diff --git a/src/com/android/contacts/common/list/DirectoryListLoader.java b/src/com/android/contacts/common/list/DirectoryListLoader.java
new file mode 100644
index 0000000..c45a3ca
--- /dev/null
+++ b/src/com/android/contacts/common/list/DirectoryListLoader.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.AsyncTaskLoader;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Handler;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Directory;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.contacts.common.ContactsUtils;
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.DirectoryCompat;
+
+/**
+ * A specialized loader for the list of directories, see {@link Directory}.
+ */
+public class DirectoryListLoader extends AsyncTaskLoader<Cursor> {
+
+ private static final String TAG = "ContactEntryListAdapter";
+
+ public static final int SEARCH_MODE_NONE = 0;
+ public static final int SEARCH_MODE_DEFAULT = 1;
+ public static final int SEARCH_MODE_CONTACT_SHORTCUT = 2;
+ public static final int SEARCH_MODE_DATA_SHORTCUT = 3;
+
+ private static final class DirectoryQuery {
+ public static final String ORDER_BY = Directory._ID;
+
+ public static final String[] PROJECTION = {
+ Directory._ID,
+ Directory.PACKAGE_NAME,
+ Directory.TYPE_RESOURCE_ID,
+ Directory.DISPLAY_NAME,
+ Directory.PHOTO_SUPPORT,
+ };
+
+ public static final int ID = 0;
+ public static final int PACKAGE_NAME = 1;
+ public static final int TYPE_RESOURCE_ID = 2;
+ public static final int DISPLAY_NAME = 3;
+ public static final int PHOTO_SUPPORT = 4;
+
+ public static Uri getDirectoryUri(int mode) {
+ if (mode == SEARCH_MODE_DATA_SHORTCUT || mode == SEARCH_MODE_CONTACT_SHORTCUT) {
+ return Directory.CONTENT_URI;
+ } else {
+ return DirectoryCompat.getContentUri();
+ }
+ }
+ }
+
+ // This is a virtual column created for a MatrixCursor.
+ public static final String DIRECTORY_TYPE = "directoryType";
+
+ private static final String[] RESULT_PROJECTION = {
+ Directory._ID,
+ DIRECTORY_TYPE,
+ Directory.DISPLAY_NAME,
+ Directory.PHOTO_SUPPORT,
+ };
+
+ private final ContentObserver mObserver = new ContentObserver(new Handler()) {
+ @Override
+ public void onChange(boolean selfChange) {
+ forceLoad();
+ }
+ };
+
+ private int mDirectorySearchMode;
+ private boolean mLocalInvisibleDirectoryEnabled;
+
+ private MatrixCursor mDefaultDirectoryList;
+
+ public DirectoryListLoader(Context context) {
+ super(context);
+ }
+
+ public void setDirectorySearchMode(int mode) {
+ mDirectorySearchMode = mode;
+ }
+
+ /**
+ * A flag that indicates whether the {@link Directory#LOCAL_INVISIBLE} directory should
+ * be included in the results.
+ */
+ public void setLocalInvisibleDirectoryEnabled(boolean flag) {
+ this.mLocalInvisibleDirectoryEnabled = flag;
+ }
+
+ @Override
+ protected void onStartLoading() {
+ getContext().getContentResolver().
+ registerContentObserver(DirectoryQuery.getDirectoryUri(mDirectorySearchMode),
+ false, mObserver);
+ forceLoad();
+ }
+
+ @Override
+ protected void onStopLoading() {
+ getContext().getContentResolver().unregisterContentObserver(mObserver);
+ }
+
+ @Override
+ public Cursor loadInBackground() {
+ if (mDirectorySearchMode == SEARCH_MODE_NONE) {
+ return getDefaultDirectories();
+ }
+
+ MatrixCursor result = new MatrixCursor(RESULT_PROJECTION);
+ Context context = getContext();
+ PackageManager pm = context.getPackageManager();
+ String selection;
+ switch (mDirectorySearchMode) {
+ case SEARCH_MODE_DEFAULT:
+ selection = null;
+ break;
+
+ case SEARCH_MODE_CONTACT_SHORTCUT:
+ selection = Directory.SHORTCUT_SUPPORT + "=" + Directory.SHORTCUT_SUPPORT_FULL;
+ break;
+
+ case SEARCH_MODE_DATA_SHORTCUT:
+ selection = Directory.SHORTCUT_SUPPORT + " IN ("
+ + Directory.SHORTCUT_SUPPORT_FULL + ", "
+ + Directory.SHORTCUT_SUPPORT_DATA_ITEMS_ONLY + ")";
+ break;
+
+ default:
+ throw new RuntimeException(
+ "Unsupported directory search mode: " + mDirectorySearchMode);
+ }
+ Cursor cursor = null;
+ try {
+ cursor = context.getContentResolver().query(
+ DirectoryQuery.getDirectoryUri(mDirectorySearchMode),
+ DirectoryQuery.PROJECTION, selection, null, DirectoryQuery.ORDER_BY);
+
+ if (cursor == null) {
+ return result;
+ }
+
+ while(cursor.moveToNext()) {
+ long directoryId = cursor.getLong(DirectoryQuery.ID);
+ if (!mLocalInvisibleDirectoryEnabled
+ && DirectoryCompat.isInvisibleDirectory(directoryId)) {
+ continue;
+ }
+ String directoryType = null;
+
+ String packageName = cursor.getString(DirectoryQuery.PACKAGE_NAME);
+ int typeResourceId = cursor.getInt(DirectoryQuery.TYPE_RESOURCE_ID);
+ if (!TextUtils.isEmpty(packageName) && typeResourceId != 0) {
+ try {
+ directoryType = pm.getResourcesForApplication(packageName)
+ .getString(typeResourceId);
+ } catch (Exception e) {
+ Log.e(TAG, "Cannot obtain directory type from package: " + packageName);
+ }
+ }
+ String displayName = cursor.getString(DirectoryQuery.DISPLAY_NAME);
+ int photoSupport = cursor.getInt(DirectoryQuery.PHOTO_SUPPORT);
+ result.addRow(new Object[]{directoryId, directoryType, displayName, photoSupport});
+ }
+ } catch (RuntimeException e) {
+ Log.w(TAG, "Runtime Exception when querying directory");
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ return result;
+ }
+
+ private Cursor getDefaultDirectories() {
+ if (mDefaultDirectoryList == null) {
+ mDefaultDirectoryList = new MatrixCursor(RESULT_PROJECTION);
+ mDefaultDirectoryList.addRow(new Object[] {
+ Directory.DEFAULT,
+ getContext().getString(R.string.contactsList),
+ null
+ });
+ mDefaultDirectoryList.addRow(new Object[] {
+ Directory.LOCAL_INVISIBLE,
+ getContext().getString(R.string.local_invisible_directory),
+ null
+ });
+ }
+ return mDefaultDirectoryList;
+ }
+
+ @Override
+ protected void onReset() {
+ stopLoading();
+ }
+}
diff --git a/src/com/android/contacts/common/list/DirectoryPartition.java b/src/com/android/contacts/common/list/DirectoryPartition.java
new file mode 100644
index 0000000..ca0dc11
--- /dev/null
+++ b/src/com/android/contacts/common/list/DirectoryPartition.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.provider.ContactsContract.Directory;
+
+import com.android.common.widget.CompositeCursorAdapter;
+
+/**
+ * Model object for a {@link Directory} row.
+ */
+public final class DirectoryPartition extends CompositeCursorAdapter.Partition {
+
+ public static final int STATUS_NOT_LOADED = 0;
+ public static final int STATUS_LOADING = 1;
+ public static final int STATUS_LOADED = 2;
+
+ public static final int RESULT_LIMIT_DEFAULT = -1;
+
+ private long mDirectoryId;
+ private String mContentUri;
+ private String mDirectoryType;
+ private String mDisplayName;
+ private int mStatus;
+ private boolean mPriorityDirectory;
+ private boolean mPhotoSupported;
+ private int mResultLimit = RESULT_LIMIT_DEFAULT;
+ private boolean mDisplayNumber = true;
+
+ private String mLabel;
+
+ public DirectoryPartition(boolean showIfEmpty, boolean hasHeader) {
+ super(showIfEmpty, hasHeader);
+ }
+
+ /**
+ * Directory ID, see {@link Directory}.
+ */
+ public long getDirectoryId() {
+ return mDirectoryId;
+ }
+
+ public void setDirectoryId(long directoryId) {
+ this.mDirectoryId = directoryId;
+ }
+
+ /**
+ * Directory type resolved from {@link Directory#PACKAGE_NAME} and
+ * {@link Directory#TYPE_RESOURCE_ID};
+ */
+ public String getDirectoryType() {
+ return mDirectoryType;
+ }
+
+ public void setDirectoryType(String directoryType) {
+ this.mDirectoryType = directoryType;
+ }
+
+ /**
+ * See {@link Directory#DISPLAY_NAME}.
+ */
+ public String getDisplayName() {
+ return mDisplayName;
+ }
+
+ public void setDisplayName(String displayName) {
+ this.mDisplayName = displayName;
+ }
+
+ public int getStatus() {
+ return mStatus;
+ }
+
+ public void setStatus(int status) {
+ mStatus = status;
+ }
+
+ public boolean isLoading() {
+ return mStatus == STATUS_NOT_LOADED || mStatus == STATUS_LOADING;
+ }
+
+ /**
+ * Returns true if this directory should be loaded before non-priority directories.
+ */
+ public boolean isPriorityDirectory() {
+ return mPriorityDirectory;
+ }
+
+ public void setPriorityDirectory(boolean priorityDirectory) {
+ mPriorityDirectory = priorityDirectory;
+ }
+
+ /**
+ * Returns true if this directory supports photos.
+ */
+ public boolean isPhotoSupported() {
+ return mPhotoSupported;
+ }
+
+ public void setPhotoSupported(boolean flag) {
+ this.mPhotoSupported = flag;
+ }
+
+ /**
+ * Max number of results for this directory. Defaults to {@link #RESULT_LIMIT_DEFAULT} which
+ * implies using the adapter's
+ * {@link com.android.contacts.common.list.ContactListAdapter#getDirectoryResultLimit()}
+ */
+ public int getResultLimit() {
+ return mResultLimit;
+ }
+
+ public void setResultLimit(int resultLimit) {
+ mResultLimit = resultLimit;
+ }
+
+ /**
+ * Used by extended directories to specify a custom content URI. Extended directories MUST have
+ * a content URI
+ */
+ public String getContentUri() {
+ return mContentUri;
+ }
+
+ public void setContentUri(String contentUri) {
+ mContentUri = contentUri;
+ }
+
+ /**
+ * A label to display in the header next to the display name.
+ */
+ public String getLabel() {
+ return mLabel;
+ }
+
+ public void setLabel(String label) {
+ mLabel = label;
+ }
+
+ @Override
+ public String toString() {
+ return "DirectoryPartition{" +
+ "mDirectoryId=" + mDirectoryId +
+ ", mContentUri='" + mContentUri + '\'' +
+ ", mDirectoryType='" + mDirectoryType + '\'' +
+ ", mDisplayName='" + mDisplayName + '\'' +
+ ", mStatus=" + mStatus +
+ ", mPriorityDirectory=" + mPriorityDirectory +
+ ", mPhotoSupported=" + mPhotoSupported +
+ ", mResultLimit=" + mResultLimit +
+ ", mLabel='" + mLabel + '\'' +
+ '}';
+ }
+
+ /**
+ * Whether or not to display the phone number in app that have that option - Dialer. If false,
+ * Phone Label should be used instead of Phone Number.
+ */
+ public boolean isDisplayNumber() {
+ return mDisplayNumber;
+ }
+
+ public void setDisplayNumber(boolean displayNumber) {
+ mDisplayNumber = displayNumber;
+ }
+}
diff --git a/src/com/android/contacts/common/list/FavoritesAndContactsLoader.java b/src/com/android/contacts/common/list/FavoritesAndContactsLoader.java
new file mode 100644
index 0000000..d1ae911
--- /dev/null
+++ b/src/com/android/contacts/common/list/FavoritesAndContactsLoader.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.content.CursorLoader;
+import android.database.Cursor;
+import android.database.MergeCursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.ContactsContract.Contacts;
+
+import com.google.common.collect.Lists;
+
+import java.util.List;
+
+/**
+ * A loader for use in the default contact list, which will also query for favorite contacts
+ * if configured to do so.
+ */
+public class FavoritesAndContactsLoader extends CursorLoader {
+
+ private boolean mLoadFavorites;
+
+ private String[] mProjection;
+
+ private Uri mExtraUri;
+ private String[] mExtraProjection;
+ private String mExtraSelection;
+ private String[] mExtraSelectionArgs;
+ private boolean mMergeExtraContactsAfterPrimary;
+
+ public FavoritesAndContactsLoader(Context context) {
+ super(context);
+ }
+
+ /** Whether to load favorites and merge results in before any other results. */
+ public void setLoadFavorites(boolean flag) {
+ mLoadFavorites = flag;
+ }
+
+ public void setProjection(String[] projection) {
+ super.setProjection(projection);
+ mProjection = projection;
+ }
+
+ /** Configure an extra query and merge results in before the primary results. */
+ public void setLoadExtraContactsFirst(Uri uri, String[] projection) {
+ mExtraUri = uri;
+ mExtraProjection = projection;
+ mMergeExtraContactsAfterPrimary = false;
+ }
+
+ /** Configure an extra query and merge results in after the primary results. */
+ public void setLoadExtraContactsLast(Uri uri, String[] projection, String selection,
+ String[] selectionArgs) {
+ mExtraUri = uri;
+ mExtraProjection = projection;
+ mExtraSelection = selection;
+ mExtraSelectionArgs = selectionArgs;
+ mMergeExtraContactsAfterPrimary = true;
+ }
+
+ private boolean canLoadExtraContacts() {
+ return mExtraUri != null && mExtraProjection != null;
+ }
+
+ @Override
+ public Cursor loadInBackground() {
+ List<Cursor> cursors = Lists.newArrayList();
+ if (mLoadFavorites) {
+ cursors.add(loadFavoritesContacts());
+ }
+ if (canLoadExtraContacts() && !mMergeExtraContactsAfterPrimary) {
+ cursors.add(loadExtraContacts());
+ }
+ // ContactsCursor.loadInBackground() can return null; MergeCursor
+ // correctly handles null cursors.
+ Cursor cursor = null;
+ try {
+ cursor = super.loadInBackground();
+ } catch (NullPointerException | SecurityException e) {
+ // Ignore NPEs and SecurityExceptions thrown by providers
+ }
+ final Cursor contactsCursor = cursor;
+ cursors.add(contactsCursor);
+ if (canLoadExtraContacts() && mMergeExtraContactsAfterPrimary) {
+ cursors.add(loadExtraContacts());
+ }
+ return new MergeCursor(cursors.toArray(new Cursor[cursors.size()])) {
+ @Override
+ public Bundle getExtras() {
+ // Need to get the extras from the contacts cursor.
+ return contactsCursor == null ? new Bundle() : contactsCursor.getExtras();
+ }
+ };
+ }
+
+ private Cursor loadExtraContacts() {
+ return getContext().getContentResolver().query(
+ mExtraUri, mExtraProjection, mExtraSelection, mExtraSelectionArgs, null);
+ }
+
+ private Cursor loadFavoritesContacts() {
+ return getContext().getContentResolver().query(
+ Contacts.CONTENT_URI, mProjection, Contacts.STARRED + "=?", new String[]{"1"},
+ getSortOrder());
+ }
+}
diff --git a/src/com/android/contacts/common/list/IndexerListAdapter.java b/src/com/android/contacts/common/list/IndexerListAdapter.java
new file mode 100644
index 0000000..032bb53
--- /dev/null
+++ b/src/com/android/contacts/common/list/IndexerListAdapter.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ListView;
+import android.widget.SectionIndexer;
+
+/**
+ * A list adapter that supports section indexer and a pinned header.
+ */
+public abstract class IndexerListAdapter extends PinnedHeaderListAdapter implements SectionIndexer {
+
+ protected Context mContext;
+ private SectionIndexer mIndexer;
+ private int mIndexedPartition = 0;
+ private boolean mSectionHeaderDisplayEnabled;
+ private View mHeader;
+
+ /**
+ * An item view is displayed differently depending on whether it is placed
+ * at the beginning, middle or end of a section. It also needs to know the
+ * section header when it is at the beginning of a section. This object
+ * captures all this configuration.
+ */
+ public static final class Placement {
+ private int position = ListView.INVALID_POSITION;
+ public boolean firstInSection;
+ public boolean lastInSection;
+ public String sectionHeader;
+
+ public void invalidate() {
+ position = ListView.INVALID_POSITION;
+ }
+ }
+
+ private Placement mPlacementCache = new Placement();
+
+ /**
+ * Constructor.
+ */
+ public IndexerListAdapter(Context context) {
+ super(context);
+ mContext = context;
+ }
+
+ /**
+ * Creates a section header view that will be pinned at the top of the list
+ * as the user scrolls.
+ */
+ protected abstract View createPinnedSectionHeaderView(Context context, ViewGroup parent);
+
+ /**
+ * Sets the title in the pinned header as the user scrolls.
+ */
+ protected abstract void setPinnedSectionTitle(View pinnedHeaderView, String title);
+
+ public boolean isSectionHeaderDisplayEnabled() {
+ return mSectionHeaderDisplayEnabled;
+ }
+
+ public void setSectionHeaderDisplayEnabled(boolean flag) {
+ this.mSectionHeaderDisplayEnabled = flag;
+ }
+
+ public int getIndexedPartition() {
+ return mIndexedPartition;
+ }
+
+ public void setIndexedPartition(int partition) {
+ this.mIndexedPartition = partition;
+ }
+
+ public SectionIndexer getIndexer() {
+ return mIndexer;
+ }
+
+ public void setIndexer(SectionIndexer indexer) {
+ mIndexer = indexer;
+ mPlacementCache.invalidate();
+ }
+
+ public Object[] getSections() {
+ if (mIndexer == null) {
+ return new String[] { " " };
+ } else {
+ return mIndexer.getSections();
+ }
+ }
+
+ /**
+ * @return relative position of the section in the indexed partition
+ */
+ public int getPositionForSection(int sectionIndex) {
+ if (mIndexer == null) {
+ return -1;
+ }
+
+ return mIndexer.getPositionForSection(sectionIndex);
+ }
+
+ /**
+ * @param position relative position in the indexed partition
+ */
+ public int getSectionForPosition(int position) {
+ if (mIndexer == null) {
+ return -1;
+ }
+
+ return mIndexer.getSectionForPosition(position);
+ }
+
+ @Override
+ public int getPinnedHeaderCount() {
+ if (isSectionHeaderDisplayEnabled()) {
+ return super.getPinnedHeaderCount() + 1;
+ } else {
+ return super.getPinnedHeaderCount();
+ }
+ }
+
+ @Override
+ public View getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent) {
+ if (isSectionHeaderDisplayEnabled() && viewIndex == getPinnedHeaderCount() - 1) {
+ if (mHeader == null) {
+ mHeader = createPinnedSectionHeaderView(mContext, parent);
+ }
+ return mHeader;
+ } else {
+ return super.getPinnedHeaderView(viewIndex, convertView, parent);
+ }
+ }
+
+ @Override
+ public void configurePinnedHeaders(PinnedHeaderListView listView) {
+ super.configurePinnedHeaders(listView);
+
+ if (!isSectionHeaderDisplayEnabled()) {
+ return;
+ }
+
+ int index = getPinnedHeaderCount() - 1;
+ if (mIndexer == null || getCount() == 0) {
+ listView.setHeaderInvisible(index, false);
+ } else {
+ int listPosition = listView.getPositionAt(listView.getTotalTopPinnedHeaderHeight());
+ int position = listPosition - listView.getHeaderViewsCount();
+
+ int section = -1;
+ int partition = getPartitionForPosition(position);
+ if (partition == mIndexedPartition) {
+ int offset = getOffsetInPartition(position);
+ if (offset != -1) {
+ section = getSectionForPosition(offset);
+ }
+ }
+
+ if (section == -1) {
+ listView.setHeaderInvisible(index, false);
+ } else {
+ View topChild = listView.getChildAt(listPosition);
+ if (topChild != null) {
+ // Match the pinned header's height to the height of the list item.
+ mHeader.setMinimumHeight(topChild.getMeasuredHeight());
+ }
+ setPinnedSectionTitle(mHeader, (String)mIndexer.getSections()[section]);
+
+ // Compute the item position where the current partition begins
+ int partitionStart = getPositionForPartition(mIndexedPartition);
+ if (hasHeader(mIndexedPartition)) {
+ partitionStart++;
+ }
+
+ // Compute the item position where the next section begins
+ int nextSectionPosition = partitionStart + getPositionForSection(section + 1);
+ boolean isLastInSection = position == nextSectionPosition - 1;
+ listView.setFadingHeader(index, listPosition, isLastInSection);
+ }
+ }
+ }
+
+ /**
+ * Computes the item's placement within its section and populates the {@code placement}
+ * object accordingly. Please note that the returned object is volatile and should be
+ * copied if the result needs to be used later.
+ */
+ public Placement getItemPlacementInSection(int position) {
+ if (mPlacementCache.position == position) {
+ return mPlacementCache;
+ }
+
+ mPlacementCache.position = position;
+ if (isSectionHeaderDisplayEnabled()) {
+ int section = getSectionForPosition(position);
+ if (section != -1 && getPositionForSection(section) == position) {
+ mPlacementCache.firstInSection = true;
+ mPlacementCache.sectionHeader = (String)getSections()[section];
+ } else {
+ mPlacementCache.firstInSection = false;
+ mPlacementCache.sectionHeader = null;
+ }
+
+ mPlacementCache.lastInSection = (getPositionForSection(section + 1) - 1 == position);
+ } else {
+ mPlacementCache.firstInSection = false;
+ mPlacementCache.lastInSection = false;
+ mPlacementCache.sectionHeader = null;
+ }
+ return mPlacementCache;
+ }
+}
diff --git a/src/com/android/contacts/common/list/MultiSelectEntryContactListAdapter.java b/src/com/android/contacts/common/list/MultiSelectEntryContactListAdapter.java
new file mode 100644
index 0000000..9ab6e1c
--- /dev/null
+++ b/src/com/android/contacts/common/list/MultiSelectEntryContactListAdapter.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.provider.ContactsContract;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.CheckBox;
+
+import java.util.TreeSet;
+
+/**
+ * An extension of the default contact adapter that adds checkboxes and the ability
+ * to select multiple contacts.
+ */
+public abstract class MultiSelectEntryContactListAdapter extends ContactEntryListAdapter {
+
+ private SelectedContactsListener mSelectedContactsListener;
+ private DeleteContactListener mDeleteContactListener;
+ private TreeSet<Long> mSelectedContactIds = new TreeSet<Long>();
+ private boolean mDisplayCheckBoxes;
+ private final int mContactIdColumnIndex;
+
+ public interface SelectedContactsListener {
+ void onSelectedContactsChanged();
+ void onSelectedContactsChangedViaCheckBox();
+ }
+
+ public interface DeleteContactListener {
+ void onContactDeleteClicked(int position);
+ }
+
+ /**
+ * @param contactIdColumnIndex the column index of the contact ID in the underlying cursor;
+ * it is passed in so that this adapter can support different kinds of contact
+ * lists (e.g. aggregate contacts or raw contacts).
+ */
+ public MultiSelectEntryContactListAdapter(Context context, int contactIdColumnIndex) {
+ super(context);
+ mContactIdColumnIndex = contactIdColumnIndex;
+ }
+
+ /**
+ * Returns the column index of the contact ID in the underlying cursor; the contact ID
+ * retrieved using this index is the value that is selected by this adapter (and returned
+ * by {@link #getSelectedContactIds}).
+ */
+ public int getContactColumnIdIndex() {
+ return mContactIdColumnIndex;
+ }
+
+ public DeleteContactListener getDeleteContactListener() {
+ return mDeleteContactListener;
+ }
+
+ public void setDeleteContactListener(DeleteContactListener deleteContactListener) {
+ mDeleteContactListener = deleteContactListener;
+ }
+
+ public void setSelectedContactsListener(SelectedContactsListener listener) {
+ mSelectedContactsListener = listener;
+ }
+
+ /**
+ * Returns set of selected contacts.
+ */
+ public TreeSet<Long> getSelectedContactIds() {
+ return mSelectedContactIds;
+ }
+
+ /**
+ * Returns the selected contacts as an array.
+ */
+ public long[] getSelectedContactIdsArray() {
+ final Long[] contactIds = mSelectedContactIds.toArray(
+ new Long[mSelectedContactIds.size()]);
+ final long[] result = new long[contactIds.length];
+ for (int i = 0; i < contactIds.length; i++) {
+ result[i] = contactIds[i];
+ }
+ return result;
+ }
+
+ /**
+ * Update set of selected contacts. This changes which checkboxes are set.
+ */
+ public void setSelectedContactIds(TreeSet<Long> selectedContactIds) {
+ this.mSelectedContactIds = selectedContactIds;
+ notifyDataSetChanged();
+ if (mSelectedContactsListener != null) {
+ mSelectedContactsListener.onSelectedContactsChanged();
+ }
+ }
+
+ /**
+ * Shows checkboxes beside contacts if {@param displayCheckBoxes} is {@code TRUE}.
+ * Not guaranteed to work with all configurations of this adapter.
+ */
+ public void setDisplayCheckBoxes(boolean showCheckBoxes) {
+ if (!mDisplayCheckBoxes && showCheckBoxes) {
+ setSelectedContactIds(new TreeSet<Long>());
+ }
+ mDisplayCheckBoxes = showCheckBoxes;
+ notifyDataSetChanged();
+ if (mSelectedContactsListener != null) {
+ mSelectedContactsListener.onSelectedContactsChanged();
+ }
+ }
+
+ /**
+ * Checkboxes are being displayed beside contacts.
+ */
+ public boolean isDisplayingCheckBoxes() {
+ return mDisplayCheckBoxes;
+ }
+
+ /**
+ * Toggle the checkbox beside the contact for {@param contactId}.
+ */
+ public void toggleSelectionOfContactId(long contactId) {
+ if (mSelectedContactIds.contains(contactId)) {
+ mSelectedContactIds.remove(contactId);
+ } else {
+ mSelectedContactIds.add(contactId);
+ }
+ notifyDataSetChanged();
+ if (mSelectedContactsListener != null) {
+ mSelectedContactsListener.onSelectedContactsChanged();
+ }
+ }
+
+ @Override
+ public long getItemId(int position) {
+ Cursor cursor = (Cursor) getItem(position);
+ if (cursor != null) {
+ return cursor.getLong(getContactColumnIdIndex());
+ }
+ return 0;
+ }
+
+ @Override
+ protected void bindView(View itemView, int partition, Cursor cursor, int position) {
+ super.bindView(itemView, partition, cursor, position);
+ final ContactListItemView view = (ContactListItemView) itemView;
+ bindViewId(view, cursor, getContactColumnIdIndex());
+ bindCheckBox(view, cursor, position, partition == ContactsContract.Directory.DEFAULT);
+ }
+
+ private void bindCheckBox(ContactListItemView view, Cursor cursor, int position,
+ boolean isLocalDirectory) {
+ // Disable clicking on all contacts from remote directories when showing check boxes. We do
+ // this by telling the view to handle clicking itself.
+ view.setClickable(!isLocalDirectory && mDisplayCheckBoxes);
+ // Only show checkboxes if mDisplayCheckBoxes is enabled. Also, never show the
+ // checkbox for other directory contacts except local directory.
+ if (!mDisplayCheckBoxes || !isLocalDirectory) {
+ view.hideCheckBox();
+ return;
+ }
+ final CheckBox checkBox = view.getCheckBox();
+ final long contactId = cursor.getLong(mContactIdColumnIndex);
+ checkBox.setChecked(mSelectedContactIds.contains(contactId));
+ checkBox.setTag(contactId);
+ checkBox.setOnClickListener(mCheckBoxClickListener);
+ }
+
+ private final OnClickListener mCheckBoxClickListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ final CheckBox checkBox = (CheckBox) v;
+ final Long contactId = (Long) checkBox.getTag();
+ if (checkBox.isChecked()) {
+ mSelectedContactIds.add(contactId);
+ } else {
+ mSelectedContactIds.remove(contactId);
+ }
+ notifyDataSetChanged();
+ if (mSelectedContactsListener != null) {
+ mSelectedContactsListener.onSelectedContactsChangedViaCheckBox();
+ }
+ }
+ };
+}
diff --git a/src/com/android/contacts/common/list/OnPhoneNumberPickerActionListener.java b/src/com/android/contacts/common/list/OnPhoneNumberPickerActionListener.java
new file mode 100644
index 0000000..fe23054
--- /dev/null
+++ b/src/com/android/contacts/common/list/OnPhoneNumberPickerActionListener.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.app.ActionBar;
+import android.content.Intent;
+import android.net.Uri;
+
+/**
+ * Action callbacks that can be sent by a phone number picker.
+ */
+public interface OnPhoneNumberPickerActionListener {
+ public static final int CALL_INITIATION_UNKNOWN = 0;
+
+ /**
+ * Returns the selected phone number uri to the requester.
+ */
+ void onPickDataUri(Uri dataUri, boolean isVideoCall, int callInitiationType);
+
+ /**
+ * Returns the specified phone number to the requester.
+ * May call the specified phone number, either as an audio or video call.
+ */
+ void onPickPhoneNumber(String phoneNumber, boolean isVideoCall, int callInitiationType);
+
+ /**
+ * Returns the selected number as a shortcut intent.
+ */
+ void onShortcutIntentCreated(Intent intent);
+
+ /**
+ * Called when home menu in {@link ActionBar} is clicked by the user.
+ */
+ void onHomeInActionBarSelected();
+}
diff --git a/src/com/android/contacts/common/list/PhoneNumberListAdapter.java b/src/com/android/contacts/common/list/PhoneNumberListAdapter.java
new file mode 100644
index 0000000..348cac2
--- /dev/null
+++ b/src/com/android/contacts/common/list/PhoneNumberListAdapter.java
@@ -0,0 +1,657 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.CursorLoader;
+import android.database.Cursor;
+import android.net.Uri;
+import android.net.Uri.Builder;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Callable;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.SipAddress;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Directory;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.contacts.common.CallUtil;
+import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest;
+import com.android.contacts.common.ContactsUtils;
+import com.android.contacts.common.GeoUtil;
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.CallableCompat;
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.compat.DirectoryCompat;
+import com.android.contacts.common.compat.PhoneCompat;
+import com.android.contacts.common.extensions.ExtendedPhoneDirectoriesManager;
+import com.android.contacts.common.extensions.ExtensionsFactory;
+import com.android.contacts.common.preference.ContactsPreferences;
+import com.android.contacts.common.util.Constants;
+
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A cursor adapter for the {@link Phone#CONTENT_ITEM_TYPE} and
+ * {@link SipAddress#CONTENT_ITEM_TYPE}.
+ *
+ * By default this adapter just handles phone numbers. When {@link #setUseCallableUri(boolean)} is
+ * called with "true", this adapter starts handling SIP addresses too, by using {@link Callable}
+ * API instead of {@link Phone}.
+ */
+public class PhoneNumberListAdapter extends ContactEntryListAdapter {
+
+ private static final String TAG = PhoneNumberListAdapter.class.getSimpleName();
+
+ public interface Listener {
+ void onVideoCallIconClicked(int position);
+ }
+
+ // A list of extended directories to add to the directories from the database
+ private final List<DirectoryPartition> mExtendedDirectories;
+
+ // Extended directories will have ID's that are higher than any of the id's from the database,
+ // so that we can identify them and set them up properly. If no extended directories
+ // exist, this will be Long.MAX_VALUE
+ private long mFirstExtendedDirectoryId = Long.MAX_VALUE;
+
+ public static class PhoneQuery {
+
+ /**
+ * Optional key used as part of a JSON lookup key to specify an analytics category
+ * associated with the row.
+ */
+ public static final String ANALYTICS_CATEGORY = "analytics_category";
+
+ /**
+ * Optional key used as part of a JSON lookup key to specify an analytics action associated
+ * with the row.
+ */
+ public static final String ANALYTICS_ACTION = "analytics_action";
+
+ /**
+ * Optional key used as part of a JSON lookup key to specify an analytics value associated
+ * with the row.
+ */
+ public static final String ANALYTICS_VALUE = "analytics_value";
+
+ public static final String[] PROJECTION_PRIMARY_INTERNAL = new String[] {
+ Phone._ID, // 0
+ Phone.TYPE, // 1
+ Phone.LABEL, // 2
+ Phone.NUMBER, // 3
+ Phone.CONTACT_ID, // 4
+ Phone.LOOKUP_KEY, // 5
+ Phone.PHOTO_ID, // 6
+ Phone.DISPLAY_NAME_PRIMARY, // 7
+ Phone.PHOTO_THUMBNAIL_URI, // 8
+ };
+
+ public static final String[] PROJECTION_PRIMARY;
+
+ static {
+ final List<String> projectionList = Lists.newArrayList(PROJECTION_PRIMARY_INTERNAL);
+ if (CompatUtils.isMarshmallowCompatible()) {
+ projectionList.add(Phone.CARRIER_PRESENCE); // 9
+ }
+ PROJECTION_PRIMARY = projectionList.toArray(new String[projectionList.size()]);
+ }
+
+ public static final String[] PROJECTION_ALTERNATIVE_INTERNAL = new String[] {
+ Phone._ID, // 0
+ Phone.TYPE, // 1
+ Phone.LABEL, // 2
+ Phone.NUMBER, // 3
+ Phone.CONTACT_ID, // 4
+ Phone.LOOKUP_KEY, // 5
+ Phone.PHOTO_ID, // 6
+ Phone.DISPLAY_NAME_ALTERNATIVE, // 7
+ Phone.PHOTO_THUMBNAIL_URI, // 8
+ };
+
+ public static final String[] PROJECTION_ALTERNATIVE;
+
+ static {
+ final List<String> projectionList = Lists.newArrayList(PROJECTION_ALTERNATIVE_INTERNAL);
+ if (CompatUtils.isMarshmallowCompatible()) {
+ projectionList.add(Phone.CARRIER_PRESENCE); // 9
+ }
+ PROJECTION_ALTERNATIVE = projectionList.toArray(new String[projectionList.size()]);
+ }
+
+ public static final int PHONE_ID = 0;
+ public static final int PHONE_TYPE = 1;
+ public static final int PHONE_LABEL = 2;
+ public static final int PHONE_NUMBER = 3;
+ public static final int CONTACT_ID = 4;
+ public static final int LOOKUP_KEY = 5;
+ public static final int PHOTO_ID = 6;
+ public static final int DISPLAY_NAME = 7;
+ public static final int PHOTO_URI = 8;
+ public static final int CARRIER_PRESENCE = 9;
+ }
+
+ private static final String IGNORE_NUMBER_TOO_LONG_CLAUSE =
+ "length(" + Phone.NUMBER + ") < 1000";
+
+ private final CharSequence mUnknownNameText;
+ private final String mCountryIso;
+
+ private ContactListItemView.PhotoPosition mPhotoPosition;
+
+ private boolean mUseCallableUri;
+
+ private Listener mListener;
+
+ private boolean mIsVideoEnabled;
+ private boolean mIsPresenceEnabled;
+
+ public PhoneNumberListAdapter(Context context) {
+ super(context);
+ setDefaultFilterHeaderText(R.string.list_filter_phones);
+ mUnknownNameText = context.getText(android.R.string.unknownName);
+ mCountryIso = GeoUtil.getCurrentCountryIso(context);
+
+ final ExtendedPhoneDirectoriesManager manager
+ = ExtensionsFactory.getExtendedPhoneDirectoriesManager();
+ if (manager != null) {
+ mExtendedDirectories = manager.getExtendedDirectories(mContext);
+ } else {
+ // Empty list to avoid sticky NPE's
+ mExtendedDirectories = new ArrayList<DirectoryPartition>();
+ }
+
+ int videoCapabilities = CallUtil.getVideoCallingAvailability(context);
+ mIsVideoEnabled = (videoCapabilities & CallUtil.VIDEO_CALLING_ENABLED) != 0;
+ mIsPresenceEnabled = (videoCapabilities & CallUtil.VIDEO_CALLING_PRESENCE) != 0;
+ }
+
+ protected CharSequence getUnknownNameText() {
+ return mUnknownNameText;
+ }
+
+ @Override
+ public void configureLoader(CursorLoader loader, long directoryId) {
+ String query = getQueryString();
+ if (query == null) {
+ query = "";
+ }
+ if (isExtendedDirectory(directoryId)) {
+ final DirectoryPartition directory = getExtendedDirectoryFromId(directoryId);
+ final String contentUri = directory.getContentUri();
+ if (contentUri == null) {
+ throw new IllegalStateException("Extended directory must have a content URL: "
+ + directory);
+ }
+ final Builder builder = Uri.parse(contentUri).buildUpon();
+ builder.appendPath(query);
+ builder.appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
+ String.valueOf(getDirectoryResultLimit(directory)));
+ loader.setUri(builder.build());
+ loader.setProjection(PhoneQuery.PROJECTION_PRIMARY);
+ } else {
+ final boolean isRemoteDirectoryQuery
+ = DirectoryCompat.isRemoteDirectoryId(directoryId);
+ final Builder builder;
+ if (isSearchMode()) {
+ final Uri baseUri;
+ if (isRemoteDirectoryQuery) {
+ baseUri = PhoneCompat.getContentFilterUri();
+ } else if (mUseCallableUri) {
+ baseUri = CallableCompat.getContentFilterUri();
+ } else {
+ baseUri = PhoneCompat.getContentFilterUri();
+ }
+ builder = baseUri.buildUpon();
+ builder.appendPath(query); // Builder will encode the query
+ builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
+ String.valueOf(directoryId));
+ if (isRemoteDirectoryQuery) {
+ builder.appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
+ String.valueOf(getDirectoryResultLimit(getDirectoryById(directoryId))));
+ }
+ } else {
+ Uri baseUri = mUseCallableUri ? Callable.CONTENT_URI : Phone.CONTENT_URI;
+ builder = baseUri.buildUpon().appendQueryParameter(
+ ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT));
+ if (isSectionHeaderDisplayEnabled()) {
+ builder.appendQueryParameter(Phone.EXTRA_ADDRESS_BOOK_INDEX, "true");
+ }
+ applyFilter(loader, builder, directoryId, getFilter());
+ }
+
+ // Ignore invalid phone numbers that are too long. These can potentially cause freezes
+ // in the UI and there is no reason to display them.
+ final String prevSelection = loader.getSelection();
+ final String newSelection;
+ if (!TextUtils.isEmpty(prevSelection)) {
+ newSelection = prevSelection + " AND " + IGNORE_NUMBER_TOO_LONG_CLAUSE;
+ } else {
+ newSelection = IGNORE_NUMBER_TOO_LONG_CLAUSE;
+ }
+ loader.setSelection(newSelection);
+
+ // Remove duplicates when it is possible.
+ builder.appendQueryParameter(ContactsContract.REMOVE_DUPLICATE_ENTRIES, "true");
+ loader.setUri(builder.build());
+
+ // TODO a projection that includes the search snippet
+ if (getContactNameDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_PRIMARY) {
+ loader.setProjection(PhoneQuery.PROJECTION_PRIMARY);
+ } else {
+ loader.setProjection(PhoneQuery.PROJECTION_ALTERNATIVE);
+ }
+
+ if (getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY) {
+ loader.setSortOrder(Phone.SORT_KEY_PRIMARY);
+ } else {
+ loader.setSortOrder(Phone.SORT_KEY_ALTERNATIVE);
+ }
+ }
+ }
+
+ protected boolean isExtendedDirectory(long directoryId) {
+ return directoryId >= mFirstExtendedDirectoryId;
+ }
+
+ private DirectoryPartition getExtendedDirectoryFromId(long directoryId) {
+ final int directoryIndex = (int) (directoryId - mFirstExtendedDirectoryId);
+ return mExtendedDirectories.get(directoryIndex);
+ }
+
+ /**
+ * Configure {@code loader} and {@code uriBuilder} according to {@code directoryId} and {@code
+ * filter}.
+ */
+ private void applyFilter(CursorLoader loader, Uri.Builder uriBuilder, long directoryId,
+ ContactListFilter filter) {
+ if (filter == null || directoryId != Directory.DEFAULT) {
+ return;
+ }
+
+ final StringBuilder selection = new StringBuilder();
+ final List<String> selectionArgs = new ArrayList<String>();
+
+ switch (filter.filterType) {
+ case ContactListFilter.FILTER_TYPE_CUSTOM: {
+ selection.append(Contacts.IN_VISIBLE_GROUP + "=1");
+ selection.append(" AND " + Contacts.HAS_PHONE_NUMBER + "=1");
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_ACCOUNT: {
+ filter.addAccountQueryParameterToUrl(uriBuilder);
+ break;
+ }
+ case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS:
+ case ContactListFilter.FILTER_TYPE_DEFAULT:
+ break; // No selection needed.
+ case ContactListFilter.FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY:
+ break; // This adapter is always "phone only", so no selection needed either.
+ default:
+ Log.w(TAG, "Unsupported filter type came " +
+ "(type: " + filter.filterType + ", toString: " + filter + ")" +
+ " showing all contacts.");
+ // No selection.
+ break;
+ }
+ loader.setSelection(selection.toString());
+ loader.setSelectionArgs(selectionArgs.toArray(new String[0]));
+ }
+
+ @Override
+ public String getContactDisplayName(int position) {
+ return ((Cursor) getItem(position)).getString(PhoneQuery.DISPLAY_NAME);
+ }
+
+ public String getPhoneNumber(int position) {
+ final Cursor item = (Cursor)getItem(position);
+ return item != null ? item.getString(PhoneQuery.PHONE_NUMBER) : null;
+ }
+
+ /**
+ * Builds a {@link Data#CONTENT_URI} for the given cursor position.
+ *
+ * @return Uri for the data. may be null if the cursor is not ready.
+ */
+ public Uri getDataUri(int position) {
+ final int partitionIndex = getPartitionForPosition(position);
+ final Cursor item = (Cursor)getItem(position);
+ return item != null ? getDataUri(partitionIndex, item) : null;
+ }
+
+ public Uri getDataUri(int partitionIndex, Cursor cursor) {
+ final long directoryId =
+ ((DirectoryPartition)getPartition(partitionIndex)).getDirectoryId();
+ if (DirectoryCompat.isRemoteDirectoryId(directoryId)) {
+ return null;
+ } else if (DirectoryCompat.isEnterpriseDirectoryId(directoryId)) {
+ /*
+ * ContentUris.withAppendedId(Data.CONTENT_URI, phoneId), is invalid if
+ * isEnterpriseDirectoryId returns true, because the uri itself will fail since the
+ * ContactsProvider in Android Framework currently doesn't support it. return null until
+ * Android framework has enterprise version of Data.CONTENT_URI
+ */
+ return null;
+ } else {
+ final long phoneId = cursor.getLong(PhoneQuery.PHONE_ID);
+ return ContentUris.withAppendedId(Data.CONTENT_URI, phoneId);
+ }
+ }
+
+ /**
+ * Retrieves the lookup key for the given cursor position.
+ *
+ * @param position The cursor position.
+ * @return The lookup key.
+ */
+ public String getLookupKey(int position) {
+ final Cursor item = (Cursor)getItem(position);
+ return item != null ? item.getString(PhoneQuery.LOOKUP_KEY) : null;
+ }
+
+ @Override
+ protected ContactListItemView newView(
+ Context context, int partition, Cursor cursor, int position, ViewGroup parent) {
+ ContactListItemView view = super.newView(context, partition, cursor, position, parent);
+ view.setUnknownNameText(mUnknownNameText);
+ view.setQuickContactEnabled(isQuickContactEnabled());
+ view.setPhotoPosition(mPhotoPosition);
+ return view;
+ }
+
+ protected void setHighlight(ContactListItemView view, Cursor cursor) {
+ view.setHighlightedPrefix(isSearchMode() ? getUpperCaseQueryString() : null);
+ }
+
+ // Override default, which would return number of phone numbers, so we
+ // instead return number of contacts.
+ @Override
+ protected int getResultCount(Cursor cursor) {
+ if (cursor == null) {
+ return 0;
+ }
+ cursor.moveToPosition(-1);
+ long curContactId = -1;
+ int numContacts = 0;
+ while(cursor.moveToNext()) {
+ final long contactId = cursor.getLong(PhoneQuery.CONTACT_ID);
+ if (contactId != curContactId) {
+ curContactId = contactId;
+ ++numContacts;
+ }
+ }
+ return numContacts;
+ }
+
+ @Override
+ protected void bindView(View itemView, int partition, Cursor cursor, int position) {
+ super.bindView(itemView, partition, cursor, position);
+ ContactListItemView view = (ContactListItemView)itemView;
+
+ setHighlight(view, cursor);
+
+ // Look at elements before and after this position, checking if contact IDs are same.
+ // If they have one same contact ID, it means they can be grouped.
+ //
+ // In one group, only the first entry will show its photo and its name, and the other
+ // entries in the group show just their data (e.g. phone number, email address).
+ cursor.moveToPosition(position);
+ boolean isFirstEntry = true;
+ boolean showBottomDivider = true;
+ final long currentContactId = cursor.getLong(PhoneQuery.CONTACT_ID);
+ if (cursor.moveToPrevious() && !cursor.isBeforeFirst()) {
+ final long previousContactId = cursor.getLong(PhoneQuery.CONTACT_ID);
+ if (currentContactId == previousContactId) {
+ isFirstEntry = false;
+ }
+ }
+ cursor.moveToPosition(position);
+ if (cursor.moveToNext() && !cursor.isAfterLast()) {
+ final long nextContactId = cursor.getLong(PhoneQuery.CONTACT_ID);
+ if (currentContactId == nextContactId) {
+ // The following entry should be in the same group, which means we don't want a
+ // divider between them.
+ // TODO: we want a different divider than the divider between groups. Just hiding
+ // this divider won't be enough.
+ showBottomDivider = false;
+ }
+ }
+ cursor.moveToPosition(position);
+
+ bindViewId(view, cursor, PhoneQuery.PHONE_ID);
+
+ bindSectionHeaderAndDivider(view, position);
+ if (isFirstEntry) {
+ bindName(view, cursor);
+ if (isQuickContactEnabled()) {
+ bindQuickContact(view, partition, cursor, PhoneQuery.PHOTO_ID,
+ PhoneQuery.PHOTO_URI, PhoneQuery.CONTACT_ID,
+ PhoneQuery.LOOKUP_KEY, PhoneQuery.DISPLAY_NAME);
+ } else {
+ if (getDisplayPhotos()) {
+ bindPhoto(view, partition, cursor);
+ }
+ }
+ } else {
+ unbindName(view);
+
+ view.removePhotoView(true, false);
+ }
+
+ final DirectoryPartition directory = (DirectoryPartition) getPartition(partition);
+ bindPhoneNumber(view, cursor, directory.isDisplayNumber(), position);
+ }
+
+ protected void bindPhoneNumber(ContactListItemView view, Cursor cursor, boolean displayNumber,
+ int position) {
+ CharSequence label = null;
+ if (displayNumber && !cursor.isNull(PhoneQuery.PHONE_TYPE)) {
+ final int type = cursor.getInt(PhoneQuery.PHONE_TYPE);
+ final String customLabel = cursor.getString(PhoneQuery.PHONE_LABEL);
+
+ // TODO cache
+ label = Phone.getTypeLabel(getContext().getResources(), type, customLabel);
+ }
+ view.setLabel(label);
+ final String text;
+ if (displayNumber) {
+ text = cursor.getString(PhoneQuery.PHONE_NUMBER);
+ } else {
+ // Display phone label. If that's null, display geocoded location for the number
+ final String phoneLabel = cursor.getString(PhoneQuery.PHONE_LABEL);
+ if (phoneLabel != null) {
+ text = phoneLabel;
+ } else {
+ final String phoneNumber = cursor.getString(PhoneQuery.PHONE_NUMBER);
+ text = GeoUtil.getGeocodedLocationFor(mContext, phoneNumber);
+ }
+ }
+ view.setPhoneNumber(text, mCountryIso);
+
+ if (CompatUtils.isVideoCompatible()) {
+ // Determine if carrier presence indicates the number supports video calling.
+ int carrierPresence = cursor.getInt(PhoneQuery.CARRIER_PRESENCE);
+ boolean isPresent = (carrierPresence & Phone.CARRIER_PRESENCE_VT_CAPABLE) != 0;
+
+ boolean isVideoIconShown = mIsVideoEnabled && (
+ mIsPresenceEnabled && isPresent || !mIsPresenceEnabled);
+ view.setShowVideoCallIcon(isVideoIconShown, mListener, position);
+ }
+ }
+
+ protected void bindSectionHeaderAndDivider(final ContactListItemView view, int position) {
+ if (isSectionHeaderDisplayEnabled()) {
+ Placement placement = getItemPlacementInSection(position);
+ view.setSectionHeader(placement.firstInSection ? placement.sectionHeader : null);
+ } else {
+ view.setSectionHeader(null);
+ }
+ }
+
+ protected void bindName(final ContactListItemView view, Cursor cursor) {
+ view.showDisplayName(cursor, PhoneQuery.DISPLAY_NAME, getContactNameDisplayOrder());
+ // Note: we don't show phonetic names any more (see issue 5265330)
+ }
+
+ protected void unbindName(final ContactListItemView view) {
+ view.hideDisplayName();
+ }
+
+ @Override
+ protected void bindWorkProfileIcon(final ContactListItemView view, int partition) {
+ final DirectoryPartition directory = (DirectoryPartition) getPartition(partition);
+ final long directoryId = directory.getDirectoryId();
+ final long userType = ContactsUtils.determineUserType(directoryId, null);
+ // Work directory must not be a extended directory. An extended directory is custom
+ // directory in the app, but not a directory provided by framework. So it can't be
+ // USER_TYPE_WORK.
+ view.setWorkProfileIconEnabled(
+ !isExtendedDirectory(directoryId) && userType == ContactsUtils.USER_TYPE_WORK);
+ }
+
+ protected void bindPhoto(final ContactListItemView view, int partitionIndex, Cursor cursor) {
+ if (!isPhotoSupported(partitionIndex)) {
+ view.removePhotoView();
+ return;
+ }
+
+ long photoId = 0;
+ if (!cursor.isNull(PhoneQuery.PHOTO_ID)) {
+ photoId = cursor.getLong(PhoneQuery.PHOTO_ID);
+ }
+
+ if (photoId != 0) {
+ getPhotoLoader().loadThumbnail(view.getPhotoView(), photoId, false,
+ getCircularPhotos(), null);
+ } else {
+ final String photoUriString = cursor.getString(PhoneQuery.PHOTO_URI);
+ final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString);
+
+ DefaultImageRequest request = null;
+ if (photoUri == null) {
+ final String displayName = cursor.getString(PhoneQuery.DISPLAY_NAME);
+ final String lookupKey = cursor.getString(PhoneQuery.LOOKUP_KEY);
+ request = new DefaultImageRequest(displayName, lookupKey, getCircularPhotos());
+ }
+ getPhotoLoader().loadDirectoryPhoto(view.getPhotoView(), photoUri, false,
+ getCircularPhotos(), request);
+ }
+ }
+
+ public void setPhotoPosition(ContactListItemView.PhotoPosition photoPosition) {
+ mPhotoPosition = photoPosition;
+ }
+
+ public ContactListItemView.PhotoPosition getPhotoPosition() {
+ return mPhotoPosition;
+ }
+
+ public void setUseCallableUri(boolean useCallableUri) {
+ mUseCallableUri = useCallableUri;
+ }
+
+ public boolean usesCallableUri() {
+ return mUseCallableUri;
+ }
+
+ /**
+ * Override base implementation to inject extended directories between local & remote
+ * directories. This is done in the following steps:
+ * 1. Call base implementation to add directories from the cursor.
+ * 2. Iterate all base directories and establish the following information:
+ * a. The highest directory id so that we can assign unused id's to the extended directories.
+ * b. The index of the last non-remote directory. This is where we will insert extended
+ * directories.
+ * 3. Iterate the extended directories and for each one, assign an ID and insert it in the
+ * proper location.
+ */
+ @Override
+ public void changeDirectories(Cursor cursor) {
+ super.changeDirectories(cursor);
+ if (getDirectorySearchMode() == DirectoryListLoader.SEARCH_MODE_NONE) {
+ return;
+ }
+ final int numExtendedDirectories = mExtendedDirectories.size();
+ if (getPartitionCount() == cursor.getCount() + numExtendedDirectories) {
+ // already added all directories;
+ return;
+ }
+ //
+ mFirstExtendedDirectoryId = Long.MAX_VALUE;
+ if (numExtendedDirectories > 0) {
+ // The Directory.LOCAL_INVISIBLE is not in the cursor but we can't reuse it's
+ // "special" ID.
+ long maxId = Directory.LOCAL_INVISIBLE;
+ int insertIndex = 0;
+ for (int i = 0, n = getPartitionCount(); i < n; i++) {
+ final DirectoryPartition partition = (DirectoryPartition) getPartition(i);
+ final long id = partition.getDirectoryId();
+ if (id > maxId) {
+ maxId = id;
+ }
+ if (!DirectoryCompat.isRemoteDirectoryId(id)) {
+ // assuming remote directories come after local, we will end up with the index
+ // where we should insert extended directories. This also works if there are no
+ // remote directories at all.
+ insertIndex = i + 1;
+ }
+ }
+ // Extended directories ID's cannot collide with base directories
+ mFirstExtendedDirectoryId = maxId + 1;
+ for (int i = 0; i < numExtendedDirectories; i++) {
+ final long id = mFirstExtendedDirectoryId + i;
+ final DirectoryPartition directory = mExtendedDirectories.get(i);
+ if (getPartitionByDirectoryId(id) == -1) {
+ addPartition(insertIndex, directory);
+ directory.setDirectoryId(id);
+ }
+ }
+ }
+ }
+
+ @Override
+ protected Uri getContactUri(int partitionIndex, Cursor cursor,
+ int contactIdColumn, int lookUpKeyColumn) {
+ final DirectoryPartition directory = (DirectoryPartition) getPartition(partitionIndex);
+ final long directoryId = directory.getDirectoryId();
+ if (!isExtendedDirectory(directoryId)) {
+ return super.getContactUri(partitionIndex, cursor, contactIdColumn, lookUpKeyColumn);
+ }
+ return Contacts.CONTENT_LOOKUP_URI.buildUpon()
+ .appendPath(Constants.LOOKUP_URI_ENCODED)
+ .appendQueryParameter(Directory.DISPLAY_NAME, directory.getLabel())
+ .appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
+ String.valueOf(directoryId))
+ .encodedFragment(cursor.getString(lookUpKeyColumn))
+ .build();
+ }
+
+ public Listener getListener() {
+ return mListener;
+ }
+
+ public void setListener(Listener listener) {
+ mListener = listener;
+ }
+}
diff --git a/src/com/android/contacts/common/list/PhoneNumberPickerFragment.java b/src/com/android/contacts/common/list/PhoneNumberPickerFragment.java
new file mode 100644
index 0000000..3d542eb
--- /dev/null
+++ b/src/com/android/contacts/common/list/PhoneNumberPickerFragment.java
@@ -0,0 +1,345 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.Intent;
+import android.content.Loader;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.list.ShortcutIntentBuilder.OnShortcutIntentCreatedListener;
+import com.android.contacts.commonbind.analytics.AnalyticsUtil;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Fragment containing a phone number list for picking.
+ */
+public class PhoneNumberPickerFragment extends ContactEntryListFragment<ContactEntryListAdapter>
+ implements OnShortcutIntentCreatedListener, PhoneNumberListAdapter.Listener {
+ private static final String TAG = PhoneNumberPickerFragment.class.getSimpleName();
+
+ private static final String KEY_SHORTCUT_ACTION = "shortcutAction";
+
+ private OnPhoneNumberPickerActionListener mListener;
+ private String mShortcutAction;
+
+ private ContactListFilter mFilter;
+
+ private static final String KEY_FILTER = "filter";
+
+ /** true if the loader has started at least once. */
+ private boolean mLoaderStarted;
+
+ private boolean mUseCallableUri;
+
+ private ContactListItemView.PhotoPosition mPhotoPosition =
+ ContactListItemView.getDefaultPhotoPosition(false /* normal/non opposite */);
+
+ /**
+ * Handles a click on the video call icon for a row in the list.
+ *
+ * @param position The position in the list where the click ocurred.
+ */
+ @Override
+ public void onVideoCallIconClicked(int position) {
+ callNumber(position, true /* isVideoCall */);
+ }
+
+ public PhoneNumberPickerFragment() {
+ setQuickContactEnabled(false);
+ setPhotoLoaderEnabled(true);
+ setSectionHeaderDisplayEnabled(true);
+ setDirectorySearchMode(DirectoryListLoader.SEARCH_MODE_NONE);
+
+ // Show nothing instead of letting caller Activity show something.
+ setHasOptionsMenu(true);
+ }
+
+ public void setDirectorySearchEnabled(boolean flag) {
+ setDirectorySearchMode(flag ? DirectoryListLoader.SEARCH_MODE_DEFAULT
+ : DirectoryListLoader.SEARCH_MODE_NONE);
+ }
+
+ public void setOnPhoneNumberPickerActionListener(OnPhoneNumberPickerActionListener listener) {
+ this.mListener = listener;
+ }
+
+ public OnPhoneNumberPickerActionListener getOnPhoneNumberPickerListener() {
+ return mListener;
+ }
+
+ @Override
+ protected void onCreateView(LayoutInflater inflater, ViewGroup container) {
+ super.onCreateView(inflater, container);
+
+ setVisibleScrollbarEnabled(getVisibleScrollbarEnabled());
+ }
+
+ protected boolean getVisibleScrollbarEnabled() {
+ return true;
+ }
+
+ @Override
+ public void restoreSavedState(Bundle savedState) {
+ super.restoreSavedState(savedState);
+
+ if (savedState == null) {
+ return;
+ }
+
+ mFilter = savedState.getParcelable(KEY_FILTER);
+ mShortcutAction = savedState.getString(KEY_SHORTCUT_ACTION);
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putParcelable(KEY_FILTER, mFilter);
+ outState.putString(KEY_SHORTCUT_ACTION, mShortcutAction);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ final int itemId = item.getItemId();
+ if (itemId == android.R.id.home) { // See ActionBar#setDisplayHomeAsUpEnabled()
+ if (mListener != null) {
+ mListener.onHomeInActionBarSelected();
+ }
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ /**
+ * @param shortcutAction either {@link Intent#ACTION_CALL} or
+ * {@link Intent#ACTION_SENDTO} or null.
+ */
+ public void setShortcutAction(String shortcutAction) {
+ this.mShortcutAction = shortcutAction;
+ }
+
+ @Override
+ protected void onItemClick(int position, long id) {
+ callNumber(position, false /* isVideoCall */);
+ }
+
+ /**
+ * Initiates a call to the number at the specified position.
+ *
+ * @param position The position.
+ * @param isVideoCall {@code true} if the call should be initiated as a video call,
+ * {@code false} otherwise.
+ */
+ private void callNumber(int position, boolean isVideoCall) {
+ final Uri phoneUri = getPhoneUri(position);
+
+ if (phoneUri != null) {
+ pickPhoneNumber(phoneUri, isVideoCall);
+ } else {
+ final String number = getPhoneNumber(position);
+ if (!TextUtils.isEmpty(number)) {
+ cacheContactInfo(position);
+ mListener.onPickPhoneNumber(number, isVideoCall,
+ getCallInitiationType(true /* isRemoteDirectory */));
+ } else {
+ Log.w(TAG, "Item at " + position + " was clicked before"
+ + " adapter is ready. Ignoring");
+ }
+ }
+
+ // Get the lookup key and track any analytics
+ final String lookupKey = getLookupKey(position);
+ if (!TextUtils.isEmpty(lookupKey)) {
+ maybeTrackAnalytics(lookupKey);
+ }
+ }
+
+ protected void cacheContactInfo(int position) {
+ // Not implemented. Hook for child classes
+ }
+
+ protected String getPhoneNumber(int position) {
+ final PhoneNumberListAdapter adapter = (PhoneNumberListAdapter) getAdapter();
+ return adapter.getPhoneNumber(position);
+ }
+
+ protected Uri getPhoneUri(int position) {
+ final PhoneNumberListAdapter adapter = (PhoneNumberListAdapter) getAdapter();
+ return adapter.getDataUri(position);
+ }
+
+ protected String getLookupKey(int position) {
+ final PhoneNumberListAdapter adapter = (PhoneNumberListAdapter) getAdapter();
+ return adapter.getLookupKey(position);
+ }
+
+ @Override
+ protected void startLoading() {
+ mLoaderStarted = true;
+ super.startLoading();
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
+ super.onLoadFinished(loader, data);
+
+ // disable scroll bar if there is no data
+ setVisibleScrollbarEnabled(data != null && !data.isClosed() && data.getCount() > 0);
+ }
+
+ public void setUseCallableUri(boolean useCallableUri) {
+ mUseCallableUri = useCallableUri;
+ }
+
+ public boolean usesCallableUri() {
+ return mUseCallableUri;
+ }
+
+ @Override
+ protected ContactEntryListAdapter createListAdapter() {
+ PhoneNumberListAdapter adapter = new PhoneNumberListAdapter(getActivity());
+ adapter.setDisplayPhotos(true);
+ adapter.setUseCallableUri(mUseCallableUri);
+ return adapter;
+ }
+
+ @Override
+ protected void configureAdapter() {
+ super.configureAdapter();
+
+ final ContactEntryListAdapter adapter = getAdapter();
+ if (adapter == null) {
+ return;
+ }
+
+ if (!isSearchMode() && mFilter != null) {
+ adapter.setFilter(mFilter);
+ }
+
+ setPhotoPosition(adapter);
+ }
+
+ protected void setPhotoPosition(ContactEntryListAdapter adapter) {
+ ((PhoneNumberListAdapter) adapter).setPhotoPosition(mPhotoPosition);
+ }
+
+ @Override
+ protected View inflateView(LayoutInflater inflater, ViewGroup container) {
+ return inflater.inflate(R.layout.contact_list_content, null);
+ }
+
+ public void pickPhoneNumber(Uri uri, boolean isVideoCall) {
+ if (mShortcutAction == null) {
+ mListener.onPickDataUri(uri, isVideoCall,
+ getCallInitiationType(false /* isRemoteDirectory */));
+ } else {
+ startPhoneNumberShortcutIntent(uri, isVideoCall);
+ }
+ }
+
+ protected void startPhoneNumberShortcutIntent(Uri uri, boolean isVideoCall) {
+ ShortcutIntentBuilder builder = new ShortcutIntentBuilder(getActivity(), this);
+ builder.createPhoneNumberShortcutIntent(uri, mShortcutAction);
+ }
+
+ @Override
+ public void onShortcutIntentCreated(Uri uri, Intent shortcutIntent) {
+ mListener.onShortcutIntentCreated(shortcutIntent);
+ }
+
+ @Override
+ public void onPickerResult(Intent data) {
+ mListener.onPickDataUri(data.getData(), false /* isVideoCall */,
+ getCallInitiationType(false /* isRemoteDirectory */));
+ }
+
+ public void setFilter(ContactListFilter filter) {
+ if ((mFilter == null && filter == null) ||
+ (mFilter != null && mFilter.equals(filter))) {
+ return;
+ }
+
+ mFilter = filter;
+ if (mLoaderStarted) {
+ reloadData();
+ }
+ }
+
+ public void setPhotoPosition(ContactListItemView.PhotoPosition photoPosition) {
+ mPhotoPosition = photoPosition;
+
+ final PhoneNumberListAdapter adapter = (PhoneNumberListAdapter) getAdapter();
+ if (adapter != null) {
+ adapter.setPhotoPosition(photoPosition);
+ }
+ }
+
+ /**
+ * @param isRemoteDirectory {@code true} if the call was initiated using a contact/phone number
+ * not in the local contacts database
+ */
+ protected int getCallInitiationType(boolean isRemoteDirectory) {
+ return OnPhoneNumberPickerActionListener.CALL_INITIATION_UNKNOWN;
+ }
+
+ /**
+ * Where a lookup key contains analytic event information, logs the associated analytics event.
+ *
+ * @param lookupKey The lookup key JSON object.
+ */
+ private void maybeTrackAnalytics(String lookupKey) {
+ try {
+ JSONObject json = new JSONObject(lookupKey);
+
+ String analyticsCategory = json.getString(
+ PhoneNumberListAdapter.PhoneQuery.ANALYTICS_CATEGORY);
+ String analyticsAction = json.getString(
+ PhoneNumberListAdapter.PhoneQuery.ANALYTICS_ACTION);
+ String analyticsValue = json.getString(
+ PhoneNumberListAdapter.PhoneQuery.ANALYTICS_VALUE);
+
+ if (TextUtils.isEmpty(analyticsCategory) || TextUtils.isEmpty(analyticsAction) ||
+ TextUtils.isEmpty(analyticsValue)) {
+ return;
+ }
+
+ // Assume that the analytic value being tracked could be a float value, but just cast
+ // to a long so that the analytic server can handle it.
+ long value;
+ try {
+ float floatValue = Float.parseFloat(analyticsValue);
+ value = (long) floatValue;
+ } catch (NumberFormatException nfe) {
+ return;
+ }
+
+ AnalyticsUtil.sendEvent(getActivity().getApplication(), analyticsCategory,
+ analyticsAction, "" /* label */, value);
+ } catch (JSONException e) {
+ // Not an error; just a lookup key that doesn't have the right information.
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/list/PinnedHeaderListAdapter.java b/src/com/android/contacts/common/list/PinnedHeaderListAdapter.java
new file mode 100644
index 0000000..72f3f19
--- /dev/null
+++ b/src/com/android/contacts/common/list/PinnedHeaderListAdapter.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.common.widget.CompositeCursorAdapter;
+
+/**
+ * A subclass of {@link CompositeCursorAdapter} that manages pinned partition headers.
+ */
+public abstract class PinnedHeaderListAdapter extends CompositeCursorAdapter
+ implements PinnedHeaderListView.PinnedHeaderAdapter {
+
+ public static final int PARTITION_HEADER_TYPE = 0;
+
+ private boolean mPinnedPartitionHeadersEnabled;
+ private boolean mHeaderVisibility[];
+
+ public PinnedHeaderListAdapter(Context context) {
+ super(context);
+ }
+
+ public PinnedHeaderListAdapter(Context context, int initialCapacity) {
+ super(context, initialCapacity);
+ }
+
+ public boolean getPinnedPartitionHeadersEnabled() {
+ return mPinnedPartitionHeadersEnabled;
+ }
+
+ public void setPinnedPartitionHeadersEnabled(boolean flag) {
+ this.mPinnedPartitionHeadersEnabled = flag;
+ }
+
+ @Override
+ public int getPinnedHeaderCount() {
+ if (mPinnedPartitionHeadersEnabled) {
+ return getPartitionCount();
+ } else {
+ return 0;
+ }
+ }
+
+ protected boolean isPinnedPartitionHeaderVisible(int partition) {
+ return getPinnedPartitionHeadersEnabled() && hasHeader(partition)
+ && !isPartitionEmpty(partition);
+ }
+
+ /**
+ * The default implementation creates the same type of view as a normal
+ * partition header.
+ */
+ @Override
+ public View getPinnedHeaderView(int partition, View convertView, ViewGroup parent) {
+ if (hasHeader(partition)) {
+ View view = null;
+ if (convertView != null) {
+ Integer headerType = (Integer)convertView.getTag();
+ if (headerType != null && headerType == PARTITION_HEADER_TYPE) {
+ view = convertView;
+ }
+ }
+ if (view == null) {
+ view = newHeaderView(getContext(), partition, null, parent);
+ view.setTag(PARTITION_HEADER_TYPE);
+ view.setFocusable(false);
+ view.setEnabled(false);
+ }
+ bindHeaderView(view, partition, getCursor(partition));
+ view.setLayoutDirection(parent.getLayoutDirection());
+ return view;
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public void configurePinnedHeaders(PinnedHeaderListView listView) {
+ if (!getPinnedPartitionHeadersEnabled()) {
+ return;
+ }
+
+ int size = getPartitionCount();
+
+ // Cache visibility bits, because we will need them several times later on
+ if (mHeaderVisibility == null || mHeaderVisibility.length != size) {
+ mHeaderVisibility = new boolean[size];
+ }
+ for (int i = 0; i < size; i++) {
+ boolean visible = isPinnedPartitionHeaderVisible(i);
+ mHeaderVisibility[i] = visible;
+ if (!visible) {
+ listView.setHeaderInvisible(i, true);
+ }
+ }
+
+ int headerViewsCount = listView.getHeaderViewsCount();
+
+ // Starting at the top, find and pin headers for partitions preceding the visible one(s)
+ int maxTopHeader = -1;
+ int topHeaderHeight = 0;
+ for (int i = 0; i < size; i++) {
+ if (mHeaderVisibility[i]) {
+ int position = listView.getPositionAt(topHeaderHeight) - headerViewsCount;
+ int partition = getPartitionForPosition(position);
+ if (i > partition) {
+ break;
+ }
+
+ listView.setHeaderPinnedAtTop(i, topHeaderHeight, false);
+ topHeaderHeight += listView.getPinnedHeaderHeight(i);
+ maxTopHeader = i;
+ }
+ }
+
+ // Starting at the bottom, find and pin headers for partitions following the visible one(s)
+ int maxBottomHeader = size;
+ int bottomHeaderHeight = 0;
+ int listHeight = listView.getHeight();
+ for (int i = size; --i > maxTopHeader;) {
+ if (mHeaderVisibility[i]) {
+ int position = listView.getPositionAt(listHeight - bottomHeaderHeight)
+ - headerViewsCount;
+ if (position < 0) {
+ break;
+ }
+
+ int partition = getPartitionForPosition(position - 1);
+ if (partition == -1 || i <= partition) {
+ break;
+ }
+
+ int height = listView.getPinnedHeaderHeight(i);
+ bottomHeaderHeight += height;
+
+ listView.setHeaderPinnedAtBottom(i, listHeight - bottomHeaderHeight, false);
+ maxBottomHeader = i;
+ }
+ }
+
+ // Headers in between the top-pinned and bottom-pinned should be hidden
+ for (int i = maxTopHeader + 1; i < maxBottomHeader; i++) {
+ if (mHeaderVisibility[i]) {
+ listView.setHeaderInvisible(i, isPartitionEmpty(i));
+ }
+ }
+ }
+
+ @Override
+ public int getScrollPositionForHeader(int viewIndex) {
+ return getPositionForPartition(viewIndex);
+ }
+}
diff --git a/src/com/android/contacts/common/list/PinnedHeaderListView.java b/src/com/android/contacts/common/list/PinnedHeaderListView.java
new file mode 100644
index 0000000..45ce4b3
--- /dev/null
+++ b/src/com/android/contacts/common/list/PinnedHeaderListView.java
@@ -0,0 +1,584 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.RectF;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AbsListView;
+import android.widget.AbsListView.OnScrollListener;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemSelectedListener;
+import android.widget.ListAdapter;
+import android.widget.TextView;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.util.ViewUtil;
+
+/**
+ * A ListView that maintains a header pinned at the top of the list. The
+ * pinned header can be pushed up and dissolved as needed.
+ */
+public class PinnedHeaderListView extends AutoScrollListView
+ implements OnScrollListener, OnItemSelectedListener {
+
+ /**
+ * Adapter interface. The list adapter must implement this interface.
+ */
+ public interface PinnedHeaderAdapter {
+
+ /**
+ * Returns the overall number of pinned headers, visible or not.
+ */
+ int getPinnedHeaderCount();
+
+ /**
+ * Creates or updates the pinned header view.
+ */
+ View getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent);
+
+ /**
+ * Configures the pinned headers to match the visible list items. The
+ * adapter should call {@link PinnedHeaderListView#setHeaderPinnedAtTop},
+ * {@link PinnedHeaderListView#setHeaderPinnedAtBottom},
+ * {@link PinnedHeaderListView#setFadingHeader} or
+ * {@link PinnedHeaderListView#setHeaderInvisible}, for each header that
+ * needs to change its position or visibility.
+ */
+ void configurePinnedHeaders(PinnedHeaderListView listView);
+
+ /**
+ * Returns the list position to scroll to if the pinned header is touched.
+ * Return -1 if the list does not need to be scrolled.
+ */
+ int getScrollPositionForHeader(int viewIndex);
+ }
+
+ private static final int MAX_ALPHA = 255;
+ private static final int TOP = 0;
+ private static final int BOTTOM = 1;
+ private static final int FADING = 2;
+
+ private static final int DEFAULT_ANIMATION_DURATION = 20;
+
+ private static final int DEFAULT_SMOOTH_SCROLL_DURATION = 100;
+
+ private static final class PinnedHeader {
+ View view;
+ boolean visible;
+ int y;
+ int height;
+ int alpha;
+ int state;
+
+ boolean animating;
+ boolean targetVisible;
+ int sourceY;
+ int targetY;
+ long targetTime;
+ }
+
+ private PinnedHeaderAdapter mAdapter;
+ private int mSize;
+ private PinnedHeader[] mHeaders;
+ private RectF mBounds = new RectF();
+ private OnScrollListener mOnScrollListener;
+ private OnItemSelectedListener mOnItemSelectedListener;
+ private int mScrollState;
+
+ private boolean mScrollToSectionOnHeaderTouch = false;
+ private boolean mHeaderTouched = false;
+
+ private int mAnimationDuration = DEFAULT_ANIMATION_DURATION;
+ private boolean mAnimating;
+ private long mAnimationTargetTime;
+ private int mHeaderPaddingStart;
+ private int mHeaderWidth;
+
+ public PinnedHeaderListView(Context context) {
+ this(context, null, android.R.attr.listViewStyle);
+ }
+
+ public PinnedHeaderListView(Context context, AttributeSet attrs) {
+ this(context, attrs, android.R.attr.listViewStyle);
+ }
+
+ public PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ super.setOnScrollListener(this);
+ super.setOnItemSelectedListener(this);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ super.onLayout(changed, l, t, r, b);
+ mHeaderPaddingStart = getPaddingStart();
+ mHeaderWidth = r - l - mHeaderPaddingStart - getPaddingEnd();
+ }
+
+ @Override
+ public void setAdapter(ListAdapter adapter) {
+ mAdapter = (PinnedHeaderAdapter)adapter;
+ super.setAdapter(adapter);
+ }
+
+ @Override
+ public void setOnScrollListener(OnScrollListener onScrollListener) {
+ mOnScrollListener = onScrollListener;
+ super.setOnScrollListener(this);
+ }
+
+ @Override
+ public void setOnItemSelectedListener(OnItemSelectedListener listener) {
+ mOnItemSelectedListener = listener;
+ super.setOnItemSelectedListener(this);
+ }
+
+ public void setScrollToSectionOnHeaderTouch(boolean value) {
+ mScrollToSectionOnHeaderTouch = value;
+ }
+
+ @Override
+ public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
+ int totalItemCount) {
+ if (mAdapter != null) {
+ int count = mAdapter.getPinnedHeaderCount();
+ if (count != mSize) {
+ mSize = count;
+ if (mHeaders == null) {
+ mHeaders = new PinnedHeader[mSize];
+ } else if (mHeaders.length < mSize) {
+ PinnedHeader[] headers = mHeaders;
+ mHeaders = new PinnedHeader[mSize];
+ System.arraycopy(headers, 0, mHeaders, 0, headers.length);
+ }
+ }
+
+ for (int i = 0; i < mSize; i++) {
+ if (mHeaders[i] == null) {
+ mHeaders[i] = new PinnedHeader();
+ }
+ mHeaders[i].view = mAdapter.getPinnedHeaderView(i, mHeaders[i].view, this);
+ }
+
+ mAnimationTargetTime = System.currentTimeMillis() + mAnimationDuration;
+ mAdapter.configurePinnedHeaders(this);
+ invalidateIfAnimating();
+ }
+ if (mOnScrollListener != null) {
+ mOnScrollListener.onScroll(this, firstVisibleItem, visibleItemCount, totalItemCount);
+ }
+ }
+
+ @Override
+ protected float getTopFadingEdgeStrength() {
+ // Disable vertical fading at the top when the pinned header is present
+ return mSize > 0 ? 0 : super.getTopFadingEdgeStrength();
+ }
+
+ @Override
+ public void onScrollStateChanged(AbsListView view, int scrollState) {
+ mScrollState = scrollState;
+ if (mOnScrollListener != null) {
+ mOnScrollListener.onScrollStateChanged(this, scrollState);
+ }
+ }
+
+ /**
+ * Ensures that the selected item is positioned below the top-pinned headers
+ * and above the bottom-pinned ones.
+ */
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ int height = getHeight();
+
+ int windowTop = 0;
+ int windowBottom = height;
+
+ for (int i = 0; i < mSize; i++) {
+ PinnedHeader header = mHeaders[i];
+ if (header.visible) {
+ if (header.state == TOP) {
+ windowTop = header.y + header.height;
+ } else if (header.state == BOTTOM) {
+ windowBottom = header.y;
+ break;
+ }
+ }
+ }
+
+ View selectedView = getSelectedView();
+ if (selectedView != null) {
+ if (selectedView.getTop() < windowTop) {
+ setSelectionFromTop(position, windowTop);
+ } else if (selectedView.getBottom() > windowBottom) {
+ setSelectionFromTop(position, windowBottom - selectedView.getHeight());
+ }
+ }
+
+ if (mOnItemSelectedListener != null) {
+ mOnItemSelectedListener.onItemSelected(parent, view, position, id);
+ }
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+ if (mOnItemSelectedListener != null) {
+ mOnItemSelectedListener.onNothingSelected(parent);
+ }
+ }
+
+ public int getPinnedHeaderHeight(int viewIndex) {
+ ensurePinnedHeaderLayout(viewIndex);
+ return mHeaders[viewIndex].view.getHeight();
+ }
+
+ /**
+ * Set header to be pinned at the top.
+ *
+ * @param viewIndex index of the header view
+ * @param y is position of the header in pixels.
+ * @param animate true if the transition to the new coordinate should be animated
+ */
+ public void setHeaderPinnedAtTop(int viewIndex, int y, boolean animate) {
+ ensurePinnedHeaderLayout(viewIndex);
+ PinnedHeader header = mHeaders[viewIndex];
+ header.visible = true;
+ header.y = y;
+ header.state = TOP;
+
+ // TODO perhaps we should animate at the top as well
+ header.animating = false;
+ }
+
+ /**
+ * Set header to be pinned at the bottom.
+ *
+ * @param viewIndex index of the header view
+ * @param y is position of the header in pixels.
+ * @param animate true if the transition to the new coordinate should be animated
+ */
+ public void setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate) {
+ ensurePinnedHeaderLayout(viewIndex);
+ PinnedHeader header = mHeaders[viewIndex];
+ header.state = BOTTOM;
+ if (header.animating) {
+ header.targetTime = mAnimationTargetTime;
+ header.sourceY = header.y;
+ header.targetY = y;
+ } else if (animate && (header.y != y || !header.visible)) {
+ if (header.visible) {
+ header.sourceY = header.y;
+ } else {
+ header.visible = true;
+ header.sourceY = y + header.height;
+ }
+ header.animating = true;
+ header.targetVisible = true;
+ header.targetTime = mAnimationTargetTime;
+ header.targetY = y;
+ } else {
+ header.visible = true;
+ header.y = y;
+ }
+ }
+
+ /**
+ * Set header to be pinned at the top of the first visible item.
+ *
+ * @param viewIndex index of the header view
+ * @param position is position of the header in pixels.
+ */
+ public void setFadingHeader(int viewIndex, int position, boolean fade) {
+ ensurePinnedHeaderLayout(viewIndex);
+
+ View child = getChildAt(position - getFirstVisiblePosition());
+ if (child == null) return;
+
+ PinnedHeader header = mHeaders[viewIndex];
+ // Hide header when it's a star.
+ // TODO: try showing the view even when it's a star;
+ // if we have to hide the star view, then try hiding it in some higher layer.
+ header.visible = !TextUtils.equals(
+ ((TextView) header.view).getText(), getContext().getString(R.string.star_sign));
+ header.state = FADING;
+ header.alpha = MAX_ALPHA;
+ header.animating = false;
+
+ int top = getTotalTopPinnedHeaderHeight();
+ header.y = top;
+ if (fade) {
+ int bottom = child.getBottom() - top;
+ int headerHeight = header.height;
+ if (bottom < headerHeight) {
+ int portion = bottom - headerHeight;
+ header.alpha = MAX_ALPHA * (headerHeight + portion) / headerHeight;
+ header.y = top + portion;
+ }
+ }
+ }
+
+ /**
+ * Makes header invisible.
+ *
+ * @param viewIndex index of the header view
+ * @param animate true if the transition to the new coordinate should be animated
+ */
+ public void setHeaderInvisible(int viewIndex, boolean animate) {
+ PinnedHeader header = mHeaders[viewIndex];
+ if (header.visible && (animate || header.animating) && header.state == BOTTOM) {
+ header.sourceY = header.y;
+ if (!header.animating) {
+ header.visible = true;
+ header.targetY = getBottom() + header.height;
+ }
+ header.animating = true;
+ header.targetTime = mAnimationTargetTime;
+ header.targetVisible = false;
+ } else {
+ header.visible = false;
+ }
+ }
+
+ private void ensurePinnedHeaderLayout(int viewIndex) {
+ View view = mHeaders[viewIndex].view;
+ if (view.isLayoutRequested()) {
+ ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
+ int widthSpec;
+ int heightSpec;
+
+ if (layoutParams != null && layoutParams.width > 0) {
+ widthSpec = View.MeasureSpec
+ .makeMeasureSpec(layoutParams.width, View.MeasureSpec.EXACTLY);
+ } else {
+ widthSpec = View.MeasureSpec
+ .makeMeasureSpec(mHeaderWidth, View.MeasureSpec.EXACTLY);
+ }
+
+ if (layoutParams != null && layoutParams.height > 0) {
+ heightSpec = View.MeasureSpec
+ .makeMeasureSpec(layoutParams.height, View.MeasureSpec.EXACTLY);
+ } else {
+ heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
+ }
+ view.measure(widthSpec, heightSpec);
+ int height = view.getMeasuredHeight();
+ mHeaders[viewIndex].height = height;
+ view.layout(0, 0, view.getMeasuredWidth(), height);
+ }
+ }
+
+ /**
+ * Returns the sum of heights of headers pinned to the top.
+ */
+ public int getTotalTopPinnedHeaderHeight() {
+ for (int i = mSize; --i >= 0;) {
+ PinnedHeader header = mHeaders[i];
+ if (header.visible && header.state == TOP) {
+ return header.y + header.height;
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Returns the list item position at the specified y coordinate.
+ */
+ public int getPositionAt(int y) {
+ do {
+ int position = pointToPosition(getPaddingLeft() + 1, y);
+ if (position != -1) {
+ return position;
+ }
+ // If position == -1, we must have hit a separator. Let's examine
+ // a nearby pixel
+ y--;
+ } while (y > 0);
+ return 0;
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(MotionEvent ev) {
+ mHeaderTouched = false;
+ if (super.onInterceptTouchEvent(ev)) {
+ return true;
+ }
+
+ if (mScrollState == SCROLL_STATE_IDLE) {
+ final int y = (int)ev.getY();
+ final int x = (int)ev.getX();
+ for (int i = mSize; --i >= 0;) {
+ PinnedHeader header = mHeaders[i];
+ final int padding = ViewUtil.isViewLayoutRtl(this) ?
+ getWidth() - mHeaderPaddingStart - header.view.getWidth() :
+ mHeaderPaddingStart;
+ if (header.visible && header.y <= y && header.y + header.height > y &&
+ x >= padding && padding + header.view.getWidth() >= x) {
+ mHeaderTouched = true;
+ if (mScrollToSectionOnHeaderTouch &&
+ ev.getAction() == MotionEvent.ACTION_DOWN) {
+ return smoothScrollToPartition(i);
+ } else {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent ev) {
+ if (mHeaderTouched) {
+ if (ev.getAction() == MotionEvent.ACTION_UP) {
+ mHeaderTouched = false;
+ }
+ return true;
+ }
+ return super.onTouchEvent(ev);
+ }
+
+ private boolean smoothScrollToPartition(int partition) {
+ if (mAdapter == null) {
+ return false;
+ }
+ final int position = mAdapter.getScrollPositionForHeader(partition);
+ if (position == -1) {
+ return false;
+ }
+
+ int offset = 0;
+ for (int i = 0; i < partition; i++) {
+ PinnedHeader header = mHeaders[i];
+ if (header.visible) {
+ offset += header.height;
+ }
+ }
+ smoothScrollToPositionFromTop(position + getHeaderViewsCount(), offset,
+ DEFAULT_SMOOTH_SCROLL_DURATION);
+ return true;
+ }
+
+ private void invalidateIfAnimating() {
+ mAnimating = false;
+ for (int i = 0; i < mSize; i++) {
+ if (mHeaders[i].animating) {
+ mAnimating = true;
+ invalidate();
+ return;
+ }
+ }
+ }
+
+ @Override
+ protected void dispatchDraw(Canvas canvas) {
+ long currentTime = mAnimating ? System.currentTimeMillis() : 0;
+
+ int top = 0;
+ int right = 0;
+ int bottom = getBottom();
+ boolean hasVisibleHeaders = false;
+ for (int i = 0; i < mSize; i++) {
+ PinnedHeader header = mHeaders[i];
+ if (header.visible) {
+ hasVisibleHeaders = true;
+ if (header.state == BOTTOM && header.y < bottom) {
+ bottom = header.y;
+ } else if (header.state == TOP || header.state == FADING) {
+ int newTop = header.y + header.height;
+ if (newTop > top) {
+ top = newTop;
+ }
+ }
+ }
+ }
+
+ if (hasVisibleHeaders) {
+ canvas.save();
+ }
+
+ super.dispatchDraw(canvas);
+
+ if (hasVisibleHeaders) {
+ canvas.restore();
+
+ // If the first item is visible and if it has a positive top that is greater than the
+ // first header's assigned y-value, use that for the first header's y value. This way,
+ // the header inherits any padding applied to the list view.
+ if (mSize > 0 && getFirstVisiblePosition() == 0) {
+ View firstChild = getChildAt(0);
+ PinnedHeader firstHeader = mHeaders[0];
+
+ if (firstHeader != null) {
+ int firstHeaderTop = firstChild != null ? firstChild.getTop() : 0;
+ firstHeader.y = Math.max(firstHeader.y, firstHeaderTop);
+ }
+ }
+
+ // First draw top headers, then the bottom ones to handle the Z axis correctly
+ for (int i = mSize; --i >= 0;) {
+ PinnedHeader header = mHeaders[i];
+ if (header.visible && (header.state == TOP || header.state == FADING)) {
+ drawHeader(canvas, header, currentTime);
+ }
+ }
+
+ for (int i = 0; i < mSize; i++) {
+ PinnedHeader header = mHeaders[i];
+ if (header.visible && header.state == BOTTOM) {
+ drawHeader(canvas, header, currentTime);
+ }
+ }
+ }
+
+ invalidateIfAnimating();
+ }
+
+ private void drawHeader(Canvas canvas, PinnedHeader header, long currentTime) {
+ if (header.animating) {
+ int timeLeft = (int)(header.targetTime - currentTime);
+ if (timeLeft <= 0) {
+ header.y = header.targetY;
+ header.visible = header.targetVisible;
+ header.animating = false;
+ } else {
+ header.y = header.targetY + (header.sourceY - header.targetY) * timeLeft
+ / mAnimationDuration;
+ }
+ }
+ if (header.visible) {
+ View view = header.view;
+ int saveCount = canvas.save();
+ int translateX = ViewUtil.isViewLayoutRtl(this) ?
+ getWidth() - mHeaderPaddingStart - view.getWidth() :
+ mHeaderPaddingStart;
+ canvas.translate(translateX, header.y);
+ if (header.state == FADING) {
+ mBounds.set(0, 0, view.getWidth(), view.getHeight());
+ canvas.saveLayerAlpha(mBounds, header.alpha, Canvas.ALL_SAVE_FLAG);
+ }
+ view.draw(canvas);
+ canvas.restoreToCount(saveCount);
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/list/ProviderStatusWatcher.java b/src/com/android/contacts/common/list/ProviderStatusWatcher.java
new file mode 100644
index 0000000..3e8e2eb
--- /dev/null
+++ b/src/com/android/contacts/common/list/ProviderStatusWatcher.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Handler;
+import android.provider.ContactsContract.ProviderStatus;
+import android.util.Log;
+
+import com.android.contacts.common.compat.ProviderStatusCompat;
+
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+
+/**
+ * A singleton that keeps track of the last known provider status.
+ *
+ * All methods must be called on the UI thread unless noted otherwise.
+ *
+ * All members must be set on the UI thread unless noted otherwise.
+ */
+public class ProviderStatusWatcher extends ContentObserver {
+ private static final String TAG = "ProviderStatusWatcher";
+ private static final boolean DEBUG = false;
+
+ /**
+ * Callback interface invoked when the provider status changes.
+ */
+ public interface ProviderStatusListener {
+ public void onProviderStatusChange();
+ }
+
+ private static final String[] PROJECTION = new String[] {
+ ProviderStatus.STATUS
+ };
+
+ /**
+ * We'll wait for this amount of time on the UI thread if the load hasn't finished.
+ */
+ private static final int LOAD_WAIT_TIMEOUT_MS = 1000;
+
+ private static ProviderStatusWatcher sInstance;
+
+ private final Context mContext;
+ private final Handler mHandler = new Handler();
+
+ private final Object mSignal = new Object();
+
+ private int mStartRequestedCount;
+
+ private LoaderTask mLoaderTask;
+
+ /** Last known provider status. This can be changed on a worker thread.
+ * See {@link ProviderStatus#STATUS} */
+ private Integer mProviderStatus;
+
+ private final ArrayList<ProviderStatusListener> mListeners = Lists.newArrayList();
+
+ private final Runnable mStartLoadingRunnable = new Runnable() {
+ @Override
+ public void run() {
+ startLoading();
+ }
+ };
+
+ /**
+ * Returns the singleton instance.
+ */
+ public synchronized static ProviderStatusWatcher getInstance(Context context) {
+ if (sInstance == null) {
+ sInstance = new ProviderStatusWatcher(context);
+ }
+ return sInstance;
+ }
+
+ private ProviderStatusWatcher(Context context) {
+ super(null);
+ mContext = context;
+ }
+
+ /** Add a listener. */
+ public void addListener(ProviderStatusListener listener) {
+ mListeners.add(listener);
+ }
+
+ /** Remove a listener */
+ public void removeListener(ProviderStatusListener listener) {
+ mListeners.remove(listener);
+ }
+
+ private void notifyListeners() {
+ if (DEBUG) {
+ Log.d(TAG, "notifyListeners: " + mListeners.size());
+ }
+ if (isStarted()) {
+ for (ProviderStatusListener listener : mListeners) {
+ listener.onProviderStatusChange();
+ }
+ }
+ }
+
+ private boolean isStarted() {
+ return mStartRequestedCount > 0;
+ }
+
+ /**
+ * Starts watching the provider status. {@link #start()} and {@link #stop()} calls can be
+ * nested.
+ */
+ public void start() {
+ if (++mStartRequestedCount == 1) {
+ mContext.getContentResolver()
+ .registerContentObserver(ProviderStatus.CONTENT_URI, false, this);
+ startLoading();
+
+ if (DEBUG) {
+ Log.d(TAG, "Start observing");
+ }
+ }
+ }
+
+ /**
+ * Stops watching the provider status.
+ */
+ public void stop() {
+ if (!isStarted()) {
+ Log.e(TAG, "Already stopped");
+ return;
+ }
+ if (--mStartRequestedCount == 0) {
+
+ mHandler.removeCallbacks(mStartLoadingRunnable);
+
+ mContext.getContentResolver().unregisterContentObserver(this);
+ if (DEBUG) {
+ Log.d(TAG, "Stop observing");
+ }
+ }
+ }
+
+ /**
+ * @return last known provider status.
+ *
+ * If this method is called when we haven't started the status query or the query is still in
+ * progress, it will start a query in a worker thread if necessary, and *wait for the result*.
+ *
+ * This means this method is essentially a blocking {@link ProviderStatus#CONTENT_URI} query.
+ * This URI is not backed by the file system, so is usually fast enough to perform on the main
+ * thread, but in extreme cases (when the system takes a while to bring up the contacts
+ * provider?) this may still cause ANRs.
+ *
+ * In order to avoid that, if we can't load the status within {@link #LOAD_WAIT_TIMEOUT_MS},
+ * we'll give up and just returns {@link ProviderStatusCompat#STATUS_BUSY} in order to unblock
+ * the UI thread. The actual result will be delivered later via {@link ProviderStatusListener}.
+ * (If {@link ProviderStatusCompat#STATUS_BUSY} is returned, the app (should) shows an according
+ * message, like "contacts are being updated".)
+ */
+ public int getProviderStatus() {
+ waitForLoaded();
+
+ if (mProviderStatus == null) {
+ return ProviderStatusCompat.STATUS_BUSY;
+ }
+
+ return mProviderStatus;
+ }
+
+ private void waitForLoaded() {
+ if (mProviderStatus == null) {
+ if (mLoaderTask == null) {
+ // For some reason the loader couldn't load the status. Let's start it again.
+ startLoading();
+ }
+ synchronized (mSignal) {
+ try {
+ mSignal.wait(LOAD_WAIT_TIMEOUT_MS);
+ } catch (InterruptedException ignore) {
+ }
+ }
+ }
+ }
+
+ private void startLoading() {
+ if (mLoaderTask != null) {
+ return; // Task already running.
+ }
+
+ if (DEBUG) {
+ Log.d(TAG, "Start loading");
+ }
+
+ mLoaderTask = new LoaderTask();
+ mLoaderTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ private class LoaderTask extends AsyncTask<Void, Void, Boolean> {
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ try {
+ Cursor cursor = mContext.getContentResolver().query(ProviderStatus.CONTENT_URI,
+ PROJECTION, null, null, null);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ // Note here we can't just say "Status", as AsyncTask has the "Status"
+ // enum too.
+ mProviderStatus = cursor.getInt(0);
+ return true;
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ return false;
+ } finally {
+ synchronized (mSignal) {
+ mSignal.notifyAll();
+ }
+ }
+ }
+
+ @Override
+ protected void onCancelled(Boolean result) {
+ cleanUp();
+ }
+
+ @Override
+ protected void onPostExecute(Boolean loaded) {
+ cleanUp();
+ if (loaded != null && loaded) {
+ notifyListeners();
+ }
+ }
+
+ private void cleanUp() {
+ mLoaderTask = null;
+ }
+ }
+
+ /**
+ * Called when provider status may has changed.
+ *
+ * This method will be called on a worker thread by the framework.
+ */
+ @Override
+ public void onChange(boolean selfChange, Uri uri) {
+ if (!ProviderStatus.CONTENT_URI.equals(uri)) return;
+
+ // Provider status change is rare, so okay to log.
+ Log.i(TAG, "Provider status changed.");
+
+ mHandler.removeCallbacks(mStartLoadingRunnable); // Remove one in the queue, if any.
+ mHandler.post(mStartLoadingRunnable);
+ }
+}
diff --git a/src/com/android/contacts/common/list/ShortcutIntentBuilder.java b/src/com/android/contacts/common/list/ShortcutIntentBuilder.java
new file mode 100644
index 0000000..f30a176
--- /dev/null
+++ b/src/com/android/contacts/common/list/ShortcutIntentBuilder.java
@@ -0,0 +1,427 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.app.ActivityManager;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Paint.FontMetricsInt;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.support.v4.graphics.drawable.RoundedBitmapDrawable;
+import android.support.v4.graphics.drawable.RoundedBitmapDrawableFactory;
+import android.telecom.PhoneAccount;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.text.TextUtils.TruncateAt;
+
+import com.android.contacts.common.ContactsUtils;
+import com.android.contacts.common.ContactPhotoManager;
+import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest;
+import com.android.contacts.common.R;
+
+/**
+ * Constructs shortcut intents.
+ */
+public class ShortcutIntentBuilder {
+
+ private static final String[] CONTACT_COLUMNS = {
+ Contacts.DISPLAY_NAME,
+ Contacts.PHOTO_ID,
+ Contacts.LOOKUP_KEY
+ };
+
+ private static final int CONTACT_DISPLAY_NAME_COLUMN_INDEX = 0;
+ private static final int CONTACT_PHOTO_ID_COLUMN_INDEX = 1;
+ private static final int CONTACT_LOOKUP_KEY_COLUMN_INDEX = 2;
+
+ private static final String[] PHONE_COLUMNS = {
+ Phone.DISPLAY_NAME,
+ Phone.PHOTO_ID,
+ Phone.NUMBER,
+ Phone.TYPE,
+ Phone.LABEL,
+ Phone.LOOKUP_KEY
+ };
+
+ private static final int PHONE_DISPLAY_NAME_COLUMN_INDEX = 0;
+ private static final int PHONE_PHOTO_ID_COLUMN_INDEX = 1;
+ private static final int PHONE_NUMBER_COLUMN_INDEX = 2;
+ private static final int PHONE_TYPE_COLUMN_INDEX = 3;
+ private static final int PHONE_LABEL_COLUMN_INDEX = 4;
+ private static final int PHONE_LOOKUP_KEY_COLUMN_INDEX = 5;
+
+ private static final String[] PHOTO_COLUMNS = {
+ Photo.PHOTO,
+ };
+
+ private static final int PHOTO_PHOTO_COLUMN_INDEX = 0;
+
+ private static final String PHOTO_SELECTION = Photo._ID + "=?";
+
+ private final OnShortcutIntentCreatedListener mListener;
+ private final Context mContext;
+ private int mIconSize;
+ private final int mIconDensity;
+ private final int mOverlayTextBackgroundColor;
+ private final Resources mResources;
+
+ /**
+ * This is a hidden API of the launcher in JellyBean that allows us to disable the animation
+ * that it would usually do, because it interferes with our own animation for QuickContact.
+ * This is needed since some versions of the launcher override the intent flags and therefore
+ * ignore Intent.FLAG_ACTIVITY_NO_ANIMATION.
+ */
+ public static final String INTENT_EXTRA_IGNORE_LAUNCH_ANIMATION =
+ "com.android.launcher.intent.extra.shortcut.INGORE_LAUNCH_ANIMATION";
+
+ /**
+ * Listener interface.
+ */
+ public interface OnShortcutIntentCreatedListener {
+
+ /**
+ * Callback for shortcut intent creation.
+ *
+ * @param uri the original URI for which the shortcut intent has been
+ * created.
+ * @param shortcutIntent resulting shortcut intent.
+ */
+ void onShortcutIntentCreated(Uri uri, Intent shortcutIntent);
+ }
+
+ public ShortcutIntentBuilder(Context context, OnShortcutIntentCreatedListener listener) {
+ mContext = context;
+ mListener = listener;
+
+ mResources = context.getResources();
+ final ActivityManager am = (ActivityManager) context
+ .getSystemService(Context.ACTIVITY_SERVICE);
+ mIconSize = mResources.getDimensionPixelSize(R.dimen.shortcut_icon_size);
+ if (mIconSize == 0) {
+ mIconSize = am.getLauncherLargeIconSize();
+ }
+ mIconDensity = am.getLauncherLargeIconDensity();
+ mOverlayTextBackgroundColor = mResources.getColor(R.color.shortcut_overlay_text_background);
+ }
+
+ public void createContactShortcutIntent(Uri contactUri) {
+ new ContactLoadingAsyncTask(contactUri).execute();
+ }
+
+ public void createPhoneNumberShortcutIntent(Uri dataUri, String shortcutAction) {
+ new PhoneNumberLoadingAsyncTask(dataUri, shortcutAction).execute();
+ }
+
+ /**
+ * An asynchronous task that loads name, photo and other data from the database.
+ */
+ private abstract class LoadingAsyncTask extends AsyncTask<Void, Void, Void> {
+ protected Uri mUri;
+ protected String mContentType;
+ protected String mDisplayName;
+ protected String mLookupKey;
+ protected byte[] mBitmapData;
+ protected long mPhotoId;
+
+ public LoadingAsyncTask(Uri uri) {
+ mUri = uri;
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ mContentType = mContext.getContentResolver().getType(mUri);
+ loadData();
+ loadPhoto();
+ return null;
+ }
+
+ protected abstract void loadData();
+
+ private void loadPhoto() {
+ if (mPhotoId == 0) {
+ return;
+ }
+
+ ContentResolver resolver = mContext.getContentResolver();
+ Cursor cursor = resolver.query(Data.CONTENT_URI, PHOTO_COLUMNS, PHOTO_SELECTION,
+ new String[] { String.valueOf(mPhotoId) }, null);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ mBitmapData = cursor.getBlob(PHOTO_PHOTO_COLUMN_INDEX);
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+ }
+
+ private final class ContactLoadingAsyncTask extends LoadingAsyncTask {
+ public ContactLoadingAsyncTask(Uri uri) {
+ super(uri);
+ }
+
+ @Override
+ protected void loadData() {
+ ContentResolver resolver = mContext.getContentResolver();
+ Cursor cursor = resolver.query(mUri, CONTACT_COLUMNS, null, null, null);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ mDisplayName = cursor.getString(CONTACT_DISPLAY_NAME_COLUMN_INDEX);
+ mPhotoId = cursor.getLong(CONTACT_PHOTO_ID_COLUMN_INDEX);
+ mLookupKey = cursor.getString(CONTACT_LOOKUP_KEY_COLUMN_INDEX);
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+ @Override
+ protected void onPostExecute(Void result) {
+ createContactShortcutIntent(mUri, mContentType, mDisplayName, mLookupKey, mBitmapData);
+ }
+ }
+
+ private final class PhoneNumberLoadingAsyncTask extends LoadingAsyncTask {
+ private final String mShortcutAction;
+ private String mPhoneNumber;
+ private int mPhoneType;
+ private String mPhoneLabel;
+
+ public PhoneNumberLoadingAsyncTask(Uri uri, String shortcutAction) {
+ super(uri);
+ mShortcutAction = shortcutAction;
+ }
+
+ @Override
+ protected void loadData() {
+ ContentResolver resolver = mContext.getContentResolver();
+ Cursor cursor = resolver.query(mUri, PHONE_COLUMNS, null, null, null);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ mDisplayName = cursor.getString(PHONE_DISPLAY_NAME_COLUMN_INDEX);
+ mPhotoId = cursor.getLong(PHONE_PHOTO_ID_COLUMN_INDEX);
+ mPhoneNumber = cursor.getString(PHONE_NUMBER_COLUMN_INDEX);
+ mPhoneType = cursor.getInt(PHONE_TYPE_COLUMN_INDEX);
+ mPhoneLabel = cursor.getString(PHONE_LABEL_COLUMN_INDEX);
+ mLookupKey = cursor.getString(PHONE_LOOKUP_KEY_COLUMN_INDEX);
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ createPhoneNumberShortcutIntent(mUri, mDisplayName, mLookupKey, mBitmapData,
+ mPhoneNumber, mPhoneType, mPhoneLabel, mShortcutAction);
+ }
+ }
+
+ private Drawable getPhotoDrawable(byte[] bitmapData, String displayName, String lookupKey) {
+ if (bitmapData != null) {
+ Bitmap bitmap = BitmapFactory.decodeByteArray(bitmapData, 0, bitmapData.length, null);
+ return new BitmapDrawable(mContext.getResources(), bitmap);
+ } else {
+ return ContactPhotoManager.getDefaultAvatarDrawableForContact(mContext.getResources(),
+ false, new DefaultImageRequest(displayName, lookupKey, false));
+ }
+ }
+
+ private void createContactShortcutIntent(Uri contactUri, String contentType, String displayName,
+ String lookupKey, byte[] bitmapData) {
+ Drawable drawable = getPhotoDrawable(bitmapData, displayName, lookupKey);
+
+ // Use an implicit intent without a package name set. It is reasonable for a disambiguation
+ // dialog to appear when opening QuickContacts from the launcher. Plus, this will be more
+ // resistant to future package name changes done to Contacts.
+ Intent shortcutIntent = new Intent(ContactsContract.QuickContact.ACTION_QUICK_CONTACT);
+
+ // When starting from the launcher, start in a new, cleared task.
+ // CLEAR_WHEN_TASK_RESET cannot reset the root of a task, so we
+ // clear the whole thing preemptively here since QuickContactActivity will
+ // finish itself when launching other detail activities. We need to use
+ // Intent.FLAG_ACTIVITY_NO_ANIMATION since not all versions of launcher will respect
+ // the INTENT_EXTRA_IGNORE_LAUNCH_ANIMATION intent extra.
+ shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK
+ | Intent.FLAG_ACTIVITY_NO_ANIMATION);
+
+ // Tell the launcher to not do its animation, because we are doing our own
+ shortcutIntent.putExtra(INTENT_EXTRA_IGNORE_LAUNCH_ANIMATION, true);
+
+ shortcutIntent.setDataAndType(contactUri, contentType);
+ shortcutIntent.putExtra(ContactsContract.QuickContact.EXTRA_EXCLUDE_MIMES,
+ (String[]) null);
+
+ final Bitmap icon = generateQuickContactIcon(drawable);
+
+ Intent intent = new Intent();
+ intent.putExtra(Intent.EXTRA_SHORTCUT_ICON, icon);
+ intent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent);
+ if (TextUtils.isEmpty(displayName)) {
+ intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, mContext.getResources().getString(
+ R.string.missing_name));
+ } else {
+ intent.putExtra(Intent.EXTRA_SHORTCUT_NAME, displayName);
+ }
+
+ mListener.onShortcutIntentCreated(contactUri, intent);
+ }
+
+ private void createPhoneNumberShortcutIntent(Uri uri, String displayName, String lookupKey,
+ byte[] bitmapData, String phoneNumber, int phoneType, String phoneLabel,
+ String shortcutAction) {
+ Drawable drawable = getPhotoDrawable(bitmapData, displayName, lookupKey);
+
+ Bitmap bitmap;
+ Uri phoneUri;
+ if (Intent.ACTION_CALL.equals(shortcutAction)) {
+ // Make the URI a direct tel: URI so that it will always continue to work
+ phoneUri = Uri.fromParts(PhoneAccount.SCHEME_TEL, phoneNumber, null);
+ bitmap = generatePhoneNumberIcon(drawable, phoneType, phoneLabel,
+ R.drawable.ic_call);
+ } else {
+ phoneUri = Uri.fromParts(ContactsUtils.SCHEME_SMSTO, phoneNumber, null);
+ bitmap = generatePhoneNumberIcon(drawable, phoneType, phoneLabel,
+ R.drawable.ic_message_24dp_mirrored);
+ }
+
+ Intent shortcutIntent = new Intent(shortcutAction, phoneUri);
+ shortcutIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ Intent intent = new Intent();
+ intent.putExtra(Intent.EXTRA_SHORTCUT_ICON, bitmap);
+ intent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent);
+
+ if (TextUtils.isEmpty(displayName)) {
+ displayName = mContext.getResources().getString(R.string.missing_name);
+ }
+ if (TextUtils.equals(shortcutAction, Intent.ACTION_CALL)) {
+ intent.putExtra(Intent.EXTRA_SHORTCUT_NAME,
+ mContext.getResources().getString(R.string.call_by_shortcut, displayName));
+ } else if (TextUtils.equals(shortcutAction, Intent.ACTION_SENDTO)) {
+ intent.putExtra(Intent.EXTRA_SHORTCUT_NAME,
+ mContext.getResources().getString(R.string.sms_by_shortcut, displayName));
+ }
+
+ mListener.onShortcutIntentCreated(uri, intent);
+ }
+
+ private Bitmap generateQuickContactIcon(Drawable photo) {
+
+ // Setup the drawing classes
+ Bitmap bitmap = Bitmap.createBitmap(mIconSize, mIconSize, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+
+ // Copy in the photo
+ Rect dst = new Rect(0,0, mIconSize, mIconSize);
+ photo.setBounds(dst);
+ photo.draw(canvas);
+
+ // Draw the icon with a rounded border
+ RoundedBitmapDrawable roundedDrawable =
+ RoundedBitmapDrawableFactory.create(mResources, bitmap);
+ roundedDrawable.setAntiAlias(true);
+ roundedDrawable.setCornerRadius(mIconSize / 2);
+ Bitmap roundedBitmap = Bitmap.createBitmap(mIconSize, mIconSize, Bitmap.Config.ARGB_8888);
+ canvas.setBitmap(roundedBitmap);
+ roundedDrawable.setBounds(dst);
+ roundedDrawable.draw(canvas);
+ canvas.setBitmap(null);
+
+ return roundedBitmap;
+ }
+
+ /**
+ * Generates a phone number shortcut icon. Adds an overlay describing the type of the phone
+ * number, and if there is a photo also adds the call action icon.
+ */
+ private Bitmap generatePhoneNumberIcon(Drawable photo, int phoneType, String phoneLabel,
+ int actionResId) {
+ final Resources r = mContext.getResources();
+ final float density = r.getDisplayMetrics().density;
+
+ Bitmap phoneIcon = ((BitmapDrawable) r.getDrawableForDensity(actionResId, mIconDensity))
+ .getBitmap();
+
+ Bitmap icon = generateQuickContactIcon(photo);
+ Canvas canvas = new Canvas(icon);
+
+ // Copy in the photo
+ Paint photoPaint = new Paint();
+ photoPaint.setDither(true);
+ photoPaint.setFilterBitmap(true);
+ Rect dst = new Rect(0, 0, mIconSize, mIconSize);
+
+ // Create an overlay for the phone number type
+ CharSequence overlay = Phone.getTypeLabel(r, phoneType, phoneLabel);
+
+ if (overlay != null) {
+ TextPaint textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DEV_KERN_TEXT_FLAG);
+ textPaint.setTextSize(r.getDimension(R.dimen.shortcut_overlay_text_size));
+ textPaint.setColor(r.getColor(R.color.textColorIconOverlay));
+ textPaint.setShadowLayer(4f, 0, 2f, r.getColor(R.color.textColorIconOverlayShadow));
+
+ final FontMetricsInt fmi = textPaint.getFontMetricsInt();
+
+ // First fill in a darker background around the text to be drawn
+ final Paint workPaint = new Paint();
+ workPaint.setColor(mOverlayTextBackgroundColor);
+ workPaint.setStyle(Paint.Style.FILL);
+ final int textPadding = r
+ .getDimensionPixelOffset(R.dimen.shortcut_overlay_text_background_padding);
+ final int textBandHeight = (fmi.descent - fmi.ascent) + textPadding * 2;
+ dst.set(0, mIconSize - textBandHeight, mIconSize, mIconSize);
+ canvas.drawRect(dst, workPaint);
+
+ overlay = TextUtils.ellipsize(overlay, textPaint, mIconSize, TruncateAt.END);
+ final float textWidth = textPaint.measureText(overlay, 0, overlay.length());
+ canvas.drawText(overlay, 0, overlay.length(), (mIconSize - textWidth) / 2, mIconSize
+ - fmi.descent - textPadding, textPaint);
+ }
+
+ // Draw the phone action icon as an overlay
+ Rect src = new Rect(0, 0, phoneIcon.getWidth(), phoneIcon.getHeight());
+ int iconWidth = icon.getWidth();
+ dst.set(iconWidth - ((int) (20 * density)), -1,
+ iconWidth, ((int) (19 * density)));
+ canvas.drawBitmap(phoneIcon, src, dst, photoPaint);
+
+ canvas.setBitmap(null);
+
+ return icon;
+ }
+}
diff --git a/src/com/android/contacts/common/list/ViewPagerTabStrip.java b/src/com/android/contacts/common/list/ViewPagerTabStrip.java
new file mode 100644
index 0000000..c8ae21a
--- /dev/null
+++ b/src/com/android/contacts/common/list/ViewPagerTabStrip.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.LinearLayout;
+
+import com.android.contacts.common.R;
+
+public class ViewPagerTabStrip extends LinearLayout {
+ private int mSelectedUnderlineThickness;
+ private final Paint mSelectedUnderlinePaint;
+
+ private int mIndexForSelection;
+ private float mSelectionOffset;
+
+ public ViewPagerTabStrip(Context context) {
+ this(context, null);
+ }
+
+ public ViewPagerTabStrip(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ final Resources res = context.getResources();
+
+ mSelectedUnderlineThickness =
+ res.getDimensionPixelSize(R.dimen.tab_selected_underline_height);
+ int underlineColor = res.getColor(R.color.tab_selected_underline_color);
+ int backgroundColor = res.getColor(R.color.actionbar_background_color);
+
+ mSelectedUnderlinePaint = new Paint();
+ mSelectedUnderlinePaint.setColor(underlineColor);
+
+ setBackgroundColor(backgroundColor);
+ setWillNotDraw(false);
+ }
+
+ /**
+ * Notifies this view that view pager has been scrolled. We save the tab index
+ * and selection offset for interpolating the position and width of selection
+ * underline.
+ */
+ void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ mIndexForSelection = position;
+ mSelectionOffset = positionOffset;
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ int childCount = getChildCount();
+
+ // Thick colored underline below the current selection
+ if (childCount > 0) {
+ View selectedTitle = getChildAt(mIndexForSelection);
+
+ if (selectedTitle == null) {
+ // The view pager's tab count changed but we weren't notified yet. Ignore this draw
+ // pass, when we get a new selection we will update and draw the selection strip in
+ // the correct place.
+ return;
+ }
+ int selectedLeft = selectedTitle.getLeft();
+ int selectedRight = selectedTitle.getRight();
+ final boolean isRtl = isRtl();
+ final boolean hasNextTab = isRtl ? mIndexForSelection > 0
+ : (mIndexForSelection < (getChildCount() - 1));
+ if ((mSelectionOffset > 0.0f) && hasNextTab) {
+ // Draw the selection partway between the tabs
+ View nextTitle = getChildAt(mIndexForSelection + (isRtl ? -1 : 1));
+ int nextLeft = nextTitle.getLeft();
+ int nextRight = nextTitle.getRight();
+
+ selectedLeft = (int) (mSelectionOffset * nextLeft +
+ (1.0f - mSelectionOffset) * selectedLeft);
+ selectedRight = (int) (mSelectionOffset * nextRight +
+ (1.0f - mSelectionOffset) * selectedRight);
+ }
+
+ int height = getHeight();
+ canvas.drawRect(selectedLeft, height - mSelectedUnderlineThickness,
+ selectedRight, height, mSelectedUnderlinePaint);
+ }
+ }
+
+ private boolean isRtl() {
+ return getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
+ }
+}
\ No newline at end of file
diff --git a/src/com/android/contacts/common/list/ViewPagerTabs.java b/src/com/android/contacts/common/list/ViewPagerTabs.java
new file mode 100644
index 0000000..48de6af
--- /dev/null
+++ b/src/com/android/contacts/common/list/ViewPagerTabs.java
@@ -0,0 +1,315 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.list;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.Outline;
+import android.support.v4.view.PagerAdapter;
+import android.support.v4.view.ViewPager;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewOutlineProvider;
+import android.widget.FrameLayout;
+import android.widget.HorizontalScrollView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.CompatUtils;
+
+/**
+ * Lightweight implementation of ViewPager tabs. This looks similar to traditional actionBar tabs,
+ * but allows for the view containing the tabs to be placed anywhere on screen. Text-related
+ * attributes can also be assigned in XML - these will get propogated to the child TextViews
+ * automatically.
+ */
+public class ViewPagerTabs extends HorizontalScrollView implements ViewPager.OnPageChangeListener {
+
+ ViewPager mPager;
+ private ViewPagerTabStrip mTabStrip;
+
+ /**
+ * Linearlayout that will contain the TextViews serving as tabs. This is the only child
+ * of the parent HorizontalScrollView.
+ */
+ final int mTextStyle;
+ final ColorStateList mTextColor;
+ final int mTextSize;
+ final boolean mTextAllCaps;
+ int mPrevSelected = -1;
+ int mSidePadding;
+
+ private int[] mTabIcons;
+ // For displaying the unread count next to the tab icon.
+ private int[] mUnreadCounts;
+
+ private static final ViewOutlineProvider VIEW_BOUNDS_OUTLINE_PROVIDER;
+ static {
+ if (CompatUtils.isLollipopCompatible()) {
+ VIEW_BOUNDS_OUTLINE_PROVIDER = new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View view, Outline outline) {
+ outline.setRect(0, 0, view.getWidth(), view.getHeight());
+ }
+ };
+ } else {
+ VIEW_BOUNDS_OUTLINE_PROVIDER = null;
+ }
+ }
+
+ private static final int TAB_SIDE_PADDING_IN_DPS = 10;
+
+ // TODO: This should use <declare-styleable> in the future
+ private static final int[] ATTRS = new int[] {
+ android.R.attr.textSize,
+ android.R.attr.textStyle,
+ android.R.attr.textColor,
+ android.R.attr.textAllCaps
+ };
+
+ /**
+ * Simulates actionbar tab behavior by showing a toast with the tab title when long clicked.
+ */
+ private class OnTabLongClickListener implements OnLongClickListener {
+ final int mPosition;
+
+ public OnTabLongClickListener(int position) {
+ mPosition = position;
+ }
+
+ @Override
+ public boolean onLongClick(View v) {
+ final int[] screenPos = new int[2];
+ getLocationOnScreen(screenPos);
+
+ final Context context = getContext();
+ final int width = getWidth();
+ final int height = getHeight();
+ final int screenWidth = context.getResources().getDisplayMetrics().widthPixels;
+
+ Toast toast = Toast.makeText(context, mPager.getAdapter().getPageTitle(mPosition),
+ Toast.LENGTH_SHORT);
+
+ // Show the toast under the tab
+ toast.setGravity(Gravity.TOP | Gravity.CENTER_HORIZONTAL,
+ (screenPos[0] + width / 2) - screenWidth / 2, screenPos[1] + height);
+
+ toast.show();
+ return true;
+ }
+ }
+
+ public ViewPagerTabs(Context context) {
+ this(context, null);
+ }
+
+ public ViewPagerTabs(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public ViewPagerTabs(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ setFillViewport(true);
+
+ mSidePadding = (int) (getResources().getDisplayMetrics().density * TAB_SIDE_PADDING_IN_DPS);
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, ATTRS);
+ mTextSize = a.getDimensionPixelSize(0, 0);
+ mTextStyle = a.getInt(1, 0);
+ mTextColor = a.getColorStateList(2);
+ mTextAllCaps = a.getBoolean(3, false);
+
+ mTabStrip = new ViewPagerTabStrip(context);
+ addView(mTabStrip,
+ new FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT));
+ a.recycle();
+
+ if (CompatUtils.isLollipopCompatible()) {
+ // enable shadow casting from view bounds
+ setOutlineProvider(VIEW_BOUNDS_OUTLINE_PROVIDER);
+ }
+ }
+
+ public void setViewPager(ViewPager viewPager) {
+ mPager = viewPager;
+ addTabs(mPager.getAdapter());
+ }
+
+ /**
+ * Set the tab icons and initialize an array for unread counts the same length as the icon
+ * array.
+ *
+ * @param tabIcons An array representing the tab icons in order.
+ */
+ public void configureTabIcons(int[] tabIcons) {
+ mTabIcons = tabIcons;
+ mUnreadCounts = new int[tabIcons.length];
+ }
+
+ public void setUnreadCount(int count, int position) {
+ if (mUnreadCounts == null || position >= mUnreadCounts.length) {
+ return;
+ }
+ mUnreadCounts[position] = count;
+ }
+
+ private void addTabs(PagerAdapter adapter) {
+ mTabStrip.removeAllViews();
+
+ final int count = adapter.getCount();
+ for (int i = 0; i < count; i++) {
+ addTab(adapter.getPageTitle(i), i);
+ }
+ }
+
+ private void addTab(CharSequence tabTitle, final int position) {
+ View tabView;
+ if (mTabIcons != null && position < mTabIcons.length) {
+ View layout = LayoutInflater.from(getContext()).inflate(
+ R.layout.unread_count_tab, null);
+ View iconView = layout.findViewById(R.id.icon);
+ iconView.setBackgroundResource(mTabIcons[position]);
+ iconView.setContentDescription(tabTitle);
+ TextView textView = (TextView) layout.findViewById(R.id.count);
+ if (mUnreadCounts != null && mUnreadCounts[position] > 0) {
+ textView.setText(Integer.toString(mUnreadCounts[position]));
+ textView.setVisibility(View.VISIBLE);
+ iconView.setContentDescription(getResources().getQuantityString(
+ R.plurals.tab_title_with_unread_items,
+ mUnreadCounts[position],
+ tabTitle.toString(),
+ mUnreadCounts[position]));
+ } else {
+ textView.setVisibility(View.INVISIBLE);
+ iconView.setContentDescription(tabTitle);
+ }
+ tabView = layout;
+ } else {
+ final TextView textView = new TextView(getContext());
+ textView.setText(tabTitle);
+ textView.setBackgroundResource(R.drawable.view_pager_tab_background);
+
+ // Assign various text appearance related attributes to child views.
+ if (mTextStyle > 0) {
+ textView.setTypeface(textView.getTypeface(), mTextStyle);
+ }
+ if (mTextSize > 0) {
+ textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize);
+ }
+ if (mTextColor != null) {
+ textView.setTextColor(mTextColor);
+ }
+ textView.setAllCaps(mTextAllCaps);
+ textView.setGravity(Gravity.CENTER);
+
+ tabView = textView;
+ }
+
+ tabView.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mPager.setCurrentItem(getRtlPosition(position));
+ }
+ });
+
+ tabView.setOnLongClickListener(new OnTabLongClickListener(position));
+
+ tabView.setPadding(mSidePadding, 0, mSidePadding, 0);
+
+ mTabStrip.addView(tabView, position, new LinearLayout.LayoutParams(
+ LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT, 1));
+
+ // Default to the first child being selected
+ if (position == 0) {
+ mPrevSelected = 0;
+ tabView.setSelected(true);
+ }
+ }
+
+ /**
+ * Remove a tab at a certain index.
+ *
+ * @param index The index of the tab view we wish to remove.
+ */
+ public void removeTab(int index) {
+ View view = mTabStrip.getChildAt(index);
+ if (view != null) {
+ mTabStrip.removeView(view);
+ }
+ }
+
+ /**
+ * Refresh a tab at a certain index by removing it and reconstructing it.
+ *
+ * @param index The index of the tab view we wish to update.
+ */
+ public void updateTab(int index) {
+ removeTab(index);
+
+ if (index < mPager.getAdapter().getCount()) {
+ addTab(mPager.getAdapter().getPageTitle(index), index);
+ }
+ }
+
+ @Override
+ public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
+ position = getRtlPosition(position);
+ int tabStripChildCount = mTabStrip.getChildCount();
+ if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) {
+ return;
+ }
+
+ mTabStrip.onPageScrolled(position, positionOffset, positionOffsetPixels);
+ }
+
+ @Override
+ public void onPageSelected(int position) {
+ position = getRtlPosition(position);
+ int tabStripChildCount = mTabStrip.getChildCount();
+ if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) {
+ return;
+ }
+
+ if (mPrevSelected >= 0 && mPrevSelected < tabStripChildCount) {
+ mTabStrip.getChildAt(mPrevSelected).setSelected(false);
+ }
+ final View selectedChild = mTabStrip.getChildAt(position);
+ selectedChild.setSelected(true);
+
+ // Update scroll position
+ final int scrollPos = selectedChild.getLeft() - (getWidth() - selectedChild.getWidth()) / 2;
+ smoothScrollTo(scrollPos, 0);
+ mPrevSelected = position;
+ }
+
+ @Override
+ public void onPageScrollStateChanged(int state) {
+ }
+
+ private int getRtlPosition(int position) {
+ if (getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) {
+ return mTabStrip.getChildCount() - 1 - position;
+ }
+ return position;
+ }
+}
+
diff --git a/src/com/android/contacts/common/location/CountryDetector.java b/src/com/android/contacts/common/location/CountryDetector.java
new file mode 100644
index 0000000..2d29a69
--- /dev/null
+++ b/src/com/android/contacts/common/location/CountryDetector.java
@@ -0,0 +1,130 @@
+package com.android.contacts.common.location;
+
+import android.content.Context;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.contacts.common.testing.NeededForTesting;
+
+import java.util.Locale;
+
+/**
+ * This class is used to detect the country where the user is. It is a simplified version of the
+ * country detector service in the framework. The sources of country location are queried in the
+ * following order of reliability:
+ * <ul>
+ * <li>Mobile network</li>
+ * <li>SIM's country</li>
+ * <li>User's default locale</li>
+ * </ul>
+ *
+ * As far as possible this class tries to replicate the behavior of the system's country detector
+ * service:
+ * 1) Order in priority of sources of country location
+ * 2) Mobile network information provided by CDMA phones is ignored
+ */
+public class CountryDetector {
+ private static final String TAG = "CountryDetector";
+
+ private static CountryDetector sInstance;
+
+ private final Context mContext;
+ private final LocaleProvider mLocaleProvider;
+ private final TelephonyManager mTelephonyManager;
+
+ // Used as a default country code when all the sources of country data have failed in the
+ // exceedingly rare event that the device does not have a default locale set for some reason.
+ private final String DEFAULT_COUNTRY_ISO = "US";
+
+ /**
+ * Class that can be used to return the user's default locale. This is in its own class so that
+ * it can be mocked out.
+ */
+ public static class LocaleProvider {
+ public Locale getDefaultLocale() {
+ return Locale.getDefault();
+ }
+ }
+
+ private CountryDetector(Context context) {
+ this (context, (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE),
+ new LocaleProvider());
+ }
+
+ private CountryDetector(Context context, TelephonyManager telephonyManager,
+ LocaleProvider localeProvider) {
+ mTelephonyManager = telephonyManager;
+ mLocaleProvider = localeProvider;
+ mContext = context;
+ }
+
+ /**
+ * Factory method for {@link CountryDetector} that allows the caller to provide mock objects.
+ */
+ @NeededForTesting
+ public CountryDetector getInstanceForTest(Context context, TelephonyManager telephonyManager,
+ LocaleProvider localeProvider) {
+ return new CountryDetector(context, telephonyManager, localeProvider);
+ }
+
+ /**
+ * Returns the instance of the country detector. {@link #initialize(Context)} must have been
+ * called previously.
+ *
+ * @return the initialized country detector.
+ */
+ public synchronized static CountryDetector getInstance(Context context) {
+ if (sInstance == null) {
+ sInstance = new CountryDetector(context.getApplicationContext());
+ }
+ return sInstance;
+ }
+
+ public String getCurrentCountryIso() {
+ String result = null;
+ if (isNetworkCountryCodeAvailable()) {
+ result = getNetworkBasedCountryIso();
+ }
+ if (TextUtils.isEmpty(result)) {
+ result = getSimBasedCountryIso();
+ }
+ if (TextUtils.isEmpty(result)) {
+ result = getLocaleBasedCountryIso();
+ }
+ if (TextUtils.isEmpty(result)) {
+ result = DEFAULT_COUNTRY_ISO;
+ }
+ return result.toUpperCase(Locale.US);
+ }
+
+ /**
+ * @return the country code of the current telephony network the user is connected to.
+ */
+ private String getNetworkBasedCountryIso() {
+ return mTelephonyManager.getNetworkCountryIso();
+ }
+
+ /**
+ * @return the country code of the SIM card currently inserted in the device.
+ */
+ private String getSimBasedCountryIso() {
+ return mTelephonyManager.getSimCountryIso();
+ }
+
+ /**
+ * @return the country code of the user's currently selected locale.
+ */
+ private String getLocaleBasedCountryIso() {
+ Locale defaultLocale = mLocaleProvider.getDefaultLocale();
+ if (defaultLocale != null) {
+ return defaultLocale.getCountry();
+ }
+ return null;
+ }
+
+ private boolean isNetworkCountryCodeAvailable() {
+ // On CDMA TelephonyManager.getNetworkCountryIso() just returns the SIM's country code.
+ return mTelephonyManager.getPhoneType() == TelephonyManager.PHONE_TYPE_GSM;
+ }
+}
diff --git a/src/com/android/contacts/common/logging/ListEvent.java b/src/com/android/contacts/common/logging/ListEvent.java
new file mode 100644
index 0000000..7d019dd
--- /dev/null
+++ b/src/com/android/contacts/common/logging/ListEvent.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.logging;
+
+import com.google.common.base.Objects;
+
+/**
+ * Describes how user view and use a list
+ */
+public final class ListEvent {
+
+ /** The type of action taken by the user. **/
+ public int actionType;
+
+ /** The type of list the user is viewing. **/
+ public int listType;
+
+ /** The number of contacts in the list. **/
+ public int count;
+
+ /** The index of contact clicked by user. **/
+ public int clickedIndex = -1;
+
+ /** The number of contact selected when user takes an action (link, delete, share, etc). **/
+ public int numSelected;
+
+ // Should match ContactsExtension.ListEvent.ActionType values in
+ // http://cs/google3/logs/proto/wireless/android/contacts/contacts_extensions.proto
+ public static final class ActionType {
+ public static final int UNKNOWN = 0;
+ public static final int LOAD = 1;
+ public static final int CLICK = 2;
+ public static final int SELECT = 3;
+ public static final int SHARE = 4;
+ public static final int DELETE = 5;
+ public static final int LINK = 6;
+ public static final int REMOVE_LABEL = 7;
+
+ private ActionType() {
+ }
+ }
+
+ // Should match ContactsExtension.ListEvent.ListType values in
+ // http://cs/google3/logs/proto/wireless/android/contacts/contacts_extensions.proto
+ public static final class ListType {
+ public static final int UNKNOWN_LIST = 0;
+ public static final int ALL_CONTACTS = 1;
+ public static final int ACCOUNT = 2;
+ public static final int GROUP = 3;
+ public static final int SEARCH_RESULT = 4;
+ public static final int DEVICE = 5;
+ public static final int CUSTOM = 6;
+ public static final int STARRED = 7;
+ public static final int PHONE_NUMBERS = 8;
+ public static final int SINGLE_CONTACT = 9;
+ public static final int PICK_CONTACT = 10;
+ public static final int PICK_CONTACT_FOR_SHORTCUT = 11;
+ public static final int PICK_PHONE = 12;
+ public static final int PICK_EMAIL = 13;
+ public static final int PICK_POSTAL = 14;
+ public static final int PICK_JOIN = 15;
+ public static final int PICK_GROUP_MEMBERS = 16;
+
+ private ListType() {
+ }
+ }
+
+ public ListEvent() {
+ }
+
+ @Override
+ public String toString() {
+ return Objects.toStringHelper(this)
+ .add("actionType", actionType)
+ .add("listType", listType)
+ .add("count", count)
+ .add("clickedIndex", clickedIndex)
+ .add("numSelected", numSelected)
+ .toString();
+ }
+}
diff --git a/src/com/android/contacts/common/logging/Logger.java b/src/com/android/contacts/common/logging/Logger.java
new file mode 100644
index 0000000..72d1e2a
--- /dev/null
+++ b/src/com/android/contacts/common/logging/Logger.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.logging;
+
+import android.app.Activity;
+
+import com.android.contacts.common.logging.ScreenEvent.ScreenType;
+import com.android.contacts.commonbind.ObjectFactory;
+
+/**
+ * Logs analytics events.
+ */
+public abstract class Logger {
+ public static final String TAG = "Logger";
+
+ private static Logger getInstance() {
+ return ObjectFactory.getLogger();
+ }
+
+ /**
+ * Logs an event indicating that a screen was displayed.
+ *
+ * @param screenType integer identifier of the displayed screen
+ * @param activity Parent activity of the displayed screen.
+ */
+ public static void logScreenView(Activity activity, int screenType) {
+ logScreenView(activity, screenType, ScreenType.UNKNOWN);
+ }
+
+ /**
+ * @param previousScreenType integer identifier of the displayed screen the user came from.
+ */
+ public static void logScreenView(Activity activity, int screenType, int previousScreenType) {
+ final Logger logger = getInstance();
+ if (logger != null) {
+ logger.logScreenViewImpl(screenType, previousScreenType);
+ }
+ }
+
+ /**
+ * Logs the results of a user search for a particular contact.
+ */
+ public static void logSearchEvent(SearchState searchState) {
+ final Logger logger = getInstance();
+ if (logger != null) {
+ logger.logSearchEventImpl(searchState);
+ }
+ }
+
+ /**
+ * Logs how users view and use a contacts list. See {@link ListEvent} for definition of
+ * parameters.
+ */
+ public static void logListEvent(int actionType, int listType, int count, int clickedIndex,
+ int numSelected) {
+ final ListEvent event = new ListEvent();
+ event.actionType = actionType;
+ event.listType = listType;
+ event.count = count;
+ event.clickedIndex = clickedIndex;
+ event.numSelected = numSelected;
+
+ final Logger logger = getInstance();
+ if (logger != null) {
+ logger.logListEventImpl(event);
+ }
+ }
+
+ public abstract void logScreenViewImpl(int screenType, int previousScreenType);
+ public abstract void logSearchEventImpl(SearchState searchState);
+ public abstract void logListEventImpl(ListEvent event);
+}
diff --git a/src/com/android/contacts/common/logging/ScreenEvent.java b/src/com/android/contacts/common/logging/ScreenEvent.java
new file mode 100644
index 0000000..6af020b
--- /dev/null
+++ b/src/com/android/contacts/common/logging/ScreenEvent.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.logging;
+
+/**
+ * Stores constants identifying individual screens/dialogs/fragments in the application, and also
+ * provides a mapping of integer id -> screen name mappings for analytics purposes.
+ */
+public class ScreenEvent {
+
+ // Should match ContactsExtension.ScreenEvent.ScreenType values in
+ // http://cs/google3/logs/proto/wireless/android/contacts/contacts_extensions.proto
+ public static class ScreenType {
+ public static final int UNKNOWN = 0;
+ public static final int SEARCH = 1;
+ public static final int SEARCH_EXIT = 2;
+ public static final int FAVORITES = 3;
+ public static final int ALL_CONTACTS = 4;
+ public static final int QUICK_CONTACT = 5;
+ public static final int EDITOR = 6;
+ public static final int LIST_ACCOUNT = 7;
+ public static final int LIST_GROUP = 8;
+ public static final int ME_CONTACT = 9;
+ }
+}
diff --git a/src/com/android/contacts/common/logging/SearchState.java b/src/com/android/contacts/common/logging/SearchState.java
new file mode 100644
index 0000000..f4719e4
--- /dev/null
+++ b/src/com/android/contacts/common/logging/SearchState.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.logging;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.google.common.base.Objects;
+
+/**
+ * Describes the results of a user search for a particular contact.
+ */
+public final class SearchState implements Parcelable {
+
+ /** The length of the query string input by the user. */
+ public int queryLength;
+
+ /** The number of partitions (groups of results) presented to the user. */
+ public int numPartitions;
+
+ /** The total number of results (across all partitions) presented to the user. */
+ public int numResults;
+
+ /** The number of results presented to the user in the partition that was selected. */
+ public int numResultsInSelectedPartition = -1;
+
+ /** The zero-based index of the partition in which the clicked query result resides. */
+ public int selectedPartition = -1;
+
+ /** The index of the clicked query result within its partition. */
+ public int selectedIndexInPartition = -1;
+
+ /**
+ * The zero-based index of the clicked query result among all results displayed to the user
+ * (across partitions).
+ */
+ public int selectedIndex = -1;
+
+ public static final Creator<SearchState> CREATOR = new Creator<SearchState>() {
+ @Override
+ public SearchState createFromParcel(Parcel in) {
+ return new SearchState(in);
+ }
+
+ @Override
+ public SearchState[] newArray(int size) {
+ return new SearchState[size];
+ }
+ };
+
+ public SearchState() {
+ }
+
+ protected SearchState(Parcel source) {
+ readFromParcel(source);
+ }
+
+ @Override
+ public String toString() {
+ return Objects.toStringHelper(this)
+ .add("queryLength", queryLength)
+ .add("numPartitions", numPartitions)
+ .add("numResults", numResults)
+ .add("numResultsInSelectedPartition", numResultsInSelectedPartition)
+ .add("selectedPartition", selectedPartition)
+ .add("selectedIndexInPartition", selectedIndexInPartition)
+ .add("selectedIndex", selectedIndex)
+ .toString();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(queryLength);
+ dest.writeInt(numPartitions);
+ dest.writeInt(numResults);
+ dest.writeInt(numResultsInSelectedPartition);
+ dest.writeInt(selectedPartition);
+ dest.writeInt(selectedIndexInPartition);
+ dest.writeInt(selectedIndex);
+ }
+
+ private void readFromParcel(Parcel source) {
+ queryLength = source.readInt();
+ numPartitions = source.readInt();
+ numResults = source.readInt();
+ numResultsInSelectedPartition = source.readInt();
+ selectedPartition = source.readInt();
+ selectedIndexInPartition = source.readInt();
+ selectedIndex = source.readInt();
+ }
+}
diff --git a/src/com/android/contacts/common/model/AccountTypeManager.java b/src/com/android/contacts/common/model/AccountTypeManager.java
new file mode 100644
index 0000000..3ef3502
--- /dev/null
+++ b/src/com/android/contacts/common/model/AccountTypeManager.java
@@ -0,0 +1,846 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.AuthenticatorDescription;
+import android.accounts.OnAccountsUpdateListener;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SyncAdapterType;
+import android.content.SyncStatusObserver;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.os.SystemClock;
+import android.provider.ContactsContract;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.TimingLogger;
+
+import com.android.contacts.common.MoreContactUtils;
+import com.android.contacts.common.list.ContactListFilterController;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountTypeWithDataSet;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.model.account.ExchangeAccountType;
+import com.android.contacts.common.model.account.ExternalAccountType;
+import com.android.contacts.common.model.account.FallbackAccountType;
+import com.android.contacts.common.model.account.GoogleAccountType;
+import com.android.contacts.common.model.account.SamsungAccountType;
+import com.android.contacts.common.model.dataitem.DataKind;
+import com.android.contacts.common.testing.NeededForTesting;
+import com.android.contacts.common.util.Constants;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Objects;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Singleton holder for all parsed {@link AccountType} available on the
+ * system, typically filled through {@link PackageManager} queries.
+ */
+public abstract class AccountTypeManager {
+ static final String TAG = "AccountTypeManager";
+
+ private static final Object mInitializationLock = new Object();
+ private static AccountTypeManager mAccountTypeManager;
+
+ /**
+ * Requests the singleton instance of {@link AccountTypeManager} with data bound from
+ * the available authenticators. This method can safely be called from the UI thread.
+ */
+ public static AccountTypeManager getInstance(Context context) {
+ synchronized (mInitializationLock) {
+ if (mAccountTypeManager == null) {
+ context = context.getApplicationContext();
+ mAccountTypeManager = new AccountTypeManagerImpl(context);
+ }
+ }
+ return mAccountTypeManager;
+ }
+
+ /**
+ * Set the instance of account type manager. This is only for and should only be used by unit
+ * tests. While having this method is not ideal, it's simpler than the alternative of
+ * holding this as a service in the ContactsApplication context class.
+ *
+ * @param mockManager The mock AccountTypeManager.
+ */
+ @NeededForTesting
+ public static void setInstanceForTest(AccountTypeManager mockManager) {
+ synchronized (mInitializationLock) {
+ mAccountTypeManager = mockManager;
+ }
+ }
+
+ /**
+ * Returns the list of all accounts (if contactWritableOnly is false) or just the list of
+ * contact writable accounts (if contactWritableOnly is true).
+ */
+ // TODO: Consider splitting this into getContactWritableAccounts() and getAllAccounts()
+ public abstract List<AccountWithDataSet> getAccounts(boolean contactWritableOnly);
+
+ /**
+ * Sort accounts based on default account.
+ */
+ public abstract void sortAccounts(AccountWithDataSet defaultAccount);
+
+ /**
+ * Returns the list of accounts that are group writable.
+ */
+ public abstract List<AccountWithDataSet> getGroupWritableAccounts();
+
+ public abstract AccountType getAccountType(AccountTypeWithDataSet accountTypeWithDataSet);
+
+ public final AccountType getAccountType(String accountType, String dataSet) {
+ return getAccountType(AccountTypeWithDataSet.get(accountType, dataSet));
+ }
+
+ public final AccountType getAccountTypeForAccount(AccountWithDataSet account) {
+ if (account != null) {
+ return getAccountType(account.getAccountTypeWithDataSet());
+ }
+ return getAccountType(null, null);
+ }
+
+ /**
+ * @return Unmodifiable map from {@link AccountTypeWithDataSet}s to {@link AccountType}s
+ * which support the "invite" feature and have one or more account.
+ *
+ * This is a filtered down and more "usable" list compared to
+ * {@link #getAllInvitableAccountTypes}, where usable is defined as:
+ * (1) making sure that the app that contributed the account type is not disabled
+ * (in order to avoid presenting the user with an option that does nothing), and
+ * (2) that there is at least one raw contact with that account type in the database
+ * (assuming that the user probably doesn't use that account type).
+ *
+ * Warning: Don't use on the UI thread because this can scan the database.
+ */
+ public abstract Map<AccountTypeWithDataSet, AccountType> getUsableInvitableAccountTypes();
+
+ /**
+ * Find the best {@link DataKind} matching the requested
+ * {@link AccountType#accountType}, {@link AccountType#dataSet}, and {@link DataKind#mimeType}.
+ * If no direct match found, we try searching {@link FallbackAccountType}.
+ */
+ public DataKind getKindOrFallback(AccountType type, String mimeType) {
+ return type == null ? null : type.getKindForMimetype(mimeType);
+ }
+
+ /**
+ * Returns all registered {@link AccountType}s, including extension ones.
+ *
+ * @param contactWritableOnly if true, it only returns ones that support writing contacts.
+ */
+ public abstract List<AccountType> getAccountTypes(boolean contactWritableOnly);
+
+ /**
+ * @param contactWritableOnly if true, it only returns ones that support writing contacts.
+ * @return true when this instance contains the given account.
+ */
+ public boolean contains(AccountWithDataSet account, boolean contactWritableOnly) {
+ for (AccountWithDataSet account_2 : getAccounts(contactWritableOnly)) {
+ if (account.equals(account_2)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
+
+class AccountComparator implements Comparator<AccountWithDataSet> {
+ private AccountWithDataSet mDefaultAccount;
+
+ public AccountComparator(AccountWithDataSet defaultAccount) {
+ mDefaultAccount = defaultAccount;
+ }
+
+ @Override
+ public int compare(AccountWithDataSet a, AccountWithDataSet b) {
+ if (Objects.equal(a.name, b.name) && Objects.equal(a.type, b.type)
+ && Objects.equal(a.dataSet, b.dataSet)) {
+ return 0;
+ } else if (b.name == null || b.type == null) {
+ return -1;
+ } else if (a.name == null || a.type == null) {
+ return 1;
+ } else if (isWritableGoogleAccount(a) && a.equals(mDefaultAccount)) {
+ return -1;
+ } else if (isWritableGoogleAccount(b) && b.equals(mDefaultAccount)) {
+ return 1;
+ } else if (isWritableGoogleAccount(a) && !isWritableGoogleAccount(b)) {
+ return -1;
+ } else if (isWritableGoogleAccount(b) && !isWritableGoogleAccount(a)) {
+ return 1;
+ } else {
+ int diff = a.name.compareToIgnoreCase(b.name);
+ if (diff != 0) {
+ return diff;
+ }
+ diff = a.type.compareToIgnoreCase(b.type);
+ if (diff != 0) {
+ return diff;
+ }
+
+ // Accounts without data sets get sorted before those that have them.
+ if (a.dataSet != null) {
+ return b.dataSet == null ? 1 : a.dataSet.compareToIgnoreCase(b.dataSet);
+ } else {
+ return -1;
+ }
+ }
+ }
+
+ private static boolean isWritableGoogleAccount(AccountWithDataSet account) {
+ return GoogleAccountType.ACCOUNT_TYPE.equals(account.type) && account.dataSet == null;
+ }
+}
+
+class AccountTypeManagerImpl extends AccountTypeManager
+ implements OnAccountsUpdateListener, SyncStatusObserver {
+
+ private static final Map<AccountTypeWithDataSet, AccountType>
+ EMPTY_UNMODIFIABLE_ACCOUNT_TYPE_MAP =
+ Collections.unmodifiableMap(new HashMap<AccountTypeWithDataSet, AccountType>());
+
+ /**
+ * A sample contact URI used to test whether any activities will respond to an
+ * invitable intent with the given URI as the intent data. This doesn't need to be
+ * specific to a real contact because an app that intercepts the intent should probably do so
+ * for all types of contact URIs.
+ */
+ private static final Uri SAMPLE_CONTACT_URI = ContactsContract.Contacts.getLookupUri(
+ 1, "xxx");
+
+ private Context mContext;
+ private AccountManager mAccountManager;
+
+ private AccountType mFallbackAccountType;
+
+ private List<AccountWithDataSet> mAccounts = Lists.newArrayList();
+ private List<AccountWithDataSet> mContactWritableAccounts = Lists.newArrayList();
+ private List<AccountWithDataSet> mGroupWritableAccounts = Lists.newArrayList();
+ private Map<AccountTypeWithDataSet, AccountType> mAccountTypesWithDataSets = Maps.newHashMap();
+ private Map<AccountTypeWithDataSet, AccountType> mInvitableAccountTypes =
+ EMPTY_UNMODIFIABLE_ACCOUNT_TYPE_MAP;
+
+ private final InvitableAccountTypeCache mInvitableAccountTypeCache;
+
+ /**
+ * The boolean value is equal to true if the {@link InvitableAccountTypeCache} has been
+ * initialized. False otherwise.
+ */
+ private final AtomicBoolean mInvitablesCacheIsInitialized = new AtomicBoolean(false);
+
+ /**
+ * The boolean value is equal to true if the {@link FindInvitablesTask} is still executing.
+ * False otherwise.
+ */
+ private final AtomicBoolean mInvitablesTaskIsRunning = new AtomicBoolean(false);
+
+ private static final int MESSAGE_LOAD_DATA = 0;
+ private static final int MESSAGE_PROCESS_BROADCAST_INTENT = 1;
+
+ private HandlerThread mListenerThread;
+ private Handler mListenerHandler;
+
+ private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper());
+ private final Runnable mCheckFilterValidityRunnable = new Runnable () {
+ @Override
+ public void run() {
+ ContactListFilterController.getInstance(mContext).checkFilterValidity(true);
+ }
+ };
+
+ private BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Message msg = mListenerHandler.obtainMessage(MESSAGE_PROCESS_BROADCAST_INTENT, intent);
+ mListenerHandler.sendMessage(msg);
+ }
+
+ };
+
+ /* A latch that ensures that asynchronous initialization completes before data is used */
+ private volatile CountDownLatch mInitializationLatch = new CountDownLatch(1);
+
+ /**
+ * Internal constructor that only performs initial parsing.
+ */
+ public AccountTypeManagerImpl(Context context) {
+ mContext = context;
+ mFallbackAccountType = new FallbackAccountType(context);
+
+ mAccountManager = AccountManager.get(mContext);
+
+ mListenerThread = new HandlerThread("AccountChangeListener");
+ mListenerThread.start();
+ mListenerHandler = new Handler(mListenerThread.getLooper()) {
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case MESSAGE_LOAD_DATA:
+ loadAccountsInBackground();
+ break;
+ case MESSAGE_PROCESS_BROADCAST_INTENT:
+ processBroadcastIntent((Intent) msg.obj);
+ break;
+ }
+ }
+ };
+
+ mInvitableAccountTypeCache = new InvitableAccountTypeCache();
+
+ // Request updates when packages or accounts change
+ IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED);
+ filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
+ filter.addAction(Intent.ACTION_PACKAGE_CHANGED);
+ filter.addDataScheme("package");
+ mContext.registerReceiver(mBroadcastReceiver, filter);
+ IntentFilter sdFilter = new IntentFilter();
+ sdFilter.addAction(Intent.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE);
+ sdFilter.addAction(Intent.ACTION_EXTERNAL_APPLICATIONS_UNAVAILABLE);
+ mContext.registerReceiver(mBroadcastReceiver, sdFilter);
+
+ // Request updates when locale is changed so that the order of each field will
+ // be able to be changed on the locale change.
+ filter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED);
+ mContext.registerReceiver(mBroadcastReceiver, filter);
+
+ mAccountManager.addOnAccountsUpdatedListener(this, mListenerHandler, false);
+
+ ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this);
+
+ mListenerHandler.sendEmptyMessage(MESSAGE_LOAD_DATA);
+ }
+
+ @Override
+ public void onStatusChanged(int which) {
+ mListenerHandler.sendEmptyMessage(MESSAGE_LOAD_DATA);
+ }
+
+ public void processBroadcastIntent(Intent intent) {
+ mListenerHandler.sendEmptyMessage(MESSAGE_LOAD_DATA);
+ }
+
+ /* This notification will arrive on the background thread */
+ public void onAccountsUpdated(Account[] accounts) {
+ // Refresh to catch any changed accounts
+ loadAccountsInBackground();
+ }
+
+ /**
+ * Returns instantly if accounts and account types have already been loaded.
+ * Otherwise waits for the background thread to complete the loading.
+ */
+ void ensureAccountsLoaded() {
+ CountDownLatch latch = mInitializationLatch;
+ if (latch == null) {
+ return;
+ }
+ while (true) {
+ try {
+ latch.await();
+ return;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ /**
+ * Loads account list and corresponding account types (potentially with data sets). Always
+ * called on a background thread.
+ */
+ protected void loadAccountsInBackground() {
+ if (Log.isLoggable(Constants.PERFORMANCE_TAG, Log.DEBUG)) {
+ Log.d(Constants.PERFORMANCE_TAG, "AccountTypeManager.loadAccountsInBackground start");
+ }
+ TimingLogger timings = new TimingLogger(TAG, "loadAccountsInBackground");
+ final long startTime = SystemClock.currentThreadTimeMillis();
+ final long startTimeWall = SystemClock.elapsedRealtime();
+
+ // Account types, keyed off the account type and data set concatenation.
+ final Map<AccountTypeWithDataSet, AccountType> accountTypesByTypeAndDataSet =
+ Maps.newHashMap();
+
+ // The same AccountTypes, but keyed off {@link RawContacts#ACCOUNT_TYPE}. Since there can
+ // be multiple account types (with different data sets) for the same type of account, each
+ // type string may have multiple AccountType entries.
+ final Map<String, List<AccountType>> accountTypesByType = Maps.newHashMap();
+
+ final List<AccountWithDataSet> allAccounts = Lists.newArrayList();
+ final List<AccountWithDataSet> contactWritableAccounts = Lists.newArrayList();
+ final List<AccountWithDataSet> groupWritableAccounts = Lists.newArrayList();
+ final Set<String> extensionPackages = Sets.newHashSet();
+
+ final AccountManager am = mAccountManager;
+
+ final SyncAdapterType[] syncs = ContentResolver.getSyncAdapterTypes();
+ final AuthenticatorDescription[] auths = am.getAuthenticatorTypes();
+
+ // First process sync adapters to find any that provide contact data.
+ for (SyncAdapterType sync : syncs) {
+ if (!ContactsContract.AUTHORITY.equals(sync.authority)) {
+ // Skip sync adapters that don't provide contact data.
+ continue;
+ }
+
+ // Look for the formatting details provided by each sync
+ // adapter, using the authenticator to find general resources.
+ final String type = sync.accountType;
+ final AuthenticatorDescription auth = findAuthenticator(auths, type);
+ if (auth == null) {
+ Log.w(TAG, "No authenticator found for type=" + type + ", ignoring it.");
+ continue;
+ }
+
+ AccountType accountType;
+ if (GoogleAccountType.ACCOUNT_TYPE.equals(type)) {
+ accountType = new GoogleAccountType(mContext, auth.packageName);
+ } else if (ExchangeAccountType.isExchangeType(type)) {
+ accountType = new ExchangeAccountType(mContext, auth.packageName, type);
+ } else if (SamsungAccountType.isSamsungAccountType(mContext, type,
+ auth.packageName)) {
+ accountType = new SamsungAccountType(mContext, auth.packageName, type);
+ } else {
+ Log.d(TAG, "Registering external account type=" + type
+ + ", packageName=" + auth.packageName);
+ accountType = new ExternalAccountType(mContext, auth.packageName, false);
+ }
+ if (!accountType.isInitialized()) {
+ if (accountType.isEmbedded()) {
+ throw new IllegalStateException("Problem initializing embedded type "
+ + accountType.getClass().getCanonicalName());
+ } else {
+ // Skip external account types that couldn't be initialized.
+ continue;
+ }
+ }
+
+ accountType.accountType = auth.type;
+ accountType.titleRes = auth.labelId;
+ accountType.iconRes = auth.iconId;
+
+ addAccountType(accountType, accountTypesByTypeAndDataSet, accountTypesByType);
+
+ // Check to see if the account type knows of any other non-sync-adapter packages
+ // that may provide other data sets of contact data.
+ extensionPackages.addAll(accountType.getExtensionPackageNames());
+ }
+
+ // If any extension packages were specified, process them as well.
+ if (!extensionPackages.isEmpty()) {
+ Log.d(TAG, "Registering " + extensionPackages.size() + " extension packages");
+ for (String extensionPackage : extensionPackages) {
+ ExternalAccountType accountType =
+ new ExternalAccountType(mContext, extensionPackage, true);
+ if (!accountType.isInitialized()) {
+ // Skip external account types that couldn't be initialized.
+ continue;
+ }
+ if (!accountType.hasContactsMetadata()) {
+ Log.w(TAG, "Skipping extension package " + extensionPackage + " because"
+ + " it doesn't have the CONTACTS_STRUCTURE metadata");
+ continue;
+ }
+ if (TextUtils.isEmpty(accountType.accountType)) {
+ Log.w(TAG, "Skipping extension package " + extensionPackage + " because"
+ + " the CONTACTS_STRUCTURE metadata doesn't have the accountType"
+ + " attribute");
+ continue;
+ }
+ Log.d(TAG, "Registering extension package account type="
+ + accountType.accountType + ", dataSet=" + accountType.dataSet
+ + ", packageName=" + extensionPackage);
+
+ addAccountType(accountType, accountTypesByTypeAndDataSet, accountTypesByType);
+ }
+ }
+ timings.addSplit("Loaded account types");
+
+ // Map in accounts to associate the account names with each account type entry.
+ Account[] accounts = mAccountManager.getAccounts();
+ for (Account account : accounts) {
+ boolean syncable =
+ ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) > 0;
+
+ if (syncable) {
+ List<AccountType> accountTypes = accountTypesByType.get(account.type);
+ if (accountTypes != null) {
+ // Add an account-with-data-set entry for each account type that is
+ // authenticated by this account.
+ for (AccountType accountType : accountTypes) {
+ AccountWithDataSet accountWithDataSet = new AccountWithDataSet(
+ account.name, account.type, accountType.dataSet);
+ allAccounts.add(accountWithDataSet);
+ if (accountType.areContactsWritable()) {
+ contactWritableAccounts.add(accountWithDataSet);
+ }
+ if (accountType.isGroupMembershipEditable()) {
+ groupWritableAccounts.add(accountWithDataSet);
+ }
+ }
+ }
+ }
+ }
+
+ final AccountComparator accountComparator = new AccountComparator(null);
+ Collections.sort(allAccounts, accountComparator);
+ Collections.sort(contactWritableAccounts, accountComparator);
+ Collections.sort(groupWritableAccounts, accountComparator);
+
+ timings.addSplit("Loaded accounts");
+
+ synchronized (this) {
+ mAccountTypesWithDataSets = accountTypesByTypeAndDataSet;
+ mAccounts = allAccounts;
+ mContactWritableAccounts = contactWritableAccounts;
+ mGroupWritableAccounts = groupWritableAccounts;
+ mInvitableAccountTypes = findAllInvitableAccountTypes(
+ mContext, allAccounts, accountTypesByTypeAndDataSet);
+ }
+
+ timings.dumpToLog();
+ final long endTimeWall = SystemClock.elapsedRealtime();
+ final long endTime = SystemClock.currentThreadTimeMillis();
+
+ Log.i(TAG, "Loaded meta-data for " + mAccountTypesWithDataSets.size() + " account types, "
+ + mAccounts.size() + " accounts in " + (endTimeWall - startTimeWall) + "ms(wall) "
+ + (endTime - startTime) + "ms(cpu)");
+
+ if (mInitializationLatch != null) {
+ mInitializationLatch.countDown();
+ mInitializationLatch = null;
+ }
+ if (Log.isLoggable(Constants.PERFORMANCE_TAG, Log.DEBUG)) {
+ Log.d(Constants.PERFORMANCE_TAG, "AccountTypeManager.loadAccountsInBackground finish");
+ }
+
+ // Check filter validity since filter may become obsolete after account update. It must be
+ // done from UI thread.
+ mMainThreadHandler.post(mCheckFilterValidityRunnable);
+ }
+
+ // Bookkeeping method for tracking the known account types in the given maps.
+ private void addAccountType(AccountType accountType,
+ Map<AccountTypeWithDataSet, AccountType> accountTypesByTypeAndDataSet,
+ Map<String, List<AccountType>> accountTypesByType) {
+ accountTypesByTypeAndDataSet.put(accountType.getAccountTypeAndDataSet(), accountType);
+ List<AccountType> accountsForType = accountTypesByType.get(accountType.accountType);
+ if (accountsForType == null) {
+ accountsForType = Lists.newArrayList();
+ }
+ accountsForType.add(accountType);
+ accountTypesByType.put(accountType.accountType, accountsForType);
+ }
+
+ /**
+ * Find a specific {@link AuthenticatorDescription} in the provided list
+ * that matches the given account type.
+ */
+ protected static AuthenticatorDescription findAuthenticator(AuthenticatorDescription[] auths,
+ String accountType) {
+ for (AuthenticatorDescription auth : auths) {
+ if (accountType.equals(auth.type)) {
+ return auth;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return list of all known, contact writable {@link AccountWithDataSet}'s.
+ */
+ @Override
+ public List<AccountWithDataSet> getAccounts(boolean contactWritableOnly) {
+ ensureAccountsLoaded();
+ return contactWritableOnly ? mContactWritableAccounts : mAccounts;
+ }
+
+ /**
+ * Sort accounts based on default account.
+ */
+ @Override
+ public void sortAccounts(AccountWithDataSet defaultAccount) {
+ Collections.sort(mAccounts, new AccountComparator(defaultAccount));
+ Collections.sort(mContactWritableAccounts, new AccountComparator(defaultAccount));
+ Collections.sort(mGroupWritableAccounts, new AccountComparator(defaultAccount));
+ }
+
+ /**
+ * Return the list of all known, group writable {@link AccountWithDataSet}'s.
+ */
+ public List<AccountWithDataSet> getGroupWritableAccounts() {
+ ensureAccountsLoaded();
+ return mGroupWritableAccounts;
+ }
+
+ /**
+ * Find the best {@link DataKind} matching the requested
+ * {@link AccountType#accountType}, {@link AccountType#dataSet}, and {@link DataKind#mimeType}.
+ * If no direct match found, we try searching {@link FallbackAccountType}.
+ */
+ @Override
+ public DataKind getKindOrFallback(AccountType type, String mimeType) {
+ ensureAccountsLoaded();
+ DataKind kind = null;
+
+ // Try finding account type and kind matching request
+ if (type != null) {
+ kind = type.getKindForMimetype(mimeType);
+ }
+
+ if (kind == null) {
+ // Nothing found, so try fallback as last resort
+ kind = mFallbackAccountType.getKindForMimetype(mimeType);
+ }
+
+ if (kind == null) {
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "Unknown type=" + type + ", mime=" + mimeType);
+ }
+ }
+
+ return kind;
+ }
+
+ /**
+ * Return {@link AccountType} for the given account type and data set.
+ */
+ @Override
+ public AccountType getAccountType(AccountTypeWithDataSet accountTypeWithDataSet) {
+ ensureAccountsLoaded();
+ synchronized (this) {
+ AccountType type = mAccountTypesWithDataSets.get(accountTypeWithDataSet);
+ return type != null ? type : mFallbackAccountType;
+ }
+ }
+
+ /**
+ * @return Unmodifiable map from {@link AccountTypeWithDataSet}s to {@link AccountType}s
+ * which support the "invite" feature and have one or more account. This is an unfiltered
+ * list. See {@link #getUsableInvitableAccountTypes()}.
+ */
+ private Map<AccountTypeWithDataSet, AccountType> getAllInvitableAccountTypes() {
+ ensureAccountsLoaded();
+ return mInvitableAccountTypes;
+ }
+
+ @Override
+ public Map<AccountTypeWithDataSet, AccountType> getUsableInvitableAccountTypes() {
+ ensureAccountsLoaded();
+ // Since this method is not thread-safe, it's possible for multiple threads to encounter
+ // the situation where (1) the cache has not been initialized yet or
+ // (2) an async task to refresh the account type list in the cache has already been
+ // started. Hence we use {@link AtomicBoolean}s and return cached values immediately
+ // while we compute the actual result in the background. We use this approach instead of
+ // using "synchronized" because computing the account type list involves a DB read, and
+ // can potentially cause a deadlock situation if this method is called from code which
+ // holds the DB lock. The trade-off of potentially having an incorrect list of invitable
+ // account types for a short period of time seems more manageable than enforcing the
+ // context in which this method is called.
+
+ // Computing the list of usable invitable account types is done on the fly as requested.
+ // If this method has never been called before, then block until the list has been computed.
+ if (!mInvitablesCacheIsInitialized.get()) {
+ mInvitableAccountTypeCache.setCachedValue(findUsableInvitableAccountTypes(mContext));
+ mInvitablesCacheIsInitialized.set(true);
+ } else {
+ // Otherwise, there is a value in the cache. If the value has expired and
+ // an async task has not already been started by another thread, then kick off a new
+ // async task to compute the list.
+ if (mInvitableAccountTypeCache.isExpired() &&
+ mInvitablesTaskIsRunning.compareAndSet(false, true)) {
+ new FindInvitablesTask().execute();
+ }
+ }
+
+ return mInvitableAccountTypeCache.getCachedValue();
+ }
+
+ /**
+ * Return all {@link AccountType}s with at least one account which supports "invite", i.e.
+ * its {@link AccountType#getInviteContactActivityClassName()} is not empty.
+ */
+ @VisibleForTesting
+ static Map<AccountTypeWithDataSet, AccountType> findAllInvitableAccountTypes(Context context,
+ Collection<AccountWithDataSet> accounts,
+ Map<AccountTypeWithDataSet, AccountType> accountTypesByTypeAndDataSet) {
+ HashMap<AccountTypeWithDataSet, AccountType> result = Maps.newHashMap();
+ for (AccountWithDataSet account : accounts) {
+ AccountTypeWithDataSet accountTypeWithDataSet = account.getAccountTypeWithDataSet();
+ AccountType type = accountTypesByTypeAndDataSet.get(accountTypeWithDataSet);
+ if (type == null) continue; // just in case
+ if (result.containsKey(accountTypeWithDataSet)) continue;
+
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "Type " + accountTypeWithDataSet
+ + " inviteClass=" + type.getInviteContactActivityClassName());
+ }
+ if (!TextUtils.isEmpty(type.getInviteContactActivityClassName())) {
+ result.put(accountTypeWithDataSet, type);
+ }
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ /**
+ * Return all usable {@link AccountType}s that support the "invite" feature from the
+ * list of all potential invitable account types (retrieved from
+ * {@link #getAllInvitableAccountTypes}). A usable invitable account type means:
+ * (1) there is at least 1 raw contact in the database with that account type, and
+ * (2) the app contributing the account type is not disabled.
+ *
+ * Warning: Don't use on the UI thread because this can scan the database.
+ */
+ private Map<AccountTypeWithDataSet, AccountType> findUsableInvitableAccountTypes(
+ Context context) {
+ Map<AccountTypeWithDataSet, AccountType> allInvitables = getAllInvitableAccountTypes();
+ if (allInvitables.isEmpty()) {
+ return EMPTY_UNMODIFIABLE_ACCOUNT_TYPE_MAP;
+ }
+
+ final HashMap<AccountTypeWithDataSet, AccountType> result = Maps.newHashMap();
+ result.putAll(allInvitables);
+
+ final PackageManager packageManager = context.getPackageManager();
+ for (AccountTypeWithDataSet accountTypeWithDataSet : allInvitables.keySet()) {
+ AccountType accountType = allInvitables.get(accountTypeWithDataSet);
+
+ // Make sure that account types don't come from apps that are disabled.
+ Intent invitableIntent = MoreContactUtils.getInvitableIntent(accountType,
+ SAMPLE_CONTACT_URI);
+ if (invitableIntent == null) {
+ result.remove(accountTypeWithDataSet);
+ continue;
+ }
+ ResolveInfo resolveInfo = packageManager.resolveActivity(invitableIntent,
+ PackageManager.MATCH_DEFAULT_ONLY);
+ if (resolveInfo == null) {
+ // If we can't find an activity to start for this intent, then there's no point in
+ // showing this option to the user.
+ result.remove(accountTypeWithDataSet);
+ continue;
+ }
+
+ // Make sure that there is at least 1 raw contact with this account type. This check
+ // is non-trivial and should not be done on the UI thread.
+ if (!accountTypeWithDataSet.hasData(context)) {
+ result.remove(accountTypeWithDataSet);
+ }
+ }
+
+ return Collections.unmodifiableMap(result);
+ }
+
+ @Override
+ public List<AccountType> getAccountTypes(boolean contactWritableOnly) {
+ ensureAccountsLoaded();
+ final List<AccountType> accountTypes = Lists.newArrayList();
+ synchronized (this) {
+ for (AccountType type : mAccountTypesWithDataSets.values()) {
+ if (!contactWritableOnly || type.areContactsWritable()) {
+ accountTypes.add(type);
+ }
+ }
+ }
+ return accountTypes;
+ }
+
+ /**
+ * Background task to find all usable {@link AccountType}s that support the "invite" feature
+ * from the list of all potential invitable account types. Once the work is completed,
+ * the list of account types is stored in the {@link AccountTypeManager}'s
+ * {@link InvitableAccountTypeCache}.
+ */
+ private class FindInvitablesTask extends AsyncTask<Void, Void,
+ Map<AccountTypeWithDataSet, AccountType>> {
+
+ @Override
+ protected Map<AccountTypeWithDataSet, AccountType> doInBackground(Void... params) {
+ return findUsableInvitableAccountTypes(mContext);
+ }
+
+ @Override
+ protected void onPostExecute(Map<AccountTypeWithDataSet, AccountType> accountTypes) {
+ mInvitableAccountTypeCache.setCachedValue(accountTypes);
+ mInvitablesTaskIsRunning.set(false);
+ }
+ }
+
+ /**
+ * This cache holds a list of invitable {@link AccountTypeWithDataSet}s, in the form of a
+ * {@link Map<AccountTypeWithDataSet, AccountType>}. Note that the cached value is valid only
+ * for {@link #TIME_TO_LIVE} milliseconds.
+ */
+ private static final class InvitableAccountTypeCache {
+
+ /**
+ * The cached {@link #mInvitableAccountTypes} list expires after this number of milliseconds
+ * has elapsed.
+ */
+ private static final long TIME_TO_LIVE = 60000;
+
+ private Map<AccountTypeWithDataSet, AccountType> mInvitableAccountTypes;
+
+ private long mTimeLastSet;
+
+ /**
+ * Returns true if the data in this cache is stale and needs to be refreshed. Returns false
+ * otherwise.
+ */
+ public boolean isExpired() {
+ return SystemClock.elapsedRealtime() - mTimeLastSet > TIME_TO_LIVE;
+ }
+
+ /**
+ * Returns the cached value. Note that the caller is responsible for checking
+ * {@link #isExpired()} to ensure that the value is not stale.
+ */
+ public Map<AccountTypeWithDataSet, AccountType> getCachedValue() {
+ return mInvitableAccountTypes;
+ }
+
+ public void setCachedValue(Map<AccountTypeWithDataSet, AccountType> map) {
+ mInvitableAccountTypes = map;
+ mTimeLastSet = SystemClock.elapsedRealtime();
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/model/BuilderWrapper.java b/src/com/android/contacts/common/model/BuilderWrapper.java
new file mode 100644
index 0000000..325c3df
--- /dev/null
+++ b/src/com/android/contacts/common/model/BuilderWrapper.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.ContentProviderOperation.Builder;
+
+/**
+ * This class is created for the purpose of compatibility and make the type of
+ * ContentProviderOperation available on pre-M SDKs. Since ContentProviderOperation is
+ * usually created by Builder and we don’t have access to the type via Builder, so we need to
+ * create a wrapper class for Builder first and include type. Then we could use the builder and
+ * the type in this class to create a wrapper of ContentProviderOperation.
+ */
+public class BuilderWrapper {
+ private Builder mBuilder;
+ private int mType;
+
+ public BuilderWrapper(Builder builder, int type) {
+ mBuilder = builder;
+ mType = type;
+ }
+
+ public int getType() {
+ return mType;
+ }
+
+ public void setType(int mType) {
+ this.mType = mType;
+ }
+
+ public Builder getBuilder() {
+ return mBuilder;
+ }
+
+ public void setBuilder(Builder mBuilder) {
+ this.mBuilder = mBuilder;
+ }
+}
diff --git a/src/com/android/contacts/common/model/CPOWrapper.java b/src/com/android/contacts/common/model/CPOWrapper.java
new file mode 100644
index 0000000..4124df8
--- /dev/null
+++ b/src/com/android/contacts/common/model/CPOWrapper.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.ContentProviderOperation;
+
+/**
+ * This class is created for the purpose of compatibility and make the type of
+ * ContentProviderOperation available on pre-M SDKs.
+ */
+public class CPOWrapper {
+ private ContentProviderOperation mOperation;
+ private int mType;
+
+ public CPOWrapper(ContentProviderOperation builder, int type) {
+ mOperation = builder;
+ mType = type;
+ }
+
+ public int getType() {
+ return mType;
+ }
+
+ public void setType(int type) {
+ this.mType = type;
+ }
+
+ public ContentProviderOperation getOperation() {
+ return mOperation;
+ }
+
+ public void setOperation(ContentProviderOperation operation) {
+ this.mOperation = operation;
+ }
+}
diff --git a/src/com/android/contacts/common/model/Contact.java b/src/com/android/contacts/common/model/Contact.java
new file mode 100644
index 0000000..27bf13e
--- /dev/null
+++ b/src/com/android/contacts/common/model/Contact.java
@@ -0,0 +1,496 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Directory;
+import android.provider.ContactsContract.DisplayNameSources;
+
+import com.android.contacts.common.GroupMetaData;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.util.DataStatus;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import java.util.ArrayList;
+import java.util.Collections;
+
+/**
+ * A Contact represents a single person or logical entity as perceived by the user. The information
+ * about a contact can come from multiple data sources, which are each represented by a RawContact
+ * object. Thus, a Contact is associated with a collection of RawContact objects.
+ *
+ * The aggregation of raw contacts into a single contact is performed automatically, and it is
+ * also possible for users to manually split and join raw contacts into various contacts.
+ *
+ * Only the {@link ContactLoader} class can create a Contact object with various flags to allow
+ * partial loading of contact data. Thus, an instance of this class should be treated as
+ * a read-only object.
+ */
+public class Contact {
+ private enum Status {
+ /** Contact is successfully loaded */
+ LOADED,
+ /** There was an error loading the contact */
+ ERROR,
+ /** Contact is not found */
+ NOT_FOUND,
+ }
+
+ private final Uri mRequestedUri;
+ private final Uri mLookupUri;
+ private final Uri mUri;
+ private final long mDirectoryId;
+ private final String mLookupKey;
+ private final long mId;
+ private final long mNameRawContactId;
+ private final int mDisplayNameSource;
+ private final long mPhotoId;
+ private final String mPhotoUri;
+ private final String mDisplayName;
+ private final String mAltDisplayName;
+ private final String mPhoneticName;
+ private final boolean mStarred;
+ private final Integer mPresence;
+ private ImmutableList<RawContact> mRawContacts;
+ private ImmutableMap<Long,DataStatus> mStatuses;
+ private ImmutableList<AccountType> mInvitableAccountTypes;
+
+ private String mDirectoryDisplayName;
+ private String mDirectoryType;
+ private String mDirectoryAccountType;
+ private String mDirectoryAccountName;
+ private int mDirectoryExportSupport;
+
+ private ImmutableList<GroupMetaData> mGroups;
+
+ private byte[] mPhotoBinaryData;
+ /**
+ * Small version of the contact photo loaded from a blob instead of from a file. If a large
+ * contact photo is not available yet, then this has the same value as mPhotoBinaryData.
+ */
+ private byte[] mThumbnailPhotoBinaryData;
+ private final boolean mSendToVoicemail;
+ private final String mCustomRingtone;
+ private final boolean mIsUserProfile;
+
+ private final Contact.Status mStatus;
+ private final Exception mException;
+
+ /**
+ * Constructor for special results, namely "no contact found" and "error".
+ */
+ private Contact(Uri requestedUri, Contact.Status status, Exception exception) {
+ if (status == Status.ERROR && exception == null) {
+ throw new IllegalArgumentException("ERROR result must have exception");
+ }
+ mStatus = status;
+ mException = exception;
+ mRequestedUri = requestedUri;
+ mLookupUri = null;
+ mUri = null;
+ mDirectoryId = -1;
+ mLookupKey = null;
+ mId = -1;
+ mRawContacts = null;
+ mStatuses = null;
+ mNameRawContactId = -1;
+ mDisplayNameSource = DisplayNameSources.UNDEFINED;
+ mPhotoId = -1;
+ mPhotoUri = null;
+ mDisplayName = null;
+ mAltDisplayName = null;
+ mPhoneticName = null;
+ mStarred = false;
+ mPresence = null;
+ mInvitableAccountTypes = null;
+ mSendToVoicemail = false;
+ mCustomRingtone = null;
+ mIsUserProfile = false;
+ }
+
+ public static Contact forError(Uri requestedUri, Exception exception) {
+ return new Contact(requestedUri, Status.ERROR, exception);
+ }
+
+ public static Contact forNotFound(Uri requestedUri) {
+ return new Contact(requestedUri, Status.NOT_FOUND, null);
+ }
+
+ /**
+ * Constructor to call when contact was found
+ */
+ public Contact(Uri requestedUri, Uri uri, Uri lookupUri, long directoryId, String lookupKey,
+ long id, long nameRawContactId, int displayNameSource, long photoId,
+ String photoUri, String displayName, String altDisplayName, String phoneticName,
+ boolean starred, Integer presence, boolean sendToVoicemail, String customRingtone,
+ boolean isUserProfile) {
+ mStatus = Status.LOADED;
+ mException = null;
+ mRequestedUri = requestedUri;
+ mLookupUri = lookupUri;
+ mUri = uri;
+ mDirectoryId = directoryId;
+ mLookupKey = lookupKey;
+ mId = id;
+ mRawContacts = null;
+ mStatuses = null;
+ mNameRawContactId = nameRawContactId;
+ mDisplayNameSource = displayNameSource;
+ mPhotoId = photoId;
+ mPhotoUri = photoUri;
+ mDisplayName = displayName;
+ mAltDisplayName = altDisplayName;
+ mPhoneticName = phoneticName;
+ mStarred = starred;
+ mPresence = presence;
+ mInvitableAccountTypes = null;
+ mSendToVoicemail = sendToVoicemail;
+ mCustomRingtone = customRingtone;
+ mIsUserProfile = isUserProfile;
+ }
+
+ public Contact(Uri requestedUri, Contact from) {
+ mRequestedUri = requestedUri;
+
+ mStatus = from.mStatus;
+ mException = from.mException;
+ mLookupUri = from.mLookupUri;
+ mUri = from.mUri;
+ mDirectoryId = from.mDirectoryId;
+ mLookupKey = from.mLookupKey;
+ mId = from.mId;
+ mNameRawContactId = from.mNameRawContactId;
+ mDisplayNameSource = from.mDisplayNameSource;
+ mPhotoId = from.mPhotoId;
+ mPhotoUri = from.mPhotoUri;
+ mDisplayName = from.mDisplayName;
+ mAltDisplayName = from.mAltDisplayName;
+ mPhoneticName = from.mPhoneticName;
+ mStarred = from.mStarred;
+ mPresence = from.mPresence;
+ mRawContacts = from.mRawContacts;
+ mStatuses = from.mStatuses;
+ mInvitableAccountTypes = from.mInvitableAccountTypes;
+
+ mDirectoryDisplayName = from.mDirectoryDisplayName;
+ mDirectoryType = from.mDirectoryType;
+ mDirectoryAccountType = from.mDirectoryAccountType;
+ mDirectoryAccountName = from.mDirectoryAccountName;
+ mDirectoryExportSupport = from.mDirectoryExportSupport;
+
+ mGroups = from.mGroups;
+
+ mPhotoBinaryData = from.mPhotoBinaryData;
+ mSendToVoicemail = from.mSendToVoicemail;
+ mCustomRingtone = from.mCustomRingtone;
+ mIsUserProfile = from.mIsUserProfile;
+ }
+
+ /**
+ * @param exportSupport See {@link Directory#EXPORT_SUPPORT}.
+ */
+ public void setDirectoryMetaData(String displayName, String directoryType,
+ String accountType, String accountName, int exportSupport) {
+ mDirectoryDisplayName = displayName;
+ mDirectoryType = directoryType;
+ mDirectoryAccountType = accountType;
+ mDirectoryAccountName = accountName;
+ mDirectoryExportSupport = exportSupport;
+ }
+
+ /* package */ void setPhotoBinaryData(byte[] photoBinaryData) {
+ mPhotoBinaryData = photoBinaryData;
+ }
+
+ /* package */ void setThumbnailPhotoBinaryData(byte[] photoBinaryData) {
+ mThumbnailPhotoBinaryData = photoBinaryData;
+ }
+
+ /**
+ * Returns the URI for the contact that contains both the lookup key and the ID. This is
+ * the best URI to reference a contact.
+ * For directory contacts, this is the same a the URI as returned by {@link #getUri()}
+ */
+ public Uri getLookupUri() {
+ return mLookupUri;
+ }
+
+ public String getLookupKey() {
+ return mLookupKey;
+ }
+
+ /**
+ * Returns the contact Uri that was passed to the provider to make the query. This is
+ * the same as the requested Uri, unless the requested Uri doesn't specify a Contact:
+ * If it either references a Raw-Contact or a Person (a pre-Eclair style Uri), this Uri will
+ * always reference the full aggregate contact.
+ */
+ public Uri getUri() {
+ return mUri;
+ }
+
+ /**
+ * Returns the URI for which this {@link ContactLoader) was initially requested.
+ */
+ public Uri getRequestedUri() {
+ return mRequestedUri;
+ }
+
+ /**
+ * Instantiate a new RawContactDeltaList for this contact.
+ */
+ public RawContactDeltaList createRawContactDeltaList() {
+ return RawContactDeltaList.fromIterator(getRawContacts().iterator());
+ }
+
+ /**
+ * Returns the contact ID.
+ */
+ @VisibleForTesting
+ public long getId() {
+ return mId;
+ }
+
+ /**
+ * @return true when an exception happened during loading, in which case
+ * {@link #getException} returns the actual exception object.
+ * Note {@link #isNotFound()} and {@link #isError()} are mutually exclusive; If
+ * {@link #isError()} is {@code true}, {@link #isNotFound()} is always {@code false},
+ * and vice versa.
+ */
+ public boolean isError() {
+ return mStatus == Status.ERROR;
+ }
+
+ public Exception getException() {
+ return mException;
+ }
+
+ /**
+ * @return true when the specified contact is not found.
+ * Note {@link #isNotFound()} and {@link #isError()} are mutually exclusive; If
+ * {@link #isError()} is {@code true}, {@link #isNotFound()} is always {@code false},
+ * and vice versa.
+ */
+ public boolean isNotFound() {
+ return mStatus == Status.NOT_FOUND;
+ }
+
+ /**
+ * @return true if the specified contact is successfully loaded.
+ * i.e. neither {@link #isError()} nor {@link #isNotFound()}.
+ */
+ public boolean isLoaded() {
+ return mStatus == Status.LOADED;
+ }
+
+ public long getNameRawContactId() {
+ return mNameRawContactId;
+ }
+
+ public int getDisplayNameSource() {
+ return mDisplayNameSource;
+ }
+
+ /**
+ * Used by various classes to determine whether or not this contact should be displayed as
+ * a business rather than a person.
+ */
+ public boolean isDisplayNameFromOrganization() {
+ return DisplayNameSources.ORGANIZATION == mDisplayNameSource;
+ }
+
+ public long getPhotoId() {
+ return mPhotoId;
+ }
+
+ public String getPhotoUri() {
+ return mPhotoUri;
+ }
+
+ public String getDisplayName() {
+ return mDisplayName;
+ }
+
+ public String getAltDisplayName() {
+ return mAltDisplayName;
+ }
+
+ public String getPhoneticName() {
+ return mPhoneticName;
+ }
+
+ public boolean getStarred() {
+ return mStarred;
+ }
+
+ public Integer getPresence() {
+ return mPresence;
+ }
+
+ /**
+ * This can return non-null invitable account types only if the {@link ContactLoader} was
+ * configured to load invitable account types in its constructor.
+ * @return
+ */
+ public ImmutableList<AccountType> getInvitableAccountTypes() {
+ return mInvitableAccountTypes;
+ }
+
+ public ImmutableList<RawContact> getRawContacts() {
+ return mRawContacts;
+ }
+
+ public ImmutableMap<Long, DataStatus> getStatuses() {
+ return mStatuses;
+ }
+
+ public long getDirectoryId() {
+ return mDirectoryId;
+ }
+
+ public boolean isDirectoryEntry() {
+ return mDirectoryId != -1 && mDirectoryId != Directory.DEFAULT
+ && mDirectoryId != Directory.LOCAL_INVISIBLE;
+ }
+
+ /**
+ * @return true if this is a contact (not group, etc.) with at least one
+ * writable raw-contact, and false otherwise.
+ */
+ public boolean isWritableContact(final Context context) {
+ return getFirstWritableRawContactId(context) != -1;
+ }
+
+ /**
+ * Return the ID of the first raw-contact in the contact data that belongs to a
+ * contact-writable account, or -1 if no such entity exists.
+ */
+ public long getFirstWritableRawContactId(final Context context) {
+ // Directory entries are non-writable
+ if (isDirectoryEntry()) return -1;
+
+ // Iterate through raw-contacts; if we find a writable on, return its ID.
+ for (RawContact rawContact : getRawContacts()) {
+ AccountType accountType = rawContact.getAccountType(context);
+ if (accountType != null && accountType.areContactsWritable()) {
+ return rawContact.getId();
+ }
+ }
+ // No writable raw-contact was found.
+ return -1;
+ }
+
+ public int getDirectoryExportSupport() {
+ return mDirectoryExportSupport;
+ }
+
+ public String getDirectoryDisplayName() {
+ return mDirectoryDisplayName;
+ }
+
+ public String getDirectoryType() {
+ return mDirectoryType;
+ }
+
+ public String getDirectoryAccountType() {
+ return mDirectoryAccountType;
+ }
+
+ public String getDirectoryAccountName() {
+ return mDirectoryAccountName;
+ }
+
+ public byte[] getPhotoBinaryData() {
+ return mPhotoBinaryData;
+ }
+
+ public byte[] getThumbnailPhotoBinaryData() {
+ return mThumbnailPhotoBinaryData;
+ }
+
+ public ArrayList<ContentValues> getContentValues() {
+ if (mRawContacts.size() != 1) {
+ throw new IllegalStateException(
+ "Cannot extract content values from an aggregated contact");
+ }
+
+ RawContact rawContact = mRawContacts.get(0);
+ ArrayList<ContentValues> result = rawContact.getContentValues();
+
+ // If the photo was loaded using the URI, create an entry for the photo
+ // binary data.
+ if (mPhotoId == 0 && mPhotoBinaryData != null) {
+ ContentValues photo = new ContentValues();
+ photo.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE);
+ photo.put(Photo.PHOTO, mPhotoBinaryData);
+ result.add(photo);
+ }
+
+ return result;
+ }
+
+ /**
+ * This can return non-null group meta-data only if the {@link ContactLoader} was configured to
+ * load group metadata in its constructor.
+ * @return
+ */
+ public ImmutableList<GroupMetaData> getGroupMetaData() {
+ return mGroups;
+ }
+
+ public boolean isSendToVoicemail() {
+ return mSendToVoicemail;
+ }
+
+ public String getCustomRingtone() {
+ return mCustomRingtone;
+ }
+
+ public boolean isUserProfile() {
+ return mIsUserProfile;
+ }
+
+ @Override
+ public String toString() {
+ return "{requested=" + mRequestedUri + ",lookupkey=" + mLookupKey +
+ ",uri=" + mUri + ",status=" + mStatus + "}";
+ }
+
+ /* package */ void setRawContacts(ImmutableList<RawContact> rawContacts) {
+ mRawContacts = rawContacts;
+ }
+
+ /* package */ void setStatuses(ImmutableMap<Long, DataStatus> statuses) {
+ mStatuses = statuses;
+ }
+
+ /* package */ void setInvitableAccountTypes(ImmutableList<AccountType> accountTypes) {
+ mInvitableAccountTypes = accountTypes;
+ }
+
+ /* package */ void setGroupMetaData(ImmutableList<GroupMetaData> groups) {
+ mGroups = groups;
+ }
+}
diff --git a/src/com/android/contacts/common/model/ContactLoader.java b/src/com/android/contacts/common/model/ContactLoader.java
new file mode 100644
index 0000000..f721379
--- /dev/null
+++ b/src/com/android/contacts/common/model/ContactLoader.java
@@ -0,0 +1,1046 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.AsyncTaskLoader;
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.AssetFileDescriptor;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Directory;
+import android.provider.ContactsContract.Groups;
+import android.provider.ContactsContract.RawContacts;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.contacts.common.GeoUtil;
+import com.android.contacts.common.GroupMetaData;
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountTypeWithDataSet;
+import com.android.contacts.common.util.Constants;
+import com.android.contacts.common.util.ContactLoaderUtils;
+import com.android.contacts.common.util.DataStatus;
+import com.android.contacts.common.util.UriUtils;
+import com.android.contacts.common.model.dataitem.DataItem;
+import com.android.contacts.common.model.dataitem.PhoneDataItem;
+import com.android.contacts.common.model.dataitem.PhotoDataItem;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Loads a single Contact and all it constituent RawContacts.
+ */
+public class ContactLoader extends AsyncTaskLoader<Contact> {
+
+ private static final String TAG = ContactLoader.class.getSimpleName();
+
+ private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+
+ /** A short-lived cache that can be set by {@link #cacheResult()} */
+ private static Contact sCachedResult = null;
+
+ private final Uri mRequestedUri;
+ private Uri mLookupUri;
+ private boolean mLoadGroupMetaData;
+ private boolean mLoadInvitableAccountTypes;
+ private boolean mPostViewNotification;
+ private boolean mComputeFormattedPhoneNumber;
+ private Contact mContact;
+ private ForceLoadContentObserver mObserver;
+ private final Set<Long> mNotifiedRawContactIds = Sets.newHashSet();
+
+ public ContactLoader(Context context, Uri lookupUri, boolean postViewNotification) {
+ this(context, lookupUri, false, false, postViewNotification, false);
+ }
+
+ public ContactLoader(Context context, Uri lookupUri, boolean loadGroupMetaData,
+ boolean loadInvitableAccountTypes,
+ boolean postViewNotification, boolean computeFormattedPhoneNumber) {
+ super(context);
+ mLookupUri = lookupUri;
+ mRequestedUri = lookupUri;
+ mLoadGroupMetaData = loadGroupMetaData;
+ mLoadInvitableAccountTypes = loadInvitableAccountTypes;
+ mPostViewNotification = postViewNotification;
+ mComputeFormattedPhoneNumber = computeFormattedPhoneNumber;
+ }
+
+ /**
+ * Projection used for the query that loads all data for the entire contact (except for
+ * social stream items).
+ */
+ private static class ContactQuery {
+ static final String[] COLUMNS_INTERNAL = new String[] {
+ Contacts.NAME_RAW_CONTACT_ID,
+ Contacts.DISPLAY_NAME_SOURCE,
+ Contacts.LOOKUP_KEY,
+ Contacts.DISPLAY_NAME,
+ Contacts.DISPLAY_NAME_ALTERNATIVE,
+ Contacts.PHONETIC_NAME,
+ Contacts.PHOTO_ID,
+ Contacts.STARRED,
+ Contacts.CONTACT_PRESENCE,
+ Contacts.CONTACT_STATUS,
+ Contacts.CONTACT_STATUS_TIMESTAMP,
+ Contacts.CONTACT_STATUS_RES_PACKAGE,
+ Contacts.CONTACT_STATUS_LABEL,
+ Contacts.Entity.CONTACT_ID,
+ Contacts.Entity.RAW_CONTACT_ID,
+
+ RawContacts.ACCOUNT_NAME,
+ RawContacts.ACCOUNT_TYPE,
+ RawContacts.DATA_SET,
+ RawContacts.DIRTY,
+ RawContacts.VERSION,
+ RawContacts.SOURCE_ID,
+ RawContacts.SYNC1,
+ RawContacts.SYNC2,
+ RawContacts.SYNC3,
+ RawContacts.SYNC4,
+ RawContacts.DELETED,
+
+ Contacts.Entity.DATA_ID,
+ Data.DATA1,
+ Data.DATA2,
+ Data.DATA3,
+ Data.DATA4,
+ Data.DATA5,
+ Data.DATA6,
+ Data.DATA7,
+ Data.DATA8,
+ Data.DATA9,
+ Data.DATA10,
+ Data.DATA11,
+ Data.DATA12,
+ Data.DATA13,
+ Data.DATA14,
+ Data.DATA15,
+ Data.SYNC1,
+ Data.SYNC2,
+ Data.SYNC3,
+ Data.SYNC4,
+ Data.DATA_VERSION,
+ Data.IS_PRIMARY,
+ Data.IS_SUPER_PRIMARY,
+ Data.MIMETYPE,
+
+ GroupMembership.GROUP_SOURCE_ID,
+
+ Data.PRESENCE,
+ Data.CHAT_CAPABILITY,
+ Data.STATUS,
+ Data.STATUS_RES_PACKAGE,
+ Data.STATUS_ICON,
+ Data.STATUS_LABEL,
+ Data.STATUS_TIMESTAMP,
+
+ Contacts.PHOTO_URI,
+ Contacts.SEND_TO_VOICEMAIL,
+ Contacts.CUSTOM_RINGTONE,
+ Contacts.IS_USER_PROFILE,
+
+ Data.TIMES_USED,
+ Data.LAST_TIME_USED
+ };
+
+ static final String[] COLUMNS;
+
+ static {
+ List<String> projectionList = Lists.newArrayList(COLUMNS_INTERNAL);
+ if (CompatUtils.isMarshmallowCompatible()) {
+ projectionList.add(Data.CARRIER_PRESENCE);
+ }
+ COLUMNS = projectionList.toArray(new String[projectionList.size()]);
+ }
+
+ public static final int NAME_RAW_CONTACT_ID = 0;
+ public static final int DISPLAY_NAME_SOURCE = 1;
+ public static final int LOOKUP_KEY = 2;
+ public static final int DISPLAY_NAME = 3;
+ public static final int ALT_DISPLAY_NAME = 4;
+ public static final int PHONETIC_NAME = 5;
+ public static final int PHOTO_ID = 6;
+ public static final int STARRED = 7;
+ public static final int CONTACT_PRESENCE = 8;
+ public static final int CONTACT_STATUS = 9;
+ public static final int CONTACT_STATUS_TIMESTAMP = 10;
+ public static final int CONTACT_STATUS_RES_PACKAGE = 11;
+ public static final int CONTACT_STATUS_LABEL = 12;
+ public static final int CONTACT_ID = 13;
+ public static final int RAW_CONTACT_ID = 14;
+
+ public static final int ACCOUNT_NAME = 15;
+ public static final int ACCOUNT_TYPE = 16;
+ public static final int DATA_SET = 17;
+ public static final int DIRTY = 18;
+ public static final int VERSION = 19;
+ public static final int SOURCE_ID = 20;
+ public static final int SYNC1 = 21;
+ public static final int SYNC2 = 22;
+ public static final int SYNC3 = 23;
+ public static final int SYNC4 = 24;
+ public static final int DELETED = 25;
+
+ public static final int DATA_ID = 26;
+ public static final int DATA1 = 27;
+ public static final int DATA2 = 28;
+ public static final int DATA3 = 29;
+ public static final int DATA4 = 30;
+ public static final int DATA5 = 31;
+ public static final int DATA6 = 32;
+ public static final int DATA7 = 33;
+ public static final int DATA8 = 34;
+ public static final int DATA9 = 35;
+ public static final int DATA10 = 36;
+ public static final int DATA11 = 37;
+ public static final int DATA12 = 38;
+ public static final int DATA13 = 39;
+ public static final int DATA14 = 40;
+ public static final int DATA15 = 41;
+ public static final int DATA_SYNC1 = 42;
+ public static final int DATA_SYNC2 = 43;
+ public static final int DATA_SYNC3 = 44;
+ public static final int DATA_SYNC4 = 45;
+ public static final int DATA_VERSION = 46;
+ public static final int IS_PRIMARY = 47;
+ public static final int IS_SUPERPRIMARY = 48;
+ public static final int MIMETYPE = 49;
+
+ public static final int GROUP_SOURCE_ID = 50;
+
+ public static final int PRESENCE = 51;
+ public static final int CHAT_CAPABILITY = 52;
+ public static final int STATUS = 53;
+ public static final int STATUS_RES_PACKAGE = 54;
+ public static final int STATUS_ICON = 55;
+ public static final int STATUS_LABEL = 56;
+ public static final int STATUS_TIMESTAMP = 57;
+
+ public static final int PHOTO_URI = 58;
+ public static final int SEND_TO_VOICEMAIL = 59;
+ public static final int CUSTOM_RINGTONE = 60;
+ public static final int IS_USER_PROFILE = 61;
+
+ public static final int TIMES_USED = 62;
+ public static final int LAST_TIME_USED = 63;
+ public static final int CARRIER_PRESENCE = 64;
+ }
+
+ /**
+ * Projection used for the query that loads all data for the entire contact.
+ */
+ private static class DirectoryQuery {
+ static final String[] COLUMNS = new String[] {
+ Directory.DISPLAY_NAME,
+ Directory.PACKAGE_NAME,
+ Directory.TYPE_RESOURCE_ID,
+ Directory.ACCOUNT_TYPE,
+ Directory.ACCOUNT_NAME,
+ Directory.EXPORT_SUPPORT,
+ };
+
+ public static final int DISPLAY_NAME = 0;
+ public static final int PACKAGE_NAME = 1;
+ public static final int TYPE_RESOURCE_ID = 2;
+ public static final int ACCOUNT_TYPE = 3;
+ public static final int ACCOUNT_NAME = 4;
+ public static final int EXPORT_SUPPORT = 5;
+ }
+
+ private static class GroupQuery {
+ static final String[] COLUMNS = new String[] {
+ Groups.ACCOUNT_NAME,
+ Groups.ACCOUNT_TYPE,
+ Groups.DATA_SET,
+ Groups._ID,
+ Groups.TITLE,
+ Groups.AUTO_ADD,
+ Groups.FAVORITES,
+ };
+
+ public static final int ACCOUNT_NAME = 0;
+ public static final int ACCOUNT_TYPE = 1;
+ public static final int DATA_SET = 2;
+ public static final int ID = 3;
+ public static final int TITLE = 4;
+ public static final int AUTO_ADD = 5;
+ public static final int FAVORITES = 6;
+ }
+
+ public void setLookupUri(Uri lookupUri) {
+ mLookupUri = lookupUri;
+ }
+
+ @Override
+ public Contact loadInBackground() {
+ Log.e(TAG, "loadInBackground=" + mLookupUri);
+ try {
+ final ContentResolver resolver = getContext().getContentResolver();
+ final Uri uriCurrentFormat = ContactLoaderUtils.ensureIsContactUri(
+ resolver, mLookupUri);
+ final Contact cachedResult = sCachedResult;
+ sCachedResult = null;
+ // Is this the same Uri as what we had before already? In that case, reuse that result
+ final Contact result;
+ final boolean resultIsCached;
+ if (cachedResult != null &&
+ UriUtils.areEqual(cachedResult.getLookupUri(), mLookupUri)) {
+ // We are using a cached result from earlier. Below, we should make sure
+ // we are not doing any more network or disc accesses
+ result = new Contact(mRequestedUri, cachedResult);
+ resultIsCached = true;
+ } else {
+ if (uriCurrentFormat.getLastPathSegment().equals(Constants.LOOKUP_URI_ENCODED)) {
+ result = loadEncodedContactEntity(uriCurrentFormat, mLookupUri);
+ } else {
+ result = loadContactEntity(resolver, uriCurrentFormat);
+ }
+ resultIsCached = false;
+ }
+ if (result.isLoaded()) {
+ if (result.isDirectoryEntry()) {
+ if (!resultIsCached) {
+ loadDirectoryMetaData(result);
+ }
+ } else if (mLoadGroupMetaData) {
+ if (result.getGroupMetaData() == null) {
+ loadGroupMetaData(result);
+ }
+ }
+ if (mComputeFormattedPhoneNumber) {
+ computeFormattedPhoneNumbers(result);
+ }
+ if (!resultIsCached) loadPhotoBinaryData(result);
+
+ // Note ME profile should never have "Add connection"
+ if (mLoadInvitableAccountTypes && result.getInvitableAccountTypes() == null) {
+ loadInvitableAccountTypes(result);
+ }
+ }
+ return result;
+ } catch (Exception e) {
+ Log.e(TAG, "Error loading the contact: " + mLookupUri, e);
+ return Contact.forError(mRequestedUri, e);
+ }
+ }
+
+ /**
+ * Parses a {@link Contact} stored as a JSON string in a lookup URI.
+ *
+ * @param lookupUri The contact information to parse .
+ * @return The parsed {@code Contact} information.
+ * @throws JSONException
+ */
+ public static Contact parseEncodedContactEntity(Uri lookupUri) {
+ try {
+ return loadEncodedContactEntity(lookupUri, lookupUri);
+ } catch (JSONException je) {
+ return null;
+ }
+ }
+
+ private static Contact loadEncodedContactEntity(Uri uri, Uri lookupUri) throws JSONException {
+ final String jsonString = uri.getEncodedFragment();
+ final JSONObject json = new JSONObject(jsonString);
+
+ final long directoryId =
+ Long.valueOf(uri.getQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY));
+
+ final String displayName = json.optString(Contacts.DISPLAY_NAME);
+ final String altDisplayName = json.optString(
+ Contacts.DISPLAY_NAME_ALTERNATIVE, displayName);
+ final int displayNameSource = json.getInt(Contacts.DISPLAY_NAME_SOURCE);
+ final String photoUri = json.optString(Contacts.PHOTO_URI, null);
+ final Contact contact = new Contact(
+ uri, uri,
+ lookupUri,
+ directoryId,
+ null /* lookupKey */,
+ -1 /* id */,
+ -1 /* nameRawContactId */,
+ displayNameSource,
+ 0 /* photoId */,
+ photoUri,
+ displayName,
+ altDisplayName,
+ null /* phoneticName */,
+ false /* starred */,
+ null /* presence */,
+ false /* sendToVoicemail */,
+ null /* customRingtone */,
+ false /* isUserProfile */);
+
+ contact.setStatuses(new ImmutableMap.Builder<Long, DataStatus>().build());
+
+ final String accountName = json.optString(RawContacts.ACCOUNT_NAME, null);
+ final String directoryName = uri.getQueryParameter(Directory.DISPLAY_NAME);
+ if (accountName != null) {
+ final String accountType = json.getString(RawContacts.ACCOUNT_TYPE);
+ contact.setDirectoryMetaData(directoryName, null, accountName, accountType,
+ json.optInt(Directory.EXPORT_SUPPORT,
+ Directory.EXPORT_SUPPORT_SAME_ACCOUNT_ONLY));
+ } else {
+ contact.setDirectoryMetaData(directoryName, null, null, null,
+ json.optInt(Directory.EXPORT_SUPPORT, Directory.EXPORT_SUPPORT_ANY_ACCOUNT));
+ }
+
+ final ContentValues values = new ContentValues();
+ values.put(Data._ID, -1);
+ values.put(Data.CONTACT_ID, -1);
+ final RawContact rawContact = new RawContact(values);
+
+ final JSONObject items = json.getJSONObject(Contacts.CONTENT_ITEM_TYPE);
+ final Iterator keys = items.keys();
+ while (keys.hasNext()) {
+ final String mimetype = (String) keys.next();
+
+ // Could be single object or array.
+ final JSONObject obj = items.optJSONObject(mimetype);
+ if (obj == null) {
+ final JSONArray array = items.getJSONArray(mimetype);
+ for (int i = 0; i < array.length(); i++) {
+ final JSONObject item = array.getJSONObject(i);
+ processOneRecord(rawContact, item, mimetype);
+ }
+ } else {
+ processOneRecord(rawContact, obj, mimetype);
+ }
+ }
+
+ contact.setRawContacts(new ImmutableList.Builder<RawContact>()
+ .add(rawContact)
+ .build());
+ return contact;
+ }
+
+ private static void processOneRecord(RawContact rawContact, JSONObject item, String mimetype)
+ throws JSONException {
+ final ContentValues itemValues = new ContentValues();
+ itemValues.put(Data.MIMETYPE, mimetype);
+ itemValues.put(Data._ID, -1);
+
+ final Iterator iterator = item.keys();
+ while (iterator.hasNext()) {
+ String name = (String) iterator.next();
+ final Object o = item.get(name);
+ if (o instanceof String) {
+ itemValues.put(name, (String) o);
+ } else if (o instanceof Integer) {
+ itemValues.put(name, (Integer) o);
+ }
+ }
+ rawContact.addDataItemValues(itemValues);
+ }
+
+ private Contact loadContactEntity(ContentResolver resolver, Uri contactUri) {
+ Uri entityUri = Uri.withAppendedPath(contactUri, Contacts.Entity.CONTENT_DIRECTORY);
+ Cursor cursor = resolver.query(entityUri, ContactQuery.COLUMNS, null, null,
+ Contacts.Entity.RAW_CONTACT_ID);
+ if (cursor == null) {
+ Log.e(TAG, "No cursor returned in loadContactEntity");
+ return Contact.forNotFound(mRequestedUri);
+ }
+
+ try {
+ if (!cursor.moveToFirst()) {
+ cursor.close();
+ return Contact.forNotFound(mRequestedUri);
+ }
+
+ // Create the loaded contact starting with the header data.
+ Contact contact = loadContactHeaderData(cursor, contactUri);
+
+ // Fill in the raw contacts, which is wrapped in an Entity and any
+ // status data. Initially, result has empty entities and statuses.
+ long currentRawContactId = -1;
+ RawContact rawContact = null;
+ ImmutableList.Builder<RawContact> rawContactsBuilder =
+ new ImmutableList.Builder<RawContact>();
+ ImmutableMap.Builder<Long, DataStatus> statusesBuilder =
+ new ImmutableMap.Builder<Long, DataStatus>();
+ do {
+ long rawContactId = cursor.getLong(ContactQuery.RAW_CONTACT_ID);
+ if (rawContactId != currentRawContactId) {
+ // First time to see this raw contact id, so create a new entity, and
+ // add it to the result's entities.
+ currentRawContactId = rawContactId;
+ rawContact = new RawContact(loadRawContactValues(cursor));
+ rawContactsBuilder.add(rawContact);
+ }
+ if (!cursor.isNull(ContactQuery.DATA_ID)) {
+ ContentValues data = loadDataValues(cursor);
+ rawContact.addDataItemValues(data);
+
+ if (!cursor.isNull(ContactQuery.PRESENCE)
+ || !cursor.isNull(ContactQuery.STATUS)) {
+ final DataStatus status = new DataStatus(cursor);
+ final long dataId = cursor.getLong(ContactQuery.DATA_ID);
+ statusesBuilder.put(dataId, status);
+ }
+ }
+ } while (cursor.moveToNext());
+
+ contact.setRawContacts(rawContactsBuilder.build());
+ contact.setStatuses(statusesBuilder.build());
+
+ return contact;
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Looks for the photo data item in entities. If found, a thumbnail will be stored. A larger
+ * photo will also be stored if available.
+ */
+ private void loadPhotoBinaryData(Contact contactData) {
+ loadThumbnailBinaryData(contactData);
+
+ // Try to load the large photo from a file using the photo URI.
+ String photoUri = contactData.getPhotoUri();
+ if (photoUri != null) {
+ try {
+ final InputStream inputStream;
+ final AssetFileDescriptor fd;
+ final Uri uri = Uri.parse(photoUri);
+ final String scheme = uri.getScheme();
+ if ("http".equals(scheme) || "https".equals(scheme)) {
+ // Support HTTP urls that might come from extended directories
+ inputStream = new URL(photoUri).openStream();
+ fd = null;
+ } else {
+ fd = getContext().getContentResolver().openAssetFileDescriptor(uri, "r");
+ inputStream = fd.createInputStream();
+ }
+ byte[] buffer = new byte[16 * 1024];
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try {
+ int size;
+ while ((size = inputStream.read(buffer)) != -1) {
+ baos.write(buffer, 0, size);
+ }
+ contactData.setPhotoBinaryData(baos.toByteArray());
+ } finally {
+ inputStream.close();
+ if (fd != null) {
+ fd.close();
+ }
+ }
+ return;
+ } catch (IOException ioe) {
+ // Just fall back to the case below.
+ }
+ }
+
+ // If we couldn't load from a file, fall back to the data blob.
+ contactData.setPhotoBinaryData(contactData.getThumbnailPhotoBinaryData());
+ }
+
+ private void loadThumbnailBinaryData(Contact contactData) {
+ final long photoId = contactData.getPhotoId();
+ if (photoId <= 0) {
+ // No photo ID
+ return;
+ }
+
+ for (RawContact rawContact : contactData.getRawContacts()) {
+ for (DataItem dataItem : rawContact.getDataItems()) {
+ if (dataItem.getId() == photoId) {
+ if (!(dataItem instanceof PhotoDataItem)) {
+ break;
+ }
+
+ final PhotoDataItem photo = (PhotoDataItem) dataItem;
+ contactData.setThumbnailPhotoBinaryData(photo.getPhoto());
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Sets the "invitable" account types to {@link Contact#mInvitableAccountTypes}.
+ */
+ private void loadInvitableAccountTypes(Contact contactData) {
+ final ImmutableList.Builder<AccountType> resultListBuilder =
+ new ImmutableList.Builder<AccountType>();
+ if (!contactData.isUserProfile()) {
+ Map<AccountTypeWithDataSet, AccountType> invitables =
+ AccountTypeManager.getInstance(getContext()).getUsableInvitableAccountTypes();
+ if (!invitables.isEmpty()) {
+ final Map<AccountTypeWithDataSet, AccountType> resultMap =
+ Maps.newHashMap(invitables);
+
+ // Remove the ones that already have a raw contact in the current contact
+ for (RawContact rawContact : contactData.getRawContacts()) {
+ final AccountTypeWithDataSet type = AccountTypeWithDataSet.get(
+ rawContact.getAccountTypeString(),
+ rawContact.getDataSet());
+ resultMap.remove(type);
+ }
+
+ resultListBuilder.addAll(resultMap.values());
+ }
+ }
+
+ // Set to mInvitableAccountTypes
+ contactData.setInvitableAccountTypes(resultListBuilder.build());
+ }
+
+ /**
+ * Extracts Contact level columns from the cursor.
+ */
+ private Contact loadContactHeaderData(final Cursor cursor, Uri contactUri) {
+ final String directoryParameter =
+ contactUri.getQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY);
+ final long directoryId = directoryParameter == null
+ ? Directory.DEFAULT
+ : Long.parseLong(directoryParameter);
+ final long contactId = cursor.getLong(ContactQuery.CONTACT_ID);
+ final String lookupKey = cursor.getString(ContactQuery.LOOKUP_KEY);
+ final long nameRawContactId = cursor.getLong(ContactQuery.NAME_RAW_CONTACT_ID);
+ final int displayNameSource = cursor.getInt(ContactQuery.DISPLAY_NAME_SOURCE);
+ final String displayName = cursor.getString(ContactQuery.DISPLAY_NAME);
+ final String altDisplayName = cursor.getString(ContactQuery.ALT_DISPLAY_NAME);
+ final String phoneticName = cursor.getString(ContactQuery.PHONETIC_NAME);
+ final long photoId = cursor.getLong(ContactQuery.PHOTO_ID);
+ final String photoUri = cursor.getString(ContactQuery.PHOTO_URI);
+ final boolean starred = cursor.getInt(ContactQuery.STARRED) != 0;
+ final Integer presence = cursor.isNull(ContactQuery.CONTACT_PRESENCE)
+ ? null
+ : cursor.getInt(ContactQuery.CONTACT_PRESENCE);
+ final boolean sendToVoicemail = cursor.getInt(ContactQuery.SEND_TO_VOICEMAIL) == 1;
+ final String customRingtone = cursor.getString(ContactQuery.CUSTOM_RINGTONE);
+ final boolean isUserProfile = cursor.getInt(ContactQuery.IS_USER_PROFILE) == 1;
+
+ Uri lookupUri;
+ if (directoryId == Directory.DEFAULT || directoryId == Directory.LOCAL_INVISIBLE) {
+ lookupUri = ContentUris.withAppendedId(
+ Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey), contactId);
+ } else {
+ lookupUri = contactUri;
+ }
+
+ return new Contact(mRequestedUri, contactUri, lookupUri, directoryId, lookupKey,
+ contactId, nameRawContactId, displayNameSource, photoId, photoUri, displayName,
+ altDisplayName, phoneticName, starred, presence, sendToVoicemail,
+ customRingtone, isUserProfile);
+ }
+
+ /**
+ * Extracts RawContact level columns from the cursor.
+ */
+ private ContentValues loadRawContactValues(Cursor cursor) {
+ ContentValues cv = new ContentValues();
+
+ cv.put(RawContacts._ID, cursor.getLong(ContactQuery.RAW_CONTACT_ID));
+
+ cursorColumnToContentValues(cursor, cv, ContactQuery.ACCOUNT_NAME);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.ACCOUNT_TYPE);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SET);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DIRTY);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.VERSION);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.SOURCE_ID);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC1);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC2);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC3);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC4);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DELETED);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.CONTACT_ID);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.STARRED);
+
+ return cv;
+ }
+
+ /**
+ * Extracts Data level columns from the cursor.
+ */
+ private ContentValues loadDataValues(Cursor cursor) {
+ ContentValues cv = new ContentValues();
+
+ cv.put(Data._ID, cursor.getLong(ContactQuery.DATA_ID));
+
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA1);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA2);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA3);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA4);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA5);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA6);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA7);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA8);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA9);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA10);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA11);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA12);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA13);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA14);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA15);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC1);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC2);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC3);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC4);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_VERSION);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.IS_PRIMARY);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.IS_SUPERPRIMARY);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.MIMETYPE);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.GROUP_SOURCE_ID);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.CHAT_CAPABILITY);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.TIMES_USED);
+ cursorColumnToContentValues(cursor, cv, ContactQuery.LAST_TIME_USED);
+ if (CompatUtils.isMarshmallowCompatible()) {
+ cursorColumnToContentValues(cursor, cv, ContactQuery.CARRIER_PRESENCE);
+ }
+
+ return cv;
+ }
+
+ private void cursorColumnToContentValues(
+ Cursor cursor, ContentValues values, int index) {
+ switch (cursor.getType(index)) {
+ case Cursor.FIELD_TYPE_NULL:
+ // don't put anything in the content values
+ break;
+ case Cursor.FIELD_TYPE_INTEGER:
+ values.put(ContactQuery.COLUMNS[index], cursor.getLong(index));
+ break;
+ case Cursor.FIELD_TYPE_STRING:
+ values.put(ContactQuery.COLUMNS[index], cursor.getString(index));
+ break;
+ case Cursor.FIELD_TYPE_BLOB:
+ values.put(ContactQuery.COLUMNS[index], cursor.getBlob(index));
+ break;
+ default:
+ throw new IllegalStateException("Invalid or unhandled data type");
+ }
+ }
+
+ private void loadDirectoryMetaData(Contact result) {
+ long directoryId = result.getDirectoryId();
+
+ Cursor cursor = getContext().getContentResolver().query(
+ ContentUris.withAppendedId(Directory.CONTENT_URI, directoryId),
+ DirectoryQuery.COLUMNS, null, null, null);
+ if (cursor == null) {
+ return;
+ }
+ try {
+ if (cursor.moveToFirst()) {
+ final String displayName = cursor.getString(DirectoryQuery.DISPLAY_NAME);
+ final String packageName = cursor.getString(DirectoryQuery.PACKAGE_NAME);
+ final int typeResourceId = cursor.getInt(DirectoryQuery.TYPE_RESOURCE_ID);
+ final String accountType = cursor.getString(DirectoryQuery.ACCOUNT_TYPE);
+ final String accountName = cursor.getString(DirectoryQuery.ACCOUNT_NAME);
+ final int exportSupport = cursor.getInt(DirectoryQuery.EXPORT_SUPPORT);
+ String directoryType = null;
+ if (!TextUtils.isEmpty(packageName)) {
+ PackageManager pm = getContext().getPackageManager();
+ try {
+ Resources resources = pm.getResourcesForApplication(packageName);
+ directoryType = resources.getString(typeResourceId);
+ } catch (NameNotFoundException e) {
+ Log.w(TAG, "Contact directory resource not found: "
+ + packageName + "." + typeResourceId);
+ }
+ }
+
+ result.setDirectoryMetaData(
+ displayName, directoryType, accountType, accountName, exportSupport);
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+ static private class AccountKey {
+ private final String mAccountName;
+ private final String mAccountType;
+ private final String mDataSet;
+
+ public AccountKey(String accountName, String accountType, String dataSet) {
+ mAccountName = accountName;
+ mAccountType = accountType;
+ mDataSet = dataSet;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mAccountName, mAccountType, mDataSet);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof AccountKey)) {
+ return false;
+ }
+ final AccountKey other = (AccountKey) obj;
+ return Objects.equals(mAccountName, other.mAccountName)
+ && Objects.equals(mAccountType, other.mAccountType)
+ && Objects.equals(mDataSet, other.mDataSet);
+ }
+ }
+
+ /**
+ * Loads groups meta-data for all groups associated with all constituent raw contacts'
+ * accounts.
+ */
+ private void loadGroupMetaData(Contact result) {
+ StringBuilder selection = new StringBuilder();
+ ArrayList<String> selectionArgs = new ArrayList<String>();
+ final HashSet<AccountKey> accountsSeen = new HashSet<>();
+ for (RawContact rawContact : result.getRawContacts()) {
+ final String accountName = rawContact.getAccountName();
+ final String accountType = rawContact.getAccountTypeString();
+ final String dataSet = rawContact.getDataSet();
+ final AccountKey accountKey = new AccountKey(accountName, accountType, dataSet);
+ if (accountName != null && accountType != null &&
+ !accountsSeen.contains(accountKey)) {
+ accountsSeen.add(accountKey);
+ if (selection.length() != 0) {
+ selection.append(" OR ");
+ }
+ selection.append(
+ "(" + Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?");
+ selectionArgs.add(accountName);
+ selectionArgs.add(accountType);
+
+ selection.append(" AND " + Groups.DELETED + "=0");
+
+ if (dataSet != null) {
+ selection.append(" AND " + Groups.DATA_SET + "=?");
+ selectionArgs.add(dataSet);
+ } else {
+ selection.append(" AND " + Groups.DATA_SET + " IS NULL");
+ }
+ selection.append(")");
+ }
+ }
+ final ImmutableList.Builder<GroupMetaData> groupListBuilder =
+ new ImmutableList.Builder<GroupMetaData>();
+ final Cursor cursor = getContext().getContentResolver().query(Groups.CONTENT_URI,
+ GroupQuery.COLUMNS, selection.toString(), selectionArgs.toArray(new String[0]),
+ null);
+ if (cursor != null) {
+ try {
+ while (cursor.moveToNext()) {
+ final String accountName = cursor.getString(GroupQuery.ACCOUNT_NAME);
+ final String accountType = cursor.getString(GroupQuery.ACCOUNT_TYPE);
+ final String dataSet = cursor.getString(GroupQuery.DATA_SET);
+ final long groupId = cursor.getLong(GroupQuery.ID);
+ final String title = cursor.getString(GroupQuery.TITLE);
+ final boolean defaultGroup = cursor.isNull(GroupQuery.AUTO_ADD)
+ ? false
+ : cursor.getInt(GroupQuery.AUTO_ADD) != 0;
+ final boolean favorites = cursor.isNull(GroupQuery.FAVORITES)
+ ? false
+ : cursor.getInt(GroupQuery.FAVORITES) != 0;
+
+ groupListBuilder.add(new GroupMetaData(
+ accountName, accountType, dataSet, groupId, title, defaultGroup,
+ favorites));
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ result.setGroupMetaData(groupListBuilder.build());
+ }
+
+ /**
+ * Iterates over all data items that represent phone numbers are tries to calculate a formatted
+ * number. This function can safely be called several times as no unformatted data is
+ * overwritten
+ */
+ private void computeFormattedPhoneNumbers(Contact contactData) {
+ final String countryIso = GeoUtil.getCurrentCountryIso(getContext());
+ final ImmutableList<RawContact> rawContacts = contactData.getRawContacts();
+ final int rawContactCount = rawContacts.size();
+ for (int rawContactIndex = 0; rawContactIndex < rawContactCount; rawContactIndex++) {
+ final RawContact rawContact = rawContacts.get(rawContactIndex);
+ final List<DataItem> dataItems = rawContact.getDataItems();
+ final int dataCount = dataItems.size();
+ for (int dataIndex = 0; dataIndex < dataCount; dataIndex++) {
+ final DataItem dataItem = dataItems.get(dataIndex);
+ if (dataItem instanceof PhoneDataItem) {
+ final PhoneDataItem phoneDataItem = (PhoneDataItem) dataItem;
+ phoneDataItem.computeFormattedPhoneNumber(countryIso);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void deliverResult(Contact result) {
+ unregisterObserver();
+
+ // The creator isn't interested in any further updates
+ if (isReset() || result == null) {
+ return;
+ }
+
+ mContact = result;
+
+ if (result.isLoaded()) {
+ mLookupUri = result.getLookupUri();
+
+ if (!result.isDirectoryEntry()) {
+ Log.i(TAG, "Registering content observer for " + mLookupUri);
+ if (mObserver == null) {
+ mObserver = new ForceLoadContentObserver();
+ }
+ getContext().getContentResolver().registerContentObserver(
+ mLookupUri, true, mObserver);
+ }
+
+ if (mPostViewNotification) {
+ // inform the source of the data that this contact is being looked at
+ postViewNotificationToSyncAdapter();
+ }
+ }
+
+ super.deliverResult(mContact);
+ }
+
+ /**
+ * Posts a message to the contributing sync adapters that have opted-in, notifying them
+ * that the contact has just been loaded
+ */
+ private void postViewNotificationToSyncAdapter() {
+ Context context = getContext();
+ for (RawContact rawContact : mContact.getRawContacts()) {
+ final long rawContactId = rawContact.getId();
+ if (mNotifiedRawContactIds.contains(rawContactId)) {
+ continue; // Already notified for this raw contact.
+ }
+ mNotifiedRawContactIds.add(rawContactId);
+ final AccountType accountType = rawContact.getAccountType(context);
+ final String serviceName = accountType.getViewContactNotifyServiceClassName();
+ final String servicePackageName = accountType.getViewContactNotifyServicePackageName();
+ if (!TextUtils.isEmpty(serviceName) && !TextUtils.isEmpty(servicePackageName)) {
+ final Uri uri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId);
+ final Intent intent = new Intent();
+ intent.setClassName(servicePackageName, serviceName);
+ intent.setAction(Intent.ACTION_VIEW);
+ intent.setDataAndType(uri, RawContacts.CONTENT_ITEM_TYPE);
+ try {
+ context.startService(intent);
+ } catch (Exception e) {
+ Log.e(TAG, "Error sending message to source-app", e);
+ }
+ }
+ }
+ }
+
+ private void unregisterObserver() {
+ if (mObserver != null) {
+ getContext().getContentResolver().unregisterContentObserver(mObserver);
+ mObserver = null;
+ }
+ }
+
+ /**
+ * Fully upgrades this ContactLoader to one with all lists fully loaded. When done, the
+ * new result will be delivered
+ */
+ public void upgradeToFullContact() {
+ // Everything requested already? Nothing to do, so let's bail out
+ if (mLoadGroupMetaData && mLoadInvitableAccountTypes
+ && mPostViewNotification && mComputeFormattedPhoneNumber) return;
+
+ mLoadGroupMetaData = true;
+ mLoadInvitableAccountTypes = true;
+ mPostViewNotification = true;
+ mComputeFormattedPhoneNumber = true;
+
+ // Cache the current result, so that we only load the "missing" parts of the contact.
+ cacheResult();
+
+ // Our load parameters have changed, so let's pretend the data has changed. Its the same
+ // thing, essentially.
+ onContentChanged();
+ }
+
+ public Uri getLookupUri() {
+ return mLookupUri;
+ }
+
+ @Override
+ protected void onStartLoading() {
+ if (mContact != null) {
+ deliverResult(mContact);
+ }
+
+ if (takeContentChanged() || mContact == null) {
+ forceLoad();
+ }
+ }
+
+ @Override
+ protected void onStopLoading() {
+ cancelLoad();
+ }
+
+ @Override
+ protected void onReset() {
+ super.onReset();
+ cancelLoad();
+ unregisterObserver();
+ mContact = null;
+ }
+
+ /**
+ * Caches the result, which is useful when we switch from activity to activity, using the same
+ * contact. If the next load is for a different contact, the cached result will be dropped
+ */
+ public void cacheResult() {
+ if (mContact == null || !mContact.isLoaded()) {
+ sCachedResult = null;
+ } else {
+ sCachedResult = mContact;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/model/RawContact.java b/src/com/android/contacts/common/model/RawContact.java
new file mode 100644
index 0000000..3d8db85
--- /dev/null
+++ b/src/com/android/contacts/common/model/RawContact.java
@@ -0,0 +1,368 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Entity;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.RawContacts;
+
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.model.dataitem.DataItem;
+import com.google.common.base.Objects;
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * RawContact represents a single raw contact in the raw contacts database.
+ * It has specialized getters/setters for raw contact
+ * items, and also contains a collection of DataItem objects. A RawContact contains the information
+ * from a single account.
+ *
+ * This allows RawContact objects to be thought of as a class with raw contact
+ * fields (like account type, name, data set, sync state, etc.) and a list of
+ * DataItem objects that represent contact information elements (like phone
+ * numbers, email, address, etc.).
+ */
+final public class RawContact implements Parcelable {
+
+ private AccountTypeManager mAccountTypeManager;
+ private final ContentValues mValues;
+ private final ArrayList<NamedDataItem> mDataItems;
+
+ final public static class NamedDataItem implements Parcelable {
+ public final Uri mUri;
+
+ // This use to be a DataItem. DataItem creation is now delayed until the point of request
+ // since there is no benefit to storing them here due to the multiple inheritance.
+ // Eventually instanceof still has to be used anyways to determine which sub-class of
+ // DataItem it is. And having parent DataItem's here makes it very difficult to serialize or
+ // parcelable.
+ //
+ // Instead of having a common DataItem super class, we should refactor this to be a generic
+ // Object where the object is a concrete class that no longer relies on ContentValues.
+ // (this will also make the classes easier to use).
+ // Since instanceof is used later anyways, having a list of Objects won't hurt and is no
+ // worse than having a DataItem.
+ public final ContentValues mContentValues;
+
+ public NamedDataItem(Uri uri, ContentValues values) {
+ this.mUri = uri;
+ this.mContentValues = values;
+ }
+
+ public NamedDataItem(Parcel parcel) {
+ this.mUri = parcel.readParcelable(Uri.class.getClassLoader());
+ this.mContentValues = parcel.readParcelable(ContentValues.class.getClassLoader());
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel parcel, int i) {
+ parcel.writeParcelable(mUri, i);
+ parcel.writeParcelable(mContentValues, i);
+ }
+
+ public static final Parcelable.Creator<NamedDataItem> CREATOR
+ = new Parcelable.Creator<NamedDataItem>() {
+
+ @Override
+ public NamedDataItem createFromParcel(Parcel parcel) {
+ return new NamedDataItem(parcel);
+ }
+
+ @Override
+ public NamedDataItem[] newArray(int i) {
+ return new NamedDataItem[i];
+ }
+ };
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(mUri, mContentValues);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) return false;
+ if (getClass() != obj.getClass()) return false;
+
+ final NamedDataItem other = (NamedDataItem) obj;
+ return Objects.equal(mUri, other.mUri) &&
+ Objects.equal(mContentValues, other.mContentValues);
+ }
+ }
+
+ public static RawContact createFrom(Entity entity) {
+ final ContentValues values = entity.getEntityValues();
+ final ArrayList<Entity.NamedContentValues> subValues = entity.getSubValues();
+
+ RawContact rawContact = new RawContact(values);
+ for (Entity.NamedContentValues subValue : subValues) {
+ rawContact.addNamedDataItemValues(subValue.uri, subValue.values);
+ }
+ return rawContact;
+ }
+
+ /**
+ * A RawContact object can be created with or without a context.
+ */
+ public RawContact() {
+ this(new ContentValues());
+ }
+
+ public RawContact(ContentValues values) {
+ mValues = values;
+ mDataItems = new ArrayList<NamedDataItem>();
+ }
+
+ /**
+ * Constructor for the parcelable.
+ *
+ * @param parcel The parcel to de-serialize from.
+ */
+ private RawContact(Parcel parcel) {
+ mValues = parcel.readParcelable(ContentValues.class.getClassLoader());
+ mDataItems = Lists.newArrayList();
+ parcel.readTypedList(mDataItems, NamedDataItem.CREATOR);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel parcel, int i) {
+ parcel.writeParcelable(mValues, i);
+ parcel.writeTypedList(mDataItems);
+ }
+
+ /**
+ * Create for building the parcelable.
+ */
+ public static final Parcelable.Creator<RawContact> CREATOR
+ = new Parcelable.Creator<RawContact>() {
+
+ @Override
+ public RawContact createFromParcel(Parcel parcel) {
+ return new RawContact(parcel);
+ }
+
+ @Override
+ public RawContact[] newArray(int i) {
+ return new RawContact[i];
+ }
+ };
+
+ public AccountTypeManager getAccountTypeManager(Context context) {
+ if (mAccountTypeManager == null) {
+ mAccountTypeManager = AccountTypeManager.getInstance(context);
+ }
+ return mAccountTypeManager;
+ }
+
+ public ContentValues getValues() {
+ return mValues;
+ }
+
+ /**
+ * Returns the id of the raw contact.
+ */
+ public Long getId() {
+ return getValues().getAsLong(RawContacts._ID);
+ }
+
+ /**
+ * Returns the account name of the raw contact.
+ */
+ public String getAccountName() {
+ return getValues().getAsString(RawContacts.ACCOUNT_NAME);
+ }
+
+ /**
+ * Returns the account type of the raw contact.
+ */
+ public String getAccountTypeString() {
+ return getValues().getAsString(RawContacts.ACCOUNT_TYPE);
+ }
+
+ /**
+ * Returns the data set of the raw contact.
+ */
+ public String getDataSet() {
+ return getValues().getAsString(RawContacts.DATA_SET);
+ }
+
+ public boolean isDirty() {
+ return getValues().getAsBoolean(RawContacts.DIRTY);
+ }
+
+ public String getSourceId() {
+ return getValues().getAsString(RawContacts.SOURCE_ID);
+ }
+
+ public String getSync1() {
+ return getValues().getAsString(RawContacts.SYNC1);
+ }
+
+ public String getSync2() {
+ return getValues().getAsString(RawContacts.SYNC2);
+ }
+
+ public String getSync3() {
+ return getValues().getAsString(RawContacts.SYNC3);
+ }
+
+ public String getSync4() {
+ return getValues().getAsString(RawContacts.SYNC4);
+ }
+
+ public boolean isDeleted() {
+ return getValues().getAsBoolean(RawContacts.DELETED);
+ }
+
+ public long getContactId() {
+ return getValues().getAsLong(Contacts.Entity.CONTACT_ID);
+ }
+
+ public boolean isStarred() {
+ return getValues().getAsBoolean(Contacts.STARRED);
+ }
+
+ public AccountType getAccountType(Context context) {
+ return getAccountTypeManager(context).getAccountType(getAccountTypeString(), getDataSet());
+ }
+
+ /**
+ * Sets the account name, account type, and data set strings.
+ * Valid combinations for account-name, account-type, data-set
+ * 1) null, null, null (local account)
+ * 2) non-null, non-null, null (valid account without data-set)
+ * 3) non-null, non-null, non-null (valid account with data-set)
+ */
+ private void setAccount(String accountName, String accountType, String dataSet) {
+ final ContentValues values = getValues();
+ if (accountName == null) {
+ if (accountType == null && dataSet == null) {
+ // This is a local account
+ values.putNull(RawContacts.ACCOUNT_NAME);
+ values.putNull(RawContacts.ACCOUNT_TYPE);
+ values.putNull(RawContacts.DATA_SET);
+ return;
+ }
+ } else {
+ if (accountType != null) {
+ // This is a valid account, either with or without a dataSet.
+ values.put(RawContacts.ACCOUNT_NAME, accountName);
+ values.put(RawContacts.ACCOUNT_TYPE, accountType);
+ if (dataSet == null) {
+ values.putNull(RawContacts.DATA_SET);
+ } else {
+ values.put(RawContacts.DATA_SET, dataSet);
+ }
+ return;
+ }
+ }
+ throw new IllegalArgumentException(
+ "Not a valid combination of account name, type, and data set.");
+ }
+
+ public void setAccount(AccountWithDataSet accountWithDataSet) {
+ if (accountWithDataSet != null) {
+ setAccount(accountWithDataSet.name, accountWithDataSet.type,
+ accountWithDataSet.dataSet);
+ } else {
+ setAccount(null, null, null);
+ }
+ }
+
+ public void setAccountToLocal() {
+ setAccount(null, null, null);
+ }
+
+ /**
+ * Creates and inserts a DataItem object that wraps the content values, and returns it.
+ */
+ public void addDataItemValues(ContentValues values) {
+ addNamedDataItemValues(Data.CONTENT_URI, values);
+ }
+
+ public NamedDataItem addNamedDataItemValues(Uri uri, ContentValues values) {
+ final NamedDataItem namedItem = new NamedDataItem(uri, values);
+ mDataItems.add(namedItem);
+ return namedItem;
+ }
+
+ public ArrayList<ContentValues> getContentValues() {
+ final ArrayList<ContentValues> list = Lists.newArrayListWithCapacity(mDataItems.size());
+ for (NamedDataItem dataItem : mDataItems) {
+ if (Data.CONTENT_URI.equals(dataItem.mUri)) {
+ list.add(dataItem.mContentValues);
+ }
+ }
+ return list;
+ }
+
+ public List<DataItem> getDataItems() {
+ final ArrayList<DataItem> list = Lists.newArrayListWithCapacity(mDataItems.size());
+ for (NamedDataItem dataItem : mDataItems) {
+ if (Data.CONTENT_URI.equals(dataItem.mUri)) {
+ list.add(DataItem.createFrom(dataItem.mContentValues));
+ }
+ }
+ return list;
+ }
+
+ public String toString() {
+ final StringBuilder sb = new StringBuilder();
+ sb.append("RawContact: ").append(mValues);
+ for (RawContact.NamedDataItem namedDataItem : mDataItems) {
+ sb.append("\n ").append(namedDataItem.mUri);
+ sb.append("\n -> ").append(namedDataItem.mContentValues);
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(mValues, mDataItems);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) return false;
+ if (getClass() != obj.getClass()) return false;
+
+ RawContact other = (RawContact) obj;
+ return Objects.equal(mValues, other.mValues) &&
+ Objects.equal(mDataItems, other.mDataItems);
+ }
+}
diff --git a/src/com/android/contacts/common/model/RawContactDelta.java b/src/com/android/contacts/common/model/RawContactDelta.java
new file mode 100644
index 0000000..b8709c3
--- /dev/null
+++ b/src/com/android/contacts/common/model/RawContactDelta.java
@@ -0,0 +1,660 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderOperation.Builder;
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.BaseColumns;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Profile;
+import android.provider.ContactsContract.RawContacts;
+import android.util.Log;
+
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.BuilderWrapper;
+import com.android.contacts.common.model.CPOWrapper;
+import com.android.contacts.common.model.ValuesDelta;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.testing.NeededForTesting;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+
+/**
+ * Contains a {@link RawContact} and records any modifications separately so the
+ * original {@link RawContact} can be swapped out with a newer version and the
+ * changes still cleanly applied.
+ * <p>
+ * One benefit of this approach is that we can build changes entirely on an
+ * empty {@link RawContact}, which then becomes an insert {@link RawContacts} case.
+ * <p>
+ * When applying modifications over an {@link RawContact}, we try finding the
+ * original {@link Data#_ID} rows where the modifications took place. If those
+ * rows are missing from the new {@link RawContact}, we know the original data must
+ * be deleted, but to preserve the user modifications we treat as an insert.
+ */
+public class RawContactDelta implements Parcelable {
+ // TODO: optimize by using contentvalues pool, since we allocate so many of them
+
+ private static final String TAG = "EntityDelta";
+ private static final boolean LOGV = false;
+
+ /**
+ * Direct values from {@link Entity#getEntityValues()}.
+ */
+ private ValuesDelta mValues;
+
+ /**
+ * URI used for contacts queries, by default it is set to query raw contacts.
+ * It can be set to query the profile's raw contact(s).
+ */
+ private Uri mContactsQueryUri = RawContacts.CONTENT_URI;
+
+ /**
+ * Internal map of children values from {@link Entity#getSubValues()}, which
+ * we store here sorted into {@link Data#MIMETYPE} bins.
+ */
+ private final HashMap<String, ArrayList<ValuesDelta>> mEntries = Maps.newHashMap();
+
+ public RawContactDelta() {
+ }
+
+ public RawContactDelta(ValuesDelta values) {
+ mValues = values;
+ }
+
+ /**
+ * Build an {@link RawContactDelta} using the given {@link RawContact} as a
+ * starting point; the "before" snapshot.
+ */
+ public static RawContactDelta fromBefore(RawContact before) {
+ final RawContactDelta rawContactDelta = new RawContactDelta();
+ rawContactDelta.mValues = ValuesDelta.fromBefore(before.getValues());
+ rawContactDelta.mValues.setIdColumn(RawContacts._ID);
+ for (final ContentValues values : before.getContentValues()) {
+ rawContactDelta.addEntry(ValuesDelta.fromBefore(values));
+ }
+ return rawContactDelta;
+ }
+
+ /**
+ * Merge the "after" values from the given {@link RawContactDelta} onto the
+ * "before" state represented by this {@link RawContactDelta}, discarding any
+ * existing "after" states. This is typically used when re-parenting changes
+ * onto an updated {@link Entity}.
+ */
+ public static RawContactDelta mergeAfter(RawContactDelta local, RawContactDelta remote) {
+ // Bail early if trying to merge delete with missing local
+ final ValuesDelta remoteValues = remote.mValues;
+ if (local == null && (remoteValues.isDelete() || remoteValues.isTransient())) return null;
+
+ // Create local version if none exists yet
+ if (local == null) local = new RawContactDelta();
+
+ if (LOGV) {
+ final Long localVersion = (local.mValues == null) ? null : local.mValues
+ .getAsLong(RawContacts.VERSION);
+ final Long remoteVersion = remote.mValues.getAsLong(RawContacts.VERSION);
+ Log.d(TAG, "Re-parenting from original version " + remoteVersion + " to "
+ + localVersion);
+ }
+
+ // Create values if needed, and merge "after" changes
+ local.mValues = ValuesDelta.mergeAfter(local.mValues, remote.mValues);
+
+ // Find matching local entry for each remote values, or create
+ for (ArrayList<ValuesDelta> mimeEntries : remote.mEntries.values()) {
+ for (ValuesDelta remoteEntry : mimeEntries) {
+ final Long childId = remoteEntry.getId();
+
+ // Find or create local match and merge
+ final ValuesDelta localEntry = local.getEntry(childId);
+ final ValuesDelta merged = ValuesDelta.mergeAfter(localEntry, remoteEntry);
+
+ if (localEntry == null && merged != null) {
+ // No local entry before, so insert
+ local.addEntry(merged);
+ }
+ }
+ }
+
+ return local;
+ }
+
+ public ValuesDelta getValues() {
+ return mValues;
+ }
+
+ public boolean isContactInsert() {
+ return mValues.isInsert();
+ }
+
+ /**
+ * Get the {@link ValuesDelta} child marked as {@link Data#IS_PRIMARY},
+ * which may return null when no entry exists.
+ */
+ public ValuesDelta getPrimaryEntry(String mimeType) {
+ final ArrayList<ValuesDelta> mimeEntries = getMimeEntries(mimeType, false);
+ if (mimeEntries == null) return null;
+
+ for (ValuesDelta entry : mimeEntries) {
+ if (entry.isPrimary()) {
+ return entry;
+ }
+ }
+
+ // When no direct primary, return something
+ return mimeEntries.size() > 0 ? mimeEntries.get(0) : null;
+ }
+
+ /**
+ * calls {@link #getSuperPrimaryEntry(String, boolean)} with true
+ * @see #getSuperPrimaryEntry(String, boolean)
+ */
+ public ValuesDelta getSuperPrimaryEntry(String mimeType) {
+ return getSuperPrimaryEntry(mimeType, true);
+ }
+
+ /**
+ * Returns the super-primary entry for the given mime type
+ * @param forceSelection if true, will try to return some value even if a super-primary
+ * doesn't exist (may be a primary, or just a random item
+ * @return
+ */
+ @NeededForTesting
+ public ValuesDelta getSuperPrimaryEntry(String mimeType, boolean forceSelection) {
+ final ArrayList<ValuesDelta> mimeEntries = getMimeEntries(mimeType, false);
+ if (mimeEntries == null) return null;
+
+ ValuesDelta primary = null;
+ for (ValuesDelta entry : mimeEntries) {
+ if (entry.isSuperPrimary()) {
+ return entry;
+ } else if (entry.isPrimary()) {
+ primary = entry;
+ }
+ }
+
+ if (!forceSelection) {
+ return null;
+ }
+
+ // When no direct super primary, return something
+ if (primary != null) {
+ return primary;
+ }
+ return mimeEntries.size() > 0 ? mimeEntries.get(0) : null;
+ }
+
+ /**
+ * Return the AccountType that this raw-contact belongs to.
+ */
+ public AccountType getRawContactAccountType(Context context) {
+ ContentValues entityValues = getValues().getCompleteValues();
+ String type = entityValues.getAsString(RawContacts.ACCOUNT_TYPE);
+ String dataSet = entityValues.getAsString(RawContacts.DATA_SET);
+ return AccountTypeManager.getInstance(context).getAccountType(type, dataSet);
+ }
+
+ public Long getRawContactId() {
+ return getValues().getAsLong(RawContacts._ID);
+ }
+
+ public String getAccountName() {
+ return getValues().getAsString(RawContacts.ACCOUNT_NAME);
+ }
+
+ public String getAccountType() {
+ return getValues().getAsString(RawContacts.ACCOUNT_TYPE);
+ }
+
+ public String getDataSet() {
+ return getValues().getAsString(RawContacts.DATA_SET);
+ }
+
+ public AccountType getAccountType(AccountTypeManager manager) {
+ return manager.getAccountType(getAccountType(), getDataSet());
+ }
+
+ public boolean isVisible() {
+ return getValues().isVisible();
+ }
+
+ /**
+ * Return the list of child {@link ValuesDelta} from our optimized map,
+ * creating the list if requested.
+ */
+ private ArrayList<ValuesDelta> getMimeEntries(String mimeType, boolean lazyCreate) {
+ ArrayList<ValuesDelta> mimeEntries = mEntries.get(mimeType);
+ if (mimeEntries == null && lazyCreate) {
+ mimeEntries = Lists.newArrayList();
+ mEntries.put(mimeType, mimeEntries);
+ }
+ return mimeEntries;
+ }
+
+ public ArrayList<ValuesDelta> getMimeEntries(String mimeType) {
+ return getMimeEntries(mimeType, false);
+ }
+
+ public int getMimeEntriesCount(String mimeType, boolean onlyVisible) {
+ final ArrayList<ValuesDelta> mimeEntries = getMimeEntries(mimeType);
+ if (mimeEntries == null) return 0;
+
+ int count = 0;
+ for (ValuesDelta child : mimeEntries) {
+ // Skip deleted items when requesting only visible
+ if (onlyVisible && !child.isVisible()) continue;
+ count++;
+ }
+ return count;
+ }
+
+ public boolean hasMimeEntries(String mimeType) {
+ return mEntries.containsKey(mimeType);
+ }
+
+ public ValuesDelta addEntry(ValuesDelta entry) {
+ final String mimeType = entry.getMimetype();
+ getMimeEntries(mimeType, true).add(entry);
+ return entry;
+ }
+
+ public ArrayList<ContentValues> getContentValues() {
+ ArrayList<ContentValues> values = Lists.newArrayList();
+ for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) {
+ for (ValuesDelta entry : mimeEntries) {
+ if (!entry.isDelete()) {
+ values.add(entry.getCompleteValues());
+ }
+ }
+ }
+ return values;
+ }
+
+ /**
+ * Find entry with the given {@link BaseColumns#_ID} value.
+ */
+ public ValuesDelta getEntry(Long childId) {
+ if (childId == null) {
+ // Requesting an "insert" entry, which has no "before"
+ return null;
+ }
+
+ // Search all children for requested entry
+ for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) {
+ for (ValuesDelta entry : mimeEntries) {
+ if (childId.equals(entry.getId())) {
+ return entry;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return the total number of {@link ValuesDelta} contained.
+ */
+ public int getEntryCount(boolean onlyVisible) {
+ int count = 0;
+ for (String mimeType : mEntries.keySet()) {
+ count += getMimeEntriesCount(mimeType, onlyVisible);
+ }
+ return count;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (object instanceof RawContactDelta) {
+ final RawContactDelta other = (RawContactDelta)object;
+
+ // Equality failed if parent values different
+ if (!other.mValues.equals(mValues)) return false;
+
+ for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) {
+ for (ValuesDelta child : mimeEntries) {
+ // Equality failed if any children unmatched
+ if (!other.containsEntry(child)) return false;
+ }
+ }
+
+ // Passed all tests, so equal
+ return true;
+ }
+ return false;
+ }
+
+ private boolean containsEntry(ValuesDelta entry) {
+ for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) {
+ for (ValuesDelta child : mimeEntries) {
+ // Contained if we find any child that matches
+ if (child.equals(entry)) return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Mark this entire object deleted, including any {@link ValuesDelta}.
+ */
+ public void markDeleted() {
+ this.mValues.markDeleted();
+ for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) {
+ for (ValuesDelta child : mimeEntries) {
+ child.markDeleted();
+ }
+ }
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder();
+ builder.append("\n(");
+ builder.append("Uri=");
+ builder.append(mContactsQueryUri);
+ builder.append(", Values=");
+ builder.append(mValues != null ? mValues.toString() : "null");
+ builder.append(", Entries={");
+ for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) {
+ for (ValuesDelta child : mimeEntries) {
+ builder.append("\n\t");
+ child.toString(builder);
+ }
+ }
+ builder.append("\n})\n");
+ return builder.toString();
+ }
+
+ /**
+ * Consider building the given {@link ContentProviderOperation.Builder} and
+ * appending it to the given list, which only happens if builder is valid.
+ */
+ private void possibleAdd(ArrayList<ContentProviderOperation> diff,
+ ContentProviderOperation.Builder builder) {
+ if (builder != null) {
+ diff.add(builder.build());
+ }
+ }
+
+ /**
+ * For compatibility purpose, this method is copied from {@link #possibleAdd} and takes
+ * BuilderWrapper and an ArrayList of CPOWrapper as parameters.
+ */
+ private void possibleAddWrapper(ArrayList<CPOWrapper> diff, BuilderWrapper bw) {
+ if (bw != null && bw.getBuilder() != null) {
+ diff.add(new CPOWrapper(bw.getBuilder().build(), bw.getType()));
+ }
+ }
+
+ /**
+ * Build a list of {@link ContentProviderOperation} that will assert any
+ * "before" state hasn't changed. This is maintained separately so that all
+ * asserts can take place before any updates occur.
+ */
+ public void buildAssert(ArrayList<ContentProviderOperation> buildInto) {
+ final Builder builder = buildAssertHelper();
+ if (builder != null) {
+ buildInto.add(builder.build());
+ }
+ }
+
+ /**
+ * For compatibility purpose, this method is copied from {@link #buildAssert} and takes an
+ * ArrayList of CPOWrapper as parameter.
+ */
+ public void buildAssertWrapper(ArrayList<CPOWrapper> buildInto) {
+ final Builder builder = buildAssertHelper();
+ if (builder != null) {
+ buildInto.add(new CPOWrapper(builder.build(), CompatUtils.TYPE_ASSERT));
+ }
+ }
+
+ private Builder buildAssertHelper() {
+ final boolean isContactInsert = mValues.isInsert();
+ ContentProviderOperation.Builder builder = null;
+ if (!isContactInsert) {
+ // Assert version is consistent while persisting changes
+ final Long beforeId = mValues.getId();
+ final Long beforeVersion = mValues.getAsLong(RawContacts.VERSION);
+ if (beforeId == null || beforeVersion == null) return builder;
+ builder = ContentProviderOperation.newAssertQuery(mContactsQueryUri);
+ builder.withSelection(RawContacts._ID + "=" + beforeId, null);
+ builder.withValue(RawContacts.VERSION, beforeVersion);
+ }
+ return builder;
+ }
+
+ /**
+ * Build a list of {@link ContentProviderOperation} that will transform the
+ * current "before" {@link Entity} state into the modified state which this
+ * {@link RawContactDelta} represents.
+ */
+ public void buildDiff(ArrayList<ContentProviderOperation> buildInto) {
+ final int firstIndex = buildInto.size();
+
+ final boolean isContactInsert = mValues.isInsert();
+ final boolean isContactDelete = mValues.isDelete();
+ final boolean isContactUpdate = !isContactInsert && !isContactDelete;
+
+ final Long beforeId = mValues.getId();
+
+ Builder builder;
+
+ if (isContactInsert) {
+ // TODO: for now simply disabling aggregation when a new contact is
+ // created on the phone. In the future, will show aggregation suggestions
+ // after saving the contact.
+ mValues.put(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_SUSPENDED);
+ }
+
+ // Build possible operation at Contact level
+ builder = mValues.buildDiff(mContactsQueryUri);
+ possibleAdd(buildInto, builder);
+
+ // Build operations for all children
+ for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) {
+ for (ValuesDelta child : mimeEntries) {
+ // Ignore children if parent was deleted
+ if (isContactDelete) continue;
+
+ // Use the profile data URI if the contact is the profile.
+ if (mContactsQueryUri.equals(Profile.CONTENT_RAW_CONTACTS_URI)) {
+ builder = child.buildDiff(Uri.withAppendedPath(Profile.CONTENT_URI,
+ RawContacts.Data.CONTENT_DIRECTORY));
+ } else {
+ builder = child.buildDiff(Data.CONTENT_URI);
+ }
+
+ if (child.isInsert()) {
+ if (isContactInsert) {
+ // Parent is brand new insert, so back-reference _id
+ builder.withValueBackReference(Data.RAW_CONTACT_ID, firstIndex);
+ } else {
+ // Inserting under existing, so fill with known _id
+ builder.withValue(Data.RAW_CONTACT_ID, beforeId);
+ }
+ } else if (isContactInsert && builder != null) {
+ // Child must be insert when Contact insert
+ throw new IllegalArgumentException("When parent insert, child must be also");
+ }
+ possibleAdd(buildInto, builder);
+ }
+ }
+
+ final boolean addedOperations = buildInto.size() > firstIndex;
+ if (addedOperations && isContactUpdate) {
+ // Suspend aggregation while persisting updates
+ builder = buildSetAggregationMode(beforeId, RawContacts.AGGREGATION_MODE_SUSPENDED);
+ buildInto.add(firstIndex, builder.build());
+
+ // Restore aggregation mode as last operation
+ builder = buildSetAggregationMode(beforeId, RawContacts.AGGREGATION_MODE_DEFAULT);
+ buildInto.add(builder.build());
+ } else if (isContactInsert) {
+ // Restore aggregation mode as last operation
+ builder = ContentProviderOperation.newUpdate(mContactsQueryUri);
+ builder.withValue(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_DEFAULT);
+ builder.withSelection(RawContacts._ID + "=?", new String[1]);
+ builder.withSelectionBackReference(0, firstIndex);
+ buildInto.add(builder.build());
+ }
+ }
+
+ /**
+ * For compatibility purpose, this method is copied from {@link #buildDiff} and takes an
+ * ArrayList of CPOWrapper as parameter.
+ */
+ public void buildDiffWrapper(ArrayList<CPOWrapper> buildInto) {
+ final int firstIndex = buildInto.size();
+
+ final boolean isContactInsert = mValues.isInsert();
+ final boolean isContactDelete = mValues.isDelete();
+ final boolean isContactUpdate = !isContactInsert && !isContactDelete;
+
+ final Long beforeId = mValues.getId();
+
+ if (isContactInsert) {
+ // TODO: for now simply disabling aggregation when a new contact is
+ // created on the phone. In the future, will show aggregation suggestions
+ // after saving the contact.
+ mValues.put(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_SUSPENDED);
+ }
+
+ // Build possible operation at Contact level
+ BuilderWrapper bw = mValues.buildDiffWrapper(mContactsQueryUri);
+ possibleAddWrapper(buildInto, bw);
+
+ // Build operations for all children
+ for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) {
+ for (ValuesDelta child : mimeEntries) {
+ // Ignore children if parent was deleted
+ if (isContactDelete) continue;
+
+ // Use the profile data URI if the contact is the profile.
+ if (mContactsQueryUri.equals(Profile.CONTENT_RAW_CONTACTS_URI)) {
+ bw = child.buildDiffWrapper(Uri.withAppendedPath(Profile.CONTENT_URI,
+ RawContacts.Data.CONTENT_DIRECTORY));
+ } else {
+ bw = child.buildDiffWrapper(Data.CONTENT_URI);
+ }
+
+ if (child.isInsert()) {
+ if (isContactInsert) {
+ // Parent is brand new insert, so back-reference _id
+ bw.getBuilder().withValueBackReference(Data.RAW_CONTACT_ID, firstIndex);
+ } else {
+ // Inserting under existing, so fill with known _id
+ bw.getBuilder().withValue(Data.RAW_CONTACT_ID, beforeId);
+ }
+ } else if (isContactInsert && bw != null && bw.getBuilder() != null) {
+ // Child must be insert when Contact insert
+ throw new IllegalArgumentException("When parent insert, child must be also");
+ }
+ possibleAddWrapper(buildInto, bw);
+ }
+ }
+
+ final boolean addedOperations = buildInto.size() > firstIndex;
+ if (addedOperations && isContactUpdate) {
+ // Suspend aggregation while persisting updates
+ Builder builder =
+ buildSetAggregationMode(beforeId, RawContacts.AGGREGATION_MODE_SUSPENDED);
+ buildInto.add(firstIndex, new CPOWrapper(builder.build(), CompatUtils.TYPE_UPDATE));
+
+ // Restore aggregation mode as last operation
+ builder = buildSetAggregationMode(beforeId, RawContacts.AGGREGATION_MODE_DEFAULT);
+ buildInto.add(new CPOWrapper(builder.build(), CompatUtils.TYPE_UPDATE));
+ } else if (isContactInsert) {
+ // Restore aggregation mode as last operation
+ Builder builder = ContentProviderOperation.newUpdate(mContactsQueryUri);
+ builder.withValue(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_DEFAULT);
+ builder.withSelection(RawContacts._ID + "=?", new String[1]);
+ builder.withSelectionBackReference(0, firstIndex);
+ buildInto.add(new CPOWrapper(builder.build(), CompatUtils.TYPE_UPDATE));
+ }
+ }
+
+ /**
+ * Build a {@link ContentProviderOperation} that changes
+ * {@link RawContacts#AGGREGATION_MODE} to the given value.
+ */
+ protected Builder buildSetAggregationMode(Long beforeId, int mode) {
+ Builder builder = ContentProviderOperation.newUpdate(mContactsQueryUri);
+ builder.withValue(RawContacts.AGGREGATION_MODE, mode);
+ builder.withSelection(RawContacts._ID + "=" + beforeId, null);
+ return builder;
+ }
+
+ /** {@inheritDoc} */
+ public int describeContents() {
+ // Nothing special about this parcel
+ return 0;
+ }
+
+ /** {@inheritDoc} */
+ public void writeToParcel(Parcel dest, int flags) {
+ final int size = this.getEntryCount(false);
+ dest.writeInt(size);
+ dest.writeParcelable(mValues, flags);
+ dest.writeParcelable(mContactsQueryUri, flags);
+ for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) {
+ for (ValuesDelta child : mimeEntries) {
+ dest.writeParcelable(child, flags);
+ }
+ }
+ }
+
+ public void readFromParcel(Parcel source) {
+ final ClassLoader loader = getClass().getClassLoader();
+ final int size = source.readInt();
+ mValues = source.<ValuesDelta> readParcelable(loader);
+ mContactsQueryUri = source.<Uri> readParcelable(loader);
+ for (int i = 0; i < size; i++) {
+ final ValuesDelta child = source.<ValuesDelta> readParcelable(loader);
+ this.addEntry(child);
+ }
+ }
+
+ /**
+ * Used to set the query URI to the profile URI to store profiles.
+ */
+ public void setProfileQueryUri() {
+ mContactsQueryUri = Profile.CONTENT_RAW_CONTACTS_URI;
+ }
+
+ public static final Parcelable.Creator<RawContactDelta> CREATOR =
+ new Parcelable.Creator<RawContactDelta>() {
+ public RawContactDelta createFromParcel(Parcel in) {
+ final RawContactDelta state = new RawContactDelta();
+ state.readFromParcel(in);
+ return state;
+ }
+
+ public RawContactDelta[] newArray(int size) {
+ return new RawContactDelta[size];
+ }
+ };
+
+}
diff --git a/src/com/android/contacts/common/model/RawContactDeltaList.java b/src/com/android/contacts/common/model/RawContactDeltaList.java
new file mode 100644
index 0000000..6964643
--- /dev/null
+++ b/src/com/android/contacts/common/model/RawContactDeltaList.java
@@ -0,0 +1,573 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderOperation.Builder;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Entity;
+import android.content.EntityIterator;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.ContactsContract.AggregationExceptions;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.RawContacts;
+import android.util.Log;
+
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.model.CPOWrapper;
+import com.android.contacts.common.model.ValuesDelta;
+
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+
+/**
+ * Container for multiple {@link RawContactDelta} objects, usually when editing
+ * together as an entire aggregate. Provides convenience methods for parceling
+ * and applying another {@link RawContactDeltaList} over it.
+ */
+public class RawContactDeltaList extends ArrayList<RawContactDelta> implements Parcelable {
+ private static final String TAG = RawContactDeltaList.class.getSimpleName();
+ private static final boolean VERBOSE_LOGGING = Log.isLoggable(TAG, Log.VERBOSE);
+
+ private boolean mSplitRawContacts;
+ private long[] mJoinWithRawContactIds;
+
+ public RawContactDeltaList() {
+ }
+
+ /**
+ * Create an {@link RawContactDeltaList} based on {@link Contacts} specified by the
+ * given query parameters. This closes the {@link EntityIterator} when
+ * finished, so it doesn't subscribe to updates.
+ */
+ public static RawContactDeltaList fromQuery(Uri entityUri, ContentResolver resolver,
+ String selection, String[] selectionArgs, String sortOrder) {
+ final EntityIterator iterator = RawContacts.newEntityIterator(
+ resolver.query(entityUri, null, selection, selectionArgs, sortOrder));
+ try {
+ return fromIterator(iterator);
+ } finally {
+ iterator.close();
+ }
+ }
+
+ /**
+ * Create an {@link RawContactDeltaList} that contains the entities of the Iterator as before
+ * values. This function can be passed an iterator of Entity objects or an iterator of
+ * RawContact objects.
+ */
+ public static RawContactDeltaList fromIterator(Iterator<?> iterator) {
+ final RawContactDeltaList state = new RawContactDeltaList();
+ state.addAll(iterator);
+ return state;
+ }
+
+ public void addAll(Iterator<?> iterator) {
+ // Perform background query to pull contact details
+ while (iterator.hasNext()) {
+ // Read all contacts into local deltas to prepare for edits
+ Object nextObject = iterator.next();
+ final RawContact before = nextObject instanceof Entity
+ ? RawContact.createFrom((Entity) nextObject)
+ : (RawContact) nextObject;
+ final RawContactDelta rawContactDelta = RawContactDelta.fromBefore(before);
+ add(rawContactDelta);
+ }
+ }
+
+ /**
+ * Merge the "after" values from the given {@link RawContactDeltaList}, discarding any
+ * previous "after" states. This is typically used when re-parenting user
+ * edits onto an updated {@link RawContactDeltaList}.
+ */
+ public static RawContactDeltaList mergeAfter(RawContactDeltaList local,
+ RawContactDeltaList remote) {
+ if (local == null) local = new RawContactDeltaList();
+
+ // For each entity in the remote set, try matching over existing
+ for (RawContactDelta remoteEntity : remote) {
+ final Long rawContactId = remoteEntity.getValues().getId();
+
+ // Find or create local match and merge
+ final RawContactDelta localEntity = local.getByRawContactId(rawContactId);
+ final RawContactDelta merged = RawContactDelta.mergeAfter(localEntity, remoteEntity);
+
+ if (localEntity == null && merged != null) {
+ // No local entry before, so insert
+ local.add(merged);
+ }
+ }
+
+ return local;
+ }
+
+ /**
+ * Build a list of {@link ContentProviderOperation} that will transform all
+ * the "before" {@link Entity} states into the modified state which all
+ * {@link RawContactDelta} objects represent. This method specifically creates
+ * any {@link AggregationExceptions} rules needed to groups edits together.
+ */
+ public ArrayList<ContentProviderOperation> buildDiff() {
+ if (VERBOSE_LOGGING) {
+ Log.v(TAG, "buildDiff: list=" + toString());
+ }
+ final ArrayList<ContentProviderOperation> diff = Lists.newArrayList();
+
+ final long rawContactId = this.findRawContactId();
+ int firstInsertRow = -1;
+
+ // First pass enforces versions remain consistent
+ for (RawContactDelta delta : this) {
+ delta.buildAssert(diff);
+ }
+
+ final int assertMark = diff.size();
+ int backRefs[] = new int[size()];
+
+ int rawContactIndex = 0;
+
+ // Second pass builds actual operations
+ for (RawContactDelta delta : this) {
+ final int firstBatch = diff.size();
+ final boolean isInsert = delta.isContactInsert();
+ backRefs[rawContactIndex++] = isInsert ? firstBatch : -1;
+
+ delta.buildDiff(diff);
+
+ // If the user chose to join with some other existing raw contact(s) at save time,
+ // add aggregation exceptions for all those raw contacts.
+ if (mJoinWithRawContactIds != null) {
+ for (Long joinedRawContactId : mJoinWithRawContactIds) {
+ final Builder builder = beginKeepTogether();
+ builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, joinedRawContactId);
+ if (rawContactId != -1) {
+ builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId);
+ } else {
+ builder.withValueBackReference(
+ AggregationExceptions.RAW_CONTACT_ID2, firstBatch);
+ }
+ diff.add(builder.build());
+ }
+ }
+
+ // Only create rules for inserts
+ if (!isInsert) continue;
+
+ // If we are going to split all contacts, there is no point in first combining them
+ if (mSplitRawContacts) continue;
+
+ if (rawContactId != -1) {
+ // Has existing contact, so bind to it strongly
+ final Builder builder = beginKeepTogether();
+ builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId);
+ builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID2, firstBatch);
+ diff.add(builder.build());
+
+ } else if (firstInsertRow == -1) {
+ // First insert case, so record row
+ firstInsertRow = firstBatch;
+
+ } else {
+ // Additional insert case, so point at first insert
+ final Builder builder = beginKeepTogether();
+ builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID1,
+ firstInsertRow);
+ builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID2, firstBatch);
+ diff.add(builder.build());
+ }
+ }
+
+ if (mSplitRawContacts) {
+ buildSplitContactDiff(diff, backRefs);
+ }
+
+ // No real changes if only left with asserts
+ if (diff.size() == assertMark) {
+ diff.clear();
+ }
+ if (VERBOSE_LOGGING) {
+ Log.v(TAG, "buildDiff: ops=" + diffToString(diff));
+ }
+ return diff;
+ }
+
+ /**
+ * For compatibility purpose, this method is copied from {@link #buildDiff} and returns an
+ * ArrayList of CPOWrapper.
+ */
+ public ArrayList<CPOWrapper> buildDiffWrapper() {
+ if (VERBOSE_LOGGING) {
+ Log.v(TAG, "buildDiffWrapper: list=" + toString());
+ }
+ final ArrayList<CPOWrapper> diffWrapper = Lists.newArrayList();
+
+ final long rawContactId = this.findRawContactId();
+ int firstInsertRow = -1;
+
+ // First pass enforces versions remain consistent
+ for (RawContactDelta delta : this) {
+ delta.buildAssertWrapper(diffWrapper);
+ }
+
+ final int assertMark = diffWrapper.size();
+ int backRefs[] = new int[size()];
+
+ int rawContactIndex = 0;
+
+ // Second pass builds actual operations
+ for (RawContactDelta delta : this) {
+ final int firstBatch = diffWrapper.size();
+ final boolean isInsert = delta.isContactInsert();
+ backRefs[rawContactIndex++] = isInsert ? firstBatch : -1;
+
+ delta.buildDiffWrapper(diffWrapper);
+
+ // If the user chose to join with some other existing raw contact(s) at save time,
+ // add aggregation exceptions for all those raw contacts.
+ if (mJoinWithRawContactIds != null) {
+ for (Long joinedRawContactId : mJoinWithRawContactIds) {
+ final Builder builder = beginKeepTogether();
+ builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, joinedRawContactId);
+ if (rawContactId != -1) {
+ builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId);
+ } else {
+ builder.withValueBackReference(
+ AggregationExceptions.RAW_CONTACT_ID2, firstBatch);
+ }
+ diffWrapper.add(new CPOWrapper(builder.build(), CompatUtils.TYPE_UPDATE));
+ }
+ }
+
+ // Only create rules for inserts
+ if (!isInsert) continue;
+
+ // If we are going to split all contacts, there is no point in first combining them
+ if (mSplitRawContacts) continue;
+
+ if (rawContactId != -1) {
+ // Has existing contact, so bind to it strongly
+ final Builder builder = beginKeepTogether();
+ builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId);
+ builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID2, firstBatch);
+ diffWrapper.add(new CPOWrapper(builder.build(), CompatUtils.TYPE_UPDATE));
+
+ } else if (firstInsertRow == -1) {
+ // First insert case, so record row
+ firstInsertRow = firstBatch;
+
+ } else {
+ // Additional insert case, so point at first insert
+ final Builder builder = beginKeepTogether();
+ builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID1,
+ firstInsertRow);
+ builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID2, firstBatch);
+ diffWrapper.add(new CPOWrapper(builder.build(), CompatUtils.TYPE_UPDATE));
+ }
+ }
+
+ if (mSplitRawContacts) {
+ buildSplitContactDiffWrapper(diffWrapper, backRefs);
+ }
+
+ // No real changes if only left with asserts
+ if (diffWrapper.size() == assertMark) {
+ diffWrapper.clear();
+ }
+ if (VERBOSE_LOGGING) {
+ Log.v(TAG, "buildDiff: ops=" + diffToStringWrapper(diffWrapper));
+ }
+ return diffWrapper;
+ }
+
+ private static String diffToString(ArrayList<ContentProviderOperation> ops) {
+ final StringBuilder sb = new StringBuilder();
+ sb.append("[\n");
+ for (ContentProviderOperation op : ops) {
+ sb.append(op.toString());
+ sb.append(",\n");
+ }
+ sb.append("]\n");
+ return sb.toString();
+ }
+
+ /**
+ * For compatibility purpose.
+ */
+ private static String diffToStringWrapper(ArrayList<CPOWrapper> cpoWrappers) {
+ ArrayList<ContentProviderOperation> ops = Lists.newArrayList();
+ for (CPOWrapper cpoWrapper : cpoWrappers) {
+ ops.add(cpoWrapper.getOperation());
+ }
+ return diffToString(ops);
+ }
+
+ /**
+ * Start building a {@link ContentProviderOperation} that will keep two
+ * {@link RawContacts} together.
+ */
+ protected Builder beginKeepTogether() {
+ final Builder builder = ContentProviderOperation
+ .newUpdate(AggregationExceptions.CONTENT_URI);
+ builder.withValue(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_TOGETHER);
+ return builder;
+ }
+
+ /**
+ * Builds {@link AggregationExceptions} to split all constituent raw contacts into
+ * separate contacts.
+ */
+ private void buildSplitContactDiff(final ArrayList<ContentProviderOperation> diff,
+ int[] backRefs) {
+ final int count = size();
+ for (int i = 0; i < count; i++) {
+ for (int j = 0; j < count; j++) {
+ if (i == j) {
+ continue;
+ }
+ final Builder builder = buildSplitContactDiffHelper(i, j, backRefs);
+ if (builder != null) {
+ diff.add(builder.build());
+ }
+ }
+ }
+ }
+
+ /**
+ * For compatibility purpose, this method is copied from {@link #buildSplitContactDiff} and
+ * takes an ArrayList of CPOWrapper as parameter.
+ */
+ private void buildSplitContactDiffWrapper(final ArrayList<CPOWrapper> diff, int[] backRefs) {
+ final int count = size();
+ for (int i = 0; i < count; i++) {
+ for (int j = 0; j < count; j++) {
+ if (i == j) {
+ continue;
+ }
+ final Builder builder = buildSplitContactDiffHelper(i, j, backRefs);
+ if (builder != null) {
+ diff.add(new CPOWrapper(builder.build(), CompatUtils.TYPE_UPDATE));
+ }
+ }
+ }
+ }
+
+ private Builder buildSplitContactDiffHelper(int index1, int index2, int[] backRefs) {
+ final Builder builder =
+ ContentProviderOperation.newUpdate(AggregationExceptions.CONTENT_URI);
+ builder.withValue(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_SEPARATE);
+
+ Long rawContactId1 = get(index1).getValues().getAsLong(RawContacts._ID);
+ int backRef1 = backRefs[index1];
+ if (rawContactId1 != null && rawContactId1 >= 0) {
+ builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1);
+ } else if (backRef1 >= 0) {
+ builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID1, backRef1);
+ } else {
+ return null;
+ }
+
+ Long rawContactId2 = get(index2).getValues().getAsLong(RawContacts._ID);
+ int backRef2 = backRefs[index2];
+ if (rawContactId2 != null && rawContactId2 >= 0) {
+ builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2);
+ } else if (backRef2 >= 0) {
+ builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID2, backRef2);
+ } else {
+ return null;
+ }
+ return builder;
+ }
+
+ /**
+ * Search all contained {@link RawContactDelta} for the first one with an
+ * existing {@link RawContacts#_ID} value. Usually used when creating
+ * {@link AggregationExceptions} during an update.
+ */
+ public long findRawContactId() {
+ for (RawContactDelta delta : this) {
+ final Long rawContactId = delta.getValues().getAsLong(RawContacts._ID);
+ if (rawContactId != null && rawContactId >= 0) {
+ return rawContactId;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Find {@link RawContacts#_ID} of the requested {@link RawContactDelta}.
+ */
+ public Long getRawContactId(int index) {
+ if (index >= 0 && index < this.size()) {
+ final RawContactDelta delta = this.get(index);
+ final ValuesDelta values = delta.getValues();
+ if (values.isVisible()) {
+ return values.getAsLong(RawContacts._ID);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Find the raw-contact (an {@link RawContactDelta}) with the specified ID.
+ */
+ public RawContactDelta getByRawContactId(Long rawContactId) {
+ final int index = this.indexOfRawContactId(rawContactId);
+ return (index == -1) ? null : this.get(index);
+ }
+
+ /**
+ * Find index of given {@link RawContacts#_ID} when present.
+ */
+ public int indexOfRawContactId(Long rawContactId) {
+ if (rawContactId == null) return -1;
+ final int size = this.size();
+ for (int i = 0; i < size; i++) {
+ final Long currentId = getRawContactId(i);
+ if (rawContactId.equals(currentId)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Return the index of the first RawContactDelta corresponding to a writable raw-contact, or -1.
+ * */
+ public int indexOfFirstWritableRawContact(Context context) {
+ // Find the first writable entity.
+ int entityIndex = 0;
+ for (RawContactDelta delta : this) {
+ if (delta.getRawContactAccountType(context).areContactsWritable()) return entityIndex;
+ entityIndex++;
+ }
+ return -1;
+ }
+
+ /** Return the first RawContactDelta corresponding to a writable raw-contact, or null. */
+ public RawContactDelta getFirstWritableRawContact(Context context) {
+ final int index = indexOfFirstWritableRawContact(context);
+ return (index == -1) ? null : get(index);
+ }
+
+ public ValuesDelta getSuperPrimaryEntry(final String mimeType) {
+ ValuesDelta primary = null;
+ ValuesDelta randomEntry = null;
+ for (RawContactDelta delta : this) {
+ final ArrayList<ValuesDelta> mimeEntries = delta.getMimeEntries(mimeType);
+ if (mimeEntries == null) return null;
+
+ for (ValuesDelta entry : mimeEntries) {
+ if (entry.isSuperPrimary()) {
+ return entry;
+ } else if (primary == null && entry.isPrimary()) {
+ primary = entry;
+ } else if (randomEntry == null) {
+ randomEntry = entry;
+ }
+ }
+ }
+ // When no direct super primary, return something
+ if (primary != null) {
+ return primary;
+ }
+ return randomEntry;
+ }
+
+ /**
+ * Sets a flag that will split ("explode") the raw_contacts into seperate contacts
+ */
+ public void markRawContactsForSplitting() {
+ mSplitRawContacts = true;
+ }
+
+ public boolean isMarkedForSplitting() {
+ return mSplitRawContacts;
+ }
+
+ public void setJoinWithRawContacts(long[] rawContactIds) {
+ mJoinWithRawContactIds = rawContactIds;
+ }
+
+ public boolean isMarkedForJoining() {
+ return mJoinWithRawContactIds != null && mJoinWithRawContactIds.length > 0;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public int describeContents() {
+ // Nothing special about this parcel
+ return 0;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ final int size = this.size();
+ dest.writeInt(size);
+ for (RawContactDelta delta : this) {
+ dest.writeParcelable(delta, flags);
+ }
+ dest.writeLongArray(mJoinWithRawContactIds);
+ dest.writeInt(mSplitRawContacts ? 1 : 0);
+ }
+
+ @SuppressWarnings("unchecked")
+ public void readFromParcel(Parcel source) {
+ final ClassLoader loader = getClass().getClassLoader();
+ final int size = source.readInt();
+ for (int i = 0; i < size; i++) {
+ this.add(source.<RawContactDelta> readParcelable(loader));
+ }
+ mJoinWithRawContactIds = source.createLongArray();
+ mSplitRawContacts = source.readInt() != 0;
+ }
+
+ public static final Parcelable.Creator<RawContactDeltaList> CREATOR =
+ new Parcelable.Creator<RawContactDeltaList>() {
+ @Override
+ public RawContactDeltaList createFromParcel(Parcel in) {
+ final RawContactDeltaList state = new RawContactDeltaList();
+ state.readFromParcel(in);
+ return state;
+ }
+
+ @Override
+ public RawContactDeltaList[] newArray(int size) {
+ return new RawContactDeltaList[size];
+ }
+ };
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("(");
+ sb.append("Split=");
+ sb.append(mSplitRawContacts);
+ sb.append(", Join=[");
+ sb.append(Arrays.toString(mJoinWithRawContactIds));
+ sb.append("], Values=");
+ sb.append(super.toString());
+ sb.append(")");
+ return sb.toString();
+ }
+}
diff --git a/src/com/android/contacts/common/model/RawContactModifier.java b/src/com/android/contacts/common/model/RawContactModifier.java
new file mode 100644
index 0000000..fd028e3
--- /dev/null
+++ b/src/com/android/contacts/common/model/RawContactModifier.java
@@ -0,0 +1,1424 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.BaseTypes;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Event;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+import android.provider.ContactsContract.CommonDataKinds.Note;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.CommonDataKinds.Relation;
+import android.provider.ContactsContract.CommonDataKinds.SipAddress;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.provider.ContactsContract.CommonDataKinds.Website;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Intents;
+import android.provider.ContactsContract.Intents.Insert;
+import android.provider.ContactsContract.RawContacts;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.SparseArray;
+import android.util.SparseIntArray;
+
+import com.android.contacts.common.ContactsUtils;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.ValuesDelta;
+import com.android.contacts.common.util.CommonDateUtils;
+import com.android.contacts.common.util.DateUtils;
+import com.android.contacts.common.util.NameConverter;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountType.EditField;
+import com.android.contacts.common.model.account.AccountType.EditType;
+import com.android.contacts.common.model.account.AccountType.EventEditType;
+import com.android.contacts.common.model.account.GoogleAccountType;
+import com.android.contacts.common.model.dataitem.DataKind;
+import com.android.contacts.common.model.dataitem.PhoneDataItem;
+import com.android.contacts.common.model.dataitem.StructuredNameDataItem;
+
+import java.text.ParsePosition;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Helper methods for modifying an {@link RawContactDelta}, such as inserting
+ * new rows, or enforcing {@link AccountType}.
+ */
+public class RawContactModifier {
+ private static final String TAG = RawContactModifier.class.getSimpleName();
+
+ /** Set to true in order to view logs on entity operations */
+ private static final boolean DEBUG = false;
+
+ /**
+ * For the given {@link RawContactDelta}, determine if the given
+ * {@link DataKind} could be inserted under specific
+ * {@link AccountType}.
+ */
+ public static boolean canInsert(RawContactDelta state, DataKind kind) {
+ // Insert possible when have valid types and under overall maximum
+ final int visibleCount = state.getMimeEntriesCount(kind.mimeType, true);
+ final boolean validTypes = hasValidTypes(state, kind);
+ final boolean validOverall = (kind.typeOverallMax == -1)
+ || (visibleCount < kind.typeOverallMax);
+ return (validTypes && validOverall);
+ }
+
+ public static boolean hasValidTypes(RawContactDelta state, DataKind kind) {
+ if (RawContactModifier.hasEditTypes(kind)) {
+ return (getValidTypes(state, kind, null, true, null, true).size() > 0);
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Ensure that at least one of the given {@link DataKind} exists in the
+ * given {@link RawContactDelta} state, and try creating one if none exist.
+ * @return The child (either newly created or the first existing one), or null if the
+ * account doesn't support this {@link DataKind}.
+ */
+ public static ValuesDelta ensureKindExists(
+ RawContactDelta state, AccountType accountType, String mimeType) {
+ final DataKind kind = accountType.getKindForMimetype(mimeType);
+ final boolean hasChild = state.getMimeEntriesCount(mimeType, true) > 0;
+
+ if (kind != null) {
+ if (hasChild) {
+ // Return the first entry.
+ return state.getMimeEntries(mimeType).get(0);
+ } else {
+ // Create child when none exists and valid kind
+ final ValuesDelta child = insertChild(state, kind);
+ if (kind.mimeType.equals(Photo.CONTENT_ITEM_TYPE)) {
+ child.setFromTemplate(true);
+ }
+ return child;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * For the given {@link RawContactDelta} and {@link DataKind}, return the
+ * list possible {@link EditType} options available based on
+ * {@link AccountType}.
+ *
+ * @param forceInclude Always include this {@link EditType} in the returned
+ * list, even when an otherwise-invalid choice. This is useful
+ * when showing a dialog that includes the current type.
+ * @param includeSecondary If true, include any valid types marked as
+ * {@link EditType#secondary}.
+ * @param typeCount When provided, will be used for the frequency count of
+ * each {@link EditType}, otherwise built using
+ * {@link #getTypeFrequencies(RawContactDelta, DataKind)}.
+ * @param checkOverall If true, check if the overall number of types is under limit.
+ */
+ public static ArrayList<EditType> getValidTypes(RawContactDelta state, DataKind kind,
+ EditType forceInclude, boolean includeSecondary, SparseIntArray typeCount,
+ boolean checkOverall) {
+ final ArrayList<EditType> validTypes = new ArrayList<EditType>();
+
+ // Bail early if no types provided
+ if (!hasEditTypes(kind)) return validTypes;
+
+ if (typeCount == null) {
+ // Build frequency counts if not provided
+ typeCount = getTypeFrequencies(state, kind);
+ }
+
+ // Build list of valid types
+ boolean validOverall = true;
+ if (checkOverall) {
+ final int overallCount = typeCount.get(FREQUENCY_TOTAL);
+ validOverall = (kind.typeOverallMax == -1 ? true
+ : overallCount < kind.typeOverallMax);
+ }
+
+ for (EditType type : kind.typeList) {
+ final boolean validSpecific = (type.specificMax == -1 ? true : typeCount
+ .get(type.rawValue) < type.specificMax);
+ final boolean validSecondary = (includeSecondary ? true : !type.secondary);
+ final boolean forcedInclude = type.equals(forceInclude);
+ if (forcedInclude || (validOverall && validSpecific && validSecondary)) {
+ // Type is valid when no limit, under limit, or forced include
+ validTypes.add(type);
+ }
+ }
+
+ return validTypes;
+ }
+
+ private static final int FREQUENCY_TOTAL = Integer.MIN_VALUE;
+
+ /**
+ * Count up the frequency that each {@link EditType} appears in the given
+ * {@link RawContactDelta}. The returned {@link SparseIntArray} maps from
+ * {@link EditType#rawValue} to counts, with the total overall count stored
+ * as {@link #FREQUENCY_TOTAL}.
+ */
+ private static SparseIntArray getTypeFrequencies(RawContactDelta state, DataKind kind) {
+ final SparseIntArray typeCount = new SparseIntArray();
+
+ // Find all entries for this kind, bailing early if none found
+ final List<ValuesDelta> mimeEntries = state.getMimeEntries(kind.mimeType);
+ if (mimeEntries == null) return typeCount;
+
+ int totalCount = 0;
+ for (ValuesDelta entry : mimeEntries) {
+ // Only count visible entries
+ if (!entry.isVisible()) continue;
+ totalCount++;
+
+ final EditType type = getCurrentType(entry, kind);
+ if (type != null) {
+ final int count = typeCount.get(type.rawValue);
+ typeCount.put(type.rawValue, count + 1);
+ }
+ }
+ typeCount.put(FREQUENCY_TOTAL, totalCount);
+ return typeCount;
+ }
+
+ /**
+ * Check if the given {@link DataKind} has multiple types that should be
+ * displayed for users to pick.
+ */
+ public static boolean hasEditTypes(DataKind kind) {
+ return kind.typeList != null && kind.typeList.size() > 0;
+ }
+
+ /**
+ * Find the {@link EditType} that describes the given
+ * {@link ValuesDelta} row, assuming the given {@link DataKind} dictates
+ * the possible types.
+ */
+ public static EditType getCurrentType(ValuesDelta entry, DataKind kind) {
+ final Long rawValue = entry.getAsLong(kind.typeColumn);
+ if (rawValue == null) return null;
+ return getType(kind, rawValue.intValue());
+ }
+
+ /**
+ * Find the {@link EditType} that describes the given {@link ContentValues} row,
+ * assuming the given {@link DataKind} dictates the possible types.
+ */
+ public static EditType getCurrentType(ContentValues entry, DataKind kind) {
+ if (kind.typeColumn == null) return null;
+ final Integer rawValue = entry.getAsInteger(kind.typeColumn);
+ if (rawValue == null) return null;
+ return getType(kind, rawValue);
+ }
+
+ /**
+ * Find the {@link EditType} that describes the given {@link Cursor} row,
+ * assuming the given {@link DataKind} dictates the possible types.
+ */
+ public static EditType getCurrentType(Cursor cursor, DataKind kind) {
+ if (kind.typeColumn == null) return null;
+ final int index = cursor.getColumnIndex(kind.typeColumn);
+ if (index == -1) return null;
+ final int rawValue = cursor.getInt(index);
+ return getType(kind, rawValue);
+ }
+
+ /**
+ * Find the {@link EditType} with the given {@link EditType#rawValue}.
+ */
+ public static EditType getType(DataKind kind, int rawValue) {
+ for (EditType type : kind.typeList) {
+ if (type.rawValue == rawValue) {
+ return type;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return the precedence for the the given {@link EditType#rawValue}, where
+ * lower numbers are higher precedence.
+ */
+ public static int getTypePrecedence(DataKind kind, int rawValue) {
+ for (int i = 0; i < kind.typeList.size(); i++) {
+ final EditType type = kind.typeList.get(i);
+ if (type.rawValue == rawValue) {
+ return i;
+ }
+ }
+ return Integer.MAX_VALUE;
+ }
+
+ /**
+ * Find the best {@link EditType} for a potential insert. The "best" is the
+ * first primary type that doesn't already exist. When all valid types
+ * exist, we pick the last valid option.
+ */
+ public static EditType getBestValidType(RawContactDelta state, DataKind kind,
+ boolean includeSecondary, int exactValue) {
+ // Shortcut when no types
+ if (kind == null || kind.typeColumn == null) return null;
+
+ // Find type counts and valid primary types, bail if none
+ final SparseIntArray typeCount = getTypeFrequencies(state, kind);
+ final ArrayList<EditType> validTypes = getValidTypes(state, kind, null, includeSecondary,
+ typeCount, /*checkOverall=*/ true);
+ if (validTypes.size() == 0) return null;
+
+ // Keep track of the last valid type
+ final EditType lastType = validTypes.get(validTypes.size() - 1);
+
+ // Remove any types that already exist
+ Iterator<EditType> iterator = validTypes.iterator();
+ while (iterator.hasNext()) {
+ final EditType type = iterator.next();
+ final int count = typeCount.get(type.rawValue);
+
+ if (exactValue == type.rawValue) {
+ // Found exact value match
+ return type;
+ }
+
+ if (count > 0) {
+ // Type already appears, so don't consider
+ iterator.remove();
+ }
+ }
+
+ // Use the best remaining, otherwise the last valid
+ if (validTypes.size() > 0) {
+ return validTypes.get(0);
+ } else {
+ return lastType;
+ }
+ }
+
+ /**
+ * Insert a new child of kind {@link DataKind} into the given
+ * {@link RawContactDelta}. Tries using the best {@link EditType} found using
+ * {@link #getBestValidType(RawContactDelta, DataKind, boolean, int)}.
+ */
+ public static ValuesDelta insertChild(RawContactDelta state, DataKind kind) {
+ // Bail early if invalid kind
+ if (kind == null) return null;
+ // First try finding a valid primary
+ EditType bestType = getBestValidType(state, kind, false, Integer.MIN_VALUE);
+ if (bestType == null) {
+ // No valid primary found, so expand search to secondary
+ bestType = getBestValidType(state, kind, true, Integer.MIN_VALUE);
+ }
+ return insertChild(state, kind, bestType);
+ }
+
+ /**
+ * Insert a new child of kind {@link DataKind} into the given
+ * {@link RawContactDelta}, marked with the given {@link EditType}.
+ */
+ public static ValuesDelta insertChild(RawContactDelta state, DataKind kind, EditType type) {
+ // Bail early if invalid kind
+ if (kind == null) return null;
+ final ContentValues after = new ContentValues();
+
+ // Our parent CONTACT_ID is provided later
+ after.put(Data.MIMETYPE, kind.mimeType);
+
+ // Fill-in with any requested default values
+ if (kind.defaultValues != null) {
+ after.putAll(kind.defaultValues);
+ }
+
+ if (kind.typeColumn != null && type != null) {
+ // Set type, if provided
+ after.put(kind.typeColumn, type.rawValue);
+ }
+
+ final ValuesDelta child = ValuesDelta.fromAfter(after);
+ state.addEntry(child);
+ return child;
+ }
+
+ /**
+ * Processing to trim any empty {@link ValuesDelta} and {@link RawContactDelta}
+ * from the given {@link RawContactDeltaList}, assuming the given {@link AccountTypeManager}
+ * dictates the structure for various fields. This method ignores rows not
+ * described by the {@link AccountType}.
+ */
+ public static void trimEmpty(RawContactDeltaList set, AccountTypeManager accountTypes) {
+ for (RawContactDelta state : set) {
+ ValuesDelta values = state.getValues();
+ final String accountType = values.getAsString(RawContacts.ACCOUNT_TYPE);
+ final String dataSet = values.getAsString(RawContacts.DATA_SET);
+ final AccountType type = accountTypes.getAccountType(accountType, dataSet);
+ trimEmpty(state, type);
+ }
+ }
+
+ public static boolean hasChanges(RawContactDeltaList set, AccountTypeManager accountTypes) {
+ return hasChanges(set, accountTypes, /* excludedMimeTypes =*/ null);
+ }
+
+ public static boolean hasChanges(RawContactDeltaList set, AccountTypeManager accountTypes,
+ Set<String> excludedMimeTypes) {
+ if (set.isMarkedForSplitting() || set.isMarkedForJoining()) {
+ return true;
+ }
+
+ for (RawContactDelta state : set) {
+ ValuesDelta values = state.getValues();
+ final String accountType = values.getAsString(RawContacts.ACCOUNT_TYPE);
+ final String dataSet = values.getAsString(RawContacts.DATA_SET);
+ final AccountType type = accountTypes.getAccountType(accountType, dataSet);
+ if (hasChanges(state, type, excludedMimeTypes)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Processing to trim any empty {@link ValuesDelta} rows from the given
+ * {@link RawContactDelta}, assuming the given {@link AccountType} dictates
+ * the structure for various fields. This method ignores rows not described
+ * by the {@link AccountType}.
+ */
+ public static void trimEmpty(RawContactDelta state, AccountType accountType) {
+ boolean hasValues = false;
+
+ // Walk through entries for each well-known kind
+ for (DataKind kind : accountType.getSortedDataKinds()) {
+ final String mimeType = kind.mimeType;
+ final ArrayList<ValuesDelta> entries = state.getMimeEntries(mimeType);
+ if (entries == null) continue;
+
+ for (ValuesDelta entry : entries) {
+ // Skip any values that haven't been touched
+ final boolean touched = entry.isInsert() || entry.isUpdate();
+ if (!touched) {
+ hasValues = true;
+ continue;
+ }
+
+ // Test and remove this row if empty and it isn't a photo from google
+ final boolean isGoogleAccount = TextUtils.equals(GoogleAccountType.ACCOUNT_TYPE,
+ state.getValues().getAsString(RawContacts.ACCOUNT_TYPE));
+ final boolean isPhoto = TextUtils.equals(Photo.CONTENT_ITEM_TYPE, kind.mimeType);
+ final boolean isGooglePhoto = isPhoto && isGoogleAccount;
+
+ if (RawContactModifier.isEmpty(entry, kind) && !isGooglePhoto) {
+ if (DEBUG) {
+ Log.v(TAG, "Trimming: " + entry.toString());
+ }
+ entry.markDeleted();
+ } else if (!entry.isFromTemplate()) {
+ hasValues = true;
+ }
+ }
+ }
+ if (!hasValues) {
+ // Trim overall entity if no children exist
+ state.markDeleted();
+ }
+ }
+
+ private static boolean hasChanges(RawContactDelta state, AccountType accountType,
+ Set<String> excludedMimeTypes) {
+ for (DataKind kind : accountType.getSortedDataKinds()) {
+ final String mimeType = kind.mimeType;
+ if (excludedMimeTypes != null && excludedMimeTypes.contains(mimeType)) continue;
+ final ArrayList<ValuesDelta> entries = state.getMimeEntries(mimeType);
+ if (entries == null) continue;
+
+ for (ValuesDelta entry : entries) {
+ // An empty Insert must be ignored, because it won't save anything (an example
+ // is an empty name that stays empty)
+ final boolean isRealInsert = entry.isInsert() && !isEmpty(entry, kind);
+ if (isRealInsert || entry.isUpdate() || entry.isDelete()) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Test if the given {@link ValuesDelta} would be considered "empty" in
+ * terms of {@link DataKind#fieldList}.
+ */
+ public static boolean isEmpty(ValuesDelta values, DataKind kind) {
+ if (Photo.CONTENT_ITEM_TYPE.equals(kind.mimeType)) {
+ return values.isInsert() && values.getAsByteArray(Photo.PHOTO) == null;
+ }
+
+ // No defined fields mean this row is always empty
+ if (kind.fieldList == null) return true;
+
+ for (EditField field : kind.fieldList) {
+ // If any field has values, we're not empty
+ final String value = values.getAsString(field.column);
+ if (ContactsUtils.isGraphic(value)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Compares corresponding fields in values1 and values2. Only the fields
+ * declared by the DataKind are taken into consideration.
+ */
+ protected static boolean areEqual(ValuesDelta values1, ContentValues values2, DataKind kind) {
+ if (kind.fieldList == null) return false;
+
+ for (EditField field : kind.fieldList) {
+ final String value1 = values1.getAsString(field.column);
+ final String value2 = values2.getAsString(field.column);
+ if (!TextUtils.equals(value1, value2)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Parse the given {@link Bundle} into the given {@link RawContactDelta} state,
+ * assuming the extras defined through {@link Intents}.
+ */
+ public static void parseExtras(Context context, AccountType accountType, RawContactDelta state,
+ Bundle extras) {
+ if (extras == null || extras.size() == 0) {
+ // Bail early if no useful data
+ return;
+ }
+
+ parseStructuredNameExtra(context, accountType, state, extras);
+ parseStructuredPostalExtra(accountType, state, extras);
+
+ {
+ // Phone
+ final DataKind kind = accountType.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ parseExtras(state, kind, extras, Insert.PHONE_TYPE, Insert.PHONE, Phone.NUMBER);
+ parseExtras(state, kind, extras, Insert.SECONDARY_PHONE_TYPE, Insert.SECONDARY_PHONE,
+ Phone.NUMBER);
+ parseExtras(state, kind, extras, Insert.TERTIARY_PHONE_TYPE, Insert.TERTIARY_PHONE,
+ Phone.NUMBER);
+ }
+
+ {
+ // Email
+ final DataKind kind = accountType.getKindForMimetype(Email.CONTENT_ITEM_TYPE);
+ parseExtras(state, kind, extras, Insert.EMAIL_TYPE, Insert.EMAIL, Email.DATA);
+ parseExtras(state, kind, extras, Insert.SECONDARY_EMAIL_TYPE, Insert.SECONDARY_EMAIL,
+ Email.DATA);
+ parseExtras(state, kind, extras, Insert.TERTIARY_EMAIL_TYPE, Insert.TERTIARY_EMAIL,
+ Email.DATA);
+ }
+
+ {
+ // Im
+ final DataKind kind = accountType.getKindForMimetype(Im.CONTENT_ITEM_TYPE);
+ fixupLegacyImType(extras);
+ parseExtras(state, kind, extras, Insert.IM_PROTOCOL, Insert.IM_HANDLE, Im.DATA);
+ }
+
+ // Organization
+ final boolean hasOrg = extras.containsKey(Insert.COMPANY)
+ || extras.containsKey(Insert.JOB_TITLE);
+ final DataKind kindOrg = accountType.getKindForMimetype(Organization.CONTENT_ITEM_TYPE);
+ if (hasOrg && RawContactModifier.canInsert(state, kindOrg)) {
+ final ValuesDelta child = RawContactModifier.insertChild(state, kindOrg);
+
+ final String company = extras.getString(Insert.COMPANY);
+ if (ContactsUtils.isGraphic(company)) {
+ child.put(Organization.COMPANY, company);
+ }
+
+ final String title = extras.getString(Insert.JOB_TITLE);
+ if (ContactsUtils.isGraphic(title)) {
+ child.put(Organization.TITLE, title);
+ }
+ }
+
+ // Notes
+ final boolean hasNotes = extras.containsKey(Insert.NOTES);
+ final DataKind kindNotes = accountType.getKindForMimetype(Note.CONTENT_ITEM_TYPE);
+ if (hasNotes && RawContactModifier.canInsert(state, kindNotes)) {
+ final ValuesDelta child = RawContactModifier.insertChild(state, kindNotes);
+
+ final String notes = extras.getString(Insert.NOTES);
+ if (ContactsUtils.isGraphic(notes)) {
+ child.put(Note.NOTE, notes);
+ }
+ }
+
+ // Arbitrary additional data
+ ArrayList<ContentValues> values = extras.getParcelableArrayList(Insert.DATA);
+ if (values != null) {
+ parseValues(state, accountType, values);
+ }
+ }
+
+ private static void parseStructuredNameExtra(
+ Context context, AccountType accountType, RawContactDelta state, Bundle extras) {
+ // StructuredName
+ RawContactModifier.ensureKindExists(state, accountType, StructuredName.CONTENT_ITEM_TYPE);
+ final ValuesDelta child = state.getPrimaryEntry(StructuredName.CONTENT_ITEM_TYPE);
+
+ final String name = extras.getString(Insert.NAME);
+ if (ContactsUtils.isGraphic(name)) {
+ final DataKind kind = accountType.getKindForMimetype(StructuredName.CONTENT_ITEM_TYPE);
+ boolean supportsDisplayName = false;
+ if (kind.fieldList != null) {
+ for (EditField field : kind.fieldList) {
+ if (StructuredName.DISPLAY_NAME.equals(field.column)) {
+ supportsDisplayName = true;
+ break;
+ }
+ }
+ }
+
+ if (supportsDisplayName) {
+ child.put(StructuredName.DISPLAY_NAME, name);
+ } else {
+ Uri uri = ContactsContract.AUTHORITY_URI.buildUpon()
+ .appendPath("complete_name")
+ .appendQueryParameter(StructuredName.DISPLAY_NAME, name)
+ .build();
+ Cursor cursor = context.getContentResolver().query(uri,
+ new String[]{
+ StructuredName.PREFIX,
+ StructuredName.GIVEN_NAME,
+ StructuredName.MIDDLE_NAME,
+ StructuredName.FAMILY_NAME,
+ StructuredName.SUFFIX,
+ }, null, null, null);
+
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ child.put(StructuredName.PREFIX, cursor.getString(0));
+ child.put(StructuredName.GIVEN_NAME, cursor.getString(1));
+ child.put(StructuredName.MIDDLE_NAME, cursor.getString(2));
+ child.put(StructuredName.FAMILY_NAME, cursor.getString(3));
+ child.put(StructuredName.SUFFIX, cursor.getString(4));
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+ }
+
+ final String phoneticName = extras.getString(Insert.PHONETIC_NAME);
+ if (ContactsUtils.isGraphic(phoneticName)) {
+ StructuredNameDataItem dataItem = NameConverter.parsePhoneticName(phoneticName, null);
+ child.put(StructuredName.PHONETIC_FAMILY_NAME, dataItem.getPhoneticFamilyName());
+ child.put(StructuredName.PHONETIC_MIDDLE_NAME, dataItem.getPhoneticMiddleName());
+ child.put(StructuredName.PHONETIC_GIVEN_NAME, dataItem.getPhoneticGivenName());
+ }
+ }
+
+ private static void parseStructuredPostalExtra(
+ AccountType accountType, RawContactDelta state, Bundle extras) {
+ // StructuredPostal
+ final DataKind kind = accountType.getKindForMimetype(StructuredPostal.CONTENT_ITEM_TYPE);
+ final ValuesDelta child = parseExtras(state, kind, extras, Insert.POSTAL_TYPE,
+ Insert.POSTAL, StructuredPostal.FORMATTED_ADDRESS);
+ String address = child == null ? null
+ : child.getAsString(StructuredPostal.FORMATTED_ADDRESS);
+ if (!TextUtils.isEmpty(address)) {
+ boolean supportsFormatted = false;
+ if (kind.fieldList != null) {
+ for (EditField field : kind.fieldList) {
+ if (StructuredPostal.FORMATTED_ADDRESS.equals(field.column)) {
+ supportsFormatted = true;
+ break;
+ }
+ }
+ }
+
+ if (!supportsFormatted) {
+ child.put(StructuredPostal.STREET, address);
+ child.putNull(StructuredPostal.FORMATTED_ADDRESS);
+ }
+ }
+ }
+
+ private static void parseValues(
+ RawContactDelta state, AccountType accountType,
+ ArrayList<ContentValues> dataValueList) {
+ for (ContentValues values : dataValueList) {
+ String mimeType = values.getAsString(Data.MIMETYPE);
+ if (TextUtils.isEmpty(mimeType)) {
+ Log.e(TAG, "Mimetype is required. Ignoring: " + values);
+ continue;
+ }
+
+ // Won't override the contact name
+ if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ continue;
+ } else if (Phone.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ values.remove(PhoneDataItem.KEY_FORMATTED_PHONE_NUMBER);
+ final Integer type = values.getAsInteger(Phone.TYPE);
+ // If the provided phone number provides a custom phone type but not a label,
+ // replace it with mobile (by default) to avoid the "Enter custom label" from
+ // popping up immediately upon entering the ContactEditorFragment
+ if (type != null && type == Phone.TYPE_CUSTOM &&
+ TextUtils.isEmpty(values.getAsString(Phone.LABEL))) {
+ values.put(Phone.TYPE, Phone.TYPE_MOBILE);
+ }
+ }
+
+ DataKind kind = accountType.getKindForMimetype(mimeType);
+ if (kind == null) {
+ Log.e(TAG, "Mimetype not supported for account type "
+ + accountType.getAccountTypeAndDataSet() + ". Ignoring: " + values);
+ continue;
+ }
+
+ ValuesDelta entry = ValuesDelta.fromAfter(values);
+ if (isEmpty(entry, kind)) {
+ continue;
+ }
+
+ ArrayList<ValuesDelta> entries = state.getMimeEntries(mimeType);
+
+ if ((kind.typeOverallMax != 1) || GroupMembership.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ // Check for duplicates
+ boolean addEntry = true;
+ int count = 0;
+ if (entries != null && entries.size() > 0) {
+ for (ValuesDelta delta : entries) {
+ if (!delta.isDelete()) {
+ if (areEqual(delta, values, kind)) {
+ addEntry = false;
+ break;
+ }
+ count++;
+ }
+ }
+ }
+
+ if (kind.typeOverallMax != -1 && count >= kind.typeOverallMax) {
+ Log.e(TAG, "Mimetype allows at most " + kind.typeOverallMax
+ + " entries. Ignoring: " + values);
+ addEntry = false;
+ }
+
+ if (addEntry) {
+ addEntry = adjustType(entry, entries, kind);
+ }
+
+ if (addEntry) {
+ state.addEntry(entry);
+ }
+ } else {
+ // Non-list entries should not be overridden
+ boolean addEntry = true;
+ if (entries != null && entries.size() > 0) {
+ for (ValuesDelta delta : entries) {
+ if (!delta.isDelete() && !isEmpty(delta, kind)) {
+ addEntry = false;
+ break;
+ }
+ }
+ if (addEntry) {
+ for (ValuesDelta delta : entries) {
+ delta.markDeleted();
+ }
+ }
+ }
+
+ if (addEntry) {
+ addEntry = adjustType(entry, entries, kind);
+ }
+
+ if (addEntry) {
+ state.addEntry(entry);
+ } else if (Note.CONTENT_ITEM_TYPE.equals(mimeType)){
+ // Note is most likely to contain large amounts of text
+ // that we don't want to drop on the ground.
+ for (ValuesDelta delta : entries) {
+ if (!isEmpty(delta, kind)) {
+ delta.put(Note.NOTE, delta.getAsString(Note.NOTE) + "\n"
+ + values.getAsString(Note.NOTE));
+ break;
+ }
+ }
+ } else {
+ Log.e(TAG, "Will not override mimetype " + mimeType + ". Ignoring: "
+ + values);
+ }
+ }
+ }
+ }
+
+ /**
+ * Checks if the data kind allows addition of another entry (e.g. Exchange only
+ * supports two "work" phone numbers). If not, tries to switch to one of the
+ * unused types. If successful, returns true.
+ */
+ private static boolean adjustType(
+ ValuesDelta entry, ArrayList<ValuesDelta> entries, DataKind kind) {
+ if (kind.typeColumn == null || kind.typeList == null || kind.typeList.size() == 0) {
+ return true;
+ }
+
+ Integer typeInteger = entry.getAsInteger(kind.typeColumn);
+ int type = typeInteger != null ? typeInteger : kind.typeList.get(0).rawValue;
+
+ if (isTypeAllowed(type, entries, kind)) {
+ entry.put(kind.typeColumn, type);
+ return true;
+ }
+
+ // Specified type is not allowed - choose the first available type that is allowed
+ int size = kind.typeList.size();
+ for (int i = 0; i < size; i++) {
+ EditType editType = kind.typeList.get(i);
+ if (isTypeAllowed(editType.rawValue, entries, kind)) {
+ entry.put(kind.typeColumn, editType.rawValue);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks if a new entry of the specified type can be added to the raw
+ * contact. For example, Exchange only supports two "work" phone numbers, so
+ * addition of a third would not be allowed.
+ */
+ private static boolean isTypeAllowed(int type, ArrayList<ValuesDelta> entries, DataKind kind) {
+ int max = 0;
+ int size = kind.typeList.size();
+ for (int i = 0; i < size; i++) {
+ EditType editType = kind.typeList.get(i);
+ if (editType.rawValue == type) {
+ max = editType.specificMax;
+ break;
+ }
+ }
+
+ if (max == 0) {
+ // This type is not allowed at all
+ return false;
+ }
+
+ if (max == -1) {
+ // Unlimited instances of this type are allowed
+ return true;
+ }
+
+ return getEntryCountByType(entries, kind.typeColumn, type) < max;
+ }
+
+ /**
+ * Counts occurrences of the specified type in the supplied entry list.
+ *
+ * @return The count of occurrences of the type in the entry list. 0 if entries is
+ * {@literal null}
+ */
+ private static int getEntryCountByType(ArrayList<ValuesDelta> entries, String typeColumn,
+ int type) {
+ int count = 0;
+ if (entries != null) {
+ for (ValuesDelta entry : entries) {
+ Integer typeInteger = entry.getAsInteger(typeColumn);
+ if (typeInteger != null && typeInteger == type) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ /**
+ * Attempt to parse legacy {@link Insert#IM_PROTOCOL} values, replacing them
+ * with updated values.
+ */
+ @SuppressWarnings("deprecation")
+ private static void fixupLegacyImType(Bundle bundle) {
+ final String encodedString = bundle.getString(Insert.IM_PROTOCOL);
+ if (encodedString == null) return;
+
+ try {
+ final Object protocol = android.provider.Contacts.ContactMethods
+ .decodeImProtocol(encodedString);
+ if (protocol instanceof Integer) {
+ bundle.putInt(Insert.IM_PROTOCOL, (Integer)protocol);
+ } else {
+ bundle.putString(Insert.IM_PROTOCOL, (String)protocol);
+ }
+ } catch (IllegalArgumentException e) {
+ // Ignore exception when legacy parser fails
+ }
+ }
+
+ /**
+ * Parse a specific entry from the given {@link Bundle} and insert into the
+ * given {@link RawContactDelta}. Silently skips the insert when missing value
+ * or no valid {@link EditType} found.
+ *
+ * @param typeExtra {@link Bundle} key that holds the incoming
+ * {@link EditType#rawValue} value.
+ * @param valueExtra {@link Bundle} key that holds the incoming value.
+ * @param valueColumn Column to write value into {@link ValuesDelta}.
+ */
+ public static ValuesDelta parseExtras(RawContactDelta state, DataKind kind, Bundle extras,
+ String typeExtra, String valueExtra, String valueColumn) {
+ final CharSequence value = extras.getCharSequence(valueExtra);
+
+ // Bail early if account type doesn't handle this MIME type
+ if (kind == null) return null;
+
+ // Bail when can't insert type, or value missing
+ final boolean canInsert = RawContactModifier.canInsert(state, kind);
+ final boolean validValue = (value != null && TextUtils.isGraphic(value));
+ if (!validValue || !canInsert) return null;
+
+ // Find exact type when requested, otherwise best available type
+ final boolean hasType = extras.containsKey(typeExtra);
+ final int typeValue = extras.getInt(typeExtra, hasType ? BaseTypes.TYPE_CUSTOM
+ : Integer.MIN_VALUE);
+ final EditType editType = RawContactModifier.getBestValidType(state, kind, true, typeValue);
+
+ // Create data row and fill with value
+ final ValuesDelta child = RawContactModifier.insertChild(state, kind, editType);
+ child.put(valueColumn, value.toString());
+
+ if (editType != null && editType.customColumn != null) {
+ // Write down label when custom type picked
+ final String customType = extras.getString(typeExtra);
+ child.put(editType.customColumn, customType);
+ }
+
+ return child;
+ }
+
+ /**
+ * Generic mime types with type support (e.g. TYPE_HOME).
+ * Here, "type support" means if the data kind has CommonColumns#TYPE or not. Data kinds which
+ * have their own migrate methods aren't listed here.
+ */
+ private static final Set<String> sGenericMimeTypesWithTypeSupport = new HashSet<String>(
+ Arrays.asList(Phone.CONTENT_ITEM_TYPE,
+ Email.CONTENT_ITEM_TYPE,
+ Im.CONTENT_ITEM_TYPE,
+ Nickname.CONTENT_ITEM_TYPE,
+ Website.CONTENT_ITEM_TYPE,
+ Relation.CONTENT_ITEM_TYPE,
+ SipAddress.CONTENT_ITEM_TYPE));
+ private static final Set<String> sGenericMimeTypesWithoutTypeSupport = new HashSet<String>(
+ Arrays.asList(Organization.CONTENT_ITEM_TYPE,
+ Note.CONTENT_ITEM_TYPE,
+ Photo.CONTENT_ITEM_TYPE,
+ GroupMembership.CONTENT_ITEM_TYPE));
+ // CommonColumns.TYPE cannot be accessed as it is protected interface, so use
+ // Phone.TYPE instead.
+ private static final String COLUMN_FOR_TYPE = Phone.TYPE;
+ private static final String COLUMN_FOR_LABEL = Phone.LABEL;
+ private static final int TYPE_CUSTOM = Phone.TYPE_CUSTOM;
+
+ /**
+ * Migrates old RawContactDelta to newly created one with a new restriction supplied from
+ * newAccountType.
+ *
+ * This is only for account switch during account creation (which must be insert operation).
+ */
+ public static void migrateStateForNewContact(Context context,
+ RawContactDelta oldState, RawContactDelta newState,
+ AccountType oldAccountType, AccountType newAccountType) {
+ if (newAccountType == oldAccountType) {
+ // Just copying all data in oldState isn't enough, but we can still rely on a lot of
+ // shortcuts.
+ for (DataKind kind : newAccountType.getSortedDataKinds()) {
+ final String mimeType = kind.mimeType;
+ // The fields with short/long form capability must be treated properly.
+ if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ migrateStructuredName(context, oldState, newState, kind);
+ } else {
+ List<ValuesDelta> entryList = oldState.getMimeEntries(mimeType);
+ if (entryList != null && !entryList.isEmpty()) {
+ for (ValuesDelta entry : entryList) {
+ ContentValues values = entry.getAfter();
+ if (values != null) {
+ newState.addEntry(ValuesDelta.fromAfter(values));
+ }
+ }
+ }
+ }
+ }
+ } else {
+ // Migrate data supported by the new account type.
+ // All the other data inside oldState are silently dropped.
+ for (DataKind kind : newAccountType.getSortedDataKinds()) {
+ if (!kind.editable) continue;
+ final String mimeType = kind.mimeType;
+ if (DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME.equals(mimeType)
+ || DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME.equals(mimeType)) {
+ // Ignore pseudo data.
+ continue;
+ } else if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ migrateStructuredName(context, oldState, newState, kind);
+ } else if (StructuredPostal.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ migratePostal(oldState, newState, kind);
+ } else if (Event.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ migrateEvent(oldState, newState, kind, null /* default Year */);
+ } else if (sGenericMimeTypesWithoutTypeSupport.contains(mimeType)) {
+ migrateGenericWithoutTypeColumn(oldState, newState, kind);
+ } else if (sGenericMimeTypesWithTypeSupport.contains(mimeType)) {
+ migrateGenericWithTypeColumn(oldState, newState, kind);
+ } else {
+ throw new IllegalStateException("Unexpected editable mime-type: " + mimeType);
+ }
+ }
+ }
+ }
+
+ /**
+ * Checks {@link DataKind#isList} and {@link DataKind#typeOverallMax}, and restricts
+ * the number of entries (ValuesDelta) inside newState.
+ */
+ private static ArrayList<ValuesDelta> ensureEntryMaxSize(RawContactDelta newState,
+ DataKind kind, ArrayList<ValuesDelta> mimeEntries) {
+ if (mimeEntries == null) {
+ return null;
+ }
+
+ final int typeOverallMax = kind.typeOverallMax;
+ if (typeOverallMax >= 0 && (mimeEntries.size() > typeOverallMax)) {
+ ArrayList<ValuesDelta> newMimeEntries = new ArrayList<ValuesDelta>(typeOverallMax);
+ for (int i = 0; i < typeOverallMax; i++) {
+ newMimeEntries.add(mimeEntries.get(i));
+ }
+ mimeEntries = newMimeEntries;
+ }
+ return mimeEntries;
+ }
+
+ /** @hide Public only for testing. */
+ public static void migrateStructuredName(
+ Context context, RawContactDelta oldState, RawContactDelta newState,
+ DataKind newDataKind) {
+ final ContentValues values =
+ oldState.getPrimaryEntry(StructuredName.CONTENT_ITEM_TYPE).getAfter();
+ if (values == null) {
+ return;
+ }
+
+ boolean supportDisplayName = false;
+ boolean supportPhoneticFullName = false;
+ boolean supportPhoneticFamilyName = false;
+ boolean supportPhoneticMiddleName = false;
+ boolean supportPhoneticGivenName = false;
+ for (EditField editField : newDataKind.fieldList) {
+ if (StructuredName.DISPLAY_NAME.equals(editField.column)) {
+ supportDisplayName = true;
+ }
+ if (DataKind.PSEUDO_COLUMN_PHONETIC_NAME.equals(editField.column)) {
+ supportPhoneticFullName = true;
+ }
+ if (StructuredName.PHONETIC_FAMILY_NAME.equals(editField.column)) {
+ supportPhoneticFamilyName = true;
+ }
+ if (StructuredName.PHONETIC_MIDDLE_NAME.equals(editField.column)) {
+ supportPhoneticMiddleName = true;
+ }
+ if (StructuredName.PHONETIC_GIVEN_NAME.equals(editField.column)) {
+ supportPhoneticGivenName = true;
+ }
+ }
+
+ // DISPLAY_NAME <-> PREFIX, GIVEN_NAME, MIDDLE_NAME, FAMILY_NAME, SUFFIX
+ final String displayName = values.getAsString(StructuredName.DISPLAY_NAME);
+ if (!TextUtils.isEmpty(displayName)) {
+ if (!supportDisplayName) {
+ // Old data has a display name, while the new account doesn't allow it.
+ NameConverter.displayNameToStructuredName(context, displayName, values);
+
+ // We don't want to migrate unseen data which may confuse users after the creation.
+ values.remove(StructuredName.DISPLAY_NAME);
+ }
+ } else {
+ if (supportDisplayName) {
+ // Old data does not have display name, while the new account requires it.
+ values.put(StructuredName.DISPLAY_NAME,
+ NameConverter.structuredNameToDisplayName(context, values));
+ for (String field : NameConverter.STRUCTURED_NAME_FIELDS) {
+ values.remove(field);
+ }
+ }
+ }
+
+ // Phonetic (full) name <-> PHONETIC_FAMILY_NAME, PHONETIC_MIDDLE_NAME, PHONETIC_GIVEN_NAME
+ final String phoneticFullName = values.getAsString(DataKind.PSEUDO_COLUMN_PHONETIC_NAME);
+ if (!TextUtils.isEmpty(phoneticFullName)) {
+ if (!supportPhoneticFullName) {
+ // Old data has a phonetic (full) name, while the new account doesn't allow it.
+ final StructuredNameDataItem tmpItem =
+ NameConverter.parsePhoneticName(phoneticFullName, null);
+ values.remove(DataKind.PSEUDO_COLUMN_PHONETIC_NAME);
+ if (supportPhoneticFamilyName) {
+ values.put(StructuredName.PHONETIC_FAMILY_NAME,
+ tmpItem.getPhoneticFamilyName());
+ } else {
+ values.remove(StructuredName.PHONETIC_FAMILY_NAME);
+ }
+ if (supportPhoneticMiddleName) {
+ values.put(StructuredName.PHONETIC_MIDDLE_NAME,
+ tmpItem.getPhoneticMiddleName());
+ } else {
+ values.remove(StructuredName.PHONETIC_MIDDLE_NAME);
+ }
+ if (supportPhoneticGivenName) {
+ values.put(StructuredName.PHONETIC_GIVEN_NAME,
+ tmpItem.getPhoneticGivenName());
+ } else {
+ values.remove(StructuredName.PHONETIC_GIVEN_NAME);
+ }
+ }
+ } else {
+ if (supportPhoneticFullName) {
+ // Old data does not have a phonetic (full) name, while the new account requires it.
+ values.put(DataKind.PSEUDO_COLUMN_PHONETIC_NAME,
+ NameConverter.buildPhoneticName(
+ values.getAsString(StructuredName.PHONETIC_FAMILY_NAME),
+ values.getAsString(StructuredName.PHONETIC_MIDDLE_NAME),
+ values.getAsString(StructuredName.PHONETIC_GIVEN_NAME)));
+ }
+ if (!supportPhoneticFamilyName) {
+ values.remove(StructuredName.PHONETIC_FAMILY_NAME);
+ }
+ if (!supportPhoneticMiddleName) {
+ values.remove(StructuredName.PHONETIC_MIDDLE_NAME);
+ }
+ if (!supportPhoneticGivenName) {
+ values.remove(StructuredName.PHONETIC_GIVEN_NAME);
+ }
+ }
+
+ newState.addEntry(ValuesDelta.fromAfter(values));
+ }
+
+ /** @hide Public only for testing. */
+ public static void migratePostal(RawContactDelta oldState, RawContactDelta newState,
+ DataKind newDataKind) {
+ final ArrayList<ValuesDelta> mimeEntries = ensureEntryMaxSize(newState, newDataKind,
+ oldState.getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE));
+ if (mimeEntries == null || mimeEntries.isEmpty()) {
+ return;
+ }
+
+ boolean supportFormattedAddress = false;
+ boolean supportStreet = false;
+ final String firstColumn = newDataKind.fieldList.get(0).column;
+ for (EditField editField : newDataKind.fieldList) {
+ if (StructuredPostal.FORMATTED_ADDRESS.equals(editField.column)) {
+ supportFormattedAddress = true;
+ }
+ if (StructuredPostal.STREET.equals(editField.column)) {
+ supportStreet = true;
+ }
+ }
+
+ final Set<Integer> supportedTypes = new HashSet<Integer>();
+ if (newDataKind.typeList != null && !newDataKind.typeList.isEmpty()) {
+ for (EditType editType : newDataKind.typeList) {
+ supportedTypes.add(editType.rawValue);
+ }
+ }
+
+ for (ValuesDelta entry : mimeEntries) {
+ final ContentValues values = entry.getAfter();
+ if (values == null) {
+ continue;
+ }
+ final Integer oldType = values.getAsInteger(StructuredPostal.TYPE);
+ if (!supportedTypes.contains(oldType)) {
+ int defaultType;
+ if (newDataKind.defaultValues != null) {
+ defaultType = newDataKind.defaultValues.getAsInteger(StructuredPostal.TYPE);
+ } else {
+ defaultType = newDataKind.typeList.get(0).rawValue;
+ }
+ values.put(StructuredPostal.TYPE, defaultType);
+ if (oldType != null && oldType == StructuredPostal.TYPE_CUSTOM) {
+ values.remove(StructuredPostal.LABEL);
+ }
+ }
+
+ final String formattedAddress = values.getAsString(StructuredPostal.FORMATTED_ADDRESS);
+ if (!TextUtils.isEmpty(formattedAddress)) {
+ if (!supportFormattedAddress) {
+ // Old data has a formatted address, while the new account doesn't allow it.
+ values.remove(StructuredPostal.FORMATTED_ADDRESS);
+
+ // Unlike StructuredName we don't have logic to split it, so first
+ // try to use street field and. If the new account doesn't have one,
+ // then select first one anyway.
+ if (supportStreet) {
+ values.put(StructuredPostal.STREET, formattedAddress);
+ } else {
+ values.put(firstColumn, formattedAddress);
+ }
+ }
+ } else {
+ if (supportFormattedAddress) {
+ // Old data does not have formatted address, while the new account requires it.
+ // Unlike StructuredName we don't have logic to join multiple address values.
+ // Use poor join heuristics for now.
+ String[] structuredData;
+ final boolean useJapaneseOrder =
+ Locale.JAPANESE.getLanguage().equals(Locale.getDefault().getLanguage());
+ if (useJapaneseOrder) {
+ structuredData = new String[] {
+ values.getAsString(StructuredPostal.COUNTRY),
+ values.getAsString(StructuredPostal.POSTCODE),
+ values.getAsString(StructuredPostal.REGION),
+ values.getAsString(StructuredPostal.CITY),
+ values.getAsString(StructuredPostal.NEIGHBORHOOD),
+ values.getAsString(StructuredPostal.STREET),
+ values.getAsString(StructuredPostal.POBOX) };
+ } else {
+ structuredData = new String[] {
+ values.getAsString(StructuredPostal.POBOX),
+ values.getAsString(StructuredPostal.STREET),
+ values.getAsString(StructuredPostal.NEIGHBORHOOD),
+ values.getAsString(StructuredPostal.CITY),
+ values.getAsString(StructuredPostal.REGION),
+ values.getAsString(StructuredPostal.POSTCODE),
+ values.getAsString(StructuredPostal.COUNTRY) };
+ }
+ final StringBuilder builder = new StringBuilder();
+ for (String elem : structuredData) {
+ if (!TextUtils.isEmpty(elem)) {
+ builder.append(elem + "\n");
+ }
+ }
+ values.put(StructuredPostal.FORMATTED_ADDRESS, builder.toString());
+
+ values.remove(StructuredPostal.POBOX);
+ values.remove(StructuredPostal.STREET);
+ values.remove(StructuredPostal.NEIGHBORHOOD);
+ values.remove(StructuredPostal.CITY);
+ values.remove(StructuredPostal.REGION);
+ values.remove(StructuredPostal.POSTCODE);
+ values.remove(StructuredPostal.COUNTRY);
+ }
+ }
+
+ newState.addEntry(ValuesDelta.fromAfter(values));
+ }
+ }
+
+ /** @hide Public only for testing. */
+ public static void migrateEvent(RawContactDelta oldState, RawContactDelta newState,
+ DataKind newDataKind, Integer defaultYear) {
+ final ArrayList<ValuesDelta> mimeEntries = ensureEntryMaxSize(newState, newDataKind,
+ oldState.getMimeEntries(Event.CONTENT_ITEM_TYPE));
+ if (mimeEntries == null || mimeEntries.isEmpty()) {
+ return;
+ }
+
+ final SparseArray<EventEditType> allowedTypes = new SparseArray<EventEditType>();
+ for (EditType editType : newDataKind.typeList) {
+ allowedTypes.put(editType.rawValue, (EventEditType) editType);
+ }
+ for (ValuesDelta entry : mimeEntries) {
+ final ContentValues values = entry.getAfter();
+ if (values == null) {
+ continue;
+ }
+ final String dateString = values.getAsString(Event.START_DATE);
+ final Integer type = values.getAsInteger(Event.TYPE);
+ if (type != null && (allowedTypes.indexOfKey(type) >= 0)
+ && !TextUtils.isEmpty(dateString)) {
+ EventEditType suitableType = allowedTypes.get(type);
+
+ final ParsePosition position = new ParsePosition(0);
+ boolean yearOptional = false;
+ Date date = CommonDateUtils.DATE_AND_TIME_FORMAT.parse(dateString, position);
+ if (date == null) {
+ yearOptional = true;
+ date = CommonDateUtils.NO_YEAR_DATE_FORMAT.parse(dateString, position);
+ }
+ if (date != null) {
+ if (yearOptional && !suitableType.isYearOptional()) {
+ // The new EditType doesn't allow optional year. Supply default.
+ final Calendar calendar = Calendar.getInstance(DateUtils.UTC_TIMEZONE,
+ Locale.US);
+ if (defaultYear == null) {
+ defaultYear = calendar.get(Calendar.YEAR);
+ }
+ calendar.setTime(date);
+ final int month = calendar.get(Calendar.MONTH);
+ final int day = calendar.get(Calendar.DAY_OF_MONTH);
+ // Exchange requires 8:00 for birthdays
+ calendar.set(defaultYear, month, day,
+ CommonDateUtils.DEFAULT_HOUR, 0, 0);
+ values.put(Event.START_DATE,
+ CommonDateUtils.FULL_DATE_FORMAT.format(calendar.getTime()));
+ }
+ }
+ newState.addEntry(ValuesDelta.fromAfter(values));
+ } else {
+ // Just drop it.
+ }
+ }
+ }
+
+ /** @hide Public only for testing. */
+ public static void migrateGenericWithoutTypeColumn(
+ RawContactDelta oldState, RawContactDelta newState, DataKind newDataKind) {
+ final ArrayList<ValuesDelta> mimeEntries = ensureEntryMaxSize(newState, newDataKind,
+ oldState.getMimeEntries(newDataKind.mimeType));
+ if (mimeEntries == null || mimeEntries.isEmpty()) {
+ return;
+ }
+
+ for (ValuesDelta entry : mimeEntries) {
+ ContentValues values = entry.getAfter();
+ if (values != null) {
+ newState.addEntry(ValuesDelta.fromAfter(values));
+ }
+ }
+ }
+
+ /** @hide Public only for testing. */
+ public static void migrateGenericWithTypeColumn(
+ RawContactDelta oldState, RawContactDelta newState, DataKind newDataKind) {
+ final ArrayList<ValuesDelta> mimeEntries = oldState.getMimeEntries(newDataKind.mimeType);
+ if (mimeEntries == null || mimeEntries.isEmpty()) {
+ return;
+ }
+
+ // Note that type specified with the old account may be invalid with the new account, while
+ // we want to preserve its data as much as possible. e.g. if a user typed a phone number
+ // with a type which is valid with an old account but not with a new account, the user
+ // probably wants to have the number with default type, rather than seeing complete data
+ // loss.
+ //
+ // Specifically, this method works as follows:
+ // 1. detect defaultType
+ // 2. prepare constants & variables for iteration
+ // 3. iterate over mimeEntries:
+ // 3.1 stop iteration if total number of mimeEntries reached typeOverallMax specified in
+ // DataKind
+ // 3.2 replace unallowed types with defaultType
+ // 3.3 check if the number of entries is below specificMax specified in AccountType
+
+ // Here, defaultType can be supplied in two ways
+ // - via kind.defaultValues
+ // - via kind.typeList.get(0).rawValue
+ Integer defaultType = null;
+ if (newDataKind.defaultValues != null) {
+ defaultType = newDataKind.defaultValues.getAsInteger(COLUMN_FOR_TYPE);
+ }
+ final Set<Integer> allowedTypes = new HashSet<Integer>();
+ // key: type, value: the number of entries allowed for the type (specificMax)
+ final SparseIntArray typeSpecificMaxMap = new SparseIntArray();
+ if (defaultType != null) {
+ allowedTypes.add(defaultType);
+ typeSpecificMaxMap.put(defaultType, -1);
+ }
+ // Note: typeList may be used in different purposes when defaultValues are specified.
+ // Especially in IM, typeList contains available protocols (e.g. PROTOCOL_GOOGLE_TALK)
+ // instead of "types" which we want to treate here (e.g. TYPE_HOME). So we don't add
+ // anything other than defaultType into allowedTypes and typeSpecificMapMax.
+ if (!Im.CONTENT_ITEM_TYPE.equals(newDataKind.mimeType) &&
+ newDataKind.typeList != null && !newDataKind.typeList.isEmpty()) {
+ for (EditType editType : newDataKind.typeList) {
+ allowedTypes.add(editType.rawValue);
+ typeSpecificMaxMap.put(editType.rawValue, editType.specificMax);
+ }
+ if (defaultType == null) {
+ defaultType = newDataKind.typeList.get(0).rawValue;
+ }
+ }
+
+ if (defaultType == null) {
+ Log.w(TAG, "Default type isn't available for mimetype " + newDataKind.mimeType);
+ }
+
+ final int typeOverallMax = newDataKind.typeOverallMax;
+
+ // key: type, value: the number of current entries.
+ final SparseIntArray currentEntryCount = new SparseIntArray();
+ int totalCount = 0;
+
+ for (ValuesDelta entry : mimeEntries) {
+ if (typeOverallMax != -1 && totalCount >= typeOverallMax) {
+ break;
+ }
+
+ final ContentValues values = entry.getAfter();
+ if (values == null) {
+ continue;
+ }
+
+ final Integer oldType = entry.getAsInteger(COLUMN_FOR_TYPE);
+ final Integer typeForNewAccount;
+ if (!allowedTypes.contains(oldType)) {
+ // The new account doesn't support the type.
+ if (defaultType != null) {
+ typeForNewAccount = defaultType.intValue();
+ values.put(COLUMN_FOR_TYPE, defaultType.intValue());
+ if (oldType != null && oldType == TYPE_CUSTOM) {
+ values.remove(COLUMN_FOR_LABEL);
+ }
+ } else {
+ typeForNewAccount = null;
+ values.remove(COLUMN_FOR_TYPE);
+ }
+ } else {
+ typeForNewAccount = oldType;
+ }
+ if (typeForNewAccount != null) {
+ final int specificMax = typeSpecificMaxMap.get(typeForNewAccount, 0);
+ if (specificMax >= 0) {
+ final int currentCount = currentEntryCount.get(typeForNewAccount, 0);
+ if (currentCount >= specificMax) {
+ continue;
+ }
+ currentEntryCount.put(typeForNewAccount, currentCount + 1);
+ }
+ }
+ newState.addEntry(ValuesDelta.fromAfter(values));
+ totalCount++;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/model/ValuesDelta.java b/src/com/android/contacts/common/model/ValuesDelta.java
new file mode 100644
index 0000000..9023709
--- /dev/null
+++ b/src/com/android/contacts/common/model/ValuesDelta.java
@@ -0,0 +1,597 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.ContentProviderOperation;
+import android.content.ContentValues;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.provider.BaseColumns;
+import android.provider.ContactsContract;
+
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.model.BuilderWrapper;
+import com.android.contacts.common.testing.NeededForTesting;
+import com.google.common.collect.Sets;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Type of {@link android.content.ContentValues} that maintains both an original state and a
+ * modified version of that state. This allows us to build insert, update,
+ * or delete operations based on a "before" {@link Entity} snapshot.
+ */
+public class ValuesDelta implements Parcelable {
+ protected ContentValues mBefore;
+ protected ContentValues mAfter;
+ protected String mIdColumn = BaseColumns._ID;
+ private boolean mFromTemplate;
+
+ /**
+ * Next value to assign to {@link #mIdColumn} when building an insert
+ * operation through {@link #fromAfter(android.content.ContentValues)}. This is used so
+ * we can concretely reference this {@link ValuesDelta} before it has
+ * been persisted.
+ */
+ protected static int sNextInsertId = -1;
+
+ protected ValuesDelta() {
+ }
+
+ /**
+ * Create {@link ValuesDelta}, using the given object as the
+ * "before" state, usually from an {@link Entity}.
+ */
+ public static ValuesDelta fromBefore(ContentValues before) {
+ final ValuesDelta entry = new ValuesDelta();
+ entry.mBefore = before;
+ entry.mAfter = new ContentValues();
+ return entry;
+ }
+
+ /**
+ * Create {@link ValuesDelta}, using the given object as the "after"
+ * state, usually when we are inserting a row instead of updating.
+ */
+ public static ValuesDelta fromAfter(ContentValues after) {
+ final ValuesDelta entry = new ValuesDelta();
+ entry.mBefore = null;
+ entry.mAfter = after;
+
+ // Assign temporary id which is dropped before insert.
+ entry.mAfter.put(entry.mIdColumn, sNextInsertId--);
+ return entry;
+ }
+
+ @NeededForTesting
+ public ContentValues getAfter() {
+ return mAfter;
+ }
+
+ public boolean containsKey(String key) {
+ return ((mAfter != null && mAfter.containsKey(key)) ||
+ (mBefore != null && mBefore.containsKey(key)));
+ }
+
+ public String getAsString(String key) {
+ if (mAfter != null && mAfter.containsKey(key)) {
+ return mAfter.getAsString(key);
+ } else if (mBefore != null && mBefore.containsKey(key)) {
+ return mBefore.getAsString(key);
+ } else {
+ return null;
+ }
+ }
+
+ public byte[] getAsByteArray(String key) {
+ if (mAfter != null && mAfter.containsKey(key)) {
+ return mAfter.getAsByteArray(key);
+ } else if (mBefore != null && mBefore.containsKey(key)) {
+ return mBefore.getAsByteArray(key);
+ } else {
+ return null;
+ }
+ }
+
+ public Long getAsLong(String key) {
+ if (mAfter != null && mAfter.containsKey(key)) {
+ return mAfter.getAsLong(key);
+ } else if (mBefore != null && mBefore.containsKey(key)) {
+ return mBefore.getAsLong(key);
+ } else {
+ return null;
+ }
+ }
+
+ public Integer getAsInteger(String key) {
+ return getAsInteger(key, null);
+ }
+
+ public Integer getAsInteger(String key, Integer defaultValue) {
+ if (mAfter != null && mAfter.containsKey(key)) {
+ return mAfter.getAsInteger(key);
+ } else if (mBefore != null && mBefore.containsKey(key)) {
+ return mBefore.getAsInteger(key);
+ } else {
+ return defaultValue;
+ }
+ }
+
+ public boolean isChanged(String key) {
+ if (mAfter == null || !mAfter.containsKey(key)) {
+ return false;
+ }
+
+ Object newValue = mAfter.get(key);
+ Object oldValue = mBefore.get(key);
+
+ if (oldValue == null) {
+ return newValue != null;
+ }
+
+ return !oldValue.equals(newValue);
+ }
+
+ public String getMimetype() {
+ return getAsString(ContactsContract.Data.MIMETYPE);
+ }
+
+ public Long getId() {
+ return getAsLong(mIdColumn);
+ }
+
+ public void setIdColumn(String idColumn) {
+ mIdColumn = idColumn;
+ }
+
+ public boolean isPrimary() {
+ final Long isPrimary = getAsLong(ContactsContract.Data.IS_PRIMARY);
+ return isPrimary == null ? false : isPrimary != 0;
+ }
+
+ public void setFromTemplate(boolean isFromTemplate) {
+ mFromTemplate = isFromTemplate;
+ }
+
+ public boolean isFromTemplate() {
+ return mFromTemplate;
+ }
+
+ public boolean isSuperPrimary() {
+ final Long isSuperPrimary = getAsLong(ContactsContract.Data.IS_SUPER_PRIMARY);
+ return isSuperPrimary == null ? false : isSuperPrimary != 0;
+ }
+
+ public boolean beforeExists() {
+ return (mBefore != null && mBefore.containsKey(mIdColumn));
+ }
+
+ /**
+ * When "after" is present, then visible
+ */
+ public boolean isVisible() {
+ return (mAfter != null);
+ }
+
+ /**
+ * When "after" is wiped, action is "delete"
+ */
+ public boolean isDelete() {
+ return beforeExists() && (mAfter == null);
+ }
+
+ /**
+ * When no "before" or "after", is transient
+ */
+ public boolean isTransient() {
+ return (mBefore == null) && (mAfter == null);
+ }
+
+ /**
+ * When "after" has some changes, action is "update"
+ */
+ public boolean isUpdate() {
+ if (!beforeExists() || mAfter == null || mAfter.size() == 0) {
+ return false;
+ }
+ for (String key : mAfter.keySet()) {
+ Object newValue = mAfter.get(key);
+ Object oldValue = mBefore.get(key);
+ if (oldValue == null) {
+ if (newValue != null) {
+ return true;
+ }
+ } else if (!oldValue.equals(newValue)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * When "after" has no changes, action is no-op
+ */
+ public boolean isNoop() {
+ return beforeExists() && (mAfter != null && mAfter.size() == 0);
+ }
+
+ /**
+ * When no "before" id, and has "after", action is "insert"
+ */
+ public boolean isInsert() {
+ return !beforeExists() && (mAfter != null);
+ }
+
+ public void markDeleted() {
+ mAfter = null;
+ }
+
+ /**
+ * Ensure that our internal structure is ready for storing updates.
+ */
+ private void ensureUpdate() {
+ if (mAfter == null) {
+ mAfter = new ContentValues();
+ }
+ }
+
+ public void put(String key, String value) {
+ ensureUpdate();
+ mAfter.put(key, value);
+ }
+
+ public void put(String key, byte[] value) {
+ ensureUpdate();
+ mAfter.put(key, value);
+ }
+
+ public void put(String key, int value) {
+ ensureUpdate();
+ mAfter.put(key, value);
+ }
+
+ public void put(String key, long value) {
+ ensureUpdate();
+ mAfter.put(key, value);
+ }
+
+ public void putNull(String key) {
+ ensureUpdate();
+ mAfter.putNull(key);
+ }
+
+ public void copyStringFrom(ValuesDelta from, String key) {
+ ensureUpdate();
+ if (containsKey(key) || from.containsKey(key)) {
+ put(key, from.getAsString(key));
+ }
+ }
+
+ /**
+ * Return set of all keys defined through this object.
+ */
+ public Set<String> keySet() {
+ final HashSet<String> keys = Sets.newHashSet();
+
+ if (mBefore != null) {
+ for (Map.Entry<String, Object> entry : mBefore.valueSet()) {
+ keys.add(entry.getKey());
+ }
+ }
+
+ if (mAfter != null) {
+ for (Map.Entry<String, Object> entry : mAfter.valueSet()) {
+ keys.add(entry.getKey());
+ }
+ }
+
+ return keys;
+ }
+
+ /**
+ * Return complete set of "before" and "after" values mixed together,
+ * giving full state regardless of edits.
+ */
+ public ContentValues getCompleteValues() {
+ final ContentValues values = new ContentValues();
+ if (mBefore != null) {
+ values.putAll(mBefore);
+ }
+ if (mAfter != null) {
+ values.putAll(mAfter);
+ }
+ if (values.containsKey(ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID)) {
+ // Clear to avoid double-definitions, and prefer rows
+ values.remove(ContactsContract.CommonDataKinds.GroupMembership.GROUP_SOURCE_ID);
+ }
+
+ return values;
+ }
+
+ /**
+ * Merge the "after" values from the given {@link ValuesDelta},
+ * discarding any existing "after" state. This is typically used when
+ * re-parenting changes onto an updated {@link Entity}.
+ */
+ public static ValuesDelta mergeAfter(ValuesDelta local, ValuesDelta remote) {
+ // Bail early if trying to merge delete with missing local
+ if (local == null && (remote.isDelete() || remote.isTransient())) return null;
+
+ // Create local version if none exists yet
+ if (local == null) local = new ValuesDelta();
+
+ if (!local.beforeExists()) {
+ // Any "before" record is missing, so take all values as "insert"
+ local.mAfter = remote.getCompleteValues();
+ } else {
+ // Existing "update" with only "after" values
+ local.mAfter = remote.mAfter;
+ }
+
+ return local;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (object instanceof ValuesDelta) {
+ // Only exactly equal with both are identical subsets
+ final ValuesDelta other = (ValuesDelta)object;
+ return this.subsetEquals(other) && other.subsetEquals(this);
+ }
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder();
+ toString(builder);
+ return builder.toString();
+ }
+
+ /**
+ * Helper for building string representation, leveraging the given
+ * {@link StringBuilder} to minimize allocations.
+ */
+ public void toString(StringBuilder builder) {
+ builder.append("{ ");
+ builder.append("IdColumn=");
+ builder.append(mIdColumn);
+ builder.append(", FromTemplate=");
+ builder.append(mFromTemplate);
+ builder.append(", ");
+ for (String key : this.keySet()) {
+ builder.append(key);
+ builder.append("=");
+ builder.append(this.getAsString(key));
+ builder.append(", ");
+ }
+ builder.append("}");
+ }
+
+ /**
+ * Check if the given {@link ValuesDelta} is both a subset of this
+ * object, and any defined keys have equal values.
+ */
+ public boolean subsetEquals(ValuesDelta other) {
+ for (String key : this.keySet()) {
+ final String ourValue = this.getAsString(key);
+ final String theirValue = other.getAsString(key);
+ if (ourValue == null) {
+ // If they have value when we're null, no match
+ if (theirValue != null) return false;
+ } else {
+ // If both values defined and aren't equal, no match
+ if (!ourValue.equals(theirValue)) return false;
+ }
+ }
+ // All values compared and matched
+ return true;
+ }
+
+ /**
+ * Build a {@link android.content.ContentProviderOperation} that will transform our
+ * "before" state into our "after" state, using insert, update, or
+ * delete as needed.
+ */
+ public ContentProviderOperation.Builder buildDiff(Uri targetUri) {
+ return buildDiffHelper(targetUri);
+ }
+
+ /**
+ * For compatibility purpose.
+ */
+ public BuilderWrapper buildDiffWrapper(Uri targetUri) {
+ final ContentProviderOperation.Builder builder = buildDiffHelper(targetUri);
+ BuilderWrapper bw = null;
+ if (isInsert()) {
+ bw = new BuilderWrapper(builder, CompatUtils.TYPE_INSERT);
+ } else if (isDelete()) {
+ bw = new BuilderWrapper(builder, CompatUtils.TYPE_DELETE);
+ } else if (isUpdate()) {
+ bw = new BuilderWrapper(builder, CompatUtils.TYPE_UPDATE);
+ }
+ return bw;
+ }
+
+ private ContentProviderOperation.Builder buildDiffHelper(Uri targetUri) {
+ ContentProviderOperation.Builder builder = null;
+ if (isInsert()) {
+ // Changed values are "insert" back-referenced to Contact
+ mAfter.remove(mIdColumn);
+ builder = ContentProviderOperation.newInsert(targetUri);
+ builder.withValues(mAfter);
+ } else if (isDelete()) {
+ // When marked for deletion and "before" exists, then "delete"
+ builder = ContentProviderOperation.newDelete(targetUri);
+ builder.withSelection(mIdColumn + "=" + getId(), null);
+ } else if (isUpdate()) {
+ // When has changes and "before" exists, then "update"
+ builder = ContentProviderOperation.newUpdate(targetUri);
+ builder.withSelection(mIdColumn + "=" + getId(), null);
+ builder.withValues(mAfter);
+ }
+ return builder;
+ }
+
+ /** {@inheritDoc} */
+ public int describeContents() {
+ // Nothing special about this parcel
+ return 0;
+ }
+
+ /** {@inheritDoc} */
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeParcelable(mBefore, flags);
+ dest.writeParcelable(mAfter, flags);
+ dest.writeString(mIdColumn);
+ }
+
+ public void readFromParcel(Parcel source) {
+ final ClassLoader loader = getClass().getClassLoader();
+ mBefore = source.<ContentValues> readParcelable(loader);
+ mAfter = source.<ContentValues> readParcelable(loader);
+ mIdColumn = source.readString();
+ }
+
+ public static final Creator<ValuesDelta> CREATOR = new Creator<ValuesDelta>() {
+ public ValuesDelta createFromParcel(Parcel in) {
+ final ValuesDelta values = new ValuesDelta();
+ values.readFromParcel(in);
+ return values;
+ }
+
+ public ValuesDelta[] newArray(int size) {
+ return new ValuesDelta[size];
+ }
+ };
+
+ public void setGroupRowId(long groupId) {
+ put(ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID, groupId);
+ }
+
+ public Long getGroupRowId() {
+ return getAsLong(ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID);
+ }
+
+ public void setPhoto(byte[] value) {
+ put(ContactsContract.CommonDataKinds.Photo.PHOTO, value);
+ }
+
+ public byte[] getPhoto() {
+ return getAsByteArray(ContactsContract.CommonDataKinds.Photo.PHOTO);
+ }
+
+ public void setSuperPrimary(boolean val) {
+ if (val) {
+ put(ContactsContract.Data.IS_SUPER_PRIMARY, 1);
+ } else {
+ put(ContactsContract.Data.IS_SUPER_PRIMARY, 0);
+ }
+ }
+
+ public void setPhoneticFamilyName(String value) {
+ put(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_FAMILY_NAME, value);
+ }
+
+ public void setPhoneticMiddleName(String value) {
+ put(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_MIDDLE_NAME, value);
+ }
+
+ public void setPhoneticGivenName(String value) {
+ put(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_GIVEN_NAME, value);
+ }
+
+ public String getPhoneticFamilyName() {
+ return getAsString(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_FAMILY_NAME);
+ }
+
+ public String getPhoneticMiddleName() {
+ return getAsString(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_MIDDLE_NAME);
+ }
+
+ public String getPhoneticGivenName() {
+ return getAsString(ContactsContract.CommonDataKinds.StructuredName.PHONETIC_GIVEN_NAME);
+ }
+
+ public String getDisplayName() {
+ return getAsString(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME);
+ }
+
+ public void setDisplayName(String name) {
+ if (name == null) {
+ putNull(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME);
+ } else {
+ put(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, name);
+ }
+ }
+
+ public void copyStructuredNameFieldsFrom(ValuesDelta name) {
+ copyStringFrom(name, ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME);
+
+ copyStringFrom(name, ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME);
+ copyStringFrom(name, ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME);
+ copyStringFrom(name, ContactsContract.CommonDataKinds.StructuredName.PREFIX);
+ copyStringFrom(name, ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME);
+ copyStringFrom(name, ContactsContract.CommonDataKinds.StructuredName.SUFFIX);
+
+ copyStringFrom(name, ContactsContract.CommonDataKinds.StructuredName.PHONETIC_GIVEN_NAME);
+ copyStringFrom(name, ContactsContract.CommonDataKinds.StructuredName.PHONETIC_MIDDLE_NAME);
+ copyStringFrom(name, ContactsContract.CommonDataKinds.StructuredName.PHONETIC_FAMILY_NAME);
+
+ copyStringFrom(name, ContactsContract.CommonDataKinds.StructuredName.FULL_NAME_STYLE);
+ copyStringFrom(name, ContactsContract.Data.DATA11);
+ }
+
+ public String getPhoneNumber() {
+ return getAsString(ContactsContract.CommonDataKinds.Phone.NUMBER);
+ }
+
+ public String getPhoneNormalizedNumber() {
+ return getAsString(ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER);
+ }
+
+ public boolean hasPhoneType() {
+ return getPhoneType() != null;
+ }
+
+ public Integer getPhoneType() {
+ return getAsInteger(ContactsContract.CommonDataKinds.Phone.TYPE);
+ }
+
+ public String getPhoneLabel() {
+ return getAsString(ContactsContract.CommonDataKinds.Phone.LABEL);
+ }
+
+ public String getEmailData() {
+ return getAsString(ContactsContract.CommonDataKinds.Email.DATA);
+ }
+
+ public boolean hasEmailType() {
+ return getEmailType() != null;
+ }
+
+ public Integer getEmailType() {
+ return getAsInteger(ContactsContract.CommonDataKinds.Email.TYPE);
+ }
+
+ public String getEmailLabel() {
+ return getAsString(ContactsContract.CommonDataKinds.Email.LABEL);
+ }
+}
diff --git a/src/com/android/contacts/common/model/account/AccountType.java b/src/com/android/contacts/common/model/account/AccountType.java
new file mode 100644
index 0000000..8b50d79
--- /dev/null
+++ b/src/com/android/contacts/common/model/account/AccountType.java
@@ -0,0 +1,526 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.account;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.graphics.drawable.Drawable;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.RawContacts;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.dataitem.DataKind;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+
+import java.text.Collator;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+
+/**
+ * Internal structure that represents constraints and styles for a specific data
+ * source, such as the various data types they support, including details on how
+ * those types should be rendered and edited.
+ * <p>
+ * In the future this may be inflated from XML defined by a data source.
+ */
+public abstract class AccountType {
+ private static final String TAG = "AccountType";
+
+ /**
+ * The {@link RawContacts#ACCOUNT_TYPE} these constraints apply to.
+ */
+ public String accountType = null;
+
+ /**
+ * The {@link RawContacts#DATA_SET} these constraints apply to.
+ */
+ public String dataSet = null;
+
+ /**
+ * Package that resources should be loaded from. Will be null for embedded types, in which
+ * case resources are stored in this package itself.
+ *
+ * TODO Clean up {@link #resourcePackageName}, {@link #syncAdapterPackageName} and
+ * {@link #getViewContactNotifyServicePackageName()}.
+ *
+ * There's the following invariants:
+ * - {@link #syncAdapterPackageName} is always set to the actual sync adapter package name.
+ * - {@link #resourcePackageName} too is set to the same value, unless {@link #isEmbedded()},
+ * in which case it'll be null.
+ * There's an unfortunate exception of {@link FallbackAccountType}. Even though it
+ * {@link #isEmbedded()}, but we set non-null to {@link #resourcePackageName} for unit tests.
+ */
+ public String resourcePackageName;
+ /**
+ * The package name for the authenticator (for the embedded types, i.e. Google and Exchange)
+ * or the sync adapter (for external type, including extensions).
+ */
+ public String syncAdapterPackageName;
+
+ public int titleRes;
+ public int iconRes;
+
+ /**
+ * Set of {@link DataKind} supported by this source.
+ */
+ private ArrayList<DataKind> mKinds = Lists.newArrayList();
+
+ /**
+ * Lookup map of {@link #mKinds} on {@link DataKind#mimeType}.
+ */
+ private HashMap<String, DataKind> mMimeKinds = Maps.newHashMap();
+
+ protected boolean mIsInitialized;
+
+ protected static class DefinitionException extends Exception {
+ public DefinitionException(String message) {
+ super(message);
+ }
+
+ public DefinitionException(String message, Exception inner) {
+ super(message, inner);
+ }
+ }
+
+ /**
+ * Whether this account type was able to be fully initialized. This may be false if
+ * (for example) the package name associated with the account type could not be found.
+ */
+ public final boolean isInitialized() {
+ return mIsInitialized;
+ }
+
+ /**
+ * @return Whether this type is an "embedded" type. i.e. any of {@link FallbackAccountType},
+ * {@link GoogleAccountType} or {@link ExternalAccountType}.
+ *
+ * If an embedded type cannot be initialized (i.e. if {@link #isInitialized()} returns
+ * {@code false}) it's considered critical, and the application will crash. On the other
+ * hand if it's not an embedded type, we just skip loading the type.
+ */
+ public boolean isEmbedded() {
+ return true;
+ }
+
+ public boolean isExtension() {
+ return false;
+ }
+
+ /**
+ * @return True if contacts can be created and edited using this app. If false,
+ * there could still be an external editor as provided by
+ * {@link #getEditContactActivityClassName()} or {@link #getCreateContactActivityClassName()}
+ */
+ public abstract boolean areContactsWritable();
+
+ /**
+ * Returns an optional custom edit activity.
+ *
+ * Only makes sense for non-embedded account types.
+ * The activity class should reside in the sync adapter package as determined by
+ * {@link #syncAdapterPackageName}.
+ */
+ public String getEditContactActivityClassName() {
+ return null;
+ }
+
+ /**
+ * Returns an optional custom new contact activity.
+ *
+ * Only makes sense for non-embedded account types.
+ * The activity class should reside in the sync adapter package as determined by
+ * {@link #syncAdapterPackageName}.
+ */
+ public String getCreateContactActivityClassName() {
+ return null;
+ }
+
+ /**
+ * Returns an optional custom invite contact activity.
+ *
+ * Only makes sense for non-embedded account types.
+ * The activity class should reside in the sync adapter package as determined by
+ * {@link #syncAdapterPackageName}.
+ */
+ public String getInviteContactActivityClassName() {
+ return null;
+ }
+
+ /**
+ * Returns an optional service that can be launched whenever a contact is being looked at.
+ * This allows the sync adapter to provide more up-to-date information.
+ *
+ * The service class should reside in the sync adapter package as determined by
+ * {@link #getViewContactNotifyServicePackageName()}.
+ */
+ public String getViewContactNotifyServiceClassName() {
+ return null;
+ }
+
+ /**
+ * TODO This is way too hacky should be removed.
+ *
+ * This is introduced for {@link GoogleAccountType} where {@link #syncAdapterPackageName}
+ * is the authenticator package name but the notification service is in the sync adapter
+ * package. See {@link #resourcePackageName} -- we should clean up those.
+ */
+ public String getViewContactNotifyServicePackageName() {
+ return syncAdapterPackageName;
+ }
+
+ /** Returns an optional Activity string that can be used to view the group. */
+ public String getViewGroupActivity() {
+ return null;
+ }
+
+ public CharSequence getDisplayLabel(Context context) {
+ // Note this resource is defined in the sync adapter package, not resourcePackageName.
+ return getResourceText(context, syncAdapterPackageName, titleRes, accountType);
+ }
+
+ /**
+ * @return resource ID for the "invite contact" action label, or -1 if not defined.
+ */
+ protected int getInviteContactActionResId() {
+ return -1;
+ }
+
+ /**
+ * @return resource ID for the "view group" label, or -1 if not defined.
+ */
+ protected int getViewGroupLabelResId() {
+ return -1;
+ }
+
+ /**
+ * Returns {@link AccountTypeWithDataSet} for this type.
+ */
+ public AccountTypeWithDataSet getAccountTypeAndDataSet() {
+ return AccountTypeWithDataSet.get(accountType, dataSet);
+ }
+
+ /**
+ * Returns a list of additional package names that should be inspected as additional
+ * external account types. This allows for a primary account type to indicate other packages
+ * that may not be sync adapters but which still provide contact data, perhaps under a
+ * separate data set within the account.
+ */
+ public List<String> getExtensionPackageNames() {
+ return new ArrayList<String>();
+ }
+
+ /**
+ * Returns an optional custom label for the "invite contact" action, which will be shown on
+ * the contact card. (If not defined, returns null.)
+ */
+ public CharSequence getInviteContactActionLabel(Context context) {
+ // Note this resource is defined in the sync adapter package, not resourcePackageName.
+ return getResourceText(context, syncAdapterPackageName, getInviteContactActionResId(), "");
+ }
+
+ /**
+ * Returns a label for the "view group" action. If not defined, this falls back to our
+ * own "View Updates" string
+ */
+ public CharSequence getViewGroupLabel(Context context) {
+ // Note this resource is defined in the sync adapter package, not resourcePackageName.
+ final CharSequence customTitle =
+ getResourceText(context, syncAdapterPackageName, getViewGroupLabelResId(), null);
+
+ return customTitle == null
+ ? context.getText(R.string.view_updates_from_group)
+ : customTitle;
+ }
+
+ /**
+ * Return a string resource loaded from the given package (or the current package
+ * if {@code packageName} is null), unless {@code resId} is -1, in which case it returns
+ * {@code defaultValue}.
+ *
+ * (The behavior is undefined if the resource or package doesn't exist.)
+ */
+ @VisibleForTesting
+ static CharSequence getResourceText(Context context, String packageName, int resId,
+ String defaultValue) {
+ if (resId != -1 && packageName != null) {
+ final PackageManager pm = context.getPackageManager();
+ return pm.getText(packageName, resId, null);
+ } else if (resId != -1) {
+ return context.getText(resId);
+ } else {
+ return defaultValue;
+ }
+ }
+
+ public Drawable getDisplayIcon(Context context) {
+ return getDisplayIcon(context, titleRes, iconRes, syncAdapterPackageName);
+ }
+
+ public static Drawable getDisplayIcon(Context context, int titleRes, int iconRes,
+ String syncAdapterPackageName) {
+ if (titleRes != -1 && syncAdapterPackageName != null) {
+ final PackageManager pm = context.getPackageManager();
+ return pm.getDrawable(syncAdapterPackageName, iconRes, null);
+ } else if (titleRes != -1) {
+ return context.getResources().getDrawable(iconRes);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Whether or not groups created under this account type have editable membership lists.
+ */
+ abstract public boolean isGroupMembershipEditable();
+
+ /**
+ * {@link Comparator} to sort by {@link DataKind#weight}.
+ */
+ private static Comparator<DataKind> sWeightComparator = new Comparator<DataKind>() {
+ @Override
+ public int compare(DataKind object1, DataKind object2) {
+ return object1.weight - object2.weight;
+ }
+ };
+
+ /**
+ * Return list of {@link DataKind} supported, sorted by
+ * {@link DataKind#weight}.
+ */
+ public ArrayList<DataKind> getSortedDataKinds() {
+ // TODO: optimize by marking if already sorted
+ Collections.sort(mKinds, sWeightComparator);
+ return mKinds;
+ }
+
+ /**
+ * Find the {@link DataKind} for a specific MIME-type, if it's handled by
+ * this data source.
+ */
+ public DataKind getKindForMimetype(String mimeType) {
+ return this.mMimeKinds.get(mimeType);
+ }
+
+ /**
+ * Add given {@link DataKind} to list of those provided by this source.
+ */
+ public DataKind addKind(DataKind kind) throws DefinitionException {
+ if (kind.mimeType == null) {
+ throw new DefinitionException("null is not a valid mime type");
+ }
+ if (mMimeKinds.get(kind.mimeType) != null) {
+ throw new DefinitionException(
+ "mime type '" + kind.mimeType + "' is already registered");
+ }
+
+ kind.resourcePackageName = this.resourcePackageName;
+ this.mKinds.add(kind);
+ this.mMimeKinds.put(kind.mimeType, kind);
+ return kind;
+ }
+
+ /**
+ * Description of a specific "type" or "label" of a {@link DataKind} row,
+ * such as {@link Phone#TYPE_WORK}. Includes constraints on total number of
+ * rows a {@link Contacts} may have of this type, and details on how
+ * user-defined labels are stored.
+ */
+ public static class EditType {
+ public int rawValue;
+ public int labelRes;
+ public boolean secondary;
+ /**
+ * The number of entries allowed for the type. -1 if not specified.
+ * @see DataKind#typeOverallMax
+ */
+ public int specificMax;
+ public String customColumn;
+
+ public EditType(int rawValue, int labelRes) {
+ this.rawValue = rawValue;
+ this.labelRes = labelRes;
+ this.specificMax = -1;
+ }
+
+ public EditType setSecondary(boolean secondary) {
+ this.secondary = secondary;
+ return this;
+ }
+
+ public EditType setSpecificMax(int specificMax) {
+ this.specificMax = specificMax;
+ return this;
+ }
+
+ public EditType setCustomColumn(String customColumn) {
+ this.customColumn = customColumn;
+ return this;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (object instanceof EditType) {
+ final EditType other = (EditType)object;
+ return other.rawValue == rawValue;
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return rawValue;
+ }
+
+ @Override
+ public String toString() {
+ return this.getClass().getSimpleName()
+ + " rawValue=" + rawValue
+ + " labelRes=" + labelRes
+ + " secondary=" + secondary
+ + " specificMax=" + specificMax
+ + " customColumn=" + customColumn;
+ }
+ }
+
+ public static class EventEditType extends EditType {
+ private boolean mYearOptional;
+
+ public EventEditType(int rawValue, int labelRes) {
+ super(rawValue, labelRes);
+ }
+
+ public boolean isYearOptional() {
+ return mYearOptional;
+ }
+
+ public EventEditType setYearOptional(boolean yearOptional) {
+ mYearOptional = yearOptional;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return super.toString() + " mYearOptional=" + mYearOptional;
+ }
+ }
+
+ /**
+ * Description of a user-editable field on a {@link DataKind} row, such as
+ * {@link Phone#NUMBER}. Includes flags to apply to an {@link EditText}, and
+ * the column where this field is stored.
+ */
+ public static final class EditField {
+ public String column;
+ public int titleRes;
+ public int inputType;
+ public int minLines;
+ public boolean optional;
+ public boolean shortForm;
+ public boolean longForm;
+
+ public EditField(String column, int titleRes) {
+ this.column = column;
+ this.titleRes = titleRes;
+ }
+
+ public EditField(String column, int titleRes, int inputType) {
+ this(column, titleRes);
+ this.inputType = inputType;
+ }
+
+ public EditField setOptional(boolean optional) {
+ this.optional = optional;
+ return this;
+ }
+
+ public EditField setShortForm(boolean shortForm) {
+ this.shortForm = shortForm;
+ return this;
+ }
+
+ public EditField setLongForm(boolean longForm) {
+ this.longForm = longForm;
+ return this;
+ }
+
+ public EditField setMinLines(int minLines) {
+ this.minLines = minLines;
+ return this;
+ }
+
+ public boolean isMultiLine() {
+ return (inputType & EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE) != 0;
+ }
+
+
+ @Override
+ public String toString() {
+ return this.getClass().getSimpleName() + ":"
+ + " column=" + column
+ + " titleRes=" + titleRes
+ + " inputType=" + inputType
+ + " minLines=" + minLines
+ + " optional=" + optional
+ + " shortForm=" + shortForm
+ + " longForm=" + longForm;
+ }
+ }
+
+ /**
+ * Generic method of inflating a given {@link ContentValues} into a user-readable
+ * {@link CharSequence}. For example, an inflater could combine the multiple
+ * columns of {@link StructuredPostal} together using a string resource
+ * before presenting to the user.
+ */
+ public interface StringInflater {
+ public CharSequence inflateUsing(Context context, ContentValues values);
+ }
+
+ /**
+ * Compare two {@link AccountType} by their {@link AccountType#getDisplayLabel} with the
+ * current locale.
+ */
+ public static class DisplayLabelComparator implements Comparator<AccountType> {
+ private final Context mContext;
+ /** {@link Comparator} for the current locale. */
+ private final Collator mCollator = Collator.getInstance();
+
+ public DisplayLabelComparator(Context context) {
+ mContext = context;
+ }
+
+ private String getDisplayLabel(AccountType type) {
+ CharSequence label = type.getDisplayLabel(mContext);
+ return (label == null) ? "" : label.toString();
+ }
+
+ @Override
+ public int compare(AccountType lhs, AccountType rhs) {
+ return mCollator.compare(getDisplayLabel(lhs), getDisplayLabel(rhs));
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/model/account/AccountTypeWithDataSet.java b/src/com/android/contacts/common/model/account/AccountTypeWithDataSet.java
new file mode 100644
index 0000000..f6bcf24
--- /dev/null
+++ b/src/com/android/contacts/common/model/account/AccountTypeWithDataSet.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.account;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.BaseColumns;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.RawContacts;
+import android.text.TextUtils;
+
+import com.google.common.base.Objects;
+
+
+/**
+ * Encapsulates an "account type" string and a "data set" string.
+ */
+public class AccountTypeWithDataSet {
+
+ private static final String[] ID_PROJECTION = new String[] {BaseColumns._ID};
+ private static final Uri RAW_CONTACTS_URI_LIMIT_1 = RawContacts.CONTENT_URI.buildUpon()
+ .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, "1").build();
+
+ /** account type. Can be null for fallback type. */
+ public final String accountType;
+
+ /** dataSet may be null, but never be "". */
+ public final String dataSet;
+
+ private AccountTypeWithDataSet(String accountType, String dataSet) {
+ this.accountType = TextUtils.isEmpty(accountType) ? null : accountType;
+ this.dataSet = TextUtils.isEmpty(dataSet) ? null : dataSet;
+ }
+
+ public static AccountTypeWithDataSet get(String accountType, String dataSet) {
+ return new AccountTypeWithDataSet(accountType, dataSet);
+ }
+
+ /**
+ * Return true if there are any contacts in the database with this account type and data set.
+ * Touches DB. Don't use in the UI thread.
+ */
+ public boolean hasData(Context context) {
+ final String BASE_SELECTION = RawContacts.ACCOUNT_TYPE + " = ?";
+ final String selection;
+ final String[] args;
+ if (TextUtils.isEmpty(dataSet)) {
+ selection = BASE_SELECTION + " AND " + RawContacts.DATA_SET + " IS NULL";
+ args = new String[] {accountType};
+ } else {
+ selection = BASE_SELECTION + " AND " + RawContacts.DATA_SET + " = ?";
+ args = new String[] {accountType, dataSet};
+ }
+
+ final Cursor c = context.getContentResolver().query(RAW_CONTACTS_URI_LIMIT_1,
+ ID_PROJECTION, selection, args, null);
+ if (c == null) return false;
+ try {
+ return c.moveToFirst();
+ } finally {
+ c.close();
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof AccountTypeWithDataSet)) return false;
+
+ AccountTypeWithDataSet other = (AccountTypeWithDataSet) o;
+ return Objects.equal(accountType, other.accountType)
+ && Objects.equal(dataSet, other.dataSet);
+ }
+
+ @Override
+ public int hashCode() {
+ return (accountType == null ? 0 : accountType.hashCode())
+ ^ (dataSet == null ? 0 : dataSet.hashCode());
+ }
+
+ @Override
+ public String toString() {
+ return "[" + accountType + "/" + dataSet + "]";
+ }
+}
diff --git a/src/com/android/contacts/common/model/account/AccountWithDataSet.java b/src/com/android/contacts/common/model/account/AccountWithDataSet.java
new file mode 100644
index 0000000..5947647
--- /dev/null
+++ b/src/com/android/contacts/common/model/account/AccountWithDataSet.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.account;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Parcelable;
+import android.os.Parcel;
+import android.provider.BaseColumns;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.RawContacts;
+import android.text.TextUtils;
+
+import com.google.common.base.Objects;
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * Wrapper for an account that includes a data set (which may be null).
+ */
+public class AccountWithDataSet implements Parcelable {
+ private static final String STRINGIFY_SEPARATOR = "\u0001";
+ private static final String ARRAY_STRINGIFY_SEPARATOR = "\u0002";
+
+ private static final Pattern STRINGIFY_SEPARATOR_PAT =
+ Pattern.compile(Pattern.quote(STRINGIFY_SEPARATOR));
+ private static final Pattern ARRAY_STRINGIFY_SEPARATOR_PAT =
+ Pattern.compile(Pattern.quote(ARRAY_STRINGIFY_SEPARATOR));
+
+ public final String name;
+ public final String type;
+ public final String dataSet;
+ private final AccountTypeWithDataSet mAccountTypeWithDataSet;
+
+ private static final String[] ID_PROJECTION = new String[] {BaseColumns._ID};
+ private static final Uri RAW_CONTACTS_URI_LIMIT_1 = RawContacts.CONTENT_URI.buildUpon()
+ .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, "1").build();
+
+ public static final String LOCAL_ACCOUNT_SELECTION = RawContacts.ACCOUNT_TYPE + " IS NULL AND "
+ + RawContacts.ACCOUNT_NAME + " IS NULL AND "
+ + RawContacts.DATA_SET + " IS NULL";
+
+ public AccountWithDataSet(String name, String type, String dataSet) {
+ this.name = emptyToNull(name);
+ this.type = emptyToNull(type);
+ this.dataSet = emptyToNull(dataSet);
+ mAccountTypeWithDataSet = AccountTypeWithDataSet.get(type, dataSet);
+ }
+
+ private static final String emptyToNull(String text) {
+ return TextUtils.isEmpty(text) ? null : text;
+ }
+
+ public AccountWithDataSet(Parcel in) {
+ this.name = in.readString();
+ this.type = in.readString();
+ this.dataSet = in.readString();
+ mAccountTypeWithDataSet = AccountTypeWithDataSet.get(type, dataSet);
+ }
+
+ public boolean isLocalAccount() {
+ return name == null && type == null && dataSet == null;
+ }
+
+ public static AccountWithDataSet getLocalAccount() {
+ return new AccountWithDataSet(null, null, null);
+ }
+
+ public Account getAccountOrNull() {
+ if (name != null && type != null) {
+ return new Account(name, type);
+ }
+ return null;
+ }
+
+ public int describeContents() {
+ return 0;
+ }
+
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(name);
+ dest.writeString(type);
+ dest.writeString(dataSet);
+ }
+
+ // For Parcelable
+ public static final Creator<AccountWithDataSet> CREATOR = new Creator<AccountWithDataSet>() {
+ public AccountWithDataSet createFromParcel(Parcel source) {
+ return new AccountWithDataSet(source);
+ }
+
+ public AccountWithDataSet[] newArray(int size) {
+ return new AccountWithDataSet[size];
+ }
+ };
+
+ public AccountTypeWithDataSet getAccountTypeWithDataSet() {
+ return mAccountTypeWithDataSet;
+ }
+
+ /**
+ * Return {@code true} if this account has any contacts in the database.
+ * Touches DB. Don't use in the UI thread.
+ */
+ public boolean hasData(Context context) {
+ String selection;
+ String[] args = null;
+ if (isLocalAccount()) {
+ selection = LOCAL_ACCOUNT_SELECTION;
+ } else {
+ final String BASE_SELECTION =
+ RawContacts.ACCOUNT_TYPE + " = ?" + " AND " + RawContacts.ACCOUNT_NAME + " = ?";
+ if (TextUtils.isEmpty(dataSet)) {
+ selection = BASE_SELECTION + " AND " + RawContacts.DATA_SET + " IS NULL";
+ args = new String[] {type, name};
+ } else {
+ selection = BASE_SELECTION + " AND " + RawContacts.DATA_SET + " = ?";
+ args = new String[] {type, name, dataSet};
+ }
+ }
+
+ final Cursor c = context.getContentResolver().query(RAW_CONTACTS_URI_LIMIT_1,
+ ID_PROJECTION, selection, args, null);
+ if (c == null) return false;
+ try {
+ return c.moveToFirst();
+ } finally {
+ c.close();
+ }
+ }
+
+ public boolean equals(Object obj) {
+ if (obj instanceof AccountWithDataSet) {
+ AccountWithDataSet other = (AccountWithDataSet) obj;
+ return Objects.equal(name, other.name)
+ && Objects.equal(type, other.type)
+ && Objects.equal(dataSet, other.dataSet);
+ }
+ return false;
+ }
+
+ public int hashCode() {
+ int result = 17;
+ result = 31 * result + (name != null ? name.hashCode() : 0);
+ result = 31 * result + (type != null ? type.hashCode() : 0);
+ result = 31 * result + (dataSet != null ? dataSet.hashCode() : 0);
+ return result;
+ }
+
+ public String toString() {
+ return "AccountWithDataSet {name=" + name + ", type=" + type + ", dataSet=" + dataSet + "}";
+ }
+
+ private static StringBuilder addStringified(StringBuilder sb, AccountWithDataSet account) {
+ if (!TextUtils.isEmpty(account.name)) sb.append(account.name);
+ sb.append(STRINGIFY_SEPARATOR);
+ if (!TextUtils.isEmpty(account.type)) sb.append(account.type);
+ sb.append(STRINGIFY_SEPARATOR);
+ if (!TextUtils.isEmpty(account.dataSet)) sb.append(account.dataSet);
+
+ return sb;
+ }
+
+ /**
+ * Pack the instance into a string.
+ */
+ public String stringify() {
+ return addStringified(new StringBuilder(), this).toString();
+ }
+
+ /**
+ * Unpack a string created by {@link #stringify}.
+ *
+ * @throws IllegalArgumentException if it's an invalid string.
+ */
+ public static AccountWithDataSet unstringify(String s) {
+ final String[] array = STRINGIFY_SEPARATOR_PAT.split(s, 3);
+ if (array.length < 3) {
+ throw new IllegalArgumentException("Invalid string " + s);
+ }
+ return new AccountWithDataSet(array[0], array[1],
+ TextUtils.isEmpty(array[2]) ? null : array[2]);
+ }
+
+ /**
+ * Pack a list of {@link AccountWithDataSet} into a string.
+ */
+ public static String stringifyList(List<AccountWithDataSet> accounts) {
+ final StringBuilder sb = new StringBuilder();
+
+ for (AccountWithDataSet account : accounts) {
+ if (sb.length() > 0) {
+ sb.append(ARRAY_STRINGIFY_SEPARATOR);
+ }
+ addStringified(sb, account);
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Unpack a list of {@link AccountWithDataSet} into a string.
+ *
+ * @throws IllegalArgumentException if it's an invalid string.
+ */
+ public static List<AccountWithDataSet> unstringifyList(String s) {
+ final ArrayList<AccountWithDataSet> ret = Lists.newArrayList();
+ if (TextUtils.isEmpty(s)) {
+ return ret;
+ }
+
+ final String[] array = ARRAY_STRINGIFY_SEPARATOR_PAT.split(s);
+
+ for (int i = 0; i < array.length; i++) {
+ ret.add(unstringify(array[i]));
+ }
+
+ return ret;
+ }
+}
diff --git a/src/com/android/contacts/common/model/account/BaseAccountType.java b/src/com/android/contacts/common/model/account/BaseAccountType.java
new file mode 100644
index 0000000..6481c06
--- /dev/null
+++ b/src/com/android/contacts/common/model/account/BaseAccountType.java
@@ -0,0 +1,1488 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.account;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.res.Resources;
+import android.provider.ContactsContract.CommonDataKinds.BaseTypes;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Event;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+import android.provider.ContactsContract.CommonDataKinds.Note;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.CommonDataKinds.Relation;
+import android.provider.ContactsContract.CommonDataKinds.SipAddress;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.provider.ContactsContract.CommonDataKinds.Website;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.inputmethod.EditorInfo;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.dataitem.DataKind;
+import com.android.contacts.common.testing.NeededForTesting;
+import com.android.contacts.common.util.CommonDateUtils;
+import com.android.contacts.common.util.ContactDisplayUtils;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+public abstract class BaseAccountType extends AccountType {
+ private static final String TAG = "BaseAccountType";
+
+ protected static final int FLAGS_PHONE = EditorInfo.TYPE_CLASS_PHONE;
+ protected static final int FLAGS_EMAIL = EditorInfo.TYPE_CLASS_TEXT
+ | EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS;
+ protected static final int FLAGS_PERSON_NAME = EditorInfo.TYPE_CLASS_TEXT
+ | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS | EditorInfo.TYPE_TEXT_VARIATION_PERSON_NAME;
+ protected static final int FLAGS_PHONETIC = EditorInfo.TYPE_CLASS_TEXT
+ | EditorInfo.TYPE_TEXT_VARIATION_PHONETIC;
+ protected static final int FLAGS_GENERIC_NAME = EditorInfo.TYPE_CLASS_TEXT
+ | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS;
+ protected static final int FLAGS_NOTE = EditorInfo.TYPE_CLASS_TEXT
+ | EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES | EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE;
+ protected static final int FLAGS_EVENT = EditorInfo.TYPE_CLASS_TEXT;
+ protected static final int FLAGS_WEBSITE = EditorInfo.TYPE_CLASS_TEXT
+ | EditorInfo.TYPE_TEXT_VARIATION_URI;
+ protected static final int FLAGS_POSTAL = EditorInfo.TYPE_CLASS_TEXT
+ | EditorInfo.TYPE_TEXT_VARIATION_POSTAL_ADDRESS | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS
+ | EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE;
+ protected static final int FLAGS_SIP_ADDRESS = EditorInfo.TYPE_CLASS_TEXT
+ | EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; // since SIP addresses have the same
+ // basic format as email addresses
+ protected static final int FLAGS_RELATION = EditorInfo.TYPE_CLASS_TEXT
+ | EditorInfo.TYPE_TEXT_FLAG_CAP_WORDS | EditorInfo.TYPE_TEXT_VARIATION_PERSON_NAME;
+
+ // Specify the maximum number of lines that can be used to display various field types. If no
+ // value is specified for a particular type, we use the default value from {@link DataKind}.
+ protected static final int MAX_LINES_FOR_POSTAL_ADDRESS = 10;
+ protected static final int MAX_LINES_FOR_GROUP = 10;
+ protected static final int MAX_LINES_FOR_NOTE = 100;
+
+ private interface Tag {
+ static final String DATA_KIND = "DataKind";
+ static final String TYPE = "Type";
+ }
+
+ private interface Attr {
+ static final String MAX_OCCURRENCE = "maxOccurs";
+ static final String DATE_WITH_TIME = "dateWithTime";
+ static final String YEAR_OPTIONAL = "yearOptional";
+ static final String KIND = "kind";
+ static final String TYPE = "type";
+ }
+
+ protected interface Weight {
+ static final int NONE = -1;
+ static final int PHONE = 10;
+ static final int EMAIL = 15;
+ static final int STRUCTURED_POSTAL = 25;
+ static final int NICKNAME = 111;
+ static final int EVENT = 120;
+ static final int ORGANIZATION = 125;
+ static final int NOTE = 130;
+ static final int IM = 140;
+ static final int SIP_ADDRESS = 145;
+ static final int GROUP_MEMBERSHIP = 150;
+ static final int WEBSITE = 160;
+ static final int RELATIONSHIP = 999;
+ }
+
+ public BaseAccountType() {
+ this.accountType = null;
+ this.dataSet = null;
+ this.titleRes = R.string.account_phone;
+ this.iconRes = R.mipmap.ic_contacts_launcher;
+ }
+
+ protected static EditType buildPhoneType(int type) {
+ return new EditType(type, Phone.getTypeLabelResource(type));
+ }
+
+ protected static EditType buildEmailType(int type) {
+ return new EditType(type, Email.getTypeLabelResource(type));
+ }
+
+ protected static EditType buildPostalType(int type) {
+ return new EditType(type, StructuredPostal.getTypeLabelResource(type));
+ }
+
+ protected static EditType buildImType(int type) {
+ return new EditType(type, Im.getProtocolLabelResource(type));
+ }
+
+ protected static EditType buildEventType(int type, boolean yearOptional) {
+ return new EventEditType(type, Event.getTypeResource(type)).setYearOptional(yearOptional);
+ }
+
+ protected static EditType buildRelationType(int type) {
+ return new EditType(type, Relation.getTypeLabelResource(type));
+ }
+
+ protected DataKind addDataKindStructuredName(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(StructuredName.CONTENT_ITEM_TYPE,
+ R.string.nameLabelsGroup, Weight.NONE, true));
+ kind.actionHeader = new SimpleInflater(R.string.nameLabelsGroup);
+ kind.actionBody = new SimpleInflater(Nickname.NAME);
+ kind.typeOverallMax = 1;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(StructuredName.DISPLAY_NAME,
+ R.string.full_name, FLAGS_PERSON_NAME));
+ kind.fieldList.add(new EditField(StructuredName.PREFIX, R.string.name_prefix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.FAMILY_NAME, R.string.name_family,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.GIVEN_NAME, R.string.name_given,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.SUFFIX, R.string.name_suffix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.PHONETIC_FAMILY_NAME,
+ R.string.name_phonetic_family, FLAGS_PHONETIC));
+ kind.fieldList.add(new EditField(StructuredName.PHONETIC_MIDDLE_NAME,
+ R.string.name_phonetic_middle, FLAGS_PHONETIC));
+ kind.fieldList.add(new EditField(StructuredName.PHONETIC_GIVEN_NAME,
+ R.string.name_phonetic_given, FLAGS_PHONETIC));
+
+ return kind;
+ }
+
+ protected DataKind addDataKindDisplayName(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME,
+ R.string.nameLabelsGroup, Weight.NONE, true));
+ kind.actionHeader = new SimpleInflater(R.string.nameLabelsGroup);
+ kind.actionBody = new SimpleInflater(Nickname.NAME);
+ kind.typeOverallMax = 1;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(StructuredName.DISPLAY_NAME,
+ R.string.full_name, FLAGS_PERSON_NAME).setShortForm(true));
+
+ boolean displayOrderPrimary =
+ context.getResources().getBoolean(R.bool.config_editor_field_order_primary);
+
+ if (!displayOrderPrimary) {
+ kind.fieldList.add(new EditField(StructuredName.PREFIX, R.string.name_prefix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.FAMILY_NAME, R.string.name_family,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.GIVEN_NAME, R.string.name_given,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.SUFFIX, R.string.name_suffix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ } else {
+ kind.fieldList.add(new EditField(StructuredName.PREFIX, R.string.name_prefix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.GIVEN_NAME, R.string.name_given,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.FAMILY_NAME, R.string.name_family,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.SUFFIX, R.string.name_suffix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ }
+
+ return kind;
+ }
+
+ protected DataKind addDataKindPhoneticName(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME,
+ R.string.name_phonetic, Weight.NONE, true));
+ kind.actionHeader = new SimpleInflater(R.string.nameLabelsGroup);
+ kind.actionBody = new SimpleInflater(Nickname.NAME);
+ kind.typeOverallMax = 1;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(DataKind.PSEUDO_COLUMN_PHONETIC_NAME,
+ R.string.name_phonetic, FLAGS_PHONETIC).setShortForm(true));
+ kind.fieldList.add(new EditField(StructuredName.PHONETIC_FAMILY_NAME,
+ R.string.name_phonetic_family, FLAGS_PHONETIC).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.PHONETIC_MIDDLE_NAME,
+ R.string.name_phonetic_middle, FLAGS_PHONETIC).setLongForm(true));
+ kind.fieldList.add(new EditField(StructuredName.PHONETIC_GIVEN_NAME,
+ R.string.name_phonetic_given, FLAGS_PHONETIC).setLongForm(true));
+
+ return kind;
+ }
+
+ protected DataKind addDataKindNickname(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Nickname.CONTENT_ITEM_TYPE,
+ R.string.nicknameLabelsGroup, Weight.NICKNAME, true));
+ kind.typeOverallMax = 1;
+ kind.actionHeader = new SimpleInflater(R.string.nicknameLabelsGroup);
+ kind.actionBody = new SimpleInflater(Nickname.NAME);
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Nickname.TYPE, Nickname.TYPE_DEFAULT);
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Nickname.NAME, R.string.nicknameLabelsGroup,
+ FLAGS_PERSON_NAME));
+
+ return kind;
+ }
+
+ protected DataKind addDataKindPhone(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Phone.CONTENT_ITEM_TYPE, R.string.phoneLabelsGroup,
+ Weight.PHONE, true));
+ kind.iconAltRes = R.drawable.ic_message_24dp_mirrored;
+ kind.iconAltDescriptionRes = R.string.sms;
+ kind.actionHeader = new PhoneActionInflater();
+ kind.actionAltHeader = new PhoneActionAltInflater();
+ kind.actionBody = new SimpleInflater(Phone.NUMBER);
+ kind.typeColumn = Phone.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildPhoneType(Phone.TYPE_MOBILE));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_HOME));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_WORK));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_WORK).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_HOME).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_PAGER).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_OTHER));
+ kind.typeList.add(
+ buildPhoneType(Phone.TYPE_CUSTOM).setSecondary(true).setCustomColumn(Phone.LABEL));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_CALLBACK).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_CAR).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_COMPANY_MAIN).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_ISDN).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_MAIN).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_OTHER_FAX).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_RADIO).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_TELEX).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_TTY_TDD).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_WORK_MOBILE).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_WORK_PAGER).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_ASSISTANT).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_MMS).setSecondary(true));
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Phone.NUMBER, R.string.phoneLabelsGroup, FLAGS_PHONE));
+
+ return kind;
+ }
+
+ protected DataKind addDataKindEmail(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Email.CONTENT_ITEM_TYPE, R.string.emailLabelsGroup,
+ Weight.EMAIL, true));
+ kind.actionHeader = new EmailActionInflater();
+ kind.actionBody = new SimpleInflater(Email.DATA);
+ kind.typeColumn = Email.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildEmailType(Email.TYPE_HOME));
+ kind.typeList.add(buildEmailType(Email.TYPE_WORK));
+ kind.typeList.add(buildEmailType(Email.TYPE_OTHER));
+ kind.typeList.add(buildEmailType(Email.TYPE_MOBILE));
+ kind.typeList.add(
+ buildEmailType(Email.TYPE_CUSTOM).setSecondary(true).setCustomColumn(Email.LABEL));
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Email.DATA, R.string.emailLabelsGroup, FLAGS_EMAIL));
+
+ return kind;
+ }
+
+ protected DataKind addDataKindStructuredPostal(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(StructuredPostal.CONTENT_ITEM_TYPE,
+ R.string.postalLabelsGroup, Weight.STRUCTURED_POSTAL, true));
+ kind.actionHeader = new PostalActionInflater();
+ kind.actionBody = new SimpleInflater(StructuredPostal.FORMATTED_ADDRESS);
+ kind.typeColumn = StructuredPostal.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildPostalType(StructuredPostal.TYPE_HOME));
+ kind.typeList.add(buildPostalType(StructuredPostal.TYPE_WORK));
+ kind.typeList.add(buildPostalType(StructuredPostal.TYPE_OTHER));
+ kind.typeList.add(buildPostalType(StructuredPostal.TYPE_CUSTOM).setSecondary(true)
+ .setCustomColumn(StructuredPostal.LABEL));
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(
+ new EditField(StructuredPostal.FORMATTED_ADDRESS, R.string.postal_address,
+ FLAGS_POSTAL));
+
+ kind.maxLinesForDisplay = MAX_LINES_FOR_POSTAL_ADDRESS;
+
+ return kind;
+ }
+
+ protected DataKind addDataKindIm(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Im.CONTENT_ITEM_TYPE, R.string.imLabelsGroup,
+ Weight.IM, true));
+ kind.actionHeader = new ImActionInflater();
+ kind.actionBody = new SimpleInflater(Im.DATA);
+
+ // NOTE: even though a traditional "type" exists, for editing
+ // purposes we're using the protocol to pick labels
+
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Im.TYPE, Im.TYPE_OTHER);
+
+ kind.typeColumn = Im.PROTOCOL;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildImType(Im.PROTOCOL_AIM));
+ kind.typeList.add(buildImType(Im.PROTOCOL_MSN));
+ kind.typeList.add(buildImType(Im.PROTOCOL_YAHOO));
+ kind.typeList.add(buildImType(Im.PROTOCOL_SKYPE));
+ kind.typeList.add(buildImType(Im.PROTOCOL_QQ));
+ kind.typeList.add(buildImType(Im.PROTOCOL_GOOGLE_TALK));
+ kind.typeList.add(buildImType(Im.PROTOCOL_ICQ));
+ kind.typeList.add(buildImType(Im.PROTOCOL_JABBER));
+ kind.typeList.add(buildImType(Im.PROTOCOL_CUSTOM).setSecondary(true).setCustomColumn(
+ Im.CUSTOM_PROTOCOL));
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Im.DATA, R.string.imLabelsGroup, FLAGS_EMAIL));
+
+ return kind;
+ }
+
+ protected DataKind addDataKindOrganization(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Organization.CONTENT_ITEM_TYPE,
+ R.string.organizationLabelsGroup, Weight.ORGANIZATION, true));
+ kind.actionHeader = new SimpleInflater(R.string.organizationLabelsGroup);
+ kind.actionBody = ORGANIZATION_BODY_INFLATER;
+ kind.typeOverallMax = 1;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Organization.COMPANY, R.string.ghostData_company,
+ FLAGS_GENERIC_NAME));
+ kind.fieldList.add(new EditField(Organization.TITLE, R.string.ghostData_title,
+ FLAGS_GENERIC_NAME));
+
+ return kind;
+ }
+
+ protected DataKind addDataKindPhoto(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Photo.CONTENT_ITEM_TYPE, -1, Weight.NONE, true));
+ kind.typeOverallMax = 1;
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Photo.PHOTO, -1, -1));
+ return kind;
+ }
+
+ protected DataKind addDataKindNote(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Note.CONTENT_ITEM_TYPE, R.string.label_notes,
+ Weight.NOTE, true));
+ kind.typeOverallMax = 1;
+ kind.actionHeader = new SimpleInflater(R.string.label_notes);
+ kind.actionBody = new SimpleInflater(Note.NOTE);
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Note.NOTE, R.string.label_notes, FLAGS_NOTE));
+
+ kind.maxLinesForDisplay = MAX_LINES_FOR_NOTE;
+
+ return kind;
+ }
+
+ protected DataKind addDataKindWebsite(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Website.CONTENT_ITEM_TYPE,
+ R.string.websiteLabelsGroup, Weight.WEBSITE, true));
+ kind.actionHeader = new SimpleInflater(R.string.websiteLabelsGroup);
+ kind.actionBody = new SimpleInflater(Website.URL);
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Website.TYPE, Website.TYPE_OTHER);
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Website.URL, R.string.websiteLabelsGroup, FLAGS_WEBSITE));
+
+ return kind;
+ }
+
+ protected DataKind addDataKindSipAddress(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(SipAddress.CONTENT_ITEM_TYPE,
+ R.string.label_sip_address, Weight.SIP_ADDRESS, true));
+
+ kind.actionHeader = new SimpleInflater(R.string.label_sip_address);
+ kind.actionBody = new SimpleInflater(SipAddress.SIP_ADDRESS);
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(SipAddress.SIP_ADDRESS,
+ R.string.label_sip_address, FLAGS_SIP_ADDRESS));
+ kind.typeOverallMax = 1;
+
+ return kind;
+ }
+
+ protected DataKind addDataKindGroupMembership(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(GroupMembership.CONTENT_ITEM_TYPE,
+ R.string.groupsLabel, Weight.GROUP_MEMBERSHIP, true));
+
+ kind.typeOverallMax = 1;
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(GroupMembership.GROUP_ROW_ID, -1, -1));
+
+ kind.maxLinesForDisplay = MAX_LINES_FOR_GROUP;
+
+ return kind;
+ }
+
+ /**
+ * Simple inflater that assumes a string resource has a "%s" that will be
+ * filled from the given column.
+ */
+ public static class SimpleInflater implements StringInflater {
+ private final int mStringRes;
+ private final String mColumnName;
+
+ public SimpleInflater(int stringRes) {
+ this(stringRes, null);
+ }
+
+ public SimpleInflater(String columnName) {
+ this(-1, columnName);
+ }
+
+ public SimpleInflater(int stringRes, String columnName) {
+ mStringRes = stringRes;
+ mColumnName = columnName;
+ }
+
+ @Override
+ public CharSequence inflateUsing(Context context, ContentValues values) {
+ final boolean validColumn = values.containsKey(mColumnName);
+ final boolean validString = mStringRes > 0;
+
+ final CharSequence stringValue = validString ? context.getText(mStringRes) : null;
+ final CharSequence columnValue = validColumn ? values.getAsString(mColumnName) : null;
+
+ if (validString && validColumn) {
+ return String.format(stringValue.toString(), columnValue);
+ } else if (validString) {
+ return stringValue;
+ } else if (validColumn) {
+ return columnValue;
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return this.getClass().getSimpleName()
+ + " mStringRes=" + mStringRes
+ + " mColumnName" + mColumnName;
+ }
+
+ @NeededForTesting
+ public String getColumnNameForTest() {
+ return mColumnName;
+ }
+ }
+
+ public static abstract class CommonInflater implements StringInflater {
+ protected abstract int getTypeLabelResource(Integer type);
+
+ protected boolean isCustom(Integer type) {
+ return type == BaseTypes.TYPE_CUSTOM;
+ }
+
+ protected String getTypeColumn() {
+ return Phone.TYPE;
+ }
+
+ protected String getLabelColumn() {
+ return Phone.LABEL;
+ }
+
+ protected CharSequence getTypeLabel(Resources res, Integer type, CharSequence label) {
+ final int labelRes = getTypeLabelResource(type);
+ if (type == null) {
+ return res.getText(labelRes);
+ } else if (isCustom(type)) {
+ return res.getString(labelRes, label == null ? "" : label);
+ } else {
+ return res.getText(labelRes);
+ }
+ }
+
+ @Override
+ public CharSequence inflateUsing(Context context, ContentValues values) {
+ final Integer type = values.getAsInteger(getTypeColumn());
+ final String label = values.getAsString(getLabelColumn());
+ return getTypeLabel(context.getResources(), type, label);
+ }
+
+ @Override
+ public String toString() {
+ return this.getClass().getSimpleName();
+ }
+ }
+
+ public static class PhoneActionInflater extends CommonInflater {
+ @Override
+ protected boolean isCustom(Integer type) {
+ return ContactDisplayUtils.isCustomPhoneType(type);
+ }
+
+ @Override
+ protected int getTypeLabelResource(Integer type) {
+ return ContactDisplayUtils.getPhoneLabelResourceId(type);
+ }
+ }
+
+ public static class PhoneActionAltInflater extends CommonInflater {
+ @Override
+ protected boolean isCustom(Integer type) {
+ return ContactDisplayUtils.isCustomPhoneType(type);
+ }
+
+ @Override
+ protected int getTypeLabelResource(Integer type) {
+ return ContactDisplayUtils.getSmsLabelResourceId(type);
+ }
+ }
+
+ public static class EmailActionInflater extends CommonInflater {
+ @Override
+ protected int getTypeLabelResource(Integer type) {
+ if (type == null) return R.string.email;
+ switch (type) {
+ case Email.TYPE_HOME: return R.string.email_home;
+ case Email.TYPE_WORK: return R.string.email_work;
+ case Email.TYPE_OTHER: return R.string.email_other;
+ case Email.TYPE_MOBILE: return R.string.email_mobile;
+ default: return R.string.email_custom;
+ }
+ }
+ }
+
+ public static class EventActionInflater extends CommonInflater {
+ @Override
+ protected int getTypeLabelResource(Integer type) {
+ return Event.getTypeResource(type);
+ }
+ }
+
+ public static class RelationActionInflater extends CommonInflater {
+ @Override
+ protected int getTypeLabelResource(Integer type) {
+ return Relation.getTypeLabelResource(type == null ? Relation.TYPE_CUSTOM : type);
+ }
+ }
+
+ public static class PostalActionInflater extends CommonInflater {
+ @Override
+ protected int getTypeLabelResource(Integer type) {
+ if (type == null) return R.string.map_other;
+ switch (type) {
+ case StructuredPostal.TYPE_HOME: return R.string.map_home;
+ case StructuredPostal.TYPE_WORK: return R.string.map_work;
+ case StructuredPostal.TYPE_OTHER: return R.string.map_other;
+ default: return R.string.map_custom;
+ }
+ }
+ }
+
+ public static class ImActionInflater extends CommonInflater {
+ @Override
+ protected String getTypeColumn() {
+ return Im.PROTOCOL;
+ }
+
+ @Override
+ protected String getLabelColumn() {
+ return Im.CUSTOM_PROTOCOL;
+ }
+
+ @Override
+ protected int getTypeLabelResource(Integer type) {
+ if (type == null) return R.string.chat;
+ switch (type) {
+ case Im.PROTOCOL_AIM: return R.string.chat_aim;
+ case Im.PROTOCOL_MSN: return R.string.chat_msn;
+ case Im.PROTOCOL_YAHOO: return R.string.chat_yahoo;
+ case Im.PROTOCOL_SKYPE: return R.string.chat_skype;
+ case Im.PROTOCOL_QQ: return R.string.chat_qq;
+ case Im.PROTOCOL_GOOGLE_TALK: return R.string.chat_gtalk;
+ case Im.PROTOCOL_ICQ: return R.string.chat_icq;
+ case Im.PROTOCOL_JABBER: return R.string.chat_jabber;
+ case Im.PROTOCOL_NETMEETING: return R.string.chat;
+ default: return R.string.chat;
+ }
+ }
+ }
+
+ public static final StringInflater ORGANIZATION_BODY_INFLATER = new StringInflater() {
+ @Override
+ public CharSequence inflateUsing(Context context, ContentValues values) {
+ final CharSequence companyValue = values.containsKey(Organization.COMPANY) ?
+ values.getAsString(Organization.COMPANY) : null;
+ final CharSequence titleValue = values.containsKey(Organization.TITLE) ?
+ values.getAsString(Organization.TITLE) : null;
+
+ if (companyValue != null && titleValue != null) {
+ return companyValue + ": " + titleValue;
+ } else if (companyValue == null) {
+ return titleValue;
+ } else {
+ return companyValue;
+ }
+ }
+ };
+
+ @Override
+ public boolean isGroupMembershipEditable() {
+ return false;
+ }
+
+ /**
+ * Parses the content of the EditSchema tag in contacts.xml.
+ */
+ protected final void parseEditSchema(Context context, XmlPullParser parser, AttributeSet attrs)
+ throws XmlPullParserException, IOException, DefinitionException {
+
+ final int outerDepth = parser.getDepth();
+ int type;
+ while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
+ && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) {
+ final int depth = parser.getDepth();
+ if (type != XmlPullParser.START_TAG || depth != outerDepth + 1) {
+ continue; // Not direct child tag
+ }
+
+ final String tag = parser.getName();
+
+ if (Tag.DATA_KIND.equals(tag)) {
+ for (DataKind kind : KindParser.INSTANCE.parseDataKindTag(context, parser, attrs)) {
+ addKind(kind);
+ }
+ } else {
+ Log.w(TAG, "Skipping unknown tag " + tag);
+ }
+ }
+ }
+
+ // Utility methods to keep code shorter.
+ private static boolean getAttr(AttributeSet attrs, String attribute, boolean defaultValue) {
+ return attrs.getAttributeBooleanValue(null, attribute, defaultValue);
+ }
+
+ private static int getAttr(AttributeSet attrs, String attribute, int defaultValue) {
+ return attrs.getAttributeIntValue(null, attribute, defaultValue);
+ }
+
+ private static String getAttr(AttributeSet attrs, String attribute) {
+ return attrs.getAttributeValue(null, attribute);
+ }
+
+ // TODO Extract it to its own class, and move all KindBuilders to it as well.
+ private static class KindParser {
+ public static final KindParser INSTANCE = new KindParser();
+
+ private final Map<String, KindBuilder> mBuilders = Maps.newHashMap();
+
+ private KindParser() {
+ addBuilder(new NameKindBuilder());
+ addBuilder(new NicknameKindBuilder());
+ addBuilder(new PhoneKindBuilder());
+ addBuilder(new EmailKindBuilder());
+ addBuilder(new StructuredPostalKindBuilder());
+ addBuilder(new ImKindBuilder());
+ addBuilder(new OrganizationKindBuilder());
+ addBuilder(new PhotoKindBuilder());
+ addBuilder(new NoteKindBuilder());
+ addBuilder(new WebsiteKindBuilder());
+ addBuilder(new SipAddressKindBuilder());
+ addBuilder(new GroupMembershipKindBuilder());
+ addBuilder(new EventKindBuilder());
+ addBuilder(new RelationshipKindBuilder());
+ }
+
+ private void addBuilder(KindBuilder builder) {
+ mBuilders.put(builder.getTagName(), builder);
+ }
+
+ /**
+ * Takes a {@link XmlPullParser} at the start of a DataKind tag, parses it and returns
+ * {@link DataKind}s. (Usually just one, but there are three for the "name" kind.)
+ *
+ * This method returns a list, because we need to add 3 kinds for the name data kind.
+ * (structured, display and phonetic)
+ */
+ public List<DataKind> parseDataKindTag(Context context, XmlPullParser parser,
+ AttributeSet attrs)
+ throws DefinitionException, XmlPullParserException, IOException {
+ final String kind = getAttr(attrs, Attr.KIND);
+ final KindBuilder builder = mBuilders.get(kind);
+ if (builder != null) {
+ return builder.parseDataKind(context, parser, attrs);
+ } else {
+ throw new DefinitionException("Undefined data kind '" + kind + "'");
+ }
+ }
+ }
+
+ private static abstract class KindBuilder {
+
+ public abstract String getTagName();
+
+ /**
+ * DataKind tag parser specific to each kind. Subclasses must implement it.
+ */
+ public abstract List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException, IOException;
+
+ /**
+ * Creates a new {@link DataKind}, and also parses the child Type tags in the DataKind
+ * tag.
+ */
+ protected final DataKind newDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs, boolean isPseudo, String mimeType, String typeColumn,
+ int titleRes, int weight, StringInflater actionHeader, StringInflater actionBody)
+ throws DefinitionException, XmlPullParserException, IOException {
+
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "Adding DataKind: " + mimeType);
+ }
+
+ final DataKind kind = new DataKind(mimeType, titleRes, weight, true);
+ kind.typeColumn = typeColumn;
+ kind.actionHeader = actionHeader;
+ kind.actionBody = actionBody;
+ kind.fieldList = Lists.newArrayList();
+
+ // Get more information from the tag...
+ // A pseudo data kind doesn't have corresponding tag the XML, so we skip this.
+ if (!isPseudo) {
+ kind.typeOverallMax = getAttr(attrs, Attr.MAX_OCCURRENCE, -1);
+
+ // Process "Type" tags.
+ // If a kind has the type column, contacts.xml must have at least one type
+ // definition. Otherwise, it mustn't have a type definition.
+ if (kind.typeColumn != null) {
+ // Parse and add types.
+ kind.typeList = Lists.newArrayList();
+ parseTypes(context, parser, attrs, kind, true);
+ if (kind.typeList.size() == 0) {
+ throw new DefinitionException(
+ "Kind " + kind.mimeType + " must have at least one type");
+ }
+ } else {
+ // Make sure it has no types.
+ parseTypes(context, parser, attrs, kind, false /* can't have types */);
+ }
+ }
+
+ return kind;
+ }
+
+ /**
+ * Parses Type elements in a DataKind element, and if {@code canHaveTypes} is true adds
+ * them to the given {@link DataKind}. Otherwise the {@link DataKind} can't have a type,
+ * so throws {@link DefinitionException}.
+ */
+ private void parseTypes(Context context, XmlPullParser parser, AttributeSet attrs,
+ DataKind kind, boolean canHaveTypes)
+ throws DefinitionException, XmlPullParserException, IOException {
+ final int outerDepth = parser.getDepth();
+ int type;
+ while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
+ && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) {
+ final int depth = parser.getDepth();
+ if (type != XmlPullParser.START_TAG || depth != outerDepth + 1) {
+ continue; // Not direct child tag
+ }
+
+ final String tag = parser.getName();
+ if (Tag.TYPE.equals(tag)) {
+ if (canHaveTypes) {
+ kind.typeList.add(parseTypeTag(parser, attrs, kind));
+ } else {
+ throw new DefinitionException(
+ "Kind " + kind.mimeType + " can't have types");
+ }
+ } else {
+ throw new DefinitionException("Unknown tag: " + tag);
+ }
+ }
+ }
+
+ /**
+ * Parses a single Type element and returns an {@link EditType} built from it. Uses
+ * {@link #buildEditTypeForTypeTag} defined in subclasses to actually build an
+ * {@link EditType}.
+ */
+ private EditType parseTypeTag(XmlPullParser parser, AttributeSet attrs, DataKind kind)
+ throws DefinitionException {
+
+ final String typeName = getAttr(attrs, Attr.TYPE);
+
+ final EditType et = buildEditTypeForTypeTag(attrs, typeName);
+ if (et == null) {
+ throw new DefinitionException(
+ "Undefined type '" + typeName + "' for data kind '" + kind.mimeType + "'");
+ }
+ et.specificMax = getAttr(attrs, Attr.MAX_OCCURRENCE, -1);
+
+ return et;
+ }
+
+ /**
+ * Returns an {@link EditType} for the given "type". Subclasses may optionally use
+ * the attributes in the tag to set optional values.
+ * (e.g. "yearOptional" for the event kind)
+ */
+ protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) {
+ return null;
+ }
+
+ protected final void throwIfList(DataKind kind) throws DefinitionException {
+ if (kind.typeOverallMax != 1) {
+ throw new DefinitionException(
+ "Kind " + kind.mimeType + " must have 'overallMax=\"1\"'");
+ }
+ }
+ }
+
+ /**
+ * DataKind parser for Name. (structured, display, phonetic)
+ */
+ private static class NameKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "name";
+ }
+
+ private static void checkAttributeTrue(boolean value, String attrName)
+ throws DefinitionException {
+ if (!value) {
+ throw new DefinitionException(attrName + " must be true");
+ }
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+
+ // Build 3 data kinds:
+ // - StructuredName.CONTENT_ITEM_TYPE
+ // - DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME
+ // - DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME
+
+ final boolean displayOrderPrimary =
+ context.getResources().getBoolean(R.bool.config_editor_field_order_primary);
+
+ final boolean supportsDisplayName = getAttr(attrs, "supportsDisplayName", false);
+ final boolean supportsPrefix = getAttr(attrs, "supportsPrefix", false);
+ final boolean supportsMiddleName = getAttr(attrs, "supportsMiddleName", false);
+ final boolean supportsSuffix = getAttr(attrs, "supportsSuffix", false);
+ final boolean supportsPhoneticFamilyName =
+ getAttr(attrs, "supportsPhoneticFamilyName", false);
+ final boolean supportsPhoneticMiddleName =
+ getAttr(attrs, "supportsPhoneticMiddleName", false);
+ final boolean supportsPhoneticGivenName =
+ getAttr(attrs, "supportsPhoneticGivenName", false);
+
+ // For now, every things must be supported.
+ checkAttributeTrue(supportsDisplayName, "supportsDisplayName");
+ checkAttributeTrue(supportsPrefix, "supportsPrefix");
+ checkAttributeTrue(supportsMiddleName, "supportsMiddleName");
+ checkAttributeTrue(supportsSuffix, "supportsSuffix");
+ checkAttributeTrue(supportsPhoneticFamilyName, "supportsPhoneticFamilyName");
+ checkAttributeTrue(supportsPhoneticMiddleName, "supportsPhoneticMiddleName");
+ checkAttributeTrue(supportsPhoneticGivenName, "supportsPhoneticGivenName");
+
+ final List<DataKind> kinds = Lists.newArrayList();
+
+ // Structured name
+ final DataKind ks = newDataKind(context, parser, attrs, false,
+ StructuredName.CONTENT_ITEM_TYPE, null, R.string.nameLabelsGroup, Weight.NONE,
+ new SimpleInflater(R.string.nameLabelsGroup),
+ new SimpleInflater(Nickname.NAME));
+
+ throwIfList(ks);
+ kinds.add(ks);
+
+ // Note about setLongForm/setShortForm below.
+ // We need to set this only when the type supports display name. (=supportsDisplayName)
+ // Otherwise (i.e. Exchange) we don't set these flags, but instead make some fields
+ // "optional".
+
+ ks.fieldList.add(new EditField(StructuredName.DISPLAY_NAME, R.string.full_name,
+ FLAGS_PERSON_NAME));
+ ks.fieldList.add(new EditField(StructuredName.PREFIX, R.string.name_prefix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ ks.fieldList.add(new EditField(StructuredName.FAMILY_NAME, R.string.name_family,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ ks.fieldList.add(new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ ks.fieldList.add(new EditField(StructuredName.GIVEN_NAME, R.string.name_given,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ ks.fieldList.add(new EditField(StructuredName.SUFFIX, R.string.name_suffix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ ks.fieldList.add(new EditField(StructuredName.PHONETIC_FAMILY_NAME,
+ R.string.name_phonetic_family, FLAGS_PHONETIC));
+ ks.fieldList.add(new EditField(StructuredName.PHONETIC_MIDDLE_NAME,
+ R.string.name_phonetic_middle, FLAGS_PHONETIC));
+ ks.fieldList.add(new EditField(StructuredName.PHONETIC_GIVEN_NAME,
+ R.string.name_phonetic_given, FLAGS_PHONETIC));
+
+ // Display name
+ final DataKind kd = newDataKind(context, parser, attrs, true,
+ DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME, null,
+ R.string.nameLabelsGroup, Weight.NONE,
+ new SimpleInflater(R.string.nameLabelsGroup),
+ new SimpleInflater(Nickname.NAME));
+ kd.typeOverallMax = 1;
+ kinds.add(kd);
+
+ kd.fieldList.add(new EditField(StructuredName.DISPLAY_NAME,
+ R.string.full_name, FLAGS_PERSON_NAME).setShortForm(true));
+
+ if (!displayOrderPrimary) {
+ kd.fieldList.add(new EditField(StructuredName.PREFIX, R.string.name_prefix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kd.fieldList.add(new EditField(StructuredName.FAMILY_NAME, R.string.name_family,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kd.fieldList.add(new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kd.fieldList.add(new EditField(StructuredName.GIVEN_NAME, R.string.name_given,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kd.fieldList.add(new EditField(StructuredName.SUFFIX, R.string.name_suffix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ } else {
+ kd.fieldList.add(new EditField(StructuredName.PREFIX, R.string.name_prefix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kd.fieldList.add(new EditField(StructuredName.GIVEN_NAME, R.string.name_given,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kd.fieldList.add(new EditField(StructuredName.MIDDLE_NAME, R.string.name_middle,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kd.fieldList.add(new EditField(StructuredName.FAMILY_NAME, R.string.name_family,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ kd.fieldList.add(new EditField(StructuredName.SUFFIX, R.string.name_suffix,
+ FLAGS_PERSON_NAME).setLongForm(true));
+ }
+
+ // Phonetic name
+ final DataKind kp = newDataKind(context, parser, attrs, true,
+ DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME, null,
+ R.string.name_phonetic, Weight.NONE,
+ new SimpleInflater(R.string.nameLabelsGroup),
+ new SimpleInflater(Nickname.NAME));
+ kp.typeOverallMax = 1;
+ kinds.add(kp);
+
+ // We may want to change the order depending on displayOrderPrimary too.
+ kp.fieldList.add(new EditField(DataKind.PSEUDO_COLUMN_PHONETIC_NAME,
+ R.string.name_phonetic, FLAGS_PHONETIC).setShortForm(true));
+ kp.fieldList.add(new EditField(StructuredName.PHONETIC_FAMILY_NAME,
+ R.string.name_phonetic_family, FLAGS_PHONETIC).setLongForm(true));
+ kp.fieldList.add(new EditField(StructuredName.PHONETIC_MIDDLE_NAME,
+ R.string.name_phonetic_middle, FLAGS_PHONETIC).setLongForm(true));
+ kp.fieldList.add(new EditField(StructuredName.PHONETIC_GIVEN_NAME,
+ R.string.name_phonetic_given, FLAGS_PHONETIC).setLongForm(true));
+ return kinds;
+ }
+ }
+
+ private static class NicknameKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "nickname";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ Nickname.CONTENT_ITEM_TYPE, null, R.string.nicknameLabelsGroup, Weight.NICKNAME,
+ new SimpleInflater(R.string.nicknameLabelsGroup),
+ new SimpleInflater(Nickname.NAME));
+
+ kind.fieldList.add(new EditField(Nickname.NAME, R.string.nicknameLabelsGroup,
+ FLAGS_PERSON_NAME));
+
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Nickname.TYPE, Nickname.TYPE_DEFAULT);
+
+ throwIfList(kind);
+ return Lists.newArrayList(kind);
+ }
+ }
+
+ private static class PhoneKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "phone";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ Phone.CONTENT_ITEM_TYPE, Phone.TYPE, R.string.phoneLabelsGroup, Weight.PHONE,
+ new PhoneActionInflater(), new SimpleInflater(Phone.NUMBER));
+
+ kind.iconAltRes = R.drawable.ic_message_24dp_mirrored;
+ kind.iconAltDescriptionRes = R.string.sms;
+ kind.actionAltHeader = new PhoneActionAltInflater();
+
+ kind.fieldList.add(new EditField(Phone.NUMBER, R.string.phoneLabelsGroup, FLAGS_PHONE));
+
+ return Lists.newArrayList(kind);
+ }
+
+ /** Just to avoid line-wrapping... */
+ protected static EditType build(int type, boolean secondary) {
+ return new EditType(type, Phone.getTypeLabelResource(type)).setSecondary(secondary);
+ }
+
+ @Override
+ protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) {
+ if ("home".equals(type)) return build(Phone.TYPE_HOME, false);
+ if ("mobile".equals(type)) return build(Phone.TYPE_MOBILE, false);
+ if ("work".equals(type)) return build(Phone.TYPE_WORK, false);
+ if ("fax_work".equals(type)) return build(Phone.TYPE_FAX_WORK, true);
+ if ("fax_home".equals(type)) return build(Phone.TYPE_FAX_HOME, true);
+ if ("pager".equals(type)) return build(Phone.TYPE_PAGER, true);
+ if ("other".equals(type)) return build(Phone.TYPE_OTHER, false);
+ if ("callback".equals(type)) return build(Phone.TYPE_CALLBACK, true);
+ if ("car".equals(type)) return build(Phone.TYPE_CAR, true);
+ if ("company_main".equals(type)) return build(Phone.TYPE_COMPANY_MAIN, true);
+ if ("isdn".equals(type)) return build(Phone.TYPE_ISDN, true);
+ if ("main".equals(type)) return build(Phone.TYPE_MAIN, true);
+ if ("other_fax".equals(type)) return build(Phone.TYPE_OTHER_FAX, true);
+ if ("radio".equals(type)) return build(Phone.TYPE_RADIO, true);
+ if ("telex".equals(type)) return build(Phone.TYPE_TELEX, true);
+ if ("tty_tdd".equals(type)) return build(Phone.TYPE_TTY_TDD, true);
+ if ("work_mobile".equals(type)) return build(Phone.TYPE_WORK_MOBILE, true);
+ if ("work_pager".equals(type)) return build(Phone.TYPE_WORK_PAGER, true);
+
+ // Note "assistant" used to be a custom column for the fallback type, but not anymore.
+ if ("assistant".equals(type)) return build(Phone.TYPE_ASSISTANT, true);
+ if ("mms".equals(type)) return build(Phone.TYPE_MMS, true);
+ if ("custom".equals(type)) {
+ return build(Phone.TYPE_CUSTOM, true).setCustomColumn(Phone.LABEL);
+ }
+ return null;
+ }
+ }
+
+ private static class EmailKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "email";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ Email.CONTENT_ITEM_TYPE, Email.TYPE, R.string.emailLabelsGroup, Weight.EMAIL,
+ new EmailActionInflater(), new SimpleInflater(Email.DATA));
+ kind.fieldList.add(new EditField(Email.DATA, R.string.emailLabelsGroup, FLAGS_EMAIL));
+
+ return Lists.newArrayList(kind);
+ }
+
+ @Override
+ protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) {
+ // EditType is mutable, so we need to create a new instance every time.
+ if ("home".equals(type)) return buildEmailType(Email.TYPE_HOME);
+ if ("work".equals(type)) return buildEmailType(Email.TYPE_WORK);
+ if ("other".equals(type)) return buildEmailType(Email.TYPE_OTHER);
+ if ("mobile".equals(type)) return buildEmailType(Email.TYPE_MOBILE);
+ if ("custom".equals(type)) {
+ return buildEmailType(Email.TYPE_CUSTOM)
+ .setSecondary(true).setCustomColumn(Email.LABEL);
+ }
+ return null;
+ }
+ }
+
+ private static class StructuredPostalKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "postal";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ StructuredPostal.CONTENT_ITEM_TYPE, StructuredPostal.TYPE,
+ R.string.postalLabelsGroup, Weight.STRUCTURED_POSTAL,
+ new PostalActionInflater(),
+ new SimpleInflater(StructuredPostal.FORMATTED_ADDRESS));
+
+ if (getAttr(attrs, "needsStructured", false)) {
+ if (Locale.JAPANESE.getLanguage().equals(Locale.getDefault().getLanguage())) {
+ // Japanese order
+ kind.fieldList.add(new EditField(StructuredPostal.COUNTRY,
+ R.string.postal_country, FLAGS_POSTAL).setOptional(true));
+ kind.fieldList.add(new EditField(StructuredPostal.POSTCODE,
+ R.string.postal_postcode, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.REGION,
+ R.string.postal_region, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.CITY,
+ R.string.postal_city,FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.STREET,
+ R.string.postal_street, FLAGS_POSTAL));
+ } else {
+ // Generic order
+ kind.fieldList.add(new EditField(StructuredPostal.STREET,
+ R.string.postal_street, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.CITY,
+ R.string.postal_city,FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.REGION,
+ R.string.postal_region, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.POSTCODE,
+ R.string.postal_postcode, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.COUNTRY,
+ R.string.postal_country, FLAGS_POSTAL).setOptional(true));
+ }
+ } else {
+ kind.maxLinesForDisplay= MAX_LINES_FOR_POSTAL_ADDRESS;
+ kind.fieldList.add(
+ new EditField(StructuredPostal.FORMATTED_ADDRESS, R.string.postal_address,
+ FLAGS_POSTAL));
+ }
+
+ return Lists.newArrayList(kind);
+ }
+
+ @Override
+ protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) {
+ // EditType is mutable, so we need to create a new instance every time.
+ if ("home".equals(type)) return buildPostalType(StructuredPostal.TYPE_HOME);
+ if ("work".equals(type)) return buildPostalType(StructuredPostal.TYPE_WORK);
+ if ("other".equals(type)) return buildPostalType(StructuredPostal.TYPE_OTHER);
+ if ("custom".equals(type)) {
+ return buildPostalType(StructuredPostal.TYPE_CUSTOM)
+ .setSecondary(true).setCustomColumn(Email.LABEL);
+ }
+ return null;
+ }
+ }
+
+ private static class ImKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "im";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+
+ // IM is special:
+ // - It uses "protocol" as the custom label field
+ // - Its TYPE is fixed to TYPE_OTHER
+
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ Im.CONTENT_ITEM_TYPE, Im.PROTOCOL, R.string.imLabelsGroup, Weight.IM,
+ new ImActionInflater(), new SimpleInflater(Im.DATA) // header / action
+ );
+ kind.fieldList.add(new EditField(Im.DATA, R.string.imLabelsGroup, FLAGS_EMAIL));
+
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Im.TYPE, Im.TYPE_OTHER);
+
+ return Lists.newArrayList(kind);
+ }
+
+ @Override
+ protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) {
+ if ("aim".equals(type)) return buildImType(Im.PROTOCOL_AIM);
+ if ("msn".equals(type)) return buildImType(Im.PROTOCOL_MSN);
+ if ("yahoo".equals(type)) return buildImType(Im.PROTOCOL_YAHOO);
+ if ("skype".equals(type)) return buildImType(Im.PROTOCOL_SKYPE);
+ if ("qq".equals(type)) return buildImType(Im.PROTOCOL_QQ);
+ if ("google_talk".equals(type)) return buildImType(Im.PROTOCOL_GOOGLE_TALK);
+ if ("icq".equals(type)) return buildImType(Im.PROTOCOL_ICQ);
+ if ("jabber".equals(type)) return buildImType(Im.PROTOCOL_JABBER);
+ if ("custom".equals(type)) {
+ return buildImType(Im.PROTOCOL_CUSTOM).setSecondary(true)
+ .setCustomColumn(Im.CUSTOM_PROTOCOL);
+ }
+ return null;
+ }
+ }
+
+ private static class OrganizationKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "organization";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ Organization.CONTENT_ITEM_TYPE, null, R.string.organizationLabelsGroup,
+ Weight.ORGANIZATION,
+ new SimpleInflater(R.string.organizationLabelsGroup),
+ ORGANIZATION_BODY_INFLATER);
+
+ kind.fieldList.add(new EditField(Organization.COMPANY, R.string.ghostData_company,
+ FLAGS_GENERIC_NAME));
+ kind.fieldList.add(new EditField(Organization.TITLE, R.string.ghostData_title,
+ FLAGS_GENERIC_NAME));
+
+ throwIfList(kind);
+
+ return Lists.newArrayList(kind);
+ }
+ }
+
+ private static class PhotoKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "photo";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ Photo.CONTENT_ITEM_TYPE, null /* no type */, Weight.NONE, -1,
+ null, null // no header, no body
+ );
+
+ kind.fieldList.add(new EditField(Photo.PHOTO, -1, -1));
+
+ throwIfList(kind);
+
+ return Lists.newArrayList(kind);
+ }
+ }
+
+ private static class NoteKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "note";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ Note.CONTENT_ITEM_TYPE, null, R.string.label_notes, Weight.NOTE,
+ new SimpleInflater(R.string.label_notes), new SimpleInflater(Note.NOTE));
+
+ kind.fieldList.add(new EditField(Note.NOTE, R.string.label_notes, FLAGS_NOTE));
+ kind.maxLinesForDisplay = MAX_LINES_FOR_NOTE;
+
+ throwIfList(kind);
+
+ return Lists.newArrayList(kind);
+ }
+ }
+
+ private static class WebsiteKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "website";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ Website.CONTENT_ITEM_TYPE, null, R.string.websiteLabelsGroup, Weight.WEBSITE,
+ new SimpleInflater(R.string.websiteLabelsGroup),
+ new SimpleInflater(Website.URL));
+
+ kind.fieldList.add(new EditField(Website.URL, R.string.websiteLabelsGroup,
+ FLAGS_WEBSITE));
+
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Website.TYPE, Website.TYPE_OTHER);
+
+ return Lists.newArrayList(kind);
+ }
+ }
+
+ private static class SipAddressKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "sip_address";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ SipAddress.CONTENT_ITEM_TYPE, null, R.string.label_sip_address,
+ Weight.SIP_ADDRESS,
+ new SimpleInflater(R.string.label_sip_address),
+ new SimpleInflater(SipAddress.SIP_ADDRESS));
+
+ kind.fieldList.add(new EditField(SipAddress.SIP_ADDRESS,
+ R.string.label_sip_address, FLAGS_SIP_ADDRESS));
+
+ throwIfList(kind);
+
+ return Lists.newArrayList(kind);
+ }
+ }
+
+ private static class GroupMembershipKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "group_membership";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ GroupMembership.CONTENT_ITEM_TYPE, null,
+ R.string.groupsLabel, Weight.GROUP_MEMBERSHIP, null, null);
+
+ kind.fieldList.add(new EditField(GroupMembership.GROUP_ROW_ID, -1, -1));
+ kind.maxLinesForDisplay = MAX_LINES_FOR_GROUP;
+
+ throwIfList(kind);
+
+ return Lists.newArrayList(kind);
+ }
+ }
+
+ /**
+ * Event DataKind parser.
+ *
+ * Event DataKind is used only for Google/Exchange types, so this parser is not used for now.
+ */
+ private static class EventKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "event";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ Event.CONTENT_ITEM_TYPE, Event.TYPE, R.string.eventLabelsGroup, Weight.EVENT,
+ new EventActionInflater(), new SimpleInflater(Event.START_DATE));
+
+ kind.fieldList.add(new EditField(Event.DATA, R.string.eventLabelsGroup, FLAGS_EVENT));
+
+ if (getAttr(attrs, Attr.DATE_WITH_TIME, false)) {
+ kind.dateFormatWithoutYear = CommonDateUtils.NO_YEAR_DATE_AND_TIME_FORMAT;
+ kind.dateFormatWithYear = CommonDateUtils.DATE_AND_TIME_FORMAT;
+ } else {
+ kind.dateFormatWithoutYear = CommonDateUtils.NO_YEAR_DATE_FORMAT;
+ kind.dateFormatWithYear = CommonDateUtils.FULL_DATE_FORMAT;
+ }
+
+ return Lists.newArrayList(kind);
+ }
+
+ @Override
+ protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) {
+ final boolean yo = getAttr(attrs, Attr.YEAR_OPTIONAL, false);
+
+ if ("birthday".equals(type)) {
+ return buildEventType(Event.TYPE_BIRTHDAY, yo).setSpecificMax(1);
+ }
+ if ("anniversary".equals(type)) return buildEventType(Event.TYPE_ANNIVERSARY, yo);
+ if ("other".equals(type)) return buildEventType(Event.TYPE_OTHER, yo);
+ if ("custom".equals(type)) {
+ return buildEventType(Event.TYPE_CUSTOM, yo)
+ .setSecondary(true).setCustomColumn(Event.LABEL);
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Relationship DataKind parser.
+ *
+ * Relationship DataKind is used only for Google/Exchange types, so this parser is not used for
+ * now.
+ */
+ private static class RelationshipKindBuilder extends KindBuilder {
+ @Override
+ public String getTagName() {
+ return "relationship";
+ }
+
+ @Override
+ public List<DataKind> parseDataKind(Context context, XmlPullParser parser,
+ AttributeSet attrs) throws DefinitionException, XmlPullParserException,
+ IOException {
+ final DataKind kind = newDataKind(context, parser, attrs, false,
+ Relation.CONTENT_ITEM_TYPE, Relation.TYPE,
+ R.string.relationLabelsGroup, Weight.RELATIONSHIP,
+ new RelationActionInflater(), new SimpleInflater(Relation.NAME));
+
+ kind.fieldList.add(new EditField(Relation.DATA, R.string.relationLabelsGroup,
+ FLAGS_RELATION));
+
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Relation.TYPE, Relation.TYPE_SPOUSE);
+
+ return Lists.newArrayList(kind);
+ }
+
+ @Override
+ protected EditType buildEditTypeForTypeTag(AttributeSet attrs, String type) {
+ // EditType is mutable, so we need to create a new instance every time.
+ if ("assistant".equals(type)) return buildRelationType(Relation.TYPE_ASSISTANT);
+ if ("brother".equals(type)) return buildRelationType(Relation.TYPE_BROTHER);
+ if ("child".equals(type)) return buildRelationType(Relation.TYPE_CHILD);
+ if ("domestic_partner".equals(type)) {
+ return buildRelationType(Relation.TYPE_DOMESTIC_PARTNER);
+ }
+ if ("father".equals(type)) return buildRelationType(Relation.TYPE_FATHER);
+ if ("friend".equals(type)) return buildRelationType(Relation.TYPE_FRIEND);
+ if ("manager".equals(type)) return buildRelationType(Relation.TYPE_MANAGER);
+ if ("mother".equals(type)) return buildRelationType(Relation.TYPE_MOTHER);
+ if ("parent".equals(type)) return buildRelationType(Relation.TYPE_PARENT);
+ if ("partner".equals(type)) return buildRelationType(Relation.TYPE_PARTNER);
+ if ("referred_by".equals(type)) return buildRelationType(Relation.TYPE_REFERRED_BY);
+ if ("relative".equals(type)) return buildRelationType(Relation.TYPE_RELATIVE);
+ if ("sister".equals(type)) return buildRelationType(Relation.TYPE_SISTER);
+ if ("spouse".equals(type)) return buildRelationType(Relation.TYPE_SPOUSE);
+ if ("custom".equals(type)) {
+ return buildRelationType(Relation.TYPE_CUSTOM).setSecondary(true)
+ .setCustomColumn(Relation.LABEL);
+ }
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/model/account/ExchangeAccountType.java b/src/com/android/contacts/common/model/account/ExchangeAccountType.java
new file mode 100644
index 0000000..7020836
--- /dev/null
+++ b/src/com/android/contacts/common/model/account/ExchangeAccountType.java
@@ -0,0 +1,349 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.account;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Event;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+import android.provider.ContactsContract.CommonDataKinds.Note;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.provider.ContactsContract.CommonDataKinds.Website;
+import android.util.Log;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.dataitem.DataKind;
+import com.android.contacts.common.util.CommonDateUtils;
+import com.google.common.collect.Lists;
+
+import java.util.Locale;
+
+public class ExchangeAccountType extends BaseAccountType {
+ private static final String TAG = "ExchangeAccountType";
+
+ private static final String ACCOUNT_TYPE_AOSP = "com.android.exchange";
+ private static final String ACCOUNT_TYPE_GOOGLE_1 = "com.google.android.exchange";
+ private static final String ACCOUNT_TYPE_GOOGLE_2 = "com.google.android.gm.exchange";
+
+ public ExchangeAccountType(Context context, String authenticatorPackageName, String type) {
+ this.accountType = type;
+ this.resourcePackageName = null;
+ this.syncAdapterPackageName = authenticatorPackageName;
+
+ try {
+ addDataKindStructuredName(context);
+ addDataKindDisplayName(context);
+ addDataKindPhoneticName(context);
+ addDataKindNickname(context);
+ addDataKindPhone(context);
+ addDataKindEmail(context);
+ addDataKindStructuredPostal(context);
+ addDataKindIm(context);
+ addDataKindOrganization(context);
+ addDataKindPhoto(context);
+ addDataKindNote(context);
+ addDataKindEvent(context);
+ addDataKindWebsite(context);
+ addDataKindGroupMembership(context);
+
+ mIsInitialized = true;
+ } catch (DefinitionException e) {
+ Log.e(TAG, "Problem building account type", e);
+ }
+ }
+
+ public static boolean isExchangeType(String type) {
+ return ACCOUNT_TYPE_AOSP.equals(type) || ACCOUNT_TYPE_GOOGLE_1.equals(type)
+ || ACCOUNT_TYPE_GOOGLE_2.equals(type);
+ }
+
+ @Override
+ protected DataKind addDataKindStructuredName(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(StructuredName.CONTENT_ITEM_TYPE,
+ R.string.nameLabelsGroup, Weight.NONE, true));
+ kind.actionHeader = new SimpleInflater(R.string.nameLabelsGroup);
+ kind.actionBody = new SimpleInflater(Nickname.NAME);
+
+ kind.typeOverallMax = 1;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(StructuredName.PREFIX, R.string.name_prefix,
+ FLAGS_PERSON_NAME).setOptional(true));
+ kind.fieldList.add(new EditField(StructuredName.FAMILY_NAME,
+ R.string.name_family, FLAGS_PERSON_NAME));
+ kind.fieldList.add(new EditField(StructuredName.MIDDLE_NAME,
+ R.string.name_middle, FLAGS_PERSON_NAME));
+ kind.fieldList.add(new EditField(StructuredName.GIVEN_NAME,
+ R.string.name_given, FLAGS_PERSON_NAME));
+ kind.fieldList.add(new EditField(StructuredName.SUFFIX,
+ R.string.name_suffix, FLAGS_PERSON_NAME));
+
+ kind.fieldList.add(new EditField(StructuredName.PHONETIC_FAMILY_NAME,
+ R.string.name_phonetic_family, FLAGS_PHONETIC));
+ kind.fieldList.add(new EditField(StructuredName.PHONETIC_GIVEN_NAME,
+ R.string.name_phonetic_given, FLAGS_PHONETIC));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindDisplayName(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME,
+ R.string.nameLabelsGroup, Weight.NONE, true));
+
+ boolean displayOrderPrimary =
+ context.getResources().getBoolean(R.bool.config_editor_field_order_primary);
+ kind.typeOverallMax = 1;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(StructuredName.PREFIX, R.string.name_prefix,
+ FLAGS_PERSON_NAME).setOptional(true));
+ if (!displayOrderPrimary) {
+ kind.fieldList.add(new EditField(StructuredName.FAMILY_NAME,
+ R.string.name_family, FLAGS_PERSON_NAME));
+ kind.fieldList.add(new EditField(StructuredName.MIDDLE_NAME,
+ R.string.name_middle, FLAGS_PERSON_NAME).setOptional(true));
+ kind.fieldList.add(new EditField(StructuredName.GIVEN_NAME,
+ R.string.name_given, FLAGS_PERSON_NAME));
+ } else {
+ kind.fieldList.add(new EditField(StructuredName.GIVEN_NAME,
+ R.string.name_given, FLAGS_PERSON_NAME));
+ kind.fieldList.add(new EditField(StructuredName.MIDDLE_NAME,
+ R.string.name_middle, FLAGS_PERSON_NAME).setOptional(true));
+ kind.fieldList.add(new EditField(StructuredName.FAMILY_NAME,
+ R.string.name_family, FLAGS_PERSON_NAME));
+ }
+ kind.fieldList.add(new EditField(StructuredName.SUFFIX,
+ R.string.name_suffix, FLAGS_PERSON_NAME).setOptional(true));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindPhoneticName(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME,
+ R.string.name_phonetic, Weight.NONE, true));
+ kind.actionHeader = new SimpleInflater(R.string.nameLabelsGroup);
+ kind.actionBody = new SimpleInflater(Nickname.NAME);
+
+ kind.typeOverallMax = 1;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(StructuredName.PHONETIC_FAMILY_NAME,
+ R.string.name_phonetic_family, FLAGS_PHONETIC));
+ kind.fieldList.add(new EditField(StructuredName.PHONETIC_GIVEN_NAME,
+ R.string.name_phonetic_given, FLAGS_PHONETIC));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindNickname(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindNickname(context);
+
+ kind.typeOverallMax = 1;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Nickname.NAME, R.string.nicknameLabelsGroup,
+ FLAGS_PERSON_NAME));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindPhone(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindPhone(context);
+
+ kind.typeColumn = Phone.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildPhoneType(Phone.TYPE_MOBILE).setSpecificMax(1));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_HOME).setSpecificMax(2));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_WORK).setSpecificMax(2));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_WORK).setSecondary(true)
+ .setSpecificMax(1));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_HOME).setSecondary(true)
+ .setSpecificMax(1));
+ kind.typeList
+ .add(buildPhoneType(Phone.TYPE_PAGER).setSecondary(true).setSpecificMax(1));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_CAR).setSecondary(true).setSpecificMax(1));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_COMPANY_MAIN).setSecondary(true)
+ .setSpecificMax(1));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_MMS).setSecondary(true).setSpecificMax(1));
+ kind.typeList
+ .add(buildPhoneType(Phone.TYPE_RADIO).setSecondary(true).setSpecificMax(1));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_ASSISTANT).setSecondary(true)
+ .setSpecificMax(1));
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Phone.NUMBER, R.string.phoneLabelsGroup, FLAGS_PHONE));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindEmail(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindEmail(context);
+
+ kind.typeOverallMax = 3;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Email.DATA, R.string.emailLabelsGroup, FLAGS_EMAIL));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindStructuredPostal(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindStructuredPostal(context);
+
+ final boolean useJapaneseOrder =
+ Locale.JAPANESE.getLanguage().equals(Locale.getDefault().getLanguage());
+ kind.typeColumn = StructuredPostal.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildPostalType(StructuredPostal.TYPE_WORK).setSpecificMax(1));
+ kind.typeList.add(buildPostalType(StructuredPostal.TYPE_HOME).setSpecificMax(1));
+ kind.typeList.add(buildPostalType(StructuredPostal.TYPE_OTHER).setSpecificMax(1));
+
+ kind.fieldList = Lists.newArrayList();
+ if (useJapaneseOrder) {
+ kind.fieldList.add(new EditField(StructuredPostal.COUNTRY,
+ R.string.postal_country, FLAGS_POSTAL).setOptional(true));
+ kind.fieldList.add(new EditField(StructuredPostal.POSTCODE,
+ R.string.postal_postcode, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.REGION,
+ R.string.postal_region, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.CITY,
+ R.string.postal_city,FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.STREET,
+ R.string.postal_street, FLAGS_POSTAL));
+ } else {
+ kind.fieldList.add(new EditField(StructuredPostal.STREET,
+ R.string.postal_street, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.CITY,
+ R.string.postal_city,FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.REGION,
+ R.string.postal_region, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.POSTCODE,
+ R.string.postal_postcode, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.COUNTRY,
+ R.string.postal_country, FLAGS_POSTAL).setOptional(true));
+ }
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindIm(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindIm(context);
+
+ // Types are not supported for IM. There can be 3 IMs, but OWA only shows only the first
+ kind.typeOverallMax = 3;
+
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Im.TYPE, Im.TYPE_OTHER);
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Im.DATA, R.string.imLabelsGroup, FLAGS_EMAIL));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindOrganization(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindOrganization(context);
+
+ kind.typeOverallMax = 1;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Organization.COMPANY, R.string.ghostData_company,
+ FLAGS_GENERIC_NAME));
+ kind.fieldList.add(new EditField(Organization.TITLE, R.string.ghostData_title,
+ FLAGS_GENERIC_NAME));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindPhoto(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindPhoto(context);
+
+ kind.typeOverallMax = 1;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Photo.PHOTO, -1, -1));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindNote(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindNote(context);
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Note.NOTE, R.string.label_notes, FLAGS_NOTE));
+
+ return kind;
+ }
+
+ protected DataKind addDataKindEvent(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Event.CONTENT_ITEM_TYPE, R.string.eventLabelsGroup,
+ Weight.EVENT, true));
+ kind.actionHeader = new EventActionInflater();
+ kind.actionBody = new SimpleInflater(Event.START_DATE);
+
+ kind.typeOverallMax = 1;
+
+ kind.typeColumn = Event.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildEventType(Event.TYPE_BIRTHDAY, false).setSpecificMax(1));
+
+ kind.dateFormatWithYear = CommonDateUtils.DATE_AND_TIME_FORMAT;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Event.DATA, R.string.eventLabelsGroup, FLAGS_EVENT));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindWebsite(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindWebsite(context);
+
+ kind.typeOverallMax = 1;
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Website.URL, R.string.websiteLabelsGroup, FLAGS_WEBSITE));
+
+ return kind;
+ }
+
+ @Override
+ public boolean isGroupMembershipEditable() {
+ return true;
+ }
+
+ @Override
+ public boolean areContactsWritable() {
+ return true;
+ }
+}
diff --git a/src/com/android/contacts/common/model/account/ExternalAccountType.java b/src/com/android/contacts/common/model/account/ExternalAccountType.java
new file mode 100644
index 0000000..1298fb3
--- /dev/null
+++ b/src/com/android/contacts/common/model/account/ExternalAccountType.java
@@ -0,0 +1,441 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.account;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ServiceInfo;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.content.res.XmlResourceParser;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.Xml;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.dataitem.DataKind;
+import com.google.common.annotations.VisibleForTesting;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A general contacts account type descriptor.
+ */
+public class ExternalAccountType extends BaseAccountType {
+ private static final String TAG = "ExternalAccountType";
+
+ private static final String SYNC_META_DATA = "android.content.SyncAdapter";
+
+ /**
+ * The metadata name for so-called "contacts.xml".
+ *
+ * On LMP and later, we also accept the "alternate" name.
+ * This is to allow sync adapters to have a contacts.xml without making it visible on older
+ * platforms. If you modify this also update the corresponding list in
+ * ContactsProvider/PhotoPriorityResolver
+ */
+ private static final String[] METADATA_CONTACTS_NAMES = new String[] {
+ "android.provider.ALTERNATE_CONTACTS_STRUCTURE",
+ "android.provider.CONTACTS_STRUCTURE"
+ };
+
+ private static final String TAG_CONTACTS_SOURCE_LEGACY = "ContactsSource";
+ private static final String TAG_CONTACTS_ACCOUNT_TYPE = "ContactsAccountType";
+ private static final String TAG_CONTACTS_DATA_KIND = "ContactsDataKind";
+ private static final String TAG_EDIT_SCHEMA = "EditSchema";
+
+ private static final String ATTR_EDIT_CONTACT_ACTIVITY = "editContactActivity";
+ private static final String ATTR_CREATE_CONTACT_ACTIVITY = "createContactActivity";
+ private static final String ATTR_INVITE_CONTACT_ACTIVITY = "inviteContactActivity";
+ private static final String ATTR_INVITE_CONTACT_ACTION_LABEL = "inviteContactActionLabel";
+ private static final String ATTR_VIEW_CONTACT_NOTIFY_SERVICE = "viewContactNotifyService";
+ private static final String ATTR_VIEW_GROUP_ACTIVITY = "viewGroupActivity";
+ private static final String ATTR_VIEW_GROUP_ACTION_LABEL = "viewGroupActionLabel";
+ private static final String ATTR_DATA_SET = "dataSet";
+ private static final String ATTR_EXTENSION_PACKAGE_NAMES = "extensionPackageNames";
+
+ // The following attributes should only be set in non-sync-adapter account types. They allow
+ // for the account type and resource IDs to be specified without an associated authenticator.
+ private static final String ATTR_ACCOUNT_TYPE = "accountType";
+ private static final String ATTR_ACCOUNT_LABEL = "accountTypeLabel";
+ private static final String ATTR_ACCOUNT_ICON = "accountTypeIcon";
+
+ private final boolean mIsExtension;
+
+ private String mEditContactActivityClassName;
+ private String mCreateContactActivityClassName;
+ private String mInviteContactActivity;
+ private String mInviteActionLabelAttribute;
+ private int mInviteActionLabelResId;
+ private String mViewContactNotifyService;
+ private String mViewGroupActivity;
+ private String mViewGroupLabelAttribute;
+ private int mViewGroupLabelResId;
+ private List<String> mExtensionPackageNames;
+ private String mAccountTypeLabelAttribute;
+ private String mAccountTypeIconAttribute;
+ private boolean mHasContactsMetadata;
+ private boolean mHasEditSchema;
+
+ public ExternalAccountType(Context context, String resPackageName, boolean isExtension) {
+ this(context, resPackageName, isExtension, null);
+ }
+
+ /**
+ * Constructor used for testing to initialize with any arbitrary XML.
+ *
+ * @param injectedMetadata If non-null, it'll be used to initialize the type. Only set by
+ * tests. If null, the metadata is loaded from the specified package.
+ */
+ ExternalAccountType(Context context, String packageName, boolean isExtension,
+ XmlResourceParser injectedMetadata) {
+ this.mIsExtension = isExtension;
+ this.resourcePackageName = packageName;
+ this.syncAdapterPackageName = packageName;
+
+ final XmlResourceParser parser;
+ if (injectedMetadata == null) {
+ parser = loadContactsXml(context, packageName);
+ } else {
+ parser = injectedMetadata;
+ }
+ boolean needLineNumberInErrorLog = true;
+ try {
+ if (parser != null) {
+ inflate(context, parser);
+ }
+
+ // Done parsing; line number no longer needed in error log.
+ needLineNumberInErrorLog = false;
+ if (mHasEditSchema) {
+ checkKindExists(StructuredName.CONTENT_ITEM_TYPE);
+ checkKindExists(DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME);
+ checkKindExists(DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME);
+ checkKindExists(Photo.CONTENT_ITEM_TYPE);
+ } else {
+ // Bring in name and photo from fallback source, which are non-optional
+ addDataKindStructuredName(context);
+ addDataKindDisplayName(context);
+ addDataKindPhoneticName(context);
+ addDataKindPhoto(context);
+ }
+ } catch (DefinitionException e) {
+ final StringBuilder error = new StringBuilder();
+ error.append("Problem reading XML");
+ if (needLineNumberInErrorLog && (parser != null)) {
+ error.append(" in line ");
+ error.append(parser.getLineNumber());
+ }
+ error.append(" for external package ");
+ error.append(packageName);
+
+ Log.e(TAG, error.toString(), e);
+ return;
+ } finally {
+ if (parser != null) {
+ parser.close();
+ }
+ }
+
+ mExtensionPackageNames = new ArrayList<String>();
+ mInviteActionLabelResId = resolveExternalResId(context, mInviteActionLabelAttribute,
+ syncAdapterPackageName, ATTR_INVITE_CONTACT_ACTION_LABEL);
+ mViewGroupLabelResId = resolveExternalResId(context, mViewGroupLabelAttribute,
+ syncAdapterPackageName, ATTR_VIEW_GROUP_ACTION_LABEL);
+ titleRes = resolveExternalResId(context, mAccountTypeLabelAttribute,
+ syncAdapterPackageName, ATTR_ACCOUNT_LABEL);
+ iconRes = resolveExternalResId(context, mAccountTypeIconAttribute,
+ syncAdapterPackageName, ATTR_ACCOUNT_ICON);
+
+ // If we reach this point, the account type has been successfully initialized.
+ mIsInitialized = true;
+ }
+
+ /**
+ * Returns the CONTACTS_STRUCTURE metadata (aka "contacts.xml") in the given apk package.
+ *
+ * This method looks through all services in the package that handle sync adapter
+ * intents for the first one that contains CONTACTS_STRUCTURE metadata. We have to look
+ * through all sync adapters in the package in case there are contacts and other sync
+ * adapters (eg, calendar) in the same package.
+ *
+ * Returns {@code null} if the package has no CONTACTS_STRUCTURE metadata. In this case
+ * the account type *will* be initialized with minimal configuration.
+ */
+ public static XmlResourceParser loadContactsXml(Context context, String resPackageName) {
+ final PackageManager pm = context.getPackageManager();
+ final Intent intent = new Intent(SYNC_META_DATA).setPackage(resPackageName);
+ final List<ResolveInfo> intentServices = pm.queryIntentServices(intent,
+ PackageManager.GET_SERVICES | PackageManager.GET_META_DATA);
+
+ if (intentServices != null) {
+ for (final ResolveInfo resolveInfo : intentServices) {
+ final ServiceInfo serviceInfo = resolveInfo.serviceInfo;
+ if (serviceInfo == null) {
+ continue;
+ }
+ for (String metadataName : METADATA_CONTACTS_NAMES) {
+ final XmlResourceParser parser = serviceInfo.loadXmlMetaData(
+ pm, metadataName);
+ if (parser != null) {
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, String.format("Metadata loaded from: %s, %s, %s",
+ serviceInfo.packageName, serviceInfo.name,
+ metadataName));
+ }
+ return parser;
+ }
+ }
+ }
+ }
+
+ // Package was found, but that doesn't contain the CONTACTS_STRUCTURE metadata.
+ return null;
+ }
+
+ /**
+ * Returns {@code TRUE} if the package contains CONTACTS_STRUCTURE metadata.
+ */
+ public static boolean hasContactsXml(Context context, String resPackageName) {
+ return loadContactsXml(context, resPackageName) != null;
+ }
+
+ private void checkKindExists(String mimeType) throws DefinitionException {
+ if (getKindForMimetype(mimeType) == null) {
+ throw new DefinitionException(mimeType + " must be supported");
+ }
+ }
+
+ @Override
+ public boolean isEmbedded() {
+ return false;
+ }
+
+ @Override
+ public boolean isExtension() {
+ return mIsExtension;
+ }
+
+ @Override
+ public boolean areContactsWritable() {
+ return mHasEditSchema;
+ }
+
+ /**
+ * Whether this account type has the android.provider.CONTACTS_STRUCTURE metadata xml.
+ */
+ public boolean hasContactsMetadata() {
+ return mHasContactsMetadata;
+ }
+
+ @Override
+ public String getEditContactActivityClassName() {
+ return mEditContactActivityClassName;
+ }
+
+ @Override
+ public String getCreateContactActivityClassName() {
+ return mCreateContactActivityClassName;
+ }
+
+ @Override
+ public String getInviteContactActivityClassName() {
+ return mInviteContactActivity;
+ }
+
+ @Override
+ protected int getInviteContactActionResId() {
+ return mInviteActionLabelResId;
+ }
+
+ @Override
+ public String getViewContactNotifyServiceClassName() {
+ return mViewContactNotifyService;
+ }
+
+ @Override
+ public String getViewGroupActivity() {
+ return mViewGroupActivity;
+ }
+
+ @Override
+ protected int getViewGroupLabelResId() {
+ return mViewGroupLabelResId;
+ }
+
+ @Override
+ public List<String> getExtensionPackageNames() {
+ return mExtensionPackageNames;
+ }
+
+ /**
+ * Inflate this {@link AccountType} from the given parser. This may only
+ * load details matching the publicly-defined schema.
+ */
+ protected void inflate(Context context, XmlPullParser parser) throws DefinitionException {
+ final AttributeSet attrs = Xml.asAttributeSet(parser);
+
+ try {
+ int type;
+ while ((type = parser.next()) != XmlPullParser.START_TAG
+ && type != XmlPullParser.END_DOCUMENT) {
+ // Drain comments and whitespace
+ }
+
+ if (type != XmlPullParser.START_TAG) {
+ throw new IllegalStateException("No start tag found");
+ }
+
+ String rootTag = parser.getName();
+ if (!TAG_CONTACTS_ACCOUNT_TYPE.equals(rootTag) &&
+ !TAG_CONTACTS_SOURCE_LEGACY.equals(rootTag)) {
+ throw new IllegalStateException("Top level element must be "
+ + TAG_CONTACTS_ACCOUNT_TYPE + ", not " + rootTag);
+ }
+
+ mHasContactsMetadata = true;
+
+ int attributeCount = parser.getAttributeCount();
+ for (int i = 0; i < attributeCount; i++) {
+ String attr = parser.getAttributeName(i);
+ String value = parser.getAttributeValue(i);
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, attr + "=" + value);
+ }
+ if (ATTR_EDIT_CONTACT_ACTIVITY.equals(attr)) {
+ mEditContactActivityClassName = value;
+ } else if (ATTR_CREATE_CONTACT_ACTIVITY.equals(attr)) {
+ mCreateContactActivityClassName = value;
+ } else if (ATTR_INVITE_CONTACT_ACTIVITY.equals(attr)) {
+ mInviteContactActivity = value;
+ } else if (ATTR_INVITE_CONTACT_ACTION_LABEL.equals(attr)) {
+ mInviteActionLabelAttribute = value;
+ } else if (ATTR_VIEW_CONTACT_NOTIFY_SERVICE.equals(attr)) {
+ mViewContactNotifyService = value;
+ } else if (ATTR_VIEW_GROUP_ACTIVITY.equals(attr)) {
+ mViewGroupActivity = value;
+ } else if (ATTR_VIEW_GROUP_ACTION_LABEL.equals(attr)) {
+ mViewGroupLabelAttribute = value;
+ } else if (ATTR_DATA_SET.equals(attr)) {
+ dataSet = value;
+ } else if (ATTR_EXTENSION_PACKAGE_NAMES.equals(attr)) {
+ mExtensionPackageNames.add(value);
+ } else if (ATTR_ACCOUNT_TYPE.equals(attr)) {
+ accountType = value;
+ } else if (ATTR_ACCOUNT_LABEL.equals(attr)) {
+ mAccountTypeLabelAttribute = value;
+ } else if (ATTR_ACCOUNT_ICON.equals(attr)) {
+ mAccountTypeIconAttribute = value;
+ } else {
+ Log.e(TAG, "Unsupported attribute " + attr);
+ }
+ }
+
+ // Parse all children kinds
+ final int startDepth = parser.getDepth();
+ while (((type = parser.next()) != XmlPullParser.END_TAG
+ || parser.getDepth() > startDepth)
+ && type != XmlPullParser.END_DOCUMENT) {
+
+ if (type != XmlPullParser.START_TAG || parser.getDepth() != startDepth + 1) {
+ continue; // Not a direct child tag
+ }
+
+ String tag = parser.getName();
+ if (TAG_EDIT_SCHEMA.equals(tag)) {
+ mHasEditSchema = true;
+ parseEditSchema(context, parser, attrs);
+ } else if (TAG_CONTACTS_DATA_KIND.equals(tag)) {
+ final TypedArray a = context.obtainStyledAttributes(attrs,
+ R.styleable.ContactsDataKind);
+ final DataKind kind = new DataKind();
+
+ kind.mimeType = a
+ .getString(R.styleable.ContactsDataKind_android_mimeType);
+ final String summaryColumn = a.getString(
+ R.styleable.ContactsDataKind_android_summaryColumn);
+ if (summaryColumn != null) {
+ // Inflate a specific column as summary when requested
+ kind.actionHeader = new SimpleInflater(summaryColumn);
+ }
+ final String detailColumn = a.getString(
+ R.styleable.ContactsDataKind_android_detailColumn);
+ if (detailColumn != null) {
+ // Inflate specific column as summary
+ kind.actionBody = new SimpleInflater(detailColumn);
+ }
+
+ a.recycle();
+
+ addKind(kind);
+ }
+ }
+ } catch (XmlPullParserException e) {
+ throw new DefinitionException("Problem reading XML", e);
+ } catch (IOException e) {
+ throw new DefinitionException("Problem reading XML", e);
+ }
+ }
+
+ /**
+ * Takes a string in the "@xxx/yyy" format and return the resource ID for the resource in
+ * the resource package.
+ *
+ * If the argument is in the invalid format or isn't a resource name, it returns -1.
+ *
+ * @param context context
+ * @param resourceName Resource name in the "@xxx/yyy" format, e.g. "@string/invite_lavbel"
+ * @param packageName name of the package containing the resource.
+ * @param xmlAttributeName attribute name which the resource came from. Used for logging.
+ */
+ @VisibleForTesting
+ static int resolveExternalResId(Context context, String resourceName,
+ String packageName, String xmlAttributeName) {
+ if (TextUtils.isEmpty(resourceName)) {
+ return -1; // Empty text is okay.
+ }
+ if (resourceName.charAt(0) != '@') {
+ Log.e(TAG, xmlAttributeName + " must be a resource name beginnig with '@'");
+ return -1;
+ }
+ final String name = resourceName.substring(1);
+ final Resources res;
+ try {
+ res = context.getPackageManager().getResourcesForApplication(packageName);
+ } catch (NameNotFoundException e) {
+ Log.e(TAG, "Unable to load package " + packageName);
+ return -1;
+ }
+ final int resId = res.getIdentifier(name, null, packageName);
+ if (resId == 0) {
+ Log.e(TAG, "Unable to load " + resourceName + " from package " + packageName);
+ return -1;
+ }
+ return resId;
+ }
+}
diff --git a/src/com/android/contacts/common/model/account/FallbackAccountType.java b/src/com/android/contacts/common/model/account/FallbackAccountType.java
new file mode 100644
index 0000000..42e6b6a
--- /dev/null
+++ b/src/com/android/contacts/common/model/account/FallbackAccountType.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.account;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.dataitem.DataKind;
+import com.android.contacts.common.testing.NeededForTesting;
+
+public class FallbackAccountType extends BaseAccountType {
+ private static final String TAG = "FallbackAccountType";
+
+ private FallbackAccountType(Context context, String resPackageName) {
+ this.accountType = null;
+ this.dataSet = null;
+ this.titleRes = R.string.account_phone;
+ this.iconRes = R.drawable.ic_device;
+
+ // Note those are only set for unit tests.
+ this.resourcePackageName = resPackageName;
+ this.syncAdapterPackageName = resPackageName;
+
+ try {
+ addDataKindStructuredName(context);
+ addDataKindDisplayName(context);
+ addDataKindPhoneticName(context);
+ addDataKindNickname(context);
+ addDataKindPhone(context);
+ addDataKindEmail(context);
+ addDataKindStructuredPostal(context);
+ addDataKindIm(context);
+ addDataKindOrganization(context);
+ addDataKindPhoto(context);
+ addDataKindNote(context);
+ addDataKindWebsite(context);
+ addDataKindSipAddress(context);
+ addDataKindGroupMembership(context);
+
+ mIsInitialized = true;
+ } catch (DefinitionException e) {
+ Log.e(TAG, "Problem building account type", e);
+ }
+ }
+
+ public FallbackAccountType(Context context) {
+ this(context, null);
+ }
+
+ /**
+ * Used to compare with an {@link ExternalAccountType} built from a test contacts.xml.
+ * In order to build {@link DataKind}s with the same resource package name,
+ * {@code resPackageName} is injectable.
+ */
+ @NeededForTesting
+ static AccountType createWithPackageNameForTest(Context context, String resPackageName) {
+ return new FallbackAccountType(context, resPackageName);
+ }
+
+ @Override
+ public boolean areContactsWritable() {
+ return true;
+ }
+}
diff --git a/src/com/android/contacts/common/model/account/GoogleAccountType.java b/src/com/android/contacts/common/model/account/GoogleAccountType.java
new file mode 100644
index 0000000..8f7f172
--- /dev/null
+++ b/src/com/android/contacts/common/model/account/GoogleAccountType.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.account;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Event;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Relation;
+import android.util.Log;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.dataitem.DataKind;
+import com.android.contacts.common.util.CommonDateUtils;
+import com.google.common.collect.Lists;
+
+import java.util.List;
+
+public class GoogleAccountType extends BaseAccountType {
+ private static final String TAG = "GoogleAccountType";
+
+ /**
+ * The package name that we should load contacts.xml from and rely on to handle
+ * G+ account actions. Even though this points to gms, in some cases gms will still hand
+ * off responsibility to the G+ app.
+ */
+ public static final String PLUS_EXTENSION_PACKAGE_NAME = "com.google.android.gms";
+
+ public static final String ACCOUNT_TYPE = "com.google";
+
+ private static final List<String> mExtensionPackages =
+ Lists.newArrayList(PLUS_EXTENSION_PACKAGE_NAME);
+
+ public GoogleAccountType(Context context, String authenticatorPackageName) {
+ this.accountType = ACCOUNT_TYPE;
+ this.resourcePackageName = null;
+ this.syncAdapterPackageName = authenticatorPackageName;
+
+ try {
+ addDataKindStructuredName(context);
+ addDataKindDisplayName(context);
+ addDataKindPhoneticName(context);
+ addDataKindNickname(context);
+ addDataKindPhone(context);
+ addDataKindEmail(context);
+ addDataKindStructuredPostal(context);
+ addDataKindIm(context);
+ addDataKindOrganization(context);
+ addDataKindPhoto(context);
+ addDataKindNote(context);
+ addDataKindWebsite(context);
+ addDataKindSipAddress(context);
+ addDataKindGroupMembership(context);
+ addDataKindRelation(context);
+ addDataKindEvent(context);
+
+ mIsInitialized = true;
+ } catch (DefinitionException e) {
+ Log.e(TAG, "Problem building account type", e);
+ }
+ }
+
+ @Override
+ public List<String> getExtensionPackageNames() {
+ return mExtensionPackages;
+ }
+
+ @Override
+ protected DataKind addDataKindPhone(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindPhone(context);
+
+ kind.typeColumn = Phone.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildPhoneType(Phone.TYPE_MOBILE));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_WORK));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_HOME));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_MAIN));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_WORK).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_HOME).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_PAGER).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_OTHER));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_CUSTOM).setSecondary(true)
+ .setCustomColumn(Phone.LABEL));
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Phone.NUMBER, R.string.phoneLabelsGroup, FLAGS_PHONE));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindEmail(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindEmail(context);
+
+ kind.typeColumn = Email.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildEmailType(Email.TYPE_HOME));
+ kind.typeList.add(buildEmailType(Email.TYPE_WORK));
+ kind.typeList.add(buildEmailType(Email.TYPE_OTHER));
+ kind.typeList.add(buildEmailType(Email.TYPE_CUSTOM).setSecondary(true).setCustomColumn(
+ Email.LABEL));
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Email.DATA, R.string.emailLabelsGroup, FLAGS_EMAIL));
+
+ return kind;
+ }
+
+ private DataKind addDataKindRelation(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Relation.CONTENT_ITEM_TYPE,
+ R.string.relationLabelsGroup, Weight.RELATIONSHIP, true));
+ kind.actionHeader = new RelationActionInflater();
+ kind.actionBody = new SimpleInflater(Relation.NAME);
+
+ kind.typeColumn = Relation.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildRelationType(Relation.TYPE_ASSISTANT));
+ kind.typeList.add(buildRelationType(Relation.TYPE_BROTHER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_CHILD));
+ kind.typeList.add(buildRelationType(Relation.TYPE_DOMESTIC_PARTNER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_FATHER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_FRIEND));
+ kind.typeList.add(buildRelationType(Relation.TYPE_MANAGER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_MOTHER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_PARENT));
+ kind.typeList.add(buildRelationType(Relation.TYPE_PARTNER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_REFERRED_BY));
+ kind.typeList.add(buildRelationType(Relation.TYPE_RELATIVE));
+ kind.typeList.add(buildRelationType(Relation.TYPE_SISTER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_SPOUSE));
+ kind.typeList.add(buildRelationType(Relation.TYPE_CUSTOM).setSecondary(true)
+ .setCustomColumn(Relation.LABEL));
+
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Relation.TYPE, Relation.TYPE_SPOUSE);
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Relation.DATA, R.string.relationLabelsGroup,
+ FLAGS_RELATION));
+
+ return kind;
+ }
+
+ private DataKind addDataKindEvent(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Event.CONTENT_ITEM_TYPE,
+ R.string.eventLabelsGroup, Weight.EVENT, true));
+ kind.actionHeader = new EventActionInflater();
+ kind.actionBody = new SimpleInflater(Event.START_DATE);
+
+ kind.typeColumn = Event.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.dateFormatWithoutYear = CommonDateUtils.NO_YEAR_DATE_FORMAT;
+ kind.dateFormatWithYear = CommonDateUtils.FULL_DATE_FORMAT;
+ kind.typeList.add(buildEventType(Event.TYPE_BIRTHDAY, true).setSpecificMax(1));
+ kind.typeList.add(buildEventType(Event.TYPE_ANNIVERSARY, false));
+ kind.typeList.add(buildEventType(Event.TYPE_OTHER, false));
+ kind.typeList.add(buildEventType(Event.TYPE_CUSTOM, false).setSecondary(true)
+ .setCustomColumn(Event.LABEL));
+
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Event.TYPE, Event.TYPE_BIRTHDAY);
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Event.DATA, R.string.eventLabelsGroup, FLAGS_EVENT));
+
+ return kind;
+ }
+
+ @Override
+ public boolean isGroupMembershipEditable() {
+ return true;
+ }
+
+ @Override
+ public boolean areContactsWritable() {
+ return true;
+ }
+
+ @Override
+ public String getViewContactNotifyServiceClassName() {
+ return "com.google.android.syncadapters.contacts." +
+ "SyncHighResPhotoIntentService";
+ }
+
+ @Override
+ public String getViewContactNotifyServicePackageName() {
+ return "com.google.android.syncadapters.contacts";
+ }
+}
diff --git a/src/com/android/contacts/common/model/account/SamsungAccountType.java b/src/com/android/contacts/common/model/account/SamsungAccountType.java
new file mode 100644
index 0000000..85a9ab8
--- /dev/null
+++ b/src/com/android/contacts/common/model/account/SamsungAccountType.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.account;
+
+import com.google.common.collect.Lists;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.dataitem.DataKind;
+import com.android.contacts.common.util.CommonDateUtils;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Event;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Relation;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.util.Log;
+
+import java.util.Locale;
+
+/**
+ * A writable account type that can be used to support samsung contacts. This may not perfectly
+ * match Samsung's latest intended account schema.
+ *
+ * This is only used to partially support Samsung accounts. The DataKind labels & fields are
+ * setup to support the values used by Samsung. But, not everything in the Samsung account type is
+ * supported. The Samsung account type includes a "Message Type" mimetype that we have no intention
+ * of showing inside the Contact editor. Similarly, we don't handle the "Ringtone" mimetype here
+ * since managing ringtones is handled in a different flow.
+ */
+public class SamsungAccountType extends BaseAccountType {
+ private static final String TAG = "KnownExternalAccountType";
+ private static final String ACCOUNT_TYPE_SAMSUNG = "com.osp.app.signin";
+
+ public SamsungAccountType(Context context, String authenticatorPackageName, String type) {
+ this.accountType = type;
+ this.resourcePackageName = null;
+ this.syncAdapterPackageName = authenticatorPackageName;
+
+ try {
+ addDataKindStructuredName(context);
+ addDataKindDisplayName(context);
+ addDataKindPhoneticName(context);
+ addDataKindNickname(context);
+ addDataKindPhone(context);
+ addDataKindEmail(context);
+ addDataKindStructuredPostal(context);
+ addDataKindIm(context);
+ addDataKindOrganization(context);
+ addDataKindPhoto(context);
+ addDataKindNote(context);
+ addDataKindWebsite(context);
+ addDataKindGroupMembership(context);
+ addDataKindRelation(context);
+ addDataKindEvent(context);
+
+ mIsInitialized = true;
+ } catch (DefinitionException e) {
+ Log.e(TAG, "Problem building account type", e);
+ }
+ }
+
+ /**
+ * Returns {@code TRUE} if this is samsung's account type and Samsung hasn't bothered to
+ * define a contacts.xml to provide a more accurate definition than ours.
+ */
+ public static boolean isSamsungAccountType(Context context, String type,
+ String packageName) {
+ return ACCOUNT_TYPE_SAMSUNG.equals(type)
+ && !ExternalAccountType.hasContactsXml(context, packageName);
+ }
+
+ @Override
+ protected DataKind addDataKindStructuredPostal(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindStructuredPostal(context);
+
+ final boolean useJapaneseOrder =
+ Locale.JAPANESE.getLanguage().equals(Locale.getDefault().getLanguage());
+ kind.typeColumn = StructuredPostal.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildPostalType(StructuredPostal.TYPE_WORK).setSpecificMax(1));
+ kind.typeList.add(buildPostalType(StructuredPostal.TYPE_HOME).setSpecificMax(1));
+ kind.typeList.add(buildPostalType(StructuredPostal.TYPE_OTHER).setSpecificMax(1));
+
+ kind.fieldList = Lists.newArrayList();
+ if (useJapaneseOrder) {
+ kind.fieldList.add(new EditField(StructuredPostal.COUNTRY,
+ R.string.postal_country, FLAGS_POSTAL).setOptional(true));
+ kind.fieldList.add(new EditField(StructuredPostal.POSTCODE,
+ R.string.postal_postcode, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.REGION,
+ R.string.postal_region, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.CITY,
+ R.string.postal_city,FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.STREET,
+ R.string.postal_street, FLAGS_POSTAL));
+ } else {
+ kind.fieldList.add(new EditField(StructuredPostal.STREET,
+ R.string.postal_street, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.CITY,
+ R.string.postal_city,FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.REGION,
+ R.string.postal_region, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.POSTCODE,
+ R.string.postal_postcode, FLAGS_POSTAL));
+ kind.fieldList.add(new EditField(StructuredPostal.COUNTRY,
+ R.string.postal_country, FLAGS_POSTAL).setOptional(true));
+ }
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindPhone(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindPhone(context);
+
+ kind.typeColumn = Phone.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildPhoneType(Phone.TYPE_MOBILE));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_HOME));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_WORK));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_MAIN));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_WORK).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_FAX_HOME).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_PAGER).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_RADIO).setSecondary(true));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_OTHER));
+ kind.typeList.add(buildPhoneType(Phone.TYPE_CUSTOM).setSecondary(true)
+ .setCustomColumn(Phone.LABEL));
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Phone.NUMBER, R.string.phoneLabelsGroup, FLAGS_PHONE));
+
+ return kind;
+ }
+
+ @Override
+ protected DataKind addDataKindEmail(Context context) throws DefinitionException {
+ final DataKind kind = super.addDataKindEmail(context);
+
+ kind.typeColumn = Email.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildEmailType(Email.TYPE_HOME));
+ kind.typeList.add(buildEmailType(Email.TYPE_WORK));
+ kind.typeList.add(buildEmailType(Email.TYPE_OTHER));
+ kind.typeList.add(buildEmailType(Email.TYPE_CUSTOM).setSecondary(true).setCustomColumn(
+ Email.LABEL));
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Email.DATA, R.string.emailLabelsGroup, FLAGS_EMAIL));
+
+ return kind;
+ }
+
+ private DataKind addDataKindRelation(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Relation.CONTENT_ITEM_TYPE,
+ R.string.relationLabelsGroup, 160, true));
+ kind.actionHeader = new RelationActionInflater();
+ kind.actionBody = new SimpleInflater(Relation.NAME);
+
+ kind.typeColumn = Relation.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.typeList.add(buildRelationType(Relation.TYPE_ASSISTANT));
+ kind.typeList.add(buildRelationType(Relation.TYPE_BROTHER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_CHILD));
+ kind.typeList.add(buildRelationType(Relation.TYPE_DOMESTIC_PARTNER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_FATHER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_FRIEND));
+ kind.typeList.add(buildRelationType(Relation.TYPE_MANAGER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_MOTHER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_PARENT));
+ kind.typeList.add(buildRelationType(Relation.TYPE_PARTNER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_REFERRED_BY));
+ kind.typeList.add(buildRelationType(Relation.TYPE_RELATIVE));
+ kind.typeList.add(buildRelationType(Relation.TYPE_SISTER));
+ kind.typeList.add(buildRelationType(Relation.TYPE_SPOUSE));
+ kind.typeList.add(buildRelationType(Relation.TYPE_CUSTOM).setSecondary(true)
+ .setCustomColumn(Relation.LABEL));
+
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Relation.TYPE, Relation.TYPE_SPOUSE);
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Relation.DATA, R.string.relationLabelsGroup,
+ FLAGS_RELATION));
+
+ return kind;
+ }
+
+ private DataKind addDataKindEvent(Context context) throws DefinitionException {
+ DataKind kind = addKind(new DataKind(Event.CONTENT_ITEM_TYPE,
+ R.string.eventLabelsGroup, 150, true));
+ kind.actionHeader = new EventActionInflater();
+ kind.actionBody = new SimpleInflater(Event.START_DATE);
+
+ kind.typeColumn = Event.TYPE;
+ kind.typeList = Lists.newArrayList();
+ kind.dateFormatWithoutYear = CommonDateUtils.NO_YEAR_DATE_FORMAT;
+ kind.dateFormatWithYear = CommonDateUtils.FULL_DATE_FORMAT;
+ kind.typeList.add(buildEventType(Event.TYPE_BIRTHDAY, true).setSpecificMax(1));
+ kind.typeList.add(buildEventType(Event.TYPE_ANNIVERSARY, false));
+ kind.typeList.add(buildEventType(Event.TYPE_OTHER, false));
+ kind.typeList.add(buildEventType(Event.TYPE_CUSTOM, false).setSecondary(true)
+ .setCustomColumn(Event.LABEL));
+
+ kind.defaultValues = new ContentValues();
+ kind.defaultValues.put(Event.TYPE, Event.TYPE_BIRTHDAY);
+
+ kind.fieldList = Lists.newArrayList();
+ kind.fieldList.add(new EditField(Event.DATA, R.string.eventLabelsGroup, FLAGS_EVENT));
+
+ return kind;
+ }
+
+ @Override
+ public boolean isGroupMembershipEditable() {
+ return true;
+ }
+
+ @Override
+ public boolean areContactsWritable() {
+ return true;
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/DataItem.java b/src/com/android/contacts/common/model/dataitem/DataItem.java
new file mode 100644
index 0000000..4e66e32
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/DataItem.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Event;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+import android.provider.ContactsContract.CommonDataKinds.Identity;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+import android.provider.ContactsContract.CommonDataKinds.Note;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.CommonDataKinds.Relation;
+import android.provider.ContactsContract.CommonDataKinds.SipAddress;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.provider.ContactsContract.CommonDataKinds.Website;
+import android.provider.ContactsContract.Contacts.Data;
+import android.provider.ContactsContract.Contacts.Entity;
+
+import com.android.contacts.common.Collapser;
+import com.android.contacts.common.MoreContactUtils;
+import com.android.contacts.common.model.RawContactModifier;
+
+/**
+ * This is the base class for data items, which represents a row from the Data table.
+ */
+public class DataItem implements Collapser.Collapsible<DataItem> {
+
+ private final ContentValues mContentValues;
+ protected DataKind mKind;
+
+ protected DataItem(ContentValues values) {
+ mContentValues = values;
+ }
+
+ /**
+ * Factory for creating subclasses of DataItem objects based on the mimetype in the
+ * content values. Raw contact is the raw contact that this data item is associated with.
+ */
+ public static DataItem createFrom(ContentValues values) {
+ final String mimeType = values.getAsString(Data.MIMETYPE);
+ if (GroupMembership.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new GroupMembershipDataItem(values);
+ } else if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new StructuredNameDataItem(values);
+ } else if (Phone.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new PhoneDataItem(values);
+ } else if (Email.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new EmailDataItem(values);
+ } else if (StructuredPostal.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new StructuredPostalDataItem(values);
+ } else if (Im.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new ImDataItem(values);
+ } else if (Organization.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new OrganizationDataItem(values);
+ } else if (Nickname.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new NicknameDataItem(values);
+ } else if (Note.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new NoteDataItem(values);
+ } else if (Website.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new WebsiteDataItem(values);
+ } else if (SipAddress.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new SipAddressDataItem(values);
+ } else if (Event.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new EventDataItem(values);
+ } else if (Relation.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new RelationDataItem(values);
+ } else if (Identity.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new IdentityDataItem(values);
+ } else if (Photo.CONTENT_ITEM_TYPE.equals(mimeType)) {
+ return new PhotoDataItem(values);
+ }
+
+ // generic
+ return new DataItem(values);
+ }
+
+ public ContentValues getContentValues() {
+ return mContentValues;
+ }
+
+ public void setRawContactId(long rawContactId) {
+ mContentValues.put(Data.RAW_CONTACT_ID, rawContactId);
+ }
+
+ public Long getRawContactId() {
+ return mContentValues.getAsLong(Data.RAW_CONTACT_ID);
+ }
+
+ /**
+ * Returns the data id.
+ */
+ public long getId() {
+ return mContentValues.getAsLong(Data._ID);
+ }
+
+ /**
+ * Returns the mimetype of the data.
+ */
+ public String getMimeType() {
+ return mContentValues.getAsString(Data.MIMETYPE);
+ }
+
+ public void setMimeType(String mimeType) {
+ mContentValues.put(Data.MIMETYPE, mimeType);
+ }
+
+ public boolean isPrimary() {
+ Integer primary = mContentValues.getAsInteger(Data.IS_PRIMARY);
+ return primary != null && primary != 0;
+ }
+
+ public boolean isSuperPrimary() {
+ Integer superPrimary = mContentValues.getAsInteger(Data.IS_SUPER_PRIMARY);
+ return superPrimary != null && superPrimary != 0;
+ }
+
+ public boolean hasKindTypeColumn(DataKind kind) {
+ final String key = kind.typeColumn;
+ return key != null && mContentValues.containsKey(key) &&
+ mContentValues.getAsInteger(key) != null;
+ }
+
+ public int getKindTypeColumn(DataKind kind) {
+ final String key = kind.typeColumn;
+ return mContentValues.getAsInteger(key);
+ }
+
+ /**
+ * Indicates the carrier presence value for the current {@link DataItem}.
+ *
+ * @return {@link Data#CARRIER_PRESENCE_VT_CAPABLE} if the {@link DataItem} supports carrier
+ * video calling, {@code 0} otherwise.
+ */
+ public int getCarrierPresence() {
+ final Integer value = mContentValues.getAsInteger(Data.CARRIER_PRESENCE);
+ return value != null ? value.intValue() : 0;
+ }
+
+ /**
+ * This builds the data string depending on the type of data item by using the generic
+ * DataKind object underneath.
+ */
+ public String buildDataString(Context context, DataKind kind) {
+ if (kind.actionBody == null) {
+ return null;
+ }
+ CharSequence actionBody = kind.actionBody.inflateUsing(context, mContentValues);
+ return actionBody == null ? null : actionBody.toString();
+ }
+
+ /**
+ * This builds the data string(intended for display) depending on the type of data item. It
+ * returns the same value as {@link #buildDataString} by default, but certain data items can
+ * override it to provide their version of formatted data strings.
+ *
+ * @return Data string representing the data item, possibly formatted for display
+ */
+ public String buildDataStringForDisplay(Context context, DataKind kind) {
+ return buildDataString(context, kind);
+ }
+
+ public void setDataKind(DataKind kind) {
+ mKind = kind;
+ }
+
+ public DataKind getDataKind() {
+ return mKind;
+ }
+
+ public Integer getTimesUsed() {
+ return mContentValues.getAsInteger(Entity.TIMES_USED);
+ }
+
+ public Long getLastTimeUsed() {
+ return mContentValues.getAsLong(Entity.LAST_TIME_USED);
+ }
+
+ @Override
+ public void collapseWith(DataItem that) {
+ DataKind thisKind = getDataKind();
+ DataKind thatKind = that.getDataKind();
+ // If this does not have a type and that does, or if that's type is higher precedence,
+ // use that's type
+ if ((!hasKindTypeColumn(thisKind) && that.hasKindTypeColumn(thatKind)) ||
+ that.hasKindTypeColumn(thatKind) &&
+ RawContactModifier.getTypePrecedence(thisKind, getKindTypeColumn(thisKind))
+ >
+ RawContactModifier.getTypePrecedence(thatKind, that.getKindTypeColumn(thatKind))) {
+ mContentValues.put(thatKind.typeColumn, that.getKindTypeColumn(thatKind));
+ mKind = thatKind;
+ }
+
+ // Choose the max of the maxLines and maxLabelLines values.
+ mKind.maxLinesForDisplay = Math.max(thisKind.maxLinesForDisplay,
+ thatKind.maxLinesForDisplay);
+
+ // If any of the collapsed entries are super primary make the whole thing super primary.
+ if (isSuperPrimary() || that.isSuperPrimary()) {
+ mContentValues.put(Data.IS_SUPER_PRIMARY, 1);
+ mContentValues.put(Data.IS_PRIMARY, 1);
+ }
+
+ // If any of the collapsed entries are primary make the whole thing primary.
+ if (isPrimary() || that.isPrimary()) {
+ mContentValues.put(Data.IS_PRIMARY, 1);
+ }
+
+ // Add up the times used
+ mContentValues.put(Entity.TIMES_USED, (getTimesUsed() == null ? 0 : getTimesUsed()) +
+ (that.getTimesUsed() == null ? 0 : that.getTimesUsed()));
+
+ // Use the most recent time
+ mContentValues.put(Entity.LAST_TIME_USED,
+ Math.max(getLastTimeUsed() == null ? 0 : getLastTimeUsed(),
+ that.getLastTimeUsed() == null ? 0 : that.getLastTimeUsed()));
+ }
+
+ @Override
+ public boolean shouldCollapseWith(DataItem t, Context context) {
+ if (mKind == null || t.getDataKind() == null) {
+ return false;
+ }
+ return MoreContactUtils.shouldCollapse(getMimeType(), buildDataString(context, mKind),
+ t.getMimeType(), t.buildDataString(context, t.getDataKind()));
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/DataKind.java b/src/com/android/contacts/common/model/dataitem/DataKind.java
new file mode 100644
index 0000000..e4b6aea
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/DataKind.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.provider.ContactsContract.Data;
+
+import com.android.contacts.common.model.account.AccountType.EditField;
+import com.android.contacts.common.model.account.AccountType.EditType;
+import com.android.contacts.common.model.account.AccountType.StringInflater;
+import com.google.common.collect.Iterators;
+
+import java.text.SimpleDateFormat;
+import java.util.List;
+
+/**
+ * Description of a specific data type, usually marked by a unique
+ * {@link Data#MIMETYPE}. Includes details about how to view and edit
+ * {@link Data} rows of this kind, including the possible {@link EditType}
+ * labels and editable {@link EditField}.
+ */
+public final class DataKind {
+
+ public static final String PSEUDO_MIME_TYPE_DISPLAY_NAME = "#displayName";
+ public static final String PSEUDO_MIME_TYPE_PHONETIC_NAME = "#phoneticName";
+ public static final String PSEUDO_COLUMN_PHONETIC_NAME = "#phoneticName";
+
+ public String resourcePackageName;
+ public String mimeType;
+ public int titleRes;
+ public int iconAltRes;
+ public int iconAltDescriptionRes;
+ public int weight;
+ public boolean editable;
+
+ public StringInflater actionHeader;
+ public StringInflater actionAltHeader;
+ public StringInflater actionBody;
+
+ public String typeColumn;
+
+ /**
+ * Maximum number of values allowed in the list. -1 represents infinity.
+ */
+ public int typeOverallMax;
+
+ public List<EditType> typeList;
+ public List<EditField> fieldList;
+
+ public ContentValues defaultValues;
+
+ /**
+ * If this is a date field, this specifies the format of the date when saving. The
+ * date includes year, month and day. If this is not a date field or the date field is not
+ * editable, this value should be ignored.
+ */
+ public SimpleDateFormat dateFormatWithoutYear;
+
+ /**
+ * If this is a date field, this specifies the format of the date when saving. The
+ * date includes month and day. If this is not a date field, the field is not editable or
+ * dates without year are not supported, this value should be ignored.
+ */
+ public SimpleDateFormat dateFormatWithYear;
+
+ /**
+ * The number of lines available for displaying this kind of data.
+ * Defaults to 1.
+ */
+ public int maxLinesForDisplay;
+
+ public DataKind() {
+ maxLinesForDisplay = 1;
+ }
+
+ public DataKind(String mimeType, int titleRes, int weight, boolean editable) {
+ this.mimeType = mimeType;
+ this.titleRes = titleRes;
+ this.weight = weight;
+ this.editable = editable;
+ this.typeOverallMax = -1;
+ maxLinesForDisplay = 1;
+ }
+
+ public String getKindString(Context context) {
+ return (titleRes == -1 || titleRes == 0) ? "" : context.getString(titleRes);
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder();
+ sb.append("DataKind:");
+ sb.append(" resPackageName=").append(resourcePackageName);
+ sb.append(" mimeType=").append(mimeType);
+ sb.append(" titleRes=").append(titleRes);
+ sb.append(" iconAltRes=").append(iconAltRes);
+ sb.append(" iconAltDescriptionRes=").append(iconAltDescriptionRes);
+ sb.append(" weight=").append(weight);
+ sb.append(" editable=").append(editable);
+ sb.append(" actionHeader=").append(actionHeader);
+ sb.append(" actionAltHeader=").append(actionAltHeader);
+ sb.append(" actionBody=").append(actionBody);
+ sb.append(" typeColumn=").append(typeColumn);
+ sb.append(" typeOverallMax=").append(typeOverallMax);
+ sb.append(" typeList=").append(toString(typeList));
+ sb.append(" fieldList=").append(toString(fieldList));
+ sb.append(" defaultValues=").append(defaultValues);
+ sb.append(" dateFormatWithoutYear=").append(toString(dateFormatWithoutYear));
+ sb.append(" dateFormatWithYear=").append(toString(dateFormatWithYear));
+
+ return sb.toString();
+ }
+
+ public static String toString(SimpleDateFormat format) {
+ return format == null ? "(null)" : format.toPattern();
+ }
+
+ public static String toString(Iterable<?> list) {
+ if (list == null) {
+ return "(null)";
+ } else {
+ return Iterators.toString(list.iterator());
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/EmailDataItem.java b/src/com/android/contacts/common/model/dataitem/EmailDataItem.java
new file mode 100644
index 0000000..23efb01
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/EmailDataItem.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+
+/**
+ * Represents an email data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.Email}.
+ */
+public class EmailDataItem extends DataItem {
+
+ /* package */ EmailDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getAddress() {
+ return getContentValues().getAsString(Email.ADDRESS);
+ }
+
+ public String getDisplayName() {
+ return getContentValues().getAsString(Email.DISPLAY_NAME);
+ }
+
+ public String getData() {
+ return getContentValues().getAsString(Email.DATA);
+ }
+
+ public String getLabel() {
+ return getContentValues().getAsString(Email.LABEL);
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/EventDataItem.java b/src/com/android/contacts/common/model/dataitem/EventDataItem.java
new file mode 100644
index 0000000..5096fea
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/EventDataItem.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Event;
+import android.text.TextUtils;
+
+/**
+ * Represents an event data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.Event}.
+ */
+public class EventDataItem extends DataItem {
+
+ /* package */ EventDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getStartDate() {
+ return getContentValues().getAsString(Event.START_DATE);
+ }
+
+ public String getLabel() {
+ return getContentValues().getAsString(Event.LABEL);
+ }
+
+ @Override
+ public boolean shouldCollapseWith(DataItem t, Context context) {
+ if (!(t instanceof EventDataItem) || mKind == null || t.getDataKind() == null) {
+ return false;
+ }
+ final EventDataItem that = (EventDataItem) t;
+ // Events can be different (anniversary, birthday) but have the same start date
+ if (!TextUtils.equals(getStartDate(), that.getStartDate())) {
+ return false;
+ } else if (!hasKindTypeColumn(mKind) || !that.hasKindTypeColumn(that.getDataKind())) {
+ return hasKindTypeColumn(mKind) == that.hasKindTypeColumn(that.getDataKind());
+ } else if (getKindTypeColumn(mKind) != that.getKindTypeColumn(that.getDataKind())) {
+ return false;
+ } else if (getKindTypeColumn(mKind) == Event.TYPE_CUSTOM &&
+ !TextUtils.equals(getLabel(), that.getLabel())) {
+ // Check if custom types are not the same
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/GroupMembershipDataItem.java b/src/com/android/contacts/common/model/dataitem/GroupMembershipDataItem.java
new file mode 100644
index 0000000..41f19e6
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/GroupMembershipDataItem.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+
+/**
+ * Represents a group memebership data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.GroupMembership}.
+ */
+public class GroupMembershipDataItem extends DataItem {
+
+ /* package */ GroupMembershipDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public Long getGroupRowId() {
+ return getContentValues().getAsLong(GroupMembership.GROUP_ROW_ID);
+ }
+
+ public String getGroupSourceId() {
+ return getContentValues().getAsString(GroupMembership.GROUP_SOURCE_ID);
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/IdentityDataItem.java b/src/com/android/contacts/common/model/dataitem/IdentityDataItem.java
new file mode 100644
index 0000000..29e9a40
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/IdentityDataItem.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Identity;
+
+/**
+ * Represents an identity data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.Identity}.
+ */
+public class IdentityDataItem extends DataItem {
+
+ /* package */ IdentityDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getIdentity() {
+ return getContentValues().getAsString(Identity.IDENTITY);
+ }
+
+ public String getNamespace() {
+ return getContentValues().getAsString(Identity.NAMESPACE);
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/ImDataItem.java b/src/com/android/contacts/common/model/dataitem/ImDataItem.java
new file mode 100644
index 0000000..f89e5c6
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/ImDataItem.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.text.TextUtils;
+
+/**
+ * Represents an IM data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.Im}.
+ */
+public class ImDataItem extends DataItem {
+
+ private final boolean mCreatedFromEmail;
+
+ /* package */ ImDataItem(ContentValues values) {
+ super(values);
+ mCreatedFromEmail = false;
+ }
+
+ private ImDataItem(ContentValues values, boolean createdFromEmail) {
+ super(values);
+ mCreatedFromEmail = createdFromEmail;
+ }
+
+ public static ImDataItem createFromEmail(EmailDataItem item) {
+ final ImDataItem im = new ImDataItem(new ContentValues(item.getContentValues()), true);
+ im.setMimeType(Im.CONTENT_ITEM_TYPE);
+ return im;
+ }
+
+ public String getData() {
+ if (mCreatedFromEmail) {
+ return getContentValues().getAsString(Email.DATA);
+ } else {
+ return getContentValues().getAsString(Im.DATA);
+ }
+ }
+
+ public String getLabel() {
+ return getContentValues().getAsString(Im.LABEL);
+ }
+
+ /**
+ * Values are one of Im.PROTOCOL_
+ */
+ public Integer getProtocol() {
+ return getContentValues().getAsInteger(Im.PROTOCOL);
+ }
+
+ public boolean isProtocolValid() {
+ return getProtocol() != null;
+ }
+
+ public String getCustomProtocol() {
+ return getContentValues().getAsString(Im.CUSTOM_PROTOCOL);
+ }
+
+ public int getChatCapability() {
+ Integer result = getContentValues().getAsInteger(Im.CHAT_CAPABILITY);
+ return result == null ? 0 : result;
+ }
+
+ public boolean isCreatedFromEmail() {
+ return mCreatedFromEmail;
+ }
+
+ @Override
+ public boolean shouldCollapseWith(DataItem t, Context context) {
+ if (!(t instanceof ImDataItem) || mKind == null || t.getDataKind() == null) {
+ return false;
+ }
+ final ImDataItem that = (ImDataItem) t;
+ // IM can have the same data put different protocol. These should not collapse.
+ if (!getData().equals(that.getData())) {
+ return false;
+ } else if (!isProtocolValid() || !that.isProtocolValid()) {
+ // Deal with invalid protocol as if it was custom. If either has a non valid
+ // protocol, check to see if the other has a valid that is not custom
+ if (isProtocolValid()) {
+ return getProtocol() == Im.PROTOCOL_CUSTOM;
+ } else if (that.isProtocolValid()) {
+ return that.getProtocol() == Im.PROTOCOL_CUSTOM;
+ }
+ return true;
+ } else if (getProtocol() != that.getProtocol()) {
+ return false;
+ } else if (getProtocol() == Im.PROTOCOL_CUSTOM &&
+ !TextUtils.equals(getCustomProtocol(), that.getCustomProtocol())) {
+ // Check if custom protocols are not the same
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/NicknameDataItem.java b/src/com/android/contacts/common/model/dataitem/NicknameDataItem.java
new file mode 100644
index 0000000..e7f9d4a
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/NicknameDataItem.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+
+/**
+ * Represents a nickname data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.Nickname}.
+ */
+public class NicknameDataItem extends DataItem {
+
+ public NicknameDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getName() {
+ return getContentValues().getAsString(Nickname.NAME);
+ }
+
+ public String getLabel() {
+ return getContentValues().getAsString(Nickname.LABEL);
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/NoteDataItem.java b/src/com/android/contacts/common/model/dataitem/NoteDataItem.java
new file mode 100644
index 0000000..3d71167
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/NoteDataItem.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Note;
+
+/**
+ * Represents a note data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.Note}.
+ */
+public class NoteDataItem extends DataItem {
+
+ /* package */ NoteDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getNote() {
+ return getContentValues().getAsString(Note.NOTE);
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/OrganizationDataItem.java b/src/com/android/contacts/common/model/dataitem/OrganizationDataItem.java
new file mode 100644
index 0000000..37cd852
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/OrganizationDataItem.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+
+/**
+ * Represents an organization data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.Organization}.
+ */
+public class OrganizationDataItem extends DataItem {
+
+ /* package */ OrganizationDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getCompany() {
+ return getContentValues().getAsString(Organization.COMPANY);
+ }
+
+ public String getLabel() {
+ return getContentValues().getAsString(Organization.LABEL);
+ }
+
+ public String getTitle() {
+ return getContentValues().getAsString(Organization.TITLE);
+ }
+
+ public String getDepartment() {
+ return getContentValues().getAsString(Organization.DEPARTMENT);
+ }
+
+ public String getJobDescription() {
+ return getContentValues().getAsString(Organization.JOB_DESCRIPTION);
+ }
+
+ public String getSymbol() {
+ return getContentValues().getAsString(Organization.SYMBOL);
+ }
+
+ public String getPhoneticName() {
+ return getContentValues().getAsString(Organization.PHONETIC_NAME);
+ }
+
+ public String getOfficeLocation() {
+ return getContentValues().getAsString(Organization.OFFICE_LOCATION);
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/PhoneDataItem.java b/src/com/android/contacts/common/model/dataitem/PhoneDataItem.java
new file mode 100644
index 0000000..d6aa2a9
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/PhoneDataItem.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+
+import com.android.contacts.common.compat.PhoneNumberUtilsCompat;
+import com.android.contacts.common.model.dataitem.DataKind;
+
+/**
+ * Represents a phone data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.Phone}.
+ */
+public class PhoneDataItem extends DataItem {
+
+ public static final String KEY_FORMATTED_PHONE_NUMBER = "formattedPhoneNumber";
+
+ /* package */ PhoneDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getNumber() {
+ return getContentValues().getAsString(Phone.NUMBER);
+ }
+
+ /**
+ * Returns the normalized phone number in E164 format.
+ */
+ public String getNormalizedNumber() {
+ return getContentValues().getAsString(Phone.NORMALIZED_NUMBER);
+ }
+
+ public String getFormattedPhoneNumber() {
+ return getContentValues().getAsString(KEY_FORMATTED_PHONE_NUMBER);
+ }
+
+ public String getLabel() {
+ return getContentValues().getAsString(Phone.LABEL);
+ }
+
+ public void computeFormattedPhoneNumber(String defaultCountryIso) {
+ final String phoneNumber = getNumber();
+ if (phoneNumber != null) {
+ final String formattedPhoneNumber = PhoneNumberUtilsCompat.formatNumber(phoneNumber,
+ getNormalizedNumber(), defaultCountryIso);
+ getContentValues().put(KEY_FORMATTED_PHONE_NUMBER, formattedPhoneNumber);
+ }
+ }
+
+ /**
+ * Returns the formatted phone number (if already computed using {@link
+ * #computeFormattedPhoneNumber}). Otherwise this method returns the unformatted phone number.
+ */
+ @Override
+ public String buildDataStringForDisplay(Context context, DataKind kind) {
+ final String formatted = getFormattedPhoneNumber();
+ if (formatted != null) {
+ return formatted;
+ } else {
+ return getNumber();
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/PhotoDataItem.java b/src/com/android/contacts/common/model/dataitem/PhotoDataItem.java
new file mode 100644
index 0000000..a61218b
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/PhotoDataItem.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Contacts.Photo;
+
+/**
+ * Represents a photo data item, wrapping the columns in
+ * {@link ContactsContract.Contacts.Photo}.
+ */
+public class PhotoDataItem extends DataItem {
+
+ /* package */ PhotoDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public Long getPhotoFileId() {
+ return getContentValues().getAsLong(Photo.PHOTO_FILE_ID);
+ }
+
+ public byte[] getPhoto() {
+ return getContentValues().getAsByteArray(Photo.PHOTO);
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/RelationDataItem.java b/src/com/android/contacts/common/model/dataitem/RelationDataItem.java
new file mode 100644
index 0000000..9e883fe
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/RelationDataItem.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Relation;
+import android.text.TextUtils;
+
+/**
+ * Represents a relation data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.Relation}.
+ */
+public class RelationDataItem extends DataItem {
+
+ /* package */ RelationDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getName() {
+ return getContentValues().getAsString(Relation.NAME);
+ }
+
+ public String getLabel() {
+ return getContentValues().getAsString(Relation.LABEL);
+ }
+
+ @Override
+ public boolean shouldCollapseWith(DataItem t, Context context) {
+ if (!(t instanceof RelationDataItem) || mKind == null || t.getDataKind() == null) {
+ return false;
+ }
+ final RelationDataItem that = (RelationDataItem) t;
+ // Relations can have different types (assistant, father) but have the same name
+ if (!TextUtils.equals(getName(), that.getName())) {
+ return false;
+ } else if (!hasKindTypeColumn(mKind) || !that.hasKindTypeColumn(that.getDataKind())) {
+ return hasKindTypeColumn(mKind) == that.hasKindTypeColumn(that.getDataKind());
+ } else if (getKindTypeColumn(mKind) != that.getKindTypeColumn(that.getDataKind())) {
+ return false;
+ } else if (getKindTypeColumn(mKind) == Relation.TYPE_CUSTOM &&
+ !TextUtils.equals(getLabel(), that.getLabel())) {
+ // Check if custom types are not the same
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/SipAddressDataItem.java b/src/com/android/contacts/common/model/dataitem/SipAddressDataItem.java
new file mode 100644
index 0000000..ec704fc
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/SipAddressDataItem.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.SipAddress;
+
+/**
+ * Represents a sip address data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.SipAddress}.
+ */
+public class SipAddressDataItem extends DataItem {
+
+ /* package */ SipAddressDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getSipAddress() {
+ return getContentValues().getAsString(SipAddress.SIP_ADDRESS);
+ }
+
+ public String getLabel() {
+ return getContentValues().getAsString(SipAddress.LABEL);
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/StructuredNameDataItem.java b/src/com/android/contacts/common/model/dataitem/StructuredNameDataItem.java
new file mode 100644
index 0000000..4d463da
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/StructuredNameDataItem.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.Contacts.Data;
+
+/**
+ * Represents a structured name data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.StructuredName}.
+ */
+public class StructuredNameDataItem extends DataItem {
+
+ public StructuredNameDataItem() {
+ super(new ContentValues());
+ getContentValues().put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
+ }
+
+ /* package */ StructuredNameDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getDisplayName() {
+ return getContentValues().getAsString(StructuredName.DISPLAY_NAME);
+ }
+
+ public void setDisplayName(String name) {
+ getContentValues().put(StructuredName.DISPLAY_NAME, name);
+ }
+
+ public String getGivenName() {
+ return getContentValues().getAsString(StructuredName.GIVEN_NAME);
+ }
+
+ public String getFamilyName() {
+ return getContentValues().getAsString(StructuredName.FAMILY_NAME);
+ }
+
+ public String getPrefix() {
+ return getContentValues().getAsString(StructuredName.PREFIX);
+ }
+
+ public String getMiddleName() {
+ return getContentValues().getAsString(StructuredName.MIDDLE_NAME);
+ }
+
+ public String getSuffix() {
+ return getContentValues().getAsString(StructuredName.SUFFIX);
+ }
+
+ public String getPhoneticGivenName() {
+ return getContentValues().getAsString(StructuredName.PHONETIC_GIVEN_NAME);
+ }
+
+ public String getPhoneticMiddleName() {
+ return getContentValues().getAsString(StructuredName.PHONETIC_MIDDLE_NAME);
+ }
+
+ public String getPhoneticFamilyName() {
+ return getContentValues().getAsString(StructuredName.PHONETIC_FAMILY_NAME);
+ }
+
+ public String getFullNameStyle() {
+ return getContentValues().getAsString(StructuredName.FULL_NAME_STYLE);
+ }
+
+ public void setPhoneticFamilyName(String name) {
+ getContentValues().put(StructuredName.PHONETIC_FAMILY_NAME, name);
+ }
+
+ public void setPhoneticMiddleName(String name) {
+ getContentValues().put(StructuredName.PHONETIC_MIDDLE_NAME, name);
+ }
+
+ public void setPhoneticGivenName(String name) {
+ getContentValues().put(StructuredName.PHONETIC_GIVEN_NAME, name);
+ }
+
+ public boolean isSuperPrimary() {
+ final ContentValues contentValues = getContentValues();
+ return contentValues == null || !contentValues.containsKey(StructuredName.IS_SUPER_PRIMARY)
+ ? false : contentValues.getAsBoolean(StructuredName.IS_SUPER_PRIMARY);
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/StructuredPostalDataItem.java b/src/com/android/contacts/common/model/dataitem/StructuredPostalDataItem.java
new file mode 100644
index 0000000..6cfc0c1
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/StructuredPostalDataItem.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+
+/**
+ * Represents a structured postal data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.StructuredPostal}.
+ */
+public class StructuredPostalDataItem extends DataItem {
+
+ /* package */ StructuredPostalDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getFormattedAddress() {
+ return getContentValues().getAsString(StructuredPostal.FORMATTED_ADDRESS);
+ }
+
+ public String getLabel() {
+ return getContentValues().getAsString(StructuredPostal.LABEL);
+ }
+
+ public String getStreet() {
+ return getContentValues().getAsString(StructuredPostal.STREET);
+ }
+
+ public String getPOBox() {
+ return getContentValues().getAsString(StructuredPostal.POBOX);
+ }
+
+ public String getNeighborhood() {
+ return getContentValues().getAsString(StructuredPostal.NEIGHBORHOOD);
+ }
+
+ public String getCity() {
+ return getContentValues().getAsString(StructuredPostal.CITY);
+ }
+
+ public String getRegion() {
+ return getContentValues().getAsString(StructuredPostal.REGION);
+ }
+
+ public String getPostcode() {
+ return getContentValues().getAsString(StructuredPostal.POSTCODE);
+ }
+
+ public String getCountry() {
+ return getContentValues().getAsString(StructuredPostal.COUNTRY);
+ }
+}
diff --git a/src/com/android/contacts/common/model/dataitem/WebsiteDataItem.java b/src/com/android/contacts/common/model/dataitem/WebsiteDataItem.java
new file mode 100644
index 0000000..0939421
--- /dev/null
+++ b/src/com/android/contacts/common/model/dataitem/WebsiteDataItem.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Website;
+
+/**
+ * Represents a website data item, wrapping the columns in
+ * {@link ContactsContract.CommonDataKinds.Website}.
+ */
+public class WebsiteDataItem extends DataItem {
+
+ /* package */ WebsiteDataItem(ContentValues values) {
+ super(values);
+ }
+
+ public String getUrl() {
+ return getContentValues().getAsString(Website.URL);
+ }
+
+ public String getLabel() {
+ return getContentValues().getAsString(Website.LABEL);
+ }
+}
diff --git a/src/com/android/contacts/common/preference/AboutPreferenceFragment.java b/src/com/android/contacts/common/preference/AboutPreferenceFragment.java
new file mode 100644
index 0000000..3b5a28d
--- /dev/null
+++ b/src/com/android/contacts/common/preference/AboutPreferenceFragment.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.preference;
+
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import android.widget.Toast;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.activity.LicenseActivity;
+
+/**
+ * This fragment shows the preferences for "about".
+ */
+public class AboutPreferenceFragment extends PreferenceFragment {
+
+ private static final String PRIVACY_POLICY_URL = "http://www.google.com/policies/privacy";
+ private static final String TERMS_OF_SERVICE_URL = "http://www.google.com/policies/terms";
+
+ public static AboutPreferenceFragment newInstance() {
+ return new AboutPreferenceFragment();
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Load the preferences from an XML resource
+ addPreferencesFromResource(R.xml.preference_about);
+
+ // Set build version of Contacts App.
+ final PackageManager manager = getActivity().getPackageManager();
+ try {
+ final PackageInfo info = manager.getPackageInfo(getActivity().getPackageName(), 0);
+ final Preference versionPreference = findPreference(
+ getString(R.string.pref_build_version_key));
+ versionPreference.setSummary(info.versionName);
+ } catch (PackageManager.NameNotFoundException e) {
+ // Nothing
+ }
+
+ final Preference licensePreference = findPreference(
+ getString(R.string.pref_open_source_licenses_key));
+ licensePreference.setIntent(new Intent(getActivity(), LicenseActivity.class));
+
+ final Preference privacyPolicyPreference = findPreference("pref_privacy_policy");
+ final Preference termsOfServicePreference = findPreference("pref_terms_of_service");
+
+ final Preference.OnPreferenceClickListener listener =
+ new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ try {
+ if (preference == privacyPolicyPreference) {
+ startActivityForUrl(PRIVACY_POLICY_URL);
+ } else if (preference == termsOfServicePreference) {
+ startActivityForUrl(TERMS_OF_SERVICE_URL);
+ }
+ } catch (ActivityNotFoundException ex) {
+ Toast.makeText(getContext(), getString(R.string.url_open_error_toast),
+ Toast.LENGTH_SHORT).show();
+ }
+ return true;
+ }
+ };
+
+ privacyPolicyPreference.setOnPreferenceClickListener(listener);
+ termsOfServicePreference.setOnPreferenceClickListener(listener);
+ }
+
+ @Override
+ public Context getContext() {
+ return getActivity();
+ }
+
+ private void startActivityForUrl(String urlString) {
+ final Intent intent = new Intent();
+ intent.setAction(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse(urlString));
+ startActivity(intent);
+ }
+}
+
diff --git a/src/com/android/contacts/common/preference/ContactsPreferenceActivity.java b/src/com/android/contacts/common/preference/ContactsPreferenceActivity.java
new file mode 100644
index 0000000..dbf3cb9
--- /dev/null
+++ b/src/com/android/contacts/common/preference/ContactsPreferenceActivity.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.preference;
+
+import android.app.ActionBar;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.preference.PreferenceActivity;
+import android.provider.ContactsContract.ProviderStatus;
+import android.provider.ContactsContract.QuickContact;
+import android.text.TextUtils;
+import android.view.MenuItem;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.list.ProviderStatusWatcher;
+import com.android.contacts.common.preference.DisplayOptionsPreferenceFragment.ProfileListener;
+import com.android.contacts.common.preference.DisplayOptionsPreferenceFragment.ProfileQuery;
+
+/**
+ * Contacts settings.
+ */
+public final class ContactsPreferenceActivity extends PreferenceActivity implements
+ ProfileListener {
+
+ private static final String TAG_ABOUT = "about_contacts";
+ private static final String TAG_DISPLAY_OPTIONS = "display_options";
+
+ private String mNewLocalProfileExtra;
+ private String mPreviousScreenExtra;
+ private int mModeFullyExpanded;
+ private boolean mAreContactsAvailable;
+
+ private ProviderStatusWatcher mProviderStatusWatcher;
+
+ public static final String EXTRA_NEW_LOCAL_PROFILE = "newLocalProfile";
+ public static final String EXTRA_MODE_FULLY_EXPANDED = "modeFullyExpanded";
+ public static final String EXTRA_PREVIOUS_SCREEN_TYPE = "previousScreenType";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP);
+ }
+
+ mProviderStatusWatcher = ProviderStatusWatcher.getInstance(this);
+
+ mNewLocalProfileExtra = getIntent().getStringExtra(EXTRA_NEW_LOCAL_PROFILE);
+ mModeFullyExpanded = getIntent().getIntExtra(EXTRA_MODE_FULLY_EXPANDED,
+ QuickContact.MODE_LARGE);
+ mPreviousScreenExtra = getIntent().getStringExtra(EXTRA_PREVIOUS_SCREEN_TYPE);
+ final int providerStatus = mProviderStatusWatcher.getProviderStatus();
+ mAreContactsAvailable = providerStatus == ProviderStatus.STATUS_NORMAL;
+
+ if (savedInstanceState == null) {
+ final DisplayOptionsPreferenceFragment fragment = DisplayOptionsPreferenceFragment
+ .newInstance(mNewLocalProfileExtra, mPreviousScreenExtra, mModeFullyExpanded,
+ mAreContactsAvailable);
+ getFragmentManager().beginTransaction()
+ .replace(android.R.id.content, fragment, TAG_DISPLAY_OPTIONS)
+ .commit();
+ setActivityTitle(R.string.activity_title_settings);
+ } else {
+ final AboutPreferenceFragment aboutFragment = (AboutPreferenceFragment)
+ getFragmentManager().findFragmentByTag(TAG_ABOUT);
+
+ if (aboutFragment != null) {
+ setActivityTitle(R.string.setting_about);
+ } else {
+ setActivityTitle(R.string.activity_title_settings);
+ }
+ }
+ }
+
+ protected void showAboutFragment() {
+ getFragmentManager().beginTransaction()
+ .replace(android.R.id.content, AboutPreferenceFragment.newInstance(), TAG_ABOUT)
+ .addToBackStack(null)
+ .commit();
+ setActivityTitle(R.string.setting_about);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ onBackPressed();
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (getFragmentManager().getBackStackEntryCount() > 0) {
+ setActivityTitle(R.string.activity_title_settings);
+ getFragmentManager().popBackStack();
+ } else {
+ super.onBackPressed();
+ }
+ }
+
+ private void setActivityTitle(int res) {
+ final ActionBar actionBar = getActionBar();
+ if (actionBar != null) {
+ actionBar.setTitle(res);
+ }
+ }
+
+ @Override
+ public void onProfileLoaded(Cursor cursor) {
+ boolean hasProfile = false;
+ String displayName = null;
+ long contactId = -1;
+ if (cursor != null && cursor.moveToFirst()) {
+ hasProfile = cursor.getInt(ProfileQuery.CONTACT_IS_USER_PROFILE) == 1;
+ displayName = cursor.getString(ProfileQuery.CONTACT_DISPLAY_NAME);
+ contactId = cursor.getLong(ProfileQuery.CONTACT_ID);
+ }
+ if (hasProfile && TextUtils.isEmpty(displayName)) {
+ displayName = getString(R.string.missing_name);
+ }
+ final DisplayOptionsPreferenceFragment fragment = (DisplayOptionsPreferenceFragment)
+ getFragmentManager().findFragmentByTag(TAG_DISPLAY_OPTIONS);
+ fragment.updateMyInfoPreference(hasProfile, displayName, contactId);
+ }
+}
diff --git a/src/com/android/contacts/common/preference/ContactsPreferences.java b/src/com/android/contacts/common/preference/ContactsPreferences.java
new file mode 100644
index 0000000..a8a9089
--- /dev/null
+++ b/src/com/android/contacts/common/preference/ContactsPreferences.java
@@ -0,0 +1,365 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.preference;
+
+import android.accounts.Account;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.preference.PreferenceManager;
+import android.provider.ContactsContract;
+import android.provider.Settings;
+import android.provider.Settings.SettingNotFoundException;
+import android.text.TextUtils;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.model.account.GoogleAccountType;
+import com.android.contacts.common.model.AccountTypeManager;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Manages user preferences for contacts.
+ */
+public class ContactsPreferences implements OnSharedPreferenceChangeListener {
+
+ /**
+ * The value for the DISPLAY_ORDER key to show the given name first.
+ */
+ public static final int DISPLAY_ORDER_PRIMARY = 1;
+
+ /**
+ * The value for the DISPLAY_ORDER key to show the family name first.
+ */
+ public static final int DISPLAY_ORDER_ALTERNATIVE = 2;
+
+ public static final String DISPLAY_ORDER_KEY = "android.contacts.DISPLAY_ORDER";
+
+ /**
+ * The value for the SORT_ORDER key corresponding to sort by given name first.
+ */
+ public static final int SORT_ORDER_PRIMARY = 1;
+
+ public static final String SORT_ORDER_KEY = "android.contacts.SORT_ORDER";
+
+ /**
+ * The value for the SORT_ORDER key corresponding to sort by family name first.
+ */
+ public static final int SORT_ORDER_ALTERNATIVE = 2;
+
+ public static final String PREF_DISPLAY_ONLY_PHONES = "only_phones";
+
+ public static final boolean PREF_DISPLAY_ONLY_PHONES_DEFAULT = false;
+
+ public static final String DO_NOT_SYNC_CONTACT_METADATA_MSG = "Do not sync metadata";
+
+ public static final String CONTACT_METADATA_AUTHORITY = "com.android.contacts.metadata";
+
+ public static final String SHOULD_CLEAR_METADATA_BEFORE_SYNCING =
+ "should_clear_metadata_before_syncing";
+
+ public static final String ONLY_CLEAR_DONOT_SYNC = "only_clear_donot_sync";
+ /**
+ * Value to use when a preference is unassigned and needs to be read from the shared preferences
+ */
+ private static final int PREFERENCE_UNASSIGNED = -1;
+
+ private final Context mContext;
+ private int mSortOrder = PREFERENCE_UNASSIGNED;
+ private int mDisplayOrder = PREFERENCE_UNASSIGNED;
+ private String mDefaultAccount = null;
+ private ChangeListener mListener = null;
+ private Handler mHandler;
+ private final SharedPreferences mPreferences;
+ private String mDefaultAccountKey;
+ private String mDefaultAccountSavedKey;
+
+ public ContactsPreferences(Context context) {
+ mContext = context;
+ mHandler = new Handler();
+ mPreferences = mContext.getSharedPreferences(context.getPackageName(),
+ Context.MODE_PRIVATE);
+ mDefaultAccountKey = mContext.getResources().getString(
+ R.string.contact_editor_default_account_key);
+ mDefaultAccountSavedKey = mContext.getResources().getString(
+ R.string.contact_editor_anything_saved_key);
+ maybeMigrateSystemSettings();
+ }
+
+ public boolean isSortOrderUserChangeable() {
+ return mContext.getResources().getBoolean(R.bool.config_sort_order_user_changeable);
+ }
+
+ public int getDefaultSortOrder() {
+ if (mContext.getResources().getBoolean(R.bool.config_default_sort_order_primary)) {
+ return SORT_ORDER_PRIMARY;
+ } else {
+ return SORT_ORDER_ALTERNATIVE;
+ }
+ }
+
+ public int getSortOrder() {
+ if (!isSortOrderUserChangeable()) {
+ return getDefaultSortOrder();
+ }
+ if (mSortOrder == PREFERENCE_UNASSIGNED) {
+ mSortOrder = mPreferences.getInt(SORT_ORDER_KEY, getDefaultSortOrder());
+ }
+ return mSortOrder;
+ }
+
+ public void setSortOrder(int sortOrder) {
+ mSortOrder = sortOrder;
+ final Editor editor = mPreferences.edit();
+ editor.putInt(SORT_ORDER_KEY, sortOrder);
+ editor.commit();
+ }
+
+ public boolean isDisplayOrderUserChangeable() {
+ return mContext.getResources().getBoolean(R.bool.config_display_order_user_changeable);
+ }
+
+ public int getDefaultDisplayOrder() {
+ if (mContext.getResources().getBoolean(R.bool.config_default_display_order_primary)) {
+ return DISPLAY_ORDER_PRIMARY;
+ } else {
+ return DISPLAY_ORDER_ALTERNATIVE;
+ }
+ }
+
+ public int getDisplayOrder() {
+ if (!isDisplayOrderUserChangeable()) {
+ return getDefaultDisplayOrder();
+ }
+ if (mDisplayOrder == PREFERENCE_UNASSIGNED) {
+ mDisplayOrder = mPreferences.getInt(DISPLAY_ORDER_KEY, getDefaultDisplayOrder());
+ }
+ return mDisplayOrder;
+ }
+
+ public void setDisplayOrder(int displayOrder) {
+ mDisplayOrder = displayOrder;
+ final Editor editor = mPreferences.edit();
+ editor.putInt(DISPLAY_ORDER_KEY, displayOrder);
+ editor.commit();
+ }
+
+ public boolean isDefaultAccountUserChangeable() {
+ return mContext.getResources().getBoolean(R.bool.config_default_account_user_changeable);
+ }
+
+ public String getDefaultAccount() {
+ if (!isDefaultAccountUserChangeable()) {
+ return mDefaultAccount;
+ }
+ if (TextUtils.isEmpty(mDefaultAccount)) {
+ final String accountString = mPreferences
+ .getString(mDefaultAccountKey, mDefaultAccount);
+ if (!TextUtils.isEmpty(accountString)) {
+ final AccountWithDataSet accountWithDataSet = AccountWithDataSet.unstringify(
+ accountString);
+ mDefaultAccount = accountWithDataSet.name;
+ }
+ }
+ return mDefaultAccount;
+ }
+
+ public void setDefaultAccount(AccountWithDataSet accountWithDataSet) {
+ mDefaultAccount = accountWithDataSet == null ? null : accountWithDataSet.name;
+ final Editor editor = mPreferences.edit();
+ if (TextUtils.isEmpty(mDefaultAccount)) {
+ editor.remove(mDefaultAccountKey);
+ } else {
+ editor.putString(mDefaultAccountKey, accountWithDataSet.stringify());
+ }
+ editor.putBoolean(mDefaultAccountSavedKey, true);
+ editor.commit();
+ }
+
+ public String getContactMetadataSyncAccountName() {
+ final Account syncAccount = getContactMetadataSyncAccount();
+ return syncAccount == null ? DO_NOT_SYNC_CONTACT_METADATA_MSG : syncAccount.name;
+ }
+
+ public void setContactMetadataSyncAccount(AccountWithDataSet accountWithDataSet) {
+ final String mContactMetadataSyncAccount =
+ accountWithDataSet == null ? null : accountWithDataSet.name;
+ requestMetadataSyncForAccount(mContactMetadataSyncAccount);
+ }
+
+ private Account getContactMetadataSyncAccount() {
+ for (Account account : getFocusGoogleAccounts()) {
+ if (ContentResolver.getIsSyncable(account, CONTACT_METADATA_AUTHORITY) == 1
+ && ContentResolver.getSyncAutomatically(account, CONTACT_METADATA_AUTHORITY)) {
+ return account;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Turn on contact metadata sync for this {@param accountName} and turn off automatic sync
+ * for other accounts. If accountName is null, then turn off automatic sync for all accounts.
+ */
+ private void requestMetadataSyncForAccount(String accountName) {
+ for (Account account : getFocusGoogleAccounts()) {
+ if (!TextUtils.isEmpty(accountName) && accountName.equals(account.name)) {
+ // Request sync.
+ final Bundle b = new Bundle();
+ b.putBoolean(SHOULD_CLEAR_METADATA_BEFORE_SYNCING, true);
+ b.putBoolean(ONLY_CLEAR_DONOT_SYNC, false);
+ b.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
+ b.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true);
+ ContentResolver.requestSync(account, CONTACT_METADATA_AUTHORITY, b);
+
+ ContentResolver.setSyncAutomatically(account, CONTACT_METADATA_AUTHORITY, true);
+ } else if (ContentResolver.getSyncAutomatically(account, CONTACT_METADATA_AUTHORITY)) {
+ // Turn off automatic sync for previous sync account.
+ ContentResolver.setSyncAutomatically(account, CONTACT_METADATA_AUTHORITY, false);
+ if (TextUtils.isEmpty(accountName)) {
+ // Request sync to clear old data.
+ final Bundle b = new Bundle();
+ b.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
+ b.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true);
+ b.putBoolean(SHOULD_CLEAR_METADATA_BEFORE_SYNCING, true);
+ b.putBoolean(ONLY_CLEAR_DONOT_SYNC, true);
+ ContentResolver.requestSync(account, CONTACT_METADATA_AUTHORITY, b);
+ }
+ }
+ }
+ }
+
+ /**
+ * @return google accounts with "com.google" account type and null data set.
+ */
+ private List<Account> getFocusGoogleAccounts() {
+ List<Account> focusGoogleAccounts = new ArrayList<Account>();
+ final AccountTypeManager accountTypeManager = AccountTypeManager.getInstance(mContext);
+ List<AccountWithDataSet> accounts = accountTypeManager.getAccounts(true);
+ for (AccountWithDataSet account : accounts) {
+ if (GoogleAccountType.ACCOUNT_TYPE.equals(account.type) && account.dataSet == null) {
+ focusGoogleAccounts.add(account.getAccountOrNull());
+ }
+ }
+ return focusGoogleAccounts;
+ }
+
+ public void registerChangeListener(ChangeListener listener) {
+ if (mListener != null) unregisterChangeListener();
+
+ mListener = listener;
+
+ // Reset preferences to "unknown" because they may have changed while the
+ // listener was unregistered.
+ mDisplayOrder = PREFERENCE_UNASSIGNED;
+ mSortOrder = PREFERENCE_UNASSIGNED;
+ mDefaultAccount = null;
+
+ mPreferences.registerOnSharedPreferenceChangeListener(this);
+ }
+
+ public void unregisterChangeListener() {
+ if (mListener != null) {
+ mListener = null;
+ }
+
+ mPreferences.unregisterOnSharedPreferenceChangeListener(this);
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, final String key) {
+ // This notification is not sent on the Ui thread. Use the previously created Handler
+ // to switch to the Ui thread
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ refreshValue(key);
+ }
+ });
+ }
+
+ /**
+ * Forces the value for the given key to be looked up from shared preferences and notifies
+ * the registered {@link ChangeListener}
+ *
+ * @param key the {@link SharedPreferences} key to look up
+ */
+ public void refreshValue(String key) {
+ if (DISPLAY_ORDER_KEY.equals(key)) {
+ mDisplayOrder = PREFERENCE_UNASSIGNED;
+ mDisplayOrder = getDisplayOrder();
+ } else if (SORT_ORDER_KEY.equals(key)) {
+ mSortOrder = PREFERENCE_UNASSIGNED;
+ mSortOrder = getSortOrder();
+ } else if (mDefaultAccountKey.equals(key)) {
+ mDefaultAccount = null;
+ mDefaultAccount = getDefaultAccount();
+ }
+ if (mListener != null) mListener.onChange();
+ }
+
+ public interface ChangeListener {
+ void onChange();
+ }
+
+ /**
+ * If there are currently no preferences (which means this is the first time we are run),
+ * For sort order and display order, check to see if there are any preferences stored in
+ * system settings (pre-L) which can be copied into our own SharedPreferences.
+ * For default account setting, check to see if there are any preferences stored in the previous
+ * SharedPreferences which can be copied into current SharedPreferences.
+ */
+ private void maybeMigrateSystemSettings() {
+ if (!mPreferences.contains(SORT_ORDER_KEY)) {
+ int sortOrder = getDefaultSortOrder();
+ try {
+ sortOrder = Settings.System.getInt(mContext.getContentResolver(),
+ SORT_ORDER_KEY);
+ } catch (SettingNotFoundException e) {
+ }
+ setSortOrder(sortOrder);
+ }
+
+ if (!mPreferences.contains(DISPLAY_ORDER_KEY)) {
+ int displayOrder = getDefaultDisplayOrder();
+ try {
+ displayOrder = Settings.System.getInt(mContext.getContentResolver(),
+ DISPLAY_ORDER_KEY);
+ } catch (SettingNotFoundException e) {
+ }
+ setDisplayOrder(displayOrder);
+ }
+
+ if (!mPreferences.contains(mDefaultAccountKey)) {
+ final SharedPreferences previousPrefs =
+ PreferenceManager.getDefaultSharedPreferences(mContext);
+ final String defaultAccount = previousPrefs.getString(mDefaultAccountKey, null);
+ if (!TextUtils.isEmpty(defaultAccount)) {
+ final AccountWithDataSet accountWithDataSet = AccountWithDataSet.unstringify(
+ defaultAccount);
+ setDefaultAccount(accountWithDataSet);
+ }
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/preference/DefaultAccountPreference.java b/src/com/android/contacts/common/preference/DefaultAccountPreference.java
new file mode 100644
index 0000000..2fc5c7b
--- /dev/null
+++ b/src/com/android/contacts/common/preference/DefaultAccountPreference.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.preference;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.preference.ListPreference;
+import android.util.AttributeSet;
+import android.view.View;
+
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountTypeWithDataSet;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class DefaultAccountPreference extends ListPreference {
+ private ContactsPreferences mPreferences;
+ private Map<String, AccountWithDataSet> mAccountMap;
+
+ public DefaultAccountPreference(Context context) {
+ super(context);
+ prepare();
+ }
+
+ public DefaultAccountPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ prepare();
+ }
+
+ @Override
+ protected View onCreateDialogView() {
+ prepare();
+ return super.onCreateDialogView();
+ }
+
+ private void prepare() {
+ mPreferences = new ContactsPreferences(getContext());
+ mAccountMap = new HashMap<>();
+ final AccountTypeManager accountTypeManager = AccountTypeManager.getInstance(getContext());
+ List<AccountWithDataSet> accounts = accountTypeManager.getAccounts(true);
+ for (AccountWithDataSet account : accounts) {
+ mAccountMap.put(account.name, account);
+ }
+ final Set<String> accountNames = mAccountMap.keySet();
+ final String[] accountNamesArray = accountNames.toArray(new String[accountNames.size()]);
+ setEntries(accountNamesArray);
+ setEntryValues(accountNamesArray);
+ final String defaultAccount = String.valueOf(mPreferences.getDefaultAccount());
+ if (accounts.size() == 1) {
+ setValue(accounts.get(0).name);
+ } else if (accountNames.contains(defaultAccount)) {
+ setValue(defaultAccount);
+ } else {
+ setValue(null);
+ }
+ }
+
+ @Override
+ protected boolean shouldPersist() {
+ return false; // This preference takes care of its own storage
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ return mPreferences.getDefaultAccount();
+ }
+
+ @Override
+ protected boolean persistString(String value) {
+ if (value == null && mPreferences.getDefaultAccount() == null) {
+ return true;
+ }
+ if (value == null || mPreferences.getDefaultAccount() == null
+ || !value.equals(mPreferences.getDefaultAccount())) {
+ mPreferences.setDefaultAccount(mAccountMap.get(value));
+ notifyChanged();
+ }
+ return true;
+ }
+
+ @Override
+ // UX recommendation is not to show cancel button on such lists.
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ super.onPrepareDialogBuilder(builder);
+ builder.setNegativeButton(null, null);
+ }
+}
diff --git a/src/com/android/contacts/common/preference/DisplayOptionsPreferenceFragment.java b/src/com/android/contacts/common/preference/DisplayOptionsPreferenceFragment.java
new file mode 100644
index 0000000..57229a4
--- /dev/null
+++ b/src/com/android/contacts/common/preference/DisplayOptionsPreferenceFragment.java
@@ -0,0 +1,362 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.preference;
+
+import android.app.Activity;
+import android.app.LoaderManager;
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.CursorLoader;
+import android.content.Intent;
+import android.content.Loader;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import android.provider.BlockedNumberContract;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Profile;
+import android.telecom.TelecomManager;
+import android.telephony.TelephonyManager;
+
+import com.android.contacts.common.ContactsUtils;
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.TelecomManagerUtil;
+import com.android.contacts.common.compat.TelephonyManagerCompat;
+import com.android.contacts.common.interactions.ImportExportDialogFragment;
+import com.android.contacts.common.list.ContactListFilter;
+import com.android.contacts.common.list.ContactListFilterController;
+import com.android.contacts.common.logging.ScreenEvent.ScreenType;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.util.AccountFilterUtil;
+import com.android.contacts.common.util.ImplicitIntentsUtil;
+import com.android.contacts.commonbind.ObjectFactory;
+
+import java.util.List;
+
+/**
+ * This fragment shows the preferences for "display options"
+ */
+public class DisplayOptionsPreferenceFragment extends PreferenceFragment
+ implements Preference.OnPreferenceClickListener {
+
+ private static final int REQUEST_CODE_CUSTOM_CONTACTS_FILTER = 0;
+
+ private static final String ARG_CONTACTS_AVAILABLE = "are_contacts_available";
+ private static final String ARG_MODE_FULLY_EXPANDED = "mode_fully_expanded";
+ private static final String ARG_NEW_LOCAL_PROFILE = "new_local_profile";
+ private static final String ARG_PREVIOUS_SCREEN = "previous_screen";
+
+ private static final String KEY_ABOUT = "about";
+ private static final String KEY_ACCOUNTS = "accounts";
+ private static final String KEY_DEFAULT_ACCOUNT = "defaultAccount";
+ private static final String KEY_BLOCKED_NUMBERS = "blockedNumbers";
+ private static final String KEY_DISPLAY_ORDER = "displayOrder";
+ private static final String KEY_CUSTOM_CONTACTS_FILTER = "customContactsFilter";
+ private static final String KEY_IMPORT_EXPORT = "importExport";
+ private static final String KEY_MY_INFO = "myInfo";
+ private static final String KEY_SORT_ORDER = "sortOrder";
+
+ private static final int LOADER_PROFILE = 0;
+
+ /**
+ * Callbacks for hosts of the {@link DisplayOptionsPreferenceFragment}.
+ */
+ public interface ProfileListener {
+ /**
+ * Invoked after profile has been loaded.
+ */
+ void onProfileLoaded(Cursor data);
+ }
+
+ /**
+ * The projections that are used to obtain user profile
+ */
+ public static class ProfileQuery {
+ /**
+ * Not instantiable.
+ */
+ private ProfileQuery() {}
+
+ private static final String[] PROFILE_PROJECTION_PRIMARY = new String[] {
+ Contacts._ID, // 0
+ Contacts.DISPLAY_NAME_PRIMARY, // 1
+ Contacts.IS_USER_PROFILE, // 2
+ };
+
+ private static final String[] PROFILE_PROJECTION_ALTERNATIVE = new String[] {
+ Contacts._ID, // 0
+ Contacts.DISPLAY_NAME_ALTERNATIVE, // 1
+ Contacts.IS_USER_PROFILE, // 2
+ };
+
+ public static final int CONTACT_ID = 0;
+ public static final int CONTACT_DISPLAY_NAME = 1;
+ public static final int CONTACT_IS_USER_PROFILE = 2;
+ }
+
+ private String mNewLocalProfileExtra;
+ private String mPreviousScreenExtra;
+ private int mModeFullyExpanded;
+ private boolean mAreContactsAvailable;
+
+ private boolean mHasProfile;
+ private long mProfileContactId;
+
+ private Preference mMyInfoPreference;
+
+ private ProfileListener mListener;
+
+ private final LoaderManager.LoaderCallbacks<Cursor> mProfileLoaderListener =
+ new LoaderManager.LoaderCallbacks<Cursor>() {
+
+ @Override
+ public CursorLoader onCreateLoader(int id, Bundle args) {
+ final CursorLoader loader = createCursorLoader(getContext());
+ loader.setUri(Profile.CONTENT_URI);
+ loader.setProjection(getProjection(getContext()));
+ return loader;
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
+ if (mListener != null) {
+ mListener.onProfileLoaded(data);
+ }
+ }
+
+ public void onLoaderReset(Loader<Cursor> loader) {
+ }
+ };
+
+ public static DisplayOptionsPreferenceFragment newInstance(String newLocalProfileExtra,
+ String previousScreenExtra, int modeFullyExpanded, boolean areContactsAvailable) {
+ final DisplayOptionsPreferenceFragment fragment = new DisplayOptionsPreferenceFragment();
+ final Bundle args = new Bundle();
+ args.putString(ARG_NEW_LOCAL_PROFILE, newLocalProfileExtra);
+ args.putString(ARG_PREVIOUS_SCREEN, previousScreenExtra);
+ args.putInt(ARG_MODE_FULLY_EXPANDED, modeFullyExpanded);
+ args.putBoolean(ARG_CONTACTS_AVAILABLE, areContactsAvailable);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ try {
+ mListener = (ProfileListener) activity;
+ } catch (ClassCastException e) {
+ throw new ClassCastException(activity.toString() + " must implement ProfileListener");
+ }
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Load the preferences from an XML resource
+ addPreferencesFromResource(R.xml.preference_display_options);
+
+ removeUnsupportedPreferences();
+ addExtraPreferences();
+
+ final Bundle args = getArguments();
+ mNewLocalProfileExtra = args.getString(ARG_NEW_LOCAL_PROFILE);
+ mPreviousScreenExtra = args.getString(ARG_PREVIOUS_SCREEN);
+ mModeFullyExpanded = args.getInt(ARG_MODE_FULLY_EXPANDED);
+ mAreContactsAvailable = args.getBoolean(ARG_CONTACTS_AVAILABLE);
+
+ mMyInfoPreference = findPreference(KEY_MY_INFO);
+
+ final Preference accountsPreference = findPreference(KEY_ACCOUNTS);
+ accountsPreference.setOnPreferenceClickListener(this);
+
+ final Preference importExportPreference = findPreference(KEY_IMPORT_EXPORT);
+ importExportPreference.setOnPreferenceClickListener(this);
+
+ final Preference blockedNumbersPreference = findPreference(KEY_BLOCKED_NUMBERS);
+ if (blockedNumbersPreference != null) {
+ blockedNumbersPreference.setOnPreferenceClickListener(this);
+ }
+
+ final Preference aboutPreference = findPreference(KEY_ABOUT);
+ aboutPreference.setOnPreferenceClickListener(this);
+
+ final Preference customFilterPreference = findPreference(KEY_CUSTOM_CONTACTS_FILTER);
+ if (customFilterPreference != null) {
+ customFilterPreference.setOnPreferenceClickListener(this);
+ setCustomContactsFilterSummary();
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ getLoaderManager().restartLoader(LOADER_PROFILE, null, mProfileLoaderListener);
+ }
+
+ public void updateMyInfoPreference(boolean hasProfile, String displayName, long contactId) {
+ final CharSequence summary = hasProfile ? displayName : getString(R.string.set_up_profile);
+ mMyInfoPreference.setSummary(summary);
+ mHasProfile = hasProfile;
+ mProfileContactId = contactId;
+ mMyInfoPreference.setOnPreferenceClickListener(this);
+ }
+
+ private void removeUnsupportedPreferences() {
+ // Disable sort order for CJK locales where it is not supported
+ final Resources resources = getResources();
+ if (!resources.getBoolean(R.bool.config_sort_order_user_changeable)) {
+ getPreferenceScreen().removePreference(findPreference(KEY_SORT_ORDER));
+ }
+
+ // Disable display order for CJK locales as well
+ if (!resources.getBoolean(R.bool.config_display_order_user_changeable)) {
+ getPreferenceScreen().removePreference(findPreference(KEY_DISPLAY_ORDER));
+ }
+
+ // Remove the "Default account" setting if there aren't any writable accounts
+ final AccountTypeManager accountTypeManager = AccountTypeManager.getInstance(getContext());
+ final List<AccountWithDataSet> accounts = accountTypeManager.getAccounts(
+ /* contactWritableOnly */ true);
+ if (accounts.isEmpty()) {
+ getPreferenceScreen().removePreference(findPreference(KEY_DEFAULT_ACCOUNT));
+ }
+
+ final boolean isPhone = TelephonyManagerCompat.isVoiceCapable(
+ (TelephonyManager) getContext().getSystemService(Context.TELEPHONY_SERVICE));
+ final boolean showBlockedNumbers = isPhone && ContactsUtils.FLAG_N_FEATURE
+ && BlockedNumberContract.canCurrentUserBlockNumbers(getContext());
+ if (!showBlockedNumbers) {
+ getPreferenceScreen().removePreference(findPreference(KEY_BLOCKED_NUMBERS));
+ }
+ }
+
+ private void addExtraPreferences() {
+ final PreferenceManager preferenceManager = ObjectFactory.getPreferenceManager(
+ getContext());
+ if (preferenceManager != null) {
+ for (Preference preference : preferenceManager.getPreferences()) {
+ getPreferenceScreen().addPreference(preference);
+ }
+ }
+ }
+
+ @Override
+ public Context getContext() {
+ return getActivity();
+ }
+
+ private CursorLoader createCursorLoader(Context context) {
+ return new CursorLoader(context) {
+ @Override
+ protected Cursor onLoadInBackground() {
+ try {
+ return super.onLoadInBackground();
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+ };
+ }
+
+ private String[] getProjection(Context context) {
+ final ContactsPreferences contactsPrefs = new ContactsPreferences(context);
+ final int displayOrder = contactsPrefs.getDisplayOrder();
+ if (displayOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY) {
+ return ProfileQuery.PROFILE_PROJECTION_PRIMARY;
+ }
+ return ProfileQuery.PROFILE_PROJECTION_ALTERNATIVE;
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference p) {
+ final String prefKey = p.getKey();
+
+ if (KEY_ABOUT.equals(prefKey)) {
+ ((ContactsPreferenceActivity) getActivity()).showAboutFragment();
+ return true;
+ } else if (KEY_IMPORT_EXPORT.equals(prefKey)) {
+ ImportExportDialogFragment.show(getFragmentManager(), mAreContactsAvailable,
+ ContactsPreferenceActivity.class,
+ ImportExportDialogFragment.EXPORT_MODE_ALL_CONTACTS);
+ return true;
+ } else if (KEY_MY_INFO.equals(prefKey)) {
+ final Intent intent;
+ if (mHasProfile) {
+ final Uri uri = ContentUris.withAppendedId(Contacts.CONTENT_URI, mProfileContactId);
+ intent = ImplicitIntentsUtil.composeQuickContactIntent(uri, mModeFullyExpanded);
+ intent.putExtra(mPreviousScreenExtra, ScreenType.ME_CONTACT);
+ } else {
+ intent = new Intent(Intent.ACTION_INSERT, Contacts.CONTENT_URI);
+ intent.putExtra(mNewLocalProfileExtra, true);
+ }
+ ImplicitIntentsUtil.startActivityInApp(getActivity(), intent);
+ return true;
+ } else if (KEY_ACCOUNTS.equals(prefKey)) {
+ ImplicitIntentsUtil.startActivityOutsideApp(getContext(),
+ ImplicitIntentsUtil.getIntentForAddingAccount());
+ return true;
+ } else if (KEY_BLOCKED_NUMBERS.equals(prefKey)) {
+ final Intent intent = TelecomManagerUtil.createManageBlockedNumbersIntent(
+ (TelecomManager) getContext().getSystemService(Context.TELECOM_SERVICE));
+ startActivity(intent);
+ return true;
+ } else if (KEY_CUSTOM_CONTACTS_FILTER.equals(prefKey)) {
+ final ContactListFilter filter =
+ ContactListFilterController.getInstance(getContext()).getFilter();
+ AccountFilterUtil.startAccountFilterActivityForResult(
+ this, REQUEST_CODE_CUSTOM_CONTACTS_FILTER, filter);
+ }
+ return false;
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (requestCode == REQUEST_CODE_CUSTOM_CONTACTS_FILTER
+ && resultCode == Activity.RESULT_OK) {
+ AccountFilterUtil.handleAccountFilterResult(
+ ContactListFilterController.getInstance(getContext()), resultCode, data);
+ setCustomContactsFilterSummary();
+ } else {
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+ }
+
+ private void setCustomContactsFilterSummary() {
+ final Preference customFilterPreference = findPreference(KEY_CUSTOM_CONTACTS_FILTER);
+ if (customFilterPreference != null) {
+ final ContactListFilter filter =
+ ContactListFilterController.getInstance(getContext()).getPersistedFilter();
+ if (filter != null) {
+ if (filter.filterType == ContactListFilter.FILTER_TYPE_DEFAULT ||
+ filter.filterType == ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS) {
+ customFilterPreference.setSummary(R.string.list_filter_all_accounts);
+ } else if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) {
+ customFilterPreference.setSummary(R.string.listCustomView);
+ } else {
+ customFilterPreference.setSummary(null);
+ }
+ }
+ }
+ }
+}
+
diff --git a/src/com/android/contacts/common/preference/DisplayOrderPreference.java b/src/com/android/contacts/common/preference/DisplayOrderPreference.java
new file mode 100644
index 0000000..6a182c5
--- /dev/null
+++ b/src/com/android/contacts/common/preference/DisplayOrderPreference.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.preference;
+
+import android.app.AlertDialog.Builder;
+import android.content.Context;
+import android.preference.ListPreference;
+import android.provider.ContactsContract;
+import android.util.AttributeSet;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.preference.ContactsPreferences;
+
+/**
+ * Custom preference: view-name-as (first name first or last name first).
+ */
+public final class DisplayOrderPreference extends ListPreference {
+
+ private ContactsPreferences mPreferences;
+ private Context mContext;
+
+ public DisplayOrderPreference(Context context) {
+ super(context);
+ prepare();
+ }
+
+ public DisplayOrderPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ prepare();
+ }
+
+ private void prepare() {
+ mContext = getContext();
+ mPreferences = new ContactsPreferences(mContext);
+ setEntries(new String[]{
+ mContext.getString(R.string.display_options_view_given_name_first),
+ mContext.getString(R.string.display_options_view_family_name_first),
+ });
+ setEntryValues(new String[]{
+ String.valueOf(ContactsPreferences.DISPLAY_ORDER_PRIMARY),
+ String.valueOf(ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE),
+ });
+ setValue(String.valueOf(mPreferences.getDisplayOrder()));
+ }
+
+ @Override
+ protected boolean shouldPersist() {
+ return false; // This preference takes care of its own storage
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ switch (mPreferences.getDisplayOrder()) {
+ case ContactsPreferences.DISPLAY_ORDER_PRIMARY:
+ return mContext.getString(R.string.display_options_view_given_name_first);
+ case ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE:
+ return mContext.getString(R.string.display_options_view_family_name_first);
+ }
+ return null;
+ }
+
+ @Override
+ protected boolean persistString(String value) {
+ int newValue = Integer.parseInt(value);
+ if (newValue != mPreferences.getDisplayOrder()) {
+ mPreferences.setDisplayOrder(newValue);
+ notifyChanged();
+ }
+ return true;
+ }
+
+ @Override
+ // UX recommendation is not to show cancel button on such lists.
+ protected void onPrepareDialogBuilder(Builder builder) {
+ super.onPrepareDialogBuilder(builder);
+ builder.setNegativeButton(null, null);
+ }
+}
diff --git a/src/com/android/contacts/common/preference/PreferenceManager.java b/src/com/android/contacts/common/preference/PreferenceManager.java
new file mode 100644
index 0000000..816f94e
--- /dev/null
+++ b/src/com/android/contacts/common/preference/PreferenceManager.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.preference;
+
+import android.preference.Preference;
+
+import java.util.List;
+
+public interface PreferenceManager {
+ List<Preference> getPreferences();
+}
diff --git a/src/com/android/contacts/common/preference/SortOrderPreference.java b/src/com/android/contacts/common/preference/SortOrderPreference.java
new file mode 100644
index 0000000..dfd9550
--- /dev/null
+++ b/src/com/android/contacts/common/preference/SortOrderPreference.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.preference;
+
+import android.app.AlertDialog.Builder;
+import android.content.Context;
+import android.preference.ListPreference;
+import android.util.AttributeSet;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.preference.ContactsPreferences;
+
+/**
+ * Custom preference: sort-by.
+ */
+public final class SortOrderPreference extends ListPreference {
+
+ private ContactsPreferences mPreferences;
+ private Context mContext;
+
+ public SortOrderPreference(Context context) {
+ super(context);
+ prepare();
+ }
+
+ public SortOrderPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ prepare();
+ }
+
+ private void prepare() {
+ mContext = getContext();
+ mPreferences = new ContactsPreferences(mContext);
+ setEntries(new String[]{
+ mContext.getString(R.string.display_options_sort_by_given_name),
+ mContext.getString(R.string.display_options_sort_by_family_name),
+ });
+ setEntryValues(new String[]{
+ String.valueOf(ContactsPreferences.SORT_ORDER_PRIMARY),
+ String.valueOf(ContactsPreferences.SORT_ORDER_ALTERNATIVE),
+ });
+ setValue(String.valueOf(mPreferences.getSortOrder()));
+ }
+
+ @Override
+ protected boolean shouldPersist() {
+ return false; // This preference takes care of its own storage
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ switch (mPreferences.getSortOrder()) {
+ case ContactsPreferences.SORT_ORDER_PRIMARY:
+ return mContext.getString(R.string.display_options_sort_by_given_name);
+ case ContactsPreferences.SORT_ORDER_ALTERNATIVE:
+ return mContext.getString(R.string.display_options_sort_by_family_name);
+ }
+ return null;
+ }
+
+ @Override
+ protected boolean persistString(String value) {
+ int newValue = Integer.parseInt(value);
+ if (newValue != mPreferences.getSortOrder()) {
+ mPreferences.setSortOrder(newValue);
+ notifyChanged();
+ }
+ return true;
+ }
+
+ @Override
+ // UX recommendation is not to show cancel button on such lists.
+ protected void onPrepareDialogBuilder(Builder builder) {
+ super.onPrepareDialogBuilder(builder);
+ builder.setNegativeButton(null, null);
+ }
+}
diff --git a/src/com/android/contacts/common/testing/InjectedServices.java b/src/com/android/contacts/common/testing/InjectedServices.java
new file mode 100644
index 0000000..e89cec7
--- /dev/null
+++ b/src/com/android/contacts/common/testing/InjectedServices.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.testing;
+
+import android.content.ContentResolver;
+import android.content.SharedPreferences;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Maps;
+
+import java.util.HashMap;
+
+/**
+ * A mechanism for providing alternative (mock) services to the application
+ * while running tests. Activities, Services and the Application should check
+ * with this class to see if a particular service has been overridden.
+ */
+@NeededForTesting
+public class InjectedServices {
+
+ private ContentResolver mContentResolver;
+ private SharedPreferences mSharedPreferences;
+ private HashMap<String, Object> mSystemServices;
+
+ @NeededForTesting
+ public void setContentResolver(ContentResolver contentResolver) {
+ this.mContentResolver = contentResolver;
+ }
+
+ public ContentResolver getContentResolver() {
+ return mContentResolver;
+ }
+
+ @NeededForTesting
+ public void setSharedPreferences(SharedPreferences sharedPreferences) {
+ this.mSharedPreferences = sharedPreferences;
+ }
+
+ public SharedPreferences getSharedPreferences() {
+ return mSharedPreferences;
+ }
+
+ @NeededForTesting
+ public void setSystemService(String name, Object service) {
+ if (mSystemServices == null) {
+ mSystemServices = Maps.newHashMap();
+ }
+
+ mSystemServices.put(name, service);
+ }
+
+ public Object getSystemService(String name) {
+ if (mSystemServices != null) {
+ return mSystemServices.get(name);
+ }
+ return null;
+ }
+}
diff --git a/src/com/android/contacts/common/testing/NeededForTesting.java b/src/com/android/contacts/common/testing/NeededForTesting.java
new file mode 100644
index 0000000..f841d55
--- /dev/null
+++ b/src/com/android/contacts/common/testing/NeededForTesting.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.testing;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Denotes that the class, constructor, method or field is used by tests and therefore cannot be
+ * removed by tools like ProGuard.
+ */
+@Retention(RetentionPolicy.CLASS)
+@Target({ElementType.TYPE, ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.FIELD})
+public @interface NeededForTesting {}
diff --git a/src/com/android/contacts/common/util/AccountFilterUtil.java b/src/com/android/contacts/common/util/AccountFilterUtil.java
new file mode 100644
index 0000000..76975a6
--- /dev/null
+++ b/src/com/android/contacts/common/util/AccountFilterUtil.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.AsyncTaskLoader;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.graphics.drawable.Drawable;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.list.AccountFilterActivity;
+import com.android.contacts.common.list.ContactListFilter;
+import com.android.contacts.common.list.ContactListFilterController;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Utility class for account filter manipulation.
+ */
+public class AccountFilterUtil {
+ private static final String TAG = AccountFilterUtil.class.getSimpleName();
+
+ /**
+ * Launches account filter setting Activity using
+ * {@link Fragment#startActivityForResult(Intent, int)}.
+ *
+ * @param requestCode requestCode for {@link Activity#startActivityForResult(Intent, int)}
+ * @param currentFilter currently-selected filter, so that it can be displayed as activated.
+ */
+ public static void startAccountFilterActivityForResult(
+ Fragment fragment, int requestCode, ContactListFilter currentFilter) {
+ final Activity activity = fragment.getActivity();
+ if (activity != null) {
+ final Intent intent = new Intent(activity, AccountFilterActivity.class);
+ fragment.startActivityForResult(intent, requestCode);
+ } else {
+ Log.w(TAG, "getActivity() returned null. Ignored");
+ }
+ }
+
+ /**
+ * Useful method to handle onActivityResult() for
+ * {@link #startAccountFilterActivityForResult(Fragment, int, ContactListFilter)}.
+ *
+ * This will update filter via a given ContactListFilterController.
+ */
+ public static void handleAccountFilterResult(
+ ContactListFilterController filterController, int resultCode, Intent data) {
+ if (resultCode == Activity.RESULT_OK) {
+ final ContactListFilter filter = (ContactListFilter)
+ data.getParcelableExtra(AccountFilterActivity.EXTRA_CONTACT_LIST_FILTER);
+ if (filter == null) {
+ return;
+ }
+ if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) {
+ filterController.selectCustomFilter();
+ } else {
+ filterController.setContactListFilter(filter, /* persistent */
+ filter.filterType == ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS);
+ }
+ }
+ }
+
+ /**
+ * Loads a list of contact list filters
+ */
+ public static class FilterLoader extends AsyncTaskLoader<List<ContactListFilter>> {
+ private Context mContext;
+
+ public FilterLoader(Context context) {
+ super(context);
+ mContext = context;
+ }
+
+ @Override
+ public List<ContactListFilter> loadInBackground() {
+ return loadAccountFilters(mContext);
+ }
+
+ @Override
+ protected void onStartLoading() {
+ forceLoad();
+ }
+
+ @Override
+ protected void onStopLoading() {
+ cancelLoad();
+ }
+
+ @Override
+ protected void onReset() {
+ onStopLoading();
+ }
+ }
+
+ private static List<ContactListFilter> loadAccountFilters(Context context) {
+ final ArrayList<ContactListFilter> accountFilters = Lists.newArrayList();
+ final AccountTypeManager accountTypeManager = AccountTypeManager.getInstance(context);
+ accountTypeManager.sortAccounts(/* defaultAccount */ getDefaultAccount(context));
+ final List<AccountWithDataSet> accounts =
+ accountTypeManager.getAccounts(/* contactWritableOnly */ true);
+
+ for (AccountWithDataSet account : accounts) {
+ final AccountType accountType =
+ accountTypeManager.getAccountType(account.type, account.dataSet);
+ if (accountType.isExtension() && !account.hasData(context)) {
+ // Hide extensions with no raw_contacts.
+ continue;
+ }
+ final Drawable icon = accountType != null ? accountType.getDisplayIcon(context) : null;
+ if (account.isLocalAccount()) {
+ accountFilters.add(ContactListFilter.createDeviceContactsFilter(icon));
+ } else {
+ accountFilters.add(ContactListFilter.createAccountFilter(
+ account.type, account.name, account.dataSet, icon));
+ }
+ }
+
+ final ArrayList<ContactListFilter> result = Lists.newArrayList();
+ result.addAll(accountFilters);
+ return result;
+ }
+
+ private static AccountWithDataSet getDefaultAccount(Context context) {
+ final SharedPreferences prefs =
+ context.getSharedPreferences(context.getPackageName(), Context.MODE_PRIVATE);
+ final String defaultAccountKey =
+ context.getResources().getString(R.string.contact_editor_default_account_key);
+ final String defaultAccountString = prefs.getString(defaultAccountKey, null);
+ if (TextUtils.isEmpty(defaultAccountString)) {
+ return null;
+ }
+ try {
+ return AccountWithDataSet.unstringify(defaultAccountString);
+ } catch (IllegalArgumentException exception) {
+ Log.e(TAG, "Error with retrieving default account " + exception.toString(), exception);
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/util/AccountSelectionUtil.java b/src/com/android/contacts/common/util/AccountSelectionUtil.java
new file mode 100644
index 0000000..4dc6ec9
--- /dev/null
+++ b/src/com/android/contacts/common/util/AccountSelectionUtil.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import android.util.Log;
+import android.view.ContextThemeWrapper;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.vcard.ImportVCardActivity;
+
+import java.util.List;
+
+/**
+ * Utility class for selecting an Account for importing contact(s)
+ */
+public class AccountSelectionUtil {
+ // TODO: maybe useful for EditContactActivity.java...
+ private static final String LOG_TAG = "AccountSelectionUtil";
+
+ public static boolean mVCardShare = false;
+
+ public static Uri mPath;
+
+ public static class AccountSelectedListener
+ implements DialogInterface.OnClickListener {
+
+ final private Context mContext;
+ final private int mResId;
+ final private int mSubscriptionId;
+
+ final protected List<AccountWithDataSet> mAccountList;
+
+ public AccountSelectedListener(Context context, List<AccountWithDataSet> accountList,
+ int resId, int subscriptionId) {
+ if (accountList == null || accountList.size() == 0) {
+ Log.e(LOG_TAG, "The size of Account list is 0.");
+ }
+ mContext = context;
+ mAccountList = accountList;
+ mResId = resId;
+ mSubscriptionId = subscriptionId;
+ }
+
+ public AccountSelectedListener(Context context, List<AccountWithDataSet> accountList,
+ int resId) {
+ // Subscription id is only needed for importing from SIM card. We can safely ignore
+ // its value for SD card importing.
+ this(context, accountList, resId, /* subscriptionId = */ -1);
+ }
+
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.dismiss();
+ doImport(mContext, mResId, mAccountList.get(which), mSubscriptionId);
+ }
+ }
+
+ public static Dialog getSelectAccountDialog(Context context, int resId) {
+ return getSelectAccountDialog(context, resId, null, null);
+ }
+
+ public static Dialog getSelectAccountDialog(Context context, int resId,
+ DialogInterface.OnClickListener onClickListener) {
+ return getSelectAccountDialog(context, resId, onClickListener, null);
+ }
+
+ /**
+ * When OnClickListener or OnCancelListener is null, uses a default listener.
+ * The default OnCancelListener just closes itself with {@link Dialog#dismiss()}.
+ */
+ public static Dialog getSelectAccountDialog(Context context, int resId,
+ DialogInterface.OnClickListener onClickListener,
+ DialogInterface.OnCancelListener onCancelListener) {
+ final AccountTypeManager accountTypes = AccountTypeManager.getInstance(context);
+ final List<AccountWithDataSet> writableAccountList = accountTypes.getAccounts(true);
+
+ Log.i(LOG_TAG, "The number of available accounts: " + writableAccountList.size());
+
+ // Assume accountList.size() > 1
+
+ // Wrap our context to inflate list items using correct theme
+ final Context dialogContext = new ContextThemeWrapper(
+ context, android.R.style.Theme_Light);
+ final LayoutInflater dialogInflater = (LayoutInflater)dialogContext
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ final ArrayAdapter<AccountWithDataSet> accountAdapter =
+ new ArrayAdapter<AccountWithDataSet>(
+ context, R.layout.account_selector_list_item_condensed, writableAccountList) {
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ if (convertView == null) {
+ convertView = dialogInflater.inflate(
+ R.layout.account_selector_list_item_condensed,
+ parent, false);
+ }
+
+ final TextView text1 = (TextView) convertView.findViewById(android.R.id.text1);
+ final TextView text2 = (TextView) convertView.findViewById(android.R.id.text2);
+ final ImageView icon = (ImageView) convertView.findViewById(android.R.id.icon);
+
+ final AccountWithDataSet account = this.getItem(position);
+ final AccountType accountType = accountTypes.getAccountType(
+ account.type, account.dataSet);
+ final Context context = getContext();
+
+ text1.setText(accountType.getDisplayLabel(context));
+ text2.setText(account.name);
+ icon.setImageDrawable(accountType.getDisplayIcon(getContext()));
+
+ return convertView;
+ }
+ };
+
+ if (onClickListener == null) {
+ AccountSelectedListener accountSelectedListener =
+ new AccountSelectedListener(context, writableAccountList, resId);
+ onClickListener = accountSelectedListener;
+ }
+ if (onCancelListener == null) {
+ onCancelListener = new DialogInterface.OnCancelListener() {
+ public void onCancel(DialogInterface dialog) {
+ dialog.dismiss();
+ }
+ };
+ }
+ final AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ final TextView title = (TextView) View.inflate(context, R.layout.dialog_title, null);
+ title.setText(R.string.dialog_new_contact_account);
+ builder.setCustomTitle(title);
+ builder.setSingleChoiceItems(accountAdapter, 0, onClickListener);
+ builder.setOnCancelListener(onCancelListener);
+ final AlertDialog result = builder.create();
+ return result;
+ }
+
+ public static void doImport(Context context, int resId, AccountWithDataSet account,
+ int subscriptionId) {
+ if (resId == R.string.import_from_sim) {
+ doImportFromSim(context, account, subscriptionId);
+ } else if (resId == R.string.import_from_vcf_file) {
+ doImportFromVcfFile(context, account);
+ }
+ }
+
+ public static void doImportFromSim(Context context, AccountWithDataSet account,
+ int subscriptionId) {
+ Intent importIntent = new Intent(Intent.ACTION_VIEW);
+ importIntent.setType("vnd.android.cursor.item/sim-contact");
+ if (account != null) {
+ importIntent.putExtra("account_name", account.name);
+ importIntent.putExtra("account_type", account.type);
+ importIntent.putExtra("data_set", account.dataSet);
+ }
+ importIntent.putExtra("subscription_id", (Integer) subscriptionId);
+ importIntent.setClassName("com.android.phone", "com.android.phone.SimContacts");
+ context.startActivity(importIntent);
+ }
+
+ public static void doImportFromVcfFile(Context context, AccountWithDataSet account) {
+ Intent importIntent = new Intent(context, ImportVCardActivity.class);
+ if (account != null) {
+ importIntent.putExtra("account_name", account.name);
+ importIntent.putExtra("account_type", account.type);
+ importIntent.putExtra("data_set", account.dataSet);
+ }
+
+ if (mVCardShare) {
+ importIntent.setAction(Intent.ACTION_VIEW);
+ importIntent.setData(mPath);
+ }
+ mVCardShare = false;
+ mPath = null;
+ context.startActivity(importIntent);
+ }
+}
diff --git a/src/com/android/contacts/common/util/AccountsListAdapter.java b/src/com/android/contacts/common/util/AccountsListAdapter.java
new file mode 100644
index 0000000..ef43a30
--- /dev/null
+++ b/src/com/android/contacts/common/util/AccountsListAdapter.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.content.Context;
+import android.text.TextUtils.TruncateAt;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * List-Adapter for Account selection
+ */
+public final class AccountsListAdapter extends BaseAdapter {
+ private final LayoutInflater mInflater;
+ private final List<AccountWithDataSet> mAccounts;
+ private final AccountTypeManager mAccountTypes;
+ private final Context mContext;
+ private int mCustomLayout = -1;
+
+ /**
+ * Filters that affect the list of accounts that is displayed by this adapter.
+ */
+ public enum AccountListFilter {
+ ALL_ACCOUNTS, // All read-only and writable accounts
+ ACCOUNTS_CONTACT_WRITABLE, // Only where the account type is contact writable
+ ACCOUNTS_GROUP_WRITABLE // Only accounts where the account type is group writable
+ }
+
+ public AccountsListAdapter(Context context, AccountListFilter accountListFilter) {
+ this(context, accountListFilter, null);
+ }
+
+ /**
+ * @param currentAccount the Account currently selected by the user, which should come
+ * first in the list. Can be null.
+ */
+ public AccountsListAdapter(Context context, AccountListFilter accountListFilter,
+ AccountWithDataSet currentAccount) {
+ mContext = context;
+ mAccountTypes = AccountTypeManager.getInstance(context);
+ mAccounts = getAccounts(accountListFilter);
+ if (currentAccount != null
+ && !mAccounts.isEmpty()
+ && !mAccounts.get(0).equals(currentAccount)
+ && mAccounts.remove(currentAccount)) {
+ mAccounts.add(0, currentAccount);
+ }
+ mInflater = LayoutInflater.from(context);
+ }
+
+ private List<AccountWithDataSet> getAccounts(AccountListFilter accountListFilter) {
+ if (accountListFilter == AccountListFilter.ACCOUNTS_GROUP_WRITABLE) {
+ return new ArrayList<AccountWithDataSet>(mAccountTypes.getGroupWritableAccounts());
+ }
+ return new ArrayList<AccountWithDataSet>(mAccountTypes.getAccounts(
+ accountListFilter == AccountListFilter.ACCOUNTS_CONTACT_WRITABLE));
+ }
+
+ public void setCustomLayout(int customLayout) {
+ mCustomLayout = customLayout;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final View resultView = convertView != null ? convertView :
+ mInflater.inflate(mCustomLayout > 0 ? mCustomLayout :
+ R.layout.account_selector_list_item_condensed, parent, false);
+
+ final TextView text1 = (TextView) resultView.findViewById(android.R.id.text1);
+ final TextView text2 = (TextView) resultView.findViewById(android.R.id.text2);
+ final ImageView icon = (ImageView) resultView.findViewById(android.R.id.icon);
+
+ final AccountWithDataSet account = mAccounts.get(position);
+ final AccountType accountType = mAccountTypes.getAccountType(account.type, account.dataSet);
+
+ text1.setText(accountType.getDisplayLabel(mContext));
+ text2.setText(account.name);
+
+ icon.setImageDrawable(accountType.getDisplayIcon(mContext));
+
+ return resultView;
+ }
+
+ @Override
+ public int getCount() {
+ return mAccounts.size();
+ }
+
+ @Override
+ public AccountWithDataSet getItem(int position) {
+ return mAccounts.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+}
+
diff --git a/src/com/android/contacts/common/util/BitmapUtil.java b/src/com/android/contacts/common/util/BitmapUtil.java
new file mode 100644
index 0000000..5cd856e
--- /dev/null
+++ b/src/com/android/contacts/common/util/BitmapUtil.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PorterDuff.Mode;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.BitmapDrawable;
+
+/**
+ * Provides static functions to decode bitmaps at the optimal size
+ */
+public class BitmapUtil {
+ private BitmapUtil() {}
+
+ /**
+ * Returns Width or Height of the picture, depending on which size is smaller. Doesn't actually
+ * decode the picture, so it is pretty efficient to run.
+ */
+ public static int getSmallerExtentFromBytes(byte[] bytes) {
+ final BitmapFactory.Options options = new BitmapFactory.Options();
+
+ // don't actually decode the picture, just return its bounds
+ options.inJustDecodeBounds = true;
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
+
+ // test what the best sample size is
+ return Math.min(options.outWidth, options.outHeight);
+ }
+
+ /**
+ * Finds the optimal sampleSize for loading the picture
+ * @param originalSmallerExtent Width or height of the picture, whichever is smaller
+ * @param targetExtent Width or height of the target view, whichever is bigger.
+ *
+ * If either one of the parameters is 0 or smaller, no sampling is applied
+ */
+ public static int findOptimalSampleSize(int originalSmallerExtent, int targetExtent) {
+ // If we don't know sizes, we can't do sampling.
+ if (targetExtent < 1) return 1;
+ if (originalSmallerExtent < 1) return 1;
+
+ // Test what the best sample size is. To do that, we find the sample size that gives us
+ // the best trade-off between resulting image size and memory requirement. We allow
+ // the down-sampled image to be 20% smaller than the target size. That way we can get around
+ // unfortunate cases where e.g. a 720 picture is requested for 362 and not down-sampled at
+ // all. Why 20%? Why not. Prove me wrong.
+ int extent = originalSmallerExtent;
+ int sampleSize = 1;
+ while ((extent >> 1) >= targetExtent * 0.8f) {
+ sampleSize <<= 1;
+ extent >>= 1;
+ }
+
+ return sampleSize;
+ }
+
+ /**
+ * Decodes the bitmap with the given sample size
+ */
+ public static Bitmap decodeBitmapFromBytes(byte[] bytes, int sampleSize) {
+ final BitmapFactory.Options options;
+ if (sampleSize <= 1) {
+ options = null;
+ } else {
+ options = new BitmapFactory.Options();
+ options.inSampleSize = sampleSize;
+ }
+ return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
+ }
+
+ /**
+ * Retrieves a copy of the specified drawable resource, rotated by a specified angle.
+ *
+ * @param resources The current resources.
+ * @param resourceId The resource ID of the drawable to rotate.
+ * @param angle The angle of rotation.
+ * @return Rotated drawable.
+ */
+ public static Drawable getRotatedDrawable(
+ android.content.res.Resources resources, int resourceId, float angle) {
+
+ // Get the original drawable and make a copy which will be rotated.
+ Bitmap original = BitmapFactory.decodeResource(resources, resourceId);
+ Bitmap rotated = Bitmap.createBitmap(
+ original.getWidth(), original.getHeight(), Bitmap.Config.ARGB_8888);
+
+ // Perform the rotation.
+ Canvas tempCanvas = new Canvas(rotated);
+ tempCanvas.rotate(angle, original.getWidth()/2, original.getHeight()/2);
+ tempCanvas.drawBitmap(original, 0, 0, null);
+
+ return new BitmapDrawable(resources,rotated);
+ }
+
+ /**
+ * Given an input bitmap, scales it to the given width/height and makes it round.
+ *
+ * @param input {@link Bitmap} to scale and crop
+ * @param targetWidth desired output width
+ * @param targetHeight desired output height
+ * @return output bitmap scaled to the target width/height and cropped to an oval. The
+ * cropping algorithm will try to fit as much of the input into the output as possible,
+ * while preserving the target width/height ratio.
+ */
+ public static Bitmap getRoundedBitmap(Bitmap input, int targetWidth, int targetHeight) {
+ if (input == null) {
+ return null;
+ }
+ final Bitmap.Config inputConfig = input.getConfig();
+ final Bitmap result = Bitmap.createBitmap(targetWidth, targetHeight,
+ inputConfig != null ? inputConfig : Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(result);
+ final Paint paint = new Paint();
+ canvas.drawARGB(0, 0, 0, 0);
+ paint.setAntiAlias(true);
+ final RectF dst = new RectF(0, 0, targetWidth, targetHeight);
+ canvas.drawOval(dst, paint);
+
+ // Specifies that only pixels present in the destination (i.e. the drawn oval) should
+ // be overwritten with pixels from the input bitmap.
+ paint.setXfermode(new PorterDuffXfermode(Mode.SRC_IN));
+
+ final int inputWidth = input.getWidth();
+ final int inputHeight = input.getHeight();
+
+ // Choose the largest scale factor that will fit inside the dimensions of the
+ // input bitmap.
+ final float scaleBy = Math.min((float) inputWidth / targetWidth,
+ (float) inputHeight / targetHeight);
+
+ final int xCropAmountHalved = (int) (scaleBy * targetWidth / 2);
+ final int yCropAmountHalved = (int) (scaleBy * targetHeight / 2);
+
+ final Rect src = new Rect(
+ inputWidth / 2 - xCropAmountHalved,
+ inputHeight / 2 - yCropAmountHalved,
+ inputWidth / 2 + xCropAmountHalved,
+ inputHeight / 2 + yCropAmountHalved);
+
+ canvas.drawBitmap(input, src, dst, paint);
+ return result;
+ }
+}
diff --git a/src/com/android/contacts/common/util/CommonDateUtils.java b/src/com/android/contacts/common/util/CommonDateUtils.java
new file mode 100644
index 0000000..bba910a
--- /dev/null
+++ b/src/com/android/contacts/common/util/CommonDateUtils.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.util;
+
+import java.text.SimpleDateFormat;
+import java.util.Locale;
+
+/**
+ * Common date utilities.
+ */
+public class CommonDateUtils {
+
+ // All the SimpleDateFormats in this class use the UTC timezone
+ public static final SimpleDateFormat NO_YEAR_DATE_FORMAT =
+ new SimpleDateFormat("--MM-dd", Locale.US);
+ public static final SimpleDateFormat FULL_DATE_FORMAT =
+ new SimpleDateFormat("yyyy-MM-dd", Locale.US);
+ public static final SimpleDateFormat DATE_AND_TIME_FORMAT =
+ new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
+ public static final SimpleDateFormat NO_YEAR_DATE_AND_TIME_FORMAT =
+ new SimpleDateFormat("--MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
+
+ /**
+ * Exchange requires 8:00 for birthdays
+ */
+ public final static int DEFAULT_HOUR = 8;
+}
diff --git a/src/com/android/contacts/common/util/Constants.java b/src/com/android/contacts/common/util/Constants.java
new file mode 100644
index 0000000..c0d6755
--- /dev/null
+++ b/src/com/android/contacts/common/util/Constants.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+public class Constants {
+
+ /**
+ * Log tag for performance measurement.
+ * To enable: adb shell setprop log.tag.ContactsPerf VERBOSE
+ */
+ public static final String PERFORMANCE_TAG = "ContactsPerf";
+
+ // Used for lookup URI that contains an encoded JSON string.
+ public static final String LOOKUP_URI_ENCODED = "encoded";
+}
diff --git a/src/com/android/contacts/common/util/ContactDisplayUtils.java b/src/com/android/contacts/common/util/ContactDisplayUtils.java
new file mode 100644
index 0000000..0a50748
--- /dev/null
+++ b/src/com/android/contacts/common/util/ContactDisplayUtils.java
@@ -0,0 +1,297 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.util;
+
+import static android.provider.ContactsContract.CommonDataKinds.Phone;
+
+import com.google.common.base.Preconditions;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.support.annotation.Nullable;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.style.TtsSpan;
+import android.util.Log;
+import android.util.Patterns;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.PhoneNumberUtilsCompat;
+import com.android.contacts.common.preference.ContactsPreferences;
+
+/**
+ * Methods for handling various contact data labels.
+ */
+public class ContactDisplayUtils {
+
+ private static final String TAG = ContactDisplayUtils.class.getSimpleName();
+
+ public static final int INTERACTION_CALL = 1;
+ public static final int INTERACTION_SMS = 2;
+
+ /**
+ * Checks if the given data type is a custom type.
+ *
+ * @param type Phone data type.
+ * @return {@literal true} if the type is custom. {@literal false} if not.
+ */
+ public static boolean isCustomPhoneType(Integer type) {
+ return type == Phone.TYPE_CUSTOM || type == Phone.TYPE_ASSISTANT;
+ }
+
+ /**
+ * Gets a display label for a given phone type.
+ *
+ * @param type The type of number.
+ * @param customLabel A custom label to use if the phone is determined to be of custom type
+ * determined by {@link #isCustomPhoneType(Integer))}
+ * @param interactionType whether this is a call or sms. Either {@link #INTERACTION_CALL} or
+ * {@link #INTERACTION_SMS}.
+ * @param context The application context.
+ * @return An appropriate string label
+ */
+ public static CharSequence getLabelForCallOrSms(Integer type, CharSequence customLabel,
+ int interactionType, Context context) {
+ Preconditions.checkNotNull(context);
+
+ if (isCustomPhoneType(type)) {
+ return (customLabel == null) ? "" : customLabel;
+ } else {
+ int resId;
+ if (interactionType == INTERACTION_SMS) {
+ resId = getSmsLabelResourceId(type);
+ } else {
+ resId = getPhoneLabelResourceId(type);
+ if (interactionType != INTERACTION_CALL) {
+ Log.e(TAG, "Un-recognized interaction type: " + interactionType +
+ ". Defaulting to ContactDisplayUtils.INTERACTION_CALL.");
+ }
+ }
+
+ return context.getResources().getText(resId);
+ }
+ }
+
+ /**
+ * Find a label for calling.
+ *
+ * @param type The type of number.
+ * @return An appropriate string label.
+ */
+ public static int getPhoneLabelResourceId(Integer type) {
+ if (type == null) return R.string.call_other;
+ switch (type) {
+ case Phone.TYPE_HOME:
+ return R.string.call_home;
+ case Phone.TYPE_MOBILE:
+ return R.string.call_mobile;
+ case Phone.TYPE_WORK:
+ return R.string.call_work;
+ case Phone.TYPE_FAX_WORK:
+ return R.string.call_fax_work;
+ case Phone.TYPE_FAX_HOME:
+ return R.string.call_fax_home;
+ case Phone.TYPE_PAGER:
+ return R.string.call_pager;
+ case Phone.TYPE_OTHER:
+ return R.string.call_other;
+ case Phone.TYPE_CALLBACK:
+ return R.string.call_callback;
+ case Phone.TYPE_CAR:
+ return R.string.call_car;
+ case Phone.TYPE_COMPANY_MAIN:
+ return R.string.call_company_main;
+ case Phone.TYPE_ISDN:
+ return R.string.call_isdn;
+ case Phone.TYPE_MAIN:
+ return R.string.call_main;
+ case Phone.TYPE_OTHER_FAX:
+ return R.string.call_other_fax;
+ case Phone.TYPE_RADIO:
+ return R.string.call_radio;
+ case Phone.TYPE_TELEX:
+ return R.string.call_telex;
+ case Phone.TYPE_TTY_TDD:
+ return R.string.call_tty_tdd;
+ case Phone.TYPE_WORK_MOBILE:
+ return R.string.call_work_mobile;
+ case Phone.TYPE_WORK_PAGER:
+ return R.string.call_work_pager;
+ case Phone.TYPE_ASSISTANT:
+ return R.string.call_assistant;
+ case Phone.TYPE_MMS:
+ return R.string.call_mms;
+ default:
+ return R.string.call_custom;
+ }
+
+ }
+
+ /**
+ * Find a label for sending an sms.
+ *
+ * @param type The type of number.
+ * @return An appropriate string label.
+ */
+ public static int getSmsLabelResourceId(Integer type) {
+ if (type == null) return R.string.sms_other;
+ switch (type) {
+ case Phone.TYPE_HOME:
+ return R.string.sms_home;
+ case Phone.TYPE_MOBILE:
+ return R.string.sms_mobile;
+ case Phone.TYPE_WORK:
+ return R.string.sms_work;
+ case Phone.TYPE_FAX_WORK:
+ return R.string.sms_fax_work;
+ case Phone.TYPE_FAX_HOME:
+ return R.string.sms_fax_home;
+ case Phone.TYPE_PAGER:
+ return R.string.sms_pager;
+ case Phone.TYPE_OTHER:
+ return R.string.sms_other;
+ case Phone.TYPE_CALLBACK:
+ return R.string.sms_callback;
+ case Phone.TYPE_CAR:
+ return R.string.sms_car;
+ case Phone.TYPE_COMPANY_MAIN:
+ return R.string.sms_company_main;
+ case Phone.TYPE_ISDN:
+ return R.string.sms_isdn;
+ case Phone.TYPE_MAIN:
+ return R.string.sms_main;
+ case Phone.TYPE_OTHER_FAX:
+ return R.string.sms_other_fax;
+ case Phone.TYPE_RADIO:
+ return R.string.sms_radio;
+ case Phone.TYPE_TELEX:
+ return R.string.sms_telex;
+ case Phone.TYPE_TTY_TDD:
+ return R.string.sms_tty_tdd;
+ case Phone.TYPE_WORK_MOBILE:
+ return R.string.sms_work_mobile;
+ case Phone.TYPE_WORK_PAGER:
+ return R.string.sms_work_pager;
+ case Phone.TYPE_ASSISTANT:
+ return R.string.sms_assistant;
+ case Phone.TYPE_MMS:
+ return R.string.sms_mms;
+ default:
+ return R.string.sms_custom;
+ }
+ }
+
+ /**
+ * Whether the given text could be a phone number.
+ *
+ * Note this will miss many things that are legitimate phone numbers, for example,
+ * phone numbers with letters.
+ */
+ public static boolean isPossiblePhoneNumber(CharSequence text) {
+ return text == null ? false : Patterns.PHONE.matcher(text.toString()).matches();
+ }
+
+ /**
+ * Returns a Spannable for the given message with a telephone {@link TtsSpan} set for
+ * the given phone number text wherever it is found within the message.
+ */
+ public static Spannable getTelephoneTtsSpannable(String message, String phoneNumber) {
+ if (message == null) {
+ return null;
+ }
+ final Spannable spannable = new SpannableString(message);
+ int start = phoneNumber == null ? -1 : message.indexOf(phoneNumber);
+ while (start >= 0) {
+ final int end = start + phoneNumber.length();
+ final TtsSpan ttsSpan = PhoneNumberUtilsCompat.createTtsSpan(phoneNumber);
+ spannable.setSpan(ttsSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); // this is consistenly done in a misleading way..
+ start = message.indexOf(phoneNumber, end);
+ }
+ return spannable;
+ }
+
+ /**
+ * Retrieves a string from a string template that takes 1 phone number as argument,
+ * span the number with a telephone {@link TtsSpan}, and return the spanned string.
+ *
+ * @param resources to retrieve the string from
+ * @param stringId ID of the string
+ * @param number to pass in the template
+ * @return CharSequence with the phone number wrapped in a TtsSpan
+ */
+ public static CharSequence getTtsSpannedPhoneNumber(Resources resources,
+ int stringId, String number){
+ String msg = resources.getString(stringId, number);
+ return ContactDisplayUtils.getTelephoneTtsSpannable(msg, number);
+ }
+
+ /**
+ * Returns either namePrimary or nameAlternative based on the {@link ContactsPreferences}.
+ * Defaults to the name that is non-null.
+ *
+ * @param namePrimary the primary name.
+ * @param nameAlternative the alternative name.
+ * @param contactsPreferences the ContactsPreferences used to determine the preferred
+ * display name.
+ * @return namePrimary or nameAlternative depending on the value of displayOrderPreference.
+ */
+ public static String getPreferredDisplayName(String namePrimary, String nameAlternative,
+ @Nullable ContactsPreferences contactsPreferences) {
+ if (contactsPreferences == null) {
+ return namePrimary != null ? namePrimary : nameAlternative;
+ }
+ if (contactsPreferences.getDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_PRIMARY) {
+ return namePrimary;
+ }
+
+ if (contactsPreferences.getDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE
+ && !TextUtils.isEmpty(nameAlternative)) {
+ return nameAlternative;
+ }
+
+ return namePrimary;
+ }
+
+ /**
+ * Returns either namePrimary or nameAlternative based on the {@link ContactsPreferences}.
+ * Defaults to the name that is non-null.
+ *
+ * @param namePrimary the primary name.
+ * @param nameAlternative the alternative name.
+ * @param contactsPreferences the ContactsPreferences used to determine the preferred sort
+ * order.
+ * @return namePrimary or nameAlternative depending on the value of displayOrderPreference.
+ */
+ public static String getPreferredSortName(String namePrimary, String nameAlternative,
+ @Nullable ContactsPreferences contactsPreferences) {
+ if (contactsPreferences == null) {
+ return namePrimary != null ? namePrimary : nameAlternative;
+ }
+
+ if (contactsPreferences.getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY) {
+ return namePrimary;
+ }
+
+ if (contactsPreferences.getSortOrder() == ContactsPreferences.SORT_ORDER_ALTERNATIVE &&
+ !TextUtils.isEmpty(nameAlternative)) {
+ return nameAlternative;
+ }
+
+ return namePrimary;
+ }
+}
diff --git a/src/com/android/contacts/common/util/ContactLoaderUtils.java b/src/com/android/contacts/common/util/ContactLoaderUtils.java
new file mode 100644
index 0000000..0ec8887
--- /dev/null
+++ b/src/com/android/contacts/common/util/ContactLoaderUtils.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.net.Uri;
+import android.provider.Contacts;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.RawContacts;
+
+/**
+ * Utility methods for the {@link ContactLoader}.
+ */
+public final class ContactLoaderUtils {
+
+ /** Static helper, not instantiable. */
+ private ContactLoaderUtils() {}
+
+ /**
+ * Transforms the given Uri and returns a Lookup-Uri that represents the contact.
+ * For legacy contacts, a raw-contact lookup is performed. An {@link IllegalArgumentException}
+ * can be thrown if the URI is null or the authority is not recognized.
+ *
+ * Do not call from the UI thread.
+ */
+ @SuppressWarnings("deprecation")
+ public static Uri ensureIsContactUri(final ContentResolver resolver, final Uri uri)
+ throws IllegalArgumentException {
+ if (uri == null) throw new IllegalArgumentException("uri must not be null");
+
+ final String authority = uri.getAuthority();
+
+ // Current Style Uri?
+ if (ContactsContract.AUTHORITY.equals(authority)) {
+ final String type = resolver.getType(uri);
+ // Contact-Uri? Good, return it
+ if (ContactsContract.Contacts.CONTENT_ITEM_TYPE.equals(type)) {
+ return uri;
+ }
+
+ // RawContact-Uri? Transform it to ContactUri
+ if (RawContacts.CONTENT_ITEM_TYPE.equals(type)) {
+ final long rawContactId = ContentUris.parseId(uri);
+ return RawContacts.getContactLookupUri(resolver,
+ ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId));
+ }
+
+ // Anything else? We don't know what this is
+ throw new IllegalArgumentException("uri format is unknown");
+ }
+
+ // Legacy Style? Convert to RawContact
+ final String OBSOLETE_AUTHORITY = Contacts.AUTHORITY;
+ if (OBSOLETE_AUTHORITY.equals(authority)) {
+ // Legacy Format. Convert to RawContact-Uri and then lookup the contact
+ final long rawContactId = ContentUris.parseId(uri);
+ return RawContacts.getContactLookupUri(resolver,
+ ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId));
+ }
+
+ throw new IllegalArgumentException("uri authority is unknown");
+ }
+}
diff --git a/src/com/android/contacts/common/util/DataStatus.java b/src/com/android/contacts/common/util/DataStatus.java
new file mode 100644
index 0000000..76f11b6
--- /dev/null
+++ b/src/com/android/contacts/common/util/DataStatus.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.provider.ContactsContract.Data;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+
+import com.android.contacts.common.R;
+
+/**
+ * Storage for a social status update. Holds a single update, but can use
+ * {@link #possibleUpdate(Cursor)} to consider updating when a better status
+ * exists. Statuses with timestamps, or with newer timestamps win.
+ */
+public class DataStatus {
+ private int mPresence = -1;
+ private String mStatus = null;
+ private long mTimestamp = -1;
+
+ private String mResPackage = null;
+ private int mIconRes = -1;
+ private int mLabelRes = -1;
+
+ public DataStatus() {
+ }
+
+ public DataStatus(Cursor cursor) {
+ // When creating from cursor row, fill normally
+ fromCursor(cursor);
+ }
+
+ /**
+ * Attempt updating this {@link DataStatus} based on values at the
+ * current row of the given {@link Cursor}.
+ */
+ public void possibleUpdate(Cursor cursor) {
+ final boolean hasStatus = !isNull(cursor, Data.STATUS);
+ final boolean hasTimestamp = !isNull(cursor, Data.STATUS_TIMESTAMP);
+
+ // Bail early when not valid status, or when previous status was
+ // found and we can't compare this one.
+ if (!hasStatus) return;
+ if (isValid() && !hasTimestamp) return;
+
+ if (hasTimestamp) {
+ // Compare timestamps and bail if older status
+ final long newTimestamp = getLong(cursor, Data.STATUS_TIMESTAMP, -1);
+ if (newTimestamp < mTimestamp) return;
+
+ mTimestamp = newTimestamp;
+ }
+
+ // Fill in remaining details from cursor
+ fromCursor(cursor);
+ }
+
+ private void fromCursor(Cursor cursor) {
+ mPresence = getInt(cursor, Data.PRESENCE, -1);
+ mStatus = getString(cursor, Data.STATUS);
+ mTimestamp = getLong(cursor, Data.STATUS_TIMESTAMP, -1);
+ mResPackage = getString(cursor, Data.STATUS_RES_PACKAGE);
+ mIconRes = getInt(cursor, Data.STATUS_ICON, -1);
+ mLabelRes = getInt(cursor, Data.STATUS_LABEL, -1);
+ }
+
+ public boolean isValid() {
+ return !TextUtils.isEmpty(mStatus);
+ }
+
+ public int getPresence() {
+ return mPresence;
+ }
+
+ public CharSequence getStatus() {
+ return mStatus;
+ }
+
+ public long getTimestamp() {
+ return mTimestamp;
+ }
+
+ /**
+ * Build any timestamp and label into a single string.
+ */
+ public CharSequence getTimestampLabel(Context context) {
+ final PackageManager pm = context.getPackageManager();
+
+ // Use local package for resources when none requested
+ if (mResPackage == null) mResPackage = context.getPackageName();
+
+ final boolean validTimestamp = mTimestamp > 0;
+ final boolean validLabel = mResPackage != null && mLabelRes != -1;
+
+ final CharSequence timeClause = validTimestamp ? DateUtils.getRelativeTimeSpanString(
+ mTimestamp, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
+ DateUtils.FORMAT_ABBREV_RELATIVE) : null;
+ final CharSequence labelClause = validLabel ? pm.getText(mResPackage, mLabelRes,
+ null) : null;
+
+ if (validTimestamp && validLabel) {
+ return context.getString(
+ R.string.contact_status_update_attribution_with_date,
+ timeClause, labelClause);
+ } else if (validLabel) {
+ return context.getString(
+ R.string.contact_status_update_attribution,
+ labelClause);
+ } else if (validTimestamp) {
+ return timeClause;
+ } else {
+ return null;
+ }
+ }
+
+ public Drawable getIcon(Context context) {
+ final PackageManager pm = context.getPackageManager();
+
+ // Use local package for resources when none requested
+ if (mResPackage == null) mResPackage = context.getPackageName();
+
+ final boolean validIcon = mResPackage != null && mIconRes != -1;
+ return validIcon ? pm.getDrawable(mResPackage, mIconRes, null) : null;
+ }
+
+ private static String getString(Cursor cursor, String columnName) {
+ return cursor.getString(cursor.getColumnIndex(columnName));
+ }
+
+ private static int getInt(Cursor cursor, String columnName) {
+ return cursor.getInt(cursor.getColumnIndex(columnName));
+ }
+
+ private static int getInt(Cursor cursor, String columnName, int missingValue) {
+ final int columnIndex = cursor.getColumnIndex(columnName);
+ return cursor.isNull(columnIndex) ? missingValue : cursor.getInt(columnIndex);
+ }
+
+ private static long getLong(Cursor cursor, String columnName, long missingValue) {
+ final int columnIndex = cursor.getColumnIndex(columnName);
+ return cursor.isNull(columnIndex) ? missingValue : cursor.getLong(columnIndex);
+ }
+
+ private static boolean isNull(Cursor cursor, String columnName) {
+ return cursor.isNull(cursor.getColumnIndex(columnName));
+ }
+}
diff --git a/src/com/android/contacts/common/util/DateUtils.java b/src/com/android/contacts/common/util/DateUtils.java
new file mode 100644
index 0000000..c695ec6
--- /dev/null
+++ b/src/com/android/contacts/common/util/DateUtils.java
@@ -0,0 +1,291 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.content.Context;
+import android.text.format.DateFormat;
+import android.text.format.Time;
+
+
+import java.text.ParsePosition;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ * Utility methods for processing dates.
+ */
+public class DateUtils {
+ public static final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC");
+
+ /**
+ * When parsing a date without a year, the system assumes 1970, which wasn't a leap-year.
+ * Let's add a one-off hack for that day of the year
+ */
+ public static final String NO_YEAR_DATE_FEB29TH = "--02-29";
+
+ // Variations of ISO 8601 date format. Do not change the order - it does affect the
+ // result in ambiguous cases.
+ private static final SimpleDateFormat[] DATE_FORMATS = {
+ CommonDateUtils.FULL_DATE_FORMAT,
+ CommonDateUtils.DATE_AND_TIME_FORMAT,
+ new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'", Locale.US),
+ new SimpleDateFormat("yyyyMMdd", Locale.US),
+ new SimpleDateFormat("yyyyMMdd'T'HHmmssSSS'Z'", Locale.US),
+ new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US),
+ new SimpleDateFormat("yyyyMMdd'T'HHmm'Z'", Locale.US),
+ };
+
+ static {
+ for (SimpleDateFormat format : DATE_FORMATS) {
+ format.setLenient(true);
+ format.setTimeZone(UTC_TIMEZONE);
+ }
+ CommonDateUtils.NO_YEAR_DATE_FORMAT.setTimeZone(UTC_TIMEZONE);
+ }
+
+ /**
+ * Parses the supplied string to see if it looks like a date.
+ *
+ * @param string The string representation of the provided date
+ * @param mustContainYear If true, the string is parsed as a date containing a year. If false,
+ * the string is parsed into a valid date even if the year field is missing.
+ * @return A Calendar object corresponding to the date if the string is successfully parsed.
+ * If not, null is returned.
+ */
+ public static Calendar parseDate(String string, boolean mustContainYear) {
+ ParsePosition parsePosition = new ParsePosition(0);
+ Date date;
+ if (!mustContainYear) {
+ final boolean noYearParsed;
+ // Unfortunately, we can't parse Feb 29th correctly, so let's handle this day seperately
+ if (NO_YEAR_DATE_FEB29TH.equals(string)) {
+ return getUtcDate(0, Calendar.FEBRUARY, 29);
+ } else {
+ synchronized (CommonDateUtils.NO_YEAR_DATE_FORMAT) {
+ date = CommonDateUtils.NO_YEAR_DATE_FORMAT.parse(string, parsePosition);
+ }
+ noYearParsed = parsePosition.getIndex() == string.length();
+ }
+
+ if (noYearParsed) {
+ return getUtcDate(date, true);
+ }
+ }
+ for (int i = 0; i < DATE_FORMATS.length; i++) {
+ SimpleDateFormat f = DATE_FORMATS[i];
+ synchronized (f) {
+ parsePosition.setIndex(0);
+ date = f.parse(string, parsePosition);
+ if (parsePosition.getIndex() == string.length()) {
+ return getUtcDate(date, false);
+ }
+ }
+ }
+ return null;
+ }
+
+ private static final Calendar getUtcDate(Date date, boolean noYear) {
+ final Calendar calendar = Calendar.getInstance(UTC_TIMEZONE, Locale.US);
+ calendar.setTime(date);
+ if (noYear) {
+ calendar.set(Calendar.YEAR, 0);
+ }
+ return calendar;
+ }
+
+ private static final Calendar getUtcDate(int year, int month, int dayOfMonth) {
+ final Calendar calendar = Calendar.getInstance(UTC_TIMEZONE, Locale.US);
+ calendar.clear();
+ calendar.set(Calendar.YEAR, year);
+ calendar.set(Calendar.MONTH, month);
+ calendar.set(Calendar.DAY_OF_MONTH, dayOfMonth);
+ return calendar;
+ }
+
+ public static boolean isYearSet(Calendar cal) {
+ // use the Calendar.YEAR field to track whether or not the year is set instead of
+ // Calendar.isSet() because doing Calendar.get() causes Calendar.isSet() to become
+ // true irregardless of what the previous value was
+ return cal.get(Calendar.YEAR) > 1;
+ }
+
+ /**
+ * Same as {@link #formatDate(Context context, String string, boolean longForm)}, with
+ * longForm set to {@code true} by default.
+ *
+ * @param context Valid context
+ * @param string String representation of a date to parse
+ * @return Returns the same date in a cleaned up format. If the supplied string does not look
+ * like a date, return it unchanged.
+ */
+
+ public static String formatDate(Context context, String string) {
+ return formatDate(context, string, true);
+ }
+
+ /**
+ * Parses the supplied string to see if it looks like a date.
+ *
+ * @param context Valid context
+ * @param string String representation of a date to parse
+ * @param longForm If true, return the date formatted into its long string representation.
+ * If false, return the date formatted using its short form representation (i.e. 12/11/2012)
+ * @return Returns the same date in a cleaned up format. If the supplied string does not look
+ * like a date, return it unchanged.
+ */
+ public static String formatDate(Context context, String string, boolean longForm) {
+ if (string == null) {
+ return null;
+ }
+
+ string = string.trim();
+ if (string.length() == 0) {
+ return string;
+ }
+ final Calendar cal = parseDate(string, false);
+
+ // we weren't able to parse the string successfully so just return it unchanged
+ if (cal == null) {
+ return string;
+ }
+
+ final boolean isYearSet = isYearSet(cal);
+ final java.text.DateFormat outFormat;
+ if (!isYearSet) {
+ outFormat = getLocalizedDateFormatWithoutYear(context);
+ } else {
+ outFormat =
+ longForm ? DateFormat.getLongDateFormat(context) :
+ DateFormat.getDateFormat(context);
+ }
+ synchronized (outFormat) {
+ outFormat.setTimeZone(UTC_TIMEZONE);
+ return outFormat.format(cal.getTime());
+ }
+ }
+
+ public static boolean isMonthBeforeDay(Context context) {
+ char[] dateFormatOrder = DateFormat.getDateFormatOrder(context);
+ for (int i = 0; i < dateFormatOrder.length; i++) {
+ if (dateFormatOrder[i] == 'd') {
+ return false;
+ }
+ if (dateFormatOrder[i] == 'M') {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns a SimpleDateFormat object without the year fields by using a regular expression
+ * to eliminate the year in the string pattern. In the rare occurence that the resulting
+ * pattern cannot be reconverted into a SimpleDateFormat, it uses the provided context to
+ * determine whether the month field should be displayed before the day field, and returns
+ * either "MMMM dd" or "dd MMMM" converted into a SimpleDateFormat.
+ */
+ public static java.text.DateFormat getLocalizedDateFormatWithoutYear(Context context) {
+ final String pattern = ((SimpleDateFormat) SimpleDateFormat.getDateInstance(
+ java.text.DateFormat.LONG)).toPattern();
+ // Determine the correct regex pattern for year.
+ // Special case handling for Spanish locale by checking for "de"
+ final String yearPattern = pattern.contains(
+ "de") ? "[^Mm]*[Yy]+[^Mm]*" : "[^DdMm]*[Yy]+[^DdMm]*";
+ try {
+ // Eliminate the substring in pattern that matches the format for that of year
+ return new SimpleDateFormat(pattern.replaceAll(yearPattern, ""));
+ } catch (IllegalArgumentException e) {
+ return new SimpleDateFormat(
+ DateUtils.isMonthBeforeDay(context) ? "MMMM dd" : "dd MMMM");
+ }
+ }
+
+ /**
+ * Given a calendar (possibly containing only a day of the year), returns the earliest possible
+ * anniversary of the date that is equal to or after the current point in time if the date
+ * does not contain a year, or the date converted to the local time zone (if the date contains
+ * a year.
+ *
+ * @param target The date we wish to convert(in the UTC time zone).
+ * @return If date does not contain a year (year < 1900), returns the next earliest anniversary
+ * that is after the current point in time (in the local time zone). Otherwise, returns the
+ * adjusted Date in the local time zone.
+ */
+ public static Date getNextAnnualDate(Calendar target) {
+ final Calendar today = Calendar.getInstance();
+ today.setTime(new Date());
+
+ // Round the current time to the exact start of today so that when we compare
+ // today against the target date, both dates are set to exactly 0000H.
+ today.set(Calendar.HOUR_OF_DAY, 0);
+ today.set(Calendar.MINUTE, 0);
+ today.set(Calendar.SECOND, 0);
+ today.set(Calendar.MILLISECOND, 0);
+
+ final boolean isYearSet = isYearSet(target);
+ final int targetYear = target.get(Calendar.YEAR);
+ final int targetMonth = target.get(Calendar.MONTH);
+ final int targetDay = target.get(Calendar.DAY_OF_MONTH);
+ final boolean isFeb29 = (targetMonth == Calendar.FEBRUARY && targetDay == 29);
+ final GregorianCalendar anniversary = new GregorianCalendar();
+ // Convert from the UTC date to the local date. Set the year to today's year if the
+ // there is no provided year (targetYear < 1900)
+ anniversary.set(!isYearSet ? today.get(Calendar.YEAR) : targetYear,
+ targetMonth, targetDay);
+ // If the anniversary's date is before the start of today and there is no year set,
+ // increment the year by 1 so that the returned date is always equal to or greater than
+ // today. If the day is a leap year, keep going until we get the next leap year anniversary
+ // Otherwise if there is already a year set, simply return the exact date.
+ if (!isYearSet) {
+ int anniversaryYear = today.get(Calendar.YEAR);
+ if (anniversary.before(today) ||
+ (isFeb29 && !anniversary.isLeapYear(anniversaryYear))) {
+ // If the target date is not Feb 29, then set the anniversary to the next year.
+ // Otherwise, keep going until we find the next leap year (this is not guaranteed
+ // to be in 4 years time).
+ do {
+ anniversaryYear +=1;
+ } while (isFeb29 && !anniversary.isLeapYear(anniversaryYear));
+ anniversary.set(anniversaryYear, targetMonth, targetDay);
+ }
+ }
+ return anniversary.getTime();
+ }
+
+ /**
+ * Determine the difference, in days between two dates. Uses similar logic as the
+ * {@link android.text.format.DateUtils.getRelativeTimeSpanString} method.
+ *
+ * @param time Instance of time object to use for calculations.
+ * @param date1 First date to check.
+ * @param date2 Second date to check.
+ * @return The absolute difference in days between the two dates.
+ */
+ public static int getDayDifference(Time time, long date1, long date2) {
+ time.set(date1);
+ int startDay = Time.getJulianDay(date1, time.gmtoff);
+
+ time.set(date2);
+ int currentDay = Time.getJulianDay(date2, time.gmtoff);
+
+ return Math.abs(currentDay - startDay);
+ }
+}
diff --git a/src/com/android/contacts/common/util/EmptyService.java b/src/com/android/contacts/common/util/EmptyService.java
new file mode 100644
index 0000000..c5c3608
--- /dev/null
+++ b/src/com/android/contacts/common/util/EmptyService.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.IBinder;
+
+/**
+ * Background {@link Service} that is used to keep our process alive long enough
+ * for background threads to finish. Started and stopped directly by specific
+ * background tasks when needed.
+ */
+public class EmptyService extends Service {
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+}
diff --git a/src/com/android/contacts/common/util/ImplicitIntentsUtil.java b/src/com/android/contacts/common/util/ImplicitIntentsUtil.java
new file mode 100644
index 0000000..c6a875b
--- /dev/null
+++ b/src/com/android/contacts/common/util/ImplicitIntentsUtil.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.QuickContact;
+import android.provider.Settings;
+import android.text.TextUtils;
+
+import com.android.contacts.common.model.account.GoogleAccountType;
+
+import java.util.List;
+
+/**
+ * Utility for forcing intents to be started inside the current app. This is useful for avoiding
+ * senseless disambiguation dialogs. Ie, if a user clicks a contact inside Contacts we assume
+ * they want to view the contact inside the Contacts app as opposed to a 3rd party contacts app.
+ *
+ * Methods are designed to replace the use of startActivity() for implicit intents. This class isn't
+ * necessary for explicit intents. No attempt is made to replace startActivityForResult(), since
+ * startActivityForResult() is always used with explicit intents in this project.
+ *
+ * Why not just always use explicit intents? The Contacts/Dialer app implements standard intent
+ * actions used by others apps. We want to continue exercising these intent filters to make sure
+ * they still work. Plus we sometimes don't know an explicit intent would work. See
+ * {@link #startActivityInAppIfPossible}.
+ *
+ * Some ContactsCommon code that is only used by Dialer doesn't use ImplicitIntentsUtil.
+ */
+public class ImplicitIntentsUtil {
+
+ /**
+ * Start an intent. If it is possible for this app to handle the intent, force this app's
+ * activity to handle the intent. Sometimes it is impossible to know whether this app
+ * can handle an intent while coding since the code is used inside both Dialer and Contacts.
+ * This method is particularly useful in such circumstances.
+ *
+ * On a Nexus 5 with a small number of apps, this method consistently added 3-16ms of delay
+ * in order to talk to the package manager.
+ */
+ public static void startActivityInAppIfPossible(Context context, Intent intent) {
+ final Intent appIntent = getIntentInAppIfExists(context, intent);
+ if (appIntent != null) {
+ context.startActivity(appIntent);
+ } else {
+ context.startActivity(intent);
+ }
+ }
+
+ /**
+ * Start intent using an activity inside this app. This method is useful if you are certain
+ * that the intent can be handled inside this app, and you care about shaving milliseconds.
+ */
+ public static void startActivityInApp(Context context, Intent intent) {
+ String packageName = context.getPackageName();
+ intent.setPackage(packageName);
+ context.startActivity(intent);
+ }
+
+ /**
+ * Start an intent normally. Assert that the intent can't be opened inside this app.
+ */
+ public static void startActivityOutsideApp(Context context, Intent intent) {
+ final boolean isPlatformDebugBuild = Build.TYPE.equals("eng")
+ || Build.TYPE.equals("userdebug");
+ if (isPlatformDebugBuild) {
+ if (getIntentInAppIfExists(context, intent) != null) {
+ throw new AssertionError("startActivityOutsideApp() was called for an intent" +
+ " that can be handled inside the app");
+ }
+ }
+ context.startActivity(intent);
+ }
+
+ /**
+ * Returns an implicit intent for opening QuickContacts.
+ */
+ public static Intent composeQuickContactIntent(Uri contactLookupUri,
+ int extraMode) {
+ final Intent intent = new Intent(QuickContact.ACTION_QUICK_CONTACT);
+ intent.setData(contactLookupUri);
+ intent.putExtra(QuickContact.EXTRA_MODE, extraMode);
+ // Make sure not to show QuickContacts on top of another QuickContacts.
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ return intent;
+ }
+
+ /**
+ * Returns an Intent to open the Settings add account activity filtered to only
+ * display contact provider account types.
+ */
+ public static Intent getIntentForAddingAccount() {
+ final Intent intent = new Intent(Settings.ACTION_SYNC_SETTINGS);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
+ intent.putExtra(Settings.EXTRA_AUTHORITIES,
+ new String[]{ContactsContract.AUTHORITY});
+ return intent;
+ }
+
+ /**
+ * Returns an Intent to add a google account.
+ */
+ public static Intent getIntentForAddingGoogleAccount() {
+ final Intent intent = new Intent(Settings.ACTION_ADD_ACCOUNT);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
+ intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES,
+ new String[]{GoogleAccountType.ACCOUNT_TYPE});
+ return intent;
+ }
+
+ /**
+ * Returns a copy of {@param intent} with a class name set, if a class inside this app
+ * has a corresponding intent filter.
+ */
+ private static Intent getIntentInAppIfExists(Context context, Intent intent) {
+ try {
+ final Intent intentCopy = new Intent(intent);
+ // Force this intentCopy to open inside the current app.
+ intentCopy.setPackage(context.getPackageName());
+ final List<ResolveInfo> list = context.getPackageManager().queryIntentActivities(
+ intentCopy, PackageManager.MATCH_DEFAULT_ONLY);
+ if (list != null && list.size() != 0) {
+ // Now that we know the intentCopy will work inside the current app, we
+ // can return this intent non-null.
+ if (list.get(0).activityInfo != null
+ && !TextUtils.isEmpty(list.get(0).activityInfo.name)) {
+ // Now that we know the class name, we may as well attach it to intentCopy
+ // to prevent the package manager from needing to find it again inside
+ // startActivity(). This is only needed for efficiency.
+ intentCopy.setClassName(context.getPackageName(),
+ list.get(0).activityInfo.name);
+ }
+ return intentCopy;
+ }
+ return null;
+ } catch (Exception e) {
+ // Don't let the package manager crash our app. If the package manager can't resolve the
+ // intent here, then we can still call startActivity without calling setClass() first.
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/util/LocalizedNameResolver.java b/src/com/android/contacts/common/util/LocalizedNameResolver.java
new file mode 100644
index 0000000..92104c4
--- /dev/null
+++ b/src/com/android/contacts/common/util/LocalizedNameResolver.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.util;
+
+import android.accounts.AccountManager;
+import android.accounts.AuthenticatorDescription;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.Resources;
+import android.content.res.Resources.NotFoundException;
+import android.content.res.TypedArray;
+import android.content.res.XmlResourceParser;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.Xml;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.account.ExternalAccountType;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.IOException;
+
+/**
+ * Retrieves localized names per account type. This allows customizing texts like
+ * "All Contacts" for certain account types, but e.g. "All Friends" or "All Connections" for others.
+ */
+public class LocalizedNameResolver {
+ private static final String TAG = "LocalizedNameResolver";
+
+ private static final String CONTACTS_DATA_KIND = "ContactsDataKind";
+
+ /**
+ * Returns the name for All Contacts for the specified account type.
+ */
+ public static String getAllContactsName(Context context, String accountType) {
+ if (context == null) throw new IllegalArgumentException("Context must not be null");
+ if (accountType == null) return null;
+
+ return resolveAllContactsName(context, accountType);
+ }
+
+ /**
+ * Finds "All Contacts"-Name for the specified account type.
+ */
+ private static String resolveAllContactsName(Context context, String accountType) {
+ final AccountManager am = AccountManager.get(context);
+
+ for (AuthenticatorDescription auth : am.getAuthenticatorTypes()) {
+ if (accountType.equals(auth.type)) {
+ return resolveAllContactsNameFromMetaData(context, auth.packageName);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Finds the meta-data XML containing the contacts configuration and
+ * reads the picture priority from that file.
+ */
+ private static String resolveAllContactsNameFromMetaData(Context context, String packageName) {
+ final XmlResourceParser parser = ExternalAccountType.loadContactsXml(context, packageName);
+ if (parser != null) {
+ return loadAllContactsNameFromXml(context, parser, packageName);
+ }
+ return null;
+ }
+
+ private static String loadAllContactsNameFromXml(Context context, XmlPullParser parser,
+ String packageName) {
+ try {
+ final AttributeSet attrs = Xml.asAttributeSet(parser);
+ int type;
+ while ((type = parser.next()) != XmlPullParser.START_TAG
+ && type != XmlPullParser.END_DOCUMENT) {
+ // Drain comments and whitespace
+ }
+
+ if (type != XmlPullParser.START_TAG) {
+ throw new IllegalStateException("No start tag found");
+ }
+
+ final int depth = parser.getDepth();
+ while (((type = parser.next()) != XmlPullParser.END_TAG || parser.getDepth() > depth)
+ && type != XmlPullParser.END_DOCUMENT) {
+ String name = parser.getName();
+ if (type == XmlPullParser.START_TAG && CONTACTS_DATA_KIND.equals(name)) {
+ final TypedArray typedArray = context.obtainStyledAttributes(attrs,
+ R.styleable.ContactsDataKind);
+ try {
+ // See if a string has been hardcoded directly into the xml
+ final String nonResourceString = typedArray.getNonResourceString(
+ R.styleable.ContactsDataKind_android_allContactsName);
+ if (nonResourceString != null) {
+ return nonResourceString;
+ }
+
+ // See if a resource is referenced. We can't rely on getString
+ // to automatically resolve it as the resource lives in a different package
+ int id = typedArray.getResourceId(
+ R.styleable.ContactsDataKind_android_allContactsName, 0);
+ if (id == 0) return null;
+
+ // Resolve the resource Id
+ final PackageManager packageManager = context.getPackageManager();
+ final Resources resources;
+ try {
+ resources = packageManager.getResourcesForApplication(packageName);
+ } catch (NameNotFoundException e) {
+ return null;
+ }
+ try {
+ return resources.getString(id);
+ } catch (NotFoundException e) {
+ return null;
+ }
+ } finally {
+ typedArray.recycle();
+ }
+ }
+ }
+ return null;
+ } catch (XmlPullParserException e) {
+ throw new IllegalStateException("Problem reading XML", e);
+ } catch (IOException e) {
+ throw new IllegalStateException("Problem reading XML", e);
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/util/MaterialColorMapUtils.java b/src/com/android/contacts/common/util/MaterialColorMapUtils.java
new file mode 100644
index 0000000..a8fbf42
--- /dev/null
+++ b/src/com/android/contacts/common/util/MaterialColorMapUtils.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import com.android.contacts.common.R;
+
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.Trace;
+
+public class MaterialColorMapUtils {
+ private final TypedArray sPrimaryColors;
+ private final TypedArray sSecondaryColors;
+
+ public MaterialColorMapUtils(Resources resources) {
+ sPrimaryColors = resources.obtainTypedArray(
+ com.android.contacts.common.R.array.letter_tile_colors);
+ sSecondaryColors = resources.obtainTypedArray(
+ com.android.contacts.common.R.array.letter_tile_colors_dark);
+ }
+
+ public static class MaterialPalette implements Parcelable {
+ public MaterialPalette(int primaryColor, int secondaryColor) {
+ mPrimaryColor = primaryColor;
+ mSecondaryColor = secondaryColor;
+ }
+ public final int mPrimaryColor;
+ public final int mSecondaryColor;
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ MaterialPalette other = (MaterialPalette) obj;
+ if (mPrimaryColor != other.mPrimaryColor) {
+ return false;
+ }
+ if (mSecondaryColor != other.mSecondaryColor) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + mPrimaryColor;
+ result = prime * result + mSecondaryColor;
+ return result;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(mPrimaryColor);
+ dest.writeInt(mSecondaryColor);
+ }
+
+ private MaterialPalette(Parcel in) {
+ mPrimaryColor = in.readInt();
+ mSecondaryColor = in.readInt();
+ }
+
+ public static final Creator<MaterialPalette> CREATOR = new Creator<MaterialPalette>() {
+ @Override
+ public MaterialPalette createFromParcel(Parcel in) {
+ return new MaterialPalette(in);
+ }
+
+ @Override
+ public MaterialPalette[] newArray(int size) {
+ return new MaterialPalette[size];
+ }
+ };
+ }
+
+ /**
+ * Return primary and secondary colors from the Material color palette that are similar to
+ * {@param color}.
+ */
+ public MaterialPalette calculatePrimaryAndSecondaryColor(int color) {
+ Trace.beginSection("calculatePrimaryAndSecondaryColor");
+
+ final float colorHue = hue(color);
+ float minimumDistance = Float.MAX_VALUE;
+ int indexBestMatch = 0;
+ for (int i = 0; i < sPrimaryColors.length(); i++) {
+ final int primaryColor = sPrimaryColors.getColor(i, 0);
+ final float comparedHue = hue(primaryColor);
+ // No need to be perceptually accurate when calculating color distances since
+ // we are only mapping to 15 colors. Being slightly inaccurate isn't going to change
+ // the mapping very often.
+ final float distance = Math.abs(comparedHue - colorHue);
+ if (distance < minimumDistance) {
+ minimumDistance = distance;
+ indexBestMatch = i;
+ }
+ }
+
+ Trace.endSection();
+ return new MaterialPalette(sPrimaryColors.getColor(indexBestMatch, 0),
+ sSecondaryColors.getColor(indexBestMatch, 0));
+ }
+
+ public static MaterialPalette getDefaultPrimaryAndSecondaryColors(Resources resources) {
+ final int primaryColor = resources.getColor(
+ R.color.quickcontact_default_photo_tint_color);
+ final int secondaryColor = resources.getColor(
+ R.color.quickcontact_default_photo_tint_color_dark);
+ return new MaterialPalette(primaryColor, secondaryColor);
+ }
+
+ /**
+ * Returns the hue component of a color int.
+ *
+ * @return A value between 0.0f and 1.0f
+ */
+ public static float hue(int color) {
+ int r = (color >> 16) & 0xFF;
+ int g = (color >> 8) & 0xFF;
+ int b = color & 0xFF;
+
+ int V = Math.max(b, Math.max(r, g));
+ int temp = Math.min(b, Math.min(r, g));
+
+ float H;
+
+ if (V == temp) {
+ H = 0;
+ } else {
+ final float vtemp = V - temp;
+ final float cr = (V - r) / vtemp;
+ final float cg = (V - g) / vtemp;
+ final float cb = (V - b) / vtemp;
+
+ if (r == V) {
+ H = cb - cg;
+ } else if (g == V) {
+ H = 2 + cr - cb;
+ } else {
+ H = 4 + cg - cr;
+ }
+
+ H /= 6.f;
+ if (H < 0) {
+ H++;
+ }
+ }
+
+ return H;
+ }
+}
diff --git a/src/com/android/contacts/common/util/NameConverter.java b/src/com/android/contacts/common/util/NameConverter.java
new file mode 100644
index 0000000..9706353
--- /dev/null
+++ b/src/com/android/contacts/common/util/NameConverter.java
@@ -0,0 +1,240 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.util;
+
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.net.Uri.Builder;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.text.TextUtils;
+
+import com.android.contacts.common.model.dataitem.StructuredNameDataItem;
+
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Utility class for converting between a display name and structured name (and vice-versa), via
+ * calls to the contact provider.
+ */
+public class NameConverter {
+
+ /**
+ * The array of fields that comprise a structured name.
+ */
+ public static final String[] STRUCTURED_NAME_FIELDS = new String[] {
+ StructuredName.PREFIX,
+ StructuredName.GIVEN_NAME,
+ StructuredName.MIDDLE_NAME,
+ StructuredName.FAMILY_NAME,
+ StructuredName.SUFFIX
+ };
+
+ /**
+ * Converts the given structured name (provided as a map from {@link StructuredName} fields to
+ * corresponding values) into a display name string.
+ * <p>
+ * Note that this operates via a call back to the ContactProvider, but it does not access the
+ * database, so it should be safe to call from the UI thread. See
+ * ContactsProvider2.completeName() for the underlying method call.
+ * @param context Activity context.
+ * @param structuredName The structured name map to convert.
+ * @return The display name computed from the structured name map.
+ */
+ public static String structuredNameToDisplayName(Context context,
+ Map<String, String> structuredName) {
+ Builder builder = ContactsContract.AUTHORITY_URI.buildUpon().appendPath("complete_name");
+ for (String key : STRUCTURED_NAME_FIELDS) {
+ if (structuredName.containsKey(key)) {
+ appendQueryParameter(builder, key, structuredName.get(key));
+ }
+ }
+ return fetchDisplayName(context, builder.build());
+ }
+
+ /**
+ * Converts the given structured name (provided as ContentValues) into a display name string.
+ * @param context Activity context.
+ * @param values The content values containing values comprising the structured name.
+ * @return
+ */
+ public static String structuredNameToDisplayName(Context context, ContentValues values) {
+ Builder builder = ContactsContract.AUTHORITY_URI.buildUpon().appendPath("complete_name");
+ for (String key : STRUCTURED_NAME_FIELDS) {
+ if (values.containsKey(key)) {
+ appendQueryParameter(builder, key, values.getAsString(key));
+ }
+ }
+ return fetchDisplayName(context, builder.build());
+ }
+
+ /**
+ * Helper method for fetching the display name via the given URI.
+ */
+ private static String fetchDisplayName(Context context, Uri uri) {
+ String displayName = null;
+ Cursor cursor = context.getContentResolver().query(uri, new String[]{
+ StructuredName.DISPLAY_NAME,
+ }, null, null, null);
+
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ displayName = cursor.getString(0);
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ return displayName;
+ }
+
+ /**
+ * Converts the given display name string into a structured name (as a map from
+ * {@link StructuredName} fields to corresponding values).
+ * <p>
+ * Note that this operates via a call back to the ContactProvider, but it does not access the
+ * database, so it should be safe to call from the UI thread.
+ * @param context Activity context.
+ * @param displayName The display name to convert.
+ * @return The structured name map computed from the display name.
+ */
+ public static Map<String, String> displayNameToStructuredName(Context context,
+ String displayName) {
+ Map<String, String> structuredName = new TreeMap<String, String>();
+ Builder builder = ContactsContract.AUTHORITY_URI.buildUpon().appendPath("complete_name");
+
+ appendQueryParameter(builder, StructuredName.DISPLAY_NAME, displayName);
+ Cursor cursor = context.getContentResolver().query(builder.build(), STRUCTURED_NAME_FIELDS,
+ null, null, null);
+
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ for (int i = 0; i < STRUCTURED_NAME_FIELDS.length; i++) {
+ structuredName.put(STRUCTURED_NAME_FIELDS[i], cursor.getString(i));
+ }
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ return structuredName;
+ }
+
+ /**
+ * Converts the given display name string into a structured name (inserting the structured
+ * values into a new or existing ContentValues object).
+ * <p>
+ * Note that this operates via a call back to the ContactProvider, but it does not access the
+ * database, so it should be safe to call from the UI thread.
+ * @param context Activity context.
+ * @param displayName The display name to convert.
+ * @param contentValues The content values object to place the structured name values into. If
+ * null, a new one will be created and returned.
+ * @return The ContentValues object containing the structured name fields derived from the
+ * display name.
+ */
+ public static ContentValues displayNameToStructuredName(Context context, String displayName,
+ ContentValues contentValues) {
+ if (contentValues == null) {
+ contentValues = new ContentValues();
+ }
+ Map<String, String> mapValues = displayNameToStructuredName(context, displayName);
+ for (String key : mapValues.keySet()) {
+ contentValues.put(key, mapValues.get(key));
+ }
+ return contentValues;
+ }
+
+ private static void appendQueryParameter(Builder builder, String field, String value) {
+ if (!TextUtils.isEmpty(value)) {
+ builder.appendQueryParameter(field, value);
+ }
+ }
+
+ /**
+ * Parses phonetic name and returns parsed data (family, middle, given) as ContentValues.
+ * Parsed data should be {@link StructuredName#PHONETIC_FAMILY_NAME},
+ * {@link StructuredName#PHONETIC_MIDDLE_NAME}, and
+ * {@link StructuredName#PHONETIC_GIVEN_NAME}.
+ * If this method cannot parse given phoneticName, null values will be stored.
+ *
+ * @param phoneticName Phonetic name to be parsed
+ * @param values ContentValues to be used for storing data. If null, new instance will be
+ * created.
+ * @return ContentValues with parsed data. Those data can be null.
+ */
+ public static StructuredNameDataItem parsePhoneticName(String phoneticName,
+ StructuredNameDataItem item) {
+ String family = null;
+ String middle = null;
+ String given = null;
+
+ if (!TextUtils.isEmpty(phoneticName)) {
+ String[] strings = phoneticName.split(" ", 3);
+ switch (strings.length) {
+ case 1:
+ family = strings[0];
+ break;
+ case 2:
+ family = strings[0];
+ given = strings[1];
+ break;
+ case 3:
+ family = strings[0];
+ middle = strings[1];
+ given = strings[2];
+ break;
+ }
+ }
+
+ if (item == null) {
+ item = new StructuredNameDataItem();
+ }
+ item.setPhoneticFamilyName(family);
+ item.setPhoneticMiddleName(middle);
+ item.setPhoneticGivenName(given);
+ return item;
+ }
+
+ /**
+ * Constructs and returns a phonetic full name from given parts.
+ */
+ public static String buildPhoneticName(String family, String middle, String given) {
+ if (!TextUtils.isEmpty(family) || !TextUtils.isEmpty(middle)
+ || !TextUtils.isEmpty(given)) {
+ StringBuilder sb = new StringBuilder();
+ if (!TextUtils.isEmpty(family)) {
+ sb.append(family.trim()).append(' ');
+ }
+ if (!TextUtils.isEmpty(middle)) {
+ sb.append(middle.trim()).append(' ');
+ }
+ if (!TextUtils.isEmpty(given)) {
+ sb.append(given.trim()).append(' ');
+ }
+ sb.setLength(sb.length() - 1); // Yank the last space
+ return sb.toString();
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/util/PermissionsUtil.java b/src/com/android/contacts/common/util/PermissionsUtil.java
new file mode 100644
index 0000000..37c1762
--- /dev/null
+++ b/src/com/android/contacts/common/util/PermissionsUtil.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.Manifest.permission;
+import android.app.AppOpsManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.os.Process;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.content.LocalBroadcastManager;
+
+/**
+ * Utility class to help with runtime permissions.
+ */
+public class PermissionsUtil {
+ // Each permission in this list is a cherry-picked permission from a particular permission
+ // group. Granting a permission group enables access to all permissions in that group so we
+ // only need to check a single permission in each group.
+ // Note: This assumes that the app has correctly requested for all the relevant permissions
+ // in its Manifest file.
+ public static final String PHONE = permission.CALL_PHONE;
+ public static final String CONTACTS = permission.READ_CONTACTS;
+ public static final String LOCATION = permission.ACCESS_FINE_LOCATION;
+
+ public static boolean hasPhonePermissions(Context context) {
+ return hasPermission(context, PHONE);
+ }
+
+ public static boolean hasContactsPermissions(Context context) {
+ return hasPermission(context, CONTACTS);
+ }
+
+ public static boolean hasLocationPermissions(Context context) {
+ return hasPermission(context, LOCATION);
+ }
+
+ public static boolean hasPermission(Context context, String permission) {
+ return ContextCompat.checkSelfPermission(context, permission)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+
+ public static boolean hasAppOp(Context context, String appOp) {
+ final AppOpsManager appOpsManager =
+ (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
+ final int mode = appOpsManager.checkOpNoThrow(appOp, Process.myUid(),
+ context.getPackageName());
+ return mode == AppOpsManager.MODE_ALLOWED;
+ }
+
+ /**
+ * Rudimentary methods wrapping the use of a LocalBroadcastManager to simplify the process
+ * of notifying other classes when a particular fragment is notified that a permission is
+ * granted.
+ *
+ * To be notified when a permission has been granted, create a new broadcast receiver
+ * and register it using {@link #registerPermissionReceiver(Context, BroadcastReceiver, String)}
+ *
+ * E.g.
+ *
+ * final BroadcastReceiver receiver = new BroadcastReceiver() {
+ * @Override
+ * public void onReceive(Context context, Intent intent) {
+ * refreshContactsView();
+ * }
+ * }
+ *
+ * PermissionsUtil.registerPermissionReceiver(getActivity(), receiver, READ_CONTACTS);
+ *
+ * If you register to listen for multiple permissions, you can identify which permission was
+ * granted by inspecting {@link Intent#getAction()}.
+ *
+ * In the fragment that requests for the permission, be sure to call
+ * {@link #notifyPermissionGranted(Context, String)} when the permission is granted so that
+ * any interested listeners are notified of the change.
+ */
+ public static void registerPermissionReceiver(Context context, BroadcastReceiver receiver,
+ String permission) {
+ final IntentFilter filter = new IntentFilter(permission);
+ LocalBroadcastManager.getInstance(context).registerReceiver(receiver, filter);
+ }
+
+ public static void unregisterPermissionReceiver(Context context, BroadcastReceiver receiver) {
+ LocalBroadcastManager.getInstance(context).unregisterReceiver(receiver);
+ }
+
+ public static void notifyPermissionGranted(Context context, String permission) {
+ final Intent intent = new Intent(permission);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
+ }
+}
diff --git a/src/com/android/contacts/common/util/PhoneNumberFormatter.java b/src/com/android/contacts/common/util/PhoneNumberFormatter.java
new file mode 100644
index 0000000..bbf9785
--- /dev/null
+++ b/src/com/android/contacts/common/util/PhoneNumberFormatter.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.telephony.PhoneNumberFormattingTextWatcher;
+import android.widget.TextView;
+
+import com.android.contacts.common.GeoUtil;
+import com.android.contacts.common.compat.PhoneNumberFormattingTextWatcherCompat;
+
+public final class PhoneNumberFormatter {
+ private PhoneNumberFormatter() {}
+
+ /**
+ * Load {@link TextWatcherLoadAsyncTask} in a worker thread and set it to a {@link TextView}.
+ */
+ private static class TextWatcherLoadAsyncTask extends
+ AsyncTask<Void, Void, PhoneNumberFormattingTextWatcher> {
+ private final String mCountryCode;
+ private final TextView mTextView;
+ private final boolean mFormatAfterWatcherSet;
+
+ public TextWatcherLoadAsyncTask(
+ String countryCode, TextView textView, boolean formatAfterWatcherSet) {
+ mCountryCode = countryCode;
+ mTextView = textView;
+ mFormatAfterWatcherSet = formatAfterWatcherSet;
+ }
+
+ @Override
+ protected PhoneNumberFormattingTextWatcher doInBackground(Void... params) {
+ return PhoneNumberFormattingTextWatcherCompat.newInstance(mCountryCode);
+ }
+
+ @Override
+ protected void onPostExecute(PhoneNumberFormattingTextWatcher watcher) {
+ if (watcher == null || isCancelled()) {
+ return; // May happen if we cancel the task.
+ }
+
+ // Setting a text changed listener is safe even after the view is detached.
+ mTextView.addTextChangedListener(watcher);
+
+ // Forcing formatting the existing phone number
+ if (mFormatAfterWatcherSet) {
+ watcher.afterTextChanged(mTextView.getEditableText());
+ }
+ }
+ }
+
+ /**
+ * Delay-set {@link PhoneNumberFormattingTextWatcher} to a {@link TextView}.
+ */
+ public static final void setPhoneNumberFormattingTextWatcher(Context context,
+ TextView textView) {
+ setPhoneNumberFormattingTextWatcher(context, textView,
+ /* formatAfterWatcherSet =*/ false);
+ }
+
+ /**
+ * Delay-sets {@link PhoneNumberFormattingTextWatcher} to a {@link TextView}
+ * and formats the number immediately if formatAfterWaterSet is true.
+ * In some cases, formatting before user editing might cause unwanted results
+ * (e.g. the editor thinks the user changed the content, and would save
+ * when closed even when the user didn't make other changes.)
+ */
+ public static final void setPhoneNumberFormattingTextWatcher(
+ Context context, TextView textView, boolean formatAfterWatcherSet) {
+ new TextWatcherLoadAsyncTask(GeoUtil.getCurrentCountryIso(context),
+ textView, formatAfterWatcherSet)
+ .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null);
+ }
+}
diff --git a/src/com/android/contacts/common/util/PhoneNumberHelper.java b/src/com/android/contacts/common/util/PhoneNumberHelper.java
new file mode 100644
index 0000000..794b6dd
--- /dev/null
+++ b/src/com/android/contacts/common/util/PhoneNumberHelper.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.util;
+
+import android.content.Context;
+import android.telephony.PhoneNumberUtils;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.google.i18n.phonenumbers.NumberParseException;
+import com.google.i18n.phonenumbers.PhoneNumberUtil;
+import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;
+import com.google.i18n.phonenumbers.ShortNumberInfo;
+
+import java.util.Locale;
+
+/**
+ * This class wraps several PhoneNumberUtil calls and TelephonyManager calls. Some of them are
+ * the same as the ones in the framework's code base. We can remove those once they are part of
+ * the public API.
+ */
+public class PhoneNumberHelper {
+
+ private static final String LOG_TAG = PhoneNumberHelper.class.getSimpleName();
+
+ private static final String KOREA_ISO_COUNTRY_CODE = "KR";
+ /**
+ * Determines if the specified number is actually a URI (i.e. a SIP address) rather than a
+ * regular PSTN phone number, based on whether or not the number contains an "@" character.
+ *
+ * @param number Phone number
+ * @return true if number contains @
+ *
+ * TODO: Remove if PhoneNumberUtils.isUriNumber(String number) is made public.
+ */
+ public static boolean isUriNumber(String number) {
+ // Note we allow either "@" or "%40" to indicate a URI, in case
+ // the passed-in string is URI-escaped. (Neither "@" nor "%40"
+ // will ever be found in a legal PSTN number.)
+ return number != null && (number.contains("@") || number.contains("%40"));
+ }
+
+ /**
+ * Normalize a phone number by removing the characters other than digits. If
+ * the given number has keypad letters, the letters will be converted to
+ * digits first.
+ *
+ * @param phoneNumber The number to be normalized.
+ * @return The normalized number.
+ *
+ * TODO: Remove if PhoneNumberUtils.normalizeNumber(String phoneNumber) is made public.
+ */
+ public static String normalizeNumber(String phoneNumber) {
+ StringBuilder sb = new StringBuilder();
+ int len = phoneNumber.length();
+ for (int i = 0; i < len; i++) {
+ char c = phoneNumber.charAt(i);
+ // Character.digit() supports ASCII and Unicode digits (fullwidth, Arabic-Indic, etc.)
+ int digit = Character.digit(c, 10);
+ if (digit != -1) {
+ sb.append(digit);
+ } else if (i == 0 && c == '+') {
+ sb.append(c);
+ } else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
+ return normalizeNumber(PhoneNumberUtils.convertKeypadLettersToDigits(phoneNumber));
+ }
+ }
+ return sb.toString();
+ }
+
+ /**
+ * @return the "username" part of the specified SIP address, i.e. the part before the "@"
+ * character (or "%40").
+ *
+ * @param number SIP address of the form "username@domainname" (or the URI-escaped equivalent
+ * "username%40domainname")
+ *
+ * TODO: Remove if PhoneNumberUtils.getUsernameFromUriNumber(String number) is made public.
+ */
+ public static String getUsernameFromUriNumber(String number) {
+ // The delimiter between username and domain name can be
+ // either "@" or "%40" (the URI-escaped equivalent.)
+ int delimiterIndex = number.indexOf('@');
+ if (delimiterIndex < 0) {
+ delimiterIndex = number.indexOf("%40");
+ }
+ if (delimiterIndex < 0) {
+ Log.w(LOG_TAG,
+ "getUsernameFromUriNumber: no delimiter found in SIP addr '" + number + "'");
+ return number;
+ }
+ return number.substring(0, delimiterIndex);
+ }
+}
diff --git a/src/com/android/contacts/common/util/SchedulingUtils.java b/src/com/android/contacts/common/util/SchedulingUtils.java
new file mode 100644
index 0000000..1dfa153
--- /dev/null
+++ b/src/com/android/contacts/common/util/SchedulingUtils.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.view.View;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
+import android.view.ViewTreeObserver.OnPreDrawListener;
+
+/** Static methods that are useful for scheduling actions to occur at a later time. */
+public class SchedulingUtils {
+
+
+ /** Runs a piece of code after the next layout run */
+ public static void doAfterLayout(final View view, final Runnable runnable) {
+ final OnGlobalLayoutListener listener = new OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ // Layout pass done, unregister for further events
+ view.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+ runnable.run();
+ }
+ };
+ view.getViewTreeObserver().addOnGlobalLayoutListener(listener);
+ }
+
+ /** Runs a piece of code just before the next draw, after layout and measurement */
+ public static void doOnPreDraw(final View view, final boolean drawNextFrame,
+ final Runnable runnable) {
+ final OnPreDrawListener listener = new OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ view.getViewTreeObserver().removeOnPreDrawListener(this);
+ runnable.run();
+ return drawNextFrame;
+ }
+ };
+ view.getViewTreeObserver().addOnPreDrawListener(listener);
+ }
+}
diff --git a/src/com/android/contacts/common/util/SearchUtil.java b/src/com/android/contacts/common/util/SearchUtil.java
new file mode 100644
index 0000000..ed41d6c
--- /dev/null
+++ b/src/com/android/contacts/common/util/SearchUtil.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Methods related to search.
+ */
+public class SearchUtil {
+
+ public static class MatchedLine {
+
+ public int startIndex = -1;
+ public String line;
+
+ @Override
+ public String toString() {
+ return "MatchedLine{" +
+ "line='" + line + '\'' +
+ ", startIndex=" + startIndex +
+ '}';
+ }
+ }
+
+ /**
+ * Given a string with lines delimited with '\n', finds the matching line to the given
+ * substring.
+ *
+ * @param contents The string to search.
+ * @param substring The substring to search for.
+ * @return A MatchedLine object containing the matching line and the startIndex of the substring
+ * match within that line.
+ */
+ public static MatchedLine findMatchingLine(String contents, String substring) {
+ final MatchedLine matched = new MatchedLine();
+
+ // Snippet may contain multiple lines separated by "\n".
+ // Locate the lines of the content that contain the substring.
+ final int index = SearchUtil.contains(contents, substring);
+ if (index != -1) {
+ // Match found. Find the corresponding line.
+ int start = index - 1;
+ while (start > -1) {
+ if (contents.charAt(start) == '\n') {
+ break;
+ }
+ start--;
+ }
+ int end = index + 1;
+ while (end < contents.length()) {
+ if (contents.charAt(end) == '\n') {
+ break;
+ }
+ end++;
+ }
+ matched.line = contents.substring(start + 1, end);
+ matched.startIndex = index - (start + 1);
+ }
+ return matched;
+ }
+
+ /**
+ * Similar to String.contains() with two main differences:
+ * <p>
+ * 1) Only searches token prefixes. A token is defined as any combination of letters or
+ * numbers.
+ * <p>
+ * 2) Returns the starting index where the substring is found.
+ *
+ * @param value The string to search.
+ * @param substring The substring to look for.
+ * @return The starting index where the substring is found. {@literal -1} if substring is not
+ * found in value.
+ */
+ @VisibleForTesting
+ static int contains(String value, String substring) {
+ if (value.length() < substring.length()) {
+ return -1;
+ }
+
+ // i18n support
+ // Generate the code points for the substring once.
+ // There will be a maximum of substring.length code points. But may be fewer.
+ // Since the array length is not an accurate size, we need to keep a separate variable.
+ final int[] substringCodePoints = new int[substring.length()];
+ int substringLength = 0; // may not equal substring.length()!!
+ for (int i = 0; i < substring.length(); ) {
+ final int codePoint = Character.codePointAt(substring, i);
+ substringCodePoints[substringLength] = codePoint;
+ substringLength++;
+ i += Character.charCount(codePoint);
+ }
+
+ for (int i = 0; i < value.length(); i = findNextTokenStart(value, i)) {
+ int numMatch = 0;
+ for (int j = i; j < value.length() && numMatch < substringLength; ++numMatch) {
+ int valueCp = Character.toLowerCase(value.codePointAt(j));
+ int substringCp = substringCodePoints[numMatch];
+ if (valueCp != substringCp) {
+ break;
+ }
+ j += Character.charCount(valueCp);
+ }
+ if (numMatch == substringLength) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Find the start of the next token. A token is composed of letters and numbers. Any other
+ * character are considered delimiters.
+ *
+ * @param line The string to search for the next token.
+ * @param startIndex The index to start searching. 0 based indexing.
+ * @return The index for the start of the next token. line.length() if next token not found.
+ */
+ @VisibleForTesting
+ static int findNextTokenStart(String line, int startIndex) {
+ int index = startIndex;
+
+ // If already in token, eat remainder of token.
+ while (index <= line.length()) {
+ if (index == line.length()) {
+ // No more tokens.
+ return index;
+ }
+ final int codePoint = line.codePointAt(index);
+ if (!Character.isLetterOrDigit(codePoint)) {
+ break;
+ }
+ index += Character.charCount(codePoint);
+ }
+
+ // Out of token, eat all consecutive delimiters.
+ while (index <= line.length()) {
+ if (index == line.length()) {
+ return index;
+ }
+ final int codePoint = line.codePointAt(index);
+ if (Character.isLetterOrDigit(codePoint)) {
+ break;
+ }
+ index += Character.charCount(codePoint);
+ }
+
+ return index;
+ }
+
+ /**
+ * Anything other than letter and numbers are considered delimiters. Remove start and end
+ * delimiters since they are not relevant to search.
+ *
+ * @param query The query string to clean.
+ * @return The cleaned query. Empty string if all characters are cleaned out.
+ */
+ public static String cleanStartAndEndOfSearchQuery(String query) {
+ int start = 0;
+ while (start < query.length()) {
+ int codePoint = query.codePointAt(start);
+ if (Character.isLetterOrDigit(codePoint)) {
+ break;
+ }
+ start += Character.charCount(codePoint);
+ }
+
+ if (start == query.length()) {
+ // All characters are delimiters.
+ return "";
+ }
+
+ int end = query.length() - 1;
+ while (end > -1) {
+ if (Character.isLowSurrogate(query.charAt(end))) {
+ // Assume valid i18n string. There should be a matching high surrogate before it.
+ end--;
+ }
+ int codePoint = query.codePointAt(end);
+ if (Character.isLetterOrDigit(codePoint)) {
+ break;
+ }
+ end--;
+ }
+
+ // end is a letter or digit.
+ return query.substring(start, end + 1);
+ }
+}
diff --git a/src/com/android/contacts/common/util/StopWatch.java b/src/com/android/contacts/common/util/StopWatch.java
new file mode 100644
index 0000000..581d6ee
--- /dev/null
+++ b/src/com/android/contacts/common/util/StopWatch.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.util.Log;
+
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+
+/**
+ * A {@link StopWatch} records start, laps and stop, and print them to logcat.
+ */
+public class StopWatch {
+
+ private final String mLabel;
+
+ private final ArrayList<Long> mTimes = Lists.newArrayList();
+ private final ArrayList<String> mLapLabels = Lists.newArrayList();
+
+ private StopWatch(String label) {
+ mLabel = label;
+ lap("");
+ }
+
+ /**
+ * Create a new instance and start it.
+ */
+ public static StopWatch start(String label) {
+ return new StopWatch(label);
+ }
+
+ /**
+ * Record a lap.
+ */
+ public void lap(String lapLabel) {
+ mTimes.add(System.currentTimeMillis());
+ mLapLabels.add(lapLabel);
+ }
+
+ /**
+ * Stop it and log the result, if the total time >= {@code timeThresholdToLog}.
+ */
+ public void stopAndLog(String TAG, int timeThresholdToLog) {
+
+ lap("");
+
+ final long start = mTimes.get(0);
+ final long stop = mTimes.get(mTimes.size() - 1);
+
+ final long total = stop - start;
+ if (total < timeThresholdToLog) return;
+
+ final StringBuilder sb = new StringBuilder();
+ sb.append(mLabel);
+ sb.append(",");
+ sb.append(total);
+ sb.append(": ");
+
+ long last = start;
+ for (int i = 1; i < mTimes.size(); i++) {
+ final long current = mTimes.get(i);
+ sb.append(mLapLabels.get(i));
+ sb.append(",");
+ sb.append((current - last));
+ sb.append(" ");
+ last = current;
+ }
+ Log.v(TAG, sb.toString());
+ }
+
+ /**
+ * Return a dummy instance that does no operations.
+ */
+ public static StopWatch getNullStopWatch() {
+ return NullStopWatch.INSTANCE;
+ }
+
+ private static class NullStopWatch extends StopWatch {
+ public static final NullStopWatch INSTANCE = new NullStopWatch();
+
+ public NullStopWatch() {
+ super(null);
+ }
+
+ @Override
+ public void lap(String lapLabel) {
+ // noop
+ }
+
+ @Override
+ public void stopAndLog(String TAG, int timeThresholdToLog) {
+ // noop
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/util/TelephonyManagerUtils.java b/src/com/android/contacts/common/util/TelephonyManagerUtils.java
new file mode 100644
index 0000000..7c322ca
--- /dev/null
+++ b/src/com/android/contacts/common/util/TelephonyManagerUtils.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.util;
+
+import android.content.Context;
+import android.telephony.PhoneNumberUtils;
+import android.telephony.TelephonyManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.util.Locale;
+
+/**
+ * This class provides several TelephonyManager util functions.
+ */
+public class TelephonyManagerUtils {
+
+ private static final String LOG_TAG = TelephonyManagerUtils.class.getSimpleName();
+
+ /**
+ * Gets the voicemail tag from Telephony Manager.
+ * @param context Current application context
+ * @return Voicemail tag, the alphabetic identifier associated with the voice mail number.
+ */
+ public static String getVoiceMailAlphaTag(Context context) {
+ final TelephonyManager telephonyManager =
+ (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
+ final String voiceMailLabel = telephonyManager.getVoiceMailAlphaTag();
+ return voiceMailLabel;
+ }
+
+ /**
+ * @return The ISO 3166-1 two letters country code of the country the user
+ * is in based on the network location. If the network location does not exist, fall
+ * back to the locale setting.
+ */
+ public static String getCurrentCountryIso(Context context, Locale locale) {
+ // Without framework function calls, this seems to be the most accurate location service
+ // we can rely on.
+ final TelephonyManager telephonyManager =
+ (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
+ String countryIso = telephonyManager.getNetworkCountryIso().toUpperCase();
+
+ if (countryIso == null) {
+ countryIso = locale.getCountry();
+ Log.w(LOG_TAG, "No CountryDetector; falling back to countryIso based on locale: "
+ + countryIso);
+ }
+ return countryIso;
+ }
+
+ /**
+ * @param context Current application context.
+ * @return True if there is a subscription which supports video calls. False otherwise.
+ */
+ public static boolean hasVideoCallSubscription(Context context) {
+ // TODO: Check the telephony manager's subscriptions to see if any support video calls.
+ return true;
+ }
+}
diff --git a/src/com/android/contacts/common/util/TrafficStatsTags.java b/src/com/android/contacts/common/util/TrafficStatsTags.java
new file mode 100644
index 0000000..78faa94
--- /dev/null
+++ b/src/com/android/contacts/common/util/TrafficStatsTags.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.util;
+
+public class TrafficStatsTags {
+ public static final int CONTACT_PHOTO_DOWNLOAD_TAG = 0x0001;
+ public static final int TAG_MAX = 0x9999;
+}
diff --git a/src/com/android/contacts/common/util/UriUtils.java b/src/com/android/contacts/common/util/UriUtils.java
new file mode 100644
index 0000000..41ef62f
--- /dev/null
+++ b/src/com/android/contacts/common/util/UriUtils.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.net.Uri;
+import android.provider.ContactsContract;
+
+import java.util.List;
+
+/**
+ * Utility methods for dealing with URIs.
+ */
+public class UriUtils {
+ /** Static helper, not instantiable. */
+ private UriUtils() {}
+
+ /** Checks whether two URI are equal, taking care of the case where either is null. */
+ public static boolean areEqual(Uri uri1, Uri uri2) {
+ if (uri1 == null && uri2 == null) {
+ return true;
+ }
+ if (uri1 == null || uri2 == null) {
+ return false;
+ }
+ return uri1.equals(uri2);
+ }
+
+ /** Parses a string into a URI and returns null if the given string is null. */
+ public static Uri parseUriOrNull(String uriString) {
+ if (uriString == null) {
+ return null;
+ }
+ return Uri.parse(uriString);
+ }
+
+ /** Converts a URI into a string, returns null if the given URI is null. */
+ public static String uriToString(Uri uri) {
+ return uri == null ? null : uri.toString();
+ }
+
+ public static boolean isEncodedContactUri(Uri uri) {
+ if (uri == null) {
+ return false;
+ }
+ final String lastPathSegment = uri.getLastPathSegment();
+ if (lastPathSegment == null) {
+ return false;
+ }
+ return lastPathSegment.equals(Constants.LOOKUP_URI_ENCODED);
+ }
+
+ /**
+ * @return {@code uri} as-is if the authority is of contacts provider. Otherwise
+ * or {@code uri} is null, return null otherwise
+ */
+ public static Uri nullForNonContactsUri(Uri uri) {
+ if (uri == null) {
+ return null;
+ }
+ return ContactsContract.AUTHORITY.equals(uri.getAuthority()) ? uri : null;
+ }
+
+ /**
+ * Parses the given URI to determine the original lookup key of the contact.
+ */
+ public static String getLookupKeyFromUri(Uri lookupUri) {
+ // Would be nice to be able to persist the lookup key somehow to avoid having to parse
+ // the uri entirely just to retrieve the lookup key, but every uri is already parsed
+ // once anyway to check if it is an encoded JSON uri, so this has negligible effect
+ // on performance.
+ if (lookupUri != null && !UriUtils.isEncodedContactUri(lookupUri)) {
+ final List<String> segments = lookupUri.getPathSegments();
+ // This returns the third path segment of the uri, where the lookup key is located.
+ // See {@link android.provider.ContactsContract.Contacts#CONTENT_LOOKUP_URI}.
+ return (segments.size() < 3) ? null : Uri.encode(segments.get(2));
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/util/ViewUtil.java b/src/com/android/contacts/common/util/ViewUtil.java
new file mode 100644
index 0000000..895b757
--- /dev/null
+++ b/src/com/android/contacts/common/util/ViewUtil.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.content.res.Resources;
+import android.graphics.Outline;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewOutlineProvider;
+import android.widget.ListView;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.CompatUtils;
+
+/**
+ * Provides static functions to work with views
+ */
+public class ViewUtil {
+ private ViewUtil() {}
+
+ /**
+ * Returns the width as specified in the LayoutParams
+ * @throws IllegalStateException Thrown if the view's width is unknown before a layout pass
+ * s
+ */
+ public static int getConstantPreLayoutWidth(View view) {
+ // We haven't been layed out yet, so get the size from the LayoutParams
+ final ViewGroup.LayoutParams p = view.getLayoutParams();
+ if (p.width < 0) {
+ throw new IllegalStateException("Expecting view's width to be a constant rather " +
+ "than a result of the layout pass");
+ }
+ return p.width;
+ }
+
+ /**
+ * Returns a boolean indicating whether or not the view's layout direction is RTL
+ *
+ * @param view - A valid view
+ * @return True if the view's layout direction is RTL
+ */
+ public static boolean isViewLayoutRtl(View view) {
+ return view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
+ }
+
+ private static final ViewOutlineProvider OVAL_OUTLINE_PROVIDER;
+ static {
+ if (CompatUtils.isLollipopCompatible()) {
+ OVAL_OUTLINE_PROVIDER = new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View view, Outline outline) {
+ outline.setOval(0, 0, view.getWidth(), view.getHeight());
+ }
+ };
+ } else {
+ OVAL_OUTLINE_PROVIDER = null;
+ }
+ }
+
+ private static final ViewOutlineProvider RECT_OUTLINE_PROVIDER;
+ static {
+ if (CompatUtils.isLollipopCompatible()) {
+ RECT_OUTLINE_PROVIDER = new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View view, Outline outline) {
+ outline.setRect(0, 0, view.getWidth(), view.getHeight());
+ }
+ };
+ } else {
+ RECT_OUTLINE_PROVIDER = null;
+ }
+ }
+
+ /**
+ * Adds a rectangular outline to a view. This can be useful when you want to add a shadow
+ * to a transparent view. See b/16856049.
+ * @param view view that the outline is added to
+ * @param res The resources file.
+ */
+ public static void addRectangularOutlineProvider(View view, Resources res) {
+ if (CompatUtils.isLollipopCompatible()) {
+ view.setOutlineProvider(RECT_OUTLINE_PROVIDER);
+ }
+ }
+
+ /**
+ * Configures the floating action button, clipping it to a circle and setting its translation z.
+ * @param view The float action button's view.
+ * @param res The resources file.
+ */
+ public static void setupFloatingActionButton(View view, Resources res) {
+ if (CompatUtils.isLollipopCompatible()) {
+ view.setOutlineProvider(OVAL_OUTLINE_PROVIDER);
+ view.setTranslationZ(
+ res.getDimensionPixelSize(R.dimen.floating_action_button_translation_z));
+ }
+ }
+
+ /**
+ * Adds padding to the bottom of the given {@link ListView} so that the floating action button
+ * does not obscure any content.
+ *
+ * @param listView to add the padding to
+ * @param res valid resources object
+ */
+ public static void addBottomPaddingToListViewForFab(ListView listView, Resources res) {
+ final int fabPadding = res.getDimensionPixelSize(
+ R.dimen.floating_action_button_list_bottom_padding);
+ listView.setPaddingRelative(listView.getPaddingStart(), listView.getPaddingTop(),
+ listView.getPaddingEnd(), listView.getPaddingBottom() + fabPadding);
+ listView.setClipToPadding(false);
+ }
+}
diff --git a/src/com/android/contacts/common/util/WeakAsyncTask.java b/src/com/android/contacts/common/util/WeakAsyncTask.java
new file mode 100644
index 0000000..f46e514
--- /dev/null
+++ b/src/com/android/contacts/common/util/WeakAsyncTask.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.os.AsyncTask;
+
+import java.lang.ref.WeakReference;
+
+public abstract class WeakAsyncTask<Params, Progress, Result, WeakTarget> extends
+ AsyncTask<Params, Progress, Result> {
+ protected WeakReference<WeakTarget> mTarget;
+
+ public WeakAsyncTask(WeakTarget target) {
+ mTarget = new WeakReference<WeakTarget>(target);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected final void onPreExecute() {
+ final WeakTarget target = mTarget.get();
+ if (target != null) {
+ this.onPreExecute(target);
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected final Result doInBackground(Params... params) {
+ final WeakTarget target = mTarget.get();
+ if (target != null) {
+ return this.doInBackground(target, params);
+ } else {
+ return null;
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected final void onPostExecute(Result result) {
+ final WeakTarget target = mTarget.get();
+ if (target != null) {
+ this.onPostExecute(target, result);
+ }
+ }
+
+ protected void onPreExecute(WeakTarget target) {
+ // No default action
+ }
+
+ protected abstract Result doInBackground(WeakTarget target, Params... params);
+
+ protected void onPostExecute(WeakTarget target, Result result) {
+ // No default action
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/CancelActivity.java b/src/com/android/contacts/common/vcard/CancelActivity.java
new file mode 100644
index 0000000..8e393e1
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/CancelActivity.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.vcard;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.util.Log;
+
+import com.android.contacts.common.R;
+
+/**
+ * The Activity for canceling vCard import/export.
+ */
+public class CancelActivity extends Activity implements ServiceConnection {
+ private final String LOG_TAG = "VCardCancel";
+
+ /* package */ final static String JOB_ID = "job_id";
+ /* package */ final static String DISPLAY_NAME = "display_name";
+
+ /**
+ * Type of the process to be canceled. Only used for choosing appropriate title/message.
+ * Must be {@link VCardService#TYPE_IMPORT} or {@link VCardService#TYPE_EXPORT}.
+ */
+ /* package */ final static String TYPE = "type";
+
+ private class RequestCancelListener implements DialogInterface.OnClickListener {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ bindService(new Intent(CancelActivity.this,
+ VCardService.class), CancelActivity.this, Context.BIND_AUTO_CREATE);
+ }
+ }
+
+ private class CancelListener
+ implements DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ finish();
+ }
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ finish();
+ }
+ }
+
+ private final CancelListener mCancelListener = new CancelListener();
+ private int mJobId;
+ private String mDisplayName;
+ private int mType;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ final Uri uri = getIntent().getData();
+ mJobId = Integer.parseInt(uri.getQueryParameter(JOB_ID));
+ mDisplayName = uri.getQueryParameter(DISPLAY_NAME);
+ mType = Integer.parseInt(uri.getQueryParameter(TYPE));
+ showDialog(R.id.dialog_cancel_confirmation);
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int id, Bundle bundle) {
+ if (id == R.id.dialog_cancel_confirmation) {
+ final String message;
+ if (mType == VCardService.TYPE_IMPORT) {
+ message = getString(R.string.cancel_import_confirmation_message, mDisplayName);
+ } else {
+ message = getString(R.string.cancel_export_confirmation_message, mDisplayName);
+ }
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this)
+ .setMessage(message)
+ .setPositiveButton(android.R.string.ok, new RequestCancelListener())
+ .setOnCancelListener(mCancelListener)
+ .setNegativeButton(android.R.string.cancel, mCancelListener);
+ return builder.create();
+ } else if (id == R.id.dialog_cancel_failed) {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this)
+ .setTitle(R.string.cancel_vcard_import_or_export_failed)
+ .setIconAttribute(android.R.attr.alertDialogIcon)
+ .setMessage(getString(R.string.fail_reason_unknown))
+ .setOnCancelListener(mCancelListener)
+ .setPositiveButton(android.R.string.ok, mCancelListener);
+ return builder.create();
+ } else {
+ Log.w(LOG_TAG, "Unknown dialog id: " + id);
+ return super.onCreateDialog(id, bundle);
+ }
+ }
+
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder binder) {
+ VCardService service = ((VCardService.MyBinder) binder).getService();
+
+ try {
+ final CancelRequest request = new CancelRequest(mJobId, mDisplayName);
+ service.handleCancelRequest(request, null);
+ } finally {
+ unbindService(this);
+ }
+
+ finish();
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ // do nothing
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/CancelRequest.java b/src/com/android/contacts/common/vcard/CancelRequest.java
new file mode 100644
index 0000000..a5eb4aa
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/CancelRequest.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.vcard;
+
+/**
+ * Class representing one request for canceling vCard import/export.
+ */
+public class CancelRequest {
+ public final int jobId;
+ /**
+ * Name used for showing users some useful info. Typically a file name.
+ * Must not be used to do some actual operations.
+ */
+ public final String displayName;
+ public CancelRequest(int jobId, String displayName) {
+ this.jobId = jobId;
+ this.displayName = displayName;
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/ExportProcessor.java b/src/com/android/contacts/common/vcard/ExportProcessor.java
new file mode 100644
index 0000000..2aef379
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/ExportProcessor.java
@@ -0,0 +1,342 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.vcard;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Message;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.RawContactsEntity;
+import android.text.TextUtils;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.android.contacts.common.R;
+import com.android.vcard.VCardComposer;
+import com.android.vcard.VCardConfig;
+
+import java.io.BufferedWriter;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+
+/**
+ * Class for processing one export request from a user. Dropped after exporting requested Uri(s).
+ * {@link VCardService} will create another object when there is another export request.
+ */
+public class ExportProcessor extends ProcessorBase {
+ private static final String LOG_TAG = "VCardExport";
+ private static final boolean DEBUG = VCardService.DEBUG;
+
+ private final VCardService mService;
+ private final ContentResolver mResolver;
+ private final NotificationManager mNotificationManager;
+ private final ExportRequest mExportRequest;
+ private final int mJobId;
+ private final String mCallingActivity;
+
+ private volatile boolean mCanceled;
+ private volatile boolean mDone;
+
+ private final int SHOW_READY_TOAST = 1;
+ private final Handler handler = new Handler() {
+ public void handleMessage(Message msg) {
+ if (msg.arg1 == SHOW_READY_TOAST) {
+ // This message is long, so we set the duration to LENGTH_LONG.
+ Toast.makeText(mService,
+ R.string.exporting_vcard_finished_toast, Toast.LENGTH_LONG).show();
+ }
+
+ }
+ };
+
+ public ExportProcessor(VCardService service, ExportRequest exportRequest, int jobId,
+ String callingActivity) {
+ mService = service;
+ mResolver = service.getContentResolver();
+ mNotificationManager =
+ (NotificationManager)mService.getSystemService(Context.NOTIFICATION_SERVICE);
+ mExportRequest = exportRequest;
+ mJobId = jobId;
+ mCallingActivity = callingActivity;
+ }
+
+ @Override
+ public final int getType() {
+ return VCardService.TYPE_EXPORT;
+ }
+
+ @Override
+ public void run() {
+ // ExecutorService ignores RuntimeException, so we need to show it here.
+ try {
+ runInternal();
+
+ if (isCancelled()) {
+ doCancelNotification();
+ }
+ } catch (OutOfMemoryError e) {
+ Log.e(LOG_TAG, "OutOfMemoryError thrown during import", e);
+ throw e;
+ } catch (RuntimeException e) {
+ Log.e(LOG_TAG, "RuntimeException thrown during export", e);
+ throw e;
+ } finally {
+ synchronized (this) {
+ mDone = true;
+ }
+ }
+ }
+
+ private void runInternal() {
+ if (DEBUG) Log.d(LOG_TAG, String.format("vCard export (id: %d) has started.", mJobId));
+ final ExportRequest request = mExportRequest;
+ VCardComposer composer = null;
+ Writer writer = null;
+ boolean successful = false;
+ try {
+ if (isCancelled()) {
+ Log.i(LOG_TAG, "Export request is cancelled before handling the request");
+ return;
+ }
+ final Uri uri = request.destUri;
+ final OutputStream outputStream;
+ try {
+ outputStream = mResolver.openOutputStream(uri);
+ } catch (FileNotFoundException e) {
+ Log.w(LOG_TAG, "FileNotFoundException thrown", e);
+ // Need concise title.
+
+ final String errorReason =
+ mService.getString(R.string.fail_reason_could_not_open_file,
+ uri, e.getMessage());
+ doFinishNotification(errorReason, null);
+ return;
+ }
+
+ final String exportType = request.exportType;
+ final int vcardType;
+ if (TextUtils.isEmpty(exportType)) {
+ vcardType = VCardConfig.getVCardTypeFromString(
+ mService.getString(R.string.config_export_vcard_type));
+ } else {
+ vcardType = VCardConfig.getVCardTypeFromString(exportType);
+ }
+
+ composer = new VCardComposer(mService, vcardType, true);
+
+ // for test
+ // int vcardType = (VCardConfig.VCARD_TYPE_V21_GENERIC |
+ // VCardConfig.FLAG_USE_QP_TO_PRIMARY_PROPERTIES);
+ // composer = new VCardComposer(ExportVCardActivity.this, vcardType, true);
+
+ writer = new BufferedWriter(new OutputStreamWriter(outputStream));
+ final Uri contentUriForRawContactsEntity = RawContactsEntity.CONTENT_URI;
+ // TODO: should provide better selection.
+ if (!composer.init(Contacts.CONTENT_URI, new String[] {Contacts._ID},
+ null, null,
+ null, contentUriForRawContactsEntity)) {
+ final String errorReason = composer.getErrorReason();
+ Log.e(LOG_TAG, "initialization of vCard composer failed: " + errorReason);
+ final String translatedErrorReason =
+ translateComposerError(errorReason);
+ final String title =
+ mService.getString(R.string.fail_reason_could_not_initialize_exporter,
+ translatedErrorReason);
+ doFinishNotification(title, null);
+ return;
+ }
+
+ final int total = composer.getCount();
+ if (total == 0) {
+ final String title =
+ mService.getString(R.string.fail_reason_no_exportable_contact);
+ doFinishNotification(title, null);
+ return;
+ }
+
+ int current = 1; // 1-origin
+ while (!composer.isAfterLast()) {
+ if (isCancelled()) {
+ Log.i(LOG_TAG, "Export request is cancelled during composing vCard");
+ return;
+ }
+ try {
+ writer.write(composer.createOneEntry());
+ } catch (IOException e) {
+ final String errorReason = composer.getErrorReason();
+ Log.e(LOG_TAG, "Failed to read a contact: " + errorReason);
+ final String translatedErrorReason =
+ translateComposerError(errorReason);
+ final String title =
+ mService.getString(R.string.fail_reason_error_occurred_during_export,
+ translatedErrorReason);
+ doFinishNotification(title, null);
+ return;
+ }
+
+ // vCard export is quite fast (compared to import), and frequent notifications
+ // bother notification bar too much.
+ if (current % 100 == 1) {
+ doProgressNotification(uri, total, current);
+ }
+ current++;
+ }
+ Log.i(LOG_TAG, "Successfully finished exporting vCard " + request.destUri);
+
+ if (DEBUG) {
+ Log.d(LOG_TAG, "Ask MediaScanner to scan the file: " + request.destUri.getPath());
+ }
+ mService.updateMediaScanner(request.destUri.getPath());
+
+ successful = true;
+ final String filename = ExportVCardActivity.getOpenableUriDisplayName(mService, uri);
+ // If it is a local file (i.e. not a file from Drive), we need to allow user to share
+ // the file by pressing the notification; otherwise, it would be a file in Drive, we
+ // don't need to enable this action in notification since the file is already uploaded.
+ if (isLocalFile(uri)) {
+ final Message msg = handler.obtainMessage();
+ msg.arg1 = SHOW_READY_TOAST;
+ handler.sendMessage(msg);
+ doFinishNotificationWithShareAction(
+ mService.getString(R.string.exporting_vcard_finished_title_fallback),
+ mService.getString(R.string.touch_to_share_contacts), uri);
+ } else {
+ final String title = filename == null
+ ? mService.getString(R.string.exporting_vcard_finished_title_fallback)
+ : mService.getString(R.string.exporting_vcard_finished_title, filename);
+ doFinishNotification(title, null);
+ }
+ } finally {
+ if (composer != null) {
+ composer.terminate();
+ }
+ if (writer != null) {
+ try {
+ writer.close();
+ } catch (IOException e) {
+ Log.w(LOG_TAG, "IOException is thrown during close(). Ignored. " + e);
+ }
+ }
+ mService.handleFinishExportNotification(mJobId, successful);
+ }
+ }
+
+ private boolean isLocalFile(Uri uri) {
+ final String authority = uri.getAuthority();
+ return mService.getString(R.string.contacts_file_provider_authority).equals(authority);
+ }
+
+ private String translateComposerError(String errorMessage) {
+ final Resources resources = mService.getResources();
+ if (VCardComposer.FAILURE_REASON_FAILED_TO_GET_DATABASE_INFO.equals(errorMessage)) {
+ return resources.getString(R.string.composer_failed_to_get_database_infomation);
+ } else if (VCardComposer.FAILURE_REASON_NO_ENTRY.equals(errorMessage)) {
+ return resources.getString(R.string.composer_has_no_exportable_contact);
+ } else if (VCardComposer.FAILURE_REASON_NOT_INITIALIZED.equals(errorMessage)) {
+ return resources.getString(R.string.composer_not_initialized);
+ } else {
+ return errorMessage;
+ }
+ }
+
+ private void doProgressNotification(Uri uri, int totalCount, int currentCount) {
+ final String displayName = uri.getLastPathSegment();
+ final String description =
+ mService.getString(R.string.exporting_contact_list_message, displayName);
+ final String tickerText =
+ mService.getString(R.string.exporting_contact_list_title);
+ final Notification notification =
+ NotificationImportExportListener.constructProgressNotification(mService,
+ VCardService.TYPE_EXPORT, description, tickerText, mJobId, displayName,
+ totalCount, currentCount);
+ mNotificationManager.notify(NotificationImportExportListener.DEFAULT_NOTIFICATION_TAG,
+ mJobId, notification);
+ }
+
+ private void doCancelNotification() {
+ if (DEBUG) Log.d(LOG_TAG, "send cancel notification");
+ final String description = mService.getString(R.string.exporting_vcard_canceled_title,
+ mExportRequest.destUri.getLastPathSegment());
+ final Notification notification =
+ NotificationImportExportListener.constructCancelNotification(mService, description);
+ mNotificationManager.notify(NotificationImportExportListener.DEFAULT_NOTIFICATION_TAG,
+ mJobId, notification);
+ }
+
+ private void doFinishNotification(final String title, final String description) {
+ if (DEBUG) Log.d(LOG_TAG, "send finish notification: " + title + ", " + description);
+ final Intent intent = new Intent();
+ intent.setClassName(mService, mCallingActivity);
+ final Notification notification =
+ NotificationImportExportListener.constructFinishNotification(mService, title,
+ description, intent);
+ mNotificationManager.notify(NotificationImportExportListener.DEFAULT_NOTIFICATION_TAG,
+ mJobId, notification);
+ }
+
+ /**
+ * Pass intent with ACTION_SEND to notification so that user can press the notification to
+ * share contacts.
+ */
+ private void doFinishNotificationWithShareAction(final String title, final String
+ description, Uri uri) {
+ if (DEBUG) Log.d(LOG_TAG, "send finish notification: " + title + ", " + description);
+ final Intent intent = new Intent(Intent.ACTION_SEND);
+ intent.setType(Contacts.CONTENT_VCARD_TYPE);
+ intent.putExtra(Intent.EXTRA_STREAM, uri);
+ // Securely grant access using temporary access permissions
+ intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ // Build notification
+ final Notification notification =
+ NotificationImportExportListener.constructFinishNotificationWithFlags(
+ mService, title, description, intent, Intent.FLAG_ACTIVITY_NEW_TASK);
+ mNotificationManager.notify(NotificationImportExportListener.DEFAULT_NOTIFICATION_TAG,
+ mJobId, notification);
+ }
+
+ @Override
+ public synchronized boolean cancel(boolean mayInterruptIfRunning) {
+ if (DEBUG) Log.d(LOG_TAG, "received cancel request");
+ if (mDone || mCanceled) {
+ return false;
+ }
+ mCanceled = true;
+ return true;
+ }
+
+ @Override
+ public synchronized boolean isCancelled() {
+ return mCanceled;
+ }
+
+ @Override
+ public synchronized boolean isDone() {
+ return mDone;
+ }
+
+ public ExportRequest getRequest() {
+ return mExportRequest;
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/ExportRequest.java b/src/com/android/contacts/common/vcard/ExportRequest.java
new file mode 100644
index 0000000..e05a32c
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/ExportRequest.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.vcard;
+
+import android.net.Uri;
+
+public class ExportRequest {
+ public final Uri destUri;
+ /**
+ * Can be null.
+ */
+ public final String exportType;
+
+ public ExportRequest(Uri destUri) {
+ this(destUri, null);
+ }
+
+ public ExportRequest(Uri destUri, String exportType) {
+ this.destUri = destUri;
+ this.exportType = exportType;
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/ExportVCardActivity.java b/src/com/android/contacts/common/vcard/ExportVCardActivity.java
new file mode 100644
index 0000000..18de505
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/ExportVCardActivity.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.vcard;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.provider.OpenableColumns;
+import android.text.BidiFormatter;
+import android.text.TextDirectionHeuristics;
+import android.util.Log;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.activity.RequestImportVCardPermissionsActivity;
+
+import java.util.List;
+
+/**
+ * Shows a dialog confirming the export and asks actual vCard export to {@link VCardService}
+ *
+ * This Activity first connects to VCardService and ask an available file name and shows it to
+ * a user. After the user's confirmation, it send export request with the file name, assuming the
+ * file name is not reserved yet.
+ */
+public class ExportVCardActivity extends Activity implements ServiceConnection,
+ DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
+ private static final String LOG_TAG = "VCardExport";
+ protected static final boolean DEBUG = VCardService.DEBUG;
+ private static final int REQUEST_CREATE_DOCUMENT = 100;
+
+ /**
+ * True when this Activity is connected to {@link VCardService}.
+ *
+ * Should be touched inside synchronized block.
+ */
+ protected boolean mConnected;
+
+ /**
+ * True when users need to do something and this Activity should not disconnect from
+ * VCardService. False when all necessary procedures are done (including sending export request)
+ * or there's some error occured.
+ */
+ private volatile boolean mProcessOngoing = true;
+
+ protected VCardService mService;
+ private static final BidiFormatter mBidiFormatter = BidiFormatter.getInstance();
+
+ // String for storing error reason temporarily.
+ private String mErrorReason;
+
+ @Override
+ protected void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+
+ if (RequestImportVCardPermissionsActivity.startPermissionActivity(this)) {
+ return;
+ }
+
+ if (!hasExportIntentHandler()) {
+ Log.e(LOG_TAG, "Couldn't find export intent handler");
+ showErrorDialog();
+ return;
+ }
+
+ connectVCardService();
+ }
+
+ private void connectVCardService() {
+ final String callingActivity = getIntent().getExtras()
+ .getString(VCardCommonArguments.ARG_CALLING_ACTIVITY);
+ Intent intent = new Intent(this, VCardService.class);
+ intent.putExtra(VCardCommonArguments.ARG_CALLING_ACTIVITY, callingActivity);
+
+ if (startService(intent) == null) {
+ Log.e(LOG_TAG, "Failed to start vCard service");
+ showErrorDialog();
+ return;
+ }
+
+ if (!bindService(intent, this, Context.BIND_AUTO_CREATE)) {
+ Log.e(LOG_TAG, "Failed to connect to vCard service.");
+ showErrorDialog();
+ }
+ // Continued to onServiceConnected()
+ }
+
+ private boolean hasExportIntentHandler() {
+ final Intent intent = getCreateDocIntent();
+ final List<ResolveInfo> receivers = getPackageManager().queryIntentActivities(intent,
+ PackageManager.MATCH_DEFAULT_ONLY);
+ return receivers != null && receivers.size() > 0;
+ }
+
+ private Intent getCreateDocIntent() {
+ final Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType(VCardService.X_VCARD_MIME_TYPE);
+ intent.putExtra(Intent.EXTRA_TITLE, mBidiFormatter.unicodeWrap(
+ getString(R.string.exporting_vcard_filename), TextDirectionHeuristics.LTR));
+ return intent;
+ }
+
+ private void showErrorDialog() {
+ mErrorReason = getString(R.string.fail_reason_unknown);
+ showDialog(R.id.dialog_fail_to_export_with_reason);
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (requestCode == REQUEST_CREATE_DOCUMENT) {
+ if (resultCode == Activity.RESULT_OK && mService != null &&
+ data != null && data.getData() != null) {
+ final Uri targetFileName = data.getData();
+ if (DEBUG) Log.d(LOG_TAG, "exporting to " + targetFileName);
+ final ExportRequest request = new ExportRequest(targetFileName);
+ // The connection object will call finish().
+ mService.handleExportRequest(request, new NotificationImportExportListener(
+ ExportVCardActivity.this));
+ } else if (DEBUG) {
+ if (mService == null) {
+ Log.d(LOG_TAG, "No vCard service.");
+ } else {
+ Log.d(LOG_TAG, "create document cancelled or no data returned");
+ }
+ }
+ finish();
+ }
+ }
+
+ @Override
+ public synchronized void onServiceConnected(ComponentName name, IBinder binder) {
+ if (DEBUG) Log.d(LOG_TAG, "connected to service, requesting a destination file name");
+ mConnected = true;
+ mService = ((VCardService.MyBinder) binder).getService();
+
+ // Have the user choose where vcards will be exported to
+ startActivityForResult(getCreateDocIntent(), REQUEST_CREATE_DOCUMENT);
+ }
+
+ // Use synchronized since we don't want to call finish() just after this call.
+ @Override
+ public synchronized void onServiceDisconnected(ComponentName name) {
+ if (DEBUG) Log.d(LOG_TAG, "onServiceDisconnected()");
+ mService = null;
+ mConnected = false;
+ if (mProcessOngoing) {
+ // Unexpected disconnect event.
+ Log.w(LOG_TAG, "Disconnected from service during the process ongoing.");
+ showErrorDialog();
+ }
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int id, Bundle bundle) {
+ if (id == R.id.dialog_fail_to_export_with_reason) {
+ mProcessOngoing = false;
+ return new AlertDialog.Builder(this)
+ .setTitle(R.string.exporting_contact_failed_title)
+ .setMessage(getString(R.string.exporting_contact_failed_message,
+ mErrorReason != null ? mErrorReason :
+ getString(R.string.fail_reason_unknown)))
+ .setPositiveButton(android.R.string.ok, this)
+ .setOnCancelListener(this)
+ .create();
+ }
+ return super.onCreateDialog(id, bundle);
+ }
+
+ @Override
+ protected void onPrepareDialog(int id, Dialog dialog, Bundle args) {
+ if (id == R.id.dialog_fail_to_export_with_reason) {
+ ((AlertDialog)dialog).setMessage(mErrorReason);
+ } else {
+ super.onPrepareDialog(id, dialog, args);
+ }
+ }
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (DEBUG) Log.d(LOG_TAG, "ExportVCardActivity#onClick() is called");
+ finish();
+ }
+
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ if (DEBUG) Log.d(LOG_TAG, "ExportVCardActivity#onCancel() is called");
+ mProcessOngoing = false;
+ finish();
+ }
+
+ @Override
+ public void unbindService(ServiceConnection conn) {
+ mProcessOngoing = false;
+ super.unbindService(conn);
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (mConnected) {
+ unbindService(this);
+ mConnected = false;
+ }
+ super.onDestroy();
+ }
+
+ /**
+ * Returns the display name for the given openable Uri or null if it could not be resolved. */
+ static String getOpenableUriDisplayName(Context context, Uri uri) {
+ if (uri == null) return null;
+ final Cursor cursor = context.getContentResolver().query(uri, null, null, null, null);
+ try {
+ if (cursor != null && cursor.moveToFirst()) {
+ return cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/ImportProcessor.java b/src/com/android/contacts/common/vcard/ImportProcessor.java
new file mode 100644
index 0000000..219ec14
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/ImportProcessor.java
@@ -0,0 +1,305 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.vcard;
+
+import android.accounts.Account;
+import android.content.ContentResolver;
+import android.net.Uri;
+import android.util.Log;
+
+import com.android.vcard.VCardEntry;
+import com.android.vcard.VCardEntryCommitter;
+import com.android.vcard.VCardEntryConstructor;
+import com.android.vcard.VCardEntryHandler;
+import com.android.vcard.VCardInterpreter;
+import com.android.vcard.VCardParser;
+import com.android.vcard.VCardParser_V21;
+import com.android.vcard.VCardParser_V30;
+import com.android.vcard.exception.VCardException;
+import com.android.vcard.exception.VCardNestedException;
+import com.android.vcard.exception.VCardNotSupportedException;
+import com.android.vcard.exception.VCardVersionException;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Class for processing one import request from a user. Dropped after importing requested Uri(s).
+ * {@link VCardService} will create another object when there is another import request.
+ */
+public class ImportProcessor extends ProcessorBase implements VCardEntryHandler {
+ private static final String LOG_TAG = "VCardImport";
+ private static final boolean DEBUG = VCardService.DEBUG;
+
+ private final VCardService mService;
+ private final ContentResolver mResolver;
+ private final ImportRequest mImportRequest;
+ private final int mJobId;
+ private final VCardImportExportListener mListener;
+
+ // TODO: remove and show appropriate message instead.
+ private final List<Uri> mFailedUris = new ArrayList<Uri>();
+
+ private VCardParser mVCardParser;
+
+ private volatile boolean mCanceled;
+ private volatile boolean mDone;
+
+ private int mCurrentCount = 0;
+ private int mTotalCount = 0;
+
+ public ImportProcessor(final VCardService service, final VCardImportExportListener listener,
+ final ImportRequest request, final int jobId) {
+ mService = service;
+ mResolver = mService.getContentResolver();
+ mListener = listener;
+
+ mImportRequest = request;
+ mJobId = jobId;
+ }
+
+ @Override
+ public void onStart() {
+ // do nothing
+ }
+
+ @Override
+ public void onEnd() {
+ // do nothing
+ }
+
+ @Override
+ public void onEntryCreated(VCardEntry entry) {
+ mCurrentCount++;
+ if (mListener != null) {
+ mListener.onImportParsed(mImportRequest, mJobId, entry, mCurrentCount, mTotalCount);
+ }
+ }
+
+ @Override
+ public final int getType() {
+ return VCardService.TYPE_IMPORT;
+ }
+
+ @Override
+ public void run() {
+ // ExecutorService ignores RuntimeException, so we need to show it here.
+ try {
+ runInternal();
+
+ if (isCancelled() && mListener != null) {
+ mListener.onImportCanceled(mImportRequest, mJobId);
+ }
+ } catch (OutOfMemoryError e) {
+ Log.e(LOG_TAG, "OutOfMemoryError thrown during import", e);
+ throw e;
+ } catch (RuntimeException e) {
+ Log.e(LOG_TAG, "RuntimeException thrown during import", e);
+ throw e;
+ } finally {
+ synchronized (this) {
+ mDone = true;
+ }
+ }
+ }
+
+ private void runInternal() {
+ Log.i(LOG_TAG, String.format("vCard import (id: %d) has started.", mJobId));
+ final ImportRequest request = mImportRequest;
+ if (isCancelled()) {
+ Log.i(LOG_TAG, "Canceled before actually handling parameter (" + request.uri + ")");
+ return;
+ }
+ final int[] possibleVCardVersions;
+ if (request.vcardVersion == ImportVCardActivity.VCARD_VERSION_AUTO_DETECT) {
+ /**
+ * Note: this code assumes that a given Uri is able to be opened more than once,
+ * which may not be true in certain conditions.
+ */
+ possibleVCardVersions = new int[] {
+ ImportVCardActivity.VCARD_VERSION_V21,
+ ImportVCardActivity.VCARD_VERSION_V30
+ };
+ } else {
+ possibleVCardVersions = new int[] {
+ request.vcardVersion
+ };
+ }
+
+ final Uri uri = request.uri;
+ final Account account = request.account;
+ final int estimatedVCardType = request.estimatedVCardType;
+ final String estimatedCharset = request.estimatedCharset;
+ final int entryCount = request.entryCount;
+ mTotalCount += entryCount;
+
+ final VCardEntryConstructor constructor =
+ new VCardEntryConstructor(estimatedVCardType, account, estimatedCharset);
+ final VCardEntryCommitter committer = new VCardEntryCommitter(mResolver);
+ constructor.addEntryHandler(committer);
+ constructor.addEntryHandler(this);
+
+ InputStream is = null;
+ boolean successful = false;
+ try {
+ if (uri != null) {
+ Log.i(LOG_TAG, "start importing one vCard (Uri: " + uri + ")");
+ is = mResolver.openInputStream(uri);
+ } else if (request.data != null){
+ Log.i(LOG_TAG, "start importing one vCard (byte[])");
+ is = new ByteArrayInputStream(request.data);
+ }
+
+ if (is != null) {
+ successful = readOneVCard(is, estimatedVCardType, estimatedCharset, constructor,
+ possibleVCardVersions);
+ }
+ } catch (IOException e) {
+ successful = false;
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+ }
+
+ mService.handleFinishImportNotification(mJobId, successful);
+
+ if (successful) {
+ // TODO: successful becomes true even when cancelled. Should return more appropriate
+ // value
+ if (isCancelled()) {
+ Log.i(LOG_TAG, "vCard import has been canceled (uri: " + uri + ")");
+ // Cancel notification will be done outside this method.
+ } else {
+ Log.i(LOG_TAG, "Successfully finished importing one vCard file: " + uri);
+ List<Uri> uris = committer.getCreatedUris();
+ if (mListener != null) {
+ if (uris != null && uris.size() == 1) {
+ mListener.onImportFinished(mImportRequest, mJobId, uris.get(0));
+ } else {
+ if (uris == null || uris.size() == 0) {
+ // Not critical, but suspicious.
+ Log.w(LOG_TAG, "Created Uris is null or 0 length " +
+ "though the creation itself is successful.");
+ }
+ mListener.onImportFinished(mImportRequest, mJobId, null);
+ }
+ }
+ }
+ } else {
+ Log.w(LOG_TAG, "Failed to read one vCard file: " + uri);
+ mFailedUris.add(uri);
+ }
+ }
+
+ private boolean readOneVCard(InputStream is, int vcardType, String charset,
+ final VCardInterpreter interpreter,
+ final int[] possibleVCardVersions) {
+ boolean successful = false;
+ final int length = possibleVCardVersions.length;
+ for (int i = 0; i < length; i++) {
+ final int vcardVersion = possibleVCardVersions[i];
+ try {
+ if (i > 0 && (interpreter instanceof VCardEntryConstructor)) {
+ // Let the object clean up internal temporary objects,
+ ((VCardEntryConstructor) interpreter).clear();
+ }
+
+ // We need synchronized block here,
+ // since we need to handle mCanceled and mVCardParser at once.
+ // In the worst case, a user may call cancel() just before creating
+ // mVCardParser.
+ synchronized (this) {
+ mVCardParser = (vcardVersion == ImportVCardActivity.VCARD_VERSION_V30 ?
+ new VCardParser_V30(vcardType) :
+ new VCardParser_V21(vcardType));
+ if (isCancelled()) {
+ Log.i(LOG_TAG, "ImportProcessor already recieves cancel request, so " +
+ "send cancel request to vCard parser too.");
+ mVCardParser.cancel();
+ }
+ }
+ mVCardParser.parse(is, interpreter);
+
+ successful = true;
+ break;
+ } catch (IOException e) {
+ Log.e(LOG_TAG, "IOException was emitted: " + e.getMessage());
+ } catch (VCardNestedException e) {
+ // This exception should not be thrown here. We should instead handle it
+ // in the preprocessing session in ImportVCardActivity, as we don't try
+ // to detect the type of given vCard here.
+ //
+ // TODO: Handle this case appropriately, which should mean we have to have
+ // code trying to auto-detect the type of given vCard twice (both in
+ // ImportVCardActivity and ImportVCardService).
+ Log.e(LOG_TAG, "Nested Exception is found.");
+ } catch (VCardNotSupportedException e) {
+ Log.e(LOG_TAG, e.toString());
+ } catch (VCardVersionException e) {
+ if (i == length - 1) {
+ Log.e(LOG_TAG, "Appropriate version for this vCard is not found.");
+ } else {
+ // We'll try the other (v30) version.
+ }
+ } catch (VCardException e) {
+ Log.e(LOG_TAG, e.toString());
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (IOException e) {
+ }
+ }
+ }
+ }
+
+ return successful;
+ }
+
+ @Override
+ public synchronized boolean cancel(boolean mayInterruptIfRunning) {
+ if (DEBUG) Log.d(LOG_TAG, "ImportProcessor received cancel request");
+ if (mDone || mCanceled) {
+ return false;
+ }
+ mCanceled = true;
+ synchronized (this) {
+ if (mVCardParser != null) {
+ mVCardParser.cancel();
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public synchronized boolean isCancelled() {
+ return mCanceled;
+ }
+
+
+ @Override
+ public synchronized boolean isDone() {
+ return mDone;
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/ImportRequest.java b/src/com/android/contacts/common/vcard/ImportRequest.java
new file mode 100644
index 0000000..32efb99
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/ImportRequest.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.vcard;
+
+import android.accounts.Account;
+import android.net.Uri;
+
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.vcard.VCardSourceDetector;
+
+/**
+ * Class representing one request for importing vCard (given as a Uri).
+ *
+ * Mainly used when {@link ImportVCardActivity} requests {@link VCardService}
+ * to import some specific Uri.
+ *
+ * Note: This object's accepting only One Uri does NOT mean that
+ * there's only one vCard entry inside the instance, as one Uri often has multiple
+ * vCard entries inside it.
+ */
+public class ImportRequest {
+ /**
+ * Can be null (typically when there's no Account available in the system).
+ */
+ public final Account account;
+
+ /**
+ * Uri to be imported. May have different content than originally given from users, so
+ * when displaying user-friendly information (e.g. "importing xxx.vcf"), use
+ * {@link #displayName} instead.
+ *
+ * If this is null {@link #data} contains the byte stream of the vcard.
+ */
+ public final Uri uri;
+
+ /**
+ * Holds the byte stream of the vcard, if {@link #uri} is null.
+ */
+ public final byte[] data;
+
+ /**
+ * String to be displayed to the user to indicate the source of the VCARD.
+ */
+ public final String displayName;
+
+ /**
+ * Can be {@link VCardSourceDetector#PARSE_TYPE_UNKNOWN}.
+ */
+ public final int estimatedVCardType;
+
+ /**
+ * Can be null, meaning no preferable charset is available.
+ */
+ public final String estimatedCharset;
+
+ /**
+ * Assumes that one Uri contains only one version, while there's a (tiny) possibility
+ * we may have two types in one vCard.
+ *
+ * e.g.
+ * BEGIN:VCARD
+ * VERSION:2.1
+ * ...
+ * END:VCARD
+ * BEGIN:VCARD
+ * VERSION:3.0
+ * ...
+ * END:VCARD
+ *
+ * We've never seen this kind of a file, but we may have to cope with it in the future.
+ */
+ public final int vcardVersion;
+
+ /**
+ * The count of vCard entries in {@link #uri}. A receiver of this object can use it
+ * when showing the progress of import. Thus a receiver must be able to torelate this
+ * variable being invalid because of vCard's limitation.
+ *
+ * vCard does not let us know this count without looking over a whole file content,
+ * which means we have to open and scan over {@link #uri} to know this value, while
+ * it may not be opened more than once (Uri does not require it to be opened multiple times
+ * and may become invalid after its close() request).
+ */
+ public final int entryCount;
+
+ public ImportRequest(AccountWithDataSet account,
+ byte[] data, Uri uri, String displayName, int estimatedType, String estimatedCharset,
+ int vcardVersion, int entryCount) {
+ this.account = account != null ? account.getAccountOrNull() : null;
+ this.data = data;
+ this.uri = uri;
+ this.displayName = displayName;
+ this.estimatedVCardType = estimatedType;
+ this.estimatedCharset = estimatedCharset;
+ this.vcardVersion = vcardVersion;
+ this.entryCount = entryCount;
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/ImportVCardActivity.java b/src/com/android/contacts/common/vcard/ImportVCardActivity.java
new file mode 100644
index 0000000..9da8c0b
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/ImportVCardActivity.java
@@ -0,0 +1,769 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.vcard;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.ProgressDialog;
+import android.content.ClipData;
+import android.content.ComponentName;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.PowerManager;
+import android.provider.OpenableColumns;
+import android.text.TextUtils;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.activity.RequestImportVCardPermissionsActivity;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.vcard.VCardEntryCounter;
+import com.android.vcard.VCardParser;
+import com.android.vcard.VCardParser_V21;
+import com.android.vcard.VCardParser_V30;
+import com.android.vcard.VCardSourceDetector;
+import com.android.vcard.exception.VCardException;
+import com.android.vcard.exception.VCardNestedException;
+import com.android.vcard.exception.VCardVersionException;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.channels.WritableByteChannel;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * The class letting users to import vCard. This includes the UI part for letting them select
+ * an Account and posssibly a file if there's no Uri is given from its caller Activity.
+ *
+ * Note that this Activity assumes that the instance is a "one-shot Activity", which will be
+ * finished (with the method {@link Activity#finish()}) after the import and never reuse
+ * any Dialog in the instance. So this code is careless about the management around managed
+ * dialogs stuffs (like how onCreateDialog() is used).
+ */
+public class ImportVCardActivity extends Activity {
+ private static final String LOG_TAG = "VCardImport";
+
+ private static final int SELECT_ACCOUNT = 0;
+
+ /* package */ final static int VCARD_VERSION_AUTO_DETECT = 0;
+ /* package */ final static int VCARD_VERSION_V21 = 1;
+ /* package */ final static int VCARD_VERSION_V30 = 2;
+
+ private static final int REQUEST_OPEN_DOCUMENT = 100;
+
+ /**
+ * Notification id used when error happened before sending an import request to VCardServer.
+ */
+ private static final int FAILURE_NOTIFICATION_ID = 1;
+
+ private static final String LOCAL_TMP_FILE_NAME_EXTRA =
+ "com.android.contacts.common.vcard.LOCAL_TMP_FILE_NAME";
+
+ private static final String SOURCE_URI_DISPLAY_NAME =
+ "com.android.contacts.common.vcard.SOURCE_URI_DISPLAY_NAME";
+
+ private static final String STORAGE_VCARD_URI_PREFIX = "file:///storage";
+
+ private AccountWithDataSet mAccount;
+
+ private ProgressDialog mProgressDialogForCachingVCard;
+
+ private VCardCacheThread mVCardCacheThread;
+ private ImportRequestConnection mConnection;
+ /* package */ VCardImportExportListener mListener;
+
+ private String mErrorMessage;
+
+ private Handler mHandler = new Handler();
+
+ // Runs on the UI thread.
+ private class DialogDisplayer implements Runnable {
+ private final int mResId;
+ public DialogDisplayer(int resId) {
+ mResId = resId;
+ }
+ public DialogDisplayer(String errorMessage) {
+ mResId = R.id.dialog_error_with_message;
+ mErrorMessage = errorMessage;
+ }
+ @Override
+ public void run() {
+ if (!isFinishing()) {
+ showDialog(mResId);
+ }
+ }
+ }
+
+ private class CancelListener
+ implements DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ finish();
+ }
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ finish();
+ }
+ }
+
+ private CancelListener mCancelListener = new CancelListener();
+
+ private class ImportRequestConnection implements ServiceConnection {
+ private VCardService mService;
+
+ public void sendImportRequest(final List<ImportRequest> requests) {
+ Log.i(LOG_TAG, "Send an import request");
+ mService.handleImportRequest(requests, mListener);
+ }
+
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder binder) {
+ mService = ((VCardService.MyBinder) binder).getService();
+ Log.i(LOG_TAG,
+ String.format("Connected to VCardService. Kick a vCard cache thread (uri: %s)",
+ Arrays.toString(mVCardCacheThread.getSourceUris())));
+ mVCardCacheThread.start();
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ Log.i(LOG_TAG, "Disconnected from VCardService");
+ }
+ }
+
+ /**
+ * Caches given vCard files into a local directory, and sends actual import request to
+ * {@link VCardService}.
+ *
+ * We need to cache given files into local storage. One of reasons is that some data (as Uri)
+ * may have special permissions. Callers may allow only this Activity to access that content,
+ * not what this Activity launched (like {@link VCardService}).
+ */
+ private class VCardCacheThread extends Thread
+ implements DialogInterface.OnCancelListener {
+ private boolean mCanceled;
+ private PowerManager.WakeLock mWakeLock;
+ private VCardParser mVCardParser;
+ private final Uri[] mSourceUris; // Given from a caller.
+ private final String[] mSourceDisplayNames; // Display names for each Uri in mSourceUris.
+ private final byte[] mSource;
+ private final String mDisplayName;
+
+ public VCardCacheThread(final Uri[] sourceUris, String[] sourceDisplayNames) {
+ mSourceUris = sourceUris;
+ mSourceDisplayNames = sourceDisplayNames;
+ mSource = null;
+ final Context context = ImportVCardActivity.this;
+ final PowerManager powerManager =
+ (PowerManager)context.getSystemService(Context.POWER_SERVICE);
+ mWakeLock = powerManager.newWakeLock(
+ PowerManager.SCREEN_DIM_WAKE_LOCK |
+ PowerManager.ON_AFTER_RELEASE, LOG_TAG);
+ mDisplayName = null;
+ }
+
+ @Override
+ public void finalize() {
+ if (mWakeLock != null && mWakeLock.isHeld()) {
+ Log.w(LOG_TAG, "WakeLock is being held.");
+ mWakeLock.release();
+ }
+ }
+
+ @Override
+ public void run() {
+ Log.i(LOG_TAG, "vCard cache thread starts running.");
+ if (mConnection == null) {
+ throw new NullPointerException("vCard cache thread must be launched "
+ + "after a service connection is established");
+ }
+
+ mWakeLock.acquire();
+ try {
+ if (mCanceled == true) {
+ Log.i(LOG_TAG, "vCard cache operation is canceled.");
+ return;
+ }
+
+ final Context context = ImportVCardActivity.this;
+ // Uris given from caller applications may not be opened twice: consider when
+ // it is not from local storage (e.g. "file:///...") but from some special
+ // provider (e.g. "content://...").
+ // Thus we have to once copy the content of Uri into local storage, and read
+ // it after it.
+ //
+ // We may be able to read content of each vCard file during copying them
+ // to local storage, but currently vCard code does not allow us to do so.
+ int cache_index = 0;
+ ArrayList<ImportRequest> requests = new ArrayList<ImportRequest>();
+ if (mSource != null) {
+ try {
+ requests.add(constructImportRequest(mSource, null, mDisplayName));
+ } catch (VCardException e) {
+ Log.e(LOG_TAG, "Maybe the file is in wrong format", e);
+ showFailureNotification(R.string.fail_reason_not_supported);
+ return;
+ }
+ } else {
+ int i = 0;
+ for (Uri sourceUri : mSourceUris) {
+ if (mCanceled) {
+ Log.i(LOG_TAG, "vCard cache operation is canceled.");
+ break;
+ }
+
+ String sourceDisplayName = mSourceDisplayNames[i++];
+
+ final ImportRequest request;
+ try {
+ request = constructImportRequest(null, sourceUri, sourceDisplayName);
+ } catch (VCardException e) {
+ Log.e(LOG_TAG, "Maybe the file is in wrong format", e);
+ showFailureNotification(R.string.fail_reason_not_supported);
+ return;
+ } catch (IOException e) {
+ Log.e(LOG_TAG, "Unexpected IOException", e);
+ showFailureNotification(R.string.fail_reason_io_error);
+ return;
+ }
+ if (mCanceled) {
+ Log.i(LOG_TAG, "vCard cache operation is canceled.");
+ return;
+ }
+ requests.add(request);
+ }
+ }
+ if (!requests.isEmpty()) {
+ mConnection.sendImportRequest(requests);
+ } else {
+ Log.w(LOG_TAG, "Empty import requests. Ignore it.");
+ }
+ } catch (OutOfMemoryError e) {
+ Log.e(LOG_TAG, "OutOfMemoryError occured during caching vCard");
+ System.gc();
+ runOnUiThread(new DialogDisplayer(
+ getString(R.string.fail_reason_low_memory_during_import)));
+ } catch (IOException e) {
+ Log.e(LOG_TAG, "IOException during caching vCard", e);
+ runOnUiThread(new DialogDisplayer(
+ getString(R.string.fail_reason_io_error)));
+ } finally {
+ Log.i(LOG_TAG, "Finished caching vCard.");
+ mWakeLock.release();
+ unbindService(mConnection);
+ mProgressDialogForCachingVCard.dismiss();
+ mProgressDialogForCachingVCard = null;
+ finish();
+ }
+ }
+
+ /**
+ * Reads localDataUri (possibly multiple times) and constructs {@link ImportRequest} from
+ * its content.
+ *
+ * @arg localDataUri Uri actually used for the import. Should be stored in
+ * app local storage, as we cannot guarantee other types of Uris can be read
+ * multiple times. This variable populates {@link ImportRequest#uri}.
+ * @arg displayName Used for displaying information to the user. This variable populates
+ * {@link ImportRequest#displayName}.
+ */
+ private ImportRequest constructImportRequest(final byte[] data,
+ final Uri localDataUri, final String displayName)
+ throws IOException, VCardException {
+ final ContentResolver resolver = ImportVCardActivity.this.getContentResolver();
+ VCardEntryCounter counter = null;
+ VCardSourceDetector detector = null;
+ int vcardVersion = VCARD_VERSION_V21;
+ try {
+ boolean shouldUseV30 = false;
+ InputStream is;
+ if (data != null) {
+ is = new ByteArrayInputStream(data);
+ } else {
+ is = resolver.openInputStream(localDataUri);
+ }
+ mVCardParser = new VCardParser_V21();
+ try {
+ counter = new VCardEntryCounter();
+ detector = new VCardSourceDetector();
+ mVCardParser.addInterpreter(counter);
+ mVCardParser.addInterpreter(detector);
+ mVCardParser.parse(is);
+ } catch (VCardVersionException e1) {
+ try {
+ is.close();
+ } catch (IOException e) {
+ }
+
+ shouldUseV30 = true;
+ if (data != null) {
+ is = new ByteArrayInputStream(data);
+ } else {
+ is = resolver.openInputStream(localDataUri);
+ }
+ mVCardParser = new VCardParser_V30();
+ try {
+ counter = new VCardEntryCounter();
+ detector = new VCardSourceDetector();
+ mVCardParser.addInterpreter(counter);
+ mVCardParser.addInterpreter(detector);
+ mVCardParser.parse(is);
+ } catch (VCardVersionException e2) {
+ throw new VCardException("vCard with unspported version.");
+ }
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (IOException e) {
+ }
+ }
+ }
+
+ vcardVersion = shouldUseV30 ? VCARD_VERSION_V30 : VCARD_VERSION_V21;
+ } catch (VCardNestedException e) {
+ Log.w(LOG_TAG, "Nested Exception is found (it may be false-positive).");
+ // Go through without throwing the Exception, as we may be able to detect the
+ // version before it
+ }
+ return new ImportRequest(mAccount,
+ data, localDataUri, displayName,
+ detector.getEstimatedType(),
+ detector.getEstimatedCharset(),
+ vcardVersion, counter.getCount());
+ }
+
+ public Uri[] getSourceUris() {
+ return mSourceUris;
+ }
+
+ public void cancel() {
+ mCanceled = true;
+ if (mVCardParser != null) {
+ mVCardParser.cancel();
+ }
+ }
+
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ Log.i(LOG_TAG, "Cancel request has come. Abort caching vCard.");
+ cancel();
+ }
+ }
+
+ private void importVCard(final Uri uri, final String sourceDisplayName) {
+ importVCard(new Uri[] {uri}, new String[] {sourceDisplayName});
+ }
+
+ private void importVCard(final Uri[] uris, final String[] sourceDisplayNames) {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (!isFinishing()) {
+ mVCardCacheThread = new VCardCacheThread(uris, sourceDisplayNames);
+ mListener = new NotificationImportExportListener(ImportVCardActivity.this);
+ showDialog(R.id.dialog_cache_vcard);
+ }
+ }
+ });
+ }
+
+ private String getDisplayName(Uri sourceUri) {
+ if (sourceUri == null) {
+ return null;
+ }
+ final ContentResolver resolver = ImportVCardActivity.this.getContentResolver();
+ String displayName = null;
+ Cursor cursor = null;
+ // Try to get a display name from the given Uri. If it fails, we just
+ // pick up the last part of the Uri.
+ try {
+ cursor = resolver.query(sourceUri,
+ new String[] { OpenableColumns.DISPLAY_NAME },
+ null, null, null);
+ if (cursor != null && cursor.getCount() > 0 && cursor.moveToFirst()) {
+ if (cursor.getCount() > 1) {
+ Log.w(LOG_TAG, "Unexpected multiple rows: "
+ + cursor.getCount());
+ }
+ int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
+ if (index >= 0) {
+ displayName = cursor.getString(index);
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ if (TextUtils.isEmpty(displayName)){
+ displayName = sourceUri.getLastPathSegment();
+ }
+ return displayName;
+ }
+
+ /**
+ * Copy the content of sourceUri to the destination.
+ */
+ private Uri copyTo(final Uri sourceUri, String filename) throws IOException {
+ Log.i(LOG_TAG, String.format("Copy a Uri to app local storage (%s -> %s)",
+ sourceUri, filename));
+ final Context context = ImportVCardActivity.this;
+ final ContentResolver resolver = context.getContentResolver();
+ ReadableByteChannel inputChannel = null;
+ WritableByteChannel outputChannel = null;
+ Uri destUri = null;
+ try {
+ inputChannel = Channels.newChannel(resolver.openInputStream(sourceUri));
+ destUri = Uri.parse(context.getFileStreamPath(filename).toURI().toString());
+ outputChannel = context.openFileOutput(filename, Context.MODE_PRIVATE).getChannel();
+ final ByteBuffer buffer = ByteBuffer.allocateDirect(8192);
+ while (inputChannel.read(buffer) != -1) {
+ buffer.flip();
+ outputChannel.write(buffer);
+ buffer.compact();
+ }
+ buffer.flip();
+ while (buffer.hasRemaining()) {
+ outputChannel.write(buffer);
+ }
+ } finally {
+ if (inputChannel != null) {
+ try {
+ inputChannel.close();
+ } catch (IOException e) {
+ Log.w(LOG_TAG, "Failed to close inputChannel.");
+ }
+ }
+ if (outputChannel != null) {
+ try {
+ outputChannel.close();
+ } catch(IOException e) {
+ Log.w(LOG_TAG, "Failed to close outputChannel");
+ }
+ }
+ }
+ return destUri;
+ }
+
+ /**
+ * Reads the file from {@param sourceUri} and copies it to local cache file.
+ * Returns the local file name which stores the file from sourceUri.
+ */
+ private String readUriToLocalFile(Uri sourceUri) {
+ // Read the uri to local first.
+ int cache_index = 0;
+ String localFilename = null;
+ // Note: caches are removed by VCardService.
+ while (true) {
+ localFilename = VCardService.CACHE_FILE_PREFIX + cache_index + ".vcf";
+ final File file = getFileStreamPath(localFilename);
+ if (!file.exists()) {
+ break;
+ } else {
+ if (cache_index == Integer.MAX_VALUE) {
+ throw new RuntimeException("Exceeded cache limit");
+ }
+ cache_index++;
+ }
+ }
+ try {
+ copyTo(sourceUri, localFilename);
+ } catch (SecurityException e) {
+ Log.e(LOG_TAG, "SecurityException", e);
+ showFailureNotification(R.string.fail_reason_io_error);
+ return null;
+ } catch (IOException e) {
+ Log.e(LOG_TAG, "IOException during caching vCard", e);
+ showFailureNotification(R.string.fail_reason_io_error);
+ return null;
+ }
+
+ if (localFilename == null) {
+ Log.e(LOG_TAG, "Cannot load uri to local storage.");
+ showFailureNotification(R.string.fail_reason_io_error);
+ return null;
+ }
+
+ return localFilename;
+ }
+
+ private Uri readUriToLocalUri(Uri sourceUri) {
+ final String fileName = readUriToLocalFile(sourceUri);
+ if (fileName == null) {
+ return null;
+ }
+ return Uri.parse(getFileStreamPath(fileName).toURI().toString());
+ }
+
+ // Returns true if uri is from Storage.
+ private boolean isStorageUri(Uri uri) {
+ return uri != null && uri.toString().startsWith(STORAGE_VCARD_URI_PREFIX);
+ }
+
+ @Override
+ protected void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+
+ Uri sourceUri = getIntent().getData();
+
+ // Reading uris from non-storage needs the permission granted from the source intent,
+ // instead of permissions from RequestImportVCardPermissionActivity. So skipping requesting
+ // permissions from RequestImportVCardPermissionActivity for uris from non-storage source.
+ if (isStorageUri(sourceUri)
+ && RequestImportVCardPermissionsActivity.startPermissionActivity(this)) {
+ return;
+ }
+
+ String sourceDisplayName = null;
+ if (sourceUri != null) {
+ // Read the uri to local first.
+ String localTmpFileName = getIntent().getStringExtra(LOCAL_TMP_FILE_NAME_EXTRA);
+ sourceDisplayName = getIntent().getStringExtra(SOURCE_URI_DISPLAY_NAME);
+ if (TextUtils.isEmpty(localTmpFileName)) {
+ localTmpFileName = readUriToLocalFile(sourceUri);
+ sourceDisplayName = getDisplayName(sourceUri);
+ if (localTmpFileName == null) {
+ Log.e(LOG_TAG, "Cannot load uri to local storage.");
+ showFailureNotification(R.string.fail_reason_io_error);
+ return;
+ }
+ getIntent().putExtra(LOCAL_TMP_FILE_NAME_EXTRA, localTmpFileName);
+ getIntent().putExtra(SOURCE_URI_DISPLAY_NAME, sourceDisplayName);
+ }
+ sourceUri = Uri.parse(getFileStreamPath(localTmpFileName).toURI().toString());
+ }
+
+ // Always request required permission for contacts before importing the vcard.
+ if (RequestImportVCardPermissionsActivity.startPermissionActivity(this)) {
+ return;
+ }
+
+ String accountName = null;
+ String accountType = null;
+ String dataSet = null;
+ final Intent intent = getIntent();
+ if (intent != null) {
+ accountName = intent.getStringExtra(SelectAccountActivity.ACCOUNT_NAME);
+ accountType = intent.getStringExtra(SelectAccountActivity.ACCOUNT_TYPE);
+ dataSet = intent.getStringExtra(SelectAccountActivity.DATA_SET);
+ } else {
+ Log.e(LOG_TAG, "intent does not exist");
+ }
+
+ if (!TextUtils.isEmpty(accountName) && !TextUtils.isEmpty(accountType)) {
+ mAccount = new AccountWithDataSet(accountName, accountType, dataSet);
+ } else {
+ final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this);
+ final List<AccountWithDataSet> accountList = accountTypes.getAccounts(true);
+ if (accountList.size() == 0) {
+ mAccount = null;
+ } else if (accountList.size() == 1) {
+ mAccount = accountList.get(0);
+ } else {
+ startActivityForResult(new Intent(this, SelectAccountActivity.class),
+ SELECT_ACCOUNT);
+ return;
+ }
+ }
+
+ startImport(sourceUri, sourceDisplayName);
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent intent) {
+ if (requestCode == SELECT_ACCOUNT) {
+ if (resultCode == Activity.RESULT_OK) {
+ mAccount = new AccountWithDataSet(
+ intent.getStringExtra(SelectAccountActivity.ACCOUNT_NAME),
+ intent.getStringExtra(SelectAccountActivity.ACCOUNT_TYPE),
+ intent.getStringExtra(SelectAccountActivity.DATA_SET));
+ final Uri sourceUri = getIntent().getData();
+ if (sourceUri == null) {
+ startImport(sourceUri, /* sourceDisplayName =*/ null);
+ } else {
+ final String sourceDisplayName = getIntent().getStringExtra(
+ SOURCE_URI_DISPLAY_NAME);
+ final String localFileName = getIntent().getStringExtra(
+ LOCAL_TMP_FILE_NAME_EXTRA);
+ final Uri localUri = Uri.parse(
+ getFileStreamPath(localFileName).toURI().toString());
+ startImport(localUri, sourceDisplayName);
+ }
+ } else {
+ if (resultCode != Activity.RESULT_CANCELED) {
+ Log.w(LOG_TAG, "Result code was not OK nor CANCELED: " + resultCode);
+ }
+ finish();
+ }
+ } else if (requestCode == REQUEST_OPEN_DOCUMENT) {
+ if (resultCode == Activity.RESULT_OK) {
+ final ClipData clipData = intent.getClipData();
+ if (clipData != null) {
+ final ArrayList<Uri> uris = new ArrayList<>();
+ final ArrayList<String> sourceDisplayNames = new ArrayList<>();
+ for (int i = 0; i < clipData.getItemCount(); i++) {
+ ClipData.Item item = clipData.getItemAt(i);
+ final Uri uri = item.getUri();
+ if (uri != null) {
+ final Uri localUri = readUriToLocalUri(uri);
+ if (localUri != null) {
+ final String sourceDisplayName = getDisplayName(uri);
+ uris.add(localUri);
+ sourceDisplayNames.add(sourceDisplayName);
+ }
+ }
+ }
+ if (uris.isEmpty()) {
+ Log.w(LOG_TAG, "No vCard was selected for import");
+ finish();
+ } else {
+ Log.i(LOG_TAG, "Multiple vCards selected for import: " + uris);
+ importVCard(uris.toArray(new Uri[0]),
+ sourceDisplayNames.toArray(new String[0]));
+ }
+ } else {
+ final Uri uri = intent.getData();
+ if (uri != null) {
+ Log.i(LOG_TAG, "vCard selected for import: " + uri);
+ final Uri localUri = readUriToLocalUri(uri);
+ if (localUri != null) {
+ final String sourceDisplayName = getDisplayName(uri);
+ importVCard(localUri, sourceDisplayName);
+ } else {
+ Log.w(LOG_TAG, "No local URI for vCard import");
+ finish();
+ }
+ } else {
+ Log.w(LOG_TAG, "No vCard was selected for import");
+ finish();
+ }
+ }
+ } else {
+ if (resultCode != Activity.RESULT_CANCELED) {
+ Log.w(LOG_TAG, "Result code was not OK nor CANCELED" + resultCode);
+ }
+ finish();
+ }
+ }
+ }
+
+ private void startImport(Uri uri, String sourceDisplayName) {
+ // Handle inbound files
+ if (uri != null) {
+ Log.i(LOG_TAG, "Starting vCard import using Uri " + uri);
+ importVCard(uri, sourceDisplayName);
+ } else {
+ Log.i(LOG_TAG, "Start vCard without Uri. The user will select vCard manually.");
+ final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType(VCardService.X_VCARD_MIME_TYPE);
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
+ startActivityForResult(intent, REQUEST_OPEN_DOCUMENT);
+ }
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int resId, Bundle bundle) {
+ if (resId == R.id.dialog_cache_vcard) {
+ if (mProgressDialogForCachingVCard == null) {
+ final String title = getString(R.string.caching_vcard_title);
+ final String message = getString(R.string.caching_vcard_message);
+ mProgressDialogForCachingVCard = new ProgressDialog(this);
+ mProgressDialogForCachingVCard.setTitle(title);
+ mProgressDialogForCachingVCard.setMessage(message);
+ mProgressDialogForCachingVCard.setProgressStyle(ProgressDialog.STYLE_SPINNER);
+ mProgressDialogForCachingVCard.setOnCancelListener(mVCardCacheThread);
+ startVCardService();
+ }
+ return mProgressDialogForCachingVCard;
+ } else if (resId == R.id.dialog_error_with_message) {
+ String message = mErrorMessage;
+ if (TextUtils.isEmpty(message)) {
+ Log.e(LOG_TAG, "Error message is null while it must not.");
+ message = getString(R.string.fail_reason_unknown);
+ }
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this)
+ .setTitle(getString(R.string.reading_vcard_failed_title))
+ .setIconAttribute(android.R.attr.alertDialogIcon)
+ .setMessage(message)
+ .setOnCancelListener(mCancelListener)
+ .setPositiveButton(android.R.string.ok, mCancelListener);
+ return builder.create();
+ }
+
+ return super.onCreateDialog(resId, bundle);
+ }
+
+ /* package */ void startVCardService() {
+ mConnection = new ImportRequestConnection();
+
+ Log.i(LOG_TAG, "Bind to VCardService.");
+ // We don't want the service finishes itself just after this connection.
+ Intent intent = new Intent(this, VCardService.class);
+ startService(intent);
+ bindService(new Intent(this, VCardService.class),
+ mConnection, Context.BIND_AUTO_CREATE);
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ if (mProgressDialogForCachingVCard != null) {
+ Log.i(LOG_TAG, "Cache thread is still running. Show progress dialog again.");
+ showDialog(R.id.dialog_cache_vcard);
+ }
+ }
+
+ /* package */ void showFailureNotification(int reasonId) {
+ final NotificationManager notificationManager =
+ (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE);
+ final Notification notification =
+ NotificationImportExportListener.constructImportFailureNotification(
+ ImportVCardActivity.this,
+ getString(reasonId));
+ notificationManager.notify(NotificationImportExportListener.FAILURE_NOTIFICATION_TAG,
+ FAILURE_NOTIFICATION_ID, notification);
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(ImportVCardActivity.this,
+ getString(R.string.vcard_import_failed), Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/NfcImportVCardActivity.java b/src/com/android/contacts/common/vcard/NfcImportVCardActivity.java
new file mode 100644
index 0000000..0634df4
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/NfcImportVCardActivity.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.vcard;
+
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.Uri;
+import android.nfc.NdefMessage;
+import android.nfc.NdefRecord;
+import android.nfc.NfcAdapter;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.provider.ContactsContract.RawContacts;
+import android.util.Log;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.activity.RequestPermissionsActivity;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.util.ImplicitIntentsUtil;
+import com.android.vcard.VCardEntry;
+import com.android.vcard.VCardEntryCounter;
+import com.android.vcard.VCardParser;
+import com.android.vcard.VCardParser_V21;
+import com.android.vcard.VCardParser_V30;
+import com.android.vcard.VCardSourceDetector;
+import com.android.vcard.exception.VCardException;
+import com.android.vcard.exception.VCardNestedException;
+import com.android.vcard.exception.VCardVersionException;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+public class NfcImportVCardActivity extends Activity implements ServiceConnection,
+ VCardImportExportListener {
+ private static final String TAG = "NfcImportVCardActivity";
+
+ private static final int SELECT_ACCOUNT = 1;
+
+ private NdefRecord mRecord;
+ private AccountWithDataSet mAccount;
+
+ /* package */ class ImportTask extends AsyncTask<VCardService, Void, ImportRequest> {
+ @Override
+ public ImportRequest doInBackground(VCardService... services) {
+ ImportRequest request = createImportRequest();
+ if (request == null) {
+ return null;
+ }
+
+ ArrayList<ImportRequest> requests = new ArrayList<ImportRequest>();
+ requests.add(request);
+ services[0].handleImportRequest(requests, NfcImportVCardActivity.this);
+ return request;
+ }
+
+ @Override
+ public void onCancelled() {
+ unbindService(NfcImportVCardActivity.this);
+ }
+
+ @Override
+ public void onPostExecute(ImportRequest request) {
+ unbindService(NfcImportVCardActivity.this);
+ }
+ }
+
+ /* package */ ImportRequest createImportRequest() {
+ VCardParser parser;
+ VCardEntryCounter counter = null;
+ VCardSourceDetector detector = null;
+ int vcardVersion = ImportVCardActivity.VCARD_VERSION_V21;
+ try {
+ ByteArrayInputStream is = new ByteArrayInputStream(mRecord.getPayload());
+ is.mark(0);
+ parser = new VCardParser_V21();
+ try {
+ counter = new VCardEntryCounter();
+ detector = new VCardSourceDetector();
+ parser.addInterpreter(counter);
+ parser.addInterpreter(detector);
+ parser.parse(is);
+ } catch (VCardVersionException e1) {
+ is.reset();
+ vcardVersion = ImportVCardActivity.VCARD_VERSION_V30;
+ parser = new VCardParser_V30();
+ try {
+ counter = new VCardEntryCounter();
+ detector = new VCardSourceDetector();
+ parser.addInterpreter(counter);
+ parser.addInterpreter(detector);
+ parser.parse(is);
+ } catch (VCardVersionException e2) {
+ return null;
+ }
+ } finally {
+ try {
+ if (is != null) is.close();
+ } catch (IOException e) {
+ }
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "Failed reading vcard data", e);
+ return null;
+ } catch (VCardNestedException e) {
+ Log.w(TAG, "Nested Exception is found (it may be false-positive).");
+ // Go through without throwing the Exception, as we may be able to detect the
+ // version before it
+ } catch (VCardException e) {
+ Log.e(TAG, "Error parsing vcard", e);
+ return null;
+ }
+
+ return new ImportRequest(mAccount, mRecord.getPayload(), null,
+ getString(R.string.nfc_vcard_file_name), detector.getEstimatedType(),
+ detector.getEstimatedCharset(), vcardVersion, counter.getCount());
+ }
+
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder binder) {
+ VCardService service = ((VCardService.MyBinder) binder).getService();
+ new ImportTask().execute(service);
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ // Do nothing
+ }
+
+ @Override
+ protected void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+
+ if (RequestPermissionsActivity.startPermissionActivity(this)) {
+ return;
+ }
+
+ Intent intent = getIntent();
+ if (!NfcAdapter.ACTION_NDEF_DISCOVERED.equals(intent.getAction())) {
+ Log.w(TAG, "Unknowon intent " + intent);
+ finish();
+ return;
+ }
+
+ String type = intent.getType();
+ if (type == null ||
+ (!"text/x-vcard".equals(type) && !"text/vcard".equals(type))) {
+ Log.w(TAG, "Not a vcard");
+ //setStatus(getString(R.string.fail_reason_not_supported));
+ finish();
+ return;
+ }
+ NdefMessage msg = (NdefMessage) intent.getParcelableArrayExtra(
+ NfcAdapter.EXTRA_NDEF_MESSAGES)[0];
+ mRecord = msg.getRecords()[0];
+
+ final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this);
+ final List<AccountWithDataSet> accountList = accountTypes.getAccounts(true);
+ if (accountList.size() == 0) {
+ mAccount = null;
+ } else if (accountList.size() == 1) {
+ mAccount = accountList.get(0);
+ } else {
+ startActivityForResult(new Intent(this, SelectAccountActivity.class), SELECT_ACCOUNT);
+ return;
+ }
+
+ startImport();
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent intent) {
+ if (requestCode == SELECT_ACCOUNT) {
+ if (resultCode == RESULT_OK) {
+ mAccount = new AccountWithDataSet(
+ intent.getStringExtra(SelectAccountActivity.ACCOUNT_NAME),
+ intent.getStringExtra(SelectAccountActivity.ACCOUNT_TYPE),
+ intent.getStringExtra(SelectAccountActivity.DATA_SET));
+ startImport();
+ } else {
+ finish();
+ }
+ }
+ }
+
+ private void startImport() {
+ // We don't want the service finishes itself just after this connection.
+ Intent intent = new Intent(this, VCardService.class);
+ startService(intent);
+ bindService(intent, this, Context.BIND_AUTO_CREATE);
+ }
+
+ @Override
+ public void onImportProcessed(ImportRequest request, int jobId, int sequence) {
+ // do nothing
+ }
+
+ @Override
+ public void onImportParsed(ImportRequest request, int jobId, VCardEntry entry, int currentCount,
+ int totalCount) {
+ // do nothing
+ }
+
+ @Override
+ public void onImportFinished(ImportRequest request, int jobId, Uri uri) {
+ if (isFinishing()) {
+ Log.i(TAG, "Late import -- ignoring");
+ return;
+ }
+
+ if (uri != null) {
+ Uri contactUri = RawContacts.getContactLookupUri(getContentResolver(), uri);
+ Intent intent = new Intent(Intent.ACTION_VIEW, contactUri);
+ ImplicitIntentsUtil.startActivityInAppIfPossible(this, intent);
+ finish();
+ }
+ }
+
+ @Override
+ public void onImportFailed(ImportRequest request) {
+ if (isFinishing()) {
+ Log.i(TAG, "Late import failure -- ignoring");
+ return;
+ }
+ // TODO: report failure
+ }
+
+ @Override
+ public void onImportCanceled(ImportRequest request, int jobId) {
+ // do nothing
+ }
+
+ @Override
+ public void onExportProcessed(ExportRequest request, int jobId) {
+ // do nothing
+ }
+
+ @Override
+ public void onExportFailed(ExportRequest request) {
+ // do nothing
+ }
+
+ @Override
+ public void onCancelRequest(CancelRequest request, int type) {
+ // do nothing
+ }
+
+ @Override
+ public void onComplete() {
+ // do nothing
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/NotificationImportExportListener.java b/src/com/android/contacts/common/vcard/NotificationImportExportListener.java
new file mode 100644
index 0000000..2ac8c1c
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/NotificationImportExportListener.java
@@ -0,0 +1,318 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.vcard;
+
+import android.app.Activity;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Message;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.RawContacts;
+import android.support.v4.app.NotificationCompat;
+import android.widget.Toast;
+
+import com.android.contacts.common.R;
+import com.android.vcard.VCardEntry;
+
+import java.text.NumberFormat;
+
+public class NotificationImportExportListener implements VCardImportExportListener,
+ Handler.Callback {
+ /** The tag used by vCard-related notifications. */
+ /* package */ static final String DEFAULT_NOTIFICATION_TAG = "VCardServiceProgress";
+ /**
+ * The tag used by vCard-related failure notifications.
+ * <p>
+ * Use a different tag from {@link #DEFAULT_NOTIFICATION_TAG} so that failures do not get
+ * replaced by other notifications and vice-versa.
+ */
+ /* package */ static final String FAILURE_NOTIFICATION_TAG = "VCardServiceFailure";
+
+ private final NotificationManager mNotificationManager;
+ private final Activity mContext;
+ private final Handler mHandler;
+
+ public NotificationImportExportListener(Activity activity) {
+ mContext = activity;
+ mNotificationManager = (NotificationManager) activity.getSystemService(
+ Context.NOTIFICATION_SERVICE);
+ mHandler = new Handler(this);
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ String text = (String) msg.obj;
+ Toast.makeText(mContext, text, Toast.LENGTH_LONG).show();
+ return true;
+ }
+
+ @Override
+ public void onImportProcessed(ImportRequest request, int jobId, int sequence) {
+ // Show a notification about the status
+ final String displayName;
+ final String message;
+ if (request.displayName != null) {
+ displayName = request.displayName;
+ message = mContext.getString(R.string.vcard_import_will_start_message, displayName);
+ } else {
+ displayName = mContext.getString(R.string.vcard_unknown_filename);
+ message = mContext.getString(
+ R.string.vcard_import_will_start_message_with_default_name);
+ }
+
+ // We just want to show notification for the first vCard.
+ if (sequence == 0) {
+ // TODO: Ideally we should detect the current status of import/export and
+ // show "started" when we can import right now and show "will start" when
+ // we cannot.
+ mHandler.obtainMessage(0, message).sendToTarget();
+ }
+
+ final Notification notification = constructProgressNotification(mContext,
+ VCardService.TYPE_IMPORT, message, message, jobId, displayName, -1, 0);
+ mNotificationManager.notify(DEFAULT_NOTIFICATION_TAG, jobId, notification);
+ }
+
+ @Override
+ public void onImportParsed(ImportRequest request, int jobId, VCardEntry entry, int currentCount,
+ int totalCount) {
+ if (entry.isIgnorable()) {
+ return;
+ }
+
+ final String totalCountString = String.valueOf(totalCount);
+ final String tickerText =
+ mContext.getString(R.string.progress_notifier_message,
+ String.valueOf(currentCount),
+ totalCountString,
+ entry.getDisplayName());
+ final String description = mContext.getString(R.string.importing_vcard_description,
+ entry.getDisplayName());
+
+ final Notification notification = constructProgressNotification(
+ mContext.getApplicationContext(), VCardService.TYPE_IMPORT, description, tickerText,
+ jobId, request.displayName, totalCount, currentCount);
+ mNotificationManager.notify(DEFAULT_NOTIFICATION_TAG, jobId, notification);
+ }
+
+ @Override
+ public void onImportFinished(ImportRequest request, int jobId, Uri createdUri) {
+ final String description = mContext.getString(R.string.importing_vcard_finished_title,
+ request.displayName);
+ final Intent intent;
+ if (createdUri != null) {
+ final long rawContactId = ContentUris.parseId(createdUri);
+ final Uri contactUri = RawContacts.getContactLookupUri(
+ mContext.getContentResolver(), ContentUris.withAppendedId(
+ RawContacts.CONTENT_URI, rawContactId));
+ intent = new Intent(Intent.ACTION_VIEW, contactUri);
+ } else {
+ intent = new Intent(Intent.ACTION_VIEW);
+ intent.setType(ContactsContract.Contacts.CONTENT_TYPE);
+ }
+ intent.setPackage(mContext.getPackageName());
+ final Notification notification =
+ NotificationImportExportListener.constructFinishNotification(mContext,
+ description, null, intent);
+ mNotificationManager.notify(NotificationImportExportListener.DEFAULT_NOTIFICATION_TAG,
+ jobId, notification);
+ }
+
+ @Override
+ public void onImportFailed(ImportRequest request) {
+ // TODO: a little unkind to show Toast in this case, which is shown just a moment.
+ // Ideally we should show some persistent something users can notice more easily.
+ mHandler.obtainMessage(0,
+ mContext.getString(R.string.vcard_import_request_rejected_message)).sendToTarget();
+ }
+
+ @Override
+ public void onImportCanceled(ImportRequest request, int jobId) {
+ final String description = mContext.getString(R.string.importing_vcard_canceled_title,
+ request.displayName);
+ final Notification notification =
+ NotificationImportExportListener.constructCancelNotification(mContext, description);
+ mNotificationManager.notify(NotificationImportExportListener.DEFAULT_NOTIFICATION_TAG,
+ jobId, notification);
+ }
+
+ @Override
+ public void onExportProcessed(ExportRequest request, int jobId) {
+ final String displayName = ExportVCardActivity.getOpenableUriDisplayName(mContext,
+ request.destUri);
+ final String message = mContext.getString(R.string.contacts_export_will_start_message);
+
+ mHandler.obtainMessage(0, message).sendToTarget();
+ final Notification notification =
+ NotificationImportExportListener.constructProgressNotification(mContext,
+ VCardService.TYPE_EXPORT, message, message, jobId, displayName, -1, 0);
+ mNotificationManager.notify(DEFAULT_NOTIFICATION_TAG, jobId, notification);
+ }
+
+ @Override
+ public void onExportFailed(ExportRequest request) {
+ mHandler.obtainMessage(0,
+ mContext.getString(R.string.vcard_export_request_rejected_message)).sendToTarget();
+ }
+
+ @Override
+ public void onCancelRequest(CancelRequest request, int type) {
+ final String description = type == VCardService.TYPE_IMPORT ?
+ mContext.getString(R.string.importing_vcard_canceled_title, request.displayName) :
+ mContext.getString(R.string.exporting_vcard_canceled_title, request.displayName);
+ final Notification notification = constructCancelNotification(mContext, description);
+ mNotificationManager.notify(DEFAULT_NOTIFICATION_TAG, request.jobId, notification);
+ }
+
+ /**
+ * Constructs a {@link Notification} showing the current status of import/export.
+ * Users can cancel the process with the Notification.
+ *
+ * @param context
+ * @param type import/export
+ * @param description Content of the Notification.
+ * @param tickerText
+ * @param jobId
+ * @param displayName Name to be shown to the Notification (e.g. "finished importing XXXX").
+ * Typycally a file name.
+ * @param totalCount The number of vCard entries to be imported. Used to show progress bar.
+ * -1 lets the system show the progress bar with "indeterminate" state.
+ * @param currentCount The index of current vCard. Used to show progress bar.
+ */
+ /* package */ static Notification constructProgressNotification(
+ Context context, int type, String description, String tickerText,
+ int jobId, String displayName, int totalCount, int currentCount) {
+ // Note: We cannot use extra values here (like setIntExtra()), as PendingIntent doesn't
+ // preserve them across multiple Notifications. PendingIntent preserves the first extras
+ // (when flag is not set), or update them when PendingIntent#getActivity() is called
+ // (See PendingIntent#FLAG_UPDATE_CURRENT). In either case, we cannot preserve extras as we
+ // expect (for each vCard import/export request).
+ //
+ // We use query parameter in Uri instead.
+ // Scheme and Authority is arbitorary, assuming CancelActivity never refers them.
+ final Intent intent = new Intent(context, CancelActivity.class);
+ final Uri uri = (new Uri.Builder())
+ .scheme("invalidscheme")
+ .authority("invalidauthority")
+ .appendQueryParameter(CancelActivity.JOB_ID, String.valueOf(jobId))
+ .appendQueryParameter(CancelActivity.DISPLAY_NAME, displayName)
+ .appendQueryParameter(CancelActivity.TYPE, String.valueOf(type)).build();
+ intent.setData(uri);
+
+ final NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
+ builder.setOngoing(true)
+ .setProgress(totalCount, currentCount, totalCount == - 1)
+ .setTicker(tickerText)
+ .setContentTitle(description)
+ .setColor(context.getResources().getColor(R.color.dialtacts_theme_color))
+ .setSmallIcon(type == VCardService.TYPE_IMPORT
+ ? android.R.drawable.stat_sys_download
+ : android.R.drawable.stat_sys_upload)
+ .setContentIntent(PendingIntent.getActivity(context, 0, intent, 0));
+ if (totalCount > 0) {
+ String percentage =
+ NumberFormat.getPercentInstance().format((double) currentCount / totalCount);
+ builder.setContentText(percentage);
+ }
+ return builder.getNotification();
+ }
+
+ /**
+ * Constructs a Notification telling users the process is canceled.
+ *
+ * @param context
+ * @param description Content of the Notification
+ */
+ /* package */ static Notification constructCancelNotification(
+ Context context, String description) {
+ return new NotificationCompat.Builder(context)
+ .setAutoCancel(true)
+ .setSmallIcon(android.R.drawable.stat_notify_error)
+ .setColor(context.getResources().getColor(R.color.dialtacts_theme_color))
+ .setContentTitle(description)
+ .setContentText(description)
+ // Launch an intent that won't resolve to anything. Restrict the intent to this
+ // app to make sure that no other app can steal this pending-intent b/19296918.
+ .setContentIntent(PendingIntent
+ .getActivity(context, 0, new Intent(context.getPackageName(), null), 0))
+ .getNotification();
+ }
+
+ /**
+ * Constructs a Notification telling users the process is finished.
+ *
+ * @param context
+ * @param description Content of the Notification
+ * @param intent Intent to be launched when the Notification is clicked. Can be null.
+ */
+ /* package */ static Notification constructFinishNotification(
+ Context context, String title, String description, Intent intent) {
+ return constructFinishNotificationWithFlags(context, title, description, intent, 0);
+ }
+
+ /**
+ * @param flags use FLAG_ACTIVITY_NEW_TASK to set it as new task, to get rid of cached files.
+ */
+ /* package */ static Notification constructFinishNotificationWithFlags(
+ Context context, String title, String description, Intent intent, int flags) {
+ return new NotificationCompat.Builder(context)
+ .setAutoCancel(true)
+ .setColor(context.getResources().getColor(R.color.dialtacts_theme_color))
+ .setSmallIcon(R.drawable.ic_check_mark)
+ .setContentTitle(title)
+ .setContentText(description)
+ // If no intent provided, include an intent that won't resolve to anything.
+ // Restrict the intent to this app to make sure that no other app can steal this
+ // pending-intent b/19296918.
+ .setContentIntent(PendingIntent.getActivity(context, 0,
+ (intent != null ? intent : new Intent(context.getPackageName(), null)),
+ flags))
+ .getNotification();
+ }
+
+ /**
+ * Constructs a Notification telling the vCard import has failed.
+ *
+ * @param context
+ * @param reason The reason why the import has failed. Shown in description field.
+ */
+ /* package */ static Notification constructImportFailureNotification(
+ Context context, String reason) {
+ return new NotificationCompat.Builder(context)
+ .setAutoCancel(true)
+ .setColor(context.getResources().getColor(R.color.dialtacts_theme_color))
+ .setSmallIcon(android.R.drawable.stat_notify_error)
+ .setContentTitle(context.getString(R.string.vcard_import_failed))
+ .setContentText(reason)
+ // Launch an intent that won't resolve to anything. Restrict the intent to this
+ // app to make sure that no other app can steal this pending-intent b/19296918.
+ .setContentIntent(PendingIntent
+ .getActivity(context, 0, new Intent(context.getPackageName(), null), 0))
+ .getNotification();
+ }
+
+ @Override
+ public void onComplete() {
+ mContext.finish();
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/ProcessorBase.java b/src/com/android/contacts/common/vcard/ProcessorBase.java
new file mode 100644
index 0000000..abc859d
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/ProcessorBase.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.vcard;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.RunnableFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A base processor class. One instance processes vCard one import/export request (imports a given
+ * vCard or exports a vCard). Expected to be used with {@link ExecutorService}.
+ *
+ * This instance starts itself with {@link #run()} method, and can be cancelled with
+ * {@link #cancel(boolean)}. Users can check the processor's status using {@link #isCancelled()}
+ * and {@link #isDone()} asynchronously.
+ *
+ * {@link #get()} and {@link #get(long, TimeUnit)}, which are form {@link Future}, aren't
+ * supported and {@link UnsupportedOperationException} will be just thrown when they are called.
+ */
+public abstract class ProcessorBase implements RunnableFuture<Object> {
+
+ /**
+ * @return the type of the processor. Must be {@link VCardService#TYPE_IMPORT} or
+ * {@link VCardService#TYPE_EXPORT}.
+ */
+ public abstract int getType();
+
+ @Override
+ public abstract void run();
+
+ /**
+ * Cancels this operation.
+ *
+ * @param mayInterruptIfRunning ignored. When this method is called, the instance
+ * stops processing and finish itself even if the thread is running.
+ *
+ * @see Future#cancel(boolean)
+ */
+ @Override
+ public abstract boolean cancel(boolean mayInterruptIfRunning);
+ @Override
+ public abstract boolean isCancelled();
+ @Override
+ public abstract boolean isDone();
+
+ /**
+ * Just throws {@link UnsupportedOperationException}.
+ */
+ @Override
+ public final Object get() {
+ throw new UnsupportedOperationException();
+ }
+
+ /**
+ * Just throws {@link UnsupportedOperationException}.
+ */
+ @Override
+ public final Object get(long timeout, TimeUnit unit) {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/SelectAccountActivity.java b/src/com/android/contacts/common/vcard/SelectAccountActivity.java
new file mode 100644
index 0000000..387f3fb
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/SelectAccountActivity.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.vcard;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.util.AccountSelectionUtil;
+
+import java.util.List;
+
+public class SelectAccountActivity extends Activity {
+ private static final String LOG_TAG = "SelectAccountActivity";
+
+ public static final String ACCOUNT_NAME = "account_name";
+ public static final String ACCOUNT_TYPE = "account_type";
+ public static final String DATA_SET = "data_set";
+
+ private class CancelListener
+ implements DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
+ public void onClick(DialogInterface dialog, int which) {
+ finish();
+ }
+ public void onCancel(DialogInterface dialog) {
+ finish();
+ }
+ }
+
+ private AccountSelectionUtil.AccountSelectedListener mAccountSelectionListener;
+
+ @Override
+ protected void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+
+ // There's three possibilities:
+ // - more than one accounts -> ask the user
+ // - just one account -> use the account without asking the user
+ // - no account -> use phone-local storage without asking the user
+ final int resId = R.string.import_from_vcf_file;
+ final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this);
+ final List<AccountWithDataSet> accountList = accountTypes.getAccounts(true);
+ if (accountList.size() == 0) {
+ Log.w(LOG_TAG, "Account does not exist");
+ finish();
+ return;
+ } else if (accountList.size() == 1) {
+ final AccountWithDataSet account = accountList.get(0);
+ final Intent intent = new Intent();
+ intent.putExtra(ACCOUNT_NAME, account.name);
+ intent.putExtra(ACCOUNT_TYPE, account.type);
+ intent.putExtra(DATA_SET, account.dataSet);
+ setResult(RESULT_OK, intent);
+ finish();
+ return;
+ }
+
+ Log.i(LOG_TAG, "The number of available accounts: " + accountList.size());
+
+ // Multiple accounts. Let users to select one.
+ mAccountSelectionListener =
+ new AccountSelectionUtil.AccountSelectedListener(
+ this, accountList, resId) {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.dismiss();
+ final AccountWithDataSet account = mAccountList.get(which);
+ final Intent intent = new Intent();
+ intent.putExtra(ACCOUNT_NAME, account.name);
+ intent.putExtra(ACCOUNT_TYPE, account.type);
+ intent.putExtra(DATA_SET, account.dataSet);
+ setResult(RESULT_OK, intent);
+ finish();
+ }
+ };
+ showDialog(resId);
+ return;
+ }
+
+ @Override
+ protected Dialog onCreateDialog(int resId, Bundle bundle) {
+ if (resId == R.string.import_from_vcf_file) {
+ if (mAccountSelectionListener == null) {
+ throw new NullPointerException(
+ "mAccountSelectionListener must not be null.");
+ }
+ return AccountSelectionUtil.getSelectAccountDialog(this, resId,
+ mAccountSelectionListener,
+ new CancelListener());
+ }
+ return super.onCreateDialog(resId, bundle);
+ }
+}
diff --git a/src/com/android/contacts/common/vcard/ShareVCardActivity.java b/src/com/android/contacts/common/vcard/ShareVCardActivity.java
new file mode 100644
index 0000000..93868aa
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/ShareVCardActivity.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.vcard;
+
+import android.content.ComponentName;
+import android.net.Uri;
+import android.os.IBinder;
+import android.support.v4.content.FileProvider;
+import android.util.Log;
+
+import com.android.contacts.common.R;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * This activity connects to VCardService, creates a .vcf file in cache directory and send export
+ * request with the file URI so as to write contacts data to the file in background.
+ */
+public class ShareVCardActivity extends ExportVCardActivity {
+ private static final String LOG_TAG = "VCardShare";
+ private final String EXPORT_FILE_PREFIX = "vcards_";
+ private final long A_DAY_IN_MILLIS = 1000 * 60 * 60 * 24;
+
+ @Override
+ public synchronized void onServiceConnected(ComponentName name, IBinder binder) {
+ if (DEBUG) Log.d(LOG_TAG, "connected to service, requesting a destination file name");
+ mConnected = true;
+ mService = ((VCardService.MyBinder) binder).getService();
+
+ clearExportFiles();
+
+ final File file = getLocalFile();
+ try {
+ file.createNewFile();
+ } catch (IOException e) {
+ Log.e(LOG_TAG, "Failed to create .vcf file, because: " + e);
+ finish();
+ return;
+ }
+
+ final Uri contentUri = FileProvider.getUriForFile(this,
+ getString(R.string.contacts_file_provider_authority), file);
+ if (DEBUG) Log.d(LOG_TAG, "exporting to " + contentUri);
+
+ final ExportRequest request = new ExportRequest(contentUri);
+ // The connection object will call finish().
+ mService.handleExportRequest(request, new NotificationImportExportListener(
+ ShareVCardActivity.this));
+ finish();
+ }
+
+ /**
+ * Delete the files (that are untouched for more than 1 day) in the cache directory.
+ * We cannot rely on VCardService to delete export files because it will delete export files
+ * right after finishing writing so no files could be shared. Therefore, our approach to
+ * deleting export files is:
+ * 1. put export files in cache directory so that Android may delete them;
+ * 2. manually delete the files that are older than 1 day when service is connected.
+ */
+ private void clearExportFiles() {
+ for (File file : getCacheDir().listFiles()) {
+ final long ageInMillis = System.currentTimeMillis() - file.lastModified();
+ if (file.getName().startsWith(EXPORT_FILE_PREFIX) && ageInMillis > A_DAY_IN_MILLIS) {
+ file.delete();
+ }
+ }
+ }
+
+ private File getLocalFile() {
+ final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
+ final String currentDateString = dateFormat.format(new Date()).toString();
+ final String localFilename = EXPORT_FILE_PREFIX + currentDateString + ".vcf";
+ return new File(getCacheDir(), localFilename);
+ }
+}
\ No newline at end of file
diff --git a/src/com/android/contacts/common/vcard/VCardCommonArguments.java b/src/com/android/contacts/common/vcard/VCardCommonArguments.java
new file mode 100644
index 0000000..c423ca3
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/VCardCommonArguments.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.vcard;
+
+/**
+ * Argument constants used by many activities and services.
+ */
+public class VCardCommonArguments {
+
+ // Argument used to pass calling activities to the target activity or service.
+ // The value should be a string class name (e.g. com.android.contacts.vcard.VCardCommonArgs)
+ public static final String ARG_CALLING_ACTIVITY = "CALLING_ACTIVITY";
+}
diff --git a/src/com/android/contacts/common/vcard/VCardImportExportListener.java b/src/com/android/contacts/common/vcard/VCardImportExportListener.java
new file mode 100644
index 0000000..e4e4893
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/VCardImportExportListener.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.vcard;
+
+import android.net.Uri;
+
+import com.android.vcard.VCardEntry;
+
+interface VCardImportExportListener {
+ void onImportProcessed(ImportRequest request, int jobId, int sequence);
+ void onImportParsed(ImportRequest request, int jobId, VCardEntry entry, int currentCount,
+ int totalCount);
+ void onImportFinished(ImportRequest request, int jobId, Uri uri);
+ void onImportFailed(ImportRequest request);
+ void onImportCanceled(ImportRequest request, int jobId);
+
+ void onExportProcessed(ExportRequest request, int jobId);
+ void onExportFailed(ExportRequest request);
+
+ void onCancelRequest(CancelRequest request, int type);
+ void onComplete();
+}
diff --git a/src/com/android/contacts/common/vcard/VCardService.java b/src/com/android/contacts/common/vcard/VCardService.java
new file mode 100644
index 0000000..1d7837b
--- /dev/null
+++ b/src/com/android/contacts/common/vcard/VCardService.java
@@ -0,0 +1,397 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.vcard;
+
+import android.app.Service;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.media.MediaScannerConnection;
+import android.media.MediaScannerConnection.MediaScannerConnectionClient;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.Environment;
+import android.os.IBinder;
+import android.os.Message;
+import android.os.Messenger;
+import android.os.RemoteException;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.SparseArray;
+
+import com.android.contacts.common.R;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+
+/**
+ * The class responsible for handling vCard import/export requests.
+ *
+ * This Service creates one ImportRequest/ExportRequest object (as Runnable) per request and push
+ * it to {@link ExecutorService} with single thread executor. The executor handles each request
+ * one by one, and notifies users when needed.
+ */
+// TODO: Using IntentService looks simpler than using Service + ServiceConnection though this
+// works fine enough. Investigate the feasibility.
+public class VCardService extends Service {
+ private final static String LOG_TAG = "VCardService";
+
+ /* package */ final static boolean DEBUG = false;
+
+ /**
+ * Specifies the type of operation. Used when constructing a notification, canceling
+ * some operation, etc.
+ */
+ /* package */ static final int TYPE_IMPORT = 1;
+ /* package */ static final int TYPE_EXPORT = 2;
+
+ /* package */ static final String CACHE_FILE_PREFIX = "import_tmp_";
+
+ /* package */ static final String X_VCARD_MIME_TYPE = "text/x-vcard";
+
+ private class CustomMediaScannerConnectionClient implements MediaScannerConnectionClient {
+ final MediaScannerConnection mConnection;
+ final String mPath;
+
+ public CustomMediaScannerConnectionClient(String path) {
+ mConnection = new MediaScannerConnection(VCardService.this, this);
+ mPath = path;
+ }
+
+ public void start() {
+ mConnection.connect();
+ }
+
+ @Override
+ public void onMediaScannerConnected() {
+ if (DEBUG) { Log.d(LOG_TAG, "Connected to MediaScanner. Start scanning."); }
+ mConnection.scanFile(mPath, null);
+ }
+
+ @Override
+ public void onScanCompleted(String path, Uri uri) {
+ if (DEBUG) { Log.d(LOG_TAG, "scan completed: " + path); }
+ mConnection.disconnect();
+ removeConnectionClient(this);
+ }
+ }
+
+ // Should be single thread, as we don't want to simultaneously handle import and export
+ // requests.
+ private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor();
+
+ private int mCurrentJobId;
+
+ // Stores all unfinished import/export jobs which will be executed by mExecutorService.
+ // Key is jobId.
+ private final SparseArray<ProcessorBase> mRunningJobMap = new SparseArray<ProcessorBase>();
+ // Stores ScannerConnectionClient objects until they finish scanning requested files.
+ // Uses List class for simplicity. It's not costly as we won't have multiple objects in
+ // almost all cases.
+ private final List<CustomMediaScannerConnectionClient> mRemainingScannerConnections =
+ new ArrayList<CustomMediaScannerConnectionClient>();
+
+ private MyBinder mBinder;
+
+ private String mCallingActivity;
+
+ // File names currently reserved by some export job.
+ private final Set<String> mReservedDestination = new HashSet<String>();
+ /* ** end of vCard exporter params ** */
+
+ public class MyBinder extends Binder {
+ public VCardService getService() {
+ return VCardService.this;
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ mBinder = new MyBinder();
+ if (DEBUG) Log.d(LOG_TAG, "vCard Service is being created.");
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int id) {
+ if (intent != null && intent.getExtras() != null) {
+ mCallingActivity = intent.getExtras().getString(
+ VCardCommonArguments.ARG_CALLING_ACTIVITY);
+ } else {
+ mCallingActivity = null;
+ }
+ return START_STICKY;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return mBinder;
+ }
+
+ @Override
+ public void onDestroy() {
+ if (DEBUG) Log.d(LOG_TAG, "VCardService is being destroyed.");
+ cancelAllRequestsAndShutdown();
+ clearCache();
+ super.onDestroy();
+ }
+
+ public synchronized void handleImportRequest(List<ImportRequest> requests,
+ VCardImportExportListener listener) {
+ if (DEBUG) {
+ final ArrayList<String> uris = new ArrayList<String>();
+ final ArrayList<String> displayNames = new ArrayList<String>();
+ for (ImportRequest request : requests) {
+ uris.add(request.uri.toString());
+ displayNames.add(request.displayName);
+ }
+ Log.d(LOG_TAG,
+ String.format("received multiple import request (uri: %s, displayName: %s)",
+ uris.toString(), displayNames.toString()));
+ }
+ final int size = requests.size();
+ for (int i = 0; i < size; i++) {
+ ImportRequest request = requests.get(i);
+
+ if (tryExecute(new ImportProcessor(this, listener, request, mCurrentJobId))) {
+ if (listener != null) {
+ listener.onImportProcessed(request, mCurrentJobId, i);
+ }
+ mCurrentJobId++;
+ } else {
+ if (listener != null) {
+ listener.onImportFailed(request);
+ }
+ // A rejection means executor doesn't run any more. Exit.
+ break;
+ }
+ }
+ }
+
+ public synchronized void handleExportRequest(ExportRequest request,
+ VCardImportExportListener listener) {
+ if (tryExecute(new ExportProcessor(this, request, mCurrentJobId, mCallingActivity))) {
+ final String path = request.destUri.getEncodedPath();
+ if (DEBUG) Log.d(LOG_TAG, "Reserve the path " + path);
+ if (!mReservedDestination.add(path)) {
+ Log.w(LOG_TAG,
+ String.format("The path %s is already reserved. Reject export request",
+ path));
+ if (listener != null) {
+ listener.onExportFailed(request);
+ }
+ return;
+ }
+
+ if (listener != null) {
+ listener.onExportProcessed(request, mCurrentJobId);
+ }
+ mCurrentJobId++;
+ } else {
+ if (listener != null) {
+ listener.onExportFailed(request);
+ }
+ }
+ }
+
+ /**
+ * Tries to call {@link ExecutorService#execute(Runnable)} toward a given processor.
+ * @return true when successful.
+ */
+ private synchronized boolean tryExecute(ProcessorBase processor) {
+ try {
+ if (DEBUG) {
+ Log.d(LOG_TAG, "Executor service status: shutdown: " + mExecutorService.isShutdown()
+ + ", terminated: " + mExecutorService.isTerminated());
+ }
+ mExecutorService.execute(processor);
+ mRunningJobMap.put(mCurrentJobId, processor);
+ return true;
+ } catch (RejectedExecutionException e) {
+ Log.w(LOG_TAG, "Failed to excetute a job.", e);
+ return false;
+ }
+ }
+
+ public synchronized void handleCancelRequest(CancelRequest request,
+ VCardImportExportListener listener) {
+ final int jobId = request.jobId;
+ if (DEBUG) Log.d(LOG_TAG, String.format("Received cancel request. (id: %d)", jobId));
+
+ final ProcessorBase processor = mRunningJobMap.get(jobId);
+ mRunningJobMap.remove(jobId);
+
+ if (processor != null) {
+ processor.cancel(true);
+ final int type = processor.getType();
+ if (listener != null) {
+ listener.onCancelRequest(request, type);
+ }
+ if (type == TYPE_EXPORT) {
+ final String path =
+ ((ExportProcessor)processor).getRequest().destUri.getEncodedPath();
+ Log.i(LOG_TAG,
+ String.format("Cancel reservation for the path %s if appropriate", path));
+ if (!mReservedDestination.remove(path)) {
+ Log.w(LOG_TAG, "Not reserved.");
+ }
+ }
+ } else {
+ Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId));
+ }
+ stopServiceIfAppropriate();
+ }
+
+ /**
+ * Checks job list and call {@link #stopSelf()} when there's no job and no scanner connection
+ * is remaining.
+ * A new job (import/export) cannot be submitted any more after this call.
+ */
+ private synchronized void stopServiceIfAppropriate() {
+ if (mRunningJobMap.size() > 0) {
+ final int size = mRunningJobMap.size();
+
+ // Check if there are processors which aren't finished yet. If we still have ones to
+ // process, we cannot stop the service yet. Also clean up already finished processors
+ // here.
+
+ // Job-ids to be removed. At first all elements in the array are invalid and will
+ // be filled with real job-ids from the array's top. When we find a not-yet-finished
+ // processor, then we start removing those finished jobs. In that case latter half of
+ // this array will be invalid.
+ final int[] toBeRemoved = new int[size];
+ for (int i = 0; i < size; i++) {
+ final int jobId = mRunningJobMap.keyAt(i);
+ final ProcessorBase processor = mRunningJobMap.valueAt(i);
+ if (!processor.isDone()) {
+ Log.i(LOG_TAG, String.format("Found unfinished job (id: %d)", jobId));
+
+ // Remove processors which are already "done", all of which should be before
+ // processors which aren't done yet.
+ for (int j = 0; j < i; j++) {
+ mRunningJobMap.remove(toBeRemoved[j]);
+ }
+ return;
+ }
+
+ // Remember the finished processor.
+ toBeRemoved[i] = jobId;
+ }
+
+ // We're sure we can remove all. Instead of removing one by one, just call clear().
+ mRunningJobMap.clear();
+ }
+
+ if (!mRemainingScannerConnections.isEmpty()) {
+ Log.i(LOG_TAG, "MediaScanner update is in progress.");
+ return;
+ }
+
+ Log.i(LOG_TAG, "No unfinished job. Stop this service.");
+ mExecutorService.shutdown();
+ stopSelf();
+ }
+
+ /* package */ synchronized void updateMediaScanner(String path) {
+ if (DEBUG) {
+ Log.d(LOG_TAG, "MediaScanner is being updated: " + path);
+ }
+
+ if (mExecutorService.isShutdown()) {
+ Log.w(LOG_TAG, "MediaScanner update is requested after executor's being shut down. " +
+ "Ignoring the update request");
+ return;
+ }
+ final CustomMediaScannerConnectionClient client =
+ new CustomMediaScannerConnectionClient(path);
+ mRemainingScannerConnections.add(client);
+ client.start();
+ }
+
+ private synchronized void removeConnectionClient(
+ CustomMediaScannerConnectionClient client) {
+ if (DEBUG) {
+ Log.d(LOG_TAG, "Removing custom MediaScannerConnectionClient.");
+ }
+ mRemainingScannerConnections.remove(client);
+ stopServiceIfAppropriate();
+ }
+
+ /* package */ synchronized void handleFinishImportNotification(
+ int jobId, boolean successful) {
+ if (DEBUG) {
+ Log.d(LOG_TAG, String.format("Received vCard import finish notification (id: %d). "
+ + "Result: %b", jobId, (successful ? "success" : "failure")));
+ }
+ mRunningJobMap.remove(jobId);
+ stopServiceIfAppropriate();
+ }
+
+ /* package */ synchronized void handleFinishExportNotification(
+ int jobId, boolean successful) {
+ if (DEBUG) {
+ Log.d(LOG_TAG, String.format("Received vCard export finish notification (id: %d). "
+ + "Result: %b", jobId, (successful ? "success" : "failure")));
+ }
+ final ProcessorBase job = mRunningJobMap.get(jobId);
+ mRunningJobMap.remove(jobId);
+ if (job == null) {
+ Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId));
+ } else if (!(job instanceof ExportProcessor)) {
+ Log.w(LOG_TAG,
+ String.format("Removed job (id: %s) isn't ExportProcessor", jobId));
+ } else {
+ final String path = ((ExportProcessor)job).getRequest().destUri.getEncodedPath();
+ if (DEBUG) Log.d(LOG_TAG, "Remove reserved path " + path);
+ mReservedDestination.remove(path);
+ }
+
+ stopServiceIfAppropriate();
+ }
+
+ /**
+ * Cancels all the import/export requests and calls {@link ExecutorService#shutdown()}, which
+ * means this Service becomes no longer ready for import/export requests.
+ *
+ * Mainly called from onDestroy().
+ */
+ private synchronized void cancelAllRequestsAndShutdown() {
+ for (int i = 0; i < mRunningJobMap.size(); i++) {
+ mRunningJobMap.valueAt(i).cancel(true);
+ }
+ mRunningJobMap.clear();
+ mExecutorService.shutdown();
+ }
+
+ /**
+ * Removes import caches stored locally.
+ */
+ private void clearCache() {
+ for (final String fileName : fileList()) {
+ if (fileName.startsWith(CACHE_FILE_PREFIX)) {
+ // We don't want to keep all the caches so we remove cache files old enough.
+ Log.i(LOG_TAG, "Remove a temporary file: " + fileName);
+ deleteFile(fileName);
+ }
+ }
+ }
+}
diff --git a/src/com/android/contacts/common/widget/ActivityTouchLinearLayout.java b/src/com/android/contacts/common/widget/ActivityTouchLinearLayout.java
new file mode 100644
index 0000000..d81526e
--- /dev/null
+++ b/src/com/android/contacts/common/widget/ActivityTouchLinearLayout.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.widget;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.widget.LinearLayout;
+
+import com.android.contacts.common.interactions.TouchPointManager;
+
+/**
+ * Linear layout for an activity that listens to all touch events on the screen and saves the touch
+ * point.
+ * Typically touch events are handled by child views--this class intercepts those touch events
+ * before passing them on to the child.
+ */
+public class ActivityTouchLinearLayout extends LinearLayout {
+ public ActivityTouchLinearLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent (MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN) {
+ TouchPointManager.getInstance().setPoint((int) ev.getRawX(), (int) ev.getRawY());
+ }
+ return false;
+ }
+}
diff --git a/src/com/android/contacts/common/widget/FloatingActionButtonController.java b/src/com/android/contacts/common/widget/FloatingActionButtonController.java
new file mode 100644
index 0000000..0e94df1
--- /dev/null
+++ b/src/com/android/contacts/common/widget/FloatingActionButtonController.java
@@ -0,0 +1,229 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.widget;
+
+import android.app.Activity;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.view.animation.AnimationUtils;
+import android.view.animation.Interpolator;
+import android.view.View;
+import android.widget.ImageButton;
+
+import com.android.contacts.common.util.ViewUtil;
+import com.android.contacts.common.R;
+import com.android.phone.common.animation.AnimUtils;
+
+/**
+ * Controls the movement and appearance of the FAB (Floating Action Button).
+ */
+public class FloatingActionButtonController {
+ public static final int ALIGN_MIDDLE = 0;
+ public static final int ALIGN_QUARTER_END = 1;
+ public static final int ALIGN_END = 2;
+
+ private static final int FAB_SCALE_IN_DURATION = 186;
+ private static final int FAB_SCALE_IN_FADE_IN_DELAY = 70;
+ private static final int FAB_ICON_FADE_OUT_DURATION = 46;
+
+ private final int mAnimationDuration;
+ private final int mFloatingActionButtonWidth;
+ private final int mFloatingActionButtonMarginRight;
+ private final View mFloatingActionButtonContainer;
+ private final ImageButton mFloatingActionButton;
+ private final Interpolator mFabInterpolator;
+ private int mScreenWidth;
+
+ public FloatingActionButtonController(Activity activity, View container, ImageButton button) {
+ Resources resources = activity.getResources();
+ mFabInterpolator = AnimationUtils.loadInterpolator(activity,
+ android.R.interpolator.fast_out_slow_in);
+ mFloatingActionButtonWidth = resources.getDimensionPixelSize(
+ R.dimen.floating_action_button_width);
+ mFloatingActionButtonMarginRight = resources.getDimensionPixelOffset(
+ R.dimen.floating_action_button_margin_right);
+ mAnimationDuration = resources.getInteger(
+ R.integer.floating_action_button_animation_duration);
+ mFloatingActionButtonContainer = container;
+ mFloatingActionButton = button;
+ ViewUtil.setupFloatingActionButton(mFloatingActionButtonContainer, resources);
+ }
+
+ /**
+ * Passes the screen width into the class. Necessary for translation calculations.
+ * Should be called as soon as parent View width is available.
+ *
+ * @param screenWidth The width of the screen in pixels.
+ */
+ public void setScreenWidth(int screenWidth) {
+ mScreenWidth = screenWidth;
+ }
+
+ /**
+ * Sets FAB as View.VISIBLE or View.GONE.
+ *
+ * @param visible Whether or not to make the container visible.
+ */
+ public void setVisible(boolean visible) {
+ mFloatingActionButtonContainer.setVisibility(visible ? View.VISIBLE : View.GONE);
+ }
+
+ public boolean isVisible() {
+ return mFloatingActionButtonContainer.getVisibility() == View.VISIBLE;
+ }
+
+ public void changeIcon(Drawable icon, String description) {
+ if (mFloatingActionButton.getDrawable() != icon
+ || !mFloatingActionButton.getContentDescription().equals(description)) {
+ mFloatingActionButton.setImageDrawable(icon);
+ mFloatingActionButton.setContentDescription(description);
+ }
+ }
+
+ /**
+ * Updates the FAB location (middle to right position) as the PageView scrolls.
+ *
+ * @param positionOffset A fraction used to calculate position of the FAB during page scroll.
+ */
+ public void onPageScrolled(float positionOffset) {
+ // As the page is scrolling, if we're on the first tab, update the FAB position so it
+ // moves along with it.
+ mFloatingActionButtonContainer.setTranslationX(
+ (int) (positionOffset * getTranslationXForAlignment(ALIGN_END)));
+ }
+
+ /**
+ * Aligns the FAB to the described location
+ *
+ * @param align One of ALIGN_MIDDLE, ALIGN_QUARTER_RIGHT, or ALIGN_RIGHT.
+ * @param animate Whether or not to animate the transition.
+ */
+ public void align(int align, boolean animate) {
+ align(align, 0 /*offsetX */, 0 /* offsetY */, animate);
+ }
+
+ /**
+ * Aligns the FAB to the described location plus specified additional offsets.
+ *
+ * @param align One of ALIGN_MIDDLE, ALIGN_QUARTER_RIGHT, or ALIGN_RIGHT.
+ * @param offsetX Additional offsetX to translate by.
+ * @param offsetY Additional offsetY to translate by.
+ * @param animate Whether or not to animate the transition.
+ */
+ public void align(int align, int offsetX, int offsetY, boolean animate) {
+ if (mScreenWidth == 0) {
+ return;
+ }
+
+ int translationX = getTranslationXForAlignment(align);
+
+ // Skip animation if container is not shown; animation causes container to show again.
+ if (animate && mFloatingActionButtonContainer.isShown()) {
+ mFloatingActionButtonContainer.animate()
+ .translationX(translationX + offsetX)
+ .translationY(offsetY)
+ .setInterpolator(mFabInterpolator)
+ .setDuration(mAnimationDuration)
+ .start();
+ } else {
+ mFloatingActionButtonContainer.setTranslationX(translationX + offsetX);
+ mFloatingActionButtonContainer.setTranslationY(offsetY);
+ }
+ }
+
+ /**
+ * Resizes width and height of the floating action bar container.
+ * @param dimension The new dimensions for the width and height.
+ * @param animate Whether to animate this change.
+ */
+ public void resize(int dimension, boolean animate) {
+ if (animate) {
+ AnimUtils.changeDimensions(mFloatingActionButtonContainer, dimension, dimension);
+ } else {
+ mFloatingActionButtonContainer.getLayoutParams().width = dimension;
+ mFloatingActionButtonContainer.getLayoutParams().height = dimension;
+ mFloatingActionButtonContainer.requestLayout();
+ }
+ }
+
+ /**
+ * Scales the floating action button from no height and width to its actual dimensions. This is
+ * an animation for showing the floating action button.
+ * @param delayMs The delay for the effect, in milliseconds.
+ */
+ public void scaleIn(int delayMs) {
+ setVisible(true);
+ AnimUtils.scaleIn(mFloatingActionButtonContainer, FAB_SCALE_IN_DURATION, delayMs);
+ AnimUtils.fadeIn(mFloatingActionButton, FAB_SCALE_IN_DURATION,
+ delayMs + FAB_SCALE_IN_FADE_IN_DELAY, null);
+ }
+
+ /**
+ * Immediately remove the affects of the last call to {@link #scaleOut}.
+ */
+ public void resetIn() {
+ mFloatingActionButton.setAlpha(1f);
+ mFloatingActionButton.setVisibility(View.VISIBLE);
+ mFloatingActionButtonContainer.setScaleX(1);
+ mFloatingActionButtonContainer.setScaleY(1);
+ }
+
+ /**
+ * Scales the floating action button from its actual dimensions to no height and width. This is
+ * an animation for hiding the floating action button.
+ */
+ public void scaleOut() {
+ AnimUtils.scaleOut(mFloatingActionButtonContainer, mAnimationDuration);
+ // Fade out the icon faster than the scale out animation, so that the icon scaling is less
+ // obvious. We don't want it to scale, but the resizing the container is not as performant.
+ AnimUtils.fadeOut(mFloatingActionButton, FAB_ICON_FADE_OUT_DURATION, null);
+ }
+
+ /**
+ * Calculates the X offset of the FAB to the given alignment, adjusted for whether or not the
+ * view is in RTL mode.
+ *
+ * @param align One of ALIGN_MIDDLE, ALIGN_QUARTER_RIGHT, or ALIGN_RIGHT.
+ * @return The translationX for the given alignment.
+ */
+ public int getTranslationXForAlignment(int align) {
+ int result = 0;
+ switch (align) {
+ case ALIGN_MIDDLE:
+ // Moves the FAB to exactly center screen.
+ return 0;
+ case ALIGN_QUARTER_END:
+ // Moves the FAB a quarter of the screen width.
+ result = mScreenWidth / 4;
+ break;
+ case ALIGN_END:
+ // Moves the FAB half the screen width. Same as aligning right with a marginRight.
+ result = mScreenWidth / 2
+ - mFloatingActionButtonWidth / 2
+ - mFloatingActionButtonMarginRight;
+ break;
+ }
+ if (isLayoutRtl()) {
+ result *= -1;
+ }
+ return result;
+ }
+
+ private boolean isLayoutRtl() {
+ return mFloatingActionButtonContainer.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
+ }
+}
diff --git a/src/com/android/contacts/common/widget/LayoutSuppressingImageView.java b/src/com/android/contacts/common/widget/LayoutSuppressingImageView.java
new file mode 100644
index 0000000..abcf786
--- /dev/null
+++ b/src/com/android/contacts/common/widget/LayoutSuppressingImageView.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.widget;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.ImageView;
+
+/**
+ * Custom {@link ImageView} that improves layouting performance.
+ *
+ * This improves the performance by not passing requestLayout() to its parent, taking advantage
+ * of knowing that image size won't change once set.
+ */
+public class LayoutSuppressingImageView extends ImageView {
+
+ public LayoutSuppressingImageView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public void requestLayout() {
+ forceLayout();
+ }
+}
diff --git a/src/com/android/contacts/common/widget/LayoutSuppressingQuickContactBadge.java b/src/com/android/contacts/common/widget/LayoutSuppressingQuickContactBadge.java
new file mode 100644
index 0000000..1f48f5d
--- /dev/null
+++ b/src/com/android/contacts/common/widget/LayoutSuppressingQuickContactBadge.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.widget;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.QuickContactBadge;
+
+/**
+ * Custom {@link QuickContactBadge} that improves layouting performance.
+ *
+ * This improves the performance by not passing requestLayout() to its parent, taking advantage
+ * of knowing that image size won't change once set.
+ */
+public class LayoutSuppressingQuickContactBadge extends QuickContactBadge {
+
+ public LayoutSuppressingQuickContactBadge(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ public void requestLayout() {
+ forceLayout();
+ }
+}
diff --git a/src/com/android/contacts/common/widget/ProportionalLayout.java b/src/com/android/contacts/common/widget/ProportionalLayout.java
new file mode 100644
index 0000000..5a5ac29
--- /dev/null
+++ b/src/com/android/contacts/common/widget/ProportionalLayout.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.widget;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.contacts.common.R;
+
+/**
+ * Layout that calculates its height based on its width, or vice versa (depending on the set
+ * {@link #setDirection(Direction)}. The factor is specified in {@link #setRatio(float)}.
+ * <p>For {@link Direction#heightToWidth}: width := height * factor</p>
+ * <p>For {@link Direction#widthToHeight}: height := width * factor</p>
+ * <p>Only one child is allowed; if more are required, another ViewGroup can be used as the direct
+ * child of this layout.</p>
+ */
+public class ProportionalLayout extends ViewGroup {
+ /** Specifies whether the width should be calculated based on the height or vice-versa */
+ public enum Direction {
+ widthToHeight("widthToHeight"),
+ heightToWidth("heightToWidth");
+
+ public final String XmlName;
+
+ private Direction(String xmlName) {
+ XmlName = xmlName;
+ }
+
+ /**
+ * Parses the given direction string and returns the Direction instance. This
+ * should be used when inflating from xml
+ */
+ public static Direction parse(String value) {
+ if (widthToHeight.XmlName.equals(value)) {
+ return Direction.widthToHeight;
+ } else if (heightToWidth.XmlName.equals(value)) {
+ return Direction.heightToWidth;
+ } else {
+ throw new IllegalStateException("direction must be either " +
+ widthToHeight.XmlName + " or " + heightToWidth.XmlName);
+ }
+ }
+ }
+
+ private Direction mDirection;
+ private float mRatio;
+
+ public ProportionalLayout(Context context) {
+ super(context);
+ }
+
+ public ProportionalLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initFromAttributes(context, attrs);
+ }
+
+ public ProportionalLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ initFromAttributes(context, attrs);
+ }
+
+ private void initFromAttributes(Context context, AttributeSet attrs) {
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ProportionalLayout);
+
+ mDirection = Direction.parse(a.getString(R.styleable.ProportionalLayout_direction));
+ mRatio = a.getFloat(R.styleable.ProportionalLayout_ratio, 1.0f);
+
+ a.recycle();
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ if (getChildCount() != 1) {
+ throw new IllegalStateException("ProportionalLayout requires exactly one child");
+ }
+
+ final View child = getChildAt(0);
+
+ // Do a first pass to get the optimal size
+ measureChild(child, widthMeasureSpec, heightMeasureSpec);
+ final int childWidth = child.getMeasuredWidth();
+ final int childHeight = child.getMeasuredHeight();
+
+ final int width;
+ final int height;
+ if (mDirection == Direction.heightToWidth) {
+ width = Math.round(childHeight * mRatio);
+ height = childHeight;
+ } else {
+ width = childWidth;
+ height = Math.round(childWidth * mRatio);
+ }
+
+ // Do a second pass so that all children are informed of the new size
+ measureChild(child,
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
+
+ setMeasuredDimension(
+ resolveSize(width, widthMeasureSpec), resolveSize(height, heightMeasureSpec));
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ if (getChildCount() != 1) {
+ throw new IllegalStateException("ProportionalLayout requires exactly one child");
+ }
+
+ final View child = getChildAt(0);
+ child.layout(0, 0, right-left, bottom-top);
+ }
+
+ public Direction getDirection() {
+ return mDirection;
+ }
+
+ public void setDirection(Direction direction) {
+ mDirection = direction;
+ }
+
+ public float getRatio() {
+ return mRatio;
+ }
+
+ public void setRatio(float ratio) {
+ mRatio = ratio;
+ }
+}
diff --git a/src/com/android/contacts/common/widget/SelectPhoneAccountDialogFragment.java b/src/com/android/contacts/common/widget/SelectPhoneAccountDialogFragment.java
new file mode 100644
index 0000000..709ce41
--- /dev/null
+++ b/src/com/android/contacts/common/widget/SelectPhoneAccountDialogFragment.java
@@ -0,0 +1,281 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.widget;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.graphics.drawable.Icon;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.ResultReceiver;
+import android.telecom.PhoneAccount;
+import android.telecom.PhoneAccountHandle;
+import android.telecom.TelecomManager;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ListAdapter;
+import android.widget.TextView;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.compat.PhoneAccountCompat;
+import com.android.contacts.common.compat.PhoneNumberUtilsCompat;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Dialog that allows the user to select a phone accounts for a given action. Optionally provides
+ * the choice to set the phone account as default.
+ */
+public class SelectPhoneAccountDialogFragment extends DialogFragment {
+ private static final String ARG_TITLE_RES_ID = "title_res_id";
+ private static final String ARG_CAN_SET_DEFAULT = "can_set_default";
+ private static final String ARG_ACCOUNT_HANDLES = "account_handles";
+ private static final String ARG_IS_DEFAULT_CHECKED = "is_default_checked";
+ private static final String ARG_LISTENER = "listener";
+
+ private int mTitleResId;
+ private boolean mCanSetDefault;
+ private List<PhoneAccountHandle> mAccountHandles;
+ private boolean mIsSelected;
+ private boolean mIsDefaultChecked;
+ private TelecomManager mTelecomManager;
+ private SelectPhoneAccountListener mListener;
+
+ /**
+ * Create new fragment instance with default title and no option to set as default.
+ *
+ * @param accountHandles The {@code PhoneAccountHandle}s available to select from.
+ * @param listener The listener for the results of the account selection.
+ */
+ public static SelectPhoneAccountDialogFragment newInstance(
+ List<PhoneAccountHandle> accountHandles, SelectPhoneAccountListener listener) {
+ return newInstance(R.string.select_account_dialog_title, false,
+ accountHandles, listener);
+ }
+
+ /**
+ * Create new fragment instance.
+ * This method also allows specifying a custom title and "set default" checkbox.
+ *
+ * @param titleResId The resource ID for the string to use in the title of the dialog.
+ * @param canSetDefault {@code true} if the dialog should include an option to set the selection
+ * as the default. False otherwise.
+ * @param accountHandles The {@code PhoneAccountHandle}s available to select from.
+ * @param listener The listener for the results of the account selection.
+ */
+ public static SelectPhoneAccountDialogFragment newInstance(int titleResId,
+ boolean canSetDefault, List<PhoneAccountHandle> accountHandles,
+ SelectPhoneAccountListener listener) {
+ ArrayList<PhoneAccountHandle> accountHandlesCopy = new ArrayList<PhoneAccountHandle>();
+ if (accountHandles != null) {
+ accountHandlesCopy.addAll(accountHandles);
+ }
+ SelectPhoneAccountDialogFragment fragment = new SelectPhoneAccountDialogFragment();
+ final Bundle args = new Bundle();
+ args.putInt(ARG_TITLE_RES_ID, titleResId);
+ args.putBoolean(ARG_CAN_SET_DEFAULT, canSetDefault);
+ args.putParcelableArrayList(ARG_ACCOUNT_HANDLES, accountHandlesCopy);
+ args.putParcelable(ARG_LISTENER, listener);
+ fragment.setArguments(args);
+ fragment.setListener(listener);
+ return fragment;
+ }
+
+ public SelectPhoneAccountDialogFragment() {
+ }
+
+ public void setListener(SelectPhoneAccountListener listener) {
+ mListener = listener;
+ }
+
+ public static class SelectPhoneAccountListener extends ResultReceiver {
+ static final int RESULT_SELECTED = 1;
+ static final int RESULT_DISMISSED = 2;
+
+ static final String EXTRA_SELECTED_ACCOUNT_HANDLE = "extra_selected_account_handle";
+ static final String EXTRA_SET_DEFAULT = "extra_set_default";
+
+ public SelectPhoneAccountListener() {
+ super(new Handler());
+ }
+
+ @Override
+ protected void onReceiveResult(int resultCode, Bundle resultData) {
+ if (resultCode == RESULT_SELECTED) {
+ onPhoneAccountSelected(
+ (PhoneAccountHandle) resultData.getParcelable(
+ EXTRA_SELECTED_ACCOUNT_HANDLE),
+ resultData.getBoolean(EXTRA_SET_DEFAULT));
+ } else if (resultCode == RESULT_DISMISSED) {
+ onDialogDismissed();
+ }
+ }
+
+ public void onPhoneAccountSelected(PhoneAccountHandle selectedAccountHandle,
+ boolean setDefault) {}
+
+ public void onDialogDismissed() {}
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putBoolean(ARG_IS_DEFAULT_CHECKED, mIsDefaultChecked);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ mTitleResId = getArguments().getInt(ARG_TITLE_RES_ID);
+ mCanSetDefault = getArguments().getBoolean(ARG_CAN_SET_DEFAULT);
+ mAccountHandles = getArguments().getParcelableArrayList(ARG_ACCOUNT_HANDLES);
+ mListener = getArguments().getParcelable(ARG_LISTENER);
+ if (savedInstanceState != null) {
+ mIsDefaultChecked = savedInstanceState.getBoolean(ARG_IS_DEFAULT_CHECKED);
+ }
+ mIsSelected = false;
+ mTelecomManager =
+ (TelecomManager) getActivity().getSystemService(Context.TELECOM_SERVICE);
+
+ final DialogInterface.OnClickListener selectionListener =
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ mIsSelected = true;
+ PhoneAccountHandle selectedAccountHandle = mAccountHandles.get(which);
+ final Bundle result = new Bundle();
+ result.putParcelable(SelectPhoneAccountListener.EXTRA_SELECTED_ACCOUNT_HANDLE,
+ selectedAccountHandle);
+ result.putBoolean(SelectPhoneAccountListener.EXTRA_SET_DEFAULT,
+ mIsDefaultChecked);
+ if (mListener != null) {
+ mListener.onReceiveResult(SelectPhoneAccountListener.RESULT_SELECTED, result);
+ }
+ }
+ };
+
+ final CompoundButton.OnCheckedChangeListener checkListener =
+ new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton check, boolean isChecked) {
+ mIsDefaultChecked = isChecked;
+ }
+ };
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+ ListAdapter selectAccountListAdapter = new SelectAccountListAdapter(
+ builder.getContext(),
+ R.layout.select_account_list_item,
+ mAccountHandles);
+
+ AlertDialog dialog = builder.setTitle(mTitleResId)
+ .setAdapter(selectAccountListAdapter, selectionListener)
+ .create();
+
+ if (mCanSetDefault) {
+ // Generate custom checkbox view
+ LinearLayout checkboxLayout = (LinearLayout) getActivity()
+ .getLayoutInflater()
+ .inflate(R.layout.default_account_checkbox, null);
+
+ CheckBox cb =
+ (CheckBox) checkboxLayout.findViewById(R.id.default_account_checkbox_view);
+ cb.setOnCheckedChangeListener(checkListener);
+ cb.setChecked(mIsDefaultChecked);
+
+ dialog.getListView().addFooterView(checkboxLayout);
+ }
+
+ return dialog;
+ }
+
+ private class SelectAccountListAdapter extends ArrayAdapter<PhoneAccountHandle> {
+ private int mResId;
+
+ public SelectAccountListAdapter(
+ Context context, int resource, List<PhoneAccountHandle> accountHandles) {
+ super(context, resource, accountHandles);
+ mResId = resource;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ LayoutInflater inflater = (LayoutInflater)
+ getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+
+ View rowView;
+ final ViewHolder holder;
+
+ if (convertView == null) {
+ // Cache views for faster scrolling
+ rowView = inflater.inflate(mResId, null);
+ holder = new ViewHolder();
+ holder.labelTextView = (TextView) rowView.findViewById(R.id.label);
+ holder.numberTextView = (TextView) rowView.findViewById(R.id.number);
+ holder.imageView = (ImageView) rowView.findViewById(R.id.icon);
+ rowView.setTag(holder);
+ }
+ else {
+ rowView = convertView;
+ holder = (ViewHolder) rowView.getTag();
+ }
+
+ PhoneAccountHandle accountHandle = getItem(position);
+ PhoneAccount account = mTelecomManager.getPhoneAccount(accountHandle);
+ if (account == null) {
+ return rowView;
+ }
+ holder.labelTextView.setText(account.getLabel());
+ if (account.getAddress() == null ||
+ TextUtils.isEmpty(account.getAddress().getSchemeSpecificPart())) {
+ holder.numberTextView.setVisibility(View.GONE);
+ } else {
+ holder.numberTextView.setVisibility(View.VISIBLE);
+ holder.numberTextView.setText(
+ PhoneNumberUtilsCompat.createTtsSpannable(
+ account.getAddress().getSchemeSpecificPart()));
+ }
+ holder.imageView.setImageDrawable(PhoneAccountCompat.createIconDrawable(account,
+ getContext()));
+ return rowView;
+ }
+
+ private class ViewHolder {
+ TextView labelTextView;
+ TextView numberTextView;
+ ImageView imageView;
+ }
+ }
+
+ @Override
+ public void onStop() {
+ if (!mIsSelected && mListener != null) {
+ mListener.onReceiveResult(SelectPhoneAccountListener.RESULT_DISMISSED, null);
+ }
+ super.onStop();
+ }
+}
diff --git a/src/com/android/contacts/interactions/ContactInteractionUtil.java b/src/com/android/contacts/interactions/ContactInteractionUtil.java
index 98d45ee..8ec0547 100644
--- a/src/com/android/contacts/interactions/ContactInteractionUtil.java
+++ b/src/com/android/contacts/interactions/ContactInteractionUtil.java
@@ -26,9 +26,6 @@
import java.util.Calendar;
-import com.android.contacts.R;
-
-
/**
* Utility methods for interactions and their loaders
*/
@@ -61,8 +58,7 @@
* compareCalendar.
* This formats the date based on a few conditions:
* 1. If the timestamp is today, the time is shown
- * 2. If the timestamp occurs tomorrow or yesterday, that is displayed
- * 3. Otherwise {Month Date} format is used
+ * 2. Otherwise show full date and time
*/
@NeededForTesting
public static String formatDateStringFromTimestamp(long timestamp, Context context,
@@ -76,19 +72,9 @@
interactionCalendar.getTime());
}
- // Turn compareCalendar to yesterday
- compareCalendar.add(Calendar.DAY_OF_YEAR, -1);
- if (compareCalendarDayYear(interactionCalendar, compareCalendar)) {
- return context.getString(R.string.yesterday);
- }
-
- // Turn compareCalendar to tomorrow
- compareCalendar.add(Calendar.DAY_OF_YEAR, 2);
- if (compareCalendarDayYear(interactionCalendar, compareCalendar)) {
- return context.getString(R.string.tomorrow);
- }
- return DateUtils.formatDateTime(context, interactionCalendar.getTimeInMillis(),
- DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_YEAR);
+ return DateUtils.formatDateTime(context, timestamp, DateUtils.FORMAT_SHOW_TIME
+ | DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_WEEKDAY
+ | DateUtils.FORMAT_SHOW_YEAR);
}
/**
diff --git a/src/com/android/contacts/list/DefaultContactBrowseListFragment.java b/src/com/android/contacts/list/DefaultContactBrowseListFragment.java
index 75168cd..afaafb7 100644
--- a/src/com/android/contacts/list/DefaultContactBrowseListFragment.java
+++ b/src/com/android/contacts/list/DefaultContactBrowseListFragment.java
@@ -20,6 +20,7 @@
import android.content.Loader;
import android.database.Cursor;
import android.net.Uri;
+import android.provider.ContactsContract.Directory;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.LayoutInflater;
@@ -79,7 +80,9 @@
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
- bindListHeader(data.getCount());
+ if (loader.getId() == Directory.DEFAULT) {
+ bindListHeader(data.getCount());
+ }
super.onLoadFinished(loader, data);
if (!isSearchMode() && mCallback != null) {
mCallback.onLoadFinishedCallback();
diff --git a/src/com/android/contacts/quickcontact/QuickContactActivity.java b/src/com/android/contacts/quickcontact/QuickContactActivity.java
index e918233..c595f23 100644
--- a/src/com/android/contacts/quickcontact/QuickContactActivity.java
+++ b/src/com/android/contacts/quickcontact/QuickContactActivity.java
@@ -239,7 +239,6 @@
private static final String MIMETYPE_GPLUS_PROFILE =
"vnd.android.cursor.item/vnd.googleplus.profile";
- private static final String GPLUS_PROFILE_DATA_5_ADD_TO_CIRCLE = "addtocircle";
private static final String GPLUS_PROFILE_DATA_5_VIEW_PROFILE = "view";
private static final String MIMETYPE_HANGOUTS =
"vnd.android.cursor.item/vnd.googleplus.profile.comm";
@@ -2108,30 +2107,7 @@
// Build advanced entry for known 3p types. Otherwise default to ResolveCache icon.
switch (mimetype) {
case MIMETYPE_GPLUS_PROFILE:
- // If a secondDataItem is available, use it to build an entry with
- // alternate actions
- if (secondDataItem != null) {
- icon = res.getDrawable(R.drawable.ic_google_plus_black_24dp);
- alternateIcon = res.getDrawable(R.drawable.ic_add_to_circles_black_24);
- final GPlusOrHangoutsDataItemModel itemModel =
- new GPlusOrHangoutsDataItemModel(intent, alternateIntent,
- dataItem, secondDataItem, alternateContentDescription,
- header, text, context);
-
- populateGPlusOrHangoutsDataItemModel(itemModel);
- intent = itemModel.intent;
- alternateIntent = itemModel.alternateIntent;
- alternateContentDescription = itemModel.alternateContentDescription;
- header = itemModel.header;
- text = itemModel.text;
- } else {
- if (GPLUS_PROFILE_DATA_5_ADD_TO_CIRCLE.equals(
- intent.getDataString())) {
- icon = res.getDrawable(R.drawable.ic_add_to_circles_black_24);
- } else {
- icon = res.getDrawable(R.drawable.ic_google_plus_black_24dp);
- }
- }
+ icon = res.getDrawable(R.drawable.ic_google_plus_black_24dp);
break;
case MIMETYPE_HANGOUTS:
// If a secondDataItem is available, use it to build an entry with
@@ -2139,12 +2115,12 @@
if (secondDataItem != null) {
icon = res.getDrawable(R.drawable.ic_hangout_24dp);
alternateIcon = res.getDrawable(R.drawable.ic_hangout_video_24dp);
- final GPlusOrHangoutsDataItemModel itemModel =
- new GPlusOrHangoutsDataItemModel(intent, alternateIntent,
+ final HangoutsDataItemModel itemModel =
+ new HangoutsDataItemModel(intent, alternateIntent,
dataItem, secondDataItem, alternateContentDescription,
header, text, context);
- populateGPlusOrHangoutsDataItemModel(itemModel);
+ populateHangoutsDataItemModel(itemModel);
intent = itemModel.intent;
alternateIntent = itemModel.alternateIntent;
alternateContentDescription = itemModel.alternateContentDescription;
@@ -2216,9 +2192,10 @@
private List<Entry> dataItemsToEntries(List<DataItem> dataItems,
MutableString aboutCardTitleOut) {
// Hangouts and G+ use two data items to create one entry.
- if (dataItems.get(0).getMimeType().equals(MIMETYPE_GPLUS_PROFILE) ||
- dataItems.get(0).getMimeType().equals(MIMETYPE_HANGOUTS)) {
- return gPlusOrHangoutsDataItemsToEntries(dataItems);
+ if (dataItems.get(0).getMimeType().equals(MIMETYPE_GPLUS_PROFILE)) {
+ return gPlusDataItemsToEntries(dataItems);
+ } else if (dataItems.get(0).getMimeType().equals(MIMETYPE_HANGOUTS)) {
+ return hangoutsDataItemsToEntries(dataItems);
} else {
final List<Entry> entries = new ArrayList<>();
for (DataItem dataItem : dataItems) {
@@ -2233,15 +2210,10 @@
}
/**
- * G+ and Hangout entries are unique in that a single ExpandingEntryCardView.Entry consists
- * of two data items. This method attempts to build each entry using the two data items if
- * they are available. If there are more or less than two data items, a fall back is used
- * and each data item gets its own entry.
+ * Put the data items into buckets based on the raw contact id
*/
- private List<Entry> gPlusOrHangoutsDataItemsToEntries(List<DataItem> dataItems) {
- final List<Entry> entries = new ArrayList<>();
+ private Map<Long, List<DataItem>> dataItemsToBucket(List<DataItem> dataItems) {
final Map<Long, List<DataItem>> buckets = new HashMap<>();
- // Put the data items into buckets based on the raw contact id
for (DataItem dataItem : dataItems) {
List<DataItem> bucket = buckets.get(dataItem.getRawContactId());
if (bucket == null) {
@@ -2250,10 +2222,43 @@
}
bucket.add(dataItem);
}
+ return buckets;
+ }
+
+ /**
+ * For G+ entries, a single ExpandingEntryCardView.Entry consists of two data items. This
+ * method use only the View profile to build entry.
+ */
+ private List<Entry> gPlusDataItemsToEntries(List<DataItem> dataItems) {
+ final List<Entry> entries = new ArrayList<>();
+
+ for (List<DataItem> bucket : dataItemsToBucket(dataItems).values()) {
+ for (DataItem dataItem : bucket) {
+ if (GPLUS_PROFILE_DATA_5_VIEW_PROFILE.equals(
+ dataItem.getContentValues().getAsString(Data.DATA5))) {
+ final Entry entry = dataItemToEntry(dataItem, /* secondDataItem = */ null,
+ this, mContactData, /* aboutCardName = */ null);
+ if (entry != null) {
+ entries.add(entry);
+ }
+ }
+ }
+ }
+ return entries;
+ }
+
+ /**
+ * For Hangouts entries, a single ExpandingEntryCardView.Entry consists of two data items. This
+ * method attempts to build each entry using the two data items if they are available. If there
+ * are more or less than two data items, a fall back is used and each data item gets its own
+ * entry.
+ */
+ private List<Entry> hangoutsDataItemsToEntries(List<DataItem> dataItems) {
+ final List<Entry> entries = new ArrayList<>();
// Use the buckets to build entries. If a bucket contains two data items, build the special
// entry, otherwise fall back to the normal entry.
- for (List<DataItem> bucket : buckets.values()) {
+ for (List<DataItem> bucket : dataItemsToBucket(dataItems).values()) {
if (bucket.size() == 2) {
// Use the pair to build an entry
final Entry entry = dataItemToEntry(bucket.get(0),
@@ -2276,10 +2281,10 @@
}
/**
- * Used for statically passing around G+ or Hangouts data items and entry fields to
- * populateGPlusOrHangoutsDataItemModel.
+ * Used for statically passing around Hangouts data items and entry fields to
+ * populateHangoutsDataItemModel.
*/
- private static final class GPlusOrHangoutsDataItemModel {
+ private static final class HangoutsDataItemModel {
public Intent intent;
public Intent alternateIntent;
public DataItem dataItem;
@@ -2289,7 +2294,7 @@
public String text;
public Context context;
- public GPlusOrHangoutsDataItemModel(Intent intent, Intent alternateIntent, DataItem dataItem,
+ public HangoutsDataItemModel(Intent intent, Intent alternateIntent, DataItem dataItem,
DataItem secondDataItem, StringBuilder alternateContentDescription, String header,
String text, Context context) {
this.intent = intent;
@@ -2303,18 +2308,16 @@
}
}
- private static void populateGPlusOrHangoutsDataItemModel(
- GPlusOrHangoutsDataItemModel dataModel) {
+ private static void populateHangoutsDataItemModel(
+ HangoutsDataItemModel dataModel) {
final Intent secondIntent = new Intent(Intent.ACTION_VIEW);
secondIntent.setDataAndType(ContentUris.withAppendedId(Data.CONTENT_URI,
dataModel.secondDataItem.getId()), dataModel.secondDataItem.getMimeType());
// There is no guarantee the order the data items come in. Second
// data item does not necessarily mean it's the alternate.
- // Hangouts video and Add to circles should be alternate. Swap if needed
+ // Hangouts video should be alternate. Swap if needed
if (HANGOUTS_DATA_5_VIDEO.equals(
- dataModel.dataItem.getContentValues().getAsString(Data.DATA5)) ||
- GPLUS_PROFILE_DATA_5_ADD_TO_CIRCLE.equals(
- dataModel.dataItem.getContentValues().getAsString(Data.DATA5))) {
+ dataModel.dataItem.getContentValues().getAsString(Data.DATA5))) {
dataModel.alternateIntent = dataModel.intent;
dataModel.alternateContentDescription = new StringBuilder(dataModel.header);
@@ -2323,9 +2326,7 @@
dataModel.secondDataItem.getDataKind());
dataModel.text = dataModel.secondDataItem.getDataKind().typeColumn;
} else if (HANGOUTS_DATA_5_MESSAGE.equals(
- dataModel.dataItem.getContentValues().getAsString(Data.DATA5)) ||
- GPLUS_PROFILE_DATA_5_VIEW_PROFILE.equals(
- dataModel.dataItem.getContentValues().getAsString(Data.DATA5))) {
+ dataModel.dataItem.getContentValues().getAsString(Data.DATA5))) {
dataModel.alternateIntent = secondIntent;
dataModel.alternateContentDescription = new StringBuilder(
dataModel.secondDataItem.buildDataStringForDisplay(dataModel.context,
diff --git a/tests/Android.mk b/tests/Android.mk
index 4fd947c..48a00f4 100644
--- a/tests/Android.mk
+++ b/tests/Android.mk
@@ -7,11 +7,8 @@
LOCAL_STATIC_JAVA_LIBRARIES := android-support-test
-src_dirs := src \
- ../../ContactsCommon/TestCommon/src
-
-# Include all test java files.
-LOCAL_SRC_FILES := $(call all-java-files-under, $(src_dirs))
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+LOCAL_RESOURCE_DIR := $(addprefix $(LOCAL_PATH)/, res)
LOCAL_PACKAGE_NAME := ContactsTests
@@ -20,4 +17,11 @@
LOCAL_SDK_VERSION := current
LOCAL_MIN_SDK_VERSION := 21
+LOCAL_STATIC_JAVA_LIBRARIES := \
+ mockito-target
+
+LOCAL_AAPT_FLAGS := \
+ --auto-add-overlay \
+ --extra-packages com.android.contacts.common.tests
+
include $(BUILD_PACKAGE)
diff --git a/tests/res-common/drawable/android.jpg b/tests/res-common/drawable/android.jpg
new file mode 100644
index 0000000..95693b2
--- /dev/null
+++ b/tests/res-common/drawable/android.jpg
Binary files differ
diff --git a/tests/res-common/drawable/default_icon.png b/tests/res-common/drawable/default_icon.png
new file mode 100644
index 0000000..cea0eb3
--- /dev/null
+++ b/tests/res-common/drawable/default_icon.png
Binary files differ
diff --git a/tests/res-common/drawable/ic_contact_picture.png b/tests/res-common/drawable/ic_contact_picture.png
new file mode 100644
index 0000000..6876777
--- /dev/null
+++ b/tests/res-common/drawable/ic_contact_picture.png
Binary files differ
diff --git a/tests/res-common/drawable/phone_icon.png b/tests/res-common/drawable/phone_icon.png
new file mode 100644
index 0000000..4e613ec
--- /dev/null
+++ b/tests/res-common/drawable/phone_icon.png
Binary files differ
diff --git a/tests/res-common/values/donottranslate_strings.xml b/tests/res-common/values/donottranslate_strings.xml
new file mode 100644
index 0000000..6c8527f
--- /dev/null
+++ b/tests/res-common/values/donottranslate_strings.xml
@@ -0,0 +1,21 @@
+<!--
+ ~ Copyright (C) 2012 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT 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>
+ <string name="test_string">TEST STRING</string>
+
+ <string name="authenticator_basic_label">Test adapter</string>
+</resources>
diff --git a/tests/res-common/xml/contacts_fallback.xml b/tests/res-common/xml/contacts_fallback.xml
new file mode 100644
index 0000000..7034d5e
--- /dev/null
+++ b/tests/res-common/xml/contacts_fallback.xml
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!--
+ contacts.xml to build "fallback account type" equivalent.
+ This is directly used in ExternalAccountTypeTest to test the parser. There's no sync adapter
+ that actually defined with this definition.
+-->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema
+ >
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ >
+ </DataKind>
+ <DataKind kind="photo" maxOccurs="1" />
+ <DataKind kind="phone" >
+ <Type type="mobile" />
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="fax_work" />
+ <Type type="fax_home" />
+ <Type type="pager" />
+ <Type type="other" />
+ <Type type="custom"/>
+ <Type type="callback" />
+ <Type type="car" />
+ <Type type="company_main" />
+ <Type type="isdn" />
+ <Type type="main" />
+ <Type type="other_fax" />
+ <Type type="radio" />
+ <Type type="telex" />
+ <Type type="tty_tdd" />
+ <Type type="work_mobile"/>
+ <Type type="work_pager" />
+ <Type type="assistant" />
+ <Type type="mms" />
+ </DataKind>
+ <DataKind kind="email" >
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="other" />
+ <Type type="mobile" />
+ <Type type="custom" />
+ </DataKind>
+ <DataKind kind="nickname" maxOccurs="1" />
+ <DataKind kind="im" >
+ <Type type="aim" />
+ <Type type="msn" />
+ <Type type="yahoo" />
+ <Type type="skype" />
+ <Type type="qq" />
+ <Type type="google_talk" />
+ <Type type="icq" />
+ <Type type="jabber" />
+ <Type type="custom" />
+ </DataKind>
+ <DataKind kind="postal" needsStructured="false" >
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="other" />
+ <Type type="custom" />
+ </DataKind>
+ <DataKind kind="organization" maxOccurs="1" />
+ <DataKind kind="website" />
+ <DataKind kind="sip_address" maxOccurs="1" />
+ <DataKind kind="note" maxOccurs="1" />
+ <DataKind kind="group_membership" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/contacts_readonly.xml b/tests/res-common/xml/contacts_readonly.xml
new file mode 100644
index 0000000..df8d9c0
--- /dev/null
+++ b/tests/res-common/xml/contacts_readonly.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!--
+ Contacts.xml without EditSchema.
+-->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <ContactsDataKind
+ android:icon="@drawable/android"
+ android:mimeType="vnd.android.cursor.item/a.b.c"
+ android:summaryColumn="data1"
+ android:detailColumn="data2"
+ android:detailSocialSummary="true"
+ >
+ </ContactsDataKind>
+ <ContactsDataKind
+ android:icon="@drawable/default_icon"
+ android:mimeType="vnd.android.cursor.item/d.e.f"
+ android:summaryColumn="data3"
+ android:detailColumn="data4"
+ android:detailSocialSummary="false"
+ >
+ </ContactsDataKind>
+ <ContactsDataKind
+ android:icon="@drawable/android"
+ android:mimeType="vnd.android.cursor.item/xyz"
+ android:summaryColumn="data5"
+ android:detailColumn="data6"
+ android:detailSocialSummary="true"
+ >
+ </ContactsDataKind>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/iconset.xml b/tests/res-common/xml/iconset.xml
new file mode 100644
index 0000000..d1207e7
--- /dev/null
+++ b/tests/res-common/xml/iconset.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2009 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<icon-set
+ xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <icon-default android:icon="@drawable/default_icon" />
+ <icon android:mimeType="vnd.android.cursor.item/phone"
+ android:icon="@drawable/phone_icon" />
+
+</icon-set>
\ No newline at end of file
diff --git a/tests/res-common/xml/missing_contacts_base.xml b/tests/res-common/xml/missing_contacts_base.xml
new file mode 100644
index 0000000..2c9aa6d
--- /dev/null
+++ b/tests/res-common/xml/missing_contacts_base.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Base definition, which is valid. -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ >
+ </DataKind>
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/missing_contacts_name.xml b/tests/res-common/xml/missing_contacts_name.xml
new file mode 100644
index 0000000..1ac26be
--- /dev/null
+++ b/tests/res-common/xml/missing_contacts_name.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing "name" kind. -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/missing_contacts_name_attr1.xml b/tests/res-common/xml/missing_contacts_name_attr1.xml
new file mode 100644
index 0000000..b7b0f19
--- /dev/null
+++ b/tests/res-common/xml/missing_contacts_name_attr1.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/missing_contacts_name_attr2.xml b/tests/res-common/xml/missing_contacts_name_attr2.xml
new file mode 100644
index 0000000..41be9e8
--- /dev/null
+++ b/tests/res-common/xml/missing_contacts_name_attr2.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/missing_contacts_name_attr3.xml b/tests/res-common/xml/missing_contacts_name_attr3.xml
new file mode 100644
index 0000000..e639a76
--- /dev/null
+++ b/tests/res-common/xml/missing_contacts_name_attr3.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/missing_contacts_name_attr4.xml b/tests/res-common/xml/missing_contacts_name_attr4.xml
new file mode 100644
index 0000000..b42cdcd
--- /dev/null
+++ b/tests/res-common/xml/missing_contacts_name_attr4.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/missing_contacts_name_attr5.xml b/tests/res-common/xml/missing_contacts_name_attr5.xml
new file mode 100644
index 0000000..3778d2f
--- /dev/null
+++ b/tests/res-common/xml/missing_contacts_name_attr5.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/missing_contacts_name_attr6.xml b/tests/res-common/xml/missing_contacts_name_attr6.xml
new file mode 100644
index 0000000..b3a3411
--- /dev/null
+++ b/tests/res-common/xml/missing_contacts_name_attr6.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/missing_contacts_name_attr7.xml b/tests/res-common/xml/missing_contacts_name_attr7.xml
new file mode 100644
index 0000000..c87e4f1
--- /dev/null
+++ b/tests/res-common/xml/missing_contacts_name_attr7.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/missing_contacts_photo.xml b/tests/res-common/xml/missing_contacts_photo.xml
new file mode 100644
index 0000000..87f4fc6
--- /dev/null
+++ b/tests/res-common/xml/missing_contacts_photo.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing "photo" kind. -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ >
+ </DataKind>
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/test_basic_contacts.xml b/tests/res-common/xml/test_basic_contacts.xml
new file mode 100644
index 0000000..0047204
--- /dev/null
+++ b/tests/res-common/xml/test_basic_contacts.xml
@@ -0,0 +1,283 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema
+ >
+ <!--
+ Name:
+ - maxOccurs must be 1
+ - No types.
+
+ - Currently all the supportsXxx attributes must be true, but here's the plan for the
+ future:
+ (There's some hardcoded assumptions in the contact editor, which is one reason
+ for the above restriction)
+
+ - "Family name" and "Given name" must be supported.
+ - All sync adapters must support structured name. "display name only" is not
+ supported.
+ -> Supporting this would require relatively large changes to
+ the contact editor.
+
+ - Fields are decided from the attributes:
+ StructuredName.DISPLAY_NAME if supportsDisplayName == true
+ StructuredName.PREFIX if supportsPrefix == true
+ StructuredName.FAMILY_NAME (always)
+ StructuredName.MIDDLE_NAME if supportsPrefix == true
+ StructuredName.GIVEN_NAME (always)
+ StructuredName.SUFFIX if supportsSuffix == true
+ StructuredName.PHONETIC_FAMILY_NAME if supportsPhoneticFamilyName == true
+ StructuredName.PHONETIC_MIDDLE_NAME if supportsPhoneticMiddleName == true
+ StructuredName.PHONETIC_GIVEN_NAME if supportsPhoneticGivenName == true
+
+ - DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME is always added.
+ - DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME is added
+ if any of supportsPhoneticXxx == true
+ -->
+ <!-- Fallback/Google definition. Supports all. -->
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ >
+ </DataKind>
+
+ <!-- Exchange definition. No display-name, no phonetic-middle.
+ <DataKind kind="name"
+ supportsDisplayName="false"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="false"
+ supportsPhoneticGivenName ="true"
+ >
+ </DataKind>
+ -->
+
+ <!--
+ Photo:
+ - maxOccurs must be 1
+ - No types.
+ -->
+ <DataKind kind="photo" maxOccurs="1" />
+
+ <!--
+ Phone definition.
+ - "is secondary?" is inferred from type.
+ -->
+ <!-- Fallback, Google definition. -->
+ <DataKind kind="phone" >
+ <!-- Note: Google type doesn't have obsolete ones -->
+ <Type type="mobile" />
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="fax_work" />
+ <Type type="fax_home" />
+ <Type type="pager" />
+ <Type type="other" />
+ <Type type="custom"/>
+ <Type type="callback" />
+ <Type type="car" />
+ <Type type="company_main" />
+ <Type type="isdn" />
+ <Type type="main" />
+ <Type type="other_fax" />
+ <Type type="radio" />
+ <Type type="telex" />
+ <Type type="tty_tdd" />
+ <Type type="work_mobile"/>
+ <Type type="work_pager" />
+ <Type type="assistant" />
+ <Type type="mms" />
+ </DataKind>
+
+ <!-- Exchange definition.
+ <DataKind kind="phone" >
+ <Type type="home" maxOccurs="2" />
+ <Type type="mobile" maxOccurs="1" />
+ <Type type="work" maxOccurs="2" />
+ <Type type="fax_work" maxOccurs="1" />
+ <Type type="fax_home" maxOccurs="1" />
+ <Type type="pager" maxOccurs="1" />
+ <Type type="car" maxOccurs="1" />
+ <Type type="company_main" maxOccurs="1" />
+ <Type type="mms" maxOccurs="1" />
+ <Type type="radio" maxOccurs="1" />
+ <Type type="assistant" maxOccurs="1" />
+ </DataKind>
+ -->
+
+ <!--
+ Email
+ -->
+ <!-- Fallback/Google definition. -->
+ <DataKind kind="email" >
+ <!-- Note: Google type doesn't have obsolete ones -->
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="other" />
+ <Type type="mobile" />
+ <Type type="custom" />
+ </DataKind>
+
+ <!--
+ Exchange definition.
+ - Same definition as "fallback" except for maxOccurs=3
+ <DataKind kind="email" maxOccurs="3" >
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="other" />
+ <Type type="mobile" />
+ <Type type="custom" />
+ </DataKind>
+ -->
+
+ <!--
+ Nickname
+ - maxOccurs must be 1
+ - No types.
+ -->
+ <DataKind kind="nickname" maxOccurs="1" />
+
+ <!--
+ Im:
+ - The TYPE column always stores Im.TYPE_OTHER (defaultValues is always set)
+ - The user-selected type is stored in Im.PROTOCOL
+ -->
+ <!-- Fallback, Google definition. -->
+ <DataKind kind="im" >
+ <Type type="aim" />
+ <Type type="msn" />
+ <Type type="yahoo" />
+ <Type type="skype" />
+ <Type type="qq" />
+ <Type type="google_talk" />
+ <Type type="icq" />
+ <Type type="jabber" />
+ <Type type="custom" />
+ </DataKind>
+
+ <!-- Exchange definition.
+ <DataKind kind="im" maxOccurs="3" >
+ <Type type="aim" />
+ <Type type="msn" />
+ <Type type="yahoo" />
+ <Type type="skype" />
+ <Type type="qq" />
+ <Type type="google_talk" />
+ <Type type="icq" />
+ <Type type="jabber" />
+ <Type type="custom" />
+ </DataKind>
+ -->
+
+ <!--
+ Postal address.
+ -->
+ <!-- Fallback/Google definition. Not structured. -->
+ <DataKind kind="postal" needsStructured="false" >
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="other" />
+ <Type type="custom" />
+ </DataKind>
+
+ <!-- Exchange definition. Structured.
+ <DataKind kind="postal" needsStructured="true" >
+ <Type type="work" />
+ <Type type="home" />
+ <Type type="other" />
+ </DataKind>
+ -->
+
+ <!--
+ Organization:
+ - Fields are fixed: COMPANY, TITLE
+ - maxOccurs must be 1
+ - No types.
+ -->
+ <DataKind kind="organization" maxOccurs="1" />
+
+ <!--
+ Website:
+ - No types.
+ -->
+ <DataKind kind="website" />
+
+ <!--
+ Below kinds have nothing configurable.
+ - No types are supported.
+ - maxOccurs must be 1
+ -->
+ <DataKind kind="sip_address" maxOccurs="1" />
+ <DataKind kind="note" maxOccurs="1" />
+
+ <!--
+ Google/Exchange supports it, but fallback doesn't.
+ <DataKind kind="group_membership" maxOccurs="1" />
+ -->
+
+ <!--
+ Event
+ -->
+ <DataKind kind="event" dateWithTime="false">
+ <Type type="birthday" maxOccurs="1" yearOptional="true" />
+ <Type type="anniversary" />
+ <Type type="other" />
+ <Type type="custom" />
+ </DataKind>
+
+ <!--
+ Exchange definition. dateWithTime is needed only for Exchange.
+ <DataKind kind="event" dateWithTime="true">
+ <Type type="birthday" maxOccurs="1" />
+ </DataKind>
+ -->
+
+ <!--
+ Relationship
+ -->
+ <DataKind kind="relationship" >
+ <Type type="assistant" />
+ <Type type="brother" />
+ <Type type="child" />
+ <Type type="domestic_partner" />
+ <Type type="father" />
+ <Type type="friend" />
+ <Type type="manager" />
+ <Type type="mother" />
+ <Type type="parent" />
+ <Type type="partner" />
+ <Type type="referred_by" />
+ <Type type="relative" />
+ <Type type="sister" />
+ <Type type="spouse" />
+ <Type type="custom" />
+ </DataKind>
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res-common/xml/test_basic_syncadapter.xml b/tests/res-common/xml/test_basic_syncadapter.xml
new file mode 100644
index 0000000..fecc0eb
--- /dev/null
+++ b/tests/res-common/xml/test_basic_syncadapter.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
+ android:contentAuthority="com.android.contacts"
+ android:accountType="com.android.contacts.tests.authtest.basic"
+ android:supportsUploading="true"
+ android:userVisible="true"
+/>
diff --git a/tests/res/values/donottranslate_strings.xml b/tests/res/values/donottranslate_strings.xml
index 8f43dbf..1528112 100644
--- a/tests/res/values/donottranslate_strings.xml
+++ b/tests/res/values/donottranslate_strings.xml
@@ -105,4 +105,6 @@
<string name="attribution_twitter">Twitter</string>
<string name="authenticator_basic_label">Test adapter</string>
+
+ <string name="test_string">TEST STRING</string>
</resources>
diff --git a/tests/res/xml/contacts_fallback.xml b/tests/res/xml/contacts_fallback.xml
new file mode 100644
index 0000000..7034d5e
--- /dev/null
+++ b/tests/res/xml/contacts_fallback.xml
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!--
+ contacts.xml to build "fallback account type" equivalent.
+ This is directly used in ExternalAccountTypeTest to test the parser. There's no sync adapter
+ that actually defined with this definition.
+-->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema
+ >
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ >
+ </DataKind>
+ <DataKind kind="photo" maxOccurs="1" />
+ <DataKind kind="phone" >
+ <Type type="mobile" />
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="fax_work" />
+ <Type type="fax_home" />
+ <Type type="pager" />
+ <Type type="other" />
+ <Type type="custom"/>
+ <Type type="callback" />
+ <Type type="car" />
+ <Type type="company_main" />
+ <Type type="isdn" />
+ <Type type="main" />
+ <Type type="other_fax" />
+ <Type type="radio" />
+ <Type type="telex" />
+ <Type type="tty_tdd" />
+ <Type type="work_mobile"/>
+ <Type type="work_pager" />
+ <Type type="assistant" />
+ <Type type="mms" />
+ </DataKind>
+ <DataKind kind="email" >
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="other" />
+ <Type type="mobile" />
+ <Type type="custom" />
+ </DataKind>
+ <DataKind kind="nickname" maxOccurs="1" />
+ <DataKind kind="im" >
+ <Type type="aim" />
+ <Type type="msn" />
+ <Type type="yahoo" />
+ <Type type="skype" />
+ <Type type="qq" />
+ <Type type="google_talk" />
+ <Type type="icq" />
+ <Type type="jabber" />
+ <Type type="custom" />
+ </DataKind>
+ <DataKind kind="postal" needsStructured="false" >
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="other" />
+ <Type type="custom" />
+ </DataKind>
+ <DataKind kind="organization" maxOccurs="1" />
+ <DataKind kind="website" />
+ <DataKind kind="sip_address" maxOccurs="1" />
+ <DataKind kind="note" maxOccurs="1" />
+ <DataKind kind="group_membership" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/contacts_readonly.xml b/tests/res/xml/contacts_readonly.xml
new file mode 100644
index 0000000..df8d9c0
--- /dev/null
+++ b/tests/res/xml/contacts_readonly.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!--
+ Contacts.xml without EditSchema.
+-->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <ContactsDataKind
+ android:icon="@drawable/android"
+ android:mimeType="vnd.android.cursor.item/a.b.c"
+ android:summaryColumn="data1"
+ android:detailColumn="data2"
+ android:detailSocialSummary="true"
+ >
+ </ContactsDataKind>
+ <ContactsDataKind
+ android:icon="@drawable/default_icon"
+ android:mimeType="vnd.android.cursor.item/d.e.f"
+ android:summaryColumn="data3"
+ android:detailColumn="data4"
+ android:detailSocialSummary="false"
+ >
+ </ContactsDataKind>
+ <ContactsDataKind
+ android:icon="@drawable/android"
+ android:mimeType="vnd.android.cursor.item/xyz"
+ android:summaryColumn="data5"
+ android:detailColumn="data6"
+ android:detailSocialSummary="true"
+ >
+ </ContactsDataKind>
+</ContactsAccountType>
diff --git a/tests/res/xml/iconset.xml b/tests/res/xml/iconset.xml
index b9e419d..d1207e7 100644
--- a/tests/res/xml/iconset.xml
+++ b/tests/res/xml/iconset.xml
@@ -1,25 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
-<!--
- ~ Copyright (C) 2012 The Android Open Source Project
- ~
- ~ Licensed under the Apache License, Version 2.0 (the "License");
- ~ you may not use this file except in compliance with the License.
- ~ You may obtain a copy of the License at
- ~
- ~ http://www.apache.org/licenses/LICENSE-2.0
- ~
- ~ Unless required by applicable law or agreed to in writing, software
- ~ distributed under the License is distributed on an "AS IS" BASIS,
- ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- ~ See the License for the specific language governing permissions and
- ~ limitations under the License
- -->
+<!-- Copyright (C) 2009 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
<icon-set
xmlns:android="http://schemas.android.com/apk/res/android">
<icon-default android:icon="@drawable/default_icon" />
- <icon android:mimeType="vnd.android.cursor.item/phone"
+ <icon android:mimeType="vnd.android.cursor.item/phone"
android:icon="@drawable/phone_icon" />
-</icon-set>
+</icon-set>
\ No newline at end of file
diff --git a/tests/res/xml/missing_contacts_base.xml b/tests/res/xml/missing_contacts_base.xml
new file mode 100644
index 0000000..2c9aa6d
--- /dev/null
+++ b/tests/res/xml/missing_contacts_base.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Base definition, which is valid. -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ >
+ </DataKind>
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/missing_contacts_name.xml b/tests/res/xml/missing_contacts_name.xml
new file mode 100644
index 0000000..1ac26be
--- /dev/null
+++ b/tests/res/xml/missing_contacts_name.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing "name" kind. -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/missing_contacts_name_attr1.xml b/tests/res/xml/missing_contacts_name_attr1.xml
new file mode 100644
index 0000000..b7b0f19
--- /dev/null
+++ b/tests/res/xml/missing_contacts_name_attr1.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/missing_contacts_name_attr2.xml b/tests/res/xml/missing_contacts_name_attr2.xml
new file mode 100644
index 0000000..41be9e8
--- /dev/null
+++ b/tests/res/xml/missing_contacts_name_attr2.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/missing_contacts_name_attr3.xml b/tests/res/xml/missing_contacts_name_attr3.xml
new file mode 100644
index 0000000..e639a76
--- /dev/null
+++ b/tests/res/xml/missing_contacts_name_attr3.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/missing_contacts_name_attr4.xml b/tests/res/xml/missing_contacts_name_attr4.xml
new file mode 100644
index 0000000..b42cdcd
--- /dev/null
+++ b/tests/res/xml/missing_contacts_name_attr4.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/missing_contacts_name_attr5.xml b/tests/res/xml/missing_contacts_name_attr5.xml
new file mode 100644
index 0000000..3778d2f
--- /dev/null
+++ b/tests/res/xml/missing_contacts_name_attr5.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/missing_contacts_name_attr6.xml b/tests/res/xml/missing_contacts_name_attr6.xml
new file mode 100644
index 0000000..b3a3411
--- /dev/null
+++ b/tests/res/xml/missing_contacts_name_attr6.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticGivenName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/missing_contacts_name_attr7.xml b/tests/res/xml/missing_contacts_name_attr7.xml
new file mode 100644
index 0000000..c87e4f1
--- /dev/null
+++ b/tests/res/xml/missing_contacts_name_attr7.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing one of the "support*" attributes". -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ />
+ <DataKind kind="photo" maxOccurs="1" />
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/missing_contacts_photo.xml b/tests/res/xml/missing_contacts_photo.xml
new file mode 100644
index 0000000..87f4fc6
--- /dev/null
+++ b/tests/res/xml/missing_contacts_photo.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<!-- XML for must-have checks. Missing "photo" kind. -->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema>
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ >
+ </DataKind>
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/test_basic_contacts.xml b/tests/res/xml/test_basic_contacts.xml
new file mode 100644
index 0000000..0047204
--- /dev/null
+++ b/tests/res/xml/test_basic_contacts.xml
@@ -0,0 +1,283 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<ContactsAccountType
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ >
+ <EditSchema
+ >
+ <!--
+ Name:
+ - maxOccurs must be 1
+ - No types.
+
+ - Currently all the supportsXxx attributes must be true, but here's the plan for the
+ future:
+ (There's some hardcoded assumptions in the contact editor, which is one reason
+ for the above restriction)
+
+ - "Family name" and "Given name" must be supported.
+ - All sync adapters must support structured name. "display name only" is not
+ supported.
+ -> Supporting this would require relatively large changes to
+ the contact editor.
+
+ - Fields are decided from the attributes:
+ StructuredName.DISPLAY_NAME if supportsDisplayName == true
+ StructuredName.PREFIX if supportsPrefix == true
+ StructuredName.FAMILY_NAME (always)
+ StructuredName.MIDDLE_NAME if supportsPrefix == true
+ StructuredName.GIVEN_NAME (always)
+ StructuredName.SUFFIX if supportsSuffix == true
+ StructuredName.PHONETIC_FAMILY_NAME if supportsPhoneticFamilyName == true
+ StructuredName.PHONETIC_MIDDLE_NAME if supportsPhoneticMiddleName == true
+ StructuredName.PHONETIC_GIVEN_NAME if supportsPhoneticGivenName == true
+
+ - DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME is always added.
+ - DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME is added
+ if any of supportsPhoneticXxx == true
+ -->
+ <!-- Fallback/Google definition. Supports all. -->
+ <DataKind kind="name"
+ maxOccurs="1"
+ supportsDisplayName="true"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="true"
+ supportsPhoneticGivenName="true"
+ >
+ </DataKind>
+
+ <!-- Exchange definition. No display-name, no phonetic-middle.
+ <DataKind kind="name"
+ supportsDisplayName="false"
+ supportsPrefix="true"
+ supportsMiddleName="true"
+ supportsSuffix="true"
+ supportsPhoneticFamilyName="true"
+ supportsPhoneticMiddleName="false"
+ supportsPhoneticGivenName ="true"
+ >
+ </DataKind>
+ -->
+
+ <!--
+ Photo:
+ - maxOccurs must be 1
+ - No types.
+ -->
+ <DataKind kind="photo" maxOccurs="1" />
+
+ <!--
+ Phone definition.
+ - "is secondary?" is inferred from type.
+ -->
+ <!-- Fallback, Google definition. -->
+ <DataKind kind="phone" >
+ <!-- Note: Google type doesn't have obsolete ones -->
+ <Type type="mobile" />
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="fax_work" />
+ <Type type="fax_home" />
+ <Type type="pager" />
+ <Type type="other" />
+ <Type type="custom"/>
+ <Type type="callback" />
+ <Type type="car" />
+ <Type type="company_main" />
+ <Type type="isdn" />
+ <Type type="main" />
+ <Type type="other_fax" />
+ <Type type="radio" />
+ <Type type="telex" />
+ <Type type="tty_tdd" />
+ <Type type="work_mobile"/>
+ <Type type="work_pager" />
+ <Type type="assistant" />
+ <Type type="mms" />
+ </DataKind>
+
+ <!-- Exchange definition.
+ <DataKind kind="phone" >
+ <Type type="home" maxOccurs="2" />
+ <Type type="mobile" maxOccurs="1" />
+ <Type type="work" maxOccurs="2" />
+ <Type type="fax_work" maxOccurs="1" />
+ <Type type="fax_home" maxOccurs="1" />
+ <Type type="pager" maxOccurs="1" />
+ <Type type="car" maxOccurs="1" />
+ <Type type="company_main" maxOccurs="1" />
+ <Type type="mms" maxOccurs="1" />
+ <Type type="radio" maxOccurs="1" />
+ <Type type="assistant" maxOccurs="1" />
+ </DataKind>
+ -->
+
+ <!--
+ Email
+ -->
+ <!-- Fallback/Google definition. -->
+ <DataKind kind="email" >
+ <!-- Note: Google type doesn't have obsolete ones -->
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="other" />
+ <Type type="mobile" />
+ <Type type="custom" />
+ </DataKind>
+
+ <!--
+ Exchange definition.
+ - Same definition as "fallback" except for maxOccurs=3
+ <DataKind kind="email" maxOccurs="3" >
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="other" />
+ <Type type="mobile" />
+ <Type type="custom" />
+ </DataKind>
+ -->
+
+ <!--
+ Nickname
+ - maxOccurs must be 1
+ - No types.
+ -->
+ <DataKind kind="nickname" maxOccurs="1" />
+
+ <!--
+ Im:
+ - The TYPE column always stores Im.TYPE_OTHER (defaultValues is always set)
+ - The user-selected type is stored in Im.PROTOCOL
+ -->
+ <!-- Fallback, Google definition. -->
+ <DataKind kind="im" >
+ <Type type="aim" />
+ <Type type="msn" />
+ <Type type="yahoo" />
+ <Type type="skype" />
+ <Type type="qq" />
+ <Type type="google_talk" />
+ <Type type="icq" />
+ <Type type="jabber" />
+ <Type type="custom" />
+ </DataKind>
+
+ <!-- Exchange definition.
+ <DataKind kind="im" maxOccurs="3" >
+ <Type type="aim" />
+ <Type type="msn" />
+ <Type type="yahoo" />
+ <Type type="skype" />
+ <Type type="qq" />
+ <Type type="google_talk" />
+ <Type type="icq" />
+ <Type type="jabber" />
+ <Type type="custom" />
+ </DataKind>
+ -->
+
+ <!--
+ Postal address.
+ -->
+ <!-- Fallback/Google definition. Not structured. -->
+ <DataKind kind="postal" needsStructured="false" >
+ <Type type="home" />
+ <Type type="work" />
+ <Type type="other" />
+ <Type type="custom" />
+ </DataKind>
+
+ <!-- Exchange definition. Structured.
+ <DataKind kind="postal" needsStructured="true" >
+ <Type type="work" />
+ <Type type="home" />
+ <Type type="other" />
+ </DataKind>
+ -->
+
+ <!--
+ Organization:
+ - Fields are fixed: COMPANY, TITLE
+ - maxOccurs must be 1
+ - No types.
+ -->
+ <DataKind kind="organization" maxOccurs="1" />
+
+ <!--
+ Website:
+ - No types.
+ -->
+ <DataKind kind="website" />
+
+ <!--
+ Below kinds have nothing configurable.
+ - No types are supported.
+ - maxOccurs must be 1
+ -->
+ <DataKind kind="sip_address" maxOccurs="1" />
+ <DataKind kind="note" maxOccurs="1" />
+
+ <!--
+ Google/Exchange supports it, but fallback doesn't.
+ <DataKind kind="group_membership" maxOccurs="1" />
+ -->
+
+ <!--
+ Event
+ -->
+ <DataKind kind="event" dateWithTime="false">
+ <Type type="birthday" maxOccurs="1" yearOptional="true" />
+ <Type type="anniversary" />
+ <Type type="other" />
+ <Type type="custom" />
+ </DataKind>
+
+ <!--
+ Exchange definition. dateWithTime is needed only for Exchange.
+ <DataKind kind="event" dateWithTime="true">
+ <Type type="birthday" maxOccurs="1" />
+ </DataKind>
+ -->
+
+ <!--
+ Relationship
+ -->
+ <DataKind kind="relationship" >
+ <Type type="assistant" />
+ <Type type="brother" />
+ <Type type="child" />
+ <Type type="domestic_partner" />
+ <Type type="father" />
+ <Type type="friend" />
+ <Type type="manager" />
+ <Type type="mother" />
+ <Type type="parent" />
+ <Type type="partner" />
+ <Type type="referred_by" />
+ <Type type="relative" />
+ <Type type="sister" />
+ <Type type="spouse" />
+ <Type type="custom" />
+ </DataKind>
+ </EditSchema>
+</ContactsAccountType>
diff --git a/tests/res/xml/test_basic_syncadapter.xml b/tests/res/xml/test_basic_syncadapter.xml
new file mode 100644
index 0000000..fecc0eb
--- /dev/null
+++ b/tests/res/xml/test_basic_syncadapter.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+/**
+ * Copyright (c) 2011, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+
+<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
+ android:contentAuthority="com.android.contacts"
+ android:accountType="com.android.contacts.tests.authtest.basic"
+ android:supportsUploading="true"
+ android:userVisible="true"
+/>
diff --git a/tests/src/com/android/contacts/common/ContactsUtilsTests.java b/tests/src/com/android/contacts/common/ContactsUtilsTests.java
new file mode 100644
index 0000000..a209fb2
--- /dev/null
+++ b/tests/src/com/android/contacts/common/ContactsUtilsTests.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common;
+
+import android.content.ContentValues;
+import android.content.Intent;
+import android.net.Uri;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.util.Pair;
+
+import com.android.contacts.common.ContactsUtils;
+import com.android.contacts.common.model.dataitem.DataItem;
+import com.android.contacts.common.model.dataitem.EmailDataItem;
+import com.android.contacts.common.model.dataitem.ImDataItem;
+
+/**
+ * Tests for {@link ContactsUtils}.
+ */
+@SmallTest
+public class ContactsUtilsTests extends AndroidTestCase {
+
+ private static final String TEST_ADDRESS = "user@example.org";
+ private static final String TEST_PROTOCOL = "prot%col";
+
+ public void testIsGraphicNull() throws Exception {
+ assertFalse(ContactsUtils.isGraphic(null));
+ }
+
+ public void testIsGraphicEmpty() throws Exception {
+ assertFalse(ContactsUtils.isGraphic(""));
+ }
+
+ public void testIsGraphicSpaces() throws Exception {
+ assertFalse(ContactsUtils.isGraphic(" "));
+ }
+
+ public void testIsGraphicPunctuation() throws Exception {
+ assertTrue(ContactsUtils.isGraphic("."));
+ }
+
+ public void testAreObjectsEqual() throws Exception {
+ assertTrue("null:null", ContactsUtils.areObjectsEqual(null, null));
+ assertTrue("1:1", ContactsUtils.areObjectsEqual(1, 1));
+
+ assertFalse("null:1", ContactsUtils.areObjectsEqual(null, 1));
+ assertFalse("1:null", ContactsUtils.areObjectsEqual(1, null));
+ assertFalse("1:2", ContactsUtils.areObjectsEqual(1, 2));
+ }
+
+ public void testAreIntentActionEqual() throws Exception {
+ assertTrue("1", ContactsUtils.areIntentActionEqual(null, null));
+ assertTrue("1", ContactsUtils.areIntentActionEqual(new Intent("a"), new Intent("a")));
+
+ assertFalse("11", ContactsUtils.areIntentActionEqual(new Intent("a"), null));
+ assertFalse("12", ContactsUtils.areIntentActionEqual(null, new Intent("a")));
+
+ assertFalse("21", ContactsUtils.areIntentActionEqual(new Intent("a"), new Intent()));
+ assertFalse("22", ContactsUtils.areIntentActionEqual(new Intent(), new Intent("b")));
+ assertFalse("23", ContactsUtils.areIntentActionEqual(new Intent("a"), new Intent("b")));
+ }
+
+ public void testImIntentCustom() throws Exception {
+ // Custom IM types have encoded authority. We send the imto Intent here, because
+ // legacy third party apps might not accept xmpp yet
+ final ContentValues values = new ContentValues();
+ values.put(Im.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ values.put(Im.TYPE, Im.TYPE_HOME);
+ values.put(Im.PROTOCOL, Im.PROTOCOL_CUSTOM);
+ values.put(Im.CUSTOM_PROTOCOL, TEST_PROTOCOL);
+ values.put(Im.DATA, TEST_ADDRESS);
+ final ImDataItem im = (ImDataItem) DataItem.createFrom(values);
+
+ final Pair<Intent, Intent> intents = ContactsUtils.buildImIntent(getContext(), im);
+ final Intent imIntent = intents.first;
+
+ assertEquals(Intent.ACTION_SENDTO, imIntent.getAction());
+
+ final Uri data = imIntent.getData();
+ assertEquals("imto", data.getScheme());
+ assertEquals(TEST_PROTOCOL, data.getAuthority());
+ assertEquals(TEST_ADDRESS, data.getPathSegments().get(0));
+
+ assertNull(intents.second);
+ }
+
+ public void testImIntent() throws Exception {
+ // Test GTalk XMPP URI. No chat capabilities provided
+ final ContentValues values = new ContentValues();
+ values.put(Im.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ values.put(Im.TYPE, Im.TYPE_HOME);
+ values.put(Im.PROTOCOL, Im.PROTOCOL_GOOGLE_TALK);
+ values.put(Im.DATA, TEST_ADDRESS);
+ final ImDataItem im = (ImDataItem) DataItem.createFrom(values);
+
+ final Pair<Intent, Intent> intents = ContactsUtils.buildImIntent(getContext(), im);
+ final Intent imIntent = intents.first;
+
+ assertEquals(Intent.ACTION_SENDTO, imIntent.getAction());
+ assertEquals("xmpp:" + TEST_ADDRESS + "?message", imIntent.getData().toString());
+
+ assertNull(intents.second);
+ }
+
+ public void testImIntentWithAudio() throws Exception {
+ // Test GTalk XMPP URI. Audio chat capabilities provided
+ final ContentValues values = new ContentValues();
+ values.put(Im.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ values.put(Im.TYPE, Im.TYPE_HOME);
+ values.put(Im.PROTOCOL, Im.PROTOCOL_GOOGLE_TALK);
+ values.put(Im.DATA, TEST_ADDRESS);
+ values.put(Im.CHAT_CAPABILITY, Im.CAPABILITY_HAS_VOICE | Im.CAPABILITY_HAS_VIDEO);
+ final ImDataItem im = (ImDataItem) DataItem.createFrom(values);
+
+ final Pair<Intent, Intent> intents = ContactsUtils.buildImIntent(getContext(), im);
+ final Intent imIntent = intents.first;
+
+ assertEquals(Intent.ACTION_SENDTO, imIntent.getAction());
+ assertEquals("xmpp:" + TEST_ADDRESS + "?message", imIntent.getData().toString());
+
+ final Intent secondaryIntent = intents.second;
+ assertEquals(Intent.ACTION_SENDTO, secondaryIntent.getAction());
+ assertEquals("xmpp:" + TEST_ADDRESS + "?call", secondaryIntent.getData().toString());
+ }
+
+ public void testImIntentWithVideo() throws Exception {
+ // Test GTalk XMPP URI. Video chat capabilities provided
+ final ContentValues values = new ContentValues();
+ values.put(Im.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ values.put(Im.TYPE, Im.TYPE_HOME);
+ values.put(Im.PROTOCOL, Im.PROTOCOL_GOOGLE_TALK);
+ values.put(Im.DATA, TEST_ADDRESS);
+ values.put(Im.CHAT_CAPABILITY, Im.CAPABILITY_HAS_VOICE | Im.CAPABILITY_HAS_VIDEO |
+ Im.CAPABILITY_HAS_VOICE);
+ final ImDataItem im = (ImDataItem) DataItem.createFrom(values);
+
+ final Pair<Intent, Intent> intents = ContactsUtils.buildImIntent(getContext(), im);
+ final Intent imIntent = intents.first;
+
+ assertEquals(Intent.ACTION_SENDTO, imIntent.getAction());
+ assertEquals("xmpp:" + TEST_ADDRESS + "?message", imIntent.getData().toString());
+
+ final Intent secondaryIntent = intents.second;
+ assertEquals(Intent.ACTION_SENDTO, secondaryIntent.getAction());
+ assertEquals("xmpp:" + TEST_ADDRESS + "?call", secondaryIntent.getData().toString());
+ }
+
+
+ public void testImEmailIntent() throws Exception {
+ // Email addresses are treated as Google Talk entries
+ // This test only tests the VIDEO+CAMERA case. The other cases have been addressed by the
+ // Im tests
+ final ContentValues values = new ContentValues();
+ values.put(Email.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+ values.put(Email.TYPE, Email.TYPE_HOME);
+ values.put(Email.DATA, TEST_ADDRESS);
+ values.put(Email.CHAT_CAPABILITY, Im.CAPABILITY_HAS_VOICE | Im.CAPABILITY_HAS_VIDEO |
+ Im.CAPABILITY_HAS_VOICE);
+ final ImDataItem im = ImDataItem.createFromEmail(
+ (EmailDataItem) DataItem.createFrom(values));
+
+ final Pair<Intent, Intent> intents = ContactsUtils.buildImIntent(getContext(), im);
+ final Intent imIntent = intents.first;
+
+ assertEquals(Intent.ACTION_SENDTO, imIntent.getAction());
+ assertEquals("xmpp:" + TEST_ADDRESS + "?message", imIntent.getData().toString());
+
+ final Intent secondaryIntent = intents.second;
+ assertEquals(Intent.ACTION_SENDTO, secondaryIntent.getAction());
+ assertEquals("xmpp:" + TEST_ADDRESS + "?call", secondaryIntent.getData().toString());
+ }
+}
diff --git a/tests/src/com/android/contacts/common/MoreContactUtilsTest.java b/tests/src/com/android/contacts/common/MoreContactUtilsTest.java
new file mode 100644
index 0000000..8d74455
--- /dev/null
+++ b/tests/src/com/android/contacts/common/MoreContactUtilsTest.java
@@ -0,0 +1,176 @@
+package com.android.contacts.common;
+
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import junit.framework.TestCase;
+
+/**
+ * Tests for MoreContactsUtils.
+ */
+@SmallTest
+public class MoreContactUtilsTest extends TestCase {
+
+ public void testShouldCollapse() throws Exception {
+ assertCollapses("1", true, null, null, null, null);
+ assertCollapses("2", true, "a", "b", "a", "b");
+
+ assertCollapses("11", false, "a", null, null, null);
+ assertCollapses("12", false, null, "a", null, null);
+ assertCollapses("13", false, null, null, "a", null);
+ assertCollapses("14", false, null, null, null, "a");
+
+ assertCollapses("21", false, "a", "b", null, null);
+ assertCollapses("22", false, "a", "b", "a", null);
+ assertCollapses("23", false, "a", "b", null, "b");
+ assertCollapses("24", false, "a", "b", "a", "x");
+ assertCollapses("25", false, "a", "b", "x", "b");
+
+ assertCollapses("31", false, null, null, "a", "b");
+ assertCollapses("32", false, "a", null, "a", "b");
+ assertCollapses("33", false, null, "b", "a", "b");
+ assertCollapses("34", false, "a", "x", "a", "b");
+ assertCollapses("35", false, "x", "b", "a", "b");
+
+ assertCollapses("41", true, Phone.CONTENT_ITEM_TYPE, null, Phone.CONTENT_ITEM_TYPE, null);
+ assertCollapses("42", true, Phone.CONTENT_ITEM_TYPE, "1", Phone.CONTENT_ITEM_TYPE, "1");
+
+ assertCollapses("51", false, Phone.CONTENT_ITEM_TYPE, "1", Phone.CONTENT_ITEM_TYPE, "2");
+ assertCollapses("52", false, Phone.CONTENT_ITEM_TYPE, "1", Phone.CONTENT_ITEM_TYPE, null);
+ assertCollapses("53", false, Phone.CONTENT_ITEM_TYPE, null, Phone.CONTENT_ITEM_TYPE, "2");
+
+ // Test phone numbers
+ assertCollapses("60", true, Phone.CONTENT_ITEM_TYPE, "1234567", Phone.CONTENT_ITEM_TYPE,
+ "1234567");
+ assertCollapses("61", false, Phone.CONTENT_ITEM_TYPE, "1234567", Phone.CONTENT_ITEM_TYPE,
+ "1234568");
+ assertCollapses("62", true, Phone.CONTENT_ITEM_TYPE, "1234567;0", Phone.CONTENT_ITEM_TYPE,
+ "1234567;0");
+ assertCollapses("63", false, Phone.CONTENT_ITEM_TYPE, "1234567;89321",
+ Phone.CONTENT_ITEM_TYPE, "1234567;89322");
+ assertCollapses("64", true, Phone.CONTENT_ITEM_TYPE, "1234567;89321",
+ Phone.CONTENT_ITEM_TYPE, "1234567;89321");
+ assertCollapses("65", false, Phone.CONTENT_ITEM_TYPE, "1234567;0111111111",
+ Phone.CONTENT_ITEM_TYPE, "1234567;");
+ assertCollapses("66", false, Phone.CONTENT_ITEM_TYPE, "12345675426;91970xxxxx",
+ Phone.CONTENT_ITEM_TYPE, "12345675426");
+ assertCollapses("67", false, Phone.CONTENT_ITEM_TYPE, "12345675426;23456xxxxx",
+ Phone.CONTENT_ITEM_TYPE, "12345675426;234567xxxx");
+ assertCollapses("68", true, Phone.CONTENT_ITEM_TYPE, "1234567;1234567;1234567",
+ Phone.CONTENT_ITEM_TYPE, "1234567;1234567;1234567");
+ assertCollapses("69", false, Phone.CONTENT_ITEM_TYPE, "1234567;1234567;1234567",
+ Phone.CONTENT_ITEM_TYPE, "1234567;1234567");
+
+ // test some numbers with country and area code
+ assertCollapses("70", true, Phone.CONTENT_ITEM_TYPE, "+49 (89) 12345678",
+ Phone.CONTENT_ITEM_TYPE, "+49 (89) 12345678");
+ assertCollapses("71", true, Phone.CONTENT_ITEM_TYPE, "+49 (89) 12345678",
+ Phone.CONTENT_ITEM_TYPE, "+49 (89)12345678");
+ assertCollapses("72", true, Phone.CONTENT_ITEM_TYPE, "+49 (8092) 1234",
+ Phone.CONTENT_ITEM_TYPE, "+49 (8092)1234");
+ assertCollapses("73", false, Phone.CONTENT_ITEM_TYPE, "0049 (8092) 1234",
+ Phone.CONTENT_ITEM_TYPE, "+49/80921234");
+ assertCollapses("74", false, Phone.CONTENT_ITEM_TYPE, "+49 (89) 12345678",
+ Phone.CONTENT_ITEM_TYPE, "+49 (89) 12345679");
+
+ // test special handling of collapsing country code for NANP region only
+ // This is non symmetrical, because we prefer the number with the +1.
+ assertEquals("100", true, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "+1 (415) 555-1212", Phone.CONTENT_ITEM_TYPE, "(415) 555-1212"));
+ assertEquals("101", true, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "+14155551212", Phone.CONTENT_ITEM_TYPE, "4155551212"));
+ assertEquals("102", false, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "(415) 555-1212", Phone.CONTENT_ITEM_TYPE, "+1 (415) 555-1212"));
+ assertEquals("103", false, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "4155551212", Phone.CONTENT_ITEM_TYPE, "+14155551212"));
+ // Require explicit +1 country code declaration to collapse
+ assertEquals("104", false, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "1-415-555-1212", Phone.CONTENT_ITEM_TYPE, "415-555-1212"));
+ assertEquals("105", false, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "14155551212", Phone.CONTENT_ITEM_TYPE, "4155551212"));
+ assertEquals("106", false, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "+1 (415) 555-1212", Phone.CONTENT_ITEM_TYPE, " 1 (415) 555-1212"));
+ assertEquals("107", false, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "+14155551212", Phone.CONTENT_ITEM_TYPE, " 14155551212"));
+ assertEquals("108", false, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "1 (415) 555-1212", Phone.CONTENT_ITEM_TYPE, "+1 (415) 555-1212"));
+ assertEquals("109", false, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "14155551212", Phone.CONTENT_ITEM_TYPE, "+14155551212"));
+
+ // test some numbers with wait symbol and area code
+ assertCollapses("200", true, Phone.CONTENT_ITEM_TYPE, "+49 (8092) 1234;89321",
+ Phone.CONTENT_ITEM_TYPE, "+49/80921234;89321");
+ assertCollapses("201", false, Phone.CONTENT_ITEM_TYPE, "+49 (8092) 1234;89321",
+ Phone.CONTENT_ITEM_TYPE, "+49/80921235;89321");
+ assertCollapses("202", false, Phone.CONTENT_ITEM_TYPE, "+49 (8092) 1234;89322",
+ Phone.CONTENT_ITEM_TYPE, "+49/80921234;89321");
+ assertCollapses("203", true, Phone.CONTENT_ITEM_TYPE, "1234567;+49 (8092) 1234",
+ Phone.CONTENT_ITEM_TYPE, "1234567;+49/80921234");
+
+ assertCollapses("300", true, Phone.CONTENT_ITEM_TYPE, "", Phone.CONTENT_ITEM_TYPE, "");
+
+ assertCollapses("301", false, Phone.CONTENT_ITEM_TYPE, "1", Phone.CONTENT_ITEM_TYPE, "");
+
+ assertCollapses("302", false, Phone.CONTENT_ITEM_TYPE, "", Phone.CONTENT_ITEM_TYPE, "1");
+
+ assertCollapses("303", true, Phone.CONTENT_ITEM_TYPE, "---", Phone.CONTENT_ITEM_TYPE, "---");
+
+ assertCollapses("304", false, Phone.CONTENT_ITEM_TYPE, "1-/().", Phone.CONTENT_ITEM_TYPE,
+ "--$%1");
+
+ // Test numbers using keypad letters. This is non-symmetrical, because we prefer
+ // the version with letters.
+ assertEquals("400", true, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "abcdefghijklmnopqrstuvwxyz", Phone.CONTENT_ITEM_TYPE,
+ "22233344455566677778889999"));
+ assertEquals("401", false, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "22233344455566677778889999", Phone.CONTENT_ITEM_TYPE,
+ "abcdefghijklmnopqrstuvwxyz"));
+
+ assertCollapses("402", false, Phone.CONTENT_ITEM_TYPE, "1;2", Phone.CONTENT_ITEM_TYPE,
+ "12");
+
+ assertCollapses("403", false, Phone.CONTENT_ITEM_TYPE, "1,2", Phone.CONTENT_ITEM_TYPE,
+ "12");
+ }
+
+ public void testShouldCollapse_collapsesSameNumberWithDifferentFormats() {
+ assertEquals("1", true, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "555-1212", Phone.CONTENT_ITEM_TYPE, "5551212"));
+ assertEquals("1", true, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "415-555-1212", Phone.CONTENT_ITEM_TYPE, "(415) 555-1212"));
+ assertEquals("2", true, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "4155551212", Phone.CONTENT_ITEM_TYPE, "(415) 555-1212"));
+ assertEquals("3", true, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "1-415-555-1212", Phone.CONTENT_ITEM_TYPE, "1 (415) 555-1212"));
+ assertEquals("4", true, MoreContactUtils.shouldCollapse(Phone.CONTENT_ITEM_TYPE,
+ "14155551212", Phone.CONTENT_ITEM_TYPE, "1 (415) 555-1212"));
+ }
+
+ private void assertCollapses(String message, boolean expected, CharSequence mimetype1,
+ CharSequence data1, CharSequence mimetype2, CharSequence data2) {
+ assertEquals(message, expected, MoreContactUtils.shouldCollapse(mimetype1, data1, mimetype2,
+ data2));
+ assertEquals(message, expected, MoreContactUtils.shouldCollapse(mimetype2, data2, mimetype1,
+ data1));
+
+ // If data1 and data2 are the same instance, make sure the same test passes with different
+ // instances.
+ if (data1 == data2 && data1 != null) {
+ // Create a different instance
+ final CharSequence data2_newref = new StringBuilder(data2).append("").toString();
+
+ if (data1 == data2_newref) {
+ // In some cases no matter what we do the runtime reuses the same instance, so
+ // we can't do the "different instance" test.
+ return;
+ }
+
+ // we have two different instances, now make sure we get the same result as before
+ assertEquals(message, expected, MoreContactUtils.shouldCollapse(mimetype1, data1,
+ mimetype2, data2_newref));
+ assertEquals(message, expected, MoreContactUtils.shouldCollapse(mimetype2, data2_newref,
+ mimetype1, data1));
+ }
+ }
+}
diff --git a/tests/src/com/android/contacts/common/RawContactDeltaListTests.java b/tests/src/com/android/contacts/common/RawContactDeltaListTests.java
new file mode 100644
index 0000000..77acb98
--- /dev/null
+++ b/tests/src/com/android/contacts/common/RawContactDeltaListTests.java
@@ -0,0 +1,608 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common;
+
+import android.content.ContentProviderOperation;
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+import android.provider.BaseColumns;
+import android.provider.ContactsContract.AggregationExceptions;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.RawContacts;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.LargeTest;
+
+import com.android.contacts.common.RawContactModifierTests.MockContactsSource;
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.model.CPOWrapper;
+import com.android.contacts.common.model.RawContact;
+import com.android.contacts.common.model.RawContactDelta;
+import com.android.contacts.common.model.ValuesDelta;
+import com.android.contacts.common.model.RawContactDeltaList;
+import com.android.contacts.common.model.RawContactModifier;
+import com.android.contacts.common.model.account.AccountType;
+import com.google.common.collect.Lists;
+
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collections;
+
+/**
+ * Tests for {@link RawContactDeltaList} which focus on "diff" operations that should
+ * create {@link AggregationExceptions} in certain cases.
+ */
+@LargeTest
+public class RawContactDeltaListTests extends AndroidTestCase {
+ public static final String TAG = RawContactDeltaListTests.class.getSimpleName();
+
+ // From android.content.ContentProviderOperation
+ public static final int TYPE_INSERT = 1;
+ public static final int TYPE_UPDATE = 2;
+ public static final int TYPE_DELETE = 3;
+ public static final int TYPE_ASSERT = 4;
+
+ private static final long CONTACT_FIRST = 1;
+ private static final long CONTACT_SECOND = 2;
+
+ public static final long CONTACT_BOB = 10;
+ public static final long CONTACT_MARY = 11;
+
+ public static final long PHONE_RED = 20;
+ public static final long PHONE_GREEN = 21;
+ public static final long PHONE_BLUE = 22;
+
+ public static final long EMAIL_YELLOW = 25;
+
+ public static final long VER_FIRST = 100;
+ public static final long VER_SECOND = 200;
+
+ public static final String TEST_PHONE = "555-1212";
+ public static final String TEST_ACCOUNT = "org.example.test";
+
+ public RawContactDeltaListTests() {
+ super();
+ }
+
+ @Override
+ public void setUp() {
+ mContext = getContext();
+ }
+
+ /**
+ * Build a {@link AccountType} that has various odd constraints for
+ * testing purposes.
+ */
+ protected AccountType getAccountType() {
+ return new MockContactsSource();
+ }
+
+ static ContentValues getValues(ContentProviderOperation operation)
+ throws NoSuchFieldException, IllegalAccessException {
+ final Field field = ContentProviderOperation.class.getDeclaredField("mValues");
+ field.setAccessible(true);
+ return (ContentValues) field.get(operation);
+ }
+
+ static RawContactDelta getUpdate(Context context, long rawContactId) {
+ final RawContact before = RawContactDeltaTests.getRawContact(context, rawContactId,
+ RawContactDeltaTests.TEST_PHONE_ID);
+ return RawContactDelta.fromBefore(before);
+ }
+
+ static RawContactDelta getInsert() {
+ final ContentValues after = new ContentValues();
+ after.put(RawContacts.ACCOUNT_NAME, RawContactDeltaTests.TEST_ACCOUNT_NAME);
+ after.put(RawContacts.SEND_TO_VOICEMAIL, 1);
+
+ final ValuesDelta values = ValuesDelta.fromAfter(after);
+ return new RawContactDelta(values);
+ }
+
+ static RawContactDeltaList buildSet(RawContactDelta... deltas) {
+ final RawContactDeltaList set = new RawContactDeltaList();
+ Collections.addAll(set, deltas);
+ return set;
+ }
+
+ static RawContactDelta buildBeforeEntity(Context context, long rawContactId, long version,
+ ContentValues... entries) {
+ // Build an existing contact read from database
+ final ContentValues contact = new ContentValues();
+ contact.put(RawContacts.VERSION, version);
+ contact.put(RawContacts._ID, rawContactId);
+ final RawContact before = new RawContact(contact);
+ for (ContentValues entry : entries) {
+ before.addDataItemValues(entry);
+ }
+ return RawContactDelta.fromBefore(before);
+ }
+
+ static RawContactDelta buildAfterEntity(ContentValues... entries) {
+ // Build an existing contact read from database
+ final ContentValues contact = new ContentValues();
+ contact.put(RawContacts.ACCOUNT_TYPE, TEST_ACCOUNT);
+ final RawContactDelta after = new RawContactDelta(ValuesDelta.fromAfter(contact));
+ for (ContentValues entry : entries) {
+ after.addEntry(ValuesDelta.fromAfter(entry));
+ }
+ return after;
+ }
+
+ static ContentValues buildPhone(long phoneId) {
+ return buildPhone(phoneId, Long.toString(phoneId));
+ }
+
+ static ContentValues buildPhone(long phoneId, String value) {
+ final ContentValues values = new ContentValues();
+ values.put(Data._ID, phoneId);
+ values.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ values.put(Phone.NUMBER, value);
+ values.put(Phone.TYPE, Phone.TYPE_HOME);
+ return values;
+ }
+
+ static ContentValues buildEmail(long emailId) {
+ final ContentValues values = new ContentValues();
+ values.put(Data._ID, emailId);
+ values.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+ values.put(Email.DATA, Long.toString(emailId));
+ values.put(Email.TYPE, Email.TYPE_HOME);
+ return values;
+ }
+
+ static void insertPhone(RawContactDeltaList set, long rawContactId, ContentValues values) {
+ final RawContactDelta match = set.getByRawContactId(rawContactId);
+ match.addEntry(ValuesDelta.fromAfter(values));
+ }
+
+ static ValuesDelta getPhone(RawContactDeltaList set, long rawContactId, long dataId) {
+ final RawContactDelta match = set.getByRawContactId(rawContactId);
+ return match.getEntry(dataId);
+ }
+
+ static void assertDiffPattern(RawContactDelta delta, CPOWrapper... pattern) {
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ delta.buildAssertWrapper(diff);
+ delta.buildDiffWrapper(diff);
+ assertDiffPattern(diff, pattern);
+ }
+
+ static void assertDiffPattern(RawContactDeltaList set, CPOWrapper... pattern) {
+ assertDiffPattern(set.buildDiffWrapper(), pattern);
+ }
+
+ static void assertDiffPattern(ArrayList<CPOWrapper> diff, CPOWrapper... pattern) {
+ assertEquals("Unexpected operations", pattern.length, diff.size());
+ for (int i = 0; i < pattern.length; i++) {
+ final CPOWrapper expected = pattern[i];
+ final CPOWrapper found = diff.get(i);
+
+ assertEquals("Unexpected uri",
+ expected.getOperation().getUri(), found.getOperation().getUri());
+
+ final String expectedType = getTypeString(expected);
+ final String foundType = getTypeString(found);
+ assertEquals("Unexpected type", expectedType, foundType);
+
+ if (CompatUtils.isDeleteCompat(expected)) continue;
+
+ try {
+ final ContentValues expectedValues = getValues(expected.getOperation());
+ final ContentValues foundValues = getValues(found.getOperation());
+
+ expectedValues.remove(BaseColumns._ID);
+ foundValues.remove(BaseColumns._ID);
+
+ assertEquals("Unexpected values", expectedValues, foundValues);
+ } catch (NoSuchFieldException e) {
+ fail(e.toString());
+ } catch (IllegalAccessException e) {
+ fail(e.toString());
+ }
+ }
+ }
+
+ static String getTypeString(CPOWrapper cpoWrapper) {
+ if (CompatUtils.isAssertQueryCompat(cpoWrapper)) {
+ return "TYPE_ASSERT";
+ } else if (CompatUtils.isInsertCompat(cpoWrapper)) {
+ return "TYPE_INSERT";
+ } else if (CompatUtils.isUpdateCompat(cpoWrapper)) {
+ return "TYPE_UPDATE";
+ } else if (CompatUtils.isDeleteCompat(cpoWrapper)) {
+ return "TYPE_DELETE";
+ }
+ return "TYPE_UNKNOWN";
+ }
+
+ static CPOWrapper buildAssertVersion(long version) {
+ final ContentValues values = new ContentValues();
+ values.put(RawContacts.VERSION, version);
+ return buildCPOWrapper(RawContacts.CONTENT_URI, TYPE_ASSERT, values);
+ }
+
+ static CPOWrapper buildAggregationModeUpdate(int mode) {
+ final ContentValues values = new ContentValues();
+ values.put(RawContacts.AGGREGATION_MODE, mode);
+ return buildCPOWrapper(RawContacts.CONTENT_URI, TYPE_UPDATE, values);
+ }
+
+ static CPOWrapper buildUpdateAggregationSuspended() {
+ return buildAggregationModeUpdate(RawContacts.AGGREGATION_MODE_SUSPENDED);
+ }
+
+ static CPOWrapper buildUpdateAggregationDefault() {
+ return buildAggregationModeUpdate(RawContacts.AGGREGATION_MODE_DEFAULT);
+ }
+
+ static CPOWrapper buildUpdateAggregationKeepTogether(long rawContactId) {
+ final ContentValues values = new ContentValues();
+ values.put(AggregationExceptions.RAW_CONTACT_ID1, rawContactId);
+ values.put(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_TOGETHER);
+ return buildCPOWrapper(AggregationExceptions.CONTENT_URI, TYPE_UPDATE, values);
+ }
+
+ static ContentValues buildDataInsert(ValuesDelta values, long rawContactId) {
+ final ContentValues insertValues = values.getCompleteValues();
+ insertValues.put(Data.RAW_CONTACT_ID, rawContactId);
+ return insertValues;
+ }
+
+ static CPOWrapper buildDelete(Uri uri) {
+ return buildCPOWrapper(uri, TYPE_DELETE, (ContentValues) null);
+ }
+
+ static ContentProviderOperation buildOper(Uri uri, int type, ValuesDelta values) {
+ return buildOper(uri, type, values.getCompleteValues());
+ }
+
+ static ContentProviderOperation buildOper(Uri uri, int type, ContentValues values) {
+ switch (type) {
+ case TYPE_ASSERT:
+ return ContentProviderOperation.newAssertQuery(uri).withValues(values).build();
+ case TYPE_INSERT:
+ return ContentProviderOperation.newInsert(uri).withValues(values).build();
+ case TYPE_UPDATE:
+ return ContentProviderOperation.newUpdate(uri).withValues(values).build();
+ case TYPE_DELETE:
+ return ContentProviderOperation.newDelete(uri).build();
+ }
+ return null;
+ }
+
+ static CPOWrapper buildCPOWrapper(Uri uri, int type, ContentValues values) {
+ if (type == TYPE_ASSERT || type == TYPE_INSERT || type == TYPE_UPDATE
+ || type == TYPE_DELETE) {
+ return new CPOWrapper(buildOper(uri, type, values), type);
+ }
+ return null;
+ }
+
+ static Long getVersion(RawContactDeltaList set, Long rawContactId) {
+ return set.getByRawContactId(rawContactId).getValues().getAsLong(RawContacts.VERSION);
+ }
+
+ /**
+ * Count number of {@link AggregationExceptions} updates contained in the
+ * given list of {@link CPOWrapper}.
+ */
+ static int countExceptionUpdates(ArrayList<CPOWrapper> diff) {
+ int updateCount = 0;
+ for (CPOWrapper cpoWrapper : diff) {
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ if (AggregationExceptions.CONTENT_URI.equals(oper.getUri())
+ && CompatUtils.isUpdateCompat(cpoWrapper)) {
+ updateCount++;
+ }
+ }
+ return updateCount;
+ }
+
+ public void testInsert() {
+ final RawContactDelta insert = getInsert();
+ final RawContactDeltaList set = buildSet(insert);
+
+ // Inserting single shouldn't create rules
+ final ArrayList<CPOWrapper> diff = set.buildDiffWrapper();
+ final int exceptionCount = countExceptionUpdates(diff);
+ assertEquals("Unexpected exception updates", 0, exceptionCount);
+ }
+
+ public void testUpdateUpdate() {
+ final RawContactDelta updateFirst = getUpdate(mContext, CONTACT_FIRST);
+ final RawContactDelta updateSecond = getUpdate(mContext, CONTACT_SECOND);
+ final RawContactDeltaList set = buildSet(updateFirst, updateSecond);
+
+ // Updating two existing shouldn't create rules
+ final ArrayList<CPOWrapper> diff = set.buildDiffWrapper();
+ final int exceptionCount = countExceptionUpdates(diff);
+ assertEquals("Unexpected exception updates", 0, exceptionCount);
+ }
+
+ public void testUpdateInsert() {
+ final RawContactDelta update = getUpdate(mContext, CONTACT_FIRST);
+ final RawContactDelta insert = getInsert();
+ final RawContactDeltaList set = buildSet(update, insert);
+
+ // New insert should only create one rule
+ final ArrayList<CPOWrapper> diff = set.buildDiffWrapper();
+ final int exceptionCount = countExceptionUpdates(diff);
+ assertEquals("Unexpected exception updates", 1, exceptionCount);
+ }
+
+ public void testInsertUpdateInsert() {
+ final RawContactDelta insertFirst = getInsert();
+ final RawContactDelta update = getUpdate(mContext, CONTACT_FIRST);
+ final RawContactDelta insertSecond = getInsert();
+ final RawContactDeltaList set = buildSet(insertFirst, update, insertSecond);
+
+ // Two inserts should create two rules to bind against single existing
+ final ArrayList<CPOWrapper> diff = set.buildDiffWrapper();
+ final int exceptionCount = countExceptionUpdates(diff);
+ assertEquals("Unexpected exception updates", 2, exceptionCount);
+ }
+
+ public void testInsertInsertInsert() {
+ final RawContactDelta insertFirst = getInsert();
+ final RawContactDelta insertSecond = getInsert();
+ final RawContactDelta insertThird = getInsert();
+ final RawContactDeltaList set = buildSet(insertFirst, insertSecond, insertThird);
+
+ // Three new inserts should create only two binding rules
+ final ArrayList<CPOWrapper> diff = set.buildDiffWrapper();
+ final int exceptionCount = countExceptionUpdates(diff);
+ assertEquals("Unexpected exception updates", 2, exceptionCount);
+ }
+
+ public void testMergeDataRemoteInsert() {
+ final RawContactDeltaList first = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_FIRST, buildPhone(PHONE_RED)));
+ final RawContactDeltaList second = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_SECOND, buildPhone(PHONE_RED), buildPhone(PHONE_GREEN)));
+
+ // Merge in second version, verify they match
+ final RawContactDeltaList merged = RawContactDeltaList.mergeAfter(second, first);
+ assertEquals("Unexpected change when merging", second, merged);
+ }
+
+ public void testMergeDataLocalUpdateRemoteInsert() {
+ final RawContactDeltaList first = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_FIRST, buildPhone(PHONE_RED)));
+ final RawContactDeltaList second = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_SECOND, buildPhone(PHONE_RED), buildPhone(PHONE_GREEN)));
+
+ // Change the local number to trigger update
+ final ValuesDelta phone = getPhone(first, CONTACT_BOB, PHONE_RED);
+ phone.put(Phone.NUMBER, TEST_PHONE);
+
+ assertDiffPattern(first,
+ buildAssertVersion(VER_FIRST),
+ buildUpdateAggregationSuspended(),
+ buildCPOWrapper(Data.CONTENT_URI, TYPE_UPDATE, phone.getAfter()),
+ buildUpdateAggregationDefault());
+
+ // Merge in the second version, verify diff matches
+ final RawContactDeltaList merged = RawContactDeltaList.mergeAfter(second, first);
+ assertDiffPattern(merged,
+ buildAssertVersion(VER_SECOND),
+ buildUpdateAggregationSuspended(),
+ buildCPOWrapper(Data.CONTENT_URI, TYPE_UPDATE, phone.getAfter()),
+ buildUpdateAggregationDefault());
+ }
+
+ public void testMergeDataLocalUpdateRemoteDelete() {
+ final RawContactDeltaList first = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_FIRST, buildPhone(PHONE_RED)));
+ final RawContactDeltaList second = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_SECOND, buildPhone(PHONE_GREEN)));
+
+ // Change the local number to trigger update
+ final ValuesDelta phone = getPhone(first, CONTACT_BOB, PHONE_RED);
+ phone.put(Phone.NUMBER, TEST_PHONE);
+
+ assertDiffPattern(first,
+ buildAssertVersion(VER_FIRST),
+ buildUpdateAggregationSuspended(),
+ buildCPOWrapper(Data.CONTENT_URI, TYPE_UPDATE, phone.getAfter()),
+ buildUpdateAggregationDefault());
+
+ // Merge in the second version, verify that our update changed to
+ // insert, since RED was deleted on remote side
+ final RawContactDeltaList merged = RawContactDeltaList.mergeAfter(second, first);
+ assertDiffPattern(merged,
+ buildAssertVersion(VER_SECOND),
+ buildUpdateAggregationSuspended(),
+ buildCPOWrapper(Data.CONTENT_URI, TYPE_INSERT, buildDataInsert(phone, CONTACT_BOB)),
+ buildUpdateAggregationDefault());
+ }
+
+ public void testMergeDataLocalDeleteRemoteUpdate() {
+ final RawContactDeltaList first = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_FIRST, buildPhone(PHONE_RED)));
+ final RawContactDeltaList second = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_SECOND, buildPhone(PHONE_RED, TEST_PHONE)));
+
+ // Delete phone locally
+ final ValuesDelta phone = getPhone(first, CONTACT_BOB, PHONE_RED);
+ phone.markDeleted();
+
+ assertDiffPattern(first,
+ buildAssertVersion(VER_FIRST),
+ buildUpdateAggregationSuspended(),
+ buildDelete(Data.CONTENT_URI),
+ buildUpdateAggregationDefault());
+
+ // Merge in the second version, verify that our delete remains
+ final RawContactDeltaList merged = RawContactDeltaList.mergeAfter(second, first);
+ assertDiffPattern(merged,
+ buildAssertVersion(VER_SECOND),
+ buildUpdateAggregationSuspended(),
+ buildDelete(Data.CONTENT_URI),
+ buildUpdateAggregationDefault());
+ }
+
+ public void testMergeDataLocalInsertRemoteInsert() {
+ final RawContactDeltaList first = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_FIRST, buildPhone(PHONE_RED)));
+ final RawContactDeltaList second = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_SECOND, buildPhone(PHONE_RED), buildPhone(PHONE_GREEN)));
+
+ // Insert new phone locally
+ final ValuesDelta bluePhone = ValuesDelta.fromAfter(buildPhone(PHONE_BLUE));
+ first.getByRawContactId(CONTACT_BOB).addEntry(bluePhone);
+ assertDiffPattern(first,
+ buildAssertVersion(VER_FIRST),
+ buildUpdateAggregationSuspended(),
+ buildCPOWrapper(Data.CONTENT_URI, TYPE_INSERT, buildDataInsert(bluePhone, CONTACT_BOB)),
+ buildUpdateAggregationDefault());
+
+ // Merge in the second version, verify that our insert remains
+ final RawContactDeltaList merged = RawContactDeltaList.mergeAfter(second, first);
+ assertDiffPattern(merged,
+ buildAssertVersion(VER_SECOND),
+ buildUpdateAggregationSuspended(),
+ buildCPOWrapper(Data.CONTENT_URI, TYPE_INSERT, buildDataInsert(bluePhone, CONTACT_BOB)),
+ buildUpdateAggregationDefault());
+ }
+
+ public void testMergeRawContactLocalInsertRemoteInsert() {
+ final RawContactDeltaList first = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_FIRST, buildPhone(PHONE_RED)));
+ final RawContactDeltaList second = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_SECOND, buildPhone(PHONE_RED)), buildBeforeEntity(mContext, CONTACT_MARY,
+ VER_SECOND, buildPhone(PHONE_RED)));
+
+ // Add new contact locally, should remain insert
+ final ContentValues joePhoneInsert = buildPhone(PHONE_BLUE);
+ final RawContactDelta joeContact = buildAfterEntity(joePhoneInsert);
+ final ContentValues joeContactInsert = joeContact.getValues().getCompleteValues();
+ joeContactInsert.put(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_SUSPENDED);
+ first.add(joeContact);
+ assertDiffPattern(first,
+ buildAssertVersion(VER_FIRST),
+ buildCPOWrapper(RawContacts.CONTENT_URI, TYPE_INSERT, joeContactInsert),
+ buildCPOWrapper(Data.CONTENT_URI, TYPE_INSERT, joePhoneInsert),
+ buildAggregationModeUpdate(RawContacts.AGGREGATION_MODE_DEFAULT),
+ buildUpdateAggregationKeepTogether(CONTACT_BOB));
+
+ // Merge in the second version, verify that our insert remains
+ final RawContactDeltaList merged = RawContactDeltaList.mergeAfter(second, first);
+ assertDiffPattern(merged,
+ buildAssertVersion(VER_SECOND),
+ buildAssertVersion(VER_SECOND),
+ buildCPOWrapper(RawContacts.CONTENT_URI, TYPE_INSERT, joeContactInsert),
+ buildCPOWrapper(Data.CONTENT_URI, TYPE_INSERT, joePhoneInsert),
+ buildAggregationModeUpdate(RawContacts.AGGREGATION_MODE_DEFAULT),
+ buildUpdateAggregationKeepTogether(CONTACT_BOB));
+ }
+
+ public void testMergeRawContactLocalDeleteRemoteDelete() {
+ final RawContactDeltaList first = buildSet(
+ buildBeforeEntity(mContext, CONTACT_BOB, VER_FIRST, buildPhone(PHONE_RED)),
+ buildBeforeEntity(mContext, CONTACT_MARY, VER_FIRST, buildPhone(PHONE_RED)));
+ final RawContactDeltaList second = buildSet(
+ buildBeforeEntity(mContext, CONTACT_BOB, VER_SECOND, buildPhone(PHONE_RED)));
+
+ // Remove contact locally
+ first.getByRawContactId(CONTACT_MARY).markDeleted();
+ assertDiffPattern(first,
+ buildAssertVersion(VER_FIRST),
+ buildAssertVersion(VER_FIRST),
+ buildDelete(RawContacts.CONTENT_URI));
+
+ // Merge in the second version, verify that our delete isn't needed
+ final RawContactDeltaList merged = RawContactDeltaList.mergeAfter(second, first);
+ assertDiffPattern(merged);
+ }
+
+ public void testMergeRawContactLocalUpdateRemoteDelete() {
+ final RawContactDeltaList first = buildSet(
+ buildBeforeEntity(mContext, CONTACT_BOB, VER_FIRST, buildPhone(PHONE_RED)),
+ buildBeforeEntity(mContext, CONTACT_MARY, VER_FIRST, buildPhone(PHONE_RED)));
+ final RawContactDeltaList second = buildSet(
+ buildBeforeEntity(mContext, CONTACT_BOB, VER_SECOND, buildPhone(PHONE_RED)));
+
+ // Perform local update
+ final ValuesDelta phone = getPhone(first, CONTACT_MARY, PHONE_RED);
+ phone.put(Phone.NUMBER, TEST_PHONE);
+ assertDiffPattern(first,
+ buildAssertVersion(VER_FIRST),
+ buildAssertVersion(VER_FIRST),
+ buildUpdateAggregationSuspended(),
+ buildCPOWrapper(Data.CONTENT_URI, TYPE_UPDATE, phone.getAfter()),
+ buildUpdateAggregationDefault());
+
+ final ContentValues phoneInsert = phone.getCompleteValues();
+ final ContentValues contactInsert = first.getByRawContactId(CONTACT_MARY).getValues()
+ .getCompleteValues();
+ contactInsert.put(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_SUSPENDED);
+
+ // Merge and verify that update turned into insert
+ final RawContactDeltaList merged = RawContactDeltaList.mergeAfter(second, first);
+ assertDiffPattern(merged,
+ buildAssertVersion(VER_SECOND),
+ buildCPOWrapper(RawContacts.CONTENT_URI, TYPE_INSERT, contactInsert),
+ buildCPOWrapper(Data.CONTENT_URI, TYPE_INSERT, phoneInsert),
+ buildAggregationModeUpdate(RawContacts.AGGREGATION_MODE_DEFAULT),
+ buildUpdateAggregationKeepTogether(CONTACT_BOB));
+ }
+
+ public void testMergeUsesNewVersion() {
+ final RawContactDeltaList first = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_FIRST, buildPhone(PHONE_RED)));
+ final RawContactDeltaList second = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_SECOND, buildPhone(PHONE_RED)));
+
+ assertEquals((Long)VER_FIRST, getVersion(first, CONTACT_BOB));
+ assertEquals((Long)VER_SECOND, getVersion(second, CONTACT_BOB));
+
+ final RawContactDeltaList merged = RawContactDeltaList.mergeAfter(second, first);
+ assertEquals((Long)VER_SECOND, getVersion(merged, CONTACT_BOB));
+ }
+
+ public void testMergeAfterEnsureAndTrim() {
+ final RawContactDeltaList first = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_FIRST, buildEmail(EMAIL_YELLOW)));
+ final RawContactDeltaList second = buildSet(buildBeforeEntity(mContext, CONTACT_BOB,
+ VER_SECOND, buildEmail(EMAIL_YELLOW)));
+
+ // Ensure we have at least one phone
+ final AccountType source = getAccountType();
+ final RawContactDelta bobContact = first.getByRawContactId(CONTACT_BOB);
+ RawContactModifier.ensureKindExists(bobContact, source, Phone.CONTENT_ITEM_TYPE);
+ final ValuesDelta bobPhone = bobContact.getSuperPrimaryEntry(Phone.CONTENT_ITEM_TYPE, true);
+
+ // Make sure the update would insert a row
+ assertDiffPattern(first,
+ buildAssertVersion(VER_FIRST),
+ buildUpdateAggregationSuspended(),
+ buildCPOWrapper(Data.CONTENT_URI, TYPE_INSERT, buildDataInsert(bobPhone, CONTACT_BOB)),
+ buildUpdateAggregationDefault());
+
+ // Trim values and ensure that we don't insert things
+ RawContactModifier.trimEmpty(bobContact, source);
+ assertDiffPattern(first);
+
+ // Now re-parent the change, which should remain no-op
+ final RawContactDeltaList merged = RawContactDeltaList.mergeAfter(second, first);
+ assertDiffPattern(merged);
+ }
+}
diff --git a/tests/src/com/android/contacts/common/RawContactDeltaTests.java b/tests/src/com/android/contacts/common/RawContactDeltaTests.java
new file mode 100644
index 0000000..e4690d9
--- /dev/null
+++ b/tests/src/com/android/contacts/common/RawContactDeltaTests.java
@@ -0,0 +1,383 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common;
+
+import android.content.ContentProviderOperation;
+import android.content.ContentValues;
+import android.content.Context;
+import android.os.Build;
+import android.os.Parcel;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.RawContacts;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.LargeTest;
+
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.model.BuilderWrapper;
+import com.android.contacts.common.model.CPOWrapper;
+import com.android.contacts.common.model.RawContact;
+import com.android.contacts.common.model.RawContactDelta;
+import com.android.contacts.common.model.ValuesDelta;
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+
+/**
+ * Tests for {@link RawContactDelta} and {@link ValuesDelta}. These tests
+ * focus on passing changes across {@link Parcel}, and verifying that they
+ * correctly build expected "diff" operations.
+ */
+@LargeTest
+public class RawContactDeltaTests extends AndroidTestCase {
+ public static final String TAG = "EntityDeltaTests";
+
+ public static final long TEST_CONTACT_ID = 12;
+ public static final long TEST_PHONE_ID = 24;
+
+ public static final String TEST_PHONE_NUMBER_1 = "218-555-1111";
+ public static final String TEST_PHONE_NUMBER_2 = "218-555-2222";
+
+ public static final String TEST_ACCOUNT_NAME = "TEST";
+
+ public RawContactDeltaTests() {
+ super();
+ }
+
+ @Override
+ public void setUp() {
+ mContext = getContext();
+ }
+
+ public static RawContact getRawContact(Context context, long contactId, long phoneId) {
+ // Build an existing contact read from database
+ final ContentValues contact = new ContentValues();
+ contact.put(RawContacts.VERSION, 43);
+ contact.put(RawContacts._ID, contactId);
+
+ final ContentValues phone = new ContentValues();
+ phone.put(Data._ID, phoneId);
+ phone.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ phone.put(Phone.NUMBER, TEST_PHONE_NUMBER_1);
+ phone.put(Phone.TYPE, Phone.TYPE_HOME);
+
+ final RawContact before = new RawContact(contact);
+ before.addDataItemValues(phone);
+ return before;
+ }
+
+ /**
+ * Test that {@link RawContactDelta#mergeAfter(RawContactDelta)} correctly passes
+ * any changes through the {@link Parcel} object. This enforces that
+ * {@link RawContactDelta} should be identical when serialized against the same
+ * "before" {@link RawContact}.
+ */
+ public void testParcelChangesNone() {
+ final RawContact before = getRawContact(mContext, TEST_CONTACT_ID, TEST_PHONE_ID);
+ final RawContactDelta source = RawContactDelta.fromBefore(before);
+ final RawContactDelta dest = RawContactDelta.fromBefore(before);
+
+ // Merge modified values and assert they match
+ final RawContactDelta merged = RawContactDelta.mergeAfter(dest, source);
+ assertEquals("Unexpected change when merging", source, merged);
+ }
+
+ public void testParcelChangesInsert() {
+ final RawContact before = getRawContact(mContext, TEST_CONTACT_ID, TEST_PHONE_ID);
+ final RawContactDelta source = RawContactDelta.fromBefore(before);
+ final RawContactDelta dest = RawContactDelta.fromBefore(before);
+
+ // Add a new row and pass across parcel, should be same
+ final ContentValues phone = new ContentValues();
+ phone.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ phone.put(Phone.NUMBER, TEST_PHONE_NUMBER_2);
+ phone.put(Phone.TYPE, Phone.TYPE_WORK);
+ source.addEntry(ValuesDelta.fromAfter(phone));
+
+ // Merge modified values and assert they match
+ final RawContactDelta merged = RawContactDelta.mergeAfter(dest, source);
+ assertEquals("Unexpected change when merging", source, merged);
+ }
+
+ public void testParcelChangesUpdate() {
+ // Update existing row and pass across parcel, should be same
+ final RawContact before = getRawContact(mContext, TEST_CONTACT_ID, TEST_PHONE_ID);
+ final RawContactDelta source = RawContactDelta.fromBefore(before);
+ final RawContactDelta dest = RawContactDelta.fromBefore(before);
+
+ final ValuesDelta child = source.getEntry(TEST_PHONE_ID);
+ child.put(Phone.NUMBER, TEST_PHONE_NUMBER_2);
+
+ // Merge modified values and assert they match
+ final RawContactDelta merged = RawContactDelta.mergeAfter(dest, source);
+ assertEquals("Unexpected change when merging", source, merged);
+ }
+
+ public void testParcelChangesDelete() {
+ // Delete a row and pass across parcel, should be same
+ final RawContact before = getRawContact(mContext, TEST_CONTACT_ID, TEST_PHONE_ID);
+ final RawContactDelta source = RawContactDelta.fromBefore(before);
+ final RawContactDelta dest = RawContactDelta.fromBefore(before);
+
+ final ValuesDelta child = source.getEntry(TEST_PHONE_ID);
+ child.markDeleted();
+
+ // Merge modified values and assert they match
+ final RawContactDelta merged = RawContactDelta.mergeAfter(dest, source);
+ assertEquals("Unexpected change when merging", source, merged);
+ }
+
+ public void testValuesDiffDelete() {
+ final ContentValues before = new ContentValues();
+ before.put(Data._ID, TEST_PHONE_ID);
+ before.put(Phone.NUMBER, TEST_PHONE_NUMBER_1);
+
+ final ValuesDelta values = ValuesDelta.fromBefore(before);
+ values.markDeleted();
+
+ // Should produce a delete action
+ final BuilderWrapper builderWrapper = values.buildDiffWrapper(Data.CONTENT_URI);
+ final boolean isDelete = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+ ? builderWrapper.getBuilder().build().isDelete()
+ : builderWrapper.getType() == CompatUtils.TYPE_DELETE;
+ assertTrue("Didn't produce delete action", isDelete);
+ }
+
+ /**
+ * Test that {@link RawContactDelta#buildDiffWrapper(ArrayList)} is correctly built for
+ * insert, update, and delete cases. This only tests a subset of possible
+ * {@link Data} row changes.
+ */
+ public void testEntityDiffNone() {
+ final RawContact before = getRawContact(mContext, TEST_CONTACT_ID, TEST_PHONE_ID);
+ final RawContactDelta source = RawContactDelta.fromBefore(before);
+
+ // Assert that writing unchanged produces few operations
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ source.buildDiffWrapper(diff);
+
+ assertTrue("Created changes when none needed", (diff.size() == 0));
+ }
+
+ public void testEntityDiffNoneInsert() {
+ final RawContact before = getRawContact(mContext, TEST_CONTACT_ID, TEST_PHONE_ID);
+ final RawContactDelta source = RawContactDelta.fromBefore(before);
+
+ // Insert a new phone number
+ final ContentValues phone = new ContentValues();
+ phone.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ phone.put(Phone.NUMBER, TEST_PHONE_NUMBER_2);
+ phone.put(Phone.TYPE, Phone.TYPE_WORK);
+ source.addEntry(ValuesDelta.fromAfter(phone));
+
+ // Assert two operations: insert Data row and enforce version
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ source.buildAssertWrapper(diff);
+ source.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 4, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ assertTrue("Expected version enforcement", CompatUtils.isAssertQueryCompat(cpoWrapper));
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(1);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(2);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isInsertCompat(cpoWrapper));
+ assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(3);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ }
+
+ public void testEntityDiffUpdateInsert() {
+ final RawContact before = getRawContact(mContext, TEST_CONTACT_ID, TEST_PHONE_ID);
+ final RawContactDelta source = RawContactDelta.fromBefore(before);
+
+ // Update parent contact values
+ source.getValues().put(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_DISABLED);
+
+ // Insert a new phone number
+ final ContentValues phone = new ContentValues();
+ phone.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ phone.put(Phone.NUMBER, TEST_PHONE_NUMBER_2);
+ phone.put(Phone.TYPE, Phone.TYPE_WORK);
+ source.addEntry(ValuesDelta.fromAfter(phone));
+
+ // Assert three operations: update Contact, insert Data row, enforce version
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ source.buildAssertWrapper(diff);
+ source.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 5, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ assertTrue("Expected version enforcement", CompatUtils.isAssertQueryCompat(cpoWrapper));
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(1);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(2);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(3);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isInsertCompat(cpoWrapper));
+ assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(4);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ }
+
+ public void testEntityDiffNoneUpdate() {
+ final RawContact before = getRawContact(mContext, TEST_CONTACT_ID, TEST_PHONE_ID);
+ final RawContactDelta source = RawContactDelta.fromBefore(before);
+
+ // Update existing phone number
+ final ValuesDelta child = source.getEntry(TEST_PHONE_ID);
+ child.put(Phone.NUMBER, TEST_PHONE_NUMBER_2);
+
+ // Assert that version is enforced
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ source.buildAssertWrapper(diff);
+ source.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 4, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ assertTrue("Expected version enforcement", CompatUtils.isAssertQueryCompat(cpoWrapper));
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(1);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(2);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(3);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ }
+
+ public void testEntityDiffDelete() {
+ final RawContact before = getRawContact(mContext, TEST_CONTACT_ID, TEST_PHONE_ID);
+ final RawContactDelta source = RawContactDelta.fromBefore(before);
+
+ // Delete entire entity
+ source.getValues().markDeleted();
+
+ // Assert two operations: delete Contact and enforce version
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ source.buildAssertWrapper(diff);
+ source.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 2, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ assertTrue("Expected version enforcement", CompatUtils.isAssertQueryCompat(cpoWrapper));
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(1);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isDeleteCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ }
+
+ public void testEntityDiffInsert() {
+ // Insert a RawContact
+ final ContentValues after = new ContentValues();
+ after.put(RawContacts.ACCOUNT_NAME, TEST_ACCOUNT_NAME);
+ after.put(RawContacts.SEND_TO_VOICEMAIL, 1);
+
+ final ValuesDelta values = ValuesDelta.fromAfter(after);
+ final RawContactDelta source = new RawContactDelta(values);
+
+ // Assert two operations: insert Contact and enforce version
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ source.buildAssertWrapper(diff);
+ source.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 2, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isInsertCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ }
+
+ public void testEntityDiffInsertInsert() {
+ // Insert a RawContact
+ final ContentValues after = new ContentValues();
+ after.put(RawContacts.ACCOUNT_NAME, TEST_ACCOUNT_NAME);
+ after.put(RawContacts.SEND_TO_VOICEMAIL, 1);
+
+ final ValuesDelta values = ValuesDelta.fromAfter(after);
+ final RawContactDelta source = new RawContactDelta(values);
+
+ // Insert a new phone number
+ final ContentValues phone = new ContentValues();
+ phone.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ phone.put(Phone.NUMBER, TEST_PHONE_NUMBER_2);
+ phone.put(Phone.TYPE, Phone.TYPE_WORK);
+ source.addEntry(ValuesDelta.fromAfter(phone));
+
+ // Assert two operations: delete Contact and enforce version
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ source.buildAssertWrapper(diff);
+ source.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 3, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isInsertCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(1);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isInsertCompat(cpoWrapper));
+ assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+
+ }
+ }
+}
diff --git a/tests/src/com/android/contacts/common/RawContactModifierTests.java b/tests/src/com/android/contacts/common/RawContactModifierTests.java
new file mode 100644
index 0000000..755838b
--- /dev/null
+++ b/tests/src/com/android/contacts/common/RawContactModifierTests.java
@@ -0,0 +1,1293 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common;
+
+import android.content.ContentProviderOperation;
+import android.content.ContentValues;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Event;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Intents.Insert;
+import android.provider.ContactsContract.RawContacts;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.LargeTest;
+
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.CPOWrapper;
+import com.android.contacts.common.model.RawContact;
+import com.android.contacts.common.model.RawContactDelta;
+import com.android.contacts.common.model.ValuesDelta;
+import com.android.contacts.common.model.RawContactDeltaList;
+import com.android.contacts.common.model.RawContactModifier;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountType.EditType;
+import com.android.contacts.common.model.account.ExchangeAccountType;
+import com.android.contacts.common.model.account.GoogleAccountType;
+import com.android.contacts.common.model.dataitem.DataKind;
+import com.android.contacts.common.test.mocks.ContactsMockContext;
+import com.android.contacts.common.test.mocks.MockAccountTypeManager;
+import com.android.contacts.common.test.mocks.MockContentProvider;
+import com.google.common.collect.Lists;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tests for {@link RawContactModifier} to verify that {@link AccountType}
+ * constraints are being enforced correctly.
+ */
+@LargeTest
+public class RawContactModifierTests extends AndroidTestCase {
+ public static final String TAG = "EntityModifierTests";
+
+ // From android.content.ContentProviderOperation
+ public static final int TYPE_INSERT = 1;
+
+ public static final long VER_FIRST = 100;
+
+ private static final long TEST_ID = 4;
+ private static final String TEST_PHONE = "218-555-1212";
+ private static final String TEST_NAME = "Adam Young";
+ private static final String TEST_NAME2 = "Breanne Duren";
+ private static final String TEST_IM = "example@example.com";
+ private static final String TEST_POSTAL = "1600 Amphitheatre Parkway";
+
+ private static final String TEST_ACCOUNT_NAME = "unittest@example.com";
+ private static final String TEST_ACCOUNT_TYPE = "com.example.unittest";
+
+ private static final String EXCHANGE_ACCT_TYPE = "com.android.exchange";
+
+ @Override
+ public void setUp() {
+ mContext = getContext();
+ }
+
+ public static class MockContactsSource extends AccountType {
+
+ MockContactsSource() {
+ try {
+ this.accountType = TEST_ACCOUNT_TYPE;
+
+ final DataKind nameKind = new DataKind(StructuredName.CONTENT_ITEM_TYPE,
+ R.string.nameLabelsGroup, -1, true);
+ nameKind.typeOverallMax = 1;
+ addKind(nameKind);
+
+ // Phone allows maximum 2 home, 1 work, and unlimited other, with
+ // constraint of 5 numbers maximum.
+ final DataKind phoneKind = new DataKind(
+ Phone.CONTENT_ITEM_TYPE, -1, 10, true);
+
+ phoneKind.typeOverallMax = 5;
+ phoneKind.typeColumn = Phone.TYPE;
+ phoneKind.typeList = Lists.newArrayList();
+ phoneKind.typeList.add(new EditType(Phone.TYPE_HOME, -1).setSpecificMax(2));
+ phoneKind.typeList.add(new EditType(Phone.TYPE_WORK, -1).setSpecificMax(1));
+ phoneKind.typeList.add(new EditType(Phone.TYPE_FAX_WORK, -1).setSecondary(true));
+ phoneKind.typeList.add(new EditType(Phone.TYPE_OTHER, -1));
+
+ phoneKind.fieldList = Lists.newArrayList();
+ phoneKind.fieldList.add(new EditField(Phone.NUMBER, -1, -1));
+ phoneKind.fieldList.add(new EditField(Phone.LABEL, -1, -1));
+
+ addKind(phoneKind);
+
+ // Email is unlimited
+ final DataKind emailKind = new DataKind(Email.CONTENT_ITEM_TYPE, -1, 10, true);
+ emailKind.typeOverallMax = -1;
+ emailKind.fieldList = Lists.newArrayList();
+ emailKind.fieldList.add(new EditField(Email.DATA, -1, -1));
+ addKind(emailKind);
+
+ // IM is only one
+ final DataKind imKind = new DataKind(Im.CONTENT_ITEM_TYPE, -1, 10, true);
+ imKind.typeOverallMax = 1;
+ imKind.fieldList = Lists.newArrayList();
+ imKind.fieldList.add(new EditField(Im.DATA, -1, -1));
+ addKind(imKind);
+
+ // Organization is only one
+ final DataKind orgKind = new DataKind(Organization.CONTENT_ITEM_TYPE, -1, 10, true);
+ orgKind.typeOverallMax = 1;
+ orgKind.fieldList = Lists.newArrayList();
+ orgKind.fieldList.add(new EditField(Organization.COMPANY, -1, -1));
+ orgKind.fieldList.add(new EditField(Organization.TITLE, -1, -1));
+ addKind(orgKind);
+ } catch (DefinitionException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public boolean isGroupMembershipEditable() {
+ return false;
+ }
+
+ @Override
+ public boolean areContactsWritable() {
+ return true;
+ }
+ }
+
+ /**
+ * Build a {@link AccountType} that has various odd constraints for
+ * testing purposes.
+ */
+ protected AccountType getAccountType() {
+ return new MockContactsSource();
+ }
+
+ /**
+ * Build {@link AccountTypeManager} instance.
+ */
+ protected AccountTypeManager getAccountTypes(AccountType... types) {
+ return new MockAccountTypeManager(types, null);
+ }
+
+ /**
+ * Build an {@link RawContact} with the requested set of phone numbers.
+ */
+ protected RawContactDelta getRawContact(Long existingId, ContentValues... entries) {
+ final ContentValues contact = new ContentValues();
+ if (existingId != null) {
+ contact.put(RawContacts._ID, existingId);
+ }
+ contact.put(RawContacts.ACCOUNT_NAME, TEST_ACCOUNT_NAME);
+ contact.put(RawContacts.ACCOUNT_TYPE, TEST_ACCOUNT_TYPE);
+
+ final RawContact before = new RawContact(contact);
+ for (ContentValues values : entries) {
+ before.addDataItemValues(values);
+ }
+ return RawContactDelta.fromBefore(before);
+ }
+
+ /**
+ * Assert this {@link List} contains the given {@link Object}.
+ */
+ protected void assertContains(List<?> list, Object object) {
+ assertTrue("Missing expected value", list.contains(object));
+ }
+
+ /**
+ * Assert this {@link List} does not contain the given {@link Object}.
+ */
+ protected void assertNotContains(List<?> list, Object object) {
+ assertFalse("Contained unexpected value", list.contains(object));
+ }
+
+ /**
+ * Insert various rows to test
+ * {@link RawContactModifier#getValidTypes(RawContactDelta, DataKind, EditType)}
+ */
+ public void testValidTypes() {
+ // Build a source and pull specific types
+ final AccountType source = getAccountType();
+ final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+ final EditType typeWork = RawContactModifier.getType(kindPhone, Phone.TYPE_WORK);
+ final EditType typeOther = RawContactModifier.getType(kindPhone, Phone.TYPE_OTHER);
+
+ List<EditType> validTypes;
+
+ // Add first home, first work
+ final RawContactDelta state = getRawContact(TEST_ID);
+ RawContactModifier.insertChild(state, kindPhone, typeHome);
+ RawContactModifier.insertChild(state, kindPhone, typeWork);
+
+ // Expecting home, other
+ validTypes = RawContactModifier.getValidTypes(state, kindPhone, null, true, null, true);
+ assertContains(validTypes, typeHome);
+ assertNotContains(validTypes, typeWork);
+ assertContains(validTypes, typeOther);
+
+ // Add second home
+ RawContactModifier.insertChild(state, kindPhone, typeHome);
+
+ // Expecting other
+ validTypes = RawContactModifier.getValidTypes(state, kindPhone, null, true, null, true);
+ assertNotContains(validTypes, typeHome);
+ assertNotContains(validTypes, typeWork);
+ assertContains(validTypes, typeOther);
+
+ // Add third and fourth home (invalid, but possible)
+ RawContactModifier.insertChild(state, kindPhone, typeHome);
+ RawContactModifier.insertChild(state, kindPhone, typeHome);
+
+ // Expecting none
+ validTypes = RawContactModifier.getValidTypes(state, kindPhone, null, true, null, true);
+ assertNotContains(validTypes, typeHome);
+ assertNotContains(validTypes, typeWork);
+ assertNotContains(validTypes, typeOther);
+ }
+
+ /**
+ * Test which valid types there are when trying to update the editor type.
+ * {@link RawContactModifier#getValidTypes(RawContactDelta, DataKind, EditType, Boolean)}
+ */
+ public void testValidTypesWhenUpdating() {
+ // Build a source and pull specific types
+ final AccountType source = getAccountType();
+ final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+ final EditType typeWork = RawContactModifier.getType(kindPhone, Phone.TYPE_WORK);
+ final EditType typeOther = RawContactModifier.getType(kindPhone, Phone.TYPE_OTHER);
+
+ List<EditType> validTypes;
+
+ // Add first home, first work
+ final RawContactDelta state = getRawContact(TEST_ID);
+ RawContactModifier.insertChild(state, kindPhone, typeHome);
+ RawContactModifier.insertChild(state, kindPhone, typeWork);
+
+ // Update editor type for home.
+ validTypes = RawContactModifier.getValidTypes(state, kindPhone, null, true, null, false);
+ assertContains(validTypes, typeHome);
+ assertNotContains(validTypes, typeWork);
+ assertContains(validTypes, typeOther);
+
+ // Add another 3 types. Overall limit is 5.
+ RawContactModifier.insertChild(state, kindPhone, typeHome);
+ RawContactModifier.insertChild(state, kindPhone, typeOther);
+ RawContactModifier.insertChild(state, kindPhone, typeOther);
+
+ // "Other" is valid when updating the editor type.
+ validTypes = RawContactModifier.getValidTypes(state, kindPhone, null, true, null, false);
+ assertNotContains(validTypes, typeHome);
+ assertNotContains(validTypes, typeWork);
+ assertContains(validTypes, typeOther);
+ }
+
+ /**
+ * Test {@link RawContactModifier#canInsert(RawContactDelta, DataKind)} by
+ * inserting various rows.
+ */
+ public void testCanInsert() {
+ // Build a source and pull specific types
+ final AccountType source = getAccountType();
+ final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+ final EditType typeWork = RawContactModifier.getType(kindPhone, Phone.TYPE_WORK);
+ final EditType typeOther = RawContactModifier.getType(kindPhone, Phone.TYPE_OTHER);
+
+ // Add first home, first work
+ final RawContactDelta state = getRawContact(TEST_ID);
+ RawContactModifier.insertChild(state, kindPhone, typeHome);
+ RawContactModifier.insertChild(state, kindPhone, typeWork);
+ assertTrue("Unable to insert", RawContactModifier.canInsert(state, kindPhone));
+
+ // Add two other, which puts us just under "5" overall limit
+ RawContactModifier.insertChild(state, kindPhone, typeOther);
+ RawContactModifier.insertChild(state, kindPhone, typeOther);
+ assertTrue("Unable to insert", RawContactModifier.canInsert(state, kindPhone));
+
+ // Add second home, which should push to snug limit
+ RawContactModifier.insertChild(state, kindPhone, typeHome);
+ assertFalse("Able to insert", RawContactModifier.canInsert(state, kindPhone));
+ }
+
+ /**
+ * Test
+ * {@link RawContactModifier#getBestValidType(RawContactDelta, DataKind, boolean, int)}
+ * by asserting expected best options in various states.
+ */
+ public void testBestValidType() {
+ // Build a source and pull specific types
+ final AccountType source = getAccountType();
+ final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+ final EditType typeWork = RawContactModifier.getType(kindPhone, Phone.TYPE_WORK);
+ final EditType typeFaxWork = RawContactModifier.getType(kindPhone, Phone.TYPE_FAX_WORK);
+ final EditType typeOther = RawContactModifier.getType(kindPhone, Phone.TYPE_OTHER);
+
+ EditType suggested;
+
+ // Default suggestion should be home
+ final RawContactDelta state = getRawContact(TEST_ID);
+ suggested = RawContactModifier.getBestValidType(state, kindPhone, false, Integer.MIN_VALUE);
+ assertEquals("Unexpected suggestion", typeHome, suggested);
+
+ // Add first home, should now suggest work
+ RawContactModifier.insertChild(state, kindPhone, typeHome);
+ suggested = RawContactModifier.getBestValidType(state, kindPhone, false, Integer.MIN_VALUE);
+ assertEquals("Unexpected suggestion", typeWork, suggested);
+
+ // Add work fax, should still suggest work
+ RawContactModifier.insertChild(state, kindPhone, typeFaxWork);
+ suggested = RawContactModifier.getBestValidType(state, kindPhone, false, Integer.MIN_VALUE);
+ assertEquals("Unexpected suggestion", typeWork, suggested);
+
+ // Add other, should still suggest work
+ RawContactModifier.insertChild(state, kindPhone, typeOther);
+ suggested = RawContactModifier.getBestValidType(state, kindPhone, false, Integer.MIN_VALUE);
+ assertEquals("Unexpected suggestion", typeWork, suggested);
+
+ // Add work, now should suggest other
+ RawContactModifier.insertChild(state, kindPhone, typeWork);
+ suggested = RawContactModifier.getBestValidType(state, kindPhone, false, Integer.MIN_VALUE);
+ assertEquals("Unexpected suggestion", typeOther, suggested);
+ }
+
+ public void testIsEmptyEmpty() {
+ final AccountType source = getAccountType();
+ final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+
+ // Test entirely empty row
+ final ContentValues after = new ContentValues();
+ final ValuesDelta values = ValuesDelta.fromAfter(after);
+
+ assertTrue("Expected empty", RawContactModifier.isEmpty(values, kindPhone));
+ }
+
+ public void testIsEmptyDirectFields() {
+ final AccountType source = getAccountType();
+ final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+ // Test row that has type values, but core fields are empty
+ final RawContactDelta state = getRawContact(TEST_ID);
+ final ValuesDelta values = RawContactModifier.insertChild(state, kindPhone, typeHome);
+
+ assertTrue("Expected empty", RawContactModifier.isEmpty(values, kindPhone));
+
+ // Insert some data to trigger non-empty state
+ values.put(Phone.NUMBER, TEST_PHONE);
+
+ assertFalse("Expected non-empty", RawContactModifier.isEmpty(values, kindPhone));
+ }
+
+ public void testTrimEmptySingle() {
+ final AccountType source = getAccountType();
+ final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+ // Test row that has type values, but core fields are empty
+ final RawContactDelta state = getRawContact(TEST_ID);
+ RawContactModifier.insertChild(state, kindPhone, typeHome);
+
+ // Build diff, expecting insert for data row and update enforcement
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 3, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(1);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isInsertCompat(cpoWrapper));
+ assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(2);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+
+ // Trim empty rows and try again, expecting delete of overall contact
+ RawContactModifier.trimEmpty(state, source);
+ diff.clear();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 1, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isDeleteCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ }
+
+ public void testTrimEmptySpaces() {
+ final AccountType source = getAccountType();
+ final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+ // Test row that has type values, but values are spaces
+ final RawContactDelta state = RawContactDeltaListTests.buildBeforeEntity(mContext, TEST_ID,
+ VER_FIRST);
+ final ValuesDelta values = RawContactModifier.insertChild(state, kindPhone, typeHome);
+ values.put(Phone.NUMBER, " ");
+
+ // Build diff, expecting insert for data row and update enforcement
+ RawContactDeltaListTests.assertDiffPattern(state,
+ RawContactDeltaListTests.buildAssertVersion(VER_FIRST),
+ RawContactDeltaListTests.buildUpdateAggregationSuspended(),
+ RawContactDeltaListTests.buildCPOWrapper(Data.CONTENT_URI, TYPE_INSERT,
+ RawContactDeltaListTests.buildDataInsert(values, TEST_ID)),
+ RawContactDeltaListTests.buildUpdateAggregationDefault());
+
+ // Trim empty rows and try again, expecting delete of overall contact
+ RawContactModifier.trimEmpty(state, source);
+ RawContactDeltaListTests.assertDiffPattern(state,
+ RawContactDeltaListTests.buildAssertVersion(VER_FIRST),
+ RawContactDeltaListTests.buildDelete(RawContacts.CONTENT_URI));
+ }
+
+ public void testTrimLeaveValid() {
+ final AccountType source = getAccountType();
+ final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+ // Test row that has type values with valid number
+ final RawContactDelta state = RawContactDeltaListTests.buildBeforeEntity(mContext, TEST_ID,
+ VER_FIRST);
+ final ValuesDelta values = RawContactModifier.insertChild(state, kindPhone, typeHome);
+ values.put(Phone.NUMBER, TEST_PHONE);
+
+ // Build diff, expecting insert for data row and update enforcement
+ RawContactDeltaListTests.assertDiffPattern(state,
+ RawContactDeltaListTests.buildAssertVersion(VER_FIRST),
+ RawContactDeltaListTests.buildUpdateAggregationSuspended(),
+ RawContactDeltaListTests.buildCPOWrapper(Data.CONTENT_URI, TYPE_INSERT,
+ RawContactDeltaListTests.buildDataInsert(values, TEST_ID)),
+ RawContactDeltaListTests.buildUpdateAggregationDefault());
+
+ // Trim empty rows and try again, expecting no differences
+ RawContactModifier.trimEmpty(state, source);
+ RawContactDeltaListTests.assertDiffPattern(state,
+ RawContactDeltaListTests.buildAssertVersion(VER_FIRST),
+ RawContactDeltaListTests.buildUpdateAggregationSuspended(),
+ RawContactDeltaListTests.buildCPOWrapper(Data.CONTENT_URI, TYPE_INSERT,
+ RawContactDeltaListTests.buildDataInsert(values, TEST_ID)),
+ RawContactDeltaListTests.buildUpdateAggregationDefault());
+ }
+
+ public void testTrimEmptyUntouched() {
+ final AccountType source = getAccountType();
+ final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+ // Build "before" that has empty row
+ final RawContactDelta state = getRawContact(TEST_ID);
+ final ContentValues before = new ContentValues();
+ before.put(Data._ID, TEST_ID);
+ before.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ state.addEntry(ValuesDelta.fromBefore(before));
+
+ // Build diff, expecting no changes
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 0, diff.size());
+
+ // Try trimming existing empty, which we shouldn't touch
+ RawContactModifier.trimEmpty(state, source);
+ diff.clear();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 0, diff.size());
+ }
+
+ public void testTrimEmptyAfterUpdate() {
+ final AccountType source = getAccountType();
+ final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+ // Build "before" that has row with some phone number
+ final ContentValues before = new ContentValues();
+ before.put(Data._ID, TEST_ID);
+ before.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ before.put(kindPhone.typeColumn, typeHome.rawValue);
+ before.put(Phone.NUMBER, TEST_PHONE);
+ final RawContactDelta state = getRawContact(TEST_ID, before);
+
+ // Build diff, expecting no changes
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 0, diff.size());
+
+ // Now update row by changing number to empty string, expecting single update
+ final ValuesDelta child = state.getEntry(TEST_ID);
+ child.put(Phone.NUMBER, "");
+ diff.clear();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 3, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(1);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(2);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+
+ // Now run trim, which should turn that update into delete
+ RawContactModifier.trimEmpty(state, source);
+ diff.clear();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 1, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isDeleteCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ }
+
+ public void testTrimInsertEmpty() {
+ final AccountType accountType = getAccountType();
+ final AccountTypeManager accountTypes = getAccountTypes(accountType);
+ final DataKind kindPhone = accountType.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+ // Try creating a contact without any child entries
+ final RawContactDelta state = getRawContact(null);
+ final RawContactDeltaList set = new RawContactDeltaList();
+ set.add(state);
+
+ // Build diff, expecting single insert
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 2, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isInsertCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+
+ // Trim empty rows and try again, expecting no insert
+ RawContactModifier.trimEmpty(set, accountTypes);
+ diff.clear();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 0, diff.size());
+ }
+
+ public void testTrimInsertInsert() {
+ final AccountType accountType = getAccountType();
+ final AccountTypeManager accountTypes = getAccountTypes(accountType);
+ final DataKind kindPhone = accountType.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+ // Try creating a contact with single empty entry
+ final RawContactDelta state = getRawContact(null);
+ RawContactModifier.insertChild(state, kindPhone, typeHome);
+ final RawContactDeltaList set = new RawContactDeltaList();
+ set.add(state);
+
+ // Build diff, expecting two insert operations
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 3, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isInsertCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(1);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isInsertCompat(cpoWrapper));
+ assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+ }
+
+ // Trim empty rows and try again, expecting silence
+ RawContactModifier.trimEmpty(set, accountTypes);
+ diff.clear();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 0, diff.size());
+ }
+
+ public void testTrimUpdateRemain() {
+ final AccountType accountType = getAccountType();
+ final AccountTypeManager accountTypes = getAccountTypes(accountType);
+ final DataKind kindPhone = accountType.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+ // Build "before" with two phone numbers
+ final ContentValues first = new ContentValues();
+ first.put(Data._ID, TEST_ID);
+ first.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ first.put(kindPhone.typeColumn, typeHome.rawValue);
+ first.put(Phone.NUMBER, TEST_PHONE);
+
+ final ContentValues second = new ContentValues();
+ second.put(Data._ID, TEST_ID);
+ second.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ second.put(kindPhone.typeColumn, typeHome.rawValue);
+ second.put(Phone.NUMBER, TEST_PHONE);
+
+ final RawContactDelta state = getRawContact(TEST_ID, first, second);
+ final RawContactDeltaList set = new RawContactDeltaList();
+ set.add(state);
+
+ // Build diff, expecting no changes
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 0, diff.size());
+
+ // Now update row by changing number to empty string, expecting single update
+ final ValuesDelta child = state.getEntry(TEST_ID);
+ child.put(Phone.NUMBER, "");
+ diff.clear();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 3, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(1);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(2);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+
+ // Now run trim, which should turn that update into delete
+ RawContactModifier.trimEmpty(set, accountTypes);
+ diff.clear();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 3, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(1);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isDeleteCompat(cpoWrapper));
+ assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(2);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ }
+
+ public void testTrimUpdateUpdate() {
+ final AccountType accountType = getAccountType();
+ final AccountTypeManager accountTypes = getAccountTypes(accountType);
+ final DataKind kindPhone = accountType.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+ final EditType typeHome = RawContactModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+ // Build "before" with two phone numbers
+ final ContentValues first = new ContentValues();
+ first.put(Data._ID, TEST_ID);
+ first.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ first.put(kindPhone.typeColumn, typeHome.rawValue);
+ first.put(Phone.NUMBER, TEST_PHONE);
+
+ final RawContactDelta state = getRawContact(TEST_ID, first);
+ final RawContactDeltaList set = new RawContactDeltaList();
+ set.add(state);
+
+ // Build diff, expecting no changes
+ final ArrayList<CPOWrapper> diff = Lists.newArrayList();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 0, diff.size());
+
+ // Now update row by changing number to empty string, expecting single update
+ final ValuesDelta child = state.getEntry(TEST_ID);
+ child.put(Phone.NUMBER, "");
+ diff.clear();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 3, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(1);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+ }
+ {
+ final CPOWrapper cpoWrapper = diff.get(2);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Expected aggregation mode change", CompatUtils.isUpdateCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+
+ // Now run trim, which should turn into deleting the whole contact
+ RawContactModifier.trimEmpty(set, accountTypes);
+ diff.clear();
+ state.buildDiffWrapper(diff);
+ assertEquals("Unexpected operations", 1, diff.size());
+ {
+ final CPOWrapper cpoWrapper = diff.get(0);
+ final ContentProviderOperation oper = cpoWrapper.getOperation();
+ assertTrue("Incorrect type", CompatUtils.isDeleteCompat(cpoWrapper));
+ assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+ }
+ }
+
+ public void testParseExtrasExistingName() {
+ final AccountType accountType = getAccountType();
+
+ // Build "before" name
+ final ContentValues first = new ContentValues();
+ first.put(Data._ID, TEST_ID);
+ first.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
+ first.put(StructuredName.GIVEN_NAME, TEST_NAME);
+
+ // Parse extras, making sure we keep single name
+ final RawContactDelta state = getRawContact(TEST_ID, first);
+ final Bundle extras = new Bundle();
+ extras.putString(Insert.NAME, TEST_NAME2);
+ RawContactModifier.parseExtras(mContext, accountType, state, extras);
+
+ final int nameCount = state.getMimeEntriesCount(StructuredName.CONTENT_ITEM_TYPE, true);
+ assertEquals("Unexpected names", 1, nameCount);
+ }
+
+ public void testParseExtrasIgnoreLimit() {
+ final AccountType accountType = getAccountType();
+
+ // Build "before" IM
+ final ContentValues first = new ContentValues();
+ first.put(Data._ID, TEST_ID);
+ first.put(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ first.put(Im.DATA, TEST_IM);
+
+ final RawContactDelta state = getRawContact(TEST_ID, first);
+ final int beforeCount = state.getMimeEntries(Im.CONTENT_ITEM_TYPE).size();
+
+ // We should ignore data that doesn't fit account type rules, since account type
+ // only allows single Im
+ final Bundle extras = new Bundle();
+ extras.putInt(Insert.IM_PROTOCOL, Im.PROTOCOL_GOOGLE_TALK);
+ extras.putString(Insert.IM_HANDLE, TEST_IM);
+ RawContactModifier.parseExtras(mContext, accountType, state, extras);
+
+ final int afterCount = state.getMimeEntries(Im.CONTENT_ITEM_TYPE).size();
+ assertEquals("Broke account type rules", beforeCount, afterCount);
+ }
+
+ public void testParseExtrasIgnoreUnhandled() {
+ final AccountType accountType = getAccountType();
+ final RawContactDelta state = getRawContact(TEST_ID);
+
+ // We should silently ignore types unsupported by account type
+ final Bundle extras = new Bundle();
+ extras.putString(Insert.POSTAL, TEST_POSTAL);
+ RawContactModifier.parseExtras(mContext, accountType, state, extras);
+
+ assertNull("Broke accoun type rules",
+ state.getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE));
+ }
+
+ public void testParseExtrasJobTitle() {
+ final AccountType accountType = getAccountType();
+ final RawContactDelta state = getRawContact(TEST_ID);
+
+ // Make sure that we create partial Organizations
+ final Bundle extras = new Bundle();
+ extras.putString(Insert.JOB_TITLE, TEST_NAME);
+ RawContactModifier.parseExtras(mContext, accountType, state, extras);
+
+ final int count = state.getMimeEntries(Organization.CONTENT_ITEM_TYPE).size();
+ assertEquals("Expected to create organization", 1, count);
+ }
+
+ public void testMigrateWithDisplayNameFromGoogleToExchange1() {
+ AccountType oldAccountType = new GoogleAccountType(getContext(), "");
+ AccountType newAccountType = new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE);
+ DataKind kind = newAccountType.getKindForMimetype(StructuredName.CONTENT_ITEM_TYPE);
+
+ ContactsMockContext context = new ContactsMockContext(getContext());
+
+ RawContactDelta oldState = new RawContactDelta();
+ ContentValues mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
+ mockNameValues.put(StructuredName.PREFIX, "prefix");
+ mockNameValues.put(StructuredName.GIVEN_NAME, "given");
+ mockNameValues.put(StructuredName.MIDDLE_NAME, "middle");
+ mockNameValues.put(StructuredName.FAMILY_NAME, "family");
+ mockNameValues.put(StructuredName.SUFFIX, "suffix");
+ mockNameValues.put(StructuredName.PHONETIC_FAMILY_NAME, "PHONETIC_FAMILY");
+ mockNameValues.put(StructuredName.PHONETIC_MIDDLE_NAME, "PHONETIC_MIDDLE");
+ mockNameValues.put(StructuredName.PHONETIC_GIVEN_NAME, "PHONETIC_GIVEN");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ RawContactDelta newState = new RawContactDelta();
+ RawContactModifier.migrateStructuredName(context, oldState, newState, kind);
+ List<ValuesDelta> list = newState.getMimeEntries(StructuredName.CONTENT_ITEM_TYPE);
+ assertEquals(1, list.size());
+
+ ContentValues output = list.get(0).getAfter();
+ assertEquals("prefix", output.getAsString(StructuredName.PREFIX));
+ assertEquals("given", output.getAsString(StructuredName.GIVEN_NAME));
+ assertEquals("middle", output.getAsString(StructuredName.MIDDLE_NAME));
+ assertEquals("family", output.getAsString(StructuredName.FAMILY_NAME));
+ assertEquals("suffix", output.getAsString(StructuredName.SUFFIX));
+ // Phonetic middle name isn't supported by Exchange.
+ assertEquals("PHONETIC_FAMILY", output.getAsString(StructuredName.PHONETIC_FAMILY_NAME));
+ assertEquals("PHONETIC_GIVEN", output.getAsString(StructuredName.PHONETIC_GIVEN_NAME));
+ }
+
+ public void testMigrateWithDisplayNameFromGoogleToExchange2() {
+ AccountType oldAccountType = new GoogleAccountType(getContext(), "");
+ AccountType newAccountType = new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE);
+ DataKind kind = newAccountType.getKindForMimetype(StructuredName.CONTENT_ITEM_TYPE);
+
+ ContactsMockContext context = new ContactsMockContext(getContext());
+ MockContentProvider provider = context.getContactsProvider();
+
+ String inputDisplayName = "prefix given middle family suffix";
+ // The method will ask the provider to split/join StructuredName.
+ Uri uriForBuildDisplayName =
+ ContactsContract.AUTHORITY_URI
+ .buildUpon()
+ .appendPath("complete_name")
+ .appendQueryParameter(StructuredName.DISPLAY_NAME, inputDisplayName)
+ .build();
+ provider.expectQuery(uriForBuildDisplayName)
+ .returnRow("prefix", "given", "middle", "family", "suffix")
+ .withProjection(StructuredName.PREFIX, StructuredName.GIVEN_NAME,
+ StructuredName.MIDDLE_NAME, StructuredName.FAMILY_NAME,
+ StructuredName.SUFFIX);
+
+ RawContactDelta oldState = new RawContactDelta();
+ ContentValues mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
+ mockNameValues.put(StructuredName.DISPLAY_NAME, inputDisplayName);
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ RawContactDelta newState = new RawContactDelta();
+ RawContactModifier.migrateStructuredName(context, oldState, newState, kind);
+ List<ValuesDelta> list = newState.getMimeEntries(StructuredName.CONTENT_ITEM_TYPE);
+ assertEquals(1, list.size());
+
+ ContentValues outputValues = list.get(0).getAfter();
+ assertEquals("prefix", outputValues.getAsString(StructuredName.PREFIX));
+ assertEquals("given", outputValues.getAsString(StructuredName.GIVEN_NAME));
+ assertEquals("middle", outputValues.getAsString(StructuredName.MIDDLE_NAME));
+ assertEquals("family", outputValues.getAsString(StructuredName.FAMILY_NAME));
+ assertEquals("suffix", outputValues.getAsString(StructuredName.SUFFIX));
+ }
+
+ public void testMigrateWithStructuredNameFromExchangeToGoogle() {
+ AccountType oldAccountType = new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE);
+ AccountType newAccountType = new GoogleAccountType(getContext(), "");
+ DataKind kind = newAccountType.getKindForMimetype(StructuredName.CONTENT_ITEM_TYPE);
+
+ ContactsMockContext context = new ContactsMockContext(getContext());
+ MockContentProvider provider = context.getContactsProvider();
+
+ // The method will ask the provider to split/join StructuredName.
+ Uri uriForBuildDisplayName =
+ ContactsContract.AUTHORITY_URI
+ .buildUpon()
+ .appendPath("complete_name")
+ .appendQueryParameter(StructuredName.PREFIX, "prefix")
+ .appendQueryParameter(StructuredName.GIVEN_NAME, "given")
+ .appendQueryParameter(StructuredName.MIDDLE_NAME, "middle")
+ .appendQueryParameter(StructuredName.FAMILY_NAME, "family")
+ .appendQueryParameter(StructuredName.SUFFIX, "suffix")
+ .build();
+ provider.expectQuery(uriForBuildDisplayName)
+ .returnRow("prefix given middle family suffix")
+ .withProjection(StructuredName.DISPLAY_NAME);
+
+ RawContactDelta oldState = new RawContactDelta();
+ ContentValues mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
+ mockNameValues.put(StructuredName.PREFIX, "prefix");
+ mockNameValues.put(StructuredName.GIVEN_NAME, "given");
+ mockNameValues.put(StructuredName.MIDDLE_NAME, "middle");
+ mockNameValues.put(StructuredName.FAMILY_NAME, "family");
+ mockNameValues.put(StructuredName.SUFFIX, "suffix");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ RawContactDelta newState = new RawContactDelta();
+ RawContactModifier.migrateStructuredName(context, oldState, newState, kind);
+
+ List<ValuesDelta> list = newState.getMimeEntries(StructuredName.CONTENT_ITEM_TYPE);
+ assertNotNull(list);
+ assertEquals(1, list.size());
+ ContentValues outputValues = list.get(0).getAfter();
+ assertEquals("prefix given middle family suffix",
+ outputValues.getAsString(StructuredName.DISPLAY_NAME));
+ }
+
+ public void testMigratePostalFromGoogleToExchange() {
+ AccountType oldAccountType = new GoogleAccountType(getContext(), "");
+ AccountType newAccountType = new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE);
+ DataKind kind = newAccountType.getKindForMimetype(StructuredPostal.CONTENT_ITEM_TYPE);
+
+ RawContactDelta oldState = new RawContactDelta();
+ ContentValues mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, StructuredPostal.CONTENT_ITEM_TYPE);
+ mockNameValues.put(StructuredPostal.FORMATTED_ADDRESS, "formatted_address");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ RawContactDelta newState = new RawContactDelta();
+ RawContactModifier.migratePostal(oldState, newState, kind);
+
+ List<ValuesDelta> list = newState.getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE);
+ assertNotNull(list);
+ assertEquals(1, list.size());
+ ContentValues outputValues = list.get(0).getAfter();
+ // FORMATTED_ADDRESS isn't supported by Exchange.
+ assertNull(outputValues.getAsString(StructuredPostal.FORMATTED_ADDRESS));
+ assertEquals("formatted_address", outputValues.getAsString(StructuredPostal.STREET));
+ }
+
+ public void testMigratePostalFromExchangeToGoogle() {
+ AccountType oldAccountType = new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE);
+ AccountType newAccountType = new GoogleAccountType(getContext(), "");
+ DataKind kind = newAccountType.getKindForMimetype(StructuredPostal.CONTENT_ITEM_TYPE);
+
+ RawContactDelta oldState = new RawContactDelta();
+ ContentValues mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, StructuredPostal.CONTENT_ITEM_TYPE);
+ mockNameValues.put(StructuredPostal.COUNTRY, "country");
+ mockNameValues.put(StructuredPostal.POSTCODE, "postcode");
+ mockNameValues.put(StructuredPostal.REGION, "region");
+ mockNameValues.put(StructuredPostal.CITY, "city");
+ mockNameValues.put(StructuredPostal.STREET, "street");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ RawContactDelta newState = new RawContactDelta();
+ RawContactModifier.migratePostal(oldState, newState, kind);
+
+ List<ValuesDelta> list = newState.getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE);
+ assertNotNull(list);
+ assertEquals(1, list.size());
+ ContentValues outputValues = list.get(0).getAfter();
+
+ // Check FORMATTED_ADDRESS contains all info.
+ String formattedAddress = outputValues.getAsString(StructuredPostal.FORMATTED_ADDRESS);
+ assertNotNull(formattedAddress);
+ assertTrue(formattedAddress.contains("country"));
+ assertTrue(formattedAddress.contains("postcode"));
+ assertTrue(formattedAddress.contains("region"));
+ assertTrue(formattedAddress.contains("postcode"));
+ assertTrue(formattedAddress.contains("city"));
+ assertTrue(formattedAddress.contains("street"));
+ }
+
+ public void testMigrateEventFromGoogleToExchange1() {
+ testMigrateEventCommon(new GoogleAccountType(getContext(), ""),
+ new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE));
+ }
+
+ public void testMigrateEventFromExchangeToGoogle() {
+ testMigrateEventCommon(new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE),
+ new GoogleAccountType(getContext(), ""));
+ }
+
+ private void testMigrateEventCommon(AccountType oldAccountType, AccountType newAccountType) {
+ DataKind kind = newAccountType.getKindForMimetype(Event.CONTENT_ITEM_TYPE);
+
+ RawContactDelta oldState = new RawContactDelta();
+ ContentValues mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Event.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Event.START_DATE, "1972-02-08");
+ mockNameValues.put(Event.TYPE, Event.TYPE_BIRTHDAY);
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ RawContactDelta newState = new RawContactDelta();
+ RawContactModifier.migrateEvent(oldState, newState, kind, 1990);
+
+ List<ValuesDelta> list = newState.getMimeEntries(Event.CONTENT_ITEM_TYPE);
+ assertNotNull(list);
+ assertEquals(1, list.size()); // Anniversary should be dropped.
+ ContentValues outputValues = list.get(0).getAfter();
+
+ assertEquals("1972-02-08", outputValues.getAsString(Event.START_DATE));
+ assertEquals(Event.TYPE_BIRTHDAY, outputValues.getAsInteger(Event.TYPE).intValue());
+ }
+
+ public void testMigrateEventFromGoogleToExchange2() {
+ AccountType oldAccountType = new GoogleAccountType(getContext(), "");
+ AccountType newAccountType = new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE);
+ DataKind kind = newAccountType.getKindForMimetype(Event.CONTENT_ITEM_TYPE);
+
+ RawContactDelta oldState = new RawContactDelta();
+ ContentValues mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Event.CONTENT_ITEM_TYPE);
+ // No year format is not supported by Exchange.
+ mockNameValues.put(Event.START_DATE, "--06-01");
+ mockNameValues.put(Event.TYPE, Event.TYPE_BIRTHDAY);
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+ mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Event.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Event.START_DATE, "1980-08-02");
+ // Anniversary is not supported by Exchange
+ mockNameValues.put(Event.TYPE, Event.TYPE_ANNIVERSARY);
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ RawContactDelta newState = new RawContactDelta();
+ RawContactModifier.migrateEvent(oldState, newState, kind, 1990);
+
+ List<ValuesDelta> list = newState.getMimeEntries(Event.CONTENT_ITEM_TYPE);
+ assertNotNull(list);
+ assertEquals(1, list.size()); // Anniversary should be dropped.
+ ContentValues outputValues = list.get(0).getAfter();
+
+ // Default year should be used.
+ assertEquals("1990-06-01", outputValues.getAsString(Event.START_DATE));
+ assertEquals(Event.TYPE_BIRTHDAY, outputValues.getAsInteger(Event.TYPE).intValue());
+ }
+
+ public void testMigrateEmailFromGoogleToExchange() {
+ AccountType oldAccountType = new GoogleAccountType(getContext(), "");
+ AccountType newAccountType = new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE);
+ DataKind kind = newAccountType.getKindForMimetype(Email.CONTENT_ITEM_TYPE);
+
+ RawContactDelta oldState = new RawContactDelta();
+ ContentValues mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Email.TYPE, Email.TYPE_CUSTOM);
+ mockNameValues.put(Email.LABEL, "custom_type");
+ mockNameValues.put(Email.ADDRESS, "address1");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+ mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Email.TYPE, Email.TYPE_HOME);
+ mockNameValues.put(Email.ADDRESS, "address2");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+ mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Email.TYPE, Email.TYPE_WORK);
+ mockNameValues.put(Email.ADDRESS, "address3");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+ // Exchange can have up to 3 email entries. This 4th entry should be dropped.
+ mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Email.TYPE, Email.TYPE_OTHER);
+ mockNameValues.put(Email.ADDRESS, "address4");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ RawContactDelta newState = new RawContactDelta();
+ RawContactModifier.migrateGenericWithTypeColumn(oldState, newState, kind);
+
+ List<ValuesDelta> list = newState.getMimeEntries(Email.CONTENT_ITEM_TYPE);
+ assertNotNull(list);
+ assertEquals(3, list.size());
+
+ ContentValues outputValues = list.get(0).getAfter();
+ assertEquals(Email.TYPE_CUSTOM, outputValues.getAsInteger(Email.TYPE).intValue());
+ assertEquals("custom_type", outputValues.getAsString(Email.LABEL));
+ assertEquals("address1", outputValues.getAsString(Email.ADDRESS));
+
+ outputValues = list.get(1).getAfter();
+ assertEquals(Email.TYPE_HOME, outputValues.getAsInteger(Email.TYPE).intValue());
+ assertEquals("address2", outputValues.getAsString(Email.ADDRESS));
+
+ outputValues = list.get(2).getAfter();
+ assertEquals(Email.TYPE_WORK, outputValues.getAsInteger(Email.TYPE).intValue());
+ assertEquals("address3", outputValues.getAsString(Email.ADDRESS));
+ }
+
+ public void testMigrateImFromGoogleToExchange() {
+ AccountType oldAccountType = new GoogleAccountType(getContext(), "");
+ AccountType newAccountType = new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE);
+ DataKind kind = newAccountType.getKindForMimetype(Im.CONTENT_ITEM_TYPE);
+
+ RawContactDelta oldState = new RawContactDelta();
+ ContentValues mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ // Exchange doesn't support TYPE_HOME
+ mockNameValues.put(Im.TYPE, Im.TYPE_HOME);
+ mockNameValues.put(Im.PROTOCOL, Im.PROTOCOL_JABBER);
+ mockNameValues.put(Im.DATA, "im1");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ // Exchange doesn't support TYPE_WORK
+ mockNameValues.put(Im.TYPE, Im.TYPE_WORK);
+ mockNameValues.put(Im.PROTOCOL, Im.PROTOCOL_YAHOO);
+ mockNameValues.put(Im.DATA, "im2");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Im.TYPE, Im.TYPE_OTHER);
+ mockNameValues.put(Im.PROTOCOL, Im.PROTOCOL_CUSTOM);
+ mockNameValues.put(Im.CUSTOM_PROTOCOL, "custom_protocol");
+ mockNameValues.put(Im.DATA, "im3");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ // Exchange can have up to 3 IM entries. This 4th entry should be dropped.
+ mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Im.TYPE, Im.TYPE_OTHER);
+ mockNameValues.put(Im.PROTOCOL, Im.PROTOCOL_GOOGLE_TALK);
+ mockNameValues.put(Im.DATA, "im4");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ RawContactDelta newState = new RawContactDelta();
+ RawContactModifier.migrateGenericWithTypeColumn(oldState, newState, kind);
+
+ List<ValuesDelta> list = newState.getMimeEntries(Im.CONTENT_ITEM_TYPE);
+ assertNotNull(list);
+ assertEquals(3, list.size());
+
+ assertNotNull(kind.defaultValues.getAsInteger(Im.TYPE));
+
+ int defaultType = kind.defaultValues.getAsInteger(Im.TYPE);
+
+ ContentValues outputValues = list.get(0).getAfter();
+ // HOME should become default type.
+ assertEquals(defaultType, outputValues.getAsInteger(Im.TYPE).intValue());
+ assertEquals(Im.PROTOCOL_JABBER, outputValues.getAsInteger(Im.PROTOCOL).intValue());
+ assertEquals("im1", outputValues.getAsString(Im.DATA));
+
+ outputValues = list.get(1).getAfter();
+ assertEquals(defaultType, outputValues.getAsInteger(Im.TYPE).intValue());
+ assertEquals(Im.PROTOCOL_YAHOO, outputValues.getAsInteger(Im.PROTOCOL).intValue());
+ assertEquals("im2", outputValues.getAsString(Im.DATA));
+
+ outputValues = list.get(2).getAfter();
+ assertEquals(defaultType, outputValues.getAsInteger(Im.TYPE).intValue());
+ assertEquals(Im.PROTOCOL_CUSTOM, outputValues.getAsInteger(Im.PROTOCOL).intValue());
+ assertEquals("custom_protocol", outputValues.getAsString(Im.CUSTOM_PROTOCOL));
+ assertEquals("im3", outputValues.getAsString(Im.DATA));
+ }
+
+ public void testMigratePhoneFromGoogleToExchange() {
+ AccountType oldAccountType = new GoogleAccountType(getContext(), "");
+ AccountType newAccountType = new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE);
+ DataKind kind = newAccountType.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+
+ // Create 5 numbers.
+ // - "1" -- HOME
+ // - "2" -- WORK
+ // - "3" -- CUSTOM
+ // - "4" -- WORK
+ // - "5" -- WORK_MOBILE
+ // Then we convert it to Exchange account type.
+ // - "1" -- HOME
+ // - "2" -- WORK
+ // - "3" -- Because CUSTOM is not supported, it'll be changed to the default, MOBILE
+ // - "4" -- WORK
+ // - "5" -- WORK_MOBILE not suppoted again, so will be MOBILE.
+ // But then, Exchange doesn't support multiple MOBILE numbers, so "5" will be removed.
+ // i.e. the result will be:
+ // - "1" -- HOME
+ // - "2" -- WORK
+ // - "3" -- MOBILE
+ // - "4" -- WORK
+
+ RawContactDelta oldState = new RawContactDelta();
+ ContentValues mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Phone.TYPE, Phone.TYPE_HOME);
+ mockNameValues.put(Phone.NUMBER, "1");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+ mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Phone.TYPE, Phone.TYPE_WORK);
+ mockNameValues.put(Phone.NUMBER, "2");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+ mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ // Exchange doesn't support this type. Default to MOBILE
+ mockNameValues.put(Phone.TYPE, Phone.TYPE_CUSTOM);
+ mockNameValues.put(Phone.LABEL, "custom_type");
+ mockNameValues.put(Phone.NUMBER, "3");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+ mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Phone.TYPE, Phone.TYPE_WORK);
+ mockNameValues.put(Phone.NUMBER, "4");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+ mockNameValues = new ContentValues();
+
+ mockNameValues.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Phone.TYPE, Phone.TYPE_WORK_MOBILE);
+ mockNameValues.put(Phone.NUMBER, "5");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ RawContactDelta newState = new RawContactDelta();
+ RawContactModifier.migrateGenericWithTypeColumn(oldState, newState, kind);
+
+ List<ValuesDelta> list = newState.getMimeEntries(Phone.CONTENT_ITEM_TYPE);
+ assertNotNull(list);
+ assertEquals(4, list.size());
+
+ int defaultType = Phone.TYPE_MOBILE;
+
+ ContentValues outputValues = list.get(0).getAfter();
+ assertEquals(Phone.TYPE_HOME, outputValues.getAsInteger(Phone.TYPE).intValue());
+ assertEquals("1", outputValues.getAsString(Phone.NUMBER));
+ outputValues = list.get(1).getAfter();
+ assertEquals(Phone.TYPE_WORK, outputValues.getAsInteger(Phone.TYPE).intValue());
+ assertEquals("2", outputValues.getAsString(Phone.NUMBER));
+ outputValues = list.get(2).getAfter();
+ assertEquals(defaultType, outputValues.getAsInteger(Phone.TYPE).intValue());
+ assertNull(outputValues.getAsInteger(Phone.LABEL));
+ assertEquals("3", outputValues.getAsString(Phone.NUMBER));
+ outputValues = list.get(3).getAfter();
+ assertEquals(Phone.TYPE_WORK, outputValues.getAsInteger(Phone.TYPE).intValue());
+ assertEquals("4", outputValues.getAsString(Phone.NUMBER));
+ }
+
+ public void testMigrateOrganizationFromGoogleToExchange() {
+ AccountType oldAccountType = new GoogleAccountType(getContext(), "");
+ AccountType newAccountType = new ExchangeAccountType(getContext(), "", EXCHANGE_ACCT_TYPE);
+ DataKind kind = newAccountType.getKindForMimetype(Organization.CONTENT_ITEM_TYPE);
+
+ RawContactDelta oldState = new RawContactDelta();
+ ContentValues mockNameValues = new ContentValues();
+ mockNameValues.put(Data.MIMETYPE, Organization.CONTENT_ITEM_TYPE);
+ mockNameValues.put(Organization.COMPANY, "company1");
+ mockNameValues.put(Organization.DEPARTMENT, "department1");
+ oldState.addEntry(ValuesDelta.fromAfter(mockNameValues));
+
+ RawContactDelta newState = new RawContactDelta();
+ RawContactModifier.migrateGenericWithoutTypeColumn(oldState, newState, kind);
+
+ List<ValuesDelta> list = newState.getMimeEntries(Organization.CONTENT_ITEM_TYPE);
+ assertNotNull(list);
+ assertEquals(1, list.size());
+
+ ContentValues outputValues = list.get(0).getAfter();
+ assertEquals("company1", outputValues.getAsString(Organization.COMPANY));
+ assertEquals("department1", outputValues.getAsString(Organization.DEPARTMENT));
+ }
+}
diff --git a/tests/src/com/android/contacts/common/compat/CompatUtilsTest.java b/tests/src/com/android/contacts/common/compat/CompatUtilsTest.java
new file mode 100644
index 0000000..3386a00
--- /dev/null
+++ b/tests/src/com/android/contacts/common/compat/CompatUtilsTest.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.compat;
+
+import android.test.AndroidTestCase;
+
+public class CompatUtilsTest extends AndroidTestCase {
+
+ public void testIsClassAvailable_NullClassName() {
+ assertFalse(CompatUtils.isClassAvailable(null));
+ }
+
+ public void testIsClassAvailable_EmptyClassName() {
+ assertFalse(CompatUtils.isClassAvailable(""));
+ }
+
+ public void testIsClassAvailable_NonexistentClass() {
+ assertFalse(CompatUtils.isClassAvailable("com.android.contacts.common.NonexistentClass"));
+ }
+
+ public void testIsClassAvailable() {
+ assertTrue(CompatUtils.isClassAvailable(BaseClass.class.getName()));
+ }
+
+ public void testIsMethodAvailable_NullClassName() {
+ assertFalse(CompatUtils.isMethodAvailable(null, "methodName"));
+ }
+
+ public void testIsMethodAvailable_EmptyClassName() {
+ assertFalse(CompatUtils.isMethodAvailable("", "methodName"));
+ }
+
+ public void testIsMethodAvailable_NullMethodName() {
+ assertFalse(CompatUtils.isMethodAvailable("className", null));
+ }
+
+ public void testIsMethodAvailable_EmptyMethodName() {
+ assertFalse(CompatUtils.isMethodAvailable("className", ""));
+ }
+
+ public void testIsMethodAvailable_NonexistentClass() {
+ assertFalse(CompatUtils.isMethodAvailable("com.android.contacts.common.NonexistentClass",
+ ""));
+ }
+
+ public void testIsMethodAvailable_NonexistentMethod() {
+ assertFalse(CompatUtils.isMethodAvailable(BaseClass.class.getName(), "derivedMethod"));
+ }
+
+ public void testIsMethodAvailable() {
+ assertTrue(CompatUtils.isMethodAvailable(BaseClass.class.getName(), "baseMethod"));
+ }
+
+ public void testIsMethodAvailable_InheritedMethod() {
+ assertTrue(CompatUtils.isMethodAvailable(DerivedClass.class.getName(), "baseMethod"));
+ }
+
+ public void testIsMethodAvailable_OverloadedMethod() {
+ assertTrue(CompatUtils.isMethodAvailable(DerivedClass.class.getName(), "overloadedMethod"));
+ assertTrue(CompatUtils.isMethodAvailable(DerivedClass.class.getName(), "overloadedMethod",
+ Integer.TYPE));
+ }
+
+ public void testIsMethodAvailable_NonexistentOverload() {
+ assertFalse(CompatUtils.isMethodAvailable(DerivedClass.class.getName(), "overloadedMethod",
+ Boolean.TYPE));
+ }
+
+ public void testInvokeMethod_NullMethodName() {
+ assertNull(CompatUtils.invokeMethod(new BaseClass(), null, null, null));
+ }
+
+ public void testInvokeMethod_EmptyMethodName() {
+ assertNull(CompatUtils.invokeMethod(new BaseClass(), "", null, null));
+ }
+
+ public void testInvokeMethod_NullClassInstance() {
+ assertNull(CompatUtils.invokeMethod(null, "", null, null));
+ }
+
+ public void testInvokeMethod_NonexistentMethod() {
+ assertNull(CompatUtils.invokeMethod(new BaseClass(), "derivedMethod", null, null));
+ }
+
+ public void testInvokeMethod_MethodWithNoParameters() {
+ assertEquals(1, CompatUtils.invokeMethod(new DerivedClass(), "overloadedMethod", null, null));
+ }
+
+ public void testInvokeMethod_MethodWithNoParameters_WithParameters() {
+ assertNull(CompatUtils.invokeMethod(new DerivedClass(), "derivedMethod",
+ new Class<?>[] {Integer.TYPE}, new Object[] {1}));
+ }
+
+ public void testInvokeMethod_MethodWithParameters_WithEmptyParameterList() {
+ assertNull(CompatUtils.invokeMethod(new DerivedClass(), "overloadedMethod",
+ new Class<?>[] {Integer.TYPE}, new Object[] {}));
+ }
+
+ public void testInvokeMethod_InvokeSimpleMethod() {
+ assertEquals(2, CompatUtils.invokeMethod(new DerivedClass(), "overloadedMethod",
+ new Class<?>[] {Integer.TYPE}, new Object[] {2}));
+ }
+
+ private class BaseClass {
+ public void baseMethod() {}
+ }
+
+ private class DerivedClass extends BaseClass {
+ public int derivedMethod() {
+ // This method needs to return something to differentiate a successful invocation from
+ // an unsuccessful one.
+ return 0;
+ }
+
+ public int overloadedMethod() {
+ return 1;
+ }
+
+ public int overloadedMethod(int i) {
+ return i;
+ }
+ }
+}
diff --git a/tests/src/com/android/contacts/common/database/NoNullCursorAsyncQueryHandlerTest.java b/tests/src/com/android/contacts/common/database/NoNullCursorAsyncQueryHandlerTest.java
new file mode 100644
index 0000000..a2b635d
--- /dev/null
+++ b/tests/src/com/android/contacts/common/database/NoNullCursorAsyncQueryHandlerTest.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.database;
+
+import android.database.Cursor;
+import android.net.Uri;
+import android.test.InstrumentationTestCase;
+import android.test.mock.MockContentProvider;
+import android.test.mock.MockContentResolver;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Unit test for {@link NoNullCursorAsyncQueryHandler}
+ */
+public class NoNullCursorAsyncQueryHandlerTest extends InstrumentationTestCase {
+
+ private MockContentResolver mMockContentResolver;
+
+ private static final String AUTHORITY = "com.android.contacts.common.unittest";
+ private static final Uri URI = Uri.parse("content://" + AUTHORITY);
+ private static final String[] PROJECTION = new String[]{"column1", "column2"};
+
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ mMockContentResolver = new MockContentResolver();
+ final MockContentProvider mMockContentProvider = new MockContentProvider() {
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection,
+ String[] selectionArgs,
+ String sortOrder) {
+ return null;
+ }
+ };
+
+ mMockContentResolver.addProvider(AUTHORITY, mMockContentProvider);
+ }
+
+ public void testCursorIsNotNull() throws Throwable {
+
+ final CountDownLatch latch = new CountDownLatch(1);
+ final ObjectHolder<Cursor> cursorHolder = ObjectHolder.newInstance();
+ final ObjectHolder<Boolean> ranHolder = ObjectHolder.newInstance(false);
+
+ runTestOnUiThread(new Runnable() {
+
+ @Override
+ public void run() {
+
+ NoNullCursorAsyncQueryHandler handler = new NoNullCursorAsyncQueryHandler(
+ mMockContentResolver) {
+ @Override
+ protected void onNotNullableQueryComplete(int token, Object cookie,
+ Cursor cursor) {
+ cursorHolder.obj = cursor;
+ ranHolder.obj = true;
+ latch.countDown();
+ }
+ };
+ handler.startQuery(1, null, URI, PROJECTION, null, null, null);
+ }
+ });
+
+ latch.await(5, TimeUnit.SECONDS);
+ assertFalse(cursorHolder.obj == null);
+ assertTrue(ranHolder.obj);
+ }
+
+ public void testCursorContainsCorrectCookies() throws Throwable {
+ final ObjectHolder<Boolean> ranHolder = ObjectHolder.newInstance(false);
+ final CountDownLatch latch = new CountDownLatch(1);
+ final ObjectHolder<Object> cookieHolder = ObjectHolder.newInstance();
+ final String cookie = "TEST COOKIE";
+ runTestOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ final NoNullCursorAsyncQueryHandler handler = new NoNullCursorAsyncQueryHandler(
+ mMockContentResolver) {
+ @Override
+ protected void onNotNullableQueryComplete(int token, Object cookie,
+ Cursor cursor) {
+ ranHolder.obj = true;
+ cookieHolder.obj = cookie;
+ latch.countDown();
+ }
+ };
+ handler.startQuery(1, cookie, URI, PROJECTION, null, null, null);
+ }
+ });
+
+ latch.await(5, TimeUnit.SECONDS);
+ assertSame(cookie, cookieHolder.obj);
+ assertTrue(ranHolder.obj);
+ }
+
+ public void testCursorContainsCorrectColumns() throws Throwable {
+ final ObjectHolder<Boolean> ranHolder = ObjectHolder.newInstance(false);
+ final CountDownLatch latch = new CountDownLatch(1);
+ final ObjectHolder<Cursor> cursorHolder = ObjectHolder.newInstance();
+ final String cookie = "TEST COOKIE";
+ runTestOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ final NoNullCursorAsyncQueryHandler handler = new NoNullCursorAsyncQueryHandler(
+ mMockContentResolver) {
+ @Override
+ protected void onNotNullableQueryComplete(int token, Object cookie,
+ Cursor cursor) {
+ ranHolder.obj = true;
+ cursorHolder.obj = cursor;
+ latch.countDown();
+ }
+ };
+ handler.startQuery(1, cookie, URI, PROJECTION, null, null, null);
+ }
+ });
+
+ latch.await(5, TimeUnit.SECONDS);
+ assertSame(PROJECTION, cursorHolder.obj.getColumnNames());
+ assertTrue(ranHolder.obj);
+ }
+
+ private static class ObjectHolder<T> {
+ public T obj;
+
+ public static <E> ObjectHolder<E> newInstance() {
+ return new ObjectHolder<E>();
+ }
+
+ public static <E> ObjectHolder<E> newInstance(E value) {
+ ObjectHolder<E> holder = new ObjectHolder<E>();
+ holder.obj = value;
+ return holder;
+ }
+ }
+}
diff --git a/tests/src/com/android/contacts/common/format/FormatUtilsTests.java b/tests/src/com/android/contacts/common/format/FormatUtilsTests.java
new file mode 100644
index 0000000..8f4f772
--- /dev/null
+++ b/tests/src/com/android/contacts/common/format/FormatUtilsTests.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.format;
+
+import android.database.CharArrayBuffer;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+
+/**
+ * Test cases for format utility methods.
+ */
+@SmallTest
+public class FormatUtilsTests extends AndroidTestCase {
+
+ public void testOverlapPoint() throws Exception {
+ assertEquals(2, FormatUtils.overlapPoint("abcde", "cdefg"));
+ assertEquals(-1, FormatUtils.overlapPoint("John Doe", "John Doe"));
+ assertEquals(5, FormatUtils.overlapPoint("John Doe", "Doe, John"));
+ assertEquals(-1, FormatUtils.overlapPoint("Mr. John Doe", "Mr. Doe, John"));
+ assertEquals(13, FormatUtils.overlapPoint("John Herbert Doe", "Doe, John Herbert"));
+ }
+
+ public void testCopyToCharArrayBuffer() {
+ CharArrayBuffer charArrayBuffer = new CharArrayBuffer(20);
+ checkCopyToCharArrayBuffer(charArrayBuffer, null, 0);
+ checkCopyToCharArrayBuffer(charArrayBuffer, "", 0);
+ checkCopyToCharArrayBuffer(charArrayBuffer, "test", 4);
+ // Check that it works after copying something into it.
+ checkCopyToCharArrayBuffer(charArrayBuffer, "", 0);
+ checkCopyToCharArrayBuffer(charArrayBuffer, "test", 4);
+ checkCopyToCharArrayBuffer(charArrayBuffer, null, 0);
+ // This requires a resize of the actual buffer.
+ checkCopyToCharArrayBuffer(charArrayBuffer, "test test test test test", 24);
+ }
+
+ public void testCharArrayBufferToString() {
+ checkCharArrayBufferToString("");
+ checkCharArrayBufferToString("test");
+ checkCharArrayBufferToString("test test test test test");
+ }
+
+ /** Checks that copying a string into a {@link CharArrayBuffer} and back works correctly. */
+ private void checkCharArrayBufferToString(String text) {
+ CharArrayBuffer buffer = new CharArrayBuffer(20);
+ FormatUtils.copyToCharArrayBuffer(text, buffer);
+ assertEquals(text, FormatUtils.charArrayBufferToString(buffer));
+ }
+
+ /**
+ * Checks that copying into the char array buffer copies the values correctly.
+ */
+ private void checkCopyToCharArrayBuffer(CharArrayBuffer buffer, String value, int length) {
+ FormatUtils.copyToCharArrayBuffer(value, buffer);
+ assertEquals(length, buffer.sizeCopied);
+ for (int index = 0; index < length; ++index) {
+ assertEquals(value.charAt(index), buffer.data[index]);
+ }
+ }
+
+ public void testIndexOfWordPrefix_NullPrefix() {
+ assertEquals(-1, FormatUtils.indexOfWordPrefix("test", null));
+ }
+
+ public void testIndexOfWordPrefix_NullText() {
+ assertEquals(-1, FormatUtils.indexOfWordPrefix(null, "TE"));
+ }
+
+ public void testIndexOfWordPrefix_MatchingPrefix() {
+ checkIndexOfWordPrefix("test", "TE", 0);
+ checkIndexOfWordPrefix("Test", "TE", 0);
+ checkIndexOfWordPrefix("TEst", "TE", 0);
+ checkIndexOfWordPrefix("TEST", "TE", 0);
+ checkIndexOfWordPrefix("a test", "TE", 2);
+ checkIndexOfWordPrefix("test test", "TE", 0);
+ checkIndexOfWordPrefix("a test test", "TE", 2);
+ }
+
+ public void testIndexOfWordPrefix_NotMatchingPrefix() {
+ checkIndexOfWordPrefix("test", "TA", -1);
+ checkIndexOfWordPrefix("test type theme", "TA", -1);
+ checkIndexOfWordPrefix("atest retest pretest", "TEST", -1);
+ checkIndexOfWordPrefix("tes", "TEST", -1);
+ }
+
+ public void testIndexOfWordPrefix_LowerCase() {
+ // The prefix match only works if the prefix is un upper case.
+ checkIndexOfWordPrefix("test", "te", -1);
+ }
+
+ /**
+ * Checks that getting the index of a word prefix in the given text returns the expected index.
+ *
+ * @param text the text in which to look for the word
+ * @param wordPrefix the word prefix to look for
+ * @param expectedIndex the expected value to be returned by the function
+ */
+ private void checkIndexOfWordPrefix(String text, String wordPrefix, int expectedIndex) {
+ assertEquals(expectedIndex, FormatUtils.indexOfWordPrefix(text, wordPrefix));
+ }
+}
diff --git a/tests/src/com/android/contacts/common/format/TextHighlighterTest.java b/tests/src/com/android/contacts/common/format/TextHighlighterTest.java
new file mode 100644
index 0000000..7bf28c5
--- /dev/null
+++ b/tests/src/com/android/contacts/common/format/TextHighlighterTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.format;
+
+import android.graphics.Typeface;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.text.SpannableString;
+
+import com.android.contacts.common.format.SpannedTestUtils;
+import com.android.contacts.common.format.TextHighlighter;
+
+import junit.framework.TestCase;
+
+/**
+ * Unit tests for {@link TextHighlighter}.
+ */
+@SmallTest
+public class TextHighlighterTest extends TestCase {
+ private static final int TEST_PREFIX_HIGHLIGHT_COLOR = 0xFF0000;
+
+ /** The object under test. */
+ private TextHighlighter mTextHighlighter;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ mTextHighlighter = new TextHighlighter(Typeface.BOLD);
+ }
+
+ public void testApply_EmptyPrefix() {
+ CharSequence seq = mTextHighlighter.applyPrefixHighlight("", "");
+ SpannedTestUtils.assertNotSpanned(seq, "");
+
+ seq = mTextHighlighter.applyPrefixHighlight("test", "");
+ SpannedTestUtils.assertNotSpanned(seq, "test");
+ }
+
+ public void testSetText_MatchingPrefix() {
+ final String prefix = "TE";
+
+ CharSequence seq = mTextHighlighter.applyPrefixHighlight("test", prefix);
+ SpannedTestUtils.assertPrefixSpan(seq, 0, 1);
+
+ seq = mTextHighlighter.applyPrefixHighlight("Test", prefix);
+ SpannedTestUtils.assertPrefixSpan(seq, 0, 1);
+
+ seq = mTextHighlighter.applyPrefixHighlight("TEst", prefix);
+ SpannedTestUtils.assertPrefixSpan(seq, 0, 1);
+
+ seq = mTextHighlighter.applyPrefixHighlight("a test", prefix);
+ SpannedTestUtils.assertPrefixSpan(seq, 2, 3);
+ }
+
+ public void testSetText_NotMatchingPrefix() {
+ final CharSequence seq = mTextHighlighter.applyPrefixHighlight("test", "TA");
+ SpannedTestUtils.assertNotSpanned(seq, "test");
+ }
+
+ public void testSetText_FirstMatch() {
+ final CharSequence seq = mTextHighlighter.applyPrefixHighlight(
+ "a test's tests are not tests", "TE");
+ SpannedTestUtils.assertPrefixSpan(seq, 2, 3);
+ }
+
+ public void testSetText_NoMatchingMiddleOfWord() {
+ final String prefix = "TE";
+ CharSequence seq = mTextHighlighter.applyPrefixHighlight("atest", prefix);
+ SpannedTestUtils.assertNotSpanned(seq, "atest");
+
+ seq = mTextHighlighter.applyPrefixHighlight("atest otest", prefix);
+ SpannedTestUtils.assertNotSpanned(seq, "atest otest");
+
+ seq = mTextHighlighter.applyPrefixHighlight("atest test", prefix);
+ SpannedTestUtils.assertPrefixSpan(seq, 6, 7);
+ }
+
+ public void testSetMask_Highlight() {
+ final SpannableString testString1 = new SpannableString("alongtest");
+ mTextHighlighter.applyMaskingHighlight(testString1, 2, 4);
+ assertEquals(2, SpannedTestUtils.getNextTransition(testString1, 0));
+ assertEquals(4, SpannedTestUtils.getNextTransition(testString1, 2));
+
+ mTextHighlighter.applyMaskingHighlight(testString1, 3, 6);
+ assertEquals(2, SpannedTestUtils.getNextTransition(testString1, 0));
+ assertEquals(4, SpannedTestUtils.getNextTransition(testString1, 3));
+
+ mTextHighlighter.applyMaskingHighlight(testString1, 4, 5);
+ assertEquals(3, SpannedTestUtils.getNextTransition(testString1, 2));
+
+ mTextHighlighter.applyMaskingHighlight(testString1, 7, 8);
+ assertEquals(6, SpannedTestUtils.getNextTransition(testString1, 5));
+ assertEquals(7, SpannedTestUtils.getNextTransition(testString1, 6));
+ assertEquals(8, SpannedTestUtils.getNextTransition(testString1, 7));
+ }
+}
diff --git a/tests/src/com/android/contacts/common/list/ContactListItemViewTest.java b/tests/src/com/android/contacts/common/list/ContactListItemViewTest.java
new file mode 100644
index 0000000..c9b2e6d
--- /dev/null
+++ b/tests/src/com/android/contacts/common/list/ContactListItemViewTest.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.list;
+
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.provider.ContactsContract;
+import android.test.ActivityInstrumentationTestCase2;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.LargeTest;
+import android.widget.TextView;
+
+import com.android.contacts.common.format.SpannedTestUtils;
+import com.android.contacts.common.list.ContactListItemView;
+import com.android.contacts.common.preference.ContactsPreferences;
+
+/**
+ * Unit tests for {@link com.android.contacts.common.list.ContactListItemView}.
+ *
+ * It uses an {@link ActivityInstrumentationTestCase2} for {@link PeopleActivity} because we need
+ * to have the style properly setup.
+ */
+@LargeTest
+public class ContactListItemViewTest extends AndroidTestCase {
+
+ //private IntegrationTestUtils mUtils;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ // This test requires that the screen be turned on.
+ //mUtils = new IntegrationTestUtils(getInstrumentation());
+ //mUtils.acquireScreenWakeLock(getInstrumentation().getTargetContext());
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ //mUtils.releaseScreenWakeLock();
+ super.tearDown();
+ }
+
+ public void testShowDisplayName_Simple() {
+ Cursor cursor = createCursor("John Doe", "Doe John");
+ ContactListItemView view = createView();
+
+ view.showDisplayName(cursor, 0, ContactsPreferences.DISPLAY_ORDER_PRIMARY);
+
+ assertEquals(view.getNameTextView().getText().toString(), "John Doe");
+ }
+
+ public void testShowDisplayName_Unknown() {
+ Cursor cursor = createCursor("", "");
+ ContactListItemView view = createView();
+
+ view.setUnknownNameText("unknown");
+ view.showDisplayName(cursor, 0, ContactsPreferences.DISPLAY_ORDER_PRIMARY);
+
+ assertEquals(view.getNameTextView().getText().toString(), "unknown");
+ }
+
+ public void testShowDisplayName_WithPrefix() {
+ Cursor cursor = createCursor("John Doe", "Doe John");
+ ContactListItemView view = createView();
+
+ view.setHighlightedPrefix("DOE");
+ view.showDisplayName(cursor, 0, ContactsPreferences.DISPLAY_ORDER_PRIMARY);
+
+ CharSequence seq = view.getNameTextView().getText();
+ assertEquals("John Doe", seq.toString());
+ SpannedTestUtils.assertPrefixSpan(seq, 5, 7);
+ // Talback should be without span tags.
+ assertEquals("John Doe", view.getNameTextView().getContentDescription());
+ assertFalse("John Doe".equals(seq));
+ }
+
+ public void testShowDisplayName_WithPrefixReversed() {
+ Cursor cursor = createCursor("John Doe", "Doe John");
+ ContactListItemView view = createView();
+
+ view.setHighlightedPrefix("DOE");
+ view.showDisplayName(cursor, 0, ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE);
+
+ CharSequence seq = view.getNameTextView().getText();
+ assertEquals("John Doe", seq.toString());
+ SpannedTestUtils.assertPrefixSpan(seq, 5, 7);
+ }
+
+ public void testSetSnippet_Prefix() {
+ ContactListItemView view = createView();
+ view.setHighlightedPrefix("TEST");
+ view.setSnippet("This is a test");
+
+ CharSequence seq = view.getSnippetView().getText();
+
+ assertEquals("This is a test", seq.toString());
+ SpannedTestUtils.assertPrefixSpan(seq, 10, 13);
+ }
+
+ /** Creates the view to be tested. */
+ private ContactListItemView createView() {
+ ContactListItemView view = new ContactListItemView(getContext());
+ // Set the name view to use a Spannable to represent its content.
+ view.getNameTextView().setText("", TextView.BufferType.SPANNABLE);
+ return view;
+ }
+
+ /**
+ * Creates a cursor containing a pair of values.
+ *
+ * @param name the name to insert in the first column of the cursor
+ * @param alternateName the alternate name to insert in the second column of the cursor
+ * @return the newly created cursor
+ */
+ private Cursor createCursor(String name, String alternateName) {
+ MatrixCursor cursor = new MatrixCursor(new String[]{"Name", "AlternateName"});
+ cursor.moveToFirst();
+ cursor.addRow(new Object[]{name, alternateName});
+ return cursor;
+ }
+}
diff --git a/tests/src/com/android/contacts/common/model/AccountTypeManagerTest.java b/tests/src/com/android/contacts/common/model/AccountTypeManagerTest.java
new file mode 100644
index 0000000..b8ebdd2
--- /dev/null
+++ b/tests/src/com/android/contacts/common/model/AccountTypeManagerTest.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.Context;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountTypeWithDataSet;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Test case for {@link com.android.contacts.common.model.AccountTypeManager}.
+ *
+ * adb shell am instrument -w -e class com.android.contacts.model.AccountTypeManagerTest \
+ com.android.contacts.tests/android.test.InstrumentationTestRunner
+ */
+@SmallTest
+public class AccountTypeManagerTest extends AndroidTestCase {
+ public void testFindAllInvitableAccountTypes() {
+ final Context c = getContext();
+
+ // Define account types.
+ final AccountType typeA = new MockAccountType("type1", null, null);
+ final AccountType typeB = new MockAccountType("type1", "minus", null);
+ final AccountType typeC = new MockAccountType("type2", null, "c");
+ final AccountType typeD = new MockAccountType("type2", "minus", "d");
+
+ // Define users
+ final AccountWithDataSet accountA1 = createAccountWithDataSet("a1", typeA);
+ final AccountWithDataSet accountC1 = createAccountWithDataSet("c1", typeC);
+ final AccountWithDataSet accountC2 = createAccountWithDataSet("c2", typeC);
+ final AccountWithDataSet accountD1 = createAccountWithDataSet("d1", typeD);
+
+ // empty - empty
+ Map<AccountTypeWithDataSet, AccountType> types =
+ AccountTypeManagerImpl.findAllInvitableAccountTypes(c,
+ buildAccounts(), buildAccountTypes());
+ assertEquals(0, types.size());
+ try {
+ types.clear();
+ fail("Returned Map should be unmodifiable.");
+ } catch (UnsupportedOperationException ok) {
+ }
+
+ // No invite support, no accounts
+ verifyAccountTypes(
+ buildAccounts(),
+ buildAccountTypes(typeA, typeB)
+ /* empty */
+ );
+
+ // No invite support, with accounts
+ verifyAccountTypes(
+ buildAccounts(accountA1),
+ buildAccountTypes(typeA)
+ /* empty */
+ );
+
+ // With invite support, no accounts
+ verifyAccountTypes(
+ buildAccounts(),
+ buildAccountTypes(typeC)
+ /* empty */
+ );
+
+ // With invite support, 1 account
+ verifyAccountTypes(
+ buildAccounts(accountC1),
+ buildAccountTypes(typeC),
+ typeC
+ );
+
+ // With invite support, 2 account
+ verifyAccountTypes(
+ buildAccounts(accountC1, accountC2),
+ buildAccountTypes(typeC),
+ typeC
+ );
+
+ // Combinations...
+ verifyAccountTypes(
+ buildAccounts(accountA1),
+ buildAccountTypes(typeA, typeC)
+ /* empty */
+ );
+
+ verifyAccountTypes(
+ buildAccounts(accountC1, accountA1),
+ buildAccountTypes(typeA, typeC),
+ typeC
+ );
+
+ verifyAccountTypes(
+ buildAccounts(accountC1, accountA1),
+ buildAccountTypes(typeD, typeA, typeC),
+ typeC
+ );
+
+ verifyAccountTypes(
+ buildAccounts(accountC1, accountA1, accountD1),
+ buildAccountTypes(typeD, typeA, typeC, typeB),
+ typeC, typeD
+ );
+ }
+
+ private static AccountWithDataSet createAccountWithDataSet(String name, AccountType type) {
+ return new AccountWithDataSet(name, type.accountType, type.dataSet);
+ }
+
+ /**
+ * Array of {@link AccountType} -> {@link Map}
+ */
+ private static Map<AccountTypeWithDataSet, AccountType> buildAccountTypes(AccountType... types) {
+ final HashMap<AccountTypeWithDataSet, AccountType> result = Maps.newHashMap();
+ for (AccountType type : types) {
+ result.put(type.getAccountTypeAndDataSet(), type);
+ }
+ return result;
+ }
+
+ /**
+ * Array of {@link AccountWithDataSet} -> {@link Collection}
+ */
+ private static Collection<AccountWithDataSet> buildAccounts(AccountWithDataSet... accounts) {
+ final List<AccountWithDataSet> result = Lists.newArrayList();
+ for (AccountWithDataSet account : accounts) {
+ result.add(account);
+ }
+ return result;
+ }
+
+ /**
+ * Executes {@link AccountTypeManagerImpl#findInvitableAccountTypes} and verifies the
+ * result.
+ */
+ private void verifyAccountTypes(
+ Collection<AccountWithDataSet> accounts,
+ Map<AccountTypeWithDataSet, AccountType> types,
+ AccountType... expectedInvitableTypes
+ ) {
+ Map<AccountTypeWithDataSet, AccountType> result =
+ AccountTypeManagerImpl.findAllInvitableAccountTypes(getContext(), accounts, types);
+ for (AccountType type : expectedInvitableTypes) {
+ assertTrue("Result doesn't contain type=" + type.getAccountTypeAndDataSet(),
+ result.containsKey(type.getAccountTypeAndDataSet()));
+ }
+ final int numExcessTypes = result.size() - expectedInvitableTypes.length;
+ assertEquals("Result contains " + numExcessTypes + " excess type(s)", 0, numExcessTypes);
+ }
+
+ private static class MockAccountType extends AccountType {
+ private final String mInviteContactActivityClassName;
+
+ public MockAccountType(String type, String dataSet, String inviteContactActivityClassName) {
+ accountType = type;
+ this.dataSet = dataSet;
+ mInviteContactActivityClassName = inviteContactActivityClassName;
+ }
+
+ @Override
+ public String getInviteContactActivityClassName() {
+ return mInviteContactActivityClassName;
+ }
+
+ @Override
+ public boolean isGroupMembershipEditable() {
+ return false;
+ }
+
+ @Override
+ public boolean areContactsWritable() {
+ return false;
+ }
+ }
+}
diff --git a/tests/src/com/android/contacts/common/model/AccountWithDataSetTest.java b/tests/src/com/android/contacts/common/model/AccountWithDataSetTest.java
new file mode 100644
index 0000000..e28f09e
--- /dev/null
+++ b/tests/src/com/android/contacts/common/model/AccountWithDataSetTest.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.os.Bundle;
+import android.test.AndroidTestCase;
+import android.test.MoreAsserts;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.google.common.collect.Lists;
+
+import java.util.List;
+
+/**
+ * Test case for {@link AccountWithDataSet}.
+ *
+ * adb shell am instrument -w -e class com.android.contacts.model.AccountWithDataSetTest \
+ com.android.contacts.tests/android.test.InstrumentationTestRunner
+ */
+@SmallTest
+public class AccountWithDataSetTest extends AndroidTestCase {
+ public void testStringifyAndUnstringify() {
+ AccountWithDataSet a1 = new AccountWithDataSet("name1", "typeA", null);
+ AccountWithDataSet a2 = new AccountWithDataSet("name2", "typeB", null);
+ AccountWithDataSet a3 = new AccountWithDataSet("name3", "typeB", "dataset");
+
+ // stringify() & unstringify
+ AccountWithDataSet a1r = AccountWithDataSet.unstringify(a1.stringify());
+ AccountWithDataSet a2r = AccountWithDataSet.unstringify(a2.stringify());
+ AccountWithDataSet a3r = AccountWithDataSet.unstringify(a3.stringify());
+
+ assertEquals(a1, a1r);
+ assertEquals(a2, a2r);
+ assertEquals(a3, a3r);
+
+ MoreAsserts.assertNotEqual(a1, a2r);
+ MoreAsserts.assertNotEqual(a1, a3r);
+
+ MoreAsserts.assertNotEqual(a2, a1r);
+ MoreAsserts.assertNotEqual(a2, a3r);
+
+ MoreAsserts.assertNotEqual(a3, a1r);
+ MoreAsserts.assertNotEqual(a3, a2r);
+ }
+
+ public void testStringifyListAndUnstringify() {
+ AccountWithDataSet a1 = new AccountWithDataSet("name1", "typeA", null);
+ AccountWithDataSet a2 = new AccountWithDataSet("name2", "typeB", null);
+ AccountWithDataSet a3 = new AccountWithDataSet("name3", "typeB", "dataset");
+
+ // Empty list
+ assertEquals(0, stringifyListAndUnstringify().size());
+
+ // 1 element
+ final List<AccountWithDataSet> listA = stringifyListAndUnstringify(a1);
+ assertEquals(1, listA.size());
+ assertEquals(a1, listA.get(0));
+
+ // 2 elements
+ final List<AccountWithDataSet> listB = stringifyListAndUnstringify(a2, a1);
+ assertEquals(2, listB.size());
+ assertEquals(a2, listB.get(0));
+ assertEquals(a1, listB.get(1));
+
+ // 3 elements
+ final List<AccountWithDataSet> listC = stringifyListAndUnstringify(a3, a2, a1);
+ assertEquals(3, listC.size());
+ assertEquals(a3, listC.get(0));
+ assertEquals(a2, listC.get(1));
+ assertEquals(a1, listC.get(2));
+ }
+
+ private static List<AccountWithDataSet> stringifyListAndUnstringify(
+ AccountWithDataSet... accounts) {
+
+ List<AccountWithDataSet> list = Lists.newArrayList(accounts);
+ return AccountWithDataSet.unstringifyList(AccountWithDataSet.stringifyList(list));
+ }
+
+ public void testParcelable() {
+ AccountWithDataSet a1 = new AccountWithDataSet("name1", "typeA", null);
+ AccountWithDataSet a2 = new AccountWithDataSet("name2", "typeB", null);
+ AccountWithDataSet a3 = new AccountWithDataSet("name3", "typeB", "dataset");
+
+ // Parcel them & unpercel.
+ final Bundle b = new Bundle();
+ b.putParcelable("a1", a1);
+ b.putParcelable("a2", a2);
+ b.putParcelable("a3", a3);
+
+ AccountWithDataSet a1r = b.getParcelable("a1");
+ AccountWithDataSet a2r = b.getParcelable("a2");
+ AccountWithDataSet a3r = b.getParcelable("a3");
+
+ assertEquals(a1, a1r);
+ assertEquals(a2, a2r);
+ assertEquals(a3, a3r);
+
+ MoreAsserts.assertNotEqual(a1, a2r);
+ MoreAsserts.assertNotEqual(a1, a3r);
+
+ MoreAsserts.assertNotEqual(a2, a1r);
+ MoreAsserts.assertNotEqual(a2, a3r);
+
+ MoreAsserts.assertNotEqual(a3, a1r);
+ MoreAsserts.assertNotEqual(a3, a2r);
+ }
+}
diff --git a/tests/src/com/android/contacts/common/model/ContactLoaderTest.java b/tests/src/com/android/contacts/common/model/ContactLoaderTest.java
new file mode 100644
index 0000000..9878a12
--- /dev/null
+++ b/tests/src/com/android/contacts/common/model/ContactLoaderTest.java
@@ -0,0 +1,433 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.ContentUris;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.DisplayNameSources;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.ContactsContract.StatusUpdates;
+import android.test.LoaderTestCase;
+import android.test.suitebuilder.annotation.LargeTest;
+
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.model.account.BaseAccountType;
+import com.android.contacts.common.testing.InjectedServices;
+import com.android.contacts.common.test.mocks.ContactsMockContext;
+import com.android.contacts.common.test.mocks.MockContentProvider;
+import com.android.contacts.common.test.mocks.MockContentProvider.Query;
+import com.android.contacts.common.test.mocks.MockAccountTypeManager;
+import com.android.contacts.common.util.Constants;
+
+import com.google.common.collect.Lists;
+
+import java.util.List;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Runs ContactLoader tests for the the contact-detail and editor view.
+ */
+@LargeTest
+public class ContactLoaderTest extends LoaderTestCase {
+ private static final long CONTACT_ID = 1;
+ private static final long RAW_CONTACT_ID = 11;
+ private static final long DATA_ID = 21;
+ private static final String LOOKUP_KEY = "aa%12%@!";
+
+ private ContactsMockContext mMockContext;
+ private MockContentProvider mContactsProvider;
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ mMockContext = new ContactsMockContext(getContext());
+ mContactsProvider = mMockContext.getContactsProvider();
+
+ InjectedServices services = new InjectedServices();
+ AccountType accountType = new BaseAccountType() {
+ @Override
+ public boolean areContactsWritable() {
+ return false;
+ }
+ };
+ accountType.accountType = "mockAccountType";
+
+ AccountWithDataSet account =
+ new AccountWithDataSet("mockAccountName", "mockAccountType", null);
+
+ AccountTypeManager.setInstanceForTest(
+ new MockAccountTypeManager(
+ new AccountType[]{accountType}, new AccountWithDataSet[]{account}));
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ mMockContext = null;
+ mContactsProvider = null;
+ super.tearDown();
+ }
+
+ private Contact assertLoadContact(Uri uri) {
+ final ContactLoader loader = new ContactLoader(mMockContext, uri, true);
+ return getLoaderResultSynchronously(loader);
+ }
+
+ public void testNullUri() {
+ Contact result = assertLoadContact(null);
+ assertTrue(result.isError());
+ }
+
+ public void testEmptyUri() {
+ Contact result = assertLoadContact(Uri.EMPTY);
+ assertTrue(result.isError());
+ }
+
+ public void testInvalidUri() {
+ Contact result = assertLoadContact(Uri.parse("content://wtf"));
+ assertTrue(result.isError());
+ }
+
+ public void testLoadContactWithContactIdUri() {
+ // Use content Uris that only contain the ID
+ final Uri baseUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, CONTACT_ID);
+ final Uri entityUri = Uri.withAppendedPath(baseUri, Contacts.Entity.CONTENT_DIRECTORY);
+ final Uri lookupUri = ContentUris.withAppendedId(
+ Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, LOOKUP_KEY),
+ CONTACT_ID);
+
+ ContactQueries queries = new ContactQueries();
+ mContactsProvider.expectTypeQuery(baseUri, Contacts.CONTENT_ITEM_TYPE);
+ queries.fetchAllData(entityUri, CONTACT_ID, RAW_CONTACT_ID, DATA_ID, LOOKUP_KEY);
+
+ Contact contact = assertLoadContact(baseUri);
+
+ assertEquals(CONTACT_ID, contact.getId());
+ assertEquals(RAW_CONTACT_ID, contact.getNameRawContactId());
+ assertEquals(DisplayNameSources.STRUCTURED_NAME, contact.getDisplayNameSource());
+ assertEquals(LOOKUP_KEY, contact.getLookupKey());
+ assertEquals(lookupUri, contact.getLookupUri());
+ assertEquals(1, contact.getRawContacts().size());
+ assertEquals(1, contact.getStatuses().size());
+ mContactsProvider.verify();
+ }
+
+ public void testLoadContactWithOldStyleUri() {
+ // Use content Uris that only contain the ID but use the format used in Donut
+ final Uri legacyUri = ContentUris.withAppendedId(
+ Uri.parse("content://contacts"), RAW_CONTACT_ID);
+ final Uri rawContactUri = ContentUris.withAppendedId(
+ RawContacts.CONTENT_URI, RAW_CONTACT_ID);
+ final Uri baseUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, CONTACT_ID);
+ final Uri lookupUri = ContentUris.withAppendedId(
+ Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, LOOKUP_KEY),
+ CONTACT_ID);
+ final Uri entityUri = Uri.withAppendedPath(lookupUri, Contacts.Entity.CONTENT_DIRECTORY);
+
+ ContactQueries queries = new ContactQueries();
+ queries.fetchContactIdAndLookupFromRawContactUri(rawContactUri, CONTACT_ID, LOOKUP_KEY);
+ queries.fetchAllData(entityUri, CONTACT_ID, RAW_CONTACT_ID, DATA_ID, LOOKUP_KEY);
+
+ Contact contact = assertLoadContact(legacyUri);
+
+ assertEquals(CONTACT_ID, contact.getId());
+ assertEquals(RAW_CONTACT_ID, contact.getNameRawContactId());
+ assertEquals(DisplayNameSources.STRUCTURED_NAME, contact.getDisplayNameSource());
+ assertEquals(LOOKUP_KEY, contact.getLookupKey());
+ assertEquals(lookupUri, contact.getLookupUri());
+ assertEquals(1, contact.getRawContacts().size());
+ assertEquals(1, contact.getStatuses().size());
+ if (CompatUtils.isMarshmallowCompatible()) {
+ assertEquals(
+ 1, contact.getRawContacts().get(0).getDataItems().get(0).getCarrierPresence());
+ }
+ mContactsProvider.verify();
+ }
+
+ public void testLoadContactWithRawContactIdUri() {
+ // Use content Uris that only contain the ID but use the format used in Donut
+ final Uri rawContactUri = ContentUris.withAppendedId(
+ RawContacts.CONTENT_URI, RAW_CONTACT_ID);
+ final Uri baseUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, CONTACT_ID);
+ final Uri lookupUri = ContentUris.withAppendedId(
+ Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, LOOKUP_KEY),
+ CONTACT_ID);
+ final Uri entityUri = Uri.withAppendedPath(lookupUri, Contacts.Entity.CONTENT_DIRECTORY);
+
+ ContactQueries queries = new ContactQueries();
+ mContactsProvider.expectTypeQuery(rawContactUri, RawContacts.CONTENT_ITEM_TYPE);
+ queries.fetchContactIdAndLookupFromRawContactUri(rawContactUri, CONTACT_ID, LOOKUP_KEY);
+ queries.fetchAllData(entityUri, CONTACT_ID, RAW_CONTACT_ID, DATA_ID, LOOKUP_KEY);
+
+ Contact contact = assertLoadContact(rawContactUri);
+
+ assertEquals(CONTACT_ID, contact.getId());
+ assertEquals(RAW_CONTACT_ID, contact.getNameRawContactId());
+ assertEquals(DisplayNameSources.STRUCTURED_NAME, contact.getDisplayNameSource());
+ assertEquals(LOOKUP_KEY, contact.getLookupKey());
+ assertEquals(lookupUri, contact.getLookupUri());
+ assertEquals(1, contact.getRawContacts().size());
+ assertEquals(1, contact.getStatuses().size());
+ mContactsProvider.verify();
+ }
+
+ public void testLoadContactWithContactLookupUri() {
+ // Use lookup-style Uris that do not contain the Contact-ID
+ final Uri baseUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, CONTACT_ID);
+ final Uri lookupNoIdUri = Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, LOOKUP_KEY);
+ final Uri lookupUri = ContentUris.withAppendedId(lookupNoIdUri, CONTACT_ID);
+ final Uri entityUri = Uri.withAppendedPath(
+ lookupNoIdUri, Contacts.Entity.CONTENT_DIRECTORY);
+
+ ContactQueries queries = new ContactQueries();
+ mContactsProvider.expectTypeQuery(lookupNoIdUri, Contacts.CONTENT_ITEM_TYPE);
+ queries.fetchAllData(entityUri, CONTACT_ID, RAW_CONTACT_ID, DATA_ID, LOOKUP_KEY);
+
+ Contact contact = assertLoadContact(lookupNoIdUri);
+
+ assertEquals(CONTACT_ID, contact.getId());
+ assertEquals(RAW_CONTACT_ID, contact.getNameRawContactId());
+ assertEquals(DisplayNameSources.STRUCTURED_NAME, contact.getDisplayNameSource());
+ assertEquals(LOOKUP_KEY, contact.getLookupKey());
+ assertEquals(lookupUri, contact.getLookupUri());
+ assertEquals(1, contact.getRawContacts().size());
+ assertEquals(1, contact.getStatuses().size());
+ mContactsProvider.verify();
+ }
+
+ public void testLoadContactWithContactLookupAndIdUri() {
+ // Use lookup-style Uris that also contain the Contact-ID
+ final Uri baseUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, CONTACT_ID);
+ final Uri lookupUri = ContentUris.withAppendedId(
+ Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, LOOKUP_KEY),
+ CONTACT_ID);
+ final Uri entityUri = Uri.withAppendedPath(lookupUri, Contacts.Entity.CONTENT_DIRECTORY);
+
+ ContactQueries queries = new ContactQueries();
+ mContactsProvider.expectTypeQuery(lookupUri, Contacts.CONTENT_ITEM_TYPE);
+ queries.fetchAllData(entityUri, CONTACT_ID, RAW_CONTACT_ID, DATA_ID, LOOKUP_KEY);
+
+ Contact contact = assertLoadContact(lookupUri);
+
+ assertEquals(CONTACT_ID, contact.getId());
+ assertEquals(RAW_CONTACT_ID, contact.getNameRawContactId());
+ assertEquals(DisplayNameSources.STRUCTURED_NAME, contact.getDisplayNameSource());
+ assertEquals(LOOKUP_KEY, contact.getLookupKey());
+ assertEquals(lookupUri, contact.getLookupUri());
+ assertEquals(1, contact.getRawContacts().size());
+ assertEquals(1, contact.getStatuses().size());
+ mContactsProvider.verify();
+ }
+
+ public void testLoadContactWithContactLookupWithIncorrectIdUri() {
+ // Use lookup-style Uris that contain incorrect Contact-ID
+ // (we want to ensure that still the correct contact is chosen)
+ final long wrongContactId = 2;
+ final long wrongRawContactId = 12;
+
+ final String wrongLookupKey = "ab%12%@!";
+ final Uri baseUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, CONTACT_ID);
+ final Uri wrongBaseUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, wrongContactId);
+ final Uri lookupUri = ContentUris.withAppendedId(
+ Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, LOOKUP_KEY),
+ CONTACT_ID);
+ final Uri lookupWithWrongIdUri = ContentUris.withAppendedId(
+ Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, LOOKUP_KEY),
+ wrongContactId);
+ final Uri entityUri = Uri.withAppendedPath(lookupWithWrongIdUri,
+ Contacts.Entity.CONTENT_DIRECTORY);
+
+ ContactQueries queries = new ContactQueries();
+ mContactsProvider.expectTypeQuery(lookupWithWrongIdUri, Contacts.CONTENT_ITEM_TYPE);
+ queries.fetchAllData(entityUri, CONTACT_ID, RAW_CONTACT_ID, DATA_ID, LOOKUP_KEY);
+
+ Contact contact = assertLoadContact(lookupWithWrongIdUri);
+
+ assertEquals(CONTACT_ID, contact.getId());
+ assertEquals(RAW_CONTACT_ID, contact.getNameRawContactId());
+ assertEquals(DisplayNameSources.STRUCTURED_NAME, contact.getDisplayNameSource());
+ assertEquals(LOOKUP_KEY, contact.getLookupKey());
+ assertEquals(lookupUri, contact.getLookupUri());
+ assertEquals(1, contact.getRawContacts().size());
+ assertEquals(1, contact.getStatuses().size());
+
+ mContactsProvider.verify();
+ }
+
+ public void testLoadContactReturnDirectoryContactWithoutDisplayName() throws JSONException {
+ // Use lookup-style Uri that contains encoded json object which encapsulates the
+ // directory contact. The test json object is:
+ // {
+ // display_name_source": 40,
+ // "vnd.android.cursor.item\/contact":{"email":{"data1":"test@google.com" }}
+ // }
+ JSONObject itemJson = new JSONObject();
+ itemJson.put("email", new JSONObject().put("data1", "test@google.com"));
+ JSONObject json = new JSONObject();
+ json.put(Contacts.NAME_RAW_CONTACT_ID, CONTACT_ID);
+ json.put(Contacts.DISPLAY_NAME_SOURCE, DisplayNameSources.STRUCTURED_NAME);
+ json.put(Contacts.CONTENT_ITEM_TYPE, itemJson);
+
+ final Uri lookupUri = Contacts.CONTENT_LOOKUP_URI.buildUpon()
+ .encodedFragment(json.toString())
+ .appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, "1")
+ .appendPath(Constants.LOOKUP_URI_ENCODED).build();
+
+ mContactsProvider.expectTypeQuery(lookupUri, Contacts.CONTENT_ITEM_TYPE);
+ Contact contact = assertLoadContact(lookupUri);
+
+ assertEquals(-1, contact.getId());
+ assertEquals(-1, contact.getNameRawContactId());
+ assertEquals(DisplayNameSources.STRUCTURED_NAME, contact.getDisplayNameSource());
+ assertEquals("", contact.getDisplayName());
+ assertEquals(lookupUri, contact.getLookupUri());
+ assertEquals(1, contact.getRawContacts().size());
+ mContactsProvider.verify();
+ }
+
+ class ContactQueries {
+ public void fetchAllData(
+ Uri baseUri, long contactId, long rawContactId, long dataId, String encodedLookup) {
+ final String[] COLUMNS_INTERNAL = new String[] {
+ Contacts.NAME_RAW_CONTACT_ID, Contacts.DISPLAY_NAME_SOURCE,
+ Contacts.LOOKUP_KEY, Contacts.DISPLAY_NAME,
+ Contacts.DISPLAY_NAME_ALTERNATIVE, Contacts.PHONETIC_NAME,
+ Contacts.PHOTO_ID, Contacts.STARRED, Contacts.CONTACT_PRESENCE,
+ Contacts.CONTACT_STATUS, Contacts.CONTACT_STATUS_TIMESTAMP,
+ Contacts.CONTACT_STATUS_RES_PACKAGE, Contacts.CONTACT_STATUS_LABEL,
+
+ Contacts.Entity.CONTACT_ID,
+ Contacts.Entity.RAW_CONTACT_ID,
+
+ RawContacts.ACCOUNT_NAME, RawContacts.ACCOUNT_TYPE,
+ RawContacts.DATA_SET,
+ RawContacts.DIRTY, RawContacts.VERSION, RawContacts.SOURCE_ID,
+ RawContacts.SYNC1, RawContacts.SYNC2, RawContacts.SYNC3, RawContacts.SYNC4,
+ RawContacts.DELETED,
+
+ Contacts.Entity.DATA_ID,
+
+ Data.DATA1, Data.DATA2, Data.DATA3, Data.DATA4, Data.DATA5,
+ Data.DATA6, Data.DATA7, Data.DATA8, Data.DATA9, Data.DATA10,
+ Data.DATA11, Data.DATA12, Data.DATA13, Data.DATA14, Data.DATA15,
+ Data.SYNC1, Data.SYNC2, Data.SYNC3, Data.SYNC4,
+ Data.DATA_VERSION, Data.IS_PRIMARY,
+ Data.IS_SUPER_PRIMARY, Data.MIMETYPE,
+
+ GroupMembership.GROUP_SOURCE_ID,
+
+ Data.PRESENCE, Data.CHAT_CAPABILITY,
+ Data.STATUS, Data.STATUS_RES_PACKAGE, Data.STATUS_ICON,
+ Data.STATUS_LABEL, Data.STATUS_TIMESTAMP,
+
+ Contacts.PHOTO_URI,
+
+ Contacts.SEND_TO_VOICEMAIL,
+ Contacts.CUSTOM_RINGTONE,
+ Contacts.IS_USER_PROFILE,
+
+ Data.TIMES_USED,
+ Data.LAST_TIME_USED
+ };
+
+ List<String> projectionList = Lists.newArrayList(COLUMNS_INTERNAL);
+ if (CompatUtils.isMarshmallowCompatible()) {
+ projectionList.add(Data.CARRIER_PRESENCE);
+ }
+ final String[] COLUMNS = projectionList.toArray(new String[projectionList.size()]);
+
+ final Object[] ROWS_INTERNAL = new Object[] {
+ rawContactId, 40,
+ "aa%12%@!", "John Doe", "Doe, John", "jdo",
+ 0, 0, StatusUpdates.AVAILABLE,
+ "Having lunch", 0,
+ "mockPkg1", 10,
+
+ contactId,
+ rawContactId,
+
+ "mockAccountName", "mockAccountType", null,
+ 0, 1, 0,
+ "sync1", "sync2", "sync3", "sync4",
+ 0,
+
+ dataId,
+
+ "dat1", "dat2", "dat3", "dat4", "dat5",
+ "dat6", "dat7", "dat8", "dat9", "dat10",
+ "dat11", "dat12", "dat13", "dat14", "dat15",
+ "syn1", "syn2", "syn3", "syn4",
+
+ 0, 0,
+ 0, StructuredName.CONTENT_ITEM_TYPE,
+
+ "groupId",
+
+ StatusUpdates.INVISIBLE, null,
+ "Having dinner", "mockPkg3", 0,
+ 20, 0,
+
+ "content:some.photo.uri",
+
+ 0,
+ null,
+ 0,
+
+ 0,
+ 0
+ };
+
+ List<Object> rowsList = Lists.newArrayList(ROWS_INTERNAL);
+ if (CompatUtils.isMarshmallowCompatible()) {
+ rowsList.add(Data.CARRIER_PRESENCE_VT_CAPABLE);
+ }
+ final Object[] ROWS = rowsList.toArray(new Object[rowsList.size()]);
+
+ mContactsProvider.expectQuery(baseUri)
+ .withProjection(COLUMNS)
+ .withSortOrder(Contacts.Entity.RAW_CONTACT_ID)
+ .returnRow(ROWS);
+ }
+
+ void fetchLookupAndId(final Uri sourceUri, final long expectedContactId,
+ final String expectedEncodedLookup) {
+ mContactsProvider.expectQuery(sourceUri)
+ .withProjection(Contacts.LOOKUP_KEY, Contacts._ID)
+ .returnRow(expectedEncodedLookup, expectedContactId);
+ }
+
+ void fetchContactIdAndLookupFromRawContactUri(final Uri rawContactUri,
+ final long expectedContactId, final String expectedEncodedLookup) {
+ // TODO: use a lighter query by joining rawcontacts with contacts in provider
+ // (See ContactContracts.java)
+ final Uri dataUri = Uri.withAppendedPath(rawContactUri,
+ RawContacts.Data.CONTENT_DIRECTORY);
+ mContactsProvider.expectQuery(dataUri)
+ .withProjection(RawContacts.CONTACT_ID, Contacts.LOOKUP_KEY)
+ .returnRow(expectedContactId, expectedEncodedLookup);
+ }
+ }
+}
diff --git a/tests/src/com/android/contacts/common/model/RawContactTest.java b/tests/src/com/android/contacts/common/model/RawContactTest.java
new file mode 100644
index 0000000..b8e03c3
--- /dev/null
+++ b/tests/src/com/android/contacts/common/model/RawContactTest.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.ContentValues;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.contacts.common.model.RawContact;
+
+import junit.framework.TestCase;
+
+/**
+ * Unit test for {@link RawContact}.
+ */
+public class RawContactTest extends TestCase {
+
+ private RawContact buildRawContact() {
+ final ContentValues values = new ContentValues();
+ values.put("key1", "value1");
+ values.put("key2", "value2");
+
+ final ContentValues dataItem = new ContentValues();
+ dataItem.put("key3", "value3");
+ dataItem.put("key4", "value4");
+
+ final RawContact contact = new RawContact(values);
+ contact.addDataItemValues(dataItem);
+
+ return contact;
+ }
+
+ private RawContact buildRawContact2() {
+ final ContentValues values = new ContentValues();
+ values.put("key11", "value11");
+ values.put("key22", "value22");
+
+ final ContentValues dataItem = new ContentValues();
+ dataItem.put("key33", "value33");
+ dataItem.put("key44", "value44");
+
+ final RawContact contact = new RawContact(values);
+ contact.addDataItemValues(dataItem);
+
+ return contact;
+ }
+
+ public void testNotEquals() {
+ final RawContact one = buildRawContact();
+ final RawContact two = buildRawContact2();
+ assertFalse(one.equals(two));
+ }
+
+ public void testEquals() {
+ assertEquals(buildRawContact(), buildRawContact());
+ }
+
+ public void testParcelable() {
+ assertParcelableEquals(buildRawContact());
+ }
+
+ private RawContact.NamedDataItem buildNamedDataItem() {
+ final ContentValues values = new ContentValues();
+ values.put("key1", "value1");
+ values.put("key2", "value2");
+ final Uri uri = Uri.fromParts("content:", "ssp", "fragment");
+
+ return new RawContact.NamedDataItem(uri, values);
+ }
+
+ private RawContact.NamedDataItem buildNamedDataItem2() {
+ final ContentValues values = new ContentValues();
+ values.put("key11", "value11");
+ values.put("key22", "value22");
+ final Uri uri = Uri.fromParts("content:", "blah", "blah");
+
+ return new RawContact.NamedDataItem(uri, values);
+ }
+
+ public void testNamedDataItemEquals() {
+ assertEquals(buildNamedDataItem(), buildNamedDataItem());
+ }
+
+ public void testNamedDataItemNotEquals() {
+ assertFalse(buildNamedDataItem().equals(buildNamedDataItem2()));
+ }
+
+ public void testNamedDataItemParcelable() {
+ assertParcelableEquals(buildNamedDataItem());
+ }
+
+ private void assertParcelableEquals(Parcelable parcelable) {
+ final Parcel parcel = Parcel.obtain();
+ try {
+ parcel.writeParcelable(parcelable, 0);
+ parcel.setDataPosition(0);
+
+ Parcelable out = parcel.readParcelable(parcelable.getClass().getClassLoader());
+ assertEquals(parcelable, out);
+ } finally {
+ parcel.recycle();
+ }
+ }
+}
diff --git a/tests/src/com/android/contacts/common/model/ValuesDeltaTests.java b/tests/src/com/android/contacts/common/model/ValuesDeltaTests.java
new file mode 100644
index 0000000..77bf456
--- /dev/null
+++ b/tests/src/com/android/contacts/common/model/ValuesDeltaTests.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model;
+
+import android.content.ContentProviderOperation.Builder;
+import android.content.ContentValues;
+import android.os.Build;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.Data;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import com.android.contacts.common.compat.CompatUtils;
+import com.android.contacts.common.model.BuilderWrapper;
+
+import junit.framework.TestCase;
+
+/**
+ * Tests for {@link ValuesDelta}. These tests
+ * focus on passing changes across {@link android.os.Parcel}, and verifying that they
+ * correctly build expected "diff" operations.
+ */
+@SmallTest
+public class ValuesDeltaTests extends TestCase {
+
+ public static final long TEST_PHONE_ID = 24;
+
+ public static final String TEST_PHONE_NUMBER_1 = "218-555-1111";
+ public static final String TEST_PHONE_NUMBER_2 = "218-555-2222";
+
+ public void testValuesDiffInsert() {
+ final ContentValues after = new ContentValues();
+ after.put(Phone.NUMBER, TEST_PHONE_NUMBER_2);
+
+ final ValuesDelta values = ValuesDelta.fromAfter(after);
+
+ // Should produce an insert action
+ final BuilderWrapper builderWrapper = values.buildDiffWrapper(Data.CONTENT_URI);
+ final boolean isInsert = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+ ? builderWrapper.getBuilder().build().isInsert()
+ : builderWrapper.getType() == CompatUtils.TYPE_INSERT;
+ assertTrue("Didn't produce insert action", isInsert);
+ }
+
+ /**
+ * Test that {@link ValuesDelta#buildDiff(android.net.Uri)} is correctly
+ * built for insert, update, and delete cases. Note this only tests behavior
+ * for individual {@link Data} rows.
+ */
+ public void testValuesDiffNone() {
+ final ContentValues before = new ContentValues();
+ before.put(Data._ID, TEST_PHONE_ID);
+ before.put(Phone.NUMBER, TEST_PHONE_NUMBER_1);
+
+ final ValuesDelta values = ValuesDelta.fromBefore(before);
+
+ // None action shouldn't produce a builder
+ final Builder builder = values.buildDiff(Data.CONTENT_URI);
+ assertNull("None action produced a builder", builder);
+ }
+
+ public void testValuesDiffUpdate() {
+ final ContentValues before = new ContentValues();
+ before.put(Data._ID, TEST_PHONE_ID);
+ before.put(Phone.NUMBER, TEST_PHONE_NUMBER_1);
+
+ final ValuesDelta values = ValuesDelta.fromBefore(before);
+ values.put(Phone.NUMBER, TEST_PHONE_NUMBER_2);
+
+ // Should produce an update action
+ final BuilderWrapper builderWrapper = values.buildDiffWrapper(Data.CONTENT_URI);
+ final boolean isUpdate = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
+ ? builderWrapper.getBuilder().build().isUpdate()
+ : builderWrapper.getType() == CompatUtils.TYPE_UPDATE;
+ assertTrue("Didn't produce update action", isUpdate);
+ }
+}
diff --git a/tests/src/com/android/contacts/common/model/account/AccountTypeTest.java b/tests/src/com/android/contacts/common/model/account/AccountTypeTest.java
new file mode 100644
index 0000000..e204722
--- /dev/null
+++ b/tests/src/com/android/contacts/common/model/account/AccountTypeTest.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.account;
+
+import android.content.Context;
+import android.test.InstrumentationTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import com.android.contacts.common.tests.R;
+
+/**
+ * Test case for {@link AccountType}.
+ *
+ * adb shell am instrument -w -e class com.android.contacts.model.AccountTypeTest \
+ com.android.contacts.tests/android.test.InstrumentationTestRunner
+ */
+@SmallTest
+public class AccountTypeTest extends InstrumentationTestCase {
+ public void testGetResourceText() {
+ // In this test we use the test package itself as an external package.
+ final String packageName = getInstrumentation().getContext().getPackageName();
+
+ final Context c = getInstrumentation().getTargetContext();
+ final String DEFAULT = "ABC";
+
+ // Package name null, resId -1, use the default
+ assertEquals(DEFAULT, AccountType.getResourceText(c, null, -1, DEFAULT));
+
+ // Resource ID -1, use the default
+ assertEquals(DEFAULT, AccountType.getResourceText(c, packageName, -1, DEFAULT));
+
+ // Load from an external package. (here, we use this test package itself)
+ final int externalResID = R.string.test_string;
+ assertEquals(getInstrumentation().getContext().getString(externalResID),
+ AccountType.getResourceText(c, packageName, externalResID, DEFAULT));
+
+ // Load from the contacts package itself.
+ final int internalResId = com.android.contacts.common.R.string.contactsList;
+ assertEquals(c.getString(internalResId),
+ AccountType.getResourceText(c, null, internalResId, DEFAULT));
+ }
+
+ /**
+ * Verify if {@link AccountType#getInviteContactActionLabel} correctly gets the resource ID
+ * from {@link AccountType#getInviteContactActionResId}
+ */
+ public void testGetInviteContactActionLabel() {
+ final String packageName = getInstrumentation().getContext().getPackageName();
+ final Context c = getInstrumentation().getTargetContext();
+
+ final int externalResID = R.string.test_string;
+
+ AccountType accountType = new AccountType() {
+ {
+ resourcePackageName = packageName;
+ syncAdapterPackageName = packageName;
+ }
+ @Override protected int getInviteContactActionResId() {
+ return externalResID;
+ }
+
+ @Override public boolean isGroupMembershipEditable() {
+ return false;
+ }
+
+ @Override public boolean areContactsWritable() {
+ return false;
+ }
+ };
+
+ assertEquals(getInstrumentation().getContext().getString(externalResID),
+ accountType.getInviteContactActionLabel(c));
+ }
+
+ public void testDisplayLabelComparator() {
+ final AccountTypeForDisplayLabelTest EMPTY = new AccountTypeForDisplayLabelTest("");
+ final AccountTypeForDisplayLabelTest NULL = new AccountTypeForDisplayLabelTest(null);
+ final AccountTypeForDisplayLabelTest AA = new AccountTypeForDisplayLabelTest("aa");
+ final AccountTypeForDisplayLabelTest BBB = new AccountTypeForDisplayLabelTest("bbb");
+ final AccountTypeForDisplayLabelTest C = new AccountTypeForDisplayLabelTest("c");
+
+ assertTrue(compareDisplayLabel(AA, BBB) < 0);
+ assertTrue(compareDisplayLabel(BBB, C) < 0);
+ assertTrue(compareDisplayLabel(AA, C) < 0);
+ assertTrue(compareDisplayLabel(AA, AA) == 0);
+ assertTrue(compareDisplayLabel(BBB, AA) > 0);
+
+ assertTrue(compareDisplayLabel(EMPTY, AA) < 0);
+ assertTrue(compareDisplayLabel(EMPTY, NULL) == 0);
+ }
+
+ private int compareDisplayLabel(AccountType lhs, AccountType rhs) {
+ return new AccountType.DisplayLabelComparator(
+ getInstrumentation().getTargetContext()).compare(lhs, rhs);
+ }
+
+ private class AccountTypeForDisplayLabelTest extends AccountType {
+ private final String mDisplayLabel;
+
+ public AccountTypeForDisplayLabelTest(String displayLabel) {
+ mDisplayLabel = displayLabel;
+ }
+
+ @Override
+ public CharSequence getDisplayLabel(Context context) {
+ return mDisplayLabel;
+ }
+
+ @Override
+ public boolean isGroupMembershipEditable() {
+ return false;
+ }
+
+ @Override
+ public boolean areContactsWritable() {
+ return false;
+ }
+ }
+}
diff --git a/tests/src/com/android/contacts/common/model/account/ExternalAccountTypeTest.java b/tests/src/com/android/contacts/common/model/account/ExternalAccountTypeTest.java
new file mode 100644
index 0000000..50a5110
--- /dev/null
+++ b/tests/src/com/android/contacts/common/model/account/ExternalAccountTypeTest.java
@@ -0,0 +1,238 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.model.account;
+
+import android.content.Context;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Event;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Note;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.CommonDataKinds.Relation;
+import android.provider.ContactsContract.CommonDataKinds.SipAddress;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.provider.ContactsContract.CommonDataKinds.Website;
+import android.test.InstrumentationTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.test.suitebuilder.annotation.Suppress;
+import android.util.Log;
+
+import com.android.contacts.common.model.dataitem.DataKind;
+import com.android.contacts.common.tests.R;
+import com.google.common.base.Objects;
+
+import java.util.List;
+
+/**
+ * Test case for {@link com.android.contacts.common.model.account.ExternalAccountType}.
+ *
+ * adb shell am instrument -w -e class com.android.contacts.model.ExternalAccountTypeTest \
+ com.android.contacts.tests/android.test.InstrumentationTestRunner
+ */
+@SmallTest
+public class ExternalAccountTypeTest extends InstrumentationTestCase {
+
+ @Suppress
+ public void testResolveExternalResId() {
+ final Context c = getInstrumentation().getTargetContext();
+ // In this test we use the test package itself as an external package.
+ final String packageName = getInstrumentation().getContext().getPackageName();
+
+ // Resource name empty.
+ assertEquals(-1, ExternalAccountType.resolveExternalResId(c, null, packageName, ""));
+ assertEquals(-1, ExternalAccountType.resolveExternalResId(c, "", packageName, ""));
+
+ // Name doesn't begin with '@'
+ assertEquals(-1, ExternalAccountType.resolveExternalResId(c, "x", packageName, ""));
+
+ // Invalid resource name
+ assertEquals(-1, ExternalAccountType.resolveExternalResId(c, "@", packageName, ""));
+ assertEquals(-1, ExternalAccountType.resolveExternalResId(c, "@a", packageName, ""));
+ assertEquals(-1, ExternalAccountType.resolveExternalResId(c, "@a/b", packageName, ""));
+
+ // Valid resource name
+ assertEquals(R.string.test_string, ExternalAccountType.resolveExternalResId(c,
+ "@string/test_string", packageName, ""));
+ }
+
+ /**
+ * Initialize with an invalid package name and see if type will be initialized, but empty.
+ */
+ public void testNoPackage() {
+ final ExternalAccountType type = new ExternalAccountType(getInstrumentation().getTargetContext(),
+ "!!!no such package name!!!", false);
+ assertTrue(type.isInitialized());
+ }
+
+ /**
+ * Initialize with the test package itself and see if EditSchema is correctly parsed.
+ */
+ @Suppress
+ public void testEditSchema() {
+ final ExternalAccountType type = new ExternalAccountType(getInstrumentation().getTargetContext(),
+ getInstrumentation().getContext().getPackageName(), false);
+
+ assertTrue(type.isInitialized());
+
+ // Let's just check if the DataKinds are registered.
+ assertNotNull(type.getKindForMimetype(StructuredName.CONTENT_ITEM_TYPE));
+ assertNotNull(type.getKindForMimetype(DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME));
+ assertNotNull(type.getKindForMimetype(DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME));
+ assertNotNull(type.getKindForMimetype(Email.CONTENT_ITEM_TYPE));
+ assertNotNull(type.getKindForMimetype(StructuredPostal.CONTENT_ITEM_TYPE));
+ assertNotNull(type.getKindForMimetype(Im.CONTENT_ITEM_TYPE));
+ assertNotNull(type.getKindForMimetype(Organization.CONTENT_ITEM_TYPE));
+ assertNotNull(type.getKindForMimetype(Photo.CONTENT_ITEM_TYPE));
+ assertNotNull(type.getKindForMimetype(Note.CONTENT_ITEM_TYPE));
+ assertNotNull(type.getKindForMimetype(Website.CONTENT_ITEM_TYPE));
+ assertNotNull(type.getKindForMimetype(SipAddress.CONTENT_ITEM_TYPE));
+ assertNotNull(type.getKindForMimetype(Event.CONTENT_ITEM_TYPE));
+ assertNotNull(type.getKindForMimetype(Relation.CONTENT_ITEM_TYPE));
+ }
+
+ /**
+ * Initialize with "contacts_fallback.xml" and compare the DataKinds to those of
+ * {@link com.android.contacts.common.model.account.FallbackAccountType}.
+ */
+ public void testEditSchema_fallback() {
+ final ExternalAccountType type = new ExternalAccountType(getInstrumentation().getTargetContext(),
+ getInstrumentation().getContext().getPackageName(), false,
+ getInstrumentation().getContext().getResources().getXml(R.xml.contacts_fallback)
+ );
+
+ assertTrue(type.isInitialized());
+
+ // Create a fallback type with the same resource package name, and compare all the data
+ // kinds to its.
+ final AccountType reference = FallbackAccountType.createWithPackageNameForTest(
+ getInstrumentation().getTargetContext(), type.resourcePackageName);
+
+ assertsDataKindEquals(reference.getSortedDataKinds(), type.getSortedDataKinds());
+ }
+
+ public void testEditSchema_mustHaveChecks() {
+ checkEditSchema_mustHaveChecks(R.xml.missing_contacts_base, true);
+ checkEditSchema_mustHaveChecks(R.xml.missing_contacts_photo, false);
+ checkEditSchema_mustHaveChecks(R.xml.missing_contacts_name, false);
+ checkEditSchema_mustHaveChecks(R.xml.missing_contacts_name_attr1, false);
+ checkEditSchema_mustHaveChecks(R.xml.missing_contacts_name_attr2, false);
+ checkEditSchema_mustHaveChecks(R.xml.missing_contacts_name_attr3, false);
+ checkEditSchema_mustHaveChecks(R.xml.missing_contacts_name_attr4, false);
+ checkEditSchema_mustHaveChecks(R.xml.missing_contacts_name_attr5, false);
+ checkEditSchema_mustHaveChecks(R.xml.missing_contacts_name_attr6, false);
+ checkEditSchema_mustHaveChecks(R.xml.missing_contacts_name_attr7, false);
+ }
+
+ private void checkEditSchema_mustHaveChecks(int xmlResId, boolean expectInitialized) {
+ final ExternalAccountType type = new ExternalAccountType(getInstrumentation().getTargetContext(),
+ getInstrumentation().getContext().getPackageName(), false,
+ getInstrumentation().getContext().getResources().getXml(xmlResId)
+ );
+
+ assertEquals(expectInitialized, type.isInitialized());
+ }
+
+ /**
+ * Initialize with "contacts_readonly.xml" and see if all data kinds are correctly registered.
+ */
+ public void testReadOnlyDefinition() {
+ final ExternalAccountType type = new ExternalAccountType(getInstrumentation().getTargetContext(),
+ getInstrumentation().getContext().getPackageName(), false,
+ getInstrumentation().getContext().getResources().getXml(R.xml.contacts_readonly)
+ );
+ assertTrue(type.isInitialized());
+
+ // Shouldn't have a "null" mimetype.
+ assertTrue(type.getKindForMimetype(null) == null);
+
+ // 3 kinds are defined in XML and 4 are added by default.
+ assertEquals(4 + 3, type.getSortedDataKinds().size());
+
+ // Check for the default kinds.
+ assertNotNull(type.getKindForMimetype(StructuredName.CONTENT_ITEM_TYPE));
+ assertNotNull(type.getKindForMimetype(DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME));
+ assertNotNull(type.getKindForMimetype(DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME));
+ assertNotNull(type.getKindForMimetype(Photo.CONTENT_ITEM_TYPE));
+
+ // Check for type specific kinds.
+ DataKind kind = type.getKindForMimetype("vnd.android.cursor.item/a.b.c");
+ assertNotNull(kind);
+ // No check for icon -- we actually just ignore it.
+ assertEquals("data1", ((BaseAccountType.SimpleInflater) kind.actionHeader)
+ .getColumnNameForTest());
+ assertEquals("data2", ((BaseAccountType.SimpleInflater) kind.actionBody)
+ .getColumnNameForTest());
+
+ kind = type.getKindForMimetype("vnd.android.cursor.item/d.e.f");
+ assertNotNull(kind);
+ assertEquals("data3", ((BaseAccountType.SimpleInflater) kind.actionHeader)
+ .getColumnNameForTest());
+ assertEquals("data4", ((BaseAccountType.SimpleInflater) kind.actionBody)
+ .getColumnNameForTest());
+
+ kind = type.getKindForMimetype("vnd.android.cursor.item/xyz");
+ assertNotNull(kind);
+ assertEquals("data5", ((BaseAccountType.SimpleInflater) kind.actionHeader)
+ .getColumnNameForTest());
+ assertEquals("data6", ((BaseAccountType.SimpleInflater) kind.actionBody)
+ .getColumnNameForTest());
+ }
+
+ private static void assertsDataKindEquals(List<DataKind> expectedKinds,
+ List<DataKind> actualKinds) {
+ final int count = Math.max(actualKinds.size(), expectedKinds.size());
+ for (int i = 0; i < count; i++) {
+ String actual = actualKinds.size() > i ? actualKinds.get(i).toString() : "(n/a)";
+ String expected = expectedKinds.size() > i ? expectedKinds.get(i).toString() : "(n/a)";
+
+ // Because assertEquals()'s output is not very friendly when comparing two similar
+ // strings, we manually do the check.
+ if (!Objects.equal(actual, expected)) {
+ final int commonPrefixEnd = findCommonPrefixEnd(actual, expected);
+ fail("Kind #" + i
+ + "\n[Actual]\n" + insertMarkerAt(actual, commonPrefixEnd)
+ + "\n[Expected]\n" + insertMarkerAt(expected, commonPrefixEnd));
+ }
+ }
+ }
+
+ private static int findCommonPrefixEnd(String s1, String s2) {
+ int i = 0;
+ for (;;) {
+ final boolean s1End = (s1.length() <= i);
+ final boolean s2End = (s2.length() <= i);
+ if (s1End || s2End) {
+ return i;
+ }
+ if (s1.charAt(i) != s2.charAt(i)) {
+ return i;
+ }
+ i++;
+ }
+ }
+
+ private static String insertMarkerAt(String s, int position) {
+ final String MARKER = "***";
+ if (position > s.length()) {
+ return s + MARKER;
+ } else {
+ return new StringBuilder(s).insert(position, MARKER).toString();
+ }
+ }
+}
diff --git a/tests/src/com/android/contacts/common/model/dataitem/DataItemTests.java b/tests/src/com/android/contacts/common/model/dataitem/DataItemTests.java
new file mode 100644
index 0000000..ec1a8da
--- /dev/null
+++ b/tests/src/com/android/contacts/common/model/dataitem/DataItemTests.java
@@ -0,0 +1,458 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+package com.android.contacts.common.model.dataitem;
+
+import android.content.ContentValues;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Event;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+import android.provider.ContactsContract.CommonDataKinds.Note;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Relation;
+import android.provider.ContactsContract.CommonDataKinds.SipAddress;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.provider.ContactsContract.CommonDataKinds.Website;
+import android.provider.ContactsContract.Contacts.Data;
+import android.provider.ContactsContract.Contacts.Entity;
+import android.test.AndroidTestCase;
+
+import com.android.contacts.common.Collapser;
+import com.android.contacts.common.model.account.AccountType.EditType;
+import com.android.contacts.common.model.account.BaseAccountType;
+import com.android.contacts.common.model.account.GoogleAccountType;
+import com.android.contacts.common.model.dataitem.DataItem;
+import com.android.contacts.common.model.dataitem.DataKind;
+
+import java.lang.Math;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Test case for {@link DataItem}.
+ */
+public class DataItemTests extends AndroidTestCase {
+
+ private ContentValues mValues1;
+ private ContentValues mValues2;
+ private ContentValues mValues3;
+ private ContentValues mValues4;
+ private GoogleAccountType mGoogleAccountType;
+
+ @Override
+ protected void setUp() {
+ mValues1 = new ContentValues();
+ mValues2 = new ContentValues();
+ mValues3 = new ContentValues();
+ mValues4 = new ContentValues();
+
+ mValues1.put(Data._ID, 1);
+ mValues2.put(Data._ID, 2);
+ mValues3.put(Data._ID, 3);
+ mValues4.put(Data._ID, 4);
+
+ mGoogleAccountType = new GoogleAccountType(getContext(), "packageName");
+ }
+
+ private List<DataItem> createDataItemsAndCollapse(DataKind kind, ContentValues... values) {
+ final List<DataItem> dataList = new ArrayList<>(values.length);
+ for (ContentValues value : values) {
+ final DataItem data = DataItem.createFrom(value);
+ data.setDataKind(kind);
+ dataList.add(data);
+ }
+ Collapser.collapseList(dataList, getContext());
+ return dataList;
+ }
+
+ public void testDataItemCollapsing_genericDataItemFields() {
+ mValues1.put(Data.IS_SUPER_PRIMARY, 1);
+ mValues2.put(Data.IS_PRIMARY, 0);
+
+ mValues1.put(Entity.TIMES_USED, 5);
+ mValues2.put(Entity.TIMES_USED, 4);
+
+ mValues1.put(Entity.LAST_TIME_USED, 555);
+ mValues2.put(Entity.LAST_TIME_USED, 999);
+
+ final DataKind kind = new DataKind("test.mimetype", 0, 0, false);
+ kind.actionBody = new BaseAccountType.SimpleInflater(0);
+ kind.typeList = new ArrayList<>();
+ kind.typeList.add(new EditType(1, -1));
+ kind.typeList.add(new EditType(2, -1));
+ kind.typeColumn = Data.DATA2;
+
+ mValues1.put(kind.typeColumn, 2);
+ mValues2.put(kind.typeColumn, 1);
+
+ final List<DataItem> dataList = createDataItemsAndCollapse(kind, mValues1, mValues2);
+
+ assertEquals(1, dataList.size());
+ assertEquals(true, dataList.get(0).isSuperPrimary());
+ assertEquals(true, dataList.get(0).isPrimary());
+ assertEquals(9, (int) dataList.get(0).getTimesUsed());
+ assertEquals(999L, (long) dataList.get(0).getLastTimeUsed());
+ assertEquals(1, dataList.get(0).getKindTypeColumn(kind));
+ }
+
+ public void testDataItemCollapsing_email() {
+ final String email1 = "email1@google.com";
+ final String email2 = "email2@google.com";
+
+ mValues1.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+
+ mValues1.put(Email.ADDRESS, email1);
+ mValues2.put(Email.ADDRESS, email1);
+ mValues3.put(Email.ADDRESS, email2);
+
+ mValues1.put(Email.TYPE, Email.TYPE_MOBILE);
+ mValues2.put(Email.TYPE, Email.TYPE_HOME);
+ mValues3.put(Email.TYPE, Email.TYPE_WORK);
+
+ final DataKind kind = mGoogleAccountType.getKindForMimetype(Email.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3);
+
+ assertEquals(2, dataList.size());
+ assertEquals(email1, ((EmailDataItem) dataList.get(0)).getAddress());
+ assertEquals(email2, ((EmailDataItem) dataList.get(1)).getAddress());
+ assertEquals(Math.min(Email.TYPE_MOBILE, Email.TYPE_HOME),
+ ((EmailDataItem) dataList.get(0)).getKindTypeColumn(kind));
+ }
+
+ public void testDataItemCollapsing_event() {
+ final String date1 = "2014-01-01";
+ final String date2 = "2014-02-02";
+ final String customLabel1 = "custom label1";
+ final String customLabel2 = "custom label2";
+
+ mValues1.put(Data.MIMETYPE, Event.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, Event.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, Event.CONTENT_ITEM_TYPE);
+ mValues4.put(Data.MIMETYPE, Event.CONTENT_ITEM_TYPE);
+
+ mValues1.put(Event.START_DATE, date1);
+ mValues2.put(Event.START_DATE, date1);
+ mValues3.put(Event.START_DATE, date1);
+ mValues4.put(Event.START_DATE, date2);
+
+ mValues1.put(Event.TYPE, Event.TYPE_CUSTOM);
+ mValues2.put(Event.TYPE, Event.TYPE_CUSTOM);
+ mValues3.put(Event.TYPE, Event.TYPE_CUSTOM);
+ mValues4.put(Event.TYPE, Event.TYPE_ANNIVERSARY);
+
+ mValues1.put(Event.LABEL, customLabel1);
+ mValues2.put(Event.LABEL, customLabel1);
+ mValues3.put(Event.LABEL, customLabel2);
+
+ final DataKind kind = mGoogleAccountType.getKindForMimetype(Event.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3, mValues4);
+
+ assertEquals(3, dataList.size());
+ assertEquals(customLabel1, ((EventDataItem) dataList.get(0)).getLabel());
+ assertEquals(customLabel2, ((EventDataItem) dataList.get(1)).getLabel());
+ assertEquals(date2, ((EventDataItem) dataList.get(2)).getStartDate());
+ }
+
+ public void testDataItemCollapsing_im() {
+ final String address1 = "address 1";
+ final String address2 = "address 2";
+ final String customProtocol1 = "custom 1";
+ final String customProtocol2 = "custom 2";
+
+ mValues1.put(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+ mValues4.put(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+
+ mValues1.put(Im.DATA, address1);
+ mValues2.put(Im.DATA, address1);
+ mValues3.put(Im.DATA, address1);
+ mValues4.put(Im.DATA, address2);
+
+ mValues1.put(Im.PROTOCOL, Im.PROTOCOL_CUSTOM);
+ mValues2.put(Im.PROTOCOL, Im.PROTOCOL_CUSTOM);
+ mValues3.put(Im.PROTOCOL, Im.PROTOCOL_CUSTOM);
+ mValues4.put(Im.PROTOCOL, Im.PROTOCOL_AIM);
+
+ mValues1.put(Im.CUSTOM_PROTOCOL, customProtocol1);
+ mValues2.put(Im.CUSTOM_PROTOCOL, customProtocol1);
+ mValues3.put(Im.CUSTOM_PROTOCOL, customProtocol2);
+
+ final DataKind kind = mGoogleAccountType.getKindForMimetype(Im.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3, mValues4);
+
+ assertEquals(3, dataList.size());
+ assertEquals(address1, ((ImDataItem) dataList.get(0)).getData());
+ assertEquals(address1, ((ImDataItem) dataList.get(1)).getData());
+ assertEquals(address2, ((ImDataItem) dataList.get(2)).getData());
+
+ assertEquals(customProtocol1, ((ImDataItem) dataList.get(0)).getCustomProtocol());
+ assertEquals(customProtocol2, ((ImDataItem) dataList.get(1)).getCustomProtocol());
+ assertEquals(Im.PROTOCOL_AIM, (int) ((ImDataItem) dataList.get(2)).getProtocol());
+ }
+
+ public void testDataItemCollapsing_nickname() {
+ final String nickname1 = "nickname 1";
+ final String nickname2 = "nickname 2";
+
+ mValues1.put(Data.MIMETYPE, Nickname.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, Nickname.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, Nickname.CONTENT_ITEM_TYPE);
+
+ mValues1.put(Nickname.NAME, nickname1);
+ mValues2.put(Nickname.NAME, nickname1);
+ mValues3.put(Nickname.NAME, nickname2);
+
+ final DataKind kind = mGoogleAccountType.getKindForMimetype(Nickname.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3);
+
+ assertEquals(2, dataList.size());
+ assertEquals(nickname1, ((NicknameDataItem) dataList.get(0)).getName());
+ assertEquals(nickname2, ((NicknameDataItem) dataList.get(1)).getName());
+ }
+
+ public void testDataItemCollapsing_note() {
+ final String note1 = "note 1";
+ final String note2 = "note 2";
+
+ mValues1.put(Data.MIMETYPE, Note.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, Note.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, Note.CONTENT_ITEM_TYPE);
+
+ mValues1.put(Note.NOTE, note1);
+ mValues2.put(Note.NOTE, note1);
+ mValues3.put(Note.NOTE, note2);
+
+ DataKind kind = mGoogleAccountType.getKindForMimetype(Note.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3);
+
+ assertEquals(2, dataList.size());
+ assertEquals(note1, ((NoteDataItem) dataList.get(0)).getNote());
+ assertEquals(note2, ((NoteDataItem) dataList.get(1)).getNote());
+ }
+
+ public void testDataItemCollapsing_organization() {
+ final String company1 = "company1";
+ final String company2 = "company2";
+ final String title1 = "title1";
+ final String title2 = "title2";
+
+ mValues1.put(Data.MIMETYPE, Organization.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, Organization.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, Organization.CONTENT_ITEM_TYPE);
+ mValues4.put(Data.MIMETYPE, Organization.CONTENT_ITEM_TYPE);
+
+ mValues1.put(Organization.COMPANY, company1);
+ mValues2.put(Organization.COMPANY, company1);
+ mValues3.put(Organization.COMPANY, company1);
+ mValues4.put(Organization.COMPANY, company2);
+
+ mValues1.put(Organization.TITLE, title1);
+ mValues2.put(Organization.TITLE, title1);
+ mValues3.put(Organization.TITLE, title2);
+ mValues4.put(Organization.TITLE, title1);
+
+ final DataKind kind =
+ mGoogleAccountType.getKindForMimetype(Organization.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3, mValues4);
+
+ assertEquals(3, dataList.size());
+ assertEquals(company1, ((OrganizationDataItem) dataList.get(0)).getCompany());
+ assertEquals(company1, ((OrganizationDataItem) dataList.get(1)).getCompany());
+ assertEquals(company2, ((OrganizationDataItem) dataList.get(2)).getCompany());
+
+ assertEquals(title1, ((OrganizationDataItem) dataList.get(0)).getTitle());
+ assertEquals(title2, ((OrganizationDataItem) dataList.get(1)).getTitle());
+ }
+
+ public void testDataItemCollapsing_phone() {
+ final String phone1 = "111-111-1111";
+ final String phone1a = "1111111111";
+ final String phone2 = "222-222-2222";
+
+ mValues1.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+
+ mValues1.put(Phone.NUMBER, phone1);
+ mValues2.put(Phone.NUMBER, phone1a);
+ mValues3.put(Phone.NUMBER, phone2);
+
+ mValues1.put(Phone.TYPE, Phone.TYPE_MOBILE);
+ mValues2.put(Phone.TYPE, Phone.TYPE_HOME);
+ mValues3.put(Phone.TYPE, Phone.TYPE_WORK);
+
+ final DataKind kind = mGoogleAccountType.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3);
+ assertEquals(2, dataList.size());
+ assertEquals(phone1, ((PhoneDataItem) dataList.get(0)).getNumber());
+ assertEquals(phone2, ((PhoneDataItem) dataList.get(1)).getNumber());
+ assertEquals(Phone.TYPE_MOBILE,
+ ((PhoneDataItem) dataList.get(0)).getKindTypeColumn(kind));
+ }
+
+ public void testDataItemCollapsing_relation() {
+ final String name1 = "name1";
+ final String name2 = "name2";
+ final String customRelation1 = "custom relation 1";
+ final String customRelation2 = "custom relation 2";
+
+ mValues1.put(Data.MIMETYPE, Relation.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, Relation.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, Relation.CONTENT_ITEM_TYPE);
+ mValues4.put(Data.MIMETYPE, Relation.CONTENT_ITEM_TYPE);
+
+ mValues1.put(Relation.NAME, name1);
+ mValues2.put(Relation.NAME, name1);
+ mValues3.put(Relation.NAME, name1);
+ mValues4.put(Relation.NAME, name2);
+
+ mValues1.put(Relation.TYPE, Relation.TYPE_CUSTOM);
+ mValues2.put(Relation.TYPE, Relation.TYPE_CUSTOM);
+ mValues3.put(Relation.TYPE, Relation.TYPE_CUSTOM);
+ mValues4.put(Relation.TYPE, Relation.TYPE_BROTHER);
+
+ mValues1.put(Relation.LABEL, customRelation1);
+ mValues2.put(Relation.LABEL, customRelation1);
+ mValues3.put(Relation.LABEL, customRelation2);
+
+ final DataKind kind = mGoogleAccountType.getKindForMimetype(Relation.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3, mValues4);
+
+ assertEquals(3, dataList.size());
+ assertEquals(name1, ((RelationDataItem) dataList.get(0)).getName());
+ assertEquals(name2, ((RelationDataItem) dataList.get(2)).getName());
+
+ assertEquals(customRelation1, ((RelationDataItem) dataList.get(0)).getLabel());
+ assertEquals(customRelation2, ((RelationDataItem) dataList.get(1)).getLabel());
+ }
+
+ public void testDataItemCollapsing_sip() {
+ final String sip1 = "sip 1";
+ final String sip2 = "sip 2";
+
+ mValues1.put(Data.MIMETYPE, SipAddress.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, SipAddress.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, SipAddress.CONTENT_ITEM_TYPE);
+
+ mValues1.put(SipAddress.SIP_ADDRESS, sip1);
+ mValues2.put(SipAddress.SIP_ADDRESS, sip1);
+ mValues3.put(SipAddress.SIP_ADDRESS, sip2);
+
+ mValues1.put(SipAddress.TYPE, SipAddress.TYPE_WORK);
+ mValues2.put(SipAddress.TYPE, SipAddress.TYPE_HOME);
+ mValues3.put(SipAddress.TYPE, SipAddress.TYPE_WORK);
+
+ final DataKind kind = mGoogleAccountType.getKindForMimetype(SipAddress.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3);
+
+ assertEquals(2, dataList.size());
+ assertEquals(sip1, ((SipAddressDataItem) dataList.get(0)).getSipAddress());
+ assertEquals(sip2, ((SipAddressDataItem) dataList.get(1)).getSipAddress());
+ }
+
+ public void testDataItemCollapsing_structuredName() {
+ final String displayName1 = "Display Name 1";
+ final String displayName2 = "Display Name 2";
+
+ mValues1.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
+
+ mValues1.put(StructuredName.DISPLAY_NAME, displayName1);
+ mValues2.put(StructuredName.DISPLAY_NAME, displayName1);
+ mValues3.put(StructuredName.DISPLAY_NAME, displayName2);
+
+ final DataKind kind =
+ mGoogleAccountType.getKindForMimetype(StructuredName.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3);
+
+ assertEquals(2, dataList.size());
+ assertEquals(displayName1, ((StructuredNameDataItem) dataList.get(0)).getDisplayName());
+ assertEquals(displayName2, ((StructuredNameDataItem) dataList.get(1)).getDisplayName());
+ }
+
+ public void testDataItemCollapsing_structuredPostal() {
+ final String formattedAddress1 = "Formatted Address 1";
+ final String formattedAddress2 = "Formatted Address 2";
+
+ mValues1.put(Data.MIMETYPE, StructuredPostal.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, StructuredPostal.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, StructuredPostal.CONTENT_ITEM_TYPE);
+
+ mValues1.put(StructuredPostal.FORMATTED_ADDRESS, formattedAddress1);
+ mValues2.put(StructuredPostal.FORMATTED_ADDRESS, formattedAddress1);
+ mValues3.put(StructuredPostal.FORMATTED_ADDRESS, formattedAddress2);
+
+ final DataKind kind =
+ mGoogleAccountType.getKindForMimetype(StructuredPostal.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3);
+
+ assertEquals(2, dataList.size());
+ assertEquals(formattedAddress1,
+ ((StructuredPostalDataItem) dataList.get(0)).getFormattedAddress());
+ assertEquals(formattedAddress2,
+ ((StructuredPostalDataItem) dataList.get(1)).getFormattedAddress());
+ }
+
+ public void testDataItemCollapsing_website() {
+ final String url1 = "www.url1.com";
+ final String url2 = "www.url2.com";
+
+ mValues1.put(Data.MIMETYPE, Website.CONTENT_ITEM_TYPE);
+ mValues2.put(Data.MIMETYPE, Website.CONTENT_ITEM_TYPE);
+ mValues3.put(Data.MIMETYPE, Website.CONTENT_ITEM_TYPE);
+
+ mValues1.put(Website.URL, url1);
+ mValues2.put(Website.URL, url1);
+ mValues3.put(Website.URL, url2);
+
+ final DataKind kind = mGoogleAccountType.getKindForMimetype(Website.CONTENT_ITEM_TYPE);
+
+ final List<DataItem> dataList =
+ createDataItemsAndCollapse(kind, mValues1, mValues2, mValues3);
+
+ assertEquals(2, dataList.size());
+ assertEquals(url1, ((WebsiteDataItem) dataList.get(0)).getUrl());
+ assertEquals(url2, ((WebsiteDataItem) dataList.get(1)).getUrl());
+ }
+}
diff --git a/tests/src/com/android/contacts/common/preference/ContactsPreferencesTest.java b/tests/src/com/android/contacts/common/preference/ContactsPreferencesTest.java
new file mode 100644
index 0000000..26e811d
--- /dev/null
+++ b/tests/src/com/android/contacts/common/preference/ContactsPreferencesTest.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.preference;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.res.Resources;
+import android.test.AndroidTestCase;
+import android.test.InstrumentationTestCase;
+import android.test.suitebuilder.annotation.MediumTest;
+
+import com.android.contacts.common.model.account.AccountWithDataSet;
+
+import junit.framework.Assert;
+
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+@MediumTest
+public class ContactsPreferencesTest extends InstrumentationTestCase {
+
+ private static final String ACCOUNT_KEY = "ACCOUNT_KEY";
+
+ @Mock private Context mContext;
+ @Mock private Resources mResources;
+ @Mock private SharedPreferences mSharedPreferences;
+
+ private ContactsPreferences mContactsPreferences;
+
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ System.setProperty("dexmaker.dexcache",
+ getInstrumentation().getTargetContext().getCacheDir().getPath());
+ MockitoAnnotations.initMocks(this);
+
+ Mockito.when(mContext.getResources()).thenReturn(mResources);
+ Mockito.when(mResources.getString(Mockito.anyInt()))
+ .thenReturn(ACCOUNT_KEY); // contact_editor_default_account_key
+
+ Mockito.when(mContext.getSharedPreferences(Mockito.anyString(), Mockito.anyInt()))
+ .thenReturn(mSharedPreferences);
+ Mockito.when(mSharedPreferences.contains(ContactsPreferences.SORT_ORDER_KEY))
+ .thenReturn(true);
+ Mockito.when(mSharedPreferences.contains(ContactsPreferences.DISPLAY_ORDER_KEY))
+ .thenReturn(true);
+
+ mContactsPreferences = new ContactsPreferences(mContext);
+ }
+
+ public void testGetSortOrderDefault() {
+ Mockito.when(mResources.getBoolean(Mockito.anyInt())).thenReturn(
+ false, // R.bool.config_sort_order_user_changeable
+ true // R.bool.config_default_sort_order_primary
+ );
+ Assert.assertEquals(ContactsPreferences.SORT_ORDER_PRIMARY,
+ mContactsPreferences.getSortOrder());
+ }
+
+ public void testGetSortOrder() {
+ Mockito.when(mResources.getBoolean(Mockito.anyInt())).thenReturn(
+ true // R.bool.config_sort_order_user_changeable
+ );
+ Mockito.when(mSharedPreferences.getInt(Mockito.eq(ContactsPreferences.SORT_ORDER_KEY),
+ Mockito.anyInt())).thenReturn(ContactsPreferences.SORT_ORDER_PRIMARY);
+ Assert.assertEquals(ContactsPreferences.SORT_ORDER_PRIMARY,
+ mContactsPreferences.getSortOrder());
+ }
+
+ public void testGetDisplayOrderDefault() {
+ Mockito.when(mResources.getBoolean(Mockito.anyInt())).thenReturn(
+ false, // R.bool.config_display_order_user_changeable
+ true // R.bool.config_default_display_order_primary
+ );
+ Assert.assertEquals(ContactsPreferences.DISPLAY_ORDER_PRIMARY,
+ mContactsPreferences.getDisplayOrder());
+ }
+
+ public void testGetDisplayOrder() {
+ Mockito.when(mResources.getBoolean(Mockito.anyInt())).thenReturn(
+ true // R.bool.config_display_order_user_changeable
+ );
+ Mockito.when(mSharedPreferences.getInt(Mockito.eq(ContactsPreferences.DISPLAY_ORDER_KEY),
+ Mockito.anyInt())).thenReturn(ContactsPreferences.DISPLAY_ORDER_PRIMARY);
+ Assert.assertEquals(ContactsPreferences.DISPLAY_ORDER_PRIMARY,
+ mContactsPreferences.getDisplayOrder());
+ }
+
+ public void testRefreshSortOrder() throws InterruptedException {
+ Mockito.when(mResources.getBoolean(Mockito.anyInt())).thenReturn(
+ true // R.bool.config_sort_order_user_changeable
+ );
+ Mockito.when(mSharedPreferences.getInt(Mockito.eq(ContactsPreferences.SORT_ORDER_KEY),
+ Mockito.anyInt())).thenReturn(ContactsPreferences.SORT_ORDER_PRIMARY,
+ ContactsPreferences.SORT_ORDER_ALTERNATIVE);
+
+ Assert.assertEquals(ContactsPreferences.SORT_ORDER_PRIMARY,
+ mContactsPreferences.getSortOrder());
+ mContactsPreferences.refreshValue(ContactsPreferences.SORT_ORDER_KEY);
+
+ Assert.assertEquals(ContactsPreferences.SORT_ORDER_ALTERNATIVE,
+ mContactsPreferences.getSortOrder());
+ }
+
+ public void testRefreshDisplayOrder() throws InterruptedException {
+ Mockito.when(mResources.getBoolean(Mockito.anyInt())).thenReturn(
+ true // R.bool.config_display_order_user_changeable
+ );
+ Mockito.when(mSharedPreferences.getInt(Mockito.eq(ContactsPreferences.DISPLAY_ORDER_KEY),
+ Mockito.anyInt())).thenReturn(ContactsPreferences.DISPLAY_ORDER_PRIMARY,
+ ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE);
+
+ Assert.assertEquals(ContactsPreferences.DISPLAY_ORDER_PRIMARY,
+ mContactsPreferences.getDisplayOrder());
+ mContactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY);
+
+ Assert.assertEquals(ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE,
+ mContactsPreferences.getDisplayOrder());
+ }
+
+ public void testRefreshDefaultAccount() throws InterruptedException {
+ Mockito.when(mResources.getBoolean(Mockito.anyInt())).thenReturn(
+ true // R.bool.config_default_account_user_changeable
+ );
+
+ Mockito.when(mSharedPreferences.getString(Mockito.eq(ACCOUNT_KEY), Mockito.anyString()))
+ .thenReturn(new AccountWithDataSet("name1", "type1", "dataset1").stringify(),
+ new AccountWithDataSet("name2", "type2", "dataset2").stringify());
+
+ Assert.assertEquals("name1", mContactsPreferences.getDefaultAccount());
+ mContactsPreferences.refreshValue(ACCOUNT_KEY);
+
+ Assert.assertEquals("name2", mContactsPreferences.getDefaultAccount());
+ }
+}
diff --git a/tests/src/com/android/contacts/common/test/FragmentTestActivity.java b/tests/src/com/android/contacts/common/test/FragmentTestActivity.java
new file mode 100644
index 0000000..5ae2d95
--- /dev/null
+++ b/tests/src/com/android/contacts/common/test/FragmentTestActivity.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.test;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+
+/**
+ * An activity that is used for testing fragments. A unit test starts this
+ * activity, adds a fragment and then tests the fragment.
+ */
+public class FragmentTestActivity extends Activity {
+
+ public final static int LAYOUT_ID = 1;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Normally fragment/activity onStart() methods will not be called when screen is locked.
+ // Use the following flags to ensure that activities can be show for testing.
+ final Window window = getWindow();
+ window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON |
+ WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
+
+ final FrameLayout layout = new FrameLayout(this);
+ layout.setId(LAYOUT_ID);
+ setContentView(layout);
+ }
+}
diff --git a/tests/src/com/android/contacts/common/test/IntegrationTestUtils.java b/tests/src/com/android/contacts/common/test/IntegrationTestUtils.java
new file mode 100644
index 0000000..5457128
--- /dev/null
+++ b/tests/src/com/android/contacts/common/test/IntegrationTestUtils.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.test;
+
+import static android.os.PowerManager.ACQUIRE_CAUSES_WAKEUP;
+import static android.os.PowerManager.FULL_WAKE_LOCK;
+import static android.os.PowerManager.ON_AFTER_RELEASE;
+
+import android.app.Activity;
+import android.app.Instrumentation;
+import android.content.Context;
+import android.os.PowerManager;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.google.common.base.Preconditions;
+
+import junit.framework.Assert;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.ThreadSafe;
+
+/** Some utility methods for making integration testing smoother. */
+@ThreadSafe
+public class IntegrationTestUtils {
+ private static final String TAG = "IntegrationTestUtils";
+
+ private final Instrumentation mInstrumentation;
+ private final Object mLock = new Object();
+ @GuardedBy("mLock") private PowerManager.WakeLock mWakeLock;
+
+ public IntegrationTestUtils(Instrumentation instrumentation) {
+ mInstrumentation = instrumentation;
+ }
+
+ /**
+ * Find a view by a given resource id, from the given activity, and click it, iff it is
+ * enabled according to {@link View#isEnabled()}.
+ */
+ public void clickButton(final Activity activity, final int buttonResourceId) throws Throwable {
+ runOnUiThreadAndGetTheResult(new Callable<Void>() {
+ @Override
+ public Void call() throws Exception {
+ View view = activity.findViewById(buttonResourceId);
+ Assert.assertNotNull(view);
+ if (view.isEnabled()) {
+ view.performClick();
+ }
+ return null;
+ }
+ });
+ }
+
+ /** Returns the result of running {@link TextView#getText()} on the ui thread. */
+ public CharSequence getText(final TextView view) throws Throwable {
+ return runOnUiThreadAndGetTheResult(new Callable<CharSequence>() {
+ @Override
+ public CharSequence call() {
+ return view.getText();
+ }
+ });
+ }
+
+ // TODO: Move this class and the appropriate documentation into a test library, having checked
+ // first to see if exactly this code already exists or not.
+ /**
+ * Execute a callable on the ui thread, returning its result synchronously.
+ * <p>
+ * Waits for an idle sync on the main thread (see {@link Instrumentation#waitForIdle(Runnable)})
+ * before executing this callable.
+ */
+ public <T> T runOnUiThreadAndGetTheResult(Callable<T> callable) throws Throwable {
+ FutureTask<T> future = new FutureTask<T>(callable);
+ mInstrumentation.waitForIdle(future);
+ try {
+ return future.get();
+ } catch (ExecutionException e) {
+ // Unwrap the cause of the exception and re-throw it.
+ throw e.getCause();
+ }
+ }
+
+ /**
+ * Wake up the screen, useful in tests that want or need the screen to be on.
+ * <p>
+ * This is usually called from setUp() for tests that require it. After calling this method,
+ * {@link #releaseScreenWakeLock()} must be called, this is usually done from tearDown().
+ */
+ public void acquireScreenWakeLock(Context context) {
+ synchronized (mLock) {
+ Preconditions.checkState(mWakeLock == null, "mWakeLock was already held");
+ mWakeLock = ((PowerManager) context.getSystemService(Context.POWER_SERVICE))
+ .newWakeLock(
+ PowerManager.ACQUIRE_CAUSES_WAKEUP | PowerManager.ON_AFTER_RELEASE | PowerManager.FULL_WAKE_LOCK, TAG);
+ mWakeLock.acquire();
+ }
+ }
+
+ /** Release the wake lock previously acquired with {@link #acquireScreenWakeLock(Context)}. */
+ public void releaseScreenWakeLock() {
+ synchronized (mLock) {
+ // We don't use Preconditions to force you to have acquired before release.
+ // This is because we don't want unnecessary exceptions in tearDown() since they'll
+ // typically mask the actual exception that happened during the test.
+ // The other reason is that this method is most likely to be called from tearDown(),
+ // which is invoked within a finally block, so it's not infrequently the case that
+ // the setUp() method fails before getting the lock, at which point we don't want
+ // to fail in tearDown().
+ if (mWakeLock != null) {
+ mWakeLock.release();
+ mWakeLock = null;
+ }
+ }
+ }
+
+ /**
+ * Gets all {@link TextView} objects whose {@link TextView#getText()} contains the given text as
+ * a substring.
+ */
+ public List<TextView> getTextViewsWithString(final Activity activity, final String text)
+ throws Throwable {
+ return getTextViewsWithString(getRootView(activity), text);
+ }
+
+ /**
+ * Gets all {@link TextView} objects whose {@link TextView#getText()} contains the given text as
+ * a substring for the given root view.
+ */
+ public List<TextView> getTextViewsWithString(final View rootView, final String text)
+ throws Throwable {
+ return runOnUiThreadAndGetTheResult(new Callable<List<TextView>>() {
+ @Override
+ public List<TextView> call() throws Exception {
+ List<TextView> matchingViews = new ArrayList<TextView>();
+ for (TextView textView : getAllViews(TextView.class, rootView)) {
+ if (textView.getText().toString().contains(text)) {
+ matchingViews.add(textView);
+ }
+ }
+ return matchingViews;
+ }
+ });
+ }
+
+ /** Find the root view for a given activity. */
+ public static View getRootView(Activity activity) {
+ return activity.findViewById(android.R.id.content).getRootView();
+ }
+
+ /**
+ * Gets a list of all views of a given type, rooted at the given parent.
+ * <p>
+ * This method will recurse down through all {@link ViewGroup} instances looking for
+ * {@link View} instances of the supplied class type. Specifically it will use the
+ * {@link Class#isAssignableFrom(Class)} method as the test for which views to add to the list,
+ * so if you provide {@code View.class} as your type, you will get every view. The parent itself
+ * will be included also, should it be of the right type.
+ * <p>
+ * This call manipulates the ui, and as such should only be called from the application's main
+ * thread.
+ */
+ private static <T extends View> List<T> getAllViews(final Class<T> clazz, final View parent) {
+ List<T> results = new ArrayList<T>();
+ if (parent.getClass().equals(clazz)) {
+ results.add(clazz.cast(parent));
+ }
+ if (parent instanceof ViewGroup) {
+ ViewGroup viewGroup = (ViewGroup) parent;
+ for (int i = 0; i < viewGroup.getChildCount(); ++i) {
+ results.addAll(getAllViews(clazz, viewGroup.getChildAt(i)));
+ }
+ }
+ return results;
+ }
+}
diff --git a/tests/src/com/android/contacts/common/test/LaunchPerformanceBase.java b/tests/src/com/android/contacts/common/test/LaunchPerformanceBase.java
new file mode 100644
index 0000000..a2ebde3
--- /dev/null
+++ b/tests/src/com/android/contacts/common/test/LaunchPerformanceBase.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2007 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.test;
+
+import android.app.Instrumentation;
+import android.content.Intent;
+import android.os.Bundle;
+
+
+/**
+ * Base class for all launch performance Instrumentation classes.
+ */
+public class LaunchPerformanceBase extends Instrumentation {
+
+ public static final String LOG_TAG = "Launch Performance";
+
+ protected Bundle mResults;
+ protected Intent mIntent;
+
+ public LaunchPerformanceBase() {
+ mResults = new Bundle();
+ mIntent = new Intent(Intent.ACTION_MAIN);
+ mIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ setAutomaticPerformanceSnapshots();
+ }
+
+ /**
+ * Launches intent, and waits for idle before returning.
+ *
+ * @hide
+ */
+ protected void LaunchApp() {
+ startActivitySync(mIntent);
+ waitForIdleSync();
+ }
+}
diff --git a/tests/src/com/android/contacts/common/test/mocks/ContactsMockContext.java b/tests/src/com/android/contacts/common/test/mocks/ContactsMockContext.java
new file mode 100644
index 0000000..c72fe3d
--- /dev/null
+++ b/tests/src/com/android/contacts/common/test/mocks/ContactsMockContext.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.test.mocks;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ProviderInfo;
+import android.provider.ContactsContract;
+import android.provider.Settings;
+import android.test.mock.MockContentResolver;
+
+/**
+ * A mock context for contacts unit tests. Forwards everything to
+ * a supplied context, except content resolver operations, which are sent
+ * to mock content providers.
+ */
+public class ContactsMockContext extends ContextWrapper {
+ private ContactsMockPackageManager mPackageManager;
+ private MockContentResolver mContentResolver;
+ private MockContentProvider mContactsProvider;
+ private MockContentProvider mSettingsProvider;
+ private Intent mIntentForStartActivity;
+
+ public ContactsMockContext(Context base) {
+ this(base, ContactsContract.AUTHORITY);
+ }
+
+ public ContactsMockContext(Context base, String authority) {
+ super(base);
+ mPackageManager = new ContactsMockPackageManager();
+ mContentResolver = new MockContentResolver();
+ mContactsProvider = new MockContentProvider();
+ mContentResolver.addProvider(authority, mContactsProvider);
+ mSettingsProvider = new MockContentProvider();
+ mContentResolver.addProvider(Settings.AUTHORITY, mSettingsProvider);
+ }
+
+ @Override
+ public ContentResolver getContentResolver() {
+ return mContentResolver;
+ }
+
+ public MockContentProvider getContactsProvider() {
+ return mContactsProvider;
+ }
+
+ public MockContentProvider getSettingsProvider() {
+ return mSettingsProvider;
+ }
+
+ @Override
+ public PackageManager getPackageManager() {
+ return mPackageManager;
+ }
+
+ @Override
+ public Context getApplicationContext() {
+ return this;
+ }
+
+ /**
+ * Instead of actually sending Intent, this method just remembers what Intent was supplied last.
+ * You can check the content via {@link #getIntentForStartActivity()} for verification.
+ */
+ @Override
+ public void startActivity(Intent intent) {
+ mIntentForStartActivity = intent;
+ }
+
+ public Intent getIntentForStartActivity() {
+ return mIntentForStartActivity;
+ }
+
+ public void verify() {
+ mContactsProvider.verify();
+ mSettingsProvider.verify();
+ }
+
+}
diff --git a/tests/src/com/android/contacts/common/test/mocks/ContactsMockPackageManager.java b/tests/src/com/android/contacts/common/test/mocks/ContactsMockPackageManager.java
new file mode 100644
index 0000000..a1557ff
--- /dev/null
+++ b/tests/src/com/android/contacts/common/test/mocks/ContactsMockPackageManager.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.test.mocks;
+
+import android.content.ComponentName;
+import android.content.pm.ApplicationInfo;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.test.mock.MockPackageManager;
+
+/**
+ */
+public class ContactsMockPackageManager extends MockPackageManager {
+ public ContactsMockPackageManager() {
+ }
+
+ @Override
+ public Drawable getActivityLogo(ComponentName activityName) throws NameNotFoundException {
+ return new ColorDrawable();
+ }
+
+ @Override
+ public Drawable getActivityIcon(ComponentName activityName) {
+ return new ColorDrawable();
+ }
+
+ @Override
+ public Drawable getDrawable(String packageName, int resid, ApplicationInfo appInfo) {
+ // TODO: make programmable
+ return new ColorDrawable();
+ }
+}
diff --git a/tests/src/com/android/contacts/common/test/mocks/MockAccountTypeManager.java b/tests/src/com/android/contacts/common/test/mocks/MockAccountTypeManager.java
new file mode 100644
index 0000000..b46c49d
--- /dev/null
+++ b/tests/src/com/android/contacts/common/test/mocks/MockAccountTypeManager.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.common.test.mocks;
+
+import com.android.contacts.common.model.AccountTypeManager;
+import com.android.contacts.common.model.account.AccountType;
+import com.android.contacts.common.model.account.AccountTypeWithDataSet;
+import com.android.contacts.common.model.account.AccountWithDataSet;
+import com.android.contacts.common.model.account.BaseAccountType;
+import com.google.common.base.Objects;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A mock {@link AccountTypeManager} class.
+ */
+public class MockAccountTypeManager extends AccountTypeManager {
+
+ public AccountType[] mTypes;
+ public AccountWithDataSet[] mAccounts;
+
+ public MockAccountTypeManager(AccountType[] types, AccountWithDataSet[] accounts) {
+ this.mTypes = types;
+ this.mAccounts = accounts;
+ }
+
+ @Override
+ public AccountType getAccountType(AccountTypeWithDataSet accountTypeWithDataSet) {
+ // Add fallback accountType to mimic the behavior of AccountTypeManagerImpl
+ AccountType mFallbackAccountType = new BaseAccountType() {
+ @Override
+ public boolean areContactsWritable() {
+ return false;
+ }
+ };
+ mFallbackAccountType.accountType = "fallback";
+ for (AccountType type : mTypes) {
+ if (Objects.equal(accountTypeWithDataSet.accountType, type.accountType)
+ && Objects.equal(accountTypeWithDataSet.dataSet, type.dataSet)) {
+ return type;
+ }
+ }
+ return mFallbackAccountType;
+ }
+
+ @Override
+ public List<AccountWithDataSet> getAccounts(boolean writableOnly) {
+ return Arrays.asList(mAccounts);
+ }
+
+ @Override
+ public void sortAccounts(AccountWithDataSet account) {}
+
+ @Override
+ public List<AccountWithDataSet> getGroupWritableAccounts() {
+ return Arrays.asList(mAccounts);
+ }
+
+ @Override
+ public Map<AccountTypeWithDataSet, AccountType> getUsableInvitableAccountTypes() {
+ return Maps.newHashMap(); // Always returns empty
+ }
+
+ @Override
+ public List<AccountType> getAccountTypes(boolean writableOnly) {
+ final List<AccountType> ret = Lists.newArrayList();
+ synchronized (this) {
+ for (AccountType type : mTypes) {
+ if (!writableOnly || type.areContactsWritable()) {
+ ret.add(type);
+ }
+ }
+ }
+ return ret;
+ }
+}
diff --git a/tests/src/com/android/contacts/common/test/mocks/MockContactPhotoManager.java b/tests/src/com/android/contacts/common/test/mocks/MockContactPhotoManager.java
new file mode 100644
index 0000000..db8f06f
--- /dev/null
+++ b/tests/src/com/android/contacts/common/test/mocks/MockContactPhotoManager.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.test.mocks;
+
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.view.View;
+import android.widget.ImageView;
+
+import com.android.contacts.common.ContactPhotoManager;
+
+/**
+ * A photo preloader that always uses the "no contact" picture and never executes any real
+ * db queries
+ */
+public class MockContactPhotoManager extends ContactPhotoManager {
+ @Override
+ public void loadThumbnail(ImageView view, long photoId, boolean darkTheme, boolean isCircular,
+ DefaultImageRequest defaultImageRequest, DefaultImageProvider defaultProvider) {
+ defaultProvider.applyDefaultImage(view, -1, darkTheme, null);
+ }
+
+ @Override
+ public void loadPhoto(ImageView view, Uri photoUri, int requestedExtent, boolean darkTheme,
+ boolean isCircular, DefaultImageRequest defaultImageRequest,
+ DefaultImageProvider defaultProvider) {
+ defaultProvider.applyDefaultImage(view, requestedExtent, darkTheme, null);
+ }
+
+ @Override
+ public void removePhoto(ImageView view) {
+ view.setImageDrawable(null);
+ }
+
+ @Override
+ public void cancelPendingRequests(View fragmentRootView) {
+ }
+
+ @Override
+ public void pause() {
+ }
+
+ @Override
+ public void resume() {
+ }
+
+ @Override
+ public void refreshCache() {
+ }
+
+ @Override
+ public void cacheBitmap(Uri photoUri, Bitmap bitmap, byte[] photoBytes) {
+ }
+
+ @Override
+ public void preloadPhotosInBackground() {
+ }
+}
diff --git a/tests/src/com/android/contacts/common/test/mocks/MockContentProvider.java b/tests/src/com/android/contacts/common/test/mocks/MockContentProvider.java
new file mode 100644
index 0000000..335e8d2
--- /dev/null
+++ b/tests/src/com/android/contacts/common/test/mocks/MockContentProvider.java
@@ -0,0 +1,659 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.test.mocks;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Maps;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.support.annotation.Nullable;
+
+import junit.framework.Assert;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * A programmable mock content provider.
+ */
+public class MockContentProvider extends android.test.mock.MockContentProvider {
+ private static final String TAG = "MockContentProvider";
+
+ public static class Query {
+
+ private final Uri mUri;
+ private String[] mProjection;
+ private String[] mDefaultProjection;
+ private String mSelection;
+ private String[] mSelectionArgs;
+ private String mSortOrder;
+ private List<Object> mRows = new ArrayList<>();
+ private boolean mAnyProjection;
+ private boolean mAnySelection;
+ private boolean mAnySortOrder;
+ private boolean mAnyNumberOfTimes;
+
+ private boolean mExecuted;
+
+ public Query(Uri uri) {
+ mUri = uri;
+ }
+
+ @Override
+ public String toString() {
+ return queryToString(mUri, mProjection, mSelection, mSelectionArgs, mSortOrder);
+ }
+
+ public Query withProjection(String... projection) {
+ mProjection = projection;
+ return this;
+ }
+
+ public Query withDefaultProjection(String... projection) {
+ mDefaultProjection = projection;
+ return this;
+ }
+
+ public Query withAnyProjection() {
+ mAnyProjection = true;
+ return this;
+ }
+
+ public Query withSelection(String selection, String... selectionArgs) {
+ mSelection = selection;
+ mSelectionArgs = selectionArgs;
+ return this;
+ }
+
+ public Query withAnySelection() {
+ mAnySelection = true;
+ return this;
+ }
+
+ public Query withSortOrder(String sortOrder) {
+ mSortOrder = sortOrder;
+ return this;
+ }
+
+ public Query withAnySortOrder() {
+ mAnySortOrder = true;
+ return this;
+ }
+
+ public Query returnRow(ContentValues values) {
+ mRows.add(values);
+ return this;
+ }
+
+ public Query returnRow(Object... row) {
+ mRows.add(row);
+ return this;
+ }
+
+ public Query returnEmptyCursor() {
+ mRows.clear();
+ return this;
+ }
+
+ public Query anyNumberOfTimes() {
+ mAnyNumberOfTimes = true;
+ return this;
+ }
+
+ public boolean equals(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ if (!uri.equals(mUri)) {
+ return false;
+ }
+
+ if (!mAnyProjection && !Arrays.equals(projection, mProjection)) {
+ return false;
+ }
+
+ if (!mAnySelection && !Objects.equals(selection, mSelection)) {
+ return false;
+ }
+
+ if (!mAnySelection && !Arrays.equals(selectionArgs, mSelectionArgs)) {
+ return false;
+ }
+
+ if (!mAnySortOrder && !Objects.equals(sortOrder, mSortOrder)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public Cursor getResult(String[] projection) {
+ String[] columnNames;
+ if (mAnyProjection) {
+ columnNames = projection;
+ } else {
+ columnNames = mProjection != null ? mProjection : mDefaultProjection;
+ }
+
+ MatrixCursor cursor = new MatrixCursor(columnNames);
+ for (Object row : mRows) {
+ if (row instanceof Object[]) {
+ cursor.addRow((Object[]) row);
+ } else {
+ ContentValues values = (ContentValues) row;
+ Object[] columns = new Object[projection.length];
+ for (int i = 0; i < projection.length; i++) {
+ columns[i] = values.get(projection[i]);
+ }
+ cursor.addRow(columns);
+ }
+ }
+ return cursor;
+ }
+ }
+
+ public static class TypeQuery {
+ private final Uri mUri;
+ private final String mType;
+
+ public TypeQuery(Uri uri, String type) {
+ mUri = uri;
+ mType = type;
+ }
+
+ public Uri getUri() {
+ return mUri;
+ }
+
+ public String getType() {
+ return mType;
+ }
+
+ @Override
+ public String toString() {
+ return mUri + " --> " + mType;
+ }
+
+ public boolean equals(Uri uri) {
+ return getUri().equals(uri);
+ }
+ }
+
+ public static class Insert {
+ private final Uri mUri;
+ private final ContentValues mContentValues;
+ private final Uri mResultUri;
+ private boolean mAnyNumberOfTimes;
+ private boolean mIsExecuted;
+
+ /**
+ * Creates a new Insert to expect.
+ *
+ * @param uri the uri of the insertion request.
+ * @param contentValues the ContentValues to insert.
+ * @param resultUri the {@link Uri} for the newly inserted item.
+ * @throws NullPointerException if any parameter is {@code null}.
+ */
+ public Insert(Uri uri, ContentValues contentValues, Uri resultUri) {
+ mUri = Preconditions.checkNotNull(uri);
+ mContentValues = Preconditions.checkNotNull(contentValues);
+ mResultUri = Preconditions.checkNotNull(resultUri);
+ }
+
+ /**
+ * Causes this insert expectation to be useable for mutliple calls to insert, rather than
+ * just one.
+ *
+ * @return this
+ */
+ public Insert anyNumberOfTimes() {
+ mAnyNumberOfTimes = true;
+ return this;
+ }
+
+ private boolean equals(Uri uri, ContentValues contentValues) {
+ return mUri.equals(uri) && mContentValues.equals(contentValues);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Insert insert = (Insert) o;
+ return mAnyNumberOfTimes == insert.mAnyNumberOfTimes &&
+ mIsExecuted == insert.mIsExecuted &&
+ Objects.equals(mUri, insert.mUri) &&
+ Objects.equals(mContentValues, insert.mContentValues) &&
+ Objects.equals(mResultUri, insert.mResultUri);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mUri, mContentValues, mResultUri, mAnyNumberOfTimes, mIsExecuted);
+ }
+
+ @Override
+ public String toString() {
+ return "Insert{" +
+ "mUri=" + mUri +
+ ", mContentValues=" + mContentValues +
+ ", mResultUri=" + mResultUri +
+ ", mAnyNumberOfTimes=" + mAnyNumberOfTimes +
+ ", mIsExecuted=" + mIsExecuted +
+ '}';
+ }
+ }
+
+ public static class Delete {
+ private final Uri mUri;
+
+ private boolean mAnyNumberOfTimes;
+ private boolean mAnySelection;
+ @Nullable private String mSelection;
+ @Nullable private String[] mSelectionArgs;
+ private boolean mIsExecuted;
+ private int mRowsAffected;
+
+ /**
+ * Creates a new Delete to expect.
+ * @param uri the uri of the delete request.
+ * @throws NullPointerException if uri is {@code null}.
+ */
+ public Delete(Uri uri) {
+ mUri = Preconditions.checkNotNull(uri);
+ }
+
+ /**
+ * Sets the given information as expected selection arguments.
+ *
+ * @param selection The selection to expect.
+ * @param selectionArgs The selection args to expect.
+ * @return this.
+ */
+ public Delete withSelection(String selection, @Nullable String[] selectionArgs) {
+ mSelection = Preconditions.checkNotNull(selection);
+ mSelectionArgs = selectionArgs;
+ mAnySelection = false;
+ return this;
+ }
+
+ /**
+ * Sets this delete to expect any selection arguments.
+ *
+ * @return this.
+ */
+ public Delete withAnySelection() {
+ mAnySelection = true;
+ return this;
+ }
+
+ /**
+ * Sets this delete to return the given number of rows affected.
+ *
+ * @param rowsAffected The value to return when this expected delete is executed.
+ * @return this.
+ */
+ public Delete returnRowsAffected(int rowsAffected) {
+ mRowsAffected = rowsAffected;
+ return this;
+ }
+
+ /**
+ * Causes this delete expectation to be useable for multiple calls to delete, rather than
+ * just one.
+ *
+ * @return this.
+ */
+ public Delete anyNumberOfTimes() {
+ mAnyNumberOfTimes = true;
+ return this;
+ }
+
+ private boolean equals(Uri uri, String selection, String[] selectionArgs) {
+ return mUri.equals(uri) && Objects.equals(mSelection, selection)
+ && Arrays.equals(mSelectionArgs, selectionArgs);
+ }
+ }
+
+ public static class Update {
+ private final Uri mUri;
+ private final ContentValues mContentValues;
+ @Nullable private String mSelection;
+ @Nullable private String[] mSelectionArgs;
+ private boolean mAnyNumberOfTimes;
+ private boolean mIsExecuted;
+ private int mRowsAffected;
+
+ /**
+ * Creates a new Update to expect.
+ *
+ * @param uri the uri of the update request.
+ * @param contentValues the ContentValues to update.
+ *
+ * @throws NullPointerException if any parameter is {@code null}.
+ */
+ public Update(Uri uri,
+ ContentValues contentValues,
+ @Nullable String selection,
+ @Nullable String[] selectionArgs) {
+ mUri = Preconditions.checkNotNull(uri);
+ mContentValues = Preconditions.checkNotNull(contentValues);
+ mSelection = selection;
+ mSelectionArgs = selectionArgs;
+ }
+
+ /**
+ * Causes this update expectation to be useable for mutliple calls to update, rather than
+ * just one.
+ *
+ * @return this
+ */
+ public Update anyNumberOfTimes() {
+ mAnyNumberOfTimes = true;
+ return this;
+ }
+
+ /**
+ * Sets this update to return the given number of rows affected.
+ *
+ * @param rowsAffected The value to return when this expected update is executed.
+ * @return this.
+ */
+ public Update returnRowsAffected(int rowsAffected) {
+ mRowsAffected = rowsAffected;
+ return this;
+ }
+
+ private boolean equals(Uri uri,
+ ContentValues contentValues,
+ @Nullable String selection,
+ @Nullable String[] selectionArgs) {
+ return mUri.equals(uri) && mContentValues.equals(contentValues) &&
+ Objects.equals(mSelection, selection) &&
+ Objects.equals(mSelectionArgs, selectionArgs);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Update update = (Update) o;
+ return mAnyNumberOfTimes == update.mAnyNumberOfTimes &&
+ mIsExecuted == update.mIsExecuted &&
+ Objects.equals(mUri, update.mUri) &&
+ Objects.equals(mContentValues, update.mContentValues) &&
+ Objects.equals(mSelection, update.mSelection) &&
+ Objects.equals(mSelectionArgs, update.mSelectionArgs);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mUri, mContentValues, mAnyNumberOfTimes, mIsExecuted, mSelection,
+ mSelectionArgs);
+ }
+
+ @Override
+ public String toString() {
+ return "Update{" +
+ "mUri=" + mUri +
+ ", mContentValues=" + mContentValues +
+ ", mAnyNumberOfTimes=" + mAnyNumberOfTimes +
+ ", mIsExecuted=" + mIsExecuted +
+ ", mSelection=" + mSelection +
+ ", mSelectionArgs=" + mSelectionArgs +
+ '}';
+ }
+ }
+
+ private List<Query> mExpectedQueries = new ArrayList<>();
+ private Map<Uri, String> mExpectedTypeQueries = Maps.newHashMap();
+ private List<Insert> mExpectedInserts = new ArrayList<>();
+ private List<Delete> mExpectedDeletes = new ArrayList<>();
+ private List<Update> mExpectedUpdates = new ArrayList<>();
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ public Query expectQuery(Uri contentUri) {
+ Query query = new Query(contentUri);
+ mExpectedQueries.add(query);
+ return query;
+ }
+
+ public void expectTypeQuery(Uri uri, String type) {
+ mExpectedTypeQueries.put(uri, type);
+ }
+
+ public void expectInsert(Uri contentUri, ContentValues contentValues, Uri resultUri) {
+ mExpectedInserts.add(new Insert(contentUri, contentValues, resultUri));
+ }
+
+ public Update expectUpdate(Uri contentUri,
+ ContentValues contentValues,
+ @Nullable String selection,
+ @Nullable String[] selectionArgs) {
+ Update update = new Update(contentUri, contentValues, selection, selectionArgs);
+ mExpectedUpdates.add(update);
+ return update;
+ }
+
+ public Delete expectDelete(Uri contentUri) {
+ Delete delete = new Delete(contentUri);
+ mExpectedDeletes.add(delete);
+ return delete;
+ }
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+ String sortOrder) {
+ if (mExpectedQueries.isEmpty()) {
+ Assert.fail("Unexpected query: Actual:"
+ + queryToString(uri, projection, selection, selectionArgs, sortOrder));
+ }
+
+ for (Iterator<Query> iterator = mExpectedQueries.iterator(); iterator.hasNext();) {
+ Query query = iterator.next();
+ if (query.equals(uri, projection, selection, selectionArgs, sortOrder)) {
+ query.mExecuted = true;
+ if (!query.mAnyNumberOfTimes) {
+ iterator.remove();
+ }
+ return query.getResult(projection);
+ }
+ }
+
+ Assert.fail("Incorrect query. Expected one of: " + mExpectedQueries + ". Actual: " +
+ queryToString(uri, projection, selection, selectionArgs, sortOrder));
+ return null;
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ if (mExpectedTypeQueries.isEmpty()) {
+ Assert.fail("Unexpected getType query: " + uri);
+ }
+
+ String mimeType = mExpectedTypeQueries.get(uri);
+ if (mimeType != null) {
+ return mimeType;
+ }
+
+ Assert.fail("Unknown mime type for: " + uri);
+ return null;
+ }
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ if (mExpectedInserts.isEmpty()) {
+ Assert.fail("Unexpected insert. Actual: " + insertToString(uri, values));
+ }
+ for (Iterator<Insert> iterator = mExpectedInserts.iterator(); iterator.hasNext(); ) {
+ Insert insert = iterator.next();
+ if (insert.equals(uri, values)) {
+ insert.mIsExecuted = true;
+ if (!insert.mAnyNumberOfTimes) {
+ iterator.remove();
+ }
+ return insert.mResultUri;
+ }
+ }
+
+ Assert.fail("Incorrect insert. Expected one of: " + mExpectedInserts + ". Actual: "
+ + insertToString(uri, values));
+ return null;
+ }
+
+ private String insertToString(Uri uri, ContentValues contentValues) {
+ return "Insert { uri=" + uri + ", contentValues=" + contentValues + '}';
+ }
+
+ @Override
+ public int update(Uri uri,
+ ContentValues values,
+ @Nullable String selection,
+ @Nullable String[] selectionArgs) {
+ if (mExpectedUpdates.isEmpty()) {
+ Assert.fail("Unexpected update. Actual: "
+ + updateToString(uri, values, selection, selectionArgs));
+ }
+ for (Iterator<Update> iterator = mExpectedUpdates.iterator(); iterator.hasNext(); ) {
+ Update update = iterator.next();
+ if (update.equals(uri, values, selection, selectionArgs)) {
+ update.mIsExecuted = true;
+ if (!update.mAnyNumberOfTimes) {
+ iterator.remove();
+ }
+ return update.mRowsAffected;
+ }
+ }
+
+ Assert.fail("Incorrect update. Expected one of: " + mExpectedUpdates + ". Actual: "
+ + updateToString(uri, values, selection, selectionArgs));
+ return - 1;
+ }
+
+ private String updateToString(Uri uri,
+ ContentValues contentValues,
+ @Nullable String selection,
+ @Nullable String[] selectionArgs) {
+ return "Update { uri=" + uri + ", contentValues=" + contentValues + ", selection=" +
+ selection + ", selectionArgs" + Arrays.toString(selectionArgs) + '}';
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ if (mExpectedDeletes.isEmpty()) {
+ Assert.fail("Unexpected delete. Actual: " + deleteToString(uri, selection,
+ selectionArgs));
+ }
+ for (Iterator<Delete> iterator = mExpectedDeletes.iterator(); iterator.hasNext(); ) {
+ Delete delete = iterator.next();
+ if (delete.equals(uri, selection, selectionArgs)) {
+ delete.mIsExecuted = true;
+ if (!delete.mAnyNumberOfTimes) {
+ iterator.remove();
+ }
+ return delete.mRowsAffected;
+ }
+ }
+ Assert.fail("Incorrect delete. Expected one of: " + mExpectedDeletes + ". Actual: "
+ + deleteToString(uri, selection, selectionArgs));
+ return -1;
+ }
+
+ private String deleteToString(Uri uri, String selection, String[] selectionArgs) {
+ return "Delete { uri=" + uri + ", selection=" + selection + ", selectionArgs"
+ + Arrays.toString(selectionArgs) + '}';
+ }
+
+ private static String queryToString(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(uri).append(" ");
+ if (projection != null) {
+ sb.append(Arrays.toString(projection));
+ } else {
+ sb.append("[]");
+ }
+ if (selection != null) {
+ sb.append(" selection: '").append(selection).append("'");
+ if (selectionArgs != null) {
+ sb.append(Arrays.toString(selectionArgs));
+ } else {
+ sb.append("[]");
+ }
+ }
+ if (sortOrder != null) {
+ sb.append(" sort: '").append(sortOrder).append("'");
+ }
+ return sb.toString();
+ }
+
+ public void verify() {
+ verifyQueries();
+ verifyInserts();
+ verifyDeletes();
+ }
+
+ private void verifyQueries() {
+ List<Query> missedQueries = new ArrayList<>();
+ for (Query query : mExpectedQueries) {
+ if (!query.mExecuted) {
+ missedQueries.add(query);
+ }
+ }
+ Assert.assertTrue("Not all expected queries have been called: " + missedQueries,
+ missedQueries.isEmpty());
+ }
+
+ private void verifyInserts() {
+ List<Insert> missedInserts = new ArrayList<>();
+ for (Insert insert : mExpectedInserts) {
+ if (!insert.mIsExecuted) {
+ missedInserts.add(insert);
+ }
+ }
+ Assert.assertTrue("Not all expected inserts have been called: " + missedInserts,
+ missedInserts.isEmpty());
+ }
+
+ private void verifyDeletes() {
+ List<Delete> missedDeletes = new ArrayList<>();
+ for (Delete delete : mExpectedDeletes) {
+ if (!delete.mIsExecuted) {
+ missedDeletes.add(delete);
+ }
+ }
+ Assert.assertTrue("Not all expected deletes have been called: " + missedDeletes,
+ missedDeletes.isEmpty());
+ }
+}
diff --git a/tests/src/com/android/contacts/common/test/mocks/MockSharedPreferences.java b/tests/src/com/android/contacts/common/test/mocks/MockSharedPreferences.java
new file mode 100644
index 0000000..13d035e
--- /dev/null
+++ b/tests/src/com/android/contacts/common/test/mocks/MockSharedPreferences.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.test.mocks;
+
+import android.content.SharedPreferences;
+
+import com.google.common.collect.Maps;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+
+/**
+ * A programmable mock content provider.
+ */
+public class MockSharedPreferences implements SharedPreferences, SharedPreferences.Editor {
+
+ private HashMap<String, Object> mValues = Maps.newHashMap();
+ private HashMap<String, Object> mTempValues = Maps.newHashMap();
+
+ public Editor edit() {
+ return this;
+ }
+
+ public boolean contains(String key) {
+ return mValues.containsKey(key);
+ }
+
+ public Map<String, ?> getAll() {
+ return new HashMap<String, Object>(mValues);
+ }
+
+ public boolean getBoolean(String key, boolean defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Boolean)mValues.get(key)).booleanValue();
+ }
+ return defValue;
+ }
+
+ public float getFloat(String key, float defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Float)mValues.get(key)).floatValue();
+ }
+ return defValue;
+ }
+
+ public int getInt(String key, int defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Integer)mValues.get(key)).intValue();
+ }
+ return defValue;
+ }
+
+ public long getLong(String key, long defValue) {
+ if (mValues.containsKey(key)) {
+ return ((Long)mValues.get(key)).longValue();
+ }
+ return defValue;
+ }
+
+ public String getString(String key, String defValue) {
+ if (mValues.containsKey(key))
+ return (String)mValues.get(key);
+ return defValue;
+ }
+
+ @SuppressWarnings("unchecked")
+ public Set<String> getStringSet(String key, Set<String> defValues) {
+ if (mValues.containsKey(key)) {
+ return (Set<String>) mValues.get(key);
+ }
+ return defValues;
+ }
+
+ public void registerOnSharedPreferenceChangeListener(
+ OnSharedPreferenceChangeListener listener) {
+ throw new UnsupportedOperationException();
+ }
+
+ public void unregisterOnSharedPreferenceChangeListener(
+ OnSharedPreferenceChangeListener listener) {
+ throw new UnsupportedOperationException();
+ }
+
+ public Editor putBoolean(String key, boolean value) {
+ mTempValues.put(key, Boolean.valueOf(value));
+ return this;
+ }
+
+ public Editor putFloat(String key, float value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putInt(String key, int value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putLong(String key, long value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putString(String key, String value) {
+ mTempValues.put(key, value);
+ return this;
+ }
+
+ public Editor putStringSet(String key, Set<String> values) {
+ mTempValues.put(key, values);
+ return this;
+ }
+
+ public Editor remove(String key) {
+ mTempValues.remove(key);
+ return this;
+ }
+
+ public Editor clear() {
+ mTempValues.clear();
+ return this;
+ }
+
+ @SuppressWarnings("unchecked")
+ public boolean commit() {
+ mValues = (HashMap<String, Object>)mTempValues.clone();
+ return true;
+ }
+
+ public void apply() {
+ commit();
+ }
+}
diff --git a/tests/src/com/android/contacts/common/tests/testauth/TestAuthenticationService.java b/tests/src/com/android/contacts/common/tests/testauth/TestAuthenticationService.java
new file mode 100644
index 0000000..93d1f4a
--- /dev/null
+++ b/tests/src/com/android/contacts/common/tests/testauth/TestAuthenticationService.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.contacts.common.tests.testauth;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.IBinder;
+import android.util.Log;
+
+public abstract class TestAuthenticationService extends Service {
+
+ private TestAuthenticator mAuthenticator;
+
+ @Override
+ public void onCreate() {
+ Log.v(TestauthConstants.LOG_TAG, this + " Service started.");
+ mAuthenticator = new TestAuthenticator(this);
+ }
+
+ @Override
+ public void onDestroy() {
+ Log.v(TestauthConstants.LOG_TAG, this + " Service stopped.");
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ Log.v(TestauthConstants.LOG_TAG, this + " getBinder() intent=" + intent);
+ return mAuthenticator.getIBinder();
+ }
+
+ public static class Basic extends TestAuthenticationService {
+ }
+}
diff --git a/tests/src/com/android/contacts/common/tests/testauth/TestAuthenticator.java b/tests/src/com/android/contacts/common/tests/testauth/TestAuthenticator.java
new file mode 100644
index 0000000..2f676c7
--- /dev/null
+++ b/tests/src/com/android/contacts/common/tests/testauth/TestAuthenticator.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.contacts.common.tests.testauth;
+
+import android.accounts.AbstractAccountAuthenticator;
+import android.accounts.Account;
+import android.accounts.AccountAuthenticatorResponse;
+import android.accounts.AccountManager;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.util.Log;
+
+/**
+ * Simple authenticator. It has no "login" dialogs/activities. When you add a new account, it'll
+ * just create a new account with a unique name.
+ */
+class TestAuthenticator extends AbstractAccountAuthenticator {
+ private static final String PASSWORD = "xxx"; // any string will do.
+
+ // To remember the last user-ID.
+ private static final String PREF_KEY_LAST_USER_ID = "TestAuthenticator.PREF_KEY_LAST_USER_ID";
+
+ private final Context mContext;
+
+ public TestAuthenticator(Context context) {
+ super(context);
+ mContext = context.getApplicationContext();
+ }
+
+ /**
+ * @return a new, unique username.
+ */
+ private String newUniqueUserName() {
+ final SharedPreferences prefs =
+ PreferenceManager.getDefaultSharedPreferences(mContext);
+ final int nextId = prefs.getInt(PREF_KEY_LAST_USER_ID, 0) + 1;
+ prefs.edit().putInt(PREF_KEY_LAST_USER_ID, nextId).apply();
+
+ return "User-" + nextId;
+ }
+
+ /**
+ * Create a new account with the name generated by {@link #newUniqueUserName()}.
+ */
+ @Override
+ public Bundle addAccount(AccountAuthenticatorResponse response, String accountType,
+ String authTokenType, String[] requiredFeatures, Bundle options) {
+ Log.v(TestauthConstants.LOG_TAG, "addAccount() type=" + accountType);
+ final Bundle bundle = new Bundle();
+
+ final Account account = new Account(newUniqueUserName(), accountType);
+
+ // Create an account.
+ AccountManager.get(mContext).addAccountExplicitly(account, PASSWORD, null);
+
+ // And return it.
+ bundle.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
+ bundle.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type);
+ return bundle;
+ }
+
+ /**
+ * Just return the user name as the authtoken.
+ */
+ @Override
+ public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account,
+ String authTokenType, Bundle loginOptions) {
+ Log.v(TestauthConstants.LOG_TAG, "getAuthToken() account=" + account);
+ final Bundle bundle = new Bundle();
+ bundle.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
+ bundle.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type);
+ bundle.putString(AccountManager.KEY_AUTHTOKEN, account.name);
+
+ return bundle;
+ }
+
+ @Override
+ public Bundle confirmCredentials(
+ AccountAuthenticatorResponse response, Account account, Bundle options) {
+ Log.v(TestauthConstants.LOG_TAG, "confirmCredentials()");
+ return null;
+ }
+
+ @Override
+ public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
+ Log.v(TestauthConstants.LOG_TAG, "editProperties()");
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getAuthTokenLabel(String authTokenType) {
+ // null means we don't support multiple authToken types
+ Log.v(TestauthConstants.LOG_TAG, "getAuthTokenLabel()");
+ return null;
+ }
+
+ @Override
+ public Bundle hasFeatures(
+ AccountAuthenticatorResponse response, Account account, String[] features) {
+ // This call is used to query whether the Authenticator supports
+ // specific features. We don't expect to get called, so we always
+ // return false (no) for any queries.
+ Log.v(TestauthConstants.LOG_TAG, "hasFeatures()");
+ final Bundle result = new Bundle();
+ result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false);
+ return result;
+ }
+
+ @Override
+ public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account,
+ String authTokenType, Bundle loginOptions) {
+ Log.v(TestauthConstants.LOG_TAG, "updateCredentials()");
+ return null;
+ }
+}
diff --git a/tests/src/com/android/contacts/common/tests/testauth/TestSyncAdapter.java b/tests/src/com/android/contacts/common/tests/testauth/TestSyncAdapter.java
new file mode 100644
index 0000000..a7c0f83
--- /dev/null
+++ b/tests/src/com/android/contacts/common/tests/testauth/TestSyncAdapter.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.android.contacts.common.tests.testauth;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.content.AbstractThreadedSyncAdapter;
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.SyncResult;
+import android.os.Bundle;
+import android.provider.ContactsContract.RawContacts;
+import android.util.Log;
+
+/**
+ * Simple (minimal) sync adapter.
+ *
+ */
+public class TestSyncAdapter extends AbstractThreadedSyncAdapter {
+ private final AccountManager mAccountManager;
+
+ private final Context mContext;
+
+ public TestSyncAdapter(Context context, boolean autoInitialize) {
+ super(context, autoInitialize);
+ mContext = context.getApplicationContext();
+ mAccountManager = AccountManager.get(mContext);
+ }
+
+ /**
+ * Doesn't actually sync, but sweep up all existing local-only contacts.
+ */
+ @Override
+ public void onPerformSync(Account account, Bundle extras, String authority,
+ ContentProviderClient provider, SyncResult syncResult) {
+ Log.v(TestauthConstants.LOG_TAG, "TestSyncAdapter.onPerformSync() account=" + account);
+
+ // First, claim all local-only contacts, if any.
+ ContentResolver cr = mContext.getContentResolver();
+ ContentValues values = new ContentValues();
+ values.put(RawContacts.ACCOUNT_NAME, account.name);
+ values.put(RawContacts.ACCOUNT_TYPE, account.type);
+ final int count = cr.update(RawContacts.CONTENT_URI, values,
+ RawContacts.ACCOUNT_NAME + " IS NULL AND " + RawContacts.ACCOUNT_TYPE + " IS NULL",
+ null);
+ if (count > 0) {
+ Log.v(TestauthConstants.LOG_TAG, "Claimed " + count + " local raw contacts");
+ }
+
+ // TODO: Clear isDirty flag
+ // TODO: Remove isDeleted raw contacts
+ }
+}
diff --git a/tests/src/com/android/contacts/common/tests/testauth/TestSyncService.java b/tests/src/com/android/contacts/common/tests/testauth/TestSyncService.java
new file mode 100644
index 0000000..3354cb4
--- /dev/null
+++ b/tests/src/com/android/contacts/common/tests/testauth/TestSyncService.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.android.contacts.common.tests.testauth;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.IBinder;
+
+public abstract class TestSyncService extends Service {
+
+ private static TestSyncAdapter sSyncAdapter;
+
+ @Override
+ public void onCreate() {
+ if (sSyncAdapter == null) {
+ sSyncAdapter = new TestSyncAdapter(getApplicationContext(), true);
+ }
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return sSyncAdapter.getSyncAdapterBinder();
+ }
+
+ public static class Basic extends TestSyncService {
+ }
+}
diff --git a/tests/src/com/android/contacts/common/tests/testauth/TestauthConstants.java b/tests/src/com/android/contacts/common/tests/testauth/TestauthConstants.java
new file mode 100644
index 0000000..3ce7f5a
--- /dev/null
+++ b/tests/src/com/android/contacts/common/tests/testauth/TestauthConstants.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.contacts.common.tests.testauth;
+
+class TestauthConstants {
+ public static final String LOG_TAG = "Testauth";
+}
diff --git a/tests/src/com/android/contacts/common/util/BitmapUtilTests.java b/tests/src/com/android/contacts/common/util/BitmapUtilTests.java
new file mode 100644
index 0000000..94394b1
--- /dev/null
+++ b/tests/src/com/android/contacts/common/util/BitmapUtilTests.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.graphics.Bitmap;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import com.android.contacts.common.util.BitmapUtil;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+/**
+ * Tests for {@link com.android.contacts.common.util.BitmapUtil}.
+ */
+@SmallTest
+public class BitmapUtilTests extends AndroidTestCase {
+ public void testGetSmallerExtentFromBytes1() throws Exception {
+ assertEquals(100, BitmapUtil.getSmallerExtentFromBytes(createJpegRawData(100, 100)));
+ assertEquals(100, BitmapUtil.getSmallerExtentFromBytes(createPngRawData(100, 100)));
+ }
+
+ public void testGetSmallerExtentFromBytes2() throws Exception {
+ assertEquals(50, BitmapUtil.getSmallerExtentFromBytes(createJpegRawData(200, 50)));
+ assertEquals(50, BitmapUtil.getSmallerExtentFromBytes(createPngRawData(200, 50)));
+ }
+
+ public void testGetSmallerExtentFromBytes3() throws Exception {
+ assertEquals(40, BitmapUtil.getSmallerExtentFromBytes(createJpegRawData(40, 150)));
+ assertEquals(40, BitmapUtil.getSmallerExtentFromBytes(createPngRawData(40, 150)));
+ }
+
+ public void testFindOptimalSampleSizeExact() throws Exception {
+ assertEquals(1, BitmapUtil.findOptimalSampleSize(512, 512));
+ }
+
+ public void testFindOptimalSampleSizeBigger() throws Exception {
+ assertEquals(1, BitmapUtil.findOptimalSampleSize(512, 1024));
+ }
+
+ public void testFindOptimalSampleSizeSmaller1() throws Exception {
+ assertEquals(2, BitmapUtil.findOptimalSampleSize(512, 256));
+ }
+
+ public void testFindOptimalSampleSizeSmaller2() throws Exception {
+ assertEquals(2, BitmapUtil.findOptimalSampleSize(512, 230));
+ }
+
+ public void testFindOptimalSampleSizeSmaller3() throws Exception {
+ assertEquals(4, BitmapUtil.findOptimalSampleSize(512, 129));
+ }
+
+ public void testFindOptimalSampleSizeSmaller4() throws Exception {
+ assertEquals(4, BitmapUtil.findOptimalSampleSize(512, 128));
+ }
+
+ public void testFindOptimalSampleSizeUnknownOriginal() throws Exception {
+ assertEquals(1, BitmapUtil.findOptimalSampleSize(-1, 128));
+ }
+
+ public void testFindOptimalSampleSizeUnknownTarget() throws Exception {
+ assertEquals(1, BitmapUtil.findOptimalSampleSize(128, -1));
+ }
+
+ public void testDecodeWithSampleSize1() throws IOException {
+ assertBitmapSize(128, 64, BitmapUtil.decodeBitmapFromBytes(createJpegRawData(128, 64), 1));
+ assertBitmapSize(128, 64, BitmapUtil.decodeBitmapFromBytes(createPngRawData(128, 64), 1));
+ }
+
+ public void testDecodeWithSampleSize2() throws IOException {
+ assertBitmapSize(64, 32, BitmapUtil.decodeBitmapFromBytes(createJpegRawData(128, 64), 2));
+ assertBitmapSize(64, 32, BitmapUtil.decodeBitmapFromBytes(createPngRawData(128, 64), 2));
+ }
+
+ public void testDecodeWithSampleSize2a() throws IOException {
+ assertBitmapSize(25, 20, BitmapUtil.decodeBitmapFromBytes(createJpegRawData(50, 40), 2));
+ assertBitmapSize(25, 20, BitmapUtil.decodeBitmapFromBytes(createPngRawData(50, 40), 2));
+ }
+
+ public void testDecodeWithSampleSize4() throws IOException {
+ assertBitmapSize(32, 16, BitmapUtil.decodeBitmapFromBytes(createJpegRawData(128, 64), 4));
+ assertBitmapSize(32, 16, BitmapUtil.decodeBitmapFromBytes(createPngRawData(128, 64), 4));
+ }
+
+ private void assertBitmapSize(int expectedWidth, int expectedHeight, Bitmap bitmap) {
+ assertEquals(expectedWidth, bitmap.getWidth());
+ assertEquals(expectedHeight, bitmap.getHeight());
+ }
+
+ private byte[] createJpegRawData(int sourceWidth, int sourceHeight) throws IOException {
+ return createRawData(Bitmap.CompressFormat.JPEG, sourceWidth, sourceHeight);
+ }
+
+ private byte[] createPngRawData(int sourceWidth, int sourceHeight) throws IOException {
+ return createRawData(Bitmap.CompressFormat.PNG, sourceWidth, sourceHeight);
+ }
+
+ private byte[] createRawData(Bitmap.CompressFormat format, int sourceWidth,
+ int sourceHeight) throws IOException {
+ // Create a temp bitmap as our source
+ Bitmap b = Bitmap.createBitmap(sourceWidth, sourceHeight, Bitmap.Config.ARGB_8888);
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ b.compress(format, 50, outputStream);
+ final byte[] data = outputStream.toByteArray();
+ outputStream.close();
+ return data;
+ }
+}
diff --git a/tests/src/com/android/contacts/common/util/ContactDisplayUtilTests.java b/tests/src/com/android/contacts/common/util/ContactDisplayUtilTests.java
new file mode 100644
index 0000000..b4cd1ca
--- /dev/null
+++ b/tests/src/com/android/contacts/common/util/ContactDisplayUtilTests.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.util;
+
+import static android.provider.ContactsContract.CommonDataKinds.Phone;
+
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import com.android.contacts.common.R;
+import com.android.contacts.common.preference.ContactsPreferences;
+
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Unit tests for (@link ContactDisplayUtils}
+ */
+@SmallTest
+public class ContactDisplayUtilTests extends AndroidTestCase {
+
+ private static final String NAME_PRIMARY = "Name Primary";
+ private static final String NAME_ALTERNATIVE = "Name Alternative";
+
+ @Mock private ContactsPreferences mContactsPreferences;
+
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ MockitoAnnotations.initMocks(this);
+ }
+
+ public void testIsCustomPhoneTypeReturnsTrue() {
+ assertTrue(ContactDisplayUtils.isCustomPhoneType(Phone.TYPE_CUSTOM));
+ assertTrue(ContactDisplayUtils.isCustomPhoneType(Phone.TYPE_ASSISTANT));
+ }
+
+ public void testIsCustomPhoneTypeReturnsFalse() {
+ assertFalse(ContactDisplayUtils.isCustomPhoneType(Phone.TYPE_HOME));
+ assertFalse(ContactDisplayUtils.isCustomPhoneType(Phone.TYPE_FAX_WORK));
+ assertFalse(ContactDisplayUtils.isCustomPhoneType(Phone.TYPE_MOBILE));
+ assertFalse(ContactDisplayUtils.isCustomPhoneType(Phone.TYPE_OTHER));
+ }
+
+ public void testGetLabelForCallOrSmsReturnsCustomLabel() {
+ final CharSequence smsResult = ContactDisplayUtils.getLabelForCallOrSms(Phone.TYPE_CUSTOM,
+ "expected sms label", ContactDisplayUtils.INTERACTION_SMS, getContext());
+ assertEquals("expected sms label", smsResult);
+
+ final CharSequence callResult = ContactDisplayUtils.getLabelForCallOrSms(Phone.TYPE_CUSTOM,
+ "expected call label", ContactDisplayUtils.INTERACTION_CALL, getContext());
+ assertEquals("expected call label", callResult);
+ }
+
+ public void testGetLabelForCallOrSmsReturnsCallLabels() {
+ CharSequence result = ContactDisplayUtils.getLabelForCallOrSms(Phone.TYPE_HOME, "",
+ ContactDisplayUtils.INTERACTION_CALL, getContext());
+ CharSequence expected = getContext().getResources().getText(R.string.call_home);
+ assertEquals(expected, result);
+
+ result = ContactDisplayUtils.getLabelForCallOrSms(Phone.TYPE_MOBILE, "",
+ ContactDisplayUtils.INTERACTION_CALL, getContext());
+ expected = getContext().getResources().getText(R.string.call_mobile);
+ assertEquals(expected, result);
+ }
+
+ public void testGetLabelForCallOrSmsReturnsSmsLabels() {
+ CharSequence result = ContactDisplayUtils.getLabelForCallOrSms(Phone.TYPE_HOME, "",
+ ContactDisplayUtils.INTERACTION_SMS, getContext());
+ CharSequence expected = getContext().getResources().getText(R.string.sms_home);
+ assertEquals(expected, result);
+
+ result = ContactDisplayUtils.getLabelForCallOrSms(Phone.TYPE_MOBILE, "",
+ ContactDisplayUtils.INTERACTION_SMS, getContext());
+ expected = getContext().getResources().getText(R.string.sms_mobile);
+ assertEquals(expected, result);
+ }
+
+ public void testGetPhoneLabelResourceIdReturnsOther() {
+ assertEquals(R.string.call_other, ContactDisplayUtils.getPhoneLabelResourceId(null));
+ }
+
+ public void testGetPhoneLabelResourceIdReturnsMatchHome() {
+ assertEquals(R.string.call_home, ContactDisplayUtils.getPhoneLabelResourceId(
+ Phone.TYPE_HOME));
+ }
+
+ public void testGetSmsLabelResourceIdReturnsOther() {
+ assertEquals(R.string.sms_other, ContactDisplayUtils.getSmsLabelResourceId(null));
+ }
+
+ public void testGetSmsLabelResourceIdReturnsMatchHome() {
+ assertEquals(R.string.sms_home, ContactDisplayUtils.getSmsLabelResourceId(Phone.TYPE_HOME));
+ }
+
+ public void testGetPreferredDisplayName_NullContactsPreferences() {
+ assertEquals(NAME_PRIMARY, ContactDisplayUtils.getPreferredDisplayName(NAME_PRIMARY,
+ NAME_ALTERNATIVE, null));
+ }
+
+ public void testGetPreferredDisplayName_NullContactsPreferences_NullAlternative() {
+ assertEquals(NAME_PRIMARY, ContactDisplayUtils.getPreferredDisplayName(NAME_PRIMARY, null,
+ null));
+ }
+
+ public void testGetPreferredDisplayName_NullContactsPreferences_NullPrimary() {
+ assertEquals(NAME_ALTERNATIVE, ContactDisplayUtils.getPreferredDisplayName(null,
+ NAME_ALTERNATIVE, null));
+ }
+
+ public void testGetPreferredDisplayName_NullContactsPreferences_BothNull() {
+ assertNull(ContactDisplayUtils.getPreferredDisplayName(null, null, null));
+ }
+
+ public void testGetPreferredDisplayName_EmptyAlternative() {
+ Mockito.when(mContactsPreferences.getDisplayOrder())
+ .thenReturn(ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE);
+ assertEquals(NAME_PRIMARY, ContactDisplayUtils.getPreferredDisplayName(NAME_PRIMARY, "",
+ mContactsPreferences));
+ }
+
+ public void testGetPreferredDisplayName_InvalidPreference() {
+ Mockito.when(mContactsPreferences.getDisplayOrder()).thenReturn(-1);
+ assertEquals(NAME_PRIMARY, ContactDisplayUtils.getPreferredDisplayName(NAME_PRIMARY,
+ NAME_ALTERNATIVE, mContactsPreferences));
+ }
+
+ public void testGetPreferredDisplayName_Primary() {
+ Mockito.when(mContactsPreferences.getDisplayOrder())
+ .thenReturn(ContactsPreferences.DISPLAY_ORDER_PRIMARY);
+ assertEquals(NAME_PRIMARY, ContactDisplayUtils.getPreferredDisplayName(NAME_PRIMARY,
+ NAME_ALTERNATIVE, mContactsPreferences));
+ }
+
+ public void testGetPreferredDisplayName_Alternative() {
+ Mockito.when(mContactsPreferences.getDisplayOrder())
+ .thenReturn(ContactsPreferences.DISPLAY_ORDER_ALTERNATIVE);
+ assertEquals(NAME_ALTERNATIVE, ContactDisplayUtils.getPreferredDisplayName(NAME_PRIMARY,
+ NAME_ALTERNATIVE, mContactsPreferences));
+ }
+
+ public void testGetPreferredSortName_NullContactsPreferences() {
+ assertEquals(NAME_PRIMARY, ContactDisplayUtils.getPreferredSortName(NAME_PRIMARY,
+ NAME_ALTERNATIVE, null));
+ }
+
+ public void testGetPreferredSortName_NullContactsPreferences_NullAlternative() {
+ assertEquals(NAME_PRIMARY, ContactDisplayUtils.getPreferredSortName(NAME_PRIMARY, null,
+ null));
+ }
+
+ public void testGetPreferredSortName_NullContactsPreferences_NullPrimary() {
+ assertEquals(NAME_ALTERNATIVE, ContactDisplayUtils.getPreferredSortName(null,
+ NAME_ALTERNATIVE, null));
+ }
+
+ public void testGetPreferredSortName_NullContactsPreferences_BothNull() {
+ assertNull(ContactDisplayUtils.getPreferredSortName(null, null, null));
+ }
+
+ public void testGetPreferredSortName_EmptyAlternative() {
+ Mockito.when(mContactsPreferences.getSortOrder())
+ .thenReturn(ContactsPreferences.SORT_ORDER_ALTERNATIVE);
+ assertEquals(NAME_PRIMARY, ContactDisplayUtils.getPreferredSortName(NAME_PRIMARY, "",
+ mContactsPreferences));
+ }
+
+ public void testGetPreferredSortName_InvalidPreference() {
+ Mockito.when(mContactsPreferences.getSortOrder()).thenReturn(-1);
+ assertEquals(NAME_PRIMARY, ContactDisplayUtils.getPreferredSortName(NAME_PRIMARY,
+ NAME_ALTERNATIVE, mContactsPreferences));
+ }
+
+ public void testGetPreferredSortName_Primary() {
+ Mockito.when(mContactsPreferences.getSortOrder())
+ .thenReturn(ContactsPreferences.SORT_ORDER_PRIMARY);
+ assertEquals(NAME_PRIMARY, ContactDisplayUtils.getPreferredSortName(NAME_PRIMARY,
+ NAME_ALTERNATIVE, mContactsPreferences));
+ }
+
+ public void testGetPreferredSortName_Alternative() {
+ Mockito.when(mContactsPreferences.getSortOrder())
+ .thenReturn(ContactsPreferences.SORT_ORDER_ALTERNATIVE);
+ assertEquals(NAME_ALTERNATIVE, ContactDisplayUtils.getPreferredSortName(NAME_PRIMARY,
+ NAME_ALTERNATIVE, mContactsPreferences));
+ }
+}
diff --git a/tests/src/com/android/contacts/common/util/DateUtilTests.java b/tests/src/com/android/contacts/common/util/DateUtilTests.java
new file mode 100644
index 0000000..f460289
--- /dev/null
+++ b/tests/src/com/android/contacts/common/util/DateUtilTests.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.contacts.common.util;
+
+import junit.framework.TestCase;
+
+import android.test.suitebuilder.annotation.SmallTest;
+import android.text.format.Time;
+
+/**
+ * Unit tests for {@link com.android.contacts.common.util.DateUtils}.
+ */
+@SmallTest
+public class DateUtilTests extends TestCase {
+
+ /**
+ * Test date differences which are in the same day.
+ */
+ public void testDayDiffNone() {
+ Time time = new Time();
+ long date1 = System.currentTimeMillis();
+ long date2 = System.currentTimeMillis() + android.text.format.DateUtils.HOUR_IN_MILLIS;
+ assertEquals(0, DateUtils.getDayDifference(time, date1, date2));
+ assertEquals(0, DateUtils.getDayDifference(time, date2, date1));
+ }
+
+ /**
+ * Test date differences which are a day apart.
+ */
+ public void testDayDiffOne() {
+ Time time = new Time();
+ long date1 = System.currentTimeMillis();
+ long date2 = date1 + android.text.format.DateUtils.DAY_IN_MILLIS;
+ assertEquals(1, DateUtils.getDayDifference(time, date1, date2));
+ assertEquals(1, DateUtils.getDayDifference(time, date2, date1));
+ }
+
+ /**
+ * Test date differences which are two days apart.
+ */
+ public void testDayDiffTwo() {
+ Time time = new Time();
+ long date1 = System.currentTimeMillis();
+ long date2 = date1 + 2*android.text.format.DateUtils.DAY_IN_MILLIS;
+ assertEquals(2, DateUtils.getDayDifference(time, date1, date2));
+ assertEquals(2, DateUtils.getDayDifference(time, date2, date1));
+ }
+}
diff --git a/tests/src/com/android/contacts/common/util/NameConverterTests.java b/tests/src/com/android/contacts/common/util/NameConverterTests.java
new file mode 100644
index 0000000..5a261eb
--- /dev/null
+++ b/tests/src/com/android/contacts/common/util/NameConverterTests.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.text.TextUtils;
+
+import com.android.contacts.common.util.NameConverter;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Tests for {@link NameConverter}.
+ */
+@SmallTest
+public class NameConverterTests extends AndroidTestCase {
+
+ public void testStructuredNameToDisplayName() {
+ Map<String, String> structuredName = new HashMap<String, String>();
+ structuredName.put(StructuredName.PREFIX, "Mr.");
+ structuredName.put(StructuredName.GIVEN_NAME, "John");
+ structuredName.put(StructuredName.MIDDLE_NAME, "Quincy");
+ structuredName.put(StructuredName.FAMILY_NAME, "Adams");
+ structuredName.put(StructuredName.SUFFIX, "Esquire");
+
+ assertEquals("Mr. John Quincy Adams, Esquire",
+ NameConverter.structuredNameToDisplayName(mContext, structuredName));
+
+ structuredName.remove(StructuredName.SUFFIX);
+ assertEquals("Mr. John Quincy Adams",
+ NameConverter.structuredNameToDisplayName(mContext, structuredName));
+
+ structuredName.remove(StructuredName.MIDDLE_NAME);
+ assertEquals("Mr. John Adams",
+ NameConverter.structuredNameToDisplayName(mContext, structuredName));
+ }
+
+ public void testDisplayNameToStructuredName() {
+ assertStructuredName("Mr. John Quincy Adams, Esquire",
+ "Mr.", "John", "Quincy", "Adams", "Esquire");
+ assertStructuredName("John Doe", null, "John", null, "Doe", null);
+ assertStructuredName("Ms. Jane Eyre", "Ms.", "Jane", null, "Eyre", null);
+ assertStructuredName("Dr Leo Spaceman, PhD", "Dr", "Leo", null, "Spaceman", "PhD");
+ }
+
+ /**
+ * Helper method to check whether a given display name parses out to the other parameters.
+ * @param displayName Display name to break into a structured name.
+ * @param prefix Expected prefix (null if not expected).
+ * @param givenName Expected given name (null if not expected).
+ * @param middleName Expected middle name (null if not expected).
+ * @param familyName Expected family name (null if not expected).
+ * @param suffix Expected suffix (null if not expected).
+ */
+ private void assertStructuredName(String displayName, String prefix,
+ String givenName, String middleName, String familyName, String suffix) {
+ Map<String, String> structuredName = NameConverter.displayNameToStructuredName(mContext,
+ displayName);
+ checkNameComponent(StructuredName.PREFIX, prefix, structuredName);
+ checkNameComponent(StructuredName.GIVEN_NAME, givenName, structuredName);
+ checkNameComponent(StructuredName.MIDDLE_NAME, middleName, structuredName);
+ checkNameComponent(StructuredName.FAMILY_NAME, familyName, structuredName);
+ checkNameComponent(StructuredName.SUFFIX, suffix, structuredName);
+ assertEquals(0, structuredName.size());
+ }
+
+ /**
+ * Checks that the given field and value are present in the structured name map (or not present
+ * if the given value is null). If the value is present and matches, the key is removed from
+ * the map - once all components of the name are checked, the map should be empty.
+ * @param field Field to check.
+ * @param value Expected value for the field (null if it is not expected to be populated).
+ * @param structuredName The map of structured field names to values.
+ */
+ private void checkNameComponent(String field, String value,
+ Map<String, String> structuredName) {
+ if (TextUtils.isEmpty(value)) {
+ assertNull(structuredName.get(field));
+ } else {
+ assertEquals(value, structuredName.get(field));
+ }
+ structuredName.remove(field);
+ }
+}
diff --git a/tests/src/com/android/contacts/common/util/SearchUtilTest.java b/tests/src/com/android/contacts/common/util/SearchUtilTest.java
new file mode 100644
index 0000000..3176a3c
--- /dev/null
+++ b/tests/src/com/android/contacts/common/util/SearchUtilTest.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.common.util;
+
+import android.test.suitebuilder.annotation.SmallTest;
+
+import junit.framework.TestCase;
+
+/**
+ * Unit tests for {@link SearchUtil}.
+ */
+@SmallTest
+public class SearchUtilTest extends TestCase {
+
+ public void testFindMatchingLine() {
+ final String actual = "this is a long test string.\nWith potentially many lines.\n" +
+ "test@google.com\nhello\nblah\n'leading punc";
+
+ SearchUtil.MatchedLine matched = SearchUtil.findMatchingLine(actual, "poten");
+ assertEquals("With potentially many lines.", matched.line);
+ assertEquals(5, matched.startIndex);
+
+ // Full line match.
+ matched = SearchUtil.findMatchingLine(actual, "hello");
+ assertEquals("hello", matched.line);
+ assertEquals(0, matched.startIndex);
+
+ // First line match
+ matched = SearchUtil.findMatchingLine(actual, "this");
+ assertEquals("this is a long test string.", matched.line);
+ assertEquals(0, matched.startIndex);
+
+ // Last line match
+ matched = SearchUtil.findMatchingLine(actual, "punc");
+ assertEquals("'leading punc", matched.line);
+ assertEquals(9, matched.startIndex);
+ }
+
+ public void testContains() {
+ final String actual = "this is a long test string.\nWith potentially many lines.\n" +
+ "test@google.com\nhello\nblah\n'leading punc";
+ assertEquals(0, SearchUtil.contains(actual, "this"));
+ assertEquals(10, SearchUtil.contains(actual, "lon"));
+
+ assertEquals(1, SearchUtil.contains("'leading punc", "lead"));
+ assertEquals(9, SearchUtil.contains("'leading punc", "punc"));
+
+ }
+
+ public void testContainsNotFound() {
+ final String actual = "this is a long test string.\nWith potentially many lines.\n" +
+ "test@google.com\nhello\nblah\n'leading punc";
+
+ // Non-prefix
+ assertEquals(-1, SearchUtil.contains(actual, "ith"));
+ assertEquals(-1, SearchUtil.contains(actual, "ing"));
+
+ // Complete misses
+ assertEquals(-1, SearchUtil.contains(actual, "thisx"));
+ assertEquals(-1, SearchUtil.contains(actual, "manyx"));
+ assertEquals(-1, SearchUtil.contains(actual, "hellox"));
+
+ // Test for partial match of start of query to end of line
+ assertEquals(-1, SearchUtil.contains(actual, "punctual"));
+ }
+
+ public void testFindNextTokenStart() {
+ final String actual = "....hello.kitty";
+ // 012345678901234
+
+ // Find first token.
+ assertEquals(4, SearchUtil.findNextTokenStart(actual, 0));
+ assertEquals(4, SearchUtil.findNextTokenStart(actual, 1));
+ assertEquals(4, SearchUtil.findNextTokenStart(actual, 2));
+ assertEquals(4, SearchUtil.findNextTokenStart(actual, 3));
+
+ // Find second token.
+ assertEquals(10, SearchUtil.findNextTokenStart(actual, 4));
+ assertEquals(10, SearchUtil.findNextTokenStart(actual, 5));
+ assertEquals(10, SearchUtil.findNextTokenStart(actual, 6));
+ assertEquals(10, SearchUtil.findNextTokenStart(actual, 7));
+ assertEquals(10, SearchUtil.findNextTokenStart(actual, 8));
+ assertEquals(10, SearchUtil.findNextTokenStart(actual, 9));
+
+ // No token.
+ assertEquals(actual.length(), SearchUtil.findNextTokenStart(actual, 10));
+ assertEquals(actual.length(), SearchUtil.findNextTokenStart(actual, 11));
+ assertEquals(actual.length(), SearchUtil.findNextTokenStart(actual, 12));
+ assertEquals(actual.length(), SearchUtil.findNextTokenStart(actual, 13));
+ assertEquals(actual.length(), SearchUtil.findNextTokenStart(actual, 14));
+ }
+
+ public void testCleanStartAndEndOfSearchQuery() {
+ assertEquals("test", SearchUtil.cleanStartAndEndOfSearchQuery("...test..."));
+ assertEquals("test", SearchUtil.cleanStartAndEndOfSearchQuery(" test "));
+ assertEquals("test", SearchUtil.cleanStartAndEndOfSearchQuery(" ||test"));
+ assertEquals("test", SearchUtil.cleanStartAndEndOfSearchQuery("test.."));
+ }
+
+}
diff --git a/tests/src/com/android/contacts/interactions/ContactInteractionUtilTest.java b/tests/src/com/android/contacts/interactions/ContactInteractionUtilTest.java
index 4802b46..86167c1 100644
--- a/tests/src/com/android/contacts/interactions/ContactInteractionUtilTest.java
+++ b/tests/src/com/android/contacts/interactions/ContactInteractionUtilTest.java
@@ -15,12 +15,9 @@
*/
package com.android.contacts.interactions;
-import com.android.contacts.common.R;
-
import android.content.res.Configuration;
import android.content.res.Resources;
import android.test.AndroidTestCase;
-import android.text.format.DateUtils;
import java.util.Calendar;
import java.util.Locale;
@@ -80,50 +77,15 @@
getContext()));
}
- public void testFormatDateStringFromTimestamp_yesterday() {
- // Test yesterday and tomorrow (Yesterday or Tomorrow shown)
- calendar.add(Calendar.DAY_OF_YEAR, -1);
- assertEquals(getContext().getResources().getString(R.string.yesterday),
- ContactInteractionUtil.formatDateStringFromTimestamp(calendar.getTimeInMillis(),
- getContext()));
- }
-
- public void testFormatDateStringFromTimestamp_yesterdayLastYear() {
- // Set to non leap year
- calendar.set(Calendar.YEAR, 1999);
- calendar.set(Calendar.DAY_OF_YEAR, 365);
- long lastYear = calendar.getTimeInMillis();
- calendar.add(Calendar.DAY_OF_YEAR, 1);
-
- assertEquals(getContext().getResources().getString(R.string.yesterday),
- ContactInteractionUtil.formatDateStringFromTimestamp(lastYear,
- getContext(), calendar));
- }
-
- public void testFormatDateStringFromTimestamp_tomorrow() {
- calendar.add(Calendar.DAY_OF_YEAR, 1);
- assertEquals(getContext().getResources().getString(R.string.tomorrow),
- ContactInteractionUtil.formatDateStringFromTimestamp(calendar.getTimeInMillis(),
- getContext()));
- }
-
- public void testFormatDateStringFromTimestamp_tomorrowNewYear() {
- calendar.set(Calendar.DAY_OF_YEAR, 1);
- long thisYear = calendar.getTimeInMillis();
- calendar.add(Calendar.DAY_OF_YEAR, -1);
-
- assertEquals(getContext().getResources().getString(R.string.tomorrow),
- ContactInteractionUtil.formatDateStringFromTimestamp(thisYear,
- getContext(), calendar));
- }
-
public void testFormatDateStringFromTimestamp_other() {
// Test other (Month Date)
calendar.set(
/* year = */ 1991,
/* month = */ Calendar.MONTH,
- /* day = */ 11);
- assertEquals("March 11",
+ /* day = */ 11,
+ /* hourOfDay = */ 8,
+ /* minute = */ 8);
+ assertEquals("Monday, March 11, 1991, 8:08 AM",
ContactInteractionUtil.formatDateStringFromTimestamp(calendar.getTimeInMillis(),
getContext()));
}