diff --git a/.gitignore b/.gitignore index 5c04032..881ae42 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ app/build/ modules/*/build/ modules/*/.git/ +modules/*/sample/ *.apk *.ap_ *.dex diff --git a/modules/chips b/modules/chips deleted file mode 160000 index 7ccb8ba..0000000 --- a/modules/chips +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7ccb8ba59b168d748edfce5030ab6b01226ef393 diff --git a/modules/chips/Android.mk b/modules/chips/Android.mk new file mode 100644 index 0000000..5ae29d4 --- /dev/null +++ b/modules/chips/Android.mk @@ -0,0 +1,33 @@ +# 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. + +LOCAL_PATH := $(call my-dir) + +include $(CLEAR_VARS) +LOCAL_MODULE := libchips +LOCAL_STATIC_ANDROID_LIBRARIES := \ + android-support-compat \ + android-support-annotations +LOCAL_SDK_VERSION := current +LOCAL_SRC_FILES := \ + $(call all-java-files-under, src) \ + $(call all-logtags-files-under, src) +LOCAL_USE_AAPT2 := true +LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res +include $(BUILD_STATIC_JAVA_LIBRARY) + +################################################## +# Build all sub-directories + +include $(call all-makefiles-under,$(LOCAL_PATH)) diff --git a/modules/chips/AndroidManifest.xml b/modules/chips/AndroidManifest.xml new file mode 100644 index 0000000..86d52b5 --- /dev/null +++ b/modules/chips/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/modules/chips/build.gradle b/modules/chips/build.gradle new file mode 100644 index 0000000..120a389 --- /dev/null +++ b/modules/chips/build.gradle @@ -0,0 +1,31 @@ +plugins { + id 'com.android.library' +} + +android { + namespace 'com.android.ex.chips' + compileSdk 35 + + defaultConfig { + minSdk 33 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + sourceSets { + main { + manifest.srcFile 'AndroidManifest.xml' + java.srcDirs = ['src'] + res.srcDirs = ['res'] + } + } +} + +dependencies { + implementation 'androidx.annotation:annotation:1.8.2' + implementation 'androidx.core:core:1.15.0' + implementation 'androidx.collection:collection:1.4.4' +} diff --git a/modules/chips/project.properties b/modules/chips/project.properties new file mode 100644 index 0000000..91d2b02 --- /dev/null +++ b/modules/chips/project.properties @@ -0,0 +1,15 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system edit +# "ant.properties", and override values to adapt the script to your +# project structure. +# +# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): +#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt + +# Project target. +target=android-19 +android.library=true diff --git a/modules/chips/res/color/chips_dropdown_text.xml b/modules/chips/res/color/chips_dropdown_text.xml new file mode 100644 index 0000000..7d333bc --- /dev/null +++ b/modules/chips/res/color/chips_dropdown_text.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/modules/chips/res/drawable-hdpi/chips_ic_close_24dp.png b/modules/chips/res/drawable-hdpi/chips_ic_close_24dp.png new file mode 100644 index 0000000..9ab350e Binary files /dev/null and b/modules/chips/res/drawable-hdpi/chips_ic_close_24dp.png differ diff --git a/modules/chips/res/drawable-hdpi/ic_cancel_wht_24dp.png b/modules/chips/res/drawable-hdpi/ic_cancel_wht_24dp.png new file mode 100644 index 0000000..7dac7b0 Binary files /dev/null and b/modules/chips/res/drawable-hdpi/ic_cancel_wht_24dp.png differ diff --git a/modules/chips/res/drawable-hdpi/ic_contact_picture.png b/modules/chips/res/drawable-hdpi/ic_contact_picture.png new file mode 100644 index 0000000..4c0e35e Binary files /dev/null and b/modules/chips/res/drawable-hdpi/ic_contact_picture.png differ diff --git a/modules/chips/res/drawable-mdpi/chips_ic_close_24dp.png b/modules/chips/res/drawable-mdpi/chips_ic_close_24dp.png new file mode 100644 index 0000000..73faf52 Binary files /dev/null and b/modules/chips/res/drawable-mdpi/chips_ic_close_24dp.png differ diff --git a/modules/chips/res/drawable-mdpi/ic_cancel_wht_24dp.png b/modules/chips/res/drawable-mdpi/ic_cancel_wht_24dp.png new file mode 100644 index 0000000..48343c9 Binary files /dev/null and b/modules/chips/res/drawable-mdpi/ic_cancel_wht_24dp.png differ diff --git a/modules/chips/res/drawable-mdpi/ic_contact_picture.png b/modules/chips/res/drawable-mdpi/ic_contact_picture.png new file mode 100644 index 0000000..ead9718 Binary files /dev/null and b/modules/chips/res/drawable-mdpi/ic_contact_picture.png differ diff --git a/modules/chips/res/drawable-xhdpi/chips_ic_close_24dp.png b/modules/chips/res/drawable-xhdpi/chips_ic_close_24dp.png new file mode 100644 index 0000000..a3896c5 Binary files /dev/null and b/modules/chips/res/drawable-xhdpi/chips_ic_close_24dp.png differ diff --git a/modules/chips/res/drawable-xhdpi/ic_cancel_wht_24dp.png b/modules/chips/res/drawable-xhdpi/ic_cancel_wht_24dp.png new file mode 100644 index 0000000..3dc3615 Binary files /dev/null and b/modules/chips/res/drawable-xhdpi/ic_cancel_wht_24dp.png differ diff --git a/modules/chips/res/drawable-xhdpi/ic_contact_picture.png b/modules/chips/res/drawable-xhdpi/ic_contact_picture.png new file mode 100644 index 0000000..05a65f6 Binary files /dev/null and b/modules/chips/res/drawable-xhdpi/ic_contact_picture.png differ diff --git a/modules/chips/res/drawable-xxhdpi/chips_ic_close_24dp.png b/modules/chips/res/drawable-xxhdpi/chips_ic_close_24dp.png new file mode 100644 index 0000000..22d7aa5 Binary files /dev/null and b/modules/chips/res/drawable-xxhdpi/chips_ic_close_24dp.png differ diff --git a/modules/chips/res/drawable-xxhdpi/ic_cancel_wht_24dp.png b/modules/chips/res/drawable-xxhdpi/ic_cancel_wht_24dp.png new file mode 100644 index 0000000..2ba1978 Binary files /dev/null and b/modules/chips/res/drawable-xxhdpi/ic_cancel_wht_24dp.png differ diff --git a/modules/chips/res/drawable-xxhdpi/ic_contact_picture.png b/modules/chips/res/drawable-xxhdpi/ic_contact_picture.png new file mode 100644 index 0000000..05a65f6 Binary files /dev/null and b/modules/chips/res/drawable-xxhdpi/ic_contact_picture.png differ diff --git a/modules/chips/res/drawable-xxxhdpi/chips_ic_close_24dp.png b/modules/chips/res/drawable-xxxhdpi/chips_ic_close_24dp.png new file mode 100644 index 0000000..7d1c061 Binary files /dev/null and b/modules/chips/res/drawable-xxxhdpi/chips_ic_close_24dp.png differ diff --git a/modules/chips/res/drawable-xxxhdpi/ic_cancel_wht_24dp.png b/modules/chips/res/drawable-xxxhdpi/ic_cancel_wht_24dp.png new file mode 100644 index 0000000..89ad2b5 Binary files /dev/null and b/modules/chips/res/drawable-xxxhdpi/ic_cancel_wht_24dp.png differ diff --git a/modules/chips/res/drawable/chip_autocomplete_divider_background.xml b/modules/chips/res/drawable/chip_autocomplete_divider_background.xml new file mode 100644 index 0000000..c013b57 --- /dev/null +++ b/modules/chips/res/drawable/chip_autocomplete_divider_background.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/modules/chips/res/drawable/chips_dropdown_background.xml b/modules/chips/res/drawable/chips_dropdown_background.xml new file mode 100644 index 0000000..6a7eeec --- /dev/null +++ b/modules/chips/res/drawable/chips_dropdown_background.xml @@ -0,0 +1,25 @@ + + + + + + + + + + \ No newline at end of file diff --git a/modules/chips/res/drawable/list_item_font_primary.xml b/modules/chips/res/drawable/list_item_font_primary.xml new file mode 100644 index 0000000..4351905 --- /dev/null +++ b/modules/chips/res/drawable/list_item_font_primary.xml @@ -0,0 +1,22 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/chips/res/drawable/list_item_font_secondary.xml b/modules/chips/res/drawable/list_item_font_secondary.xml new file mode 100644 index 0000000..78c4066 --- /dev/null +++ b/modules/chips/res/drawable/list_item_font_secondary.xml @@ -0,0 +1,22 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/chips/res/layout/chips_autocomplete_recipient_dropdown_item.xml b/modules/chips/res/layout/chips_autocomplete_recipient_dropdown_item.xml new file mode 100644 index 0000000..127be4a --- /dev/null +++ b/modules/chips/res/layout/chips_autocomplete_recipient_dropdown_item.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/chips/res/layout/chips_recipient_dropdown_item.xml b/modules/chips/res/layout/chips_recipient_dropdown_item.xml new file mode 100644 index 0000000..7cf0357 --- /dev/null +++ b/modules/chips/res/layout/chips_recipient_dropdown_item.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + diff --git a/modules/chips/res/layout/more_item.xml b/modules/chips/res/layout/more_item.xml new file mode 100644 index 0000000..20693d6 --- /dev/null +++ b/modules/chips/res/layout/more_item.xml @@ -0,0 +1,25 @@ + + + \ No newline at end of file diff --git a/modules/chips/res/values-land/dimen.xml b/modules/chips/res/values-land/dimen.xml new file mode 100644 index 0000000..1ee6608 --- /dev/null +++ b/modules/chips/res/values-land/dimen.xml @@ -0,0 +1,19 @@ + + + + + 26dip + \ No newline at end of file diff --git a/modules/chips/res/values-sw600dp/styles.xml b/modules/chips/res/values-sw600dp/styles.xml new file mode 100644 index 0000000..5ca5ad9 --- /dev/null +++ b/modules/chips/res/values-sw600dp/styles.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/modules/chips/res/values-v17/styles.xml b/modules/chips/res/values-v17/styles.xml new file mode 100644 index 0000000..0efaf62 --- /dev/null +++ b/modules/chips/res/values-v17/styles.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + diff --git a/modules/chips/res/values/attrs.xml b/modules/chips/res/values/attrs.xml new file mode 100644 index 0000000..ddc69c7 --- /dev/null +++ b/modules/chips/res/values/attrs.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/chips/res/values/colors.xml b/modules/chips/res/values/colors.xml new file mode 100644 index 0000000..1f32b60 --- /dev/null +++ b/modules/chips/res/values/colors.xml @@ -0,0 +1,34 @@ + + + + + + #4285f4 + #ededed + + #cccccc + #40000000 + + #f6f6f6 + #db4437 + + #212121 + @android:color/white + #757575 + #4285f4 + + diff --git a/modules/chips/res/values/dimen.xml b/modules/chips/res/values/dimen.xml new file mode 100644 index 0000000..6c56b30 --- /dev/null +++ b/modules/chips/res/values/dimen.xml @@ -0,0 +1,39 @@ + + + + + + -1dp + 12dp + + + 8dp + 32dp + 14sp + 3dp + -1 + + + 12dp + 72dp + 0.5dp + 8dp + + 16dp + 16dp + 16dp + 16dp + diff --git a/modules/chips/res/values/strings.xml b/modules/chips/res/values/strings.xml new file mode 100644 index 0000000..6929417 --- /dev/null +++ b/modules/chips/res/values/strings.xml @@ -0,0 +1,37 @@ + + + + + \u002B%1$d + + Copy email address + + Copy phone number + + Return + + Contact suggestions opened + + No suggestions available + + Remove %s from recipients + + Copy + + Cancel + + Allow contacts suggestions + diff --git a/modules/chips/res/values/styles.xml b/modules/chips/res/values/styles.xml new file mode 100644 index 0000000..60536ac --- /dev/null +++ b/modules/chips/res/values/styles.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/chips/src/com/android/ex/chips/AccountSpecifier.java b/modules/chips/src/com/android/ex/chips/AccountSpecifier.java new file mode 100644 index 0000000..5eb8314 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/AccountSpecifier.java @@ -0,0 +1,28 @@ +/* + * 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.ex.chips; + +import android.accounts.Account; + +/** + * The AccountSpecificAdapter interface describes an Adapter + * that can take an account to retrieve information tied to + * a specific account. + */ +public interface AccountSpecifier { + public void setAccount(Account account); +} diff --git a/modules/chips/src/com/android/ex/chips/BaseRecipientAdapter.java b/modules/chips/src/com/android/ex/chips/BaseRecipientAdapter.java new file mode 100644 index 0000000..72f42f2 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/BaseRecipientAdapter.java @@ -0,0 +1,1034 @@ +/* + * 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.ex.chips; + +import android.accounts.Account; +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.Resources; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Handler; +import android.os.Message; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Directory; +import androidx.annotation.Nullable; +import android.text.TextUtils; +import android.text.util.Rfc822Token; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AutoCompleteTextView; +import android.widget.BaseAdapter; +import android.widget.Filter; +import android.widget.Filterable; + +import com.android.ex.chips.ChipsUtil.PermissionsCheckListener; +import com.android.ex.chips.DropdownChipLayouter.AdapterType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Adapter for showing a recipient list. + * + *

It checks whether all permissions are granted before doing + * query. If not all permissions in {@link ChipsUtil#REQUIRED_PERMISSIONS} are granted and + * {@link #mShowRequestPermissionsItem} is true it will return single entry that asks user to grant + * permissions to the app. Any app that uses this library should set this when it wants us to + * display that entry but then it should set + * {@link RecipientEditTextView.PermissionsRequestItemClickedListener} on + * {@link RecipientEditTextView} as well. + */ +public class BaseRecipientAdapter extends BaseAdapter implements Filterable, AccountSpecifier, + PhotoManager.PhotoManagerCallback { + private static final String TAG = "BaseRecipientAdapter"; + + private static final boolean DEBUG = false; + + /** + * The preferred number of results to be retrieved. This number may be + * exceeded if there are several directories configured, because we will use + * the same limit for all directories. + */ + private static final int DEFAULT_PREFERRED_MAX_RESULT_COUNT = 10; + + /** + * The number of extra entries requested to allow for duplicates. Duplicates + * are removed from the overall result. + */ + static final int ALLOWANCE_FOR_DUPLICATES = 5; + + // This is ContactsContract.PRIMARY_ACCOUNT_NAME. Available from ICS as hidden + static final String PRIMARY_ACCOUNT_NAME = "name_for_primary_account"; + // This is ContactsContract.PRIMARY_ACCOUNT_TYPE. Available from ICS as hidden + static final String PRIMARY_ACCOUNT_TYPE = "type_for_primary_account"; + + /** + * The "Waiting for more contacts" message will be displayed if search is not complete + * within this many milliseconds. + */ + private static final int MESSAGE_SEARCH_PENDING_DELAY = 1000; + /** Used to prepare "Waiting for more contacts" message. */ + private static final int MESSAGE_SEARCH_PENDING = 1; + + public static final int QUERY_TYPE_EMAIL = 0; + public static final int QUERY_TYPE_PHONE = 1; + + private final Queries.Query mQueryMode; + private final int mQueryType; + + /** + * Model object for a {@link Directory} row. + */ + public final static class DirectorySearchParams { + public long directoryId; + public String directoryType; + public String displayName; + public String accountName; + public String accountType; + public CharSequence constraint; + public DirectoryFilter filter; + } + + protected static class DirectoryListQuery { + + public static final Uri URI = + Uri.withAppendedPath(ContactsContract.AUTHORITY_URI, "directories"); + public static final String[] PROJECTION = { + Directory._ID, // 0 + Directory.ACCOUNT_NAME, // 1 + Directory.ACCOUNT_TYPE, // 2 + Directory.DISPLAY_NAME, // 3 + Directory.PACKAGE_NAME, // 4 + Directory.TYPE_RESOURCE_ID, // 5 + }; + + public static final int ID = 0; + public static final int ACCOUNT_NAME = 1; + public static final int ACCOUNT_TYPE = 2; + public static final int DISPLAY_NAME = 3; + public static final int PACKAGE_NAME = 4; + public static final int TYPE_RESOURCE_ID = 5; + } + + /** Used to temporarily hold results in Cursor objects. */ + protected static class TemporaryEntry { + public final String displayName; + public final String destination; + public final int destinationType; + public final String destinationLabel; + public final long contactId; + public final Long directoryId; + public final long dataId; + public final String thumbnailUriString; + public final int displayNameSource; + public final String lookupKey; + + public TemporaryEntry( + String displayName, + String destination, + int destinationType, + String destinationLabel, + long contactId, + Long directoryId, + long dataId, + String thumbnailUriString, + int displayNameSource, + String lookupKey) { + this.displayName = displayName; + this.destination = destination; + this.destinationType = destinationType; + this.destinationLabel = destinationLabel; + this.contactId = contactId; + this.directoryId = directoryId; + this.dataId = dataId; + this.thumbnailUriString = thumbnailUriString; + this.displayNameSource = displayNameSource; + this.lookupKey = lookupKey; + } + + public TemporaryEntry(Cursor cursor, Long directoryId) { + this.displayName = cursor.getString(Queries.Query.NAME); + this.destination = cursor.getString(Queries.Query.DESTINATION); + this.destinationType = cursor.getInt(Queries.Query.DESTINATION_TYPE); + this.destinationLabel = cursor.getString(Queries.Query.DESTINATION_LABEL); + this.contactId = cursor.getLong(Queries.Query.CONTACT_ID); + this.directoryId = directoryId; + this.dataId = cursor.getLong(Queries.Query.DATA_ID); + this.thumbnailUriString = cursor.getString(Queries.Query.PHOTO_THUMBNAIL_URI); + this.displayNameSource = cursor.getInt(Queries.Query.DISPLAY_NAME_SOURCE); + this.lookupKey = cursor.getString(Queries.Query.LOOKUP_KEY); + } + } + + /** + * Used to pass results from {@link DefaultFilter#performFiltering(CharSequence)} to + * {@link DefaultFilter#publishResults(CharSequence, android.widget.Filter.FilterResults)} + */ + private static class DefaultFilterResult { + public final List entries; + public final LinkedHashMap> entryMap; + public final List nonAggregatedEntries; + public final Set existingDestinations; + public final List paramsList; + + public DefaultFilterResult(List entries, + LinkedHashMap> entryMap, + List nonAggregatedEntries, + Set existingDestinations, + List paramsList) { + this.entries = entries; + this.entryMap = entryMap; + this.nonAggregatedEntries = nonAggregatedEntries; + this.existingDestinations = existingDestinations; + this.paramsList = paramsList; + } + + private static DefaultFilterResult createResultWithNonAggregatedEntry( + RecipientEntry entry) { + return new DefaultFilterResult( + Collections.singletonList(entry), + new LinkedHashMap>() /* entryMap */, + Collections.singletonList(entry) /* nonAggregatedEntries */, + Collections.emptySet() /* existingDestinations */, + null /* paramsList */); + } + } + + /** + * An asynchronous filter used for loading two data sets: email rows from the local + * contact provider and the list of {@link Directory}'s. + */ + private final class DefaultFilter extends Filter { + + @Override + protected FilterResults performFiltering(CharSequence constraint) { + if (DEBUG) { + Log.d(TAG, "start filtering. constraint: " + constraint + ", thread:" + + Thread.currentThread()); + } + + final FilterResults results = new FilterResults(); + + if (TextUtils.isEmpty(constraint)) { + clearTempEntries(); + // Return empty results. + return results; + } + + if (!ChipsUtil.hasPermissions(mContext, mPermissionsCheckListener)) { + if (DEBUG) { + Log.d(TAG, "No Contacts permission. mShowRequestPermissionsItem: " + + mShowRequestPermissionsItem); + } + clearTempEntries(); + if (!mShowRequestPermissionsItem) { + // App doesn't want to show request permission entry. Returning empty results. + return results; + } + + // Return result with only permission request entry. + results.values = DefaultFilterResult.createResultWithNonAggregatedEntry( + RecipientEntry.constructPermissionEntry(ChipsUtil.REQUIRED_PERMISSIONS)); + results.count = 1; + return results; + } + + Cursor defaultDirectoryCursor = null; + + try { + defaultDirectoryCursor = doQuery(constraint, mPreferredMaxResultCount, + null /* directoryId */); + + if (defaultDirectoryCursor == null) { + if (DEBUG) { + Log.w(TAG, "null cursor returned for default Email filter query."); + } + } else { + // These variables will become mEntries, mEntryMap, mNonAggregatedEntries, and + // mExistingDestinations. Here we shouldn't use those member variables directly + // since this method is run outside the UI thread. + final LinkedHashMap> entryMap = + new LinkedHashMap>(); + final List nonAggregatedEntries = + new ArrayList(); + final Set existingDestinations = new HashSet(); + + while (defaultDirectoryCursor.moveToNext()) { + // Note: At this point each entry doesn't contain any photo + // (thus getPhotoBytes() returns null). + putOneEntry(new TemporaryEntry(defaultDirectoryCursor, + null /* directoryId */), + true, entryMap, nonAggregatedEntries, existingDestinations); + } + + // We'll copy this result to mEntry in publicResults() (run in the UX thread). + final List entries = constructEntryList( + entryMap, nonAggregatedEntries); + + final List paramsList = + searchOtherDirectories(existingDestinations); + + results.values = new DefaultFilterResult( + entries, entryMap, nonAggregatedEntries, + existingDestinations, paramsList); + results.count = entries.size(); + } + } finally { + if (defaultDirectoryCursor != null) { + defaultDirectoryCursor.close(); + } + } + return results; + } + + @Override + protected void publishResults(final CharSequence constraint, FilterResults results) { + mCurrentConstraint = constraint; + + clearTempEntries(); + + if (results.values != null) { + DefaultFilterResult defaultFilterResult = (DefaultFilterResult) results.values; + mEntryMap = defaultFilterResult.entryMap; + mNonAggregatedEntries = defaultFilterResult.nonAggregatedEntries; + mExistingDestinations = defaultFilterResult.existingDestinations; + + cacheCurrentEntriesIfNeeded(defaultFilterResult.entries.size(), + defaultFilterResult.paramsList == null ? 0 : + defaultFilterResult.paramsList.size()); + + updateEntries(defaultFilterResult.entries); + + // We need to search other remote directories, doing other Filter requests. + if (defaultFilterResult.paramsList != null) { + final int limit = mPreferredMaxResultCount - + defaultFilterResult.existingDestinations.size(); + startSearchOtherDirectories(constraint, defaultFilterResult.paramsList, limit); + } + } else { + updateEntries(Collections.emptyList()); + } + } + + @Override + public CharSequence convertResultToString(Object resultValue) { + final RecipientEntry entry = (RecipientEntry)resultValue; + final String displayName = entry.getDisplayName(); + final String emailAddress = entry.getDestination(); + if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) { + return emailAddress; + } else { + return new Rfc822Token(displayName, emailAddress, null).toString(); + } + } + } + + /** + * Returns the list of models for directory search (using {@link DirectoryFilter}) or + * {@code null} when we don't need or can't search other directories. + */ + protected List searchOtherDirectories(Set existingDestinations) { + if (!ChipsUtil.hasPermissions(mContext, mPermissionsCheckListener)) { + // If we don't have permissions we can't search other directories. + if (DEBUG) { + Log.d(TAG, "Not searching other directories because we don't have required " + + "permissions."); + } + return null; + } + + // After having local results, check the size of results. If the results are + // not enough, we search remote directories, which will take longer time. + final int limit = mPreferredMaxResultCount - existingDestinations.size(); + if (limit > 0) { + if (DEBUG) { + Log.d(TAG, "More entries should be needed (current: " + + existingDestinations.size() + + ", remaining limit: " + limit + ") "); + } + Cursor directoryCursor = null; + try { + directoryCursor = mContentResolver.query( + DirectoryListQuery.URI, DirectoryListQuery.PROJECTION, + null, null, null); + return setupOtherDirectories(mContext, directoryCursor, mAccount); + } finally { + if (directoryCursor != null) { + directoryCursor.close(); + } + } + } else { + // We don't need to search other directories. + return null; + } + } + + /** + * An asynchronous filter that performs search in a particular directory. + */ + protected class DirectoryFilter extends Filter { + private final DirectorySearchParams mParams; + private int mLimit; + + public DirectoryFilter(DirectorySearchParams params) { + mParams = params; + } + + public synchronized void setLimit(int limit) { + this.mLimit = limit; + } + + public synchronized int getLimit() { + return this.mLimit; + } + + @Override + protected FilterResults performFiltering(CharSequence constraint) { + if (DEBUG) { + Log.d(TAG, "DirectoryFilter#performFiltering. directoryId: " + mParams.directoryId + + ", constraint: " + constraint + ", thread: " + Thread.currentThread()); + } + final FilterResults results = new FilterResults(); + results.values = null; + results.count = 0; + + if (!TextUtils.isEmpty(constraint)) { + final ArrayList tempEntries = new ArrayList(); + + Cursor cursor = null; + try { + // We don't want to pass this Cursor object to UI thread (b/5017608). + // Assuming the result should contain fairly small results (at most ~10), + // We just copy everything to local structure. + cursor = doQuery(constraint, getLimit(), mParams.directoryId); + + if (cursor != null) { + while (cursor.moveToNext()) { + tempEntries.add(new TemporaryEntry(cursor, mParams.directoryId)); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + if (!tempEntries.isEmpty()) { + results.values = tempEntries; + results.count = tempEntries.size(); + } + } + + if (DEBUG) { + Log.v(TAG, "finished loading directory \"" + mParams.displayName + "\"" + + " with query " + constraint); + } + + return results; + } + + @Override + protected void publishResults(final CharSequence constraint, FilterResults results) { + if (DEBUG) { + Log.d(TAG, "DirectoryFilter#publishResult. constraint: " + constraint + + ", mCurrentConstraint: " + mCurrentConstraint); + } + mDelayedMessageHandler.removeDelayedLoadMessage(); + // Check if the received result matches the current constraint + // If not - the user must have continued typing after the request was issued, which + // means several member variables (like mRemainingDirectoryLoad) are already + // overwritten so shouldn't be touched here anymore. + if (TextUtils.equals(constraint, mCurrentConstraint)) { + if (results.count > 0) { + @SuppressWarnings("unchecked") + final ArrayList tempEntries = + (ArrayList) results.values; + + for (TemporaryEntry tempEntry : tempEntries) { + putOneEntry(tempEntry, mParams.directoryId == Directory.DEFAULT); + } + } + + // If there are remaining directories, set up delayed message again. + mRemainingDirectoryCount--; + if (mRemainingDirectoryCount > 0) { + if (DEBUG) { + Log.d(TAG, "Resend delayed load message. Current mRemainingDirectoryLoad: " + + mRemainingDirectoryCount); + } + mDelayedMessageHandler.sendDelayedLoadMessage(); + } + + // If this directory result has some items, or there are no more directories that + // we are waiting for, clear the temp results + if (results.count > 0 || mRemainingDirectoryCount == 0) { + // Clear the temp entries + clearTempEntries(); + } + } + + // Show the list again without "waiting" message. + updateEntries(constructEntryList()); + } + } + + private final Context mContext; + private final ContentResolver mContentResolver; + private Account mAccount; + protected final int mPreferredMaxResultCount; + private DropdownChipLayouter mDropdownChipLayouter; + + /** + * {@link #mEntries} is responsible for showing every result for this Adapter. To + * construct it, we use {@link #mEntryMap}, {@link #mNonAggregatedEntries}, and + * {@link #mExistingDestinations}. + * + * First, each destination (an email address or a phone number) with a valid contactId is + * inserted into {@link #mEntryMap} and grouped by the contactId. Destinations without valid + * contactId (possible if they aren't in local storage) are stored in + * {@link #mNonAggregatedEntries}. + * Duplicates are removed using {@link #mExistingDestinations}. + * + * After having all results from Cursor objects, all destinations in mEntryMap are copied to + * {@link #mEntries}. If the number of destinations is not enough (i.e. less than + * {@link #mPreferredMaxResultCount}), destinations in mNonAggregatedEntries are also used. + * + * These variables are only used in UI thread, thus should not be touched in + * performFiltering() methods. + */ + private LinkedHashMap> mEntryMap; + private List mNonAggregatedEntries; + private Set mExistingDestinations; + /** Note: use {@link #updateEntries(List)} to update this variable. */ + private List mEntries; + private List mTempEntries; + + /** The number of directories this adapter is waiting for results. */ + private int mRemainingDirectoryCount; + + /** + * Used to ignore asynchronous queries with a different constraint, which may happen when + * users type characters quickly. + */ + protected CharSequence mCurrentConstraint; + + /** + * Performs all photo querying as well as caching for repeated lookups. + */ + private PhotoManager mPhotoManager; + + protected boolean mShowRequestPermissionsItem; + + private PermissionsCheckListener mPermissionsCheckListener; + + /** + * Handler specific for maintaining "Waiting for more contacts" message, which will be shown + * when: + * - there are directories to be searched + * - results from directories are slow to come + */ + private final class DelayedMessageHandler extends Handler { + @Override + public void handleMessage(Message msg) { + if (mRemainingDirectoryCount > 0) { + updateEntries(constructEntryList()); + } + } + + public void sendDelayedLoadMessage() { + sendMessageDelayed(obtainMessage(MESSAGE_SEARCH_PENDING, 0, 0, null), + MESSAGE_SEARCH_PENDING_DELAY); + } + + public void removeDelayedLoadMessage() { + removeMessages(MESSAGE_SEARCH_PENDING); + } + } + + private final DelayedMessageHandler mDelayedMessageHandler = new DelayedMessageHandler(); + + private EntriesUpdatedObserver mEntriesUpdatedObserver; + + /** + * Constructor for email queries. + */ + public BaseRecipientAdapter(Context context) { + this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, QUERY_TYPE_EMAIL); + } + + public BaseRecipientAdapter(Context context, int preferredMaxResultCount) { + this(context, preferredMaxResultCount, QUERY_TYPE_EMAIL); + } + + public BaseRecipientAdapter(int queryMode, Context context) { + this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, queryMode); + } + + public BaseRecipientAdapter(int queryMode, Context context, int preferredMaxResultCount) { + this(context, preferredMaxResultCount, queryMode); + } + + public BaseRecipientAdapter(Context context, int preferredMaxResultCount, int queryMode) { + mContext = context; + mContentResolver = context.getContentResolver(); + mPreferredMaxResultCount = preferredMaxResultCount; + mPhotoManager = new DefaultPhotoManager(mContentResolver); + mQueryType = queryMode; + + if (queryMode == QUERY_TYPE_EMAIL) { + mQueryMode = Queries.EMAIL; + } else if (queryMode == QUERY_TYPE_PHONE) { + mQueryMode = Queries.PHONE; + } else { + mQueryMode = Queries.EMAIL; + Log.e(TAG, "Unsupported query type: " + queryMode); + } + } + + public Context getContext() { + return mContext; + } + + public int getQueryType() { + return mQueryType; + } + + public void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) { + mDropdownChipLayouter = dropdownChipLayouter; + mDropdownChipLayouter.setQuery(mQueryMode); + } + + public DropdownChipLayouter getDropdownChipLayouter() { + return mDropdownChipLayouter; + } + + public void setPermissionsCheckListener(PermissionsCheckListener permissionsCheckListener) { + mPermissionsCheckListener = permissionsCheckListener; + } + + @Nullable + public PermissionsCheckListener getPermissionsCheckListener() { + return mPermissionsCheckListener; + } + + /** + * Enables overriding the default photo manager that is used. + */ + public void setPhotoManager(PhotoManager photoManager) { + mPhotoManager = photoManager; + } + + public PhotoManager getPhotoManager() { + return mPhotoManager; + } + + /** + * If true, forces using the {@link com.android.ex.chips.SingleRecipientArrayAdapter} + * instead of {@link com.android.ex.chips.RecipientAlternatesAdapter} when + * clicking on a chip. Default implementation returns {@code false}. + */ + public boolean forceShowAddress() { + return false; + } + + /** + * Used to replace email addresses with chips. Default behavior + * queries the ContactsProvider for contact information about the contact. + * Derived classes should override this method if they wish to use a + * new data source. + * @param inAddresses addresses to query + * @param callback callback to return results in case of success or failure + */ + public void getMatchingRecipients(ArrayList inAddresses, + RecipientAlternatesAdapter.RecipientMatchCallback callback) { + RecipientAlternatesAdapter.getMatchingRecipients( + getContext(), this, inAddresses, getAccount(), callback, mPermissionsCheckListener); + } + + /** + * Set the account when known. Causes the search to prioritize contacts from that account. + */ + @Override + public void setAccount(Account account) { + mAccount = account; + } + + /** + * Returns permissions that this adapter needs in order to provide results. + */ + public String[] getRequiredPermissions() { + return ChipsUtil.REQUIRED_PERMISSIONS; + } + + /** + * Sets whether to ask user to grant permission if they are missing. + */ + public void setShowRequestPermissionsItem(boolean show) { + mShowRequestPermissionsItem = show; + } + + /** Will be called from {@link AutoCompleteTextView} to prepare auto-complete list. */ + @Override + public Filter getFilter() { + return new DefaultFilter(); + } + + /** + * An extension to {@link RecipientAlternatesAdapter#getMatchingRecipients} that allows + * additional sources of contacts to be considered as matching recipients. + * @param addresses A set of addresses to be matched + * @return A list of matches or null if none found + */ + public Map getMatchingRecipients(Set addresses) { + return null; + } + + public static List setupOtherDirectories(Context context, + Cursor directoryCursor, Account account) { + final PackageManager packageManager = context.getPackageManager(); + final List paramsList = new ArrayList(); + DirectorySearchParams preferredDirectory = null; + while (directoryCursor.moveToNext()) { + final long id = directoryCursor.getLong(DirectoryListQuery.ID); + + // Skip the local invisible directory, because the default directory already includes + // all local results. + if (id == Directory.LOCAL_INVISIBLE) { + continue; + } + + final DirectorySearchParams params = new DirectorySearchParams(); + final String packageName = directoryCursor.getString(DirectoryListQuery.PACKAGE_NAME); + final int resourceId = directoryCursor.getInt(DirectoryListQuery.TYPE_RESOURCE_ID); + params.directoryId = id; + params.displayName = directoryCursor.getString(DirectoryListQuery.DISPLAY_NAME); + params.accountName = directoryCursor.getString(DirectoryListQuery.ACCOUNT_NAME); + params.accountType = directoryCursor.getString(DirectoryListQuery.ACCOUNT_TYPE); + if (packageName != null && resourceId != 0) { + try { + final Resources resources = + packageManager.getResourcesForApplication(packageName); + params.directoryType = resources.getString(resourceId); + if (params.directoryType == null) { + Log.e(TAG, "Cannot resolve directory name: " + + resourceId + "@" + packageName); + } + } catch (NameNotFoundException e) { + Log.e(TAG, "Cannot resolve directory name: " + + resourceId + "@" + packageName, e); + } + } + + // If an account has been provided and we found a directory that + // corresponds to that account, place that directory second, directly + // underneath the local contacts. + if (preferredDirectory == null && account != null + && account.name.equals(params.accountName) + && account.type.equals(params.accountType)) { + preferredDirectory = params; + } else { + paramsList.add(params); + } + } + + if (preferredDirectory != null) { + paramsList.add(1, preferredDirectory); + } + + return paramsList; + } + + /** + * Starts search in other directories using {@link Filter}. Results will be handled in + * {@link DirectoryFilter}. + */ + protected void startSearchOtherDirectories( + CharSequence constraint, List paramsList, int limit) { + final int count = paramsList.size(); + // Note: skipping the default partition (index 0), which has already been loaded + for (int i = 1; i < count; i++) { + final DirectorySearchParams params = paramsList.get(i); + params.constraint = constraint; + if (params.filter == null) { + params.filter = new DirectoryFilter(params); + } + params.filter.setLimit(limit); + params.filter.filter(constraint); + } + + // Directory search started. We may show "waiting" message if directory results are slow + // enough. + mRemainingDirectoryCount = count - 1; + mDelayedMessageHandler.sendDelayedLoadMessage(); + } + + /** + * Called whenever {@link com.android.ex.chips.BaseRecipientAdapter.DirectoryFilter} + * wants to add an additional entry to the results. Derived classes should override + * this method if they are not using the default data structures provided by + * {@link com.android.ex.chips.BaseRecipientAdapter} and are instead using their + * own data structures to store and collate data. + * @param entry the entry being added + * @param isAggregatedEntry + */ + protected void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry) { + putOneEntry(entry, isAggregatedEntry, + mEntryMap, mNonAggregatedEntries, mExistingDestinations); + } + + private static void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry, + LinkedHashMap> entryMap, + List nonAggregatedEntries, + Set existingDestinations) { + if (existingDestinations.contains(entry.destination)) { + return; + } + + existingDestinations.add(entry.destination); + + if (!isAggregatedEntry) { + nonAggregatedEntries.add(RecipientEntry.constructTopLevelEntry( + entry.displayName, + entry.displayNameSource, + entry.destination, entry.destinationType, entry.destinationLabel, + entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString, + true, entry.lookupKey)); + } else if (entryMap.containsKey(entry.contactId)) { + // We already have a section for the person. + final List entryList = entryMap.get(entry.contactId); + entryList.add(RecipientEntry.constructSecondLevelEntry( + entry.displayName, + entry.displayNameSource, + entry.destination, entry.destinationType, entry.destinationLabel, + entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString, + true, entry.lookupKey)); + } else { + final List entryList = new ArrayList(); + entryList.add(RecipientEntry.constructTopLevelEntry( + entry.displayName, + entry.displayNameSource, + entry.destination, entry.destinationType, entry.destinationLabel, + entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString, + true, entry.lookupKey)); + entryMap.put(entry.contactId, entryList); + } + } + + /** + * Returns the actual list to use for this Adapter. Derived classes + * should override this method if overriding how the adapter stores and collates + * data. + */ + protected List constructEntryList() { + return constructEntryList(mEntryMap, mNonAggregatedEntries); + } + + /** + * Constructs an actual list for this Adapter using {@link #mEntryMap}. Also tries to + * fetch a cached photo for each contact entry (other than separators), or request another + * thread to get one from directories. + */ + private List constructEntryList( + LinkedHashMap> entryMap, + List nonAggregatedEntries) { + final List entries = new ArrayList(); + int validEntryCount = 0; + for (Map.Entry> mapEntry : entryMap.entrySet()) { + final List entryList = mapEntry.getValue(); + final int size = entryList.size(); + for (int i = 0; i < size; i++) { + RecipientEntry entry = entryList.get(i); + entries.add(entry); + mPhotoManager.populatePhotoBytesAsync(entry, this); + validEntryCount++; + } + if (validEntryCount > mPreferredMaxResultCount) { + break; + } + } + if (validEntryCount <= mPreferredMaxResultCount) { + for (RecipientEntry entry : nonAggregatedEntries) { + if (validEntryCount > mPreferredMaxResultCount) { + break; + } + entries.add(entry); + mPhotoManager.populatePhotoBytesAsync(entry, this); + validEntryCount++; + } + } + + return entries; + } + + + public interface EntriesUpdatedObserver { + public void onChanged(List entries); + } + + public void registerUpdateObserver(EntriesUpdatedObserver observer) { + mEntriesUpdatedObserver = observer; + } + + /** Resets {@link #mEntries} and notify the event to its parent ListView. */ + protected void updateEntries(List newEntries) { + mEntries = newEntries; + mEntriesUpdatedObserver.onChanged(newEntries); + notifyDataSetChanged(); + } + + /** + * If there are no local results and we are searching alternate results, + * in the new result set, cache off what had been shown to the user for use until + * the first directory result is returned + * @param newEntryCount number of newly loaded entries + * @param paramListCount number of alternate filters it will search (including the current one). + */ + protected void cacheCurrentEntriesIfNeeded(int newEntryCount, int paramListCount) { + if (newEntryCount == 0 && paramListCount > 1) { + cacheCurrentEntries(); + } + } + + protected void cacheCurrentEntries() { + mTempEntries = mEntries; + } + + protected void clearTempEntries() { + mTempEntries = null; + } + + protected List getEntries() { + return mTempEntries != null ? mTempEntries : mEntries; + } + + protected void fetchPhoto(final RecipientEntry entry, PhotoManager.PhotoManagerCallback cb) { + mPhotoManager.populatePhotoBytesAsync(entry, cb); + } + + private Cursor doQuery(CharSequence constraint, int limit, Long directoryId) { + if (!ChipsUtil.hasPermissions(mContext, mPermissionsCheckListener)) { + if (DEBUG) { + Log.d(TAG, "Not doing query because we don't have required permissions."); + } + return null; + } + + final Uri.Builder builder = mQueryMode.getContentFilterUri().buildUpon() + .appendPath(constraint.toString()) + .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, + String.valueOf(limit + ALLOWANCE_FOR_DUPLICATES)); + if (directoryId != null) { + builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, + String.valueOf(directoryId)); + } + if (mAccount != null) { + builder.appendQueryParameter(PRIMARY_ACCOUNT_NAME, mAccount.name); + builder.appendQueryParameter(PRIMARY_ACCOUNT_TYPE, mAccount.type); + } + final long start = System.currentTimeMillis(); + final Cursor cursor = mContentResolver.query( + builder.build(), mQueryMode.getProjection(), null, null, null); + final long end = System.currentTimeMillis(); + if (DEBUG) { + Log.d(TAG, "Time for autocomplete (query: " + constraint + + ", directoryId: " + directoryId + ", num_of_results: " + + (cursor != null ? cursor.getCount() : "null") + "): " + + (end - start) + " ms"); + } + return cursor; + } + + // TODO: This won't be used at all. We should find better way to quit the thread.. + /*public void close() { + mEntries = null; + mPhotoCacheMap.evictAll(); + if (!sPhotoHandlerThread.quit()) { + Log.w(TAG, "Failed to quit photo handler thread, ignoring it."); + } + }*/ + + @Override + public int getCount() { + final List entries = getEntries(); + return entries != null ? entries.size() : 0; + } + + @Override + public RecipientEntry getItem(int position) { + return getEntries().get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getViewTypeCount() { + return RecipientEntry.ENTRY_TYPE_SIZE; + } + + @Override + public int getItemViewType(int position) { + return getEntries().get(position).getEntryType(); + } + + @Override + public boolean isEnabled(int position) { + return getEntries().get(position).isSelectable(); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final RecipientEntry entry = getEntries().get(position); + + final String constraint = mCurrentConstraint == null ? null : + mCurrentConstraint.toString(); + + return mDropdownChipLayouter.bindView(convertView, parent, entry, position, + AdapterType.BASE_RECIPIENT, constraint); + } + + public Account getAccount() { + return mAccount; + } + + @Override + public void onPhotoBytesPopulated() { + // Default implementation does nothing + } + + @Override + public void onPhotoBytesAsynchronouslyPopulated() { + notifyDataSetChanged(); + } + + @Override + public void onPhotoBytesAsyncLoadFailed() { + // Default implementation does nothing + } +} diff --git a/modules/chips/src/com/android/ex/chips/ChipsUtil.java b/modules/chips/src/com/android/ex/chips/ChipsUtil.java new file mode 100644 index 0000000..ea31b20 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/ChipsUtil.java @@ -0,0 +1,92 @@ +/* + * 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.ex.chips; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Process; +import androidx.annotation.Nullable; + +public class ChipsUtil { + + /** + * Listener that gets notified when we check whether we have permission. + */ + public interface PermissionsCheckListener { + void onPermissionCheck(String permission, boolean granted); + } + + /** + * Permissions required by Chips library. + */ + public static final String[] REQUIRED_PERMISSIONS = + new String[] { Manifest.permission.READ_CONTACTS }; + + /** + * Returns true when the caller can use Chips UI in its environment. + */ + public static boolean supportsChipsUi() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH; + } + + /** + * Whether we are running on M or later version. + * + *

This is interesting for us because new permission model is introduced in M and we need to + * check if we have {@link #REQUIRED_PERMISSIONS}. + */ + public static boolean isRunningMOrLater() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M; + } + + /** + * Returns {@link PackageManager#PERMISSION_GRANTED} if given permission is granted, or + * {@link PackageManager#PERMISSION_DENIED} if not. + */ + public static int checkPermission(Context context, String permission) { + if (isRunningMOrLater()) { + // TODO: Use "context.checkSelfPermission(permission)" once it's safe to move to M sdk + return context.checkPermission(permission, Process.myPid(), Process.myUid()); + } else { + // Assume that we have permission before M. + return PackageManager.PERMISSION_GRANTED; + } + } + + /** + * Returns true if all permissions in {@link #REQUIRED_PERMISSIONS} are granted. + * + *

If {@link PermissionsCheckListener} is specified it will be called for every + * {@link #checkPermission} call. + */ + public static boolean hasPermissions(Context context, + @Nullable PermissionsCheckListener permissionsCheckListener) { + for (String permission : REQUIRED_PERMISSIONS) { + final boolean granted = + checkPermission(context, permission) == PackageManager.PERMISSION_GRANTED; + if (permissionsCheckListener != null) { + permissionsCheckListener.onPermissionCheck(permission, granted); + } + if (!granted) { + return false; + } + } + return true; + } +} diff --git a/modules/chips/src/com/android/ex/chips/CircularImageView.java b/modules/chips/src/com/android/ex/chips/CircularImageView.java new file mode 100644 index 0000000..246cc96 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/CircularImageView.java @@ -0,0 +1,114 @@ +package com.android.ex.chips; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.util.AttributeSet; +import android.widget.ImageView; + +/** + * An ImageView class with a circle mask so that all images are drawn in a + * circle instead of a square. + */ +public class CircularImageView extends ImageView { + private static float circularImageBorder = 1f; + + private final Matrix matrix; + private final RectF source; + private final RectF destination; + private final Paint bitmapPaint; + private final Paint borderPaint; + + public CircularImageView(Context context) { + this(context, null, 0); + } + + public CircularImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CircularImageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + matrix = new Matrix(); + source = new RectF(); + destination = new RectF(); + + bitmapPaint = new Paint(); + bitmapPaint.setAntiAlias(true); + bitmapPaint.setFilterBitmap(true); + bitmapPaint.setDither(true); + + borderPaint = new Paint(); + borderPaint.setColor(Color.TRANSPARENT); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setStrokeWidth(circularImageBorder); + borderPaint.setAntiAlias(true); + } + + @Override + protected void onDraw(Canvas canvas) { + Drawable drawable = getDrawable(); + BitmapDrawable bitmapDrawable = null; + // support state list drawable by getting the current state + if (drawable instanceof StateListDrawable) { + if (((StateListDrawable) drawable).getCurrent() != null) { + bitmapDrawable = (BitmapDrawable) drawable.getCurrent(); + } + } else { + bitmapDrawable = (BitmapDrawable) drawable; + } + + if (bitmapDrawable == null) { + return; + } + Bitmap bitmap = bitmapDrawable.getBitmap(); + if (bitmap == null) { + return; + } + + source.set(0, 0, bitmap.getWidth(), bitmap.getHeight()); + destination.set(getPaddingLeft(), getPaddingTop(), getWidth() - getPaddingRight(), + getHeight() - getPaddingBottom()); + + drawBitmapWithCircleOnCanvas(bitmap, canvas, source, destination); + } + + /** + * Given the source bitmap and a canvas, draws the bitmap through a circular + * mask. Only draws a circle with diameter equal to the destination width. + * + * @param bitmap The source bitmap to draw. + * @param canvas The canvas to draw it on. + * @param source The source bound of the bitmap. + * @param dest The destination bound on the canvas. + */ + public void drawBitmapWithCircleOnCanvas(Bitmap bitmap, Canvas canvas, + RectF source, RectF dest) { + // Draw bitmap through shader first. + BitmapShader shader = new BitmapShader(bitmap, Shader.TileMode.CLAMP, + Shader.TileMode.CLAMP); + matrix.reset(); + + // Fit bitmap to bounds. + matrix.setRectToRect(source, dest, Matrix.ScaleToFit.FILL); + + shader.setLocalMatrix(matrix); + bitmapPaint.setShader(shader); + canvas.drawCircle(dest.centerX(), dest.centerY(), dest.width() / 2f, + bitmapPaint); + + // Then draw the border. + canvas.drawCircle(dest.centerX(), dest.centerY(), + dest.width() / 2f - circularImageBorder / 2, borderPaint); + } +} \ No newline at end of file diff --git a/modules/chips/src/com/android/ex/chips/CopyDialog.java b/modules/chips/src/com/android/ex/chips/CopyDialog.java new file mode 100644 index 0000000..631b488 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/CopyDialog.java @@ -0,0 +1,51 @@ +package com.android.ex.chips; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; + +/** + * Simple dialog fragment for copying the contents of a chip. + */ +public class CopyDialog extends DialogFragment implements DialogInterface.OnClickListener { + + public static final String TAG = "chips-copy-dialog"; + + private static final String ARG_TEXT = "text"; + + private String mText; + + public static CopyDialog newInstance(String text) { + final CopyDialog fragment = new CopyDialog(); + final Bundle args = new Bundle(1); + args.putString(ARG_TEXT, text); + fragment.setArguments(args); + return fragment; + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Bundle args = getArguments(); + mText = args.getString(ARG_TEXT); + + return new AlertDialog.Builder(getActivity()) + .setMessage(mText) + .setPositiveButton(R.string.chips_action_copy, this) + .setNegativeButton(R.string.chips_action_cancel, null) + .create(); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (which == DialogInterface.BUTTON_POSITIVE) { + final ClipboardManager clipboard = (ClipboardManager) + getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(ClipData.newPlainText(null, mText)); + } + } +} diff --git a/modules/chips/src/com/android/ex/chips/DefaultPhotoManager.java b/modules/chips/src/com/android/ex/chips/DefaultPhotoManager.java new file mode 100644 index 0000000..321e54b --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/DefaultPhotoManager.java @@ -0,0 +1,145 @@ +/* + * 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.ex.chips; + +import android.content.ContentResolver; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.ContactsContract; +import androidx.collection.LruCache; +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Default implementation of {@link com.android.ex.chips.PhotoManager} that + * queries for photo bytes by using the {@link com.android.ex.chips.RecipientEntry}'s + * photoThumbnailUri. + */ +public class DefaultPhotoManager implements PhotoManager { + private static final String TAG = "DefaultPhotoManager"; + + private static final boolean DEBUG = false; + + /** + * For reading photos for directory contacts, this is the chunk size for + * copying from the {@link InputStream} to the output stream. + */ + private static final int BUFFER_SIZE = 1024*16; + + private static class PhotoQuery { + public static final String[] PROJECTION = { + ContactsContract.CommonDataKinds.Photo.PHOTO + }; + + public static final int PHOTO = 0; + } + + private final ContentResolver mContentResolver; + private final LruCache mPhotoCacheMap; + + public DefaultPhotoManager(ContentResolver contentResolver) { + mContentResolver = contentResolver; + mPhotoCacheMap = new LruCache(PHOTO_CACHE_SIZE); + } + + @Override + public void populatePhotoBytesAsync(RecipientEntry entry, PhotoManagerCallback callback) { + final Uri photoThumbnailUri = entry.getPhotoThumbnailUri(); + if (photoThumbnailUri != null) { + final byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri); + if (photoBytes != null) { + entry.setPhotoBytes(photoBytes); + if (callback != null) { + callback.onPhotoBytesPopulated(); + } + } else { + if (DEBUG) { + Log.d(TAG, "No photo cache for " + entry.getDisplayName() + + ". Fetch one asynchronously"); + } + fetchPhotoAsync(entry, photoThumbnailUri, callback); + } + } else if (callback != null) { + callback.onPhotoBytesAsyncLoadFailed(); + } + } + + private void fetchPhotoAsync(final RecipientEntry entry, final Uri photoThumbnailUri, + final PhotoManagerCallback callback) { + final AsyncTask photoLoadTask = new AsyncTask() { + @Override + protected byte[] doInBackground(Void... params) { + // First try running a query. Images for local contacts are + // loaded by sending a query to the ContactsProvider. + final Cursor photoCursor = mContentResolver.query( + photoThumbnailUri, PhotoQuery.PROJECTION, null, null, null); + if (photoCursor != null) { + try { + if (photoCursor.moveToFirst()) { + return photoCursor.getBlob(PhotoQuery.PHOTO); + } + } finally { + photoCursor.close(); + } + } else { + // If the query fails, try streaming the URI directly. + // For remote directory images, this URI resolves to the + // directory provider and the images are loaded by sending + // an openFile call to the provider. + try { + InputStream is = mContentResolver.openInputStream( + photoThumbnailUri); + if (is != null) { + byte[] buffer = new byte[BUFFER_SIZE]; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + int size; + while ((size = is.read(buffer)) != -1) { + baos.write(buffer, 0, size); + } + } finally { + is.close(); + } + return baos.toByteArray(); + } + } catch (IOException ex) { + // ignore + } + } + return null; + } + + @Override + protected void onPostExecute(final byte[] photoBytes) { + entry.setPhotoBytes(photoBytes); + if (photoBytes != null) { + mPhotoCacheMap.put(photoThumbnailUri, photoBytes); + if (callback != null) { + callback.onPhotoBytesAsynchronouslyPopulated(); + } + } else if (callback != null) { + callback.onPhotoBytesAsyncLoadFailed(); + } + } + }; + photoLoadTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); + } +} diff --git a/modules/chips/src/com/android/ex/chips/DropdownChipLayouter.java b/modules/chips/src/com/android/ex/chips/DropdownChipLayouter.java new file mode 100644 index 0000000..77415c1 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/DropdownChipLayouter.java @@ -0,0 +1,569 @@ +package com.android.ex.chips; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.net.Uri; +import androidx.annotation.DrawableRes; +import androidx.annotation.IdRes; +import androidx.annotation.LayoutRes; +import androidx.annotation.Nullable; +import androidx.core.view.MarginLayoutParamsCompat; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.text.util.Rfc822Tokenizer; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.ex.chips.Queries.Query; + +/** + * A class that inflates and binds the views in the dropdown list from + * RecipientEditTextView. + */ +public class DropdownChipLayouter { + /** + * The type of adapter that is requesting a chip layout. + */ + public enum AdapterType { + BASE_RECIPIENT, + RECIPIENT_ALTERNATES, + SINGLE_RECIPIENT + } + + public interface ChipDeleteListener { + void onChipDelete(); + } + + /** + * Listener that handles the dismisses of the entries of the + * {@link RecipientEntry#ENTRY_TYPE_PERMISSION_REQUEST} type. + */ + public interface PermissionRequestDismissedListener { + + /** + * Callback that occurs when user dismisses the item that asks user to grant permissions to + * the app. + */ + void onPermissionRequestDismissed(); + } + + private final LayoutInflater mInflater; + private final Context mContext; + private ChipDeleteListener mDeleteListener; + private PermissionRequestDismissedListener mPermissionRequestDismissedListener; + private Query mQuery; + private int mAutocompleteDividerMarginStart; + + public DropdownChipLayouter(LayoutInflater inflater, Context context) { + mInflater = inflater; + mContext = context; + mAutocompleteDividerMarginStart = + context.getResources().getDimensionPixelOffset(R.dimen.chip_wrapper_start_padding); + } + + public void setQuery(Query query) { + mQuery = query; + } + + public void setDeleteListener(ChipDeleteListener listener) { + mDeleteListener = listener; + } + + public void setPermissionRequestDismissedListener(PermissionRequestDismissedListener listener) { + mPermissionRequestDismissedListener = listener; + } + + public void setAutocompleteDividerMarginStart(int autocompleteDividerMarginStart) { + mAutocompleteDividerMarginStart = autocompleteDividerMarginStart; + } + + /** + * Layouts and binds recipient information to the view. If convertView is null, inflates a new + * view with getItemLaytout(). + * + * @param convertView The view to bind information to. + * @param parent The parent to bind the view to if we inflate a new view. + * @param entry The recipient entry to get information from. + * @param position The position in the list. + * @param type The adapter type that is requesting the bind. + * @param constraint The constraint typed in the auto complete view. + * + * @return A view ready to be shown in the drop down list. + */ + public View bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position, + AdapterType type, String constraint) { + return bindView(convertView, parent, entry, position, type, constraint, null); + } + + /** + * See {@link #bindView(View, ViewGroup, RecipientEntry, int, AdapterType, String)} + * @param deleteDrawable a {@link android.graphics.drawable.StateListDrawable} representing + * the delete icon. android.R.attr.state_activated should map to the delete icon, and the + * default state can map to a drawable of your choice (or null for no drawable). + */ + public View bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position, + AdapterType type, String constraint, StateListDrawable deleteDrawable) { + // Default to show all the information + CharSequence[] styledResults = getStyledResults(constraint, entry); + CharSequence displayName = styledResults[0]; + CharSequence destination = styledResults[1]; + boolean showImage = true; + CharSequence destinationType = getDestinationType(entry); + + final View itemView = reuseOrInflateView(convertView, parent, type); + + final ViewHolder viewHolder = new ViewHolder(itemView); + + // Hide some information depending on the adapter type. + switch (type) { + case BASE_RECIPIENT: + if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, destination)) { + displayName = destination; + + // We only show the destination for secondary entries, so clear it only for the + // first level. + if (entry.isFirstLevel()) { + destination = null; + } + } + + if (!entry.isFirstLevel()) { + displayName = null; + showImage = false; + } + + // For BASE_RECIPIENT set all top dividers except for the first one to be GONE. + if (viewHolder.topDivider != null) { + viewHolder.topDivider.setVisibility(position == 0 ? View.VISIBLE : View.GONE); + MarginLayoutParamsCompat.setMarginStart( + (MarginLayoutParams) viewHolder.topDivider.getLayoutParams(), + mAutocompleteDividerMarginStart); + } + if (viewHolder.bottomDivider != null) { + MarginLayoutParamsCompat.setMarginStart( + (MarginLayoutParams) viewHolder.bottomDivider.getLayoutParams(), + mAutocompleteDividerMarginStart); + } + break; + case RECIPIENT_ALTERNATES: + if (position != 0) { + displayName = null; + showImage = false; + } + break; + case SINGLE_RECIPIENT: + if (!PhoneUtil.isPhoneNumber(entry.getDestination())) { + destination = Rfc822Tokenizer.tokenize(entry.getDestination())[0].getAddress(); + } + destinationType = null; + } + + // Bind the information to the view + bindTextToView(displayName, viewHolder.displayNameView); + bindTextToView(destination, viewHolder.destinationView); + bindTextToView(destinationType, viewHolder.destinationTypeView); + bindIconToView(showImage, entry, viewHolder.imageView, type); + bindDrawableToDeleteView(deleteDrawable, entry.getDisplayName(), viewHolder.deleteView); + bindIndicatorToView( + entry.getIndicatorIconId(), entry.getIndicatorText(), viewHolder.indicatorView); + bindPermissionRequestDismissView(viewHolder.permissionRequestDismissView); + + // Hide some view groups depending on the entry type + final int entryType = entry.getEntryType(); + if (entryType == RecipientEntry.ENTRY_TYPE_PERSON) { + setViewVisibility(viewHolder.personViewGroup, View.VISIBLE); + setViewVisibility(viewHolder.permissionViewGroup, View.GONE); + setViewVisibility(viewHolder.permissionBottomDivider, View.GONE); + } else if (entryType == RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST) { + setViewVisibility(viewHolder.personViewGroup, View.GONE); + setViewVisibility(viewHolder.permissionViewGroup, View.VISIBLE); + setViewVisibility(viewHolder.permissionBottomDivider, View.VISIBLE); + } + + return itemView; + } + + /** + * Returns a new view with {@link #getItemLayoutResId(AdapterType)}. + */ + public View newView(AdapterType type) { + return mInflater.inflate(getItemLayoutResId(type), null); + } + + /** + * Returns the same view, or inflates a new one if the given view was null. + */ + protected View reuseOrInflateView(View convertView, ViewGroup parent, AdapterType type) { + int itemLayout = getItemLayoutResId(type); + switch (type) { + case BASE_RECIPIENT: + case RECIPIENT_ALTERNATES: + break; + case SINGLE_RECIPIENT: + itemLayout = getAlternateItemLayoutResId(type); + break; + } + return convertView != null ? convertView : mInflater.inflate(itemLayout, parent, false); + } + + /** + * Binds the text to the given text view. If the text was null, hides the text view. + */ + protected void bindTextToView(CharSequence text, TextView view) { + if (view == null) { + return; + } + + if (text != null) { + view.setText(text); + view.setVisibility(View.VISIBLE); + } else { + view.setVisibility(View.GONE); + } + } + + /** + * Binds the avatar icon to the image view. If we don't want to show the image, hides the + * image view. + */ + protected void bindIconToView(boolean showImage, RecipientEntry entry, ImageView view, + AdapterType type) { + if (view == null) { + return; + } + + if (showImage) { + switch (type) { + case BASE_RECIPIENT: + byte[] photoBytes = entry.getPhotoBytes(); + if (photoBytes != null && photoBytes.length > 0) { + final Bitmap photo = BitmapFactory.decodeByteArray(photoBytes, 0, + photoBytes.length); + view.setImageBitmap(photo); + } else { + view.setImageResource(getDefaultPhotoResId()); + } + break; + case RECIPIENT_ALTERNATES: + Uri thumbnailUri = entry.getPhotoThumbnailUri(); + if (thumbnailUri != null) { + // TODO: see if this needs to be done outside the main thread + // as it may be too slow to get immediately. + view.setImageURI(thumbnailUri); + } else { + view.setImageResource(getDefaultPhotoResId()); + } + break; + case SINGLE_RECIPIENT: + default: + break; + } + view.setVisibility(View.VISIBLE); + } else { + view.setVisibility(View.GONE); + } + } + + protected void bindDrawableToDeleteView(final StateListDrawable drawable, String recipient, + ImageView view) { + if (view == null) { + return; + } + if (drawable == null) { + view.setVisibility(View.GONE); + } else { + final Resources res = mContext.getResources(); + view.setImageDrawable(drawable); + view.setContentDescription( + res.getString(R.string.dropdown_delete_button_desc, recipient)); + if (mDeleteListener != null) { + view.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (drawable.getCurrent() != null) { + mDeleteListener.onChipDelete(); + } + } + }); + } + } + } + + protected void bindIndicatorToView( + @DrawableRes int indicatorIconId, String indicatorText, TextView view) { + if (view != null) { + if (indicatorText != null || indicatorIconId != 0) { + view.setText(indicatorText); + view.setVisibility(View.VISIBLE); + final Drawable indicatorIcon; + if (indicatorIconId != 0) { + indicatorIcon = mContext.getDrawable(indicatorIconId).mutate(); + indicatorIcon.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN); + } else { + indicatorIcon = null; + } + view.setCompoundDrawablesRelativeWithIntrinsicBounds( + indicatorIcon, null, null, null); + } else { + view.setVisibility(View.GONE); + } + } + } + + protected void bindPermissionRequestDismissView(ImageView view) { + if (view == null) { + return; + } + view.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if (mPermissionRequestDismissedListener != null) { + mPermissionRequestDismissedListener.onPermissionRequestDismissed(); + } + } + }); + } + + protected void setViewVisibility(View view, int visibility) { + if (view != null) { + view.setVisibility(visibility); + } + } + + protected CharSequence getDestinationType(RecipientEntry entry) { + return mQuery.getTypeLabel(mContext.getResources(), entry.getDestinationType(), + entry.getDestinationLabel()).toString().toUpperCase(); + } + + /** + * Returns a layout id for each item inside auto-complete list. + * + * Each View must contain two TextViews (for display name and destination) and one ImageView + * (for photo). Ids for those should be available via {@link #getDisplayNameResId()}, + * {@link #getDestinationResId()}, and {@link #getPhotoResId()}. + */ + protected @LayoutRes int getItemLayoutResId(AdapterType type) { + switch (type) { + case BASE_RECIPIENT: + return R.layout.chips_autocomplete_recipient_dropdown_item; + case RECIPIENT_ALTERNATES: + return R.layout.chips_recipient_dropdown_item; + default: + return R.layout.chips_recipient_dropdown_item; + } + } + + /** + * Returns a layout id for each item inside alternate auto-complete list. + * + * Each View must contain two TextViews (for display name and destination) and one ImageView + * (for photo). Ids for those should be available via {@link #getDisplayNameResId()}, + * {@link #getDestinationResId()}, and {@link #getPhotoResId()}. + */ + protected @LayoutRes int getAlternateItemLayoutResId(AdapterType type) { + switch (type) { + case BASE_RECIPIENT: + return R.layout.chips_autocomplete_recipient_dropdown_item; + case RECIPIENT_ALTERNATES: + return R.layout.chips_recipient_dropdown_item; + default: + return R.layout.chips_recipient_dropdown_item; + } + } + + /** + * Returns a resource ID representing an image which should be shown when ther's no relevant + * photo is available. + */ + protected @DrawableRes int getDefaultPhotoResId() { + return R.drawable.ic_contact_picture; + } + + /** + * Returns an id for the ViewGroup in an item View that contains the person ui elements. + */ + protected @IdRes int getPersonGroupResId() { + return R.id.chip_person_wrapper; + } + + /** + * Returns an id for TextView in an item View for showing a display name. By default + * {@link android.R.id#title} is returned. + */ + protected @IdRes int getDisplayNameResId() { + return android.R.id.title; + } + + /** + * Returns an id for TextView in an item View for showing a destination + * (an email address or a phone number). + * By default {@link android.R.id#text1} is returned. + */ + protected @IdRes int getDestinationResId() { + return android.R.id.text1; + } + + /** + * Returns an id for TextView in an item View for showing the type of the destination. + * By default {@link android.R.id#text2} is returned. + */ + protected @IdRes int getDestinationTypeResId() { + return android.R.id.text2; + } + + /** + * Returns an id for ImageView in an item View for showing photo image for a person. In default + * {@link android.R.id#icon} is returned. + */ + protected @IdRes int getPhotoResId() { + return android.R.id.icon; + } + + /** + * Returns an id for ImageView in an item View for showing the delete button. In default + * {@link android.R.id#icon1} is returned. + */ + protected @IdRes int getDeleteResId() { return android.R.id.icon1; } + + /** + * Returns an id for the ViewGroup in an item View that contains the request permission ui + * elements. + */ + protected @IdRes int getPermissionGroupResId() { + return R.id.chip_permission_wrapper; + } + + /** + * Returns an id for ImageView in an item View for dismissing the permission request. In default + * {@link android.R.id#icon2} is returned. + */ + protected @IdRes int getPermissionRequestDismissResId() { + return android.R.id.icon2; + } + + /** + * Given a constraint and a recipient entry, tries to find the constraint in the name and + * destination in the recipient entry. A foreground font color style will be applied to the + * section that matches the constraint. As soon as a match has been found, no further matches + * are attempted. + * + * @param constraint A string that we will attempt to find within the results. + * @param entry The recipient entry to style results for. + * + * @return An array of CharSequences, the length determined by the length of results. Each + * CharSequence will either be a styled SpannableString or just the input String. + */ + protected CharSequence[] getStyledResults(@Nullable String constraint, RecipientEntry entry) { + return getStyledResults(constraint, entry.getDisplayName(), entry.getDestination()); + } + + /** + * Given a constraint and results, tries to find the constraint in those results, one at a time. + * A foreground font color style will be applied to the section that matches the constraint. As + * soon as a match has been found, no further matches are attempted. + * + * @param constraint A string that we will attempt to find within the results. + * @param results Strings that may contain the constraint. The order given is the order used to + * search for the constraint. + * + * @return An array of CharSequences, the length determined by the length of results. Each + * CharSequence will either be a styled SpannableString or just the input String. + */ + protected CharSequence[] getStyledResults(@Nullable String constraint, String... results) { + if (isAllWhitespace(constraint)) { + return results; + } + + CharSequence[] styledResults = new CharSequence[results.length]; + boolean foundMatch = false; + for (int i = 0; i < results.length; i++) { + String result = results[i]; + if (result == null) { + continue; + } + + if (!foundMatch) { + int index = result.toLowerCase().indexOf(constraint.toLowerCase()); + if (index != -1) { + SpannableStringBuilder styled = SpannableStringBuilder.valueOf(result); + ForegroundColorSpan highlightSpan = + new ForegroundColorSpan(mContext.getResources().getColor( + R.color.chips_dropdown_text_highlighted)); + styled.setSpan(highlightSpan, + index, index + constraint.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + styledResults[i] = styled; + foundMatch = true; + continue; + } + } + styledResults[i] = result; + } + return styledResults; + } + + private static boolean isAllWhitespace(@Nullable String string) { + if (TextUtils.isEmpty(string)) { + return true; + } + + for (int i = 0; i < string.length(); ++i) { + if (!Character.isWhitespace(string.charAt(i))) { + return false; + } + } + + return true; + } + + /** + * A holder class the view. Uses the getters in DropdownChipLayouter to find the id of the + * corresponding views. + */ + protected class ViewHolder { + public final ViewGroup personViewGroup; + public final TextView displayNameView; + public final TextView destinationView; + public final TextView destinationTypeView; + public final TextView indicatorView; + public final ImageView imageView; + public final ImageView deleteView; + public final View topDivider; + public final View bottomDivider; + public final View permissionBottomDivider; + + public final ViewGroup permissionViewGroup; + public final ImageView permissionRequestDismissView; + + public ViewHolder(View view) { + personViewGroup = (ViewGroup) view.findViewById(getPersonGroupResId()); + displayNameView = (TextView) view.findViewById(getDisplayNameResId()); + destinationView = (TextView) view.findViewById(getDestinationResId()); + destinationTypeView = (TextView) view.findViewById(getDestinationTypeResId()); + imageView = (ImageView) view.findViewById(getPhotoResId()); + deleteView = (ImageView) view.findViewById(getDeleteResId()); + topDivider = view.findViewById(R.id.chip_autocomplete_top_divider); + + bottomDivider = view.findViewById(R.id.chip_autocomplete_bottom_divider); + permissionBottomDivider = view.findViewById(R.id.chip_permission_bottom_divider); + + indicatorView = (TextView) view.findViewById(R.id.chip_indicator_text); + + permissionViewGroup = (ViewGroup) view.findViewById(getPermissionGroupResId()); + permissionRequestDismissView = + (ImageView) view.findViewById(getPermissionRequestDismissResId()); + } + } +} diff --git a/modules/chips/src/com/android/ex/chips/PhoneUtil.java b/modules/chips/src/com/android/ex/chips/PhoneUtil.java new file mode 100644 index 0000000..512d275 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/PhoneUtil.java @@ -0,0 +1,52 @@ +/* + + * 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.ex.chips; + +import android.text.TextUtils; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A utility class for chips using phone numbers. + */ +public class PhoneUtil { + + // This pattern comes from android.util.Patterns. It has been tweaked to handle a "1" before + // parens, so numbers such as "1 (425) 222-2342" match. + private static final Pattern PHONE_PATTERN + = Pattern.compile( // sdd = space, dot, or dash + "(\\+[0-9]+[\\- \\.]*)?" // +* + + "(1?[ ]*\\([0-9]+\\)[\\- \\.]*)?" // 1()* + + "([0-9][0-9\\- \\.][0-9\\- \\.]+[0-9])"); // + + + /** + * Returns true if the string is a phone number. + */ + public static boolean isPhoneNumber(String number) { + // TODO: replace this function with libphonenumber's isPossibleNumber (see + // PhoneNumberUtil). One complication is that it requires the sender's region which + // comes from the CurrentCountryIso. For now, let's just do this simple match. + if (TextUtils.isEmpty(number)) { + return false; + } + + Matcher match = PHONE_PATTERN.matcher(number); + return match.matches(); + } +} diff --git a/modules/chips/src/com/android/ex/chips/PhotoManager.java b/modules/chips/src/com/android/ex/chips/PhotoManager.java new file mode 100644 index 0000000..66a64c2 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/PhotoManager.java @@ -0,0 +1,46 @@ +/* + * 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.ex.chips; + +/** + * Used by the {@link com.android.ex.chips.BaseRecipientAdapter} to handle fetching + * photos from external sources and caching them for faster lookup later. + */ +public interface PhotoManager { + + /** The number of photos cached in this Adapter. */ + public static final int PHOTO_CACHE_SIZE = 20; + + /** + * Sets the {@link com.android.ex.chips.RecipientEntry}'s photo bytes. If the photo bytes + * are cached, this action happens immediately. Otherwise, the work to fetch the photo + * bytes is performed asynchronously before setting the value on the UI thread.

+ * + * If the photo bytes were fetched asynchronously, + * {@link PhotoManagerCallback#onPhotoBytesAsynchronouslyPopulated()} is called. This + * method is not called if the photo bytes have been cached previously (because no + * asynchronous work was performed). In that case, + * {@link PhotoManagerCallback#onPhotoBytesPopulated()} is called. + */ + void populatePhotoBytesAsync(RecipientEntry entry, PhotoManagerCallback callback); + + interface PhotoManagerCallback { + void onPhotoBytesPopulated(); + void onPhotoBytesAsynchronouslyPopulated(); + void onPhotoBytesAsyncLoadFailed(); + } +} diff --git a/modules/chips/src/com/android/ex/chips/Queries.java b/modules/chips/src/com/android/ex/chips/Queries.java new file mode 100644 index 0000000..1e66b96 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/Queries.java @@ -0,0 +1,107 @@ +/* + * 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.ex.chips; + +import android.content.res.Resources; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; + +/** + * Phone and Email queries for supporting Chips UI. + */ +/* package */ class Queries { + + public static final Query PHONE = new Query(new String[] { + Contacts.DISPLAY_NAME, // 0 + Phone.NUMBER, // 1 + Phone.TYPE, // 2 + Phone.LABEL, // 3 + Phone.CONTACT_ID, // 4 + Phone._ID, // 5 + Contacts.PHOTO_THUMBNAIL_URI, // 6 + Contacts.DISPLAY_NAME_SOURCE, // 7 + Contacts.LOOKUP_KEY, // 8 + ContactsContract.CommonDataKinds.Email.MIMETYPE // 9 + }, Phone.CONTENT_FILTER_URI, Phone.CONTENT_URI) { + + @Override + public CharSequence getTypeLabel(Resources res, int type, CharSequence label) { + return Phone.getTypeLabel(res, type, label); + } + + }; + + public static final Query EMAIL = new Query(new String[]{ + Contacts.DISPLAY_NAME, // 0 + Email.DATA, // 1 + Email.TYPE, // 2 + Email.LABEL, // 3 + Email.CONTACT_ID, // 4 + Email._ID, // 5 + Contacts.PHOTO_THUMBNAIL_URI, // 6 + Contacts.DISPLAY_NAME_SOURCE, // 7 + Contacts.LOOKUP_KEY, // 8 + ContactsContract.CommonDataKinds.Email.MIMETYPE // 9 + }, Email.CONTENT_FILTER_URI, Email.CONTENT_URI) { + + @Override + public CharSequence getTypeLabel(Resources res, int type, CharSequence label) { + return Email.getTypeLabel(res, type, label); + } + + }; + + static abstract class Query { + private final String[] mProjection; + private final Uri mContentFilterUri; + private final Uri mContentUri; + + public static final int NAME = 0; // String + public static final int DESTINATION = 1; // String + public static final int DESTINATION_TYPE = 2; // int + public static final int DESTINATION_LABEL = 3; // String + public static final int CONTACT_ID = 4; // long + public static final int DATA_ID = 5; // long + public static final int PHOTO_THUMBNAIL_URI = 6; // String + public static final int DISPLAY_NAME_SOURCE = 7; // int + public static final int LOOKUP_KEY = 8; // String + public static final int MIME_TYPE = 9; // String + + public Query(String[] projection, Uri contentFilter, Uri content) { + mProjection = projection; + mContentFilterUri = contentFilter; + mContentUri = content; + } + + public String[] getProjection() { + return mProjection; + } + + public Uri getContentFilterUri() { + return mContentFilterUri; + } + + public Uri getContentUri() { + return mContentUri; + } + + public abstract CharSequence getTypeLabel(Resources res, int type, CharSequence label); + } +} diff --git a/modules/chips/src/com/android/ex/chips/RecipientAlternatesAdapter.java b/modules/chips/src/com/android/ex/chips/RecipientAlternatesAdapter.java new file mode 100644 index 0000000..cc19700 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/RecipientAlternatesAdapter.java @@ -0,0 +1,621 @@ +/* + * 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.ex.chips; + +import android.accounts.Account; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.graphics.drawable.StateListDrawable; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.text.TextUtils; +import android.text.util.Rfc822Token; +import android.text.util.Rfc822Tokenizer; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CursorAdapter; + +import com.android.ex.chips.BaseRecipientAdapter.DirectoryListQuery; +import com.android.ex.chips.BaseRecipientAdapter.DirectorySearchParams; +import com.android.ex.chips.DropdownChipLayouter.AdapterType; +import com.android.ex.chips.Queries.Query; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * RecipientAlternatesAdapter backs the RecipientEditTextView for managing contacts + * queried by email or by phone number. + */ +public class RecipientAlternatesAdapter extends CursorAdapter { + public static final int MAX_LOOKUPS = 50; + + private final long mCurrentId; + + private int mCheckedItemPosition = -1; + + private OnCheckedItemChangedListener mCheckedItemChangedListener; + + private static final String TAG = "RecipAlternates"; + + public static final int QUERY_TYPE_EMAIL = 0; + public static final int QUERY_TYPE_PHONE = 1; + private final Long mDirectoryId; + private DropdownChipLayouter mDropdownChipLayouter; + private final StateListDrawable mDeleteDrawable; + + private static final Map sCorrectedPhotoUris = new HashMap(); + + public interface RecipientMatchCallback { + public void matchesFound(Map results); + /** + * Called with all addresses that could not be resolved to valid recipients. + */ + public void matchesNotFound(Set unfoundAddresses); + } + + public static void getMatchingRecipients(Context context, BaseRecipientAdapter adapter, + ArrayList inAddresses, Account account, RecipientMatchCallback callback, + ChipsUtil.PermissionsCheckListener permissionsCheckListener) { + getMatchingRecipients(context, adapter, inAddresses, QUERY_TYPE_EMAIL, account, callback, + permissionsCheckListener); + } + + /** + * Get a HashMap of address to RecipientEntry that contains all contact + * information for a contact with the provided address, if one exists. This + * may block the UI, so run it in an async task. + * + * @param context Context. + * @param inAddresses Array of addresses on which to perform the lookup. + * @param callback RecipientMatchCallback called when a match or matches are found. + */ + public static void getMatchingRecipients(Context context, BaseRecipientAdapter adapter, + ArrayList inAddresses, int addressType, Account account, + RecipientMatchCallback callback, + ChipsUtil.PermissionsCheckListener permissionsCheckListener) { + Queries.Query query; + if (addressType == QUERY_TYPE_EMAIL) { + query = Queries.EMAIL; + } else { + query = Queries.PHONE; + } + int addressesSize = Math.min(MAX_LOOKUPS, inAddresses.size()); + HashSet addresses = new HashSet(); + StringBuilder bindString = new StringBuilder(); + // Create the "?" string and set up arguments. + for (int i = 0; i < addressesSize; i++) { + Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase()); + addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i)); + bindString.append("?"); + if (i < addressesSize - 1) { + bindString.append(","); + } + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Doing reverse lookup for " + addresses.toString()); + } + + String[] addressArray = new String[addresses.size()]; + addresses.toArray(addressArray); + HashMap recipientEntries = null; + Cursor c = null; + + try { + if (ChipsUtil.hasPermissions(context, permissionsCheckListener)) { + c = context.getContentResolver().query( + query.getContentUri(), + query.getProjection(), + query.getProjection()[Queries.Query.DESTINATION] + " IN (" + + bindString.toString() + ")", addressArray, null); + } + recipientEntries = processContactEntries(c, null /* directoryId */); + callback.matchesFound(recipientEntries); + } finally { + if (c != null) { + c.close(); + } + } + + final Set matchesNotFound = new HashSet(); + + getMatchingRecipientsFromDirectoryQueries(context, recipientEntries, + addresses, account, matchesNotFound, query, callback, permissionsCheckListener); + + getMatchingRecipientsFromExtensionMatcher(adapter, matchesNotFound, callback); + } + + public static void getMatchingRecipientsFromDirectoryQueries(Context context, + Map recipientEntries, Set addresses, + Account account, Set matchesNotFound, + RecipientMatchCallback callback, + ChipsUtil.PermissionsCheckListener permissionsCheckListener) { + getMatchingRecipientsFromDirectoryQueries( + context, recipientEntries, addresses, account, + matchesNotFound, Queries.EMAIL, callback, permissionsCheckListener); + } + + private static void getMatchingRecipientsFromDirectoryQueries(Context context, + Map recipientEntries, Set addresses, + Account account, Set matchesNotFound, Queries.Query query, + RecipientMatchCallback callback, + ChipsUtil.PermissionsCheckListener permissionsCheckListener) { + // See if any entries did not resolve; if so, we need to check other + // directories + + if (recipientEntries.size() < addresses.size()) { + // Run a directory query for each unmatched recipient. + HashSet unresolvedAddresses = new HashSet(); + for (String address : addresses) { + if (!recipientEntries.containsKey(address)) { + unresolvedAddresses.add(address); + } + } + matchesNotFound.addAll(unresolvedAddresses); + + final List paramsList; + Cursor directoryCursor = null; + try { + if (ChipsUtil.hasPermissions(context, permissionsCheckListener)) { + directoryCursor = context.getContentResolver().query( + DirectoryListQuery.URI, DirectoryListQuery.PROJECTION, + null, null, null); + } + if (directoryCursor == null) { + return; + } + paramsList = BaseRecipientAdapter.setupOtherDirectories( + context, directoryCursor, account); + } finally { + if (directoryCursor != null) { + directoryCursor.close(); + } + } + + if (paramsList != null) { + Cursor directoryContactsCursor = null; + for (String unresolvedAddress : unresolvedAddresses) { + for (int i = 0; i < paramsList.size(); i++) { + final long directoryId = paramsList.get(i).directoryId; + try { + directoryContactsCursor = doQuery(unresolvedAddress, 1 /* limit */, + directoryId, account, context, query, permissionsCheckListener); + if (directoryContactsCursor != null + && directoryContactsCursor.getCount() != 0) { + // We found the directory with at least one contact + final Map entries = + processContactEntries(directoryContactsCursor, directoryId); + + for (final String address : entries.keySet()) { + matchesNotFound.remove(address); + } + + callback.matchesFound(entries); + break; + } + } finally { + if (directoryContactsCursor != null) { + directoryContactsCursor.close(); + directoryContactsCursor = null; + } + } + } + } + } + } + } + + public static void getMatchingRecipientsFromExtensionMatcher(BaseRecipientAdapter adapter, + Set matchesNotFound, RecipientMatchCallback callback) { + // If no matches found in contact provider or the directories, try the extension + // matcher. + // todo (aalbert): This whole method needs to be in the adapter? + if (adapter != null) { + final Map entries = + adapter.getMatchingRecipients(matchesNotFound); + if (entries != null && entries.size() > 0) { + callback.matchesFound(entries); + for (final String address : entries.keySet()) { + matchesNotFound.remove(address); + } + } + } + callback.matchesNotFound(matchesNotFound); + } + + private static HashMap processContactEntries(Cursor c, + Long directoryId) { + HashMap recipientEntries = new HashMap(); + if (c != null && c.moveToFirst()) { + do { + String address = c.getString(Queries.Query.DESTINATION); + + final RecipientEntry newRecipientEntry = RecipientEntry.constructTopLevelEntry( + c.getString(Queries.Query.NAME), + c.getInt(Queries.Query.DISPLAY_NAME_SOURCE), + c.getString(Queries.Query.DESTINATION), + c.getInt(Queries.Query.DESTINATION_TYPE), + c.getString(Queries.Query.DESTINATION_LABEL), + c.getLong(Queries.Query.CONTACT_ID), + directoryId, + c.getLong(Queries.Query.DATA_ID), + c.getString(Queries.Query.PHOTO_THUMBNAIL_URI), + true, + c.getString(Queries.Query.LOOKUP_KEY)); + + /* + * In certain situations, we may have two results for one address, where one of the + * results is just the email address, and the other has a name and photo, so we want + * to use the better one. + */ + final RecipientEntry recipientEntry = + getBetterRecipient(recipientEntries.get(address), newRecipientEntry); + + recipientEntries.put(address, recipientEntry); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Received reverse look up information for " + address + + " RESULTS: " + + " NAME : " + c.getString(Queries.Query.NAME) + + " CONTACT ID : " + c.getLong(Queries.Query.CONTACT_ID) + + " ADDRESS :" + c.getString(Queries.Query.DESTINATION)); + } + } while (c.moveToNext()); + } + return recipientEntries; + } + + /** + * Given two {@link RecipientEntry}s for the same email address, this will return the one that + * contains more complete information for display purposes. Defaults to entry2 if + * no significant differences are found. + */ + static RecipientEntry getBetterRecipient(final RecipientEntry entry1, + final RecipientEntry entry2) { + // If only one has passed in, use it + if (entry2 == null) { + return entry1; + } + + if (entry1 == null) { + return entry2; + } + + // If only one has a display name, use it + if (!TextUtils.isEmpty(entry1.getDisplayName()) + && TextUtils.isEmpty(entry2.getDisplayName())) { + return entry1; + } + + if (!TextUtils.isEmpty(entry2.getDisplayName()) + && TextUtils.isEmpty(entry1.getDisplayName())) { + return entry2; + } + + // If only one has a display name that is not the same as the destination, use it + if (!TextUtils.equals(entry1.getDisplayName(), entry1.getDestination()) + && TextUtils.equals(entry2.getDisplayName(), entry2.getDestination())) { + return entry1; + } + + if (!TextUtils.equals(entry2.getDisplayName(), entry2.getDestination()) + && TextUtils.equals(entry1.getDisplayName(), entry1.getDestination())) { + return entry2; + } + + // If only one has a photo, use it + if ((entry1.getPhotoThumbnailUri() != null || entry1.getPhotoBytes() != null) + && (entry2.getPhotoThumbnailUri() == null && entry2.getPhotoBytes() == null)) { + return entry1; + } + + if ((entry2.getPhotoThumbnailUri() != null || entry2.getPhotoBytes() != null) + && (entry1.getPhotoThumbnailUri() == null && entry1.getPhotoBytes() == null)) { + return entry2; + } + + // Go with the second option as a default + return entry2; + } + + private static Cursor doQuery(CharSequence constraint, int limit, Long directoryId, + Account account, Context context, Query query, + ChipsUtil.PermissionsCheckListener permissionsCheckListener) { + if (!ChipsUtil.hasPermissions(context, permissionsCheckListener)) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Not doing query because we don't have required permissions."); + } + return null; + } + final Uri.Builder builder = query + .getContentFilterUri() + .buildUpon() + .appendPath(constraint.toString()) + .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, + String.valueOf(limit + BaseRecipientAdapter.ALLOWANCE_FOR_DUPLICATES)); + if (directoryId != null) { + builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, + String.valueOf(directoryId)); + } + if (account != null) { + builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_NAME, account.name); + builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_TYPE, account.type); + } + return context.getContentResolver() + .query(builder.build(), query.getProjection(), null, null, null); + } + + public RecipientAlternatesAdapter(Context context, long contactId, Long directoryId, + String lookupKey, long currentId, int queryMode, OnCheckedItemChangedListener listener, + DropdownChipLayouter dropdownChipLayouter, + ChipsUtil.PermissionsCheckListener permissionsCheckListener) { + this(context, contactId, directoryId, lookupKey, currentId, queryMode, listener, + dropdownChipLayouter, null, permissionsCheckListener); + } + + public RecipientAlternatesAdapter(Context context, long contactId, Long directoryId, + String lookupKey, long currentId, int queryMode, OnCheckedItemChangedListener listener, + DropdownChipLayouter dropdownChipLayouter, StateListDrawable deleteDrawable, + ChipsUtil.PermissionsCheckListener permissionsCheckListener) { + super(context, + getCursorForConstruction(context, contactId, directoryId, lookupKey, queryMode, + permissionsCheckListener), + 0); + mCurrentId = currentId; + mDirectoryId = directoryId; + mCheckedItemChangedListener = listener; + + mDropdownChipLayouter = dropdownChipLayouter; + mDeleteDrawable = deleteDrawable; + } + + private static Cursor getCursorForConstruction(Context context, long contactId, + Long directoryId, String lookupKey, int queryType, + ChipsUtil.PermissionsCheckListener permissionsCheckListener) { + final Uri uri; + final String desiredMimeType; + final String[] projection; + + if (queryType == QUERY_TYPE_EMAIL) { + projection = Queries.EMAIL.getProjection(); + + if (directoryId == null || lookupKey == null) { + uri = Queries.EMAIL.getContentUri(); + desiredMimeType = null; + } else { + uri = Contacts.getLookupUri(contactId, lookupKey) + .buildUpon() + .appendPath(Contacts.Entity.CONTENT_DIRECTORY) + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)) + .build(); + desiredMimeType = ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE; + } + } else { + projection = Queries.PHONE.getProjection(); + + if (lookupKey == null) { + uri = Queries.PHONE.getContentUri(); + desiredMimeType = null; + } else { + uri = Contacts.getLookupUri(contactId, lookupKey) + .buildUpon() + .appendPath(Contacts.Entity.CONTENT_DIRECTORY) + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)) + .build(); + desiredMimeType = ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE; + } + } + + final String selection = new StringBuilder() + .append(projection[Queries.Query.CONTACT_ID]) + .append(" = ?") + .toString(); + final Cursor cursor; + if (ChipsUtil.hasPermissions(context, permissionsCheckListener)) { + cursor = context.getContentResolver().query( + uri, projection, selection, new String[] {String.valueOf(contactId)}, null); + } else { + cursor = new MatrixCursor(projection); + } + + final Cursor resultCursor = removeUndesiredDestinations(cursor, desiredMimeType, lookupKey); + cursor.close(); + + return resultCursor; + } + + /** + * @return a new cursor based on the given cursor with all duplicate destinations removed. + * + * It's only intended to use for the alternate list, so... + * - This method ignores all other fields and dedupe solely on the destination. Normally, + * if a cursor contains multiple contacts and they have the same destination, we'd still want + * to show both. + * - This method creates a MatrixCursor, so all data will be kept in memory. We wouldn't want + * to do this if the original cursor is large, but it's okay here because the alternate list + * won't be that big. + * + * @param desiredMimeType If this is non-null, only entries with this mime type + * will be added to the cursor + * @param lookupKey The lookup key used for this contact if there isn't one in the cursor. This + * should be the same one used in the query that returned the cursor + */ + // Visible for testing + static Cursor removeUndesiredDestinations(final Cursor original, final String desiredMimeType, + final String lookupKey) { + final MatrixCursor result = new MatrixCursor( + original.getColumnNames(), original.getCount()); + final HashSet destinationsSeen = new HashSet(); + + String defaultDisplayName = null; + String defaultPhotoThumbnailUri = null; + int defaultDisplayNameSource = 0; + + // Find some nice defaults in case we need them + original.moveToPosition(-1); + while (original.moveToNext()) { + final String mimeType = original.getString(Query.MIME_TYPE); + + if (ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE.equals( + mimeType)) { + // Store this data + defaultDisplayName = original.getString(Query.NAME); + defaultPhotoThumbnailUri = original.getString(Query.PHOTO_THUMBNAIL_URI); + defaultDisplayNameSource = original.getInt(Query.DISPLAY_NAME_SOURCE); + break; + } + } + + original.moveToPosition(-1); + while (original.moveToNext()) { + if (desiredMimeType != null) { + final String mimeType = original.getString(Query.MIME_TYPE); + if (!desiredMimeType.equals(mimeType)) { + continue; + } + } + final String destination = original.getString(Query.DESTINATION); + if (destinationsSeen.contains(destination)) { + continue; + } + destinationsSeen.add(destination); + + final Object[] row = new Object[] { + original.getString(Query.NAME), + original.getString(Query.DESTINATION), + original.getInt(Query.DESTINATION_TYPE), + original.getString(Query.DESTINATION_LABEL), + original.getLong(Query.CONTACT_ID), + original.getLong(Query.DATA_ID), + original.getString(Query.PHOTO_THUMBNAIL_URI), + original.getInt(Query.DISPLAY_NAME_SOURCE), + original.getString(Query.LOOKUP_KEY), + original.getString(Query.MIME_TYPE) + }; + + if (row[Query.NAME] == null) { + row[Query.NAME] = defaultDisplayName; + } + if (row[Query.PHOTO_THUMBNAIL_URI] == null) { + row[Query.PHOTO_THUMBNAIL_URI] = defaultPhotoThumbnailUri; + } + if ((Integer) row[Query.DISPLAY_NAME_SOURCE] == 0) { + row[Query.DISPLAY_NAME_SOURCE] = defaultDisplayNameSource; + } + if (row[Query.LOOKUP_KEY] == null) { + row[Query.LOOKUP_KEY] = lookupKey; + } + + // Ensure we don't have two '?' like content://.../...?account_name=...?sz=... + final String photoThumbnailUri = (String) row[Query.PHOTO_THUMBNAIL_URI]; + if (photoThumbnailUri != null) { + if (sCorrectedPhotoUris.containsKey(photoThumbnailUri)) { + row[Query.PHOTO_THUMBNAIL_URI] = sCorrectedPhotoUris.get(photoThumbnailUri); + } else if (photoThumbnailUri.indexOf('?') != photoThumbnailUri.lastIndexOf('?')) { + final String[] parts = photoThumbnailUri.split("\\?"); + final StringBuilder correctedUriBuilder = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (i == 1) { + correctedUriBuilder.append("?"); // We only want one of these + } else if (i > 1) { + correctedUriBuilder.append("&"); // And we want these elsewhere + } + correctedUriBuilder.append(parts[i]); + } + + final String correctedUri = correctedUriBuilder.toString(); + sCorrectedPhotoUris.put(photoThumbnailUri, correctedUri); + row[Query.PHOTO_THUMBNAIL_URI] = correctedUri; + } + } + + result.addRow(row); + } + + return result; + } + + @Override + public long getItemId(int position) { + Cursor c = getCursor(); + if (c.moveToPosition(position)) { + c.getLong(Queries.Query.DATA_ID); + } + return -1; + } + + public RecipientEntry getRecipientEntry(int position) { + Cursor c = getCursor(); + c.moveToPosition(position); + return RecipientEntry.constructTopLevelEntry( + c.getString(Queries.Query.NAME), + c.getInt(Queries.Query.DISPLAY_NAME_SOURCE), + c.getString(Queries.Query.DESTINATION), + c.getInt(Queries.Query.DESTINATION_TYPE), + c.getString(Queries.Query.DESTINATION_LABEL), + c.getLong(Queries.Query.CONTACT_ID), + mDirectoryId, + c.getLong(Queries.Query.DATA_ID), + c.getString(Queries.Query.PHOTO_THUMBNAIL_URI), + true, + c.getString(Queries.Query.LOOKUP_KEY)); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + Cursor cursor = getCursor(); + cursor.moveToPosition(position); + if (convertView == null) { + convertView = mDropdownChipLayouter.newView(AdapterType.RECIPIENT_ALTERNATES); + } + if (cursor.getLong(Queries.Query.DATA_ID) == mCurrentId) { + mCheckedItemPosition = position; + if (mCheckedItemChangedListener != null) { + mCheckedItemChangedListener.onCheckedItemChanged(mCheckedItemPosition); + } + } + bindView(convertView, convertView.getContext(), cursor); + return convertView; + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + int position = cursor.getPosition(); + RecipientEntry entry = getRecipientEntry(position); + + mDropdownChipLayouter.bindView(view, null, entry, position, + AdapterType.RECIPIENT_ALTERNATES, null, mDeleteDrawable); + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + return mDropdownChipLayouter.newView(AdapterType.RECIPIENT_ALTERNATES); + } + + /*package*/ static interface OnCheckedItemChangedListener { + public void onCheckedItemChanged(int position); + } +} diff --git a/modules/chips/src/com/android/ex/chips/RecipientEditTextView.java b/modules/chips/src/com/android/ex/chips/RecipientEditTextView.java new file mode 100644 index 0000000..bc55bd4 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/RecipientEditTextView.java @@ -0,0 +1,3516 @@ +/* + + * 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.ex.chips; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.DialogFragment; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader.TileMode; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import android.text.Editable; +import android.text.InputType; +import android.text.Layout; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.text.method.QwertyKeyListener; +import android.text.util.Rfc822Token; +import android.text.util.Rfc822Tokenizer; +import android.util.AttributeSet; +import android.util.Log; +import android.view.ActionMode; +import android.view.ActionMode.Callback; +import android.view.DragEvent; +import android.view.GestureDetector; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.Filterable; +import android.widget.ListAdapter; +import android.widget.ListPopupWindow; +import android.widget.ListView; +import android.widget.MultiAutoCompleteTextView; +import android.widget.PopupWindow; +import android.widget.ScrollView; +import android.widget.TextView; + +import com.android.ex.chips.DropdownChipLayouter.PermissionRequestDismissedListener; +import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback; +import com.android.ex.chips.recipientchip.DrawableRecipientChip; +import com.android.ex.chips.recipientchip.InvisibleRecipientChip; +import com.android.ex.chips.recipientchip.ReplacementDrawableSpan; +import com.android.ex.chips.recipientchip.VisibleRecipientChip; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * RecipientEditTextView is an auto complete text view for use with applications + * that use the new Chips UI for addressing a message to recipients. + */ +public class RecipientEditTextView extends MultiAutoCompleteTextView implements + OnItemClickListener, Callback, RecipientAlternatesAdapter.OnCheckedItemChangedListener, + GestureDetector.OnGestureListener, TextView.OnEditorActionListener, + DropdownChipLayouter.ChipDeleteListener, PermissionRequestDismissedListener { + private static final String TAG = "RecipientEditTextView"; + + private static final char COMMIT_CHAR_COMMA = ','; + private static final char COMMIT_CHAR_SEMICOLON = ';'; + private static final char COMMIT_CHAR_SPACE = ' '; + private static final String SEPARATOR = String.valueOf(COMMIT_CHAR_COMMA) + + String.valueOf(COMMIT_CHAR_SPACE); + + private static final int DISMISS = "dismiss".hashCode(); + private static final long DISMISS_DELAY = 300; + + // TODO: get correct number/ algorithm from with UX. + // Visible for testing. + /*package*/ static final int CHIP_LIMIT = 2; + + private static final int MAX_CHIPS_PARSED = 50; + public static final String STATE_TEXT_VIEW = "savedTextView"; + public static final String STATE_CURRENT_WARNING_TEXT = "savedCurrentWarningText"; + + private int mUnselectedChipTextColor; + private int mUnselectedChipBackgroundColor; + + // Work variables to avoid re-allocation on every typed character. + private final Rect mRect = new Rect(); + private final int[] mCoords = new int[2]; + + // Resources for displaying chips. + private Drawable mChipBackground = null; + private Drawable mChipDelete = null; + private Drawable mInvalidChipBackground; + + // Possible attr overrides + private float mChipHeight; + private float mChipFontSize; + private float mLineSpacingExtra; + private int mChipTextStartPadding; + private int mChipTextEndPadding; + private final int mTextHeight; + private boolean mDisableDelete; + private int mMaxLines; + private int mWarningIconHeight; + + /** + * Enumerator for avatar position. See attr.xml for more details. + * 0 for end, 1 for start. + */ + private int mAvatarPosition; + private static final int AVATAR_POSITION_END = 0; + private static final int AVATAR_POSITION_START = 1; + + private Paint mWorkPaint = new Paint(); + + private Tokenizer mTokenizer; + private Validator mValidator; + private Handler mHandler; + private TextWatcher mTextWatcher; + protected DropdownChipLayouter mDropdownChipLayouter; + + private View mDropdownAnchor = this; + private ListPopupWindow mAlternatesPopup; + private ListPopupWindow mAddressPopup; + private View mAlternatePopupAnchor; + private OnItemClickListener mAlternatesListener; + + private DrawableRecipientChip mSelectedChip; + private Bitmap mDefaultContactPhoto; + private Bitmap mWarningIcon; + private ReplacementDrawableSpan mMoreChip; + private TextView mMoreItem; + + private int mCurrentSuggestionCount; + + // VisibleForTesting + final ArrayList mPendingChips = new ArrayList(); + + private int mPendingChipsCount = 0; + private int mCheckedItem; + private boolean mNoChipMode = false; + private boolean mShouldShrink = true; + private boolean mRequiresShrinkWhenNotGone = false; + + // VisibleForTesting + ArrayList mTemporaryRecipients; + + private ArrayList mHiddenSpans; + + // Chip copy fields. + private GestureDetector mGestureDetector; + + // Obtain the enclosing scroll view, if it exists, so that the view can be + // scrolled to show the last line of chips content. + private ScrollView mScrollView; + private boolean mTriedGettingScrollView; + private boolean mDragEnabled = false; + + private boolean mAttachedToWindow; + + private final Runnable mAddTextWatcher = new Runnable() { + @Override + public void run() { + if (mTextWatcher == null) { + mTextWatcher = new RecipientTextWatcher(); + addTextChangedListener(mTextWatcher); + } + } + }; + + private IndividualReplacementTask mIndividualReplacements; + + private Runnable mHandlePendingChips = new Runnable() { + + @Override + public void run() { + handlePendingChips(); + } + + }; + + private Runnable mDelayedShrink = new Runnable() { + + @Override + public void run() { + shrink(); + } + + }; + + private RecipientEntryItemClickedListener mRecipientEntryItemClickedListener; + + private RecipientChipAddedListener mRecipientChipAddedListener; + private RecipientChipDeletedListener mRecipientChipDeletedListener; + + // A set of recipient addresses that are untrusted because they are outside of the user's + // domain. We will show a warning for these addresses in the recipient chips. + private Set mUntrustedAddresses = new HashSet<>(); + + private String mWarningTextTemplate = ""; + private String mWarningTitle = ""; + // Text of the warning dialog currently being displayed. Empty if no dialog currently displayed. + private String mCurrentWarningText = ""; + + /** + * Sets this recipient edit text view to display warning icons in chips for the given addresses. + * + * @param untrustedAddresses The addresses to display warning icons for. + * @param warningIcon The icon to show for each address. + * @param warningIconHeight Height of the warning icon in + * @param warningTextTemplate Text to display when warning icon is clicked. + * @param warningTitle Title to display for text when warning icon is clicked. + */ + public void setUntrustedAddressWarning( + Set untrustedAddresses, + Bitmap warningIcon, + int warningIconHeight, + String warningTextTemplate, + String warningTitle) { + mUntrustedAddresses = untrustedAddresses; + mWarningIcon = warningIcon; + mWarningIconHeight = warningIconHeight; + mWarningTextTemplate = warningTextTemplate; + mWarningTitle = warningTitle; + } + + public interface RecipientEntryItemClickedListener { + /** + * Callback that occurs whenever an auto-complete suggestion is clicked. + * @param charactersTyped the number of characters typed by the user to provide the + * auto-complete suggestions. + * @param position the position in the dropdown list that the user clicked + */ + void onRecipientEntryItemClicked(int charactersTyped, int position); + } + + private PermissionsRequestItemClickedListener mPermissionsRequestItemClickedListener; + + /** + * Listener for handling clicks on the {@link RecipientEntry} that have + * {@link RecipientEntry#ENTRY_TYPE_PERMISSION_REQUEST} type. + */ + public interface PermissionsRequestItemClickedListener { + + /** + * Callback that occurs when user clicks the item that asks user to grant permissions to + * the app. + * + * @param view View that asks for permission. + */ + void onPermissionsRequestItemClicked(RecipientEditTextView view, String[] permissions); + + /** + * Callback that occurs when user dismisses the item that asks user to grant permissions to + * the app. + */ + void onPermissionRequestDismissed(); + } + + /** + * Listener for handling deletion of chips in the recipient edit text. + */ + public interface RecipientChipDeletedListener { + /** + * Callback that occurs when a chip is deleted. + * @param entry RecipientEntry that contains information about the chip. + */ + void onRecipientChipDeleted(RecipientEntry entry); + } + + /** + * Listener for handling addition of chips in the recipient edit text. + */ + public interface RecipientChipAddedListener { + /** + * Callback that occurs when a chip is added. + * + * @param entry RecipientEntry that contains information about the chip. + */ + void onRecipientChipAdded(RecipientEntry entry); + } + + public RecipientEditTextView(Context context, AttributeSet attrs) { + super(context, attrs); + setChipDimensions(context, attrs); + mTextHeight = calculateTextHeight(); + mAlternatesPopup = new ListPopupWindow(context); + setupPopupWindow(mAlternatesPopup); + mAddressPopup = new ListPopupWindow(context); + setupPopupWindow(mAddressPopup); + mAlternatesListener = new OnItemClickListener() { + @Override + public void onItemClick(AdapterView adapterView,View view, int position, + long rowId) { + mAlternatesPopup.setOnItemClickListener(null); + replaceChip(mSelectedChip, ((RecipientAlternatesAdapter) adapterView.getAdapter()) + .getRecipientEntry(position)); + Message delayed = Message.obtain(mHandler, DISMISS); + delayed.obj = mAlternatesPopup; + mHandler.sendMessageDelayed(delayed, DISMISS_DELAY); + clearComposingText(); + } + }; + setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + setOnItemClickListener(this); + setCustomSelectionActionModeCallback(this); + mHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + if (msg.what == DISMISS) { + ((ListPopupWindow) msg.obj).dismiss(); + return; + } + super.handleMessage(msg); + } + }; + mTextWatcher = new RecipientTextWatcher(); + addTextChangedListener(mTextWatcher); + mGestureDetector = new GestureDetector(context, this); + setOnEditorActionListener(this); + + setDropdownChipLayouter(new DropdownChipLayouter(LayoutInflater.from(context), context)); + } + + private void setupPopupWindow(ListPopupWindow popup) { + popup.setOnDismissListener(new PopupWindow.OnDismissListener() { + @Override + public void onDismiss() { + clearSelectedChip(); + } + }); + } + + private int calculateTextHeight() { + final TextPaint paint = getPaint(); + + mRect.setEmpty(); + // First measure the bounds of a sample text. + final String textHeightSample = "a"; + paint.getTextBounds(textHeightSample, 0, textHeightSample.length(), mRect); + + mRect.left = 0; + mRect.right = 0; + + return mRect.height(); + } + + public void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) { + mDropdownChipLayouter = dropdownChipLayouter; + mDropdownChipLayouter.setDeleteListener(this); + mDropdownChipLayouter.setPermissionRequestDismissedListener(this); + } + + public void setRecipientEntryItemClickedListener(RecipientEntryItemClickedListener listener) { + mRecipientEntryItemClickedListener = listener; + } + + public void setPermissionsRequestItemClickedListener( + PermissionsRequestItemClickedListener listener) { + mPermissionsRequestItemClickedListener = listener; + } + + public void setRecipientChipAddedListener(RecipientChipAddedListener listener) { + mRecipientChipAddedListener = listener; + } + + public void setRecipientChipDeletedListener(RecipientChipDeletedListener listener) { + mRecipientChipDeletedListener = listener; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mAttachedToWindow = false; + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mAttachedToWindow = true; + + final int anchorId = getDropDownAnchor(); + if (anchorId != View.NO_ID) { + mDropdownAnchor = getRootView().findViewById(anchorId); + } + } + + @Override + public void setDropDownAnchor(int anchorId) { + super.setDropDownAnchor(anchorId); + if (anchorId != View.NO_ID) { + mDropdownAnchor = getRootView().findViewById(anchorId); + } + } + + @Override + public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) { + if (action == EditorInfo.IME_ACTION_DONE) { + if (commitDefault()) { + return true; + } + if (mSelectedChip != null) { + clearSelectedChip(); + return true; + } else if (hasFocus()) { + if (focusNext()) { + return true; + } + } + } + return false; + } + + @Override + public InputConnection onCreateInputConnection(@NonNull EditorInfo outAttrs) { + InputConnection connection = super.onCreateInputConnection(outAttrs); + int imeActions = outAttrs.imeOptions&EditorInfo.IME_MASK_ACTION; + if ((imeActions&EditorInfo.IME_ACTION_DONE) != 0) { + // clear the existing action + outAttrs.imeOptions ^= imeActions; + // set the DONE action + outAttrs.imeOptions |= EditorInfo.IME_ACTION_DONE; + } + if ((outAttrs.imeOptions&EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) { + outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; + } + + outAttrs.actionId = EditorInfo.IME_ACTION_DONE; + + // Custom action labels are discouraged in L; a checkmark icon is shown in place of the + // custom text in this case. + outAttrs.actionLabel = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? null : + getContext().getString(R.string.action_label); + return connection; + } + + /*package*/ DrawableRecipientChip getLastChip() { + DrawableRecipientChip last = null; + DrawableRecipientChip[] chips = getSortedRecipients(); + if (chips != null && chips.length > 0) { + last = chips[chips.length - 1]; + } + return last; + } + + /** + * @return The list of {@link RecipientEntry}s that have been selected by the user. + */ + public List getSelectedRecipients() { + DrawableRecipientChip[] chips = + getText().getSpans(0, getText().length(), DrawableRecipientChip.class); + List results = new ArrayList(); + if (chips == null) { + return results; + } + + for (DrawableRecipientChip c : chips) { + results.add(c.getEntry()); + } + + return results; + } + + /** + * @return The list of {@link RecipientEntry}s that have been selected by the user and also + * hidden due to {@link #mMoreChip} span. + */ + public List getAllRecipients() { + List results = getSelectedRecipients(); + + if (mHiddenSpans != null) { + for (DrawableRecipientChip chip : mHiddenSpans) { + results.add(chip.getEntry()); + } + } + + return results; + } + + @Override + public void onSelectionChanged(int start, int end) { + // When selection changes, see if it is inside the chips area. + // If so, move the cursor back after the chips again. + // Only exception is when we change the selection due to a selected chip. + DrawableRecipientChip last = getLastChip(); + if (mSelectedChip == null && last != null && start < getSpannable().getSpanEnd(last)) { + // Grab the last chip and set the cursor to after it. + setSelection(Math.min(getSpannable().getSpanEnd(last) + 1, getText().length())); + } + super.onSelectionChanged(start, end); + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + Bundle savedInstanceState = (Bundle) state; + if (!TextUtils.isEmpty(getText())) { + super.onRestoreInstanceState(null); + } else { + super.onRestoreInstanceState( + savedInstanceState.getParcelable(STATE_TEXT_VIEW)); + } + String savedWarningText = savedInstanceState.getString( + STATE_CURRENT_WARNING_TEXT); + if (!savedWarningText.isEmpty()) { + showWarningDialog(savedWarningText); + } + } + + @Override + public Parcelable onSaveInstanceState() { + // If the user changes orientation while they are editing, just roll back the selection. + clearSelectedChip(); + Bundle savedInstanceState = new Bundle(); + savedInstanceState.putParcelable(STATE_TEXT_VIEW, super.onSaveInstanceState()); + savedInstanceState.putString(STATE_CURRENT_WARNING_TEXT, mCurrentWarningText); + return savedInstanceState; + } + + /** + * Convenience method: Append the specified text slice to the TextView's + * display buffer, upgrading it to BufferType.EDITABLE if it was + * not already editable. Commas are excluded as they are added automatically + * by the view. + */ + @Override + public void append(CharSequence text, int start, int end) { + // We don't care about watching text changes while appending. + if (mTextWatcher != null) { + removeTextChangedListener(mTextWatcher); + } + super.append(text, start, end); + if (!TextUtils.isEmpty(text) && TextUtils.getTrimmedLength(text) > 0) { + String displayString = text.toString(); + + if (!displayString.trim().endsWith(String.valueOf(COMMIT_CHAR_COMMA))) { + // We have no separator, so we should add it + super.append(SEPARATOR, 0, SEPARATOR.length()); + displayString += SEPARATOR; + } + + if (!TextUtils.isEmpty(displayString) + && TextUtils.getTrimmedLength(displayString) > 0) { + mPendingChipsCount++; + mPendingChips.add(displayString); + } + } + // Put a message on the queue to make sure we ALWAYS handle pending + // chips. + if (mPendingChipsCount > 0) { + postHandlePendingChips(); + } + mHandler.post(mAddTextWatcher); + } + + @Override + public void onFocusChanged(boolean hasFocus, int direction, Rect previous) { + super.onFocusChanged(hasFocus, direction, previous); + if (!hasFocus) { + shrink(); + } else { + expand(); + } + } + + @Override + public void setAdapter(@NonNull T adapter) { + super.setAdapter(adapter); + BaseRecipientAdapter baseAdapter = (BaseRecipientAdapter) adapter; + baseAdapter.registerUpdateObserver(new BaseRecipientAdapter.EntriesUpdatedObserver() { + @Override + public void onChanged(List entries) { + int suggestionCount = entries == null ? 0 : entries.size(); + + // Scroll the chips field to the top of the screen so + // that the user can see as many results as possible. + if (entries != null && entries.size() > 0) { + scrollBottomIntoView(); + // Here the current suggestion count is still the old one since we update + // the count at the bottom of this function. + if (mCurrentSuggestionCount == 0) { + // Announce the new number of possible choices for accessibility. + announceForAccessibilityCompat( + getSuggestionDropdownOpenedVerbalization(suggestionCount)); + } + } + + // Is the dropdown closing? + if ((entries == null || entries.size() == 0) + // Here the current suggestion count is still the old one since we update + // the count at the bottom of this function. + && mCurrentSuggestionCount != 0 + // If there is no text, there's no need to know if no suggestions are + // available. + && getText().length() > 0) { + announceForAccessibilityCompat(getResources().getString( + R.string.accessbility_suggestion_dropdown_closed)); + } + + if ((entries != null) + && (entries.size() == 1) + && (entries.get(0).getEntryType() == + RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST)) { + // Do nothing; showing a single permissions entry. Resizing not required. + } else { + // Set the dropdown height to be the remaining height from the anchor to the + // bottom. + mDropdownAnchor.getLocationOnScreen(mCoords); + getWindowVisibleDisplayFrame(mRect); + setDropDownHeight(mRect.bottom - mCoords[1] - mDropdownAnchor.getHeight() - + getDropDownVerticalOffset()); + } + + mCurrentSuggestionCount = suggestionCount; + } + }); + baseAdapter.setDropdownChipLayouter(mDropdownChipLayouter); + } + + /** + * Return the accessibility verbalization when the suggestion dropdown is opened. + */ + public String getSuggestionDropdownOpenedVerbalization(int suggestionCount) { + return getResources().getString(R.string.accessbility_suggestion_dropdown_opened); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private void announceForAccessibilityCompat(String text) { + final AccessibilityManager accessibilityManager = + (AccessibilityManager) getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); + final boolean isAccessibilityOn = accessibilityManager.isEnabled(); + + if (isAccessibilityOn && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + final ViewParent parent = getParent(); + if (parent != null) { + AccessibilityEvent event = AccessibilityEvent.obtain( + AccessibilityEvent.TYPE_ANNOUNCEMENT); + onInitializeAccessibilityEvent(event); + event.getText().add(text); + event.setContentDescription(null); + parent.requestSendAccessibilityEvent(this, event); + } + } + } + + protected void scrollBottomIntoView() { + if (mScrollView != null && mShouldShrink) { + getLocationInWindow(mCoords); + // Desired position shows at least 1 line of chips below the action + // bar. We add excess padding to make sure this is always below other + // content. + final int height = getHeight(); + final int currentPos = mCoords[1] + height; + mScrollView.getLocationInWindow(mCoords); + final int desiredPos = mCoords[1] + height / getLineCount(); + if (currentPos > desiredPos) { + mScrollView.scrollBy(0, currentPos - desiredPos); + } + } + } + + protected ScrollView getScrollView() { + return mScrollView; + } + + @Override + public void performValidation() { + // Do nothing. Chips handles its own validation. + } + + private void shrink() { + if (mTokenizer == null) { + return; + } + long contactId = mSelectedChip != null ? mSelectedChip.getEntry().getContactId() : -1; + if (mSelectedChip != null && contactId != RecipientEntry.INVALID_CONTACT + && (!isPhoneQuery() && contactId != RecipientEntry.GENERATED_CONTACT)) { + clearSelectedChip(); + } else { + if (getWidth() <= 0) { + mHandler.removeCallbacks(mDelayedShrink); + + if (getVisibility() == GONE) { + // We aren't going to have a width any time soon, so defer + // this until we're not GONE. + mRequiresShrinkWhenNotGone = true; + } else { + // We don't have the width yet which means the view hasn't been drawn yet + // and there is no reason to attempt to commit chips yet. + // This focus lost must be the result of an orientation change + // or an initial rendering. + // Re-post the shrink for later. + mHandler.post(mDelayedShrink); + } + return; + } + // Reset any pending chips as they would have been handled + // when the field lost focus. + if (mPendingChipsCount > 0) { + postHandlePendingChips(); + } else { + Editable editable = getText(); + int end = getSelectionEnd(); + int start = mTokenizer.findTokenStart(editable, end); + DrawableRecipientChip[] chips = + getSpannable().getSpans(start, end, DrawableRecipientChip.class); + if ((chips == null || chips.length == 0)) { + Editable text = getText(); + int whatEnd = mTokenizer.findTokenEnd(text, start); + // This token was already tokenized, so skip past the ending token. + if (whatEnd < text.length() && text.charAt(whatEnd) == ',') { + whatEnd = movePastTerminators(whatEnd); + } + // In the middle of chip; treat this as an edit + // and commit the whole token. + int selEnd = getSelectionEnd(); + if (whatEnd != selEnd) { + handleEdit(start, whatEnd); + } else { + commitChip(start, end, editable); + } + } + } + mHandler.post(mAddTextWatcher); + } + createMoreChip(); + } + + private void expand() { + if (mShouldShrink) { + setMaxLines(Integer.MAX_VALUE); + } + removeMoreChip(); + setCursorVisible(true); + Editable text = getText(); + setSelection(text != null && text.length() > 0 ? text.length() : 0); + // If there are any temporary chips, try replacing them now that the user + // has expanded the field. + if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0) { + new RecipientReplacementTask().execute(); + mTemporaryRecipients = null; + } + } + + private CharSequence ellipsizeText(CharSequence text, TextPaint paint, float maxWidth) { + paint.setTextSize(mChipFontSize); + if (maxWidth <= 0 && Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Max width is negative: " + maxWidth); + } + return TextUtils.ellipsize(text, paint, maxWidth, + TextUtils.TruncateAt.END); + } + + /** + * Creates a bitmap of the given contact on a selected chip. + * + * @param contact The recipient entry to pull data from. + * @param paint The paint to use to draw the bitmap. + */ + private ChipBitmapContainer createChipBitmap(RecipientEntry contact, TextPaint paint) { + paint.setColor(getDefaultChipTextColor(contact)); + ChipBitmapContainer bitmapContainer = createChipBitmap(contact, paint, + getChipBackground(contact), getDefaultChipBackgroundColor(contact)); + + if (bitmapContainer.loadIcon) { + loadAvatarIcon(contact, bitmapContainer); + } + return bitmapContainer; + } + + private ChipBitmapContainer createChipBitmap(RecipientEntry contact, TextPaint paint, + Drawable overrideBackgroundDrawable, int backgroundColor) { + final ChipBitmapContainer result = new ChipBitmapContainer(); + + Drawable indicatorIcon = null; + int indicatorPadding = 0; + if (contact.getIndicatorIconId() != 0) { + indicatorIcon = getContext().getDrawable(contact.getIndicatorIconId()); + indicatorIcon.setBounds(0, 0, + indicatorIcon.getIntrinsicWidth(), indicatorIcon.getIntrinsicHeight()); + indicatorPadding = indicatorIcon.getBounds().width() + mChipTextEndPadding; + } + + Rect backgroundPadding = new Rect(); + if (overrideBackgroundDrawable != null) { + overrideBackgroundDrawable.getPadding(backgroundPadding); + } + + // Ellipsize the text so that it takes AT MOST the entire width of the + // autocomplete text entry area. Make sure to leave space for padding + // on the sides. + int height = (int) mChipHeight; + // Since the icon is a square, it's width is equal to the maximum height it can be inside + // the chip. Don't include iconWidth for invalid contacts and when not displaying photos. + boolean displayIcon = contact.isValid() && contact.shouldDisplayIcon(); + int iconWidth = displayIcon ? + height - backgroundPadding.top - backgroundPadding.bottom : 0; + + final boolean shouldDisplayWarningIcon = mUntrustedAddresses.contains( + contact.getDestination()); + final float warningIconWidth = shouldDisplayWarningIcon ? mWarningIconHeight : 0; + final float warningIconTopMargin = (mChipHeight - mWarningIconHeight) / 2f; + final float warningIconEndMargin = shouldDisplayWarningIcon ? mChipTextEndPadding : 0; + + float[] widths = new float[1]; + paint.getTextWidths(" ", widths); + CharSequence ellipsizedText = ellipsizeText(createChipDisplayText(contact), paint, + calculateAvailableWidth() + - iconWidth + - warningIconWidth + - warningIconEndMargin + - widths[0] + - backgroundPadding.left + - backgroundPadding.right + - indicatorPadding); + int textWidth = (int) paint.measureText(ellipsizedText, 0, ellipsizedText.length()); + + // Chip start padding is the same as the end padding if there is no contact image. + final int startPadding = displayIcon ? mChipTextStartPadding : mChipTextEndPadding; + // Make sure there is a minimum chip width so the user can ALWAYS + // tap a chip without difficulty. + int width = Math.max(iconWidth * 2, + textWidth + + startPadding + + mChipTextEndPadding + + iconWidth + + (int) warningIconWidth + + (int) warningIconEndMargin + + backgroundPadding.left + + backgroundPadding.right + + indicatorPadding); + + // Create the background of the chip. + result.bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(result.bitmap); + + // Check if the background drawable is set via attr + if (overrideBackgroundDrawable != null) { + overrideBackgroundDrawable.setBounds(0, 0, width, height); + overrideBackgroundDrawable.draw(canvas); + } else { + // Draw the default chip background + mWorkPaint.reset(); + mWorkPaint.setColor(backgroundColor); + final float radius = height / 2; + canvas.drawRoundRect(new RectF(0, 0, width, height), radius, radius, + mWorkPaint); + } + + // Draw the text vertically aligned + int textX = shouldPositionAvatarOnRight() ? + mChipTextEndPadding + + backgroundPadding.left + + indicatorPadding + + (int) warningIconWidth + + (int) warningIconEndMargin : + width + - backgroundPadding.right + - mChipTextEndPadding + - textWidth + - indicatorPadding + - (int) warningIconWidth + - (int) warningIconEndMargin; + canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), + textX, getTextYOffset(height), paint); + + if (indicatorIcon != null) { + int indicatorX = shouldPositionAvatarOnRight() + ? backgroundPadding.left + mChipTextEndPadding + : width - backgroundPadding.right - indicatorIcon.getBounds().width() + - mChipTextEndPadding; + int indicatorY = height / 2 - indicatorIcon.getBounds().height() / 2; + indicatorIcon.getBounds().offsetTo(indicatorX, indicatorY); + indicatorIcon.draw(canvas); + } + + // Set the variables that are needed to draw the icon bitmap once it's loaded + final int iconX = shouldPositionAvatarOnRight() ? + width - backgroundPadding.right - iconWidth : + backgroundPadding.left; + result.left = iconX; + result.top = backgroundPadding.top; + result.right = iconX + iconWidth; + result.bottom = height - backgroundPadding.bottom; + result.loadIcon = displayIcon; + + // Set the variables needed to draw the warning icon bitmap once it's loaded. + final float warningIconX = shouldPositionAvatarOnRight() ? + backgroundPadding.left + warningIconEndMargin : + width - backgroundPadding.right - warningIconWidth - warningIconEndMargin; + final float warningIconY = warningIconTopMargin; + result.warningIconLeft = warningIconX; + result.warningIconTop = warningIconY; + result.warningIconRight = warningIconX + warningIconWidth; + result.warningIconBottom = warningIconY + mWarningIconHeight; + + return result; + } + + /** + * Helper function that draws the loaded icon bitmap into the chips bitmap + */ + private void drawIcon(ChipBitmapContainer bitMapResult, Bitmap icon) { + if (icon == null) { + return; + } + final Canvas canvas = new Canvas(bitMapResult.bitmap); + final RectF src = new RectF(0, 0, icon.getWidth(), icon.getHeight()); + final RectF dst = new RectF(bitMapResult.left, bitMapResult.top, bitMapResult.right, + bitMapResult.bottom); + drawCircularIconOnCanvas(icon, canvas, src, dst); + } + + /** + * Draws the warning icon onto the chip's bitmap and returns the rectangle it drew on. + */ + private RectF drawWarningIcon(ChipBitmapContainer bitMapResult) { + if (mWarningIcon == null) { + return new RectF(0, 0, 0, 0); + } + final Canvas canvas = new Canvas(bitMapResult.bitmap); + final RectF src = new RectF(0, 0, mWarningIcon.getWidth(), mWarningIcon.getHeight()); + final RectF dst = new RectF(bitMapResult.warningIconLeft, bitMapResult.warningIconTop, + bitMapResult.warningIconRight, bitMapResult.warningIconBottom); + drawRectanglularIconOnCanvas(mWarningIcon, canvas, src, dst); + return dst; + } + + /** + * Returns true if the avatar should be positioned at the right edge of the chip. + * Takes into account both the set avatar position (start or end) as well as whether + * the layout direction is LTR or RTL. + */ + private boolean shouldPositionAvatarOnRight() { + final boolean isRtl = Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && + getLayoutDirection() == LAYOUT_DIRECTION_RTL; + final boolean assignedPosition = mAvatarPosition == AVATAR_POSITION_END; + // If in Rtl mode, the position should be flipped. + return isRtl ? !assignedPosition : assignedPosition; + } + + /** + * Returns the avatar icon to use for this recipient entry. Returns null if we don't want to + * draw an icon for this recipient. + */ + private void loadAvatarIcon(final RecipientEntry contact, + final ChipBitmapContainer bitmapContainer) { + // Don't draw photos for recipients that have been typed in OR generated on the fly. + long contactId = contact.getContactId(); + boolean drawPhotos = isPhoneQuery() ? + contactId != RecipientEntry.INVALID_CONTACT + : (contactId != RecipientEntry.INVALID_CONTACT + && contactId != RecipientEntry.GENERATED_CONTACT); + + if (drawPhotos) { + final byte[] origPhotoBytes = contact.getPhotoBytes(); + // There may not be a photo yet if anything but the first contact address + // was selected. + if (origPhotoBytes == null) { + // TODO: cache this in the recipient entry? + getAdapter().fetchPhoto(contact, new PhotoManager.PhotoManagerCallback() { + @Override + public void onPhotoBytesPopulated() { + // Call through to the async version which will ensure + // proper threading. + onPhotoBytesAsynchronouslyPopulated(); + } + + @Override + public void onPhotoBytesAsynchronouslyPopulated() { + final byte[] loadedPhotoBytes = contact.getPhotoBytes(); + final Bitmap icon = BitmapFactory.decodeByteArray(loadedPhotoBytes, 0, + loadedPhotoBytes.length); + tryDrawAndInvalidate(icon); + } + + @Override + public void onPhotoBytesAsyncLoadFailed() { + // TODO: can the scaled down default photo be cached? + tryDrawAndInvalidate(mDefaultContactPhoto); + } + + private void tryDrawAndInvalidate(Bitmap icon) { + drawIcon(bitmapContainer, icon); + // The caller might originated from a background task. However, if the + // background task has already completed, the view might be already drawn + // on the UI but the callback would happen on the background thread. + // So if we are on a background thread, post an invalidate call to the UI. + if (Looper.myLooper() == Looper.getMainLooper()) { + // The view might not redraw itself since it's loaded asynchronously + invalidate(); + } else { + post(new Runnable() { + @Override + public void run() { + invalidate(); + } + }); + } + } + }); + } else { + final Bitmap icon = BitmapFactory.decodeByteArray(origPhotoBytes, 0, + origPhotoBytes.length); + drawIcon(bitmapContainer, icon); + } + } + } + + /** + * Get the background drawable for a RecipientChip. + */ + // Visible for testing. + /* package */Drawable getChipBackground(RecipientEntry contact) { + return contact.isValid() ? mChipBackground : mInvalidChipBackground; + } + + private int getDefaultChipTextColor(RecipientEntry contact) { + return contact.isValid() ? mUnselectedChipTextColor : + getResources().getColor(android.R.color.black); + } + + private int getDefaultChipBackgroundColor(RecipientEntry contact) { + return contact.isValid() ? mUnselectedChipBackgroundColor : + getResources().getColor(R.color.chip_background_invalid); + } + + /** + * Given a height, returns a Y offset that will draw the text in the middle of the height. + */ + protected float getTextYOffset(int height) { + return height - ((height - mTextHeight) / 2); + } + + /** + * Draws the icon onto the canvas given the source rectangle of the bitmap and the destination + * rectangle of the canvas. + * + *

The icon is drawn as a circle. + */ + protected void drawCircularIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst) { + setWorkPaintForIcon(icon, src, dst); + canvas.drawCircle(dst.centerX(), dst.centerY(), dst.width() / 2f, mWorkPaint); + + final float borderWidth = 1f; + setWorkPaintForBorder(borderWidth); + canvas.drawCircle(dst.centerX(), dst.centerY(), dst.width() / 2f - borderWidth / 2, + mWorkPaint); + + mWorkPaint.reset(); + } + + /** + * Draws the icon onto the canvas given the source rectangle of the bitmap and the destination + * rectangle of the canvas. + * + *

The icon is drawn as a rectangle. + */ + private void drawRectanglularIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst) { + setWorkPaintForIcon(icon, src, dst); + canvas.drawRect(dst, mWorkPaint); + + final float borderWidth = 1f; + setWorkPaintForBorder(borderWidth); + canvas.drawRect(dst, mWorkPaint); + + mWorkPaint.reset(); + } + + /** + * Sets WorkPaint for drawing the icon from src onto dst. + */ + private void setWorkPaintForIcon(Bitmap icon, RectF src, RectF dst) { + final Matrix matrix = new Matrix(); + + // Draw bitmap through shader first. + final BitmapShader shader = new BitmapShader(icon, TileMode.CLAMP, TileMode.CLAMP); + matrix.reset(); + + // Fit bitmap to bounds. + matrix.setRectToRect(src, dst, Matrix.ScaleToFit.FILL); + + shader.setLocalMatrix(matrix); + mWorkPaint.reset(); + mWorkPaint.setShader(shader); + mWorkPaint.setAntiAlias(true); + mWorkPaint.setFilterBitmap(true); + mWorkPaint.setDither(true); + } + + /** + * Sets WorkPaint for drawing the icon border with the given width. + */ + private void setWorkPaintForBorder(float borderWidth) { + mWorkPaint.reset(); + mWorkPaint.setColor(Color.TRANSPARENT); + mWorkPaint.setStyle(Style.STROKE); + mWorkPaint.setStrokeWidth(borderWidth); + mWorkPaint.setAntiAlias(true); + } + + private DrawableRecipientChip constructChipSpan(RecipientEntry contact) { + TextPaint paint = getPaint(); + float defaultSize = paint.getTextSize(); + int defaultColor = paint.getColor(); + + ChipBitmapContainer bitmapContainer = createChipBitmap(contact, paint); + final Rect warningIconBounds = new Rect(0, 0, 0, 0); + if (mUntrustedAddresses.contains(contact.getDestination())) { + drawWarningIcon(bitmapContainer).round(warningIconBounds); + } + final Bitmap tmpBitmap = bitmapContainer.bitmap; + + // Pass the full text, un-ellipsized, to the chip. + final int iconWidth = tmpBitmap != null ? tmpBitmap.getWidth() : 0; + final int iconHeight = tmpBitmap != null ? tmpBitmap.getHeight() : 0; + Drawable result = new BitmapDrawable(getResources(), tmpBitmap); + result.setBounds(0, 0, iconWidth, iconHeight); + VisibleRecipientChip recipientChip = + new VisibleRecipientChip(result, contact); + recipientChip.setExtraMargin(mLineSpacingExtra); + // Return text to the original size. + paint.setTextSize(defaultSize); + paint.setColor(defaultColor); + // Put warning icon dimensions info in the chip + recipientChip.setWarningIconBounds(warningIconBounds); + return recipientChip; + } + + /** + * Calculate the offset from bottom of the EditText to top of the provided line. + */ + private int calculateOffsetFromBottomToTop(int line) { + return -(int) ((mChipHeight + (2 * mLineSpacingExtra)) * (Math + .abs(getLineCount() - line)) + getPaddingBottom()); + } + + /** + * Get the max amount of space a chip can take up. The formula takes into + * account the width of the EditTextView, any view padding, and padding + * that will be added to the chip. + */ + private float calculateAvailableWidth() { + return getWidth() - getPaddingLeft() - getPaddingRight() - mChipTextStartPadding + - mChipTextEndPadding; + } + + + private void setChipDimensions(Context context, AttributeSet attrs) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RecipientEditTextView, 0, + 0); + Resources r = getContext().getResources(); + + mChipBackground = a.getDrawable(R.styleable.RecipientEditTextView_chipBackground); + mInvalidChipBackground = a + .getDrawable(R.styleable.RecipientEditTextView_invalidChipBackground); + mChipDelete = a.getDrawable(R.styleable.RecipientEditTextView_chipDelete); + if (mChipDelete == null) { + mChipDelete = r.getDrawable(R.drawable.ic_cancel_wht_24dp); + } + mChipTextStartPadding = mChipTextEndPadding + = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipPadding, -1); + if (mChipTextStartPadding == -1) { + mChipTextStartPadding = mChipTextEndPadding = + (int) r.getDimension(R.dimen.chip_padding); + } + // xml-overrides for each individual padding + // TODO: add these to attr? + int overridePadding = (int) r.getDimension(R.dimen.chip_padding_start); + if (overridePadding >= 0) { + mChipTextStartPadding = overridePadding; + } + overridePadding = (int) r.getDimension(R.dimen.chip_padding_end); + if (overridePadding >= 0) { + mChipTextEndPadding = overridePadding; + } + + mDefaultContactPhoto = BitmapFactory.decodeResource(r, R.drawable.ic_contact_picture); + + mMoreItem = (TextView) LayoutInflater.from(getContext()).inflate(R.layout.more_item, null); + + mChipHeight = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipHeight, -1); + if (mChipHeight == -1) { + mChipHeight = r.getDimension(R.dimen.chip_height); + } + mChipFontSize = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipFontSize, -1); + if (mChipFontSize == -1) { + mChipFontSize = r.getDimension(R.dimen.chip_text_size); + } + mAvatarPosition = + a.getInt(R.styleable.RecipientEditTextView_avatarPosition, AVATAR_POSITION_START); + mDisableDelete = a.getBoolean(R.styleable.RecipientEditTextView_disableDelete, false); + + mMaxLines = r.getInteger(R.integer.chips_max_lines); + mLineSpacingExtra = r.getDimensionPixelOffset(R.dimen.line_spacing_extra); + + mUnselectedChipTextColor = a.getColor( + R.styleable.RecipientEditTextView_unselectedChipTextColor, + r.getColor(android.R.color.black)); + + mUnselectedChipBackgroundColor = a.getColor( + R.styleable.RecipientEditTextView_unselectedChipBackgroundColor, + r.getColor(R.color.chip_background)); + + a.recycle(); + } + + // Visible for testing. + /* package */ void setMoreItem(TextView moreItem) { + mMoreItem = moreItem; + } + + + // Visible for testing. + /* package */ void setChipBackground(Drawable chipBackground) { + mChipBackground = chipBackground; + } + + // Visible for testing. + /* package */ void setChipHeight(int height) { + mChipHeight = height; + } + + public float getChipHeight() { + return mChipHeight; + } + + /** Returns whether view is in no-chip or chip mode. */ + public boolean isNoChipMode() { + return mNoChipMode; + } + + /** + * Set whether to shrink the recipients field such that at most + * one line of recipients chips are shown when the field loses + * focus. By default, the number of displayed recipients will be + * limited and a "more" chip will be shown when focus is lost. + * @param shrink + */ + public void setOnFocusListShrinkRecipients(boolean shrink) { + mShouldShrink = shrink; + } + + @Override + public void onSizeChanged(int width, int height, int oldw, int oldh) { + super.onSizeChanged(width, height, oldw, oldh); + if (width != 0 && height != 0) { + if (mPendingChipsCount > 0) { + postHandlePendingChips(); + } else { + checkChipWidths(); + } + } + // Try to find the scroll view parent, if it exists. + if (mScrollView == null && !mTriedGettingScrollView) { + ViewParent parent = getParent(); + while (parent != null && !(parent instanceof ScrollView)) { + parent = parent.getParent(); + } + if (parent != null) { + mScrollView = (ScrollView) parent; + } + mTriedGettingScrollView = true; + } + } + + private void postHandlePendingChips() { + mHandler.removeCallbacks(mHandlePendingChips); + mHandler.post(mHandlePendingChips); + } + + private void checkChipWidths() { + // Check the widths of the associated chips. + DrawableRecipientChip[] chips = getSortedRecipients(); + if (chips != null) { + Rect bounds; + for (DrawableRecipientChip chip : chips) { + bounds = chip.getBounds(); + if (getWidth() > 0 && bounds.right - bounds.left > + getWidth() - getPaddingLeft() - getPaddingRight()) { + // Need to redraw that chip. + replaceChip(chip, chip.getEntry()); + } + } + } + } + + // Visible for testing. + /*package*/ void handlePendingChips() { + if (getViewWidth() <= 0) { + // The widget has not been sized yet. + // This will be called as a result of onSizeChanged + // at a later point. + return; + } + if (mPendingChipsCount <= 0) { + return; + } + + synchronized (mPendingChips) { + Editable editable = getText(); + // Tokenize! + if (mPendingChipsCount <= MAX_CHIPS_PARSED) { + for (int i = 0; i < mPendingChips.size(); i++) { + String current = mPendingChips.get(i); + int tokenStart = editable.toString().indexOf(current); + // Always leave a space at the end between tokens. + int tokenEnd = tokenStart + current.length() - 1; + if (tokenStart >= 0) { + // When we have a valid token, include it with the token + // to the left. + if (tokenEnd < editable.length() - 2 + && editable.charAt(tokenEnd) == COMMIT_CHAR_COMMA) { + tokenEnd++; + } + createReplacementChip(tokenStart, tokenEnd, editable, i < CHIP_LIMIT + || !mShouldShrink); + } + mPendingChipsCount--; + } + sanitizeEnd(); + } else { + mNoChipMode = true; + } + + if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0 + && mTemporaryRecipients.size() <= RecipientAlternatesAdapter.MAX_LOOKUPS) { + if (hasFocus() || mTemporaryRecipients.size() < CHIP_LIMIT) { + new RecipientReplacementTask().execute(); + mTemporaryRecipients = null; + } else { + // Create the "more" chip + mIndividualReplacements = new IndividualReplacementTask(); + mIndividualReplacements.execute(new ArrayList( + mTemporaryRecipients.subList(0, CHIP_LIMIT))); + if (mTemporaryRecipients.size() > CHIP_LIMIT) { + mTemporaryRecipients = new ArrayList( + mTemporaryRecipients.subList(CHIP_LIMIT, + mTemporaryRecipients.size())); + } else { + mTemporaryRecipients = null; + } + createMoreChip(); + } + } else { + // There are too many recipients to look up, so just fall back + // to showing addresses for all of them. + mTemporaryRecipients = null; + createMoreChip(); + } + mPendingChipsCount = 0; + mPendingChips.clear(); + } + } + + // Visible for testing. + /*package*/ int getViewWidth() { + return getWidth(); + } + + /** + * Remove any characters after the last valid chip. + */ + // Visible for testing. + /*package*/ void sanitizeEnd() { + // Don't sanitize while we are waiting for pending chips to complete. + if (mPendingChipsCount > 0) { + return; + } + // Find the last chip; eliminate any commit characters after it. + DrawableRecipientChip[] chips = getSortedRecipients(); + Spannable spannable = getSpannable(); + if (chips != null && chips.length > 0) { + int end; + mMoreChip = getMoreChip(); + if (mMoreChip != null) { + end = spannable.getSpanEnd(mMoreChip); + } else { + end = getSpannable().getSpanEnd(getLastChip()); + } + Editable editable = getText(); + int length = editable.length(); + if (length > end) { + // See what characters occur after that and eliminate them. + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "There were extra characters after the last tokenizable entry." + + editable); + } + editable.delete(end + 1, length); + } + } + } + + /** + * Create a chip that represents just the email address of a recipient. At some later + * point, this chip will be attached to a real contact entry, if one exists. + */ + // VisibleForTesting + void createReplacementChip(int tokenStart, int tokenEnd, Editable editable, + boolean visible) { + if (alreadyHasChip(tokenStart, tokenEnd)) { + // There is already a chip present at this location. + // Don't recreate it. + return; + } + String token = editable.toString().substring(tokenStart, tokenEnd); + final String trimmedToken = token.trim(); + int commitCharIndex = trimmedToken.lastIndexOf(COMMIT_CHAR_COMMA); + if (commitCharIndex != -1 && commitCharIndex == trimmedToken.length() - 1) { + token = trimmedToken.substring(0, trimmedToken.length() - 1); + } + RecipientEntry entry = createTokenizedEntry(token); + if (entry != null) { + DrawableRecipientChip chip = null; + try { + if (!mNoChipMode) { + chip = visible ? constructChipSpan(entry) : new InvisibleRecipientChip(entry); + } + } catch (NullPointerException e) { + Log.e(TAG, e.getMessage(), e); + } + editable.setSpan(chip, tokenStart, tokenEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + // Add this chip to the list of entries "to replace" + if (chip != null) { + if (mTemporaryRecipients == null) { + mTemporaryRecipients = new ArrayList(); + } + chip.setOriginalText(token); + mTemporaryRecipients.add(chip); + } + } + } + + // VisibleForTesting + RecipientEntry createTokenizedEntry(final String token) { + if (TextUtils.isEmpty(token)) { + return null; + } + if (isPhoneQuery() && PhoneUtil.isPhoneNumber(token)) { + return RecipientEntry.constructFakePhoneEntry(token, true); + } + Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(token); + boolean isValid = isValid(token); + if (isValid && tokens != null && tokens.length > 0) { + // If we can get a name from tokenizing, then generate an entry from + // this. + String display = tokens[0].getName(); + if (!TextUtils.isEmpty(display)) { + return RecipientEntry.constructGeneratedEntry(display, tokens[0].getAddress(), + isValid); + } else { + display = tokens[0].getAddress(); + if (!TextUtils.isEmpty(display)) { + return RecipientEntry.constructFakeEntry(display, isValid); + } + } + } + // Unable to validate the token or to create a valid token from it. + // Just create a chip the user can edit. + String validatedToken = null; + if (mValidator != null && !isValid) { + // Try fixing up the entry using the validator. + validatedToken = mValidator.fixText(token).toString(); + if (!TextUtils.isEmpty(validatedToken)) { + if (validatedToken.contains(token)) { + // protect against the case of a validator with a null + // domain, + // which doesn't add a domain to the token + Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(validatedToken); + if (tokenized.length > 0) { + validatedToken = tokenized[0].getAddress(); + isValid = true; + } + } else { + // We ran into a case where the token was invalid and + // removed + // by the validator. In this case, just use the original + // token + // and let the user sort out the error chip. + validatedToken = null; + isValid = false; + } + } + } + // Otherwise, fallback to just creating an editable email address chip. + return RecipientEntry.constructFakeEntry( + !TextUtils.isEmpty(validatedToken) ? validatedToken : token, isValid); + } + + private boolean isValid(String text) { + return mValidator == null ? true : mValidator.isValid(text); + } + + private static String tokenizeAddress(String destination) { + Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(destination); + if (tokens != null && tokens.length > 0) { + return tokens[0].getAddress(); + } + return destination; + } + + @Override + public void setTokenizer(Tokenizer tokenizer) { + mTokenizer = tokenizer; + super.setTokenizer(mTokenizer); + } + + @Override + public void setValidator(Validator validator) { + mValidator = validator; + super.setValidator(validator); + } + + /** + * We cannot use the default mechanism for replaceText. Instead, + * we override onItemClickListener so we can get all the associated + * contact information including display text, address, and id. + */ + @Override + protected void replaceText(CharSequence text) { + return; + } + + /** + * Dismiss any selected chips when the back key is pressed. + */ + @Override + public boolean onKeyPreIme(int keyCode, @NonNull KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK && mSelectedChip != null) { + clearSelectedChip(); + return true; + } + return super.onKeyPreIme(keyCode, event); + } + + /** + * Monitor key presses in this view to see if the user types + * any commit keys, which consist of ENTER, TAB, or DPAD_CENTER. + * If the user has entered text that has contact matches and types + * a commit key, create a chip from the topmost matching contact. + * If the user has entered text that has no contact matches and types + * a commit key, then create a chip from the text they have entered. + */ + @Override + public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_TAB: + if (event.hasNoModifiers()) { + if (mSelectedChip != null) { + clearSelectedChip(); + } else { + commitDefault(); + } + } + break; + } + return super.onKeyUp(keyCode, event); + } + + private boolean focusNext() { + View next = focusSearch(View.FOCUS_DOWN); + if (next != null) { + next.requestFocus(); + return true; + } + return false; + } + + /** + * Create a chip from the default selection. If the popup is showing, the + * default is the selected item (if one is selected), or the first item, in the popup + * suggestions list. Otherwise, it is whatever the user had typed in. End represents where the + * tokenizer should search for a token to turn into a chip. + * @return If a chip was created from a real contact. + */ + private boolean commitDefault() { + // If there is no tokenizer, don't try to commit. + if (mTokenizer == null) { + return false; + } + Editable editable = getText(); + int end = getSelectionEnd(); + int start = mTokenizer.findTokenStart(editable, end); + + if (shouldCreateChip(start, end)) { + int whatEnd = mTokenizer.findTokenEnd(getText(), start); + // In the middle of chip; treat this as an edit + // and commit the whole token. + whatEnd = movePastTerminators(whatEnd); + if (whatEnd != getSelectionEnd()) { + handleEdit(start, whatEnd); + return true; + } + return commitChip(start, end , editable); + } + return false; + } + + private void commitByCharacter() { + // We can't possibly commit by character if we can't tokenize. + if (mTokenizer == null) { + return; + } + Editable editable = getText(); + int end = getSelectionEnd(); + int start = mTokenizer.findTokenStart(editable, end); + if (shouldCreateChip(start, end)) { + commitChip(start, end, editable); + } + setSelection(getText().length()); + } + + private boolean commitChip(int start, int end, Editable editable) { + int position = positionOfFirstEntryWithTypePerson(); + if (position != -1 && enoughToFilter() + && end == getSelectionEnd() && !isPhoneQuery() + && !isValidEmailAddress(editable.toString().substring(start, end).trim())) { + // let's choose the selected or first entry if only the input text is NOT an email + // address so we won't try to replace the user's potentially correct but + // new/unencountered email input + final int selectedPosition = getListSelection(); + if (selectedPosition == -1 || !isEntryAtPositionTypePerson(selectedPosition)) { + // Nothing is selected or selected item is not type person; use the first item + submitItemAtPosition(position); + } else { + submitItemAtPosition(selectedPosition); + } + dismissDropDown(); + return true; + } else { + int tokenEnd = mTokenizer.findTokenEnd(editable, start); + if (editable.length() > tokenEnd + 1) { + char charAt = editable.charAt(tokenEnd + 1); + if (charAt == COMMIT_CHAR_COMMA || charAt == COMMIT_CHAR_SEMICOLON) { + tokenEnd++; + } + } + String text = editable.toString().substring(start, tokenEnd).trim(); + clearComposingText(); + if (text.length() > 0 && !text.equals(" ")) { + RecipientEntry entry = createTokenizedEntry(text); + if (entry != null) { + QwertyKeyListener.markAsReplaced(editable, start, end, ""); + CharSequence chipText = createChip(entry); + if (chipText != null && start > -1 && end > -1) { + editable.replace(start, end, chipText); + } + } + // Only dismiss the dropdown if it is related to the text we + // just committed. + // For paste, it may not be as there are possibly multiple + // tokens being added. + if (end == getSelectionEnd()) { + dismissDropDown(); + } + sanitizeBetween(); + return true; + } + } + return false; + } + + private int positionOfFirstEntryWithTypePerson() { + ListAdapter adapter = getAdapter(); + int itemCount = adapter != null ? adapter.getCount() : 0; + for (int i = 0; i < itemCount; i++) { + if (isEntryAtPositionTypePerson(i)) { + return i; + } + } + return -1; + } + + private boolean isEntryAtPositionTypePerson(int position) { + return getAdapter().getItem(position).getEntryType() == RecipientEntry.ENTRY_TYPE_PERSON; + } + + // Visible for testing. + /* package */ void sanitizeBetween() { + // Don't sanitize while we are waiting for content to chipify. + if (mPendingChipsCount > 0) { + return; + } + // Find the last chip. + DrawableRecipientChip[] recips = getSortedRecipients(); + if (recips != null && recips.length > 0) { + DrawableRecipientChip last = recips[recips.length - 1]; + DrawableRecipientChip beforeLast = null; + if (recips.length > 1) { + beforeLast = recips[recips.length - 2]; + } + int startLooking = 0; + int end = getSpannable().getSpanStart(last); + if (beforeLast != null) { + startLooking = getSpannable().getSpanEnd(beforeLast); + Editable text = getText(); + if (startLooking == -1 || startLooking > text.length() - 1) { + // There is nothing after this chip. + return; + } + if (text.charAt(startLooking) == ' ') { + startLooking++; + } + } + if (startLooking >= 0 && end >= 0 && startLooking < end) { + getText().delete(startLooking, end); + } + } + } + + private boolean shouldCreateChip(int start, int end) { + return !mNoChipMode && hasFocus() && enoughToFilter() && !alreadyHasChip(start, end); + } + + private boolean alreadyHasChip(int start, int end) { + if (mNoChipMode) { + return true; + } + DrawableRecipientChip[] chips = + getSpannable().getSpans(start, end, DrawableRecipientChip.class); + return chips != null && chips.length > 0; + } + + private void handleEdit(int start, int end) { + if (start == -1 || end == -1) { + // This chip no longer exists in the field. + dismissDropDown(); + return; + } + // This is in the middle of a chip, so select out the whole chip + // and commit it. + Editable editable = getText(); + setSelection(end); + String text = getText().toString().substring(start, end); + if (!TextUtils.isEmpty(text)) { + RecipientEntry entry = RecipientEntry.constructFakeEntry(text, isValid(text)); + QwertyKeyListener.markAsReplaced(editable, start, end, ""); + CharSequence chipText = createChip(entry); + int selEnd = getSelectionEnd(); + if (chipText != null && start > -1 && selEnd > -1) { + editable.replace(start, selEnd, chipText); + } + } + dismissDropDown(); + } + + /** + * If there is a selected chip, delegate the key events + * to the selected chip. + */ + @Override + public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) { + if (mSelectedChip != null && keyCode == KeyEvent.KEYCODE_DEL) { + if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { + mAlternatesPopup.dismiss(); + } + removeChip(mSelectedChip); + } + + switch (keyCode) { + case KeyEvent.KEYCODE_ENTER: + case KeyEvent.KEYCODE_DPAD_CENTER: + if (event.hasNoModifiers()) { + if (commitDefault()) { + return true; + } + if (mSelectedChip != null) { + clearSelectedChip(); + return true; + } else if (focusNext()) { + return true; + } + } + break; + } + + final DrawableRecipientChip lastRecipientChip = getLastChip(); + boolean isHandled = super.onKeyDown(keyCode, event); + + /* + * Hacky way to report a deleted chip: + * In some devices/configurations, {@link KeyEvent#KEYCODE_DEL} character is causing + * onKeyDown() to be called, which in turns handles the chip deletion instead of + * {@link RecipientTextWatcher#onTextChanged}. We want to call + * {@link RecipientChipDeletedListener#onRecipientChipDeleted} callback for these cases. + */ + if (keyCode == KeyEvent.KEYCODE_DEL && isHandled && lastRecipientChip != null) { + final RecipientEntry entry = lastRecipientChip.getEntry(); + if (!mNoChipMode && mRecipientChipDeletedListener != null && entry != null) { + mRecipientChipDeletedListener.onRecipientChipDeleted(entry); + } + } + + return isHandled; + } + + // Visible for testing. + /* package */ Spannable getSpannable() { + return getText(); + } + + private int getChipStart(DrawableRecipientChip chip) { + return getSpannable().getSpanStart(chip); + } + + private int getChipEnd(DrawableRecipientChip chip) { + return getSpannable().getSpanEnd(chip); + } + + /** + * Instead of filtering on the entire contents of the edit box, + * this subclass method filters on the range from + * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd} + * if the length of that range meets or exceeds {@link #getThreshold} + * and makes sure that the range is not already a Chip. + */ + @Override + public void performFiltering(@NonNull CharSequence text, int keyCode) { + boolean isCompletedToken = isCompletedToken(text); + if (enoughToFilter() && !isCompletedToken) { + int end = getSelectionEnd(); + int start = mTokenizer.findTokenStart(text, end); + // If this is a RecipientChip, don't filter + // on its contents. + Spannable span = getSpannable(); + DrawableRecipientChip[] chips = span.getSpans(start, end, DrawableRecipientChip.class); + if (chips != null && chips.length > 0) { + dismissDropDown(); + return; + } + } else if (isCompletedToken) { + dismissDropDown(); + return; + } + super.performFiltering(text, keyCode); + } + + // Visible for testing. + /*package*/ boolean isCompletedToken(CharSequence text) { + if (TextUtils.isEmpty(text)) { + return false; + } + // Check to see if this is a completed token before filtering. + int end = text.length(); + int start = mTokenizer.findTokenStart(text, end); + String token = text.toString().substring(start, end).trim(); + if (!TextUtils.isEmpty(token)) { + char atEnd = token.charAt(token.length() - 1); + return atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON; + } + return false; + } + + /** + * Clears the selected chip if there is one (and dismissing any popups related to the selected + * chip in the process). + */ + public void clearSelectedChip() { + if (mSelectedChip != null) { + unselectChip(mSelectedChip); + mSelectedChip = null; + } + setCursorVisible(true); + setSelection(getText().length()); + } + + /** + * Monitor touch events in the RecipientEditTextView. + * If the view does not have focus, any tap on the view + * will just focus the view. If the view has focus, determine + * if the touch target is a recipient chip. If it is and the chip + * is not selected, select it and clear any other selected chips. + * If it isn't, then select that chip. + */ + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + boolean handled; + int action = event.getAction(); + final float x = event.getX(); + final float y = event.getY(); + final int offset = putOffsetInRange(x, y); + final DrawableRecipientChip currentChip = findChip(offset); + if (action == MotionEvent.ACTION_UP) { + boolean touchedWarningIcon = touchedWarningIcon(x, y, currentChip); + if (touchedWarningIcon) { + String warningText = String.format(mWarningTextTemplate, + currentChip.getEntry().getDestination()); + showWarningDialog(warningText); + return true; + } + if (!isFocused()) { + // Ignore further chip taps until this view is focused. + return touchedWarningIcon || super.onTouchEvent(event); + } + handled = super.onTouchEvent(event); + if (mSelectedChip == null) { + mGestureDetector.onTouchEvent(event); + } + boolean chipWasSelected = false; + if (currentChip != null) { + if (mSelectedChip != null && mSelectedChip != currentChip) { + clearSelectedChip(); + selectChip(currentChip); + } else if (mSelectedChip == null) { + commitDefault(); + selectChip(currentChip); + } else { + onClick(mSelectedChip); + } + chipWasSelected = true; + handled = true; + } else if (mSelectedChip != null && shouldShowEditableText(mSelectedChip)) { + chipWasSelected = true; + } + if (!chipWasSelected) { + clearSelectedChip(); + } + } else { + boolean touchedWarningIcon = touchedWarningIcon(x, y, currentChip); + if (touchedWarningIcon) { + return true; + } + handled = super.onTouchEvent(event); + if (!isFocused()) { + return handled; + } + if (mSelectedChip == null) { + mGestureDetector.onTouchEvent(event); + } + } + return handled; + } + + private boolean touchedWarningIcon(float x, float y, DrawableRecipientChip currentChip) { + boolean touchedWarningIcon = false; + if (currentChip != null) { + Rect outOfDomainWarningBounds = currentChip.getWarningIconBounds(); + if (outOfDomainWarningBounds != null) { + int chipLeftOffset = shouldPositionAvatarOnRight() + ? getChipEnd(currentChip) : getChipStart(currentChip); + float chipLeftPosition = this.getLayout().getPrimaryHorizontal(chipLeftOffset); + float chipTopPosition = this.getLayout().getLineTop( + this.getLayout().getLineForOffset(chipLeftOffset)) + getTotalPaddingTop(); + final RectF touchOutOfDomainWarning = new RectF( + chipLeftPosition + outOfDomainWarningBounds.left, + chipTopPosition + outOfDomainWarningBounds.top, + chipLeftPosition + outOfDomainWarningBounds.right, + chipTopPosition + outOfDomainWarningBounds.bottom); + touchedWarningIcon = touchOutOfDomainWarning.contains(x, y); + } + } + return touchedWarningIcon; + } + + private void showWarningDialog(String warningText) { + mCurrentWarningText = warningText; + new AlertDialog.Builder(RecipientEditTextView.this.getContext()) + .setTitle(mWarningTitle) + .setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface dialog) { + mCurrentWarningText = ""; + } + }) + .setMessage(mCurrentWarningText) + .show(); + } + + private void showAlternates(final DrawableRecipientChip currentChip, + final ListPopupWindow alternatesPopup) { + new AsyncTask() { + @Override + protected ListAdapter doInBackground(final Void... params) { + return createAlternatesAdapter(currentChip); + } + + @Override + protected void onPostExecute(final ListAdapter result) { + if (!mAttachedToWindow) { + return; + } + int line = getLayout().getLineForOffset(getChipStart(currentChip)); + int bottomOffset = calculateOffsetFromBottomToTop(line); + + // Align the alternates popup with the left side of the View, + // regardless of the position of the chip tapped. + alternatesPopup.setAnchorView((mAlternatePopupAnchor != null) ? + mAlternatePopupAnchor : RecipientEditTextView.this); + alternatesPopup.setVerticalOffset(bottomOffset); + alternatesPopup.setAdapter(result); + alternatesPopup.setOnItemClickListener(mAlternatesListener); + // Clear the checked item. + mCheckedItem = -1; + alternatesPopup.show(); + ListView listView = alternatesPopup.getListView(); + listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); + // Checked item would be -1 if the adapter has not + // loaded the view that should be checked yet. The + // variable will be set correctly when onCheckedItemChanged + // is called in a separate thread. + if (mCheckedItem != -1) { + listView.setItemChecked(mCheckedItem, true); + mCheckedItem = -1; + } + } + }.execute((Void[]) null); + } + + protected ListAdapter createAlternatesAdapter(DrawableRecipientChip chip) { + return new RecipientAlternatesAdapter(getContext(), chip.getContactId(), + chip.getDirectoryId(), chip.getLookupKey(), chip.getDataId(), + getAdapter().getQueryType(), this, mDropdownChipLayouter, + constructStateListDeleteDrawable(), getAdapter().getPermissionsCheckListener()); + } + + private ListAdapter createSingleAddressAdapter(DrawableRecipientChip currentChip) { + return new SingleRecipientArrayAdapter(getContext(), currentChip.getEntry(), + mDropdownChipLayouter, constructStateListDeleteDrawable()); + } + + private StateListDrawable constructStateListDeleteDrawable() { + // Construct the StateListDrawable from deleteDrawable + StateListDrawable deleteDrawable = new StateListDrawable(); + if (!mDisableDelete) { + deleteDrawable.addState(new int[]{android.R.attr.state_activated}, mChipDelete); + } + deleteDrawable.addState(new int[0], null); + return deleteDrawable; + } + + @Override + public void onCheckedItemChanged(int position) { + ListView listView = mAlternatesPopup.getListView(); + if (listView != null && listView.getCheckedItemCount() == 0) { + listView.setItemChecked(position, true); + } + mCheckedItem = position; + } + + private int putOffsetInRange(final float x, final float y) { + final int offset; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + offset = getOffsetForPosition(x, y); + } else { + offset = supportGetOffsetForPosition(x, y); + } + + return putOffsetInRange(offset); + } + + // TODO: This algorithm will need a lot of tweaking after more people have used + // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring + // what comes before the finger. + private int putOffsetInRange(int o) { + int offset = o; + Editable text = getText(); + int length = text.length(); + // Remove whitespace from end to find "real end" + int realLength = length; + for (int i = length - 1; i >= 0; i--) { + if (text.charAt(i) == ' ') { + realLength--; + } else { + break; + } + } + + // If the offset is beyond or at the end of the text, + // leave it alone. + if (offset >= realLength) { + return offset; + } + Editable editable = getText(); + while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) { + // Keep walking backward! + offset--; + } + return offset; + } + + private static int findText(Editable text, int offset) { + if (text.charAt(offset) != ' ') { + return offset; + } + return -1; + } + + private DrawableRecipientChip findChip(int offset) { + final Spannable span = getSpannable(); + final DrawableRecipientChip[] chips = + span.getSpans(0, span.length(), DrawableRecipientChip.class); + // Find the chip that contains this offset. + for (DrawableRecipientChip chip : chips) { + int start = getChipStart(chip); + int end = getChipEnd(chip); + if (offset >= start && offset <= end) { + return chip; + } + } + return null; + } + + // Visible for testing. + // Use this method to generate text to add to the list of addresses. + /* package */String createAddressText(RecipientEntry entry) { + String display = entry.getDisplayName(); + String address = entry.getDestination(); + if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) { + display = null; + } + String trimmedDisplayText; + if (isPhoneQuery() && PhoneUtil.isPhoneNumber(address)) { + trimmedDisplayText = address.trim(); + } else { + if (address != null) { + // Tokenize out the address in case the address already + // contained the username as well. + Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(address); + if (tokenized != null && tokenized.length > 0) { + address = tokenized[0].getAddress(); + } + } + Rfc822Token token = new Rfc822Token(display, address, null); + trimmedDisplayText = token.toString().trim(); + } + int index = trimmedDisplayText.indexOf(","); + return mTokenizer != null && !TextUtils.isEmpty(trimmedDisplayText) + && index < trimmedDisplayText.length() - 1 ? (String) mTokenizer + .terminateToken(trimmedDisplayText) : trimmedDisplayText; + } + + // Visible for testing. + // Use this method to generate text to display in a chip. + /*package*/ String createChipDisplayText(RecipientEntry entry) { + String display = entry.getDisplayName(); + String address = entry.getDestination(); + if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) { + display = null; + } + if (!TextUtils.isEmpty(display)) { + return display; + } else if (!TextUtils.isEmpty(address)){ + return address; + } else { + return new Rfc822Token(display, address, null).toString(); + } + } + + private CharSequence createChip(RecipientEntry entry) { + final String displayText = createAddressText(entry); + if (TextUtils.isEmpty(displayText)) { + return null; + } + // Always leave a blank space at the end of a chip. + final int textLength = displayText.length() - 1; + final SpannableString chipText = new SpannableString(displayText); + if (!mNoChipMode) { + try { + DrawableRecipientChip chip = constructChipSpan(entry); + chipText.setSpan(chip, 0, textLength, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + chip.setOriginalText(chipText.toString()); + } catch (NullPointerException e) { + Log.e(TAG, e.getMessage(), e); + return null; + } + } + onChipCreated(entry); + return chipText; + } + + /** + * A callback for subclasses to use to know when a chip was created with the + * given RecipientEntry. + */ + protected void onChipCreated(RecipientEntry entry) { + if (!mNoChipMode && mRecipientChipAddedListener != null) { + mRecipientChipAddedListener.onRecipientChipAdded(entry); + } + } + + /** + * When an item in the suggestions list has been clicked, create a chip from the + * contact information of the selected item. + */ + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + if (position < 0) { + return; + } + + final RecipientEntry entry = getAdapter().getItem(position); + if (entry.getEntryType() == RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST) { + if (mPermissionsRequestItemClickedListener != null) { + mPermissionsRequestItemClickedListener + .onPermissionsRequestItemClicked(this, entry.getPermissions()); + } + return; + } + + final int charactersTyped = submitItemAtPosition(position); + if (charactersTyped > -1 && mRecipientEntryItemClickedListener != null) { + mRecipientEntryItemClickedListener + .onRecipientEntryItemClicked(charactersTyped, position); + } + } + + private int submitItemAtPosition(int position) { + RecipientEntry entry = createValidatedEntry(getAdapter().getItem(position)); + if (entry == null) { + return -1; + } + clearComposingText(); + + int end = getSelectionEnd(); + int start = mTokenizer.findTokenStart(getText(), end); + + Editable editable = getText(); + QwertyKeyListener.markAsReplaced(editable, start, end, ""); + CharSequence chip = createChip(entry); + if (chip != null && start >= 0 && end >= 0) { + editable.replace(start, end, chip); + } + sanitizeBetween(); + + return end - start; + } + + private RecipientEntry createValidatedEntry(RecipientEntry item) { + if (item == null) { + return null; + } + final RecipientEntry entry; + // If the display name and the address are the same, or if this is a + // valid contact, but the destination is invalid, then make this a fake + // recipient that is editable. + String destination = item.getDestination(); + if (!isPhoneQuery() && item.getContactId() == RecipientEntry.GENERATED_CONTACT) { + entry = RecipientEntry.constructGeneratedEntry(item.getDisplayName(), + destination, item.isValid()); + } else if (RecipientEntry.isCreatedRecipient(item.getContactId()) + && (TextUtils.isEmpty(item.getDisplayName()) + || TextUtils.equals(item.getDisplayName(), destination) + || (mValidator != null && !mValidator.isValid(destination)))) { + entry = RecipientEntry.constructFakeEntry(destination, item.isValid()); + } else { + entry = item; + } + return entry; + } + + // Visible for testing. + /* package */DrawableRecipientChip[] getSortedRecipients() { + DrawableRecipientChip[] recips = getSpannable() + .getSpans(0, getText().length(), DrawableRecipientChip.class); + ArrayList recipientsList = new ArrayList( + Arrays.asList(recips)); + final Spannable spannable = getSpannable(); + Collections.sort(recipientsList, new Comparator() { + + @Override + public int compare(DrawableRecipientChip first, DrawableRecipientChip second) { + int firstStart = spannable.getSpanStart(first); + int secondStart = spannable.getSpanStart(second); + if (firstStart < secondStart) { + return -1; + } else if (firstStart > secondStart) { + return 1; + } else { + return 0; + } + } + }); + return recipientsList.toArray(new DrawableRecipientChip[recipientsList.size()]); + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + /** + * No chips are selectable. + */ + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + return false; + } + + // Visible for testing. + /* package */ReplacementDrawableSpan getMoreChip() { + MoreImageSpan[] moreSpans = getSpannable().getSpans(0, getText().length(), + MoreImageSpan.class); + return moreSpans != null && moreSpans.length > 0 ? moreSpans[0] : null; + } + + private MoreImageSpan createMoreSpan(int count) { + String moreText = String.format(mMoreItem.getText().toString(), count); + mWorkPaint.set(getPaint()); + mWorkPaint.setTextSize(mMoreItem.getTextSize()); + mWorkPaint.setColor(mMoreItem.getCurrentTextColor()); + final int width = (int) mWorkPaint.measureText(moreText) + mMoreItem.getPaddingLeft() + + mMoreItem.getPaddingRight(); + final int height = (int) mChipHeight; + Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(drawable); + int adjustedHeight = height; + Layout layout = getLayout(); + if (layout != null) { + adjustedHeight -= layout.getLineDescent(0); + } + canvas.drawText(moreText, 0, moreText.length(), 0, adjustedHeight, mWorkPaint); + + Drawable result = new BitmapDrawable(getResources(), drawable); + result.setBounds(0, 0, width, height); + return new MoreImageSpan(result); + } + + // Visible for testing. + /*package*/ void createMoreChipPlainText() { + // Take the first <= CHIP_LIMIT addresses and get to the end of the second one. + Editable text = getText(); + int start = 0; + int end = start; + for (int i = 0; i < CHIP_LIMIT; i++) { + end = movePastTerminators(mTokenizer.findTokenEnd(text, start)); + start = end; // move to the next token and get its end. + } + // Now, count total addresses. + int tokenCount = countTokens(text); + MoreImageSpan moreSpan = createMoreSpan(tokenCount - CHIP_LIMIT); + SpannableString chipText = new SpannableString(text.subSequence(end, text.length())); + chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.replace(end, text.length(), chipText); + mMoreChip = moreSpan; + } + + // Visible for testing. + /* package */int countTokens(Editable text) { + int tokenCount = 0; + int start = 0; + while (start < text.length()) { + start = movePastTerminators(mTokenizer.findTokenEnd(text, start)); + tokenCount++; + if (start >= text.length()) { + break; + } + } + return tokenCount; + } + + /** + * Create the more chip. The more chip is text that replaces any chips that + * do not fit in the pre-defined available space when the + * RecipientEditTextView loses focus. + */ + // Visible for testing. + /* package */ void createMoreChip() { + if (mNoChipMode) { + createMoreChipPlainText(); + return; + } + + if (!mShouldShrink) { + return; + } + ReplacementDrawableSpan[] tempMore = getSpannable().getSpans(0, getText().length(), + MoreImageSpan.class); + if (tempMore.length > 0) { + getSpannable().removeSpan(tempMore[0]); + } + DrawableRecipientChip[] recipients = getSortedRecipients(); + + if (recipients == null || recipients.length <= CHIP_LIMIT) { + mMoreChip = null; + return; + } + Spannable spannable = getSpannable(); + int numRecipients = recipients.length; + int overage = numRecipients - CHIP_LIMIT; + MoreImageSpan moreSpan = createMoreSpan(overage); + mHiddenSpans = new ArrayList(); + int totalReplaceStart = 0; + int totalReplaceEnd = 0; + Editable text = getText(); + for (int i = numRecipients - overage; i < recipients.length; i++) { + mHiddenSpans.add(recipients[i]); + if (i == numRecipients - overage) { + totalReplaceStart = spannable.getSpanStart(recipients[i]); + } + if (i == recipients.length - 1) { + totalReplaceEnd = spannable.getSpanEnd(recipients[i]); + } + if (mTemporaryRecipients == null || !mTemporaryRecipients.contains(recipients[i])) { + int spanStart = spannable.getSpanStart(recipients[i]); + int spanEnd = spannable.getSpanEnd(recipients[i]); + recipients[i].setOriginalText(text.toString().substring(spanStart, spanEnd)); + } + spannable.removeSpan(recipients[i]); + } + if (totalReplaceEnd < text.length()) { + totalReplaceEnd = text.length(); + } + int end = Math.max(totalReplaceStart, totalReplaceEnd); + int start = Math.min(totalReplaceStart, totalReplaceEnd); + SpannableString chipText = new SpannableString(text.subSequence(start, end)); + chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.replace(start, end, chipText); + mMoreChip = moreSpan; + // If adding the +more chip goes over the limit, resize accordingly. + if (!isPhoneQuery() && getLineCount() > mMaxLines) { + setMaxLines(getLineCount()); + } + } + + /** + * Replace the more chip, if it exists, with all of the recipient chips it had + * replaced when the RecipientEditTextView gains focus. + */ + // Visible for testing. + /*package*/ void removeMoreChip() { + if (mMoreChip != null) { + Spannable span = getSpannable(); + span.removeSpan(mMoreChip); + mMoreChip = null; + // Re-add the spans that were hidden. + if (mHiddenSpans != null && mHiddenSpans.size() > 0) { + // Recreate each hidden span. + DrawableRecipientChip[] recipients = getSortedRecipients(); + // Start the search for tokens after the last currently visible + // chip. + if (recipients == null || recipients.length == 0) { + return; + } + int end = span.getSpanEnd(recipients[recipients.length - 1]); + Editable editable = getText(); + for (DrawableRecipientChip chip : mHiddenSpans) { + int chipStart; + int chipEnd; + String token; + // Need to find the location of the chip, again. + token = (String) chip.getOriginalText(); + // As we find the matching recipient for the hidden spans, + // reduce the size of the string we need to search. + // That way, if there are duplicates, we always find the correct + // recipient. + chipStart = editable.toString().indexOf(token, end); + end = chipEnd = Math.min(editable.length(), chipStart + token.length()); + // Only set the span if we found a matching token. + if (chipStart != -1) { + editable.setSpan(chip, chipStart, chipEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + mHiddenSpans.clear(); + } + } + } + + /** + * Show specified chip as selected. If the RecipientChip is just an email address, + * selecting the chip will take the contents of the chip and place it at + * the end of the RecipientEditTextView for inline editing. If the + * RecipientChip is a complete contact, then selecting the chip + * will show a popup window with the address in use highlighted and any other + * alternate addresses for the contact. + * @param currentChip Chip to select. + */ + private void selectChip(DrawableRecipientChip currentChip) { + if (shouldShowEditableText(currentChip)) { + CharSequence text = currentChip.getValue(); + Editable editable = getText(); + Spannable spannable = getSpannable(); + int spanStart = spannable.getSpanStart(currentChip); + int spanEnd = spannable.getSpanEnd(currentChip); + spannable.removeSpan(currentChip); + // Don't need leading space if it's the only chip + if (spanEnd - spanStart == editable.length() - 1) { + spanEnd++; + } + editable.delete(spanStart, spanEnd); + setCursorVisible(true); + setSelection(editable.length()); + editable.append(text); + mSelectedChip = constructChipSpan( + RecipientEntry.constructFakeEntry((String) text, isValid(text.toString()))); + + /* + * Because chip is destroyed and converted into an editable text, we call + * {@link RecipientChipDeletedListener#onRecipientChipDeleted}. For the cases where + * editable text is not shown (i.e. chip is in user's contact list), chip is focused + * and below callback is not called. + */ + if (!mNoChipMode && mRecipientChipDeletedListener != null) { + mRecipientChipDeletedListener.onRecipientChipDeleted(currentChip.getEntry()); + } + } else { + final boolean showAddress = + currentChip.getContactId() == RecipientEntry.GENERATED_CONTACT || + getAdapter().forceShowAddress(); + if (showAddress && mNoChipMode) { + return; + } + + if (isTouchExplorationEnabled()) { + // The chips cannot be touch-explored. However, doing a double-tap results in + // the popup being shown for the last chip, which is of no value. + return; + } + + mSelectedChip = currentChip; + setSelection(getText().getSpanEnd(mSelectedChip)); + setCursorVisible(false); + + if (showAddress) { + showAddress(currentChip, mAddressPopup); + } else { + showAlternates(currentChip, mAlternatesPopup); + } + } + } + + private boolean isTouchExplorationEnabled() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + return false; + } + + final AccessibilityManager accessibilityManager = (AccessibilityManager) + getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); + return accessibilityManager.isTouchExplorationEnabled(); + } + + private boolean shouldShowEditableText(DrawableRecipientChip currentChip) { + long contactId = currentChip.getContactId(); + return contactId == RecipientEntry.INVALID_CONTACT + || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT); + } + + private void showAddress(final DrawableRecipientChip currentChip, final ListPopupWindow popup) { + if (!mAttachedToWindow) { + return; + } + int line = getLayout().getLineForOffset(getChipStart(currentChip)); + int bottomOffset = calculateOffsetFromBottomToTop(line); + // Align the alternates popup with the left side of the View, + // regardless of the position of the chip tapped. + popup.setAnchorView((mAlternatePopupAnchor != null) ? mAlternatePopupAnchor : this); + popup.setVerticalOffset(bottomOffset); + popup.setAdapter(createSingleAddressAdapter(currentChip)); + popup.setOnItemClickListener(new OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + unselectChip(currentChip); + popup.dismiss(); + } + }); + popup.show(); + ListView listView = popup.getListView(); + listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); + listView.setItemChecked(0, true); + } + + /** + * Remove selection from this chip. Unselecting a RecipientChip will render + * the chip without a delete icon and with an unfocused background. This is + * called when the RecipientChip no longer has focus. + */ + private void unselectChip(DrawableRecipientChip chip) { + int start = getChipStart(chip); + int end = getChipEnd(chip); + Editable editable = getText(); + mSelectedChip = null; + if (start == -1 || end == -1) { + Log.w(TAG, "The chip doesn't exist or may be a chip a user was editing"); + setSelection(editable.length()); + commitDefault(); + } else { + getSpannable().removeSpan(chip); + QwertyKeyListener.markAsReplaced(editable, start, end, ""); + editable.removeSpan(chip); + try { + if (!mNoChipMode) { + editable.setSpan(constructChipSpan(chip.getEntry()), + start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } catch (NullPointerException e) { + Log.e(TAG, e.getMessage(), e); + } + } + setCursorVisible(true); + setSelection(editable.length()); + if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { + mAlternatesPopup.dismiss(); + } + } + + @Override + public void onChipDelete() { + if (mSelectedChip != null) { + if (!mNoChipMode && mRecipientChipDeletedListener != null) { + mRecipientChipDeletedListener.onRecipientChipDeleted(mSelectedChip.getEntry()); + } + removeChip(mSelectedChip); + } + dismissPopups(); + } + + @Override + public void onPermissionRequestDismissed() { + if (mPermissionsRequestItemClickedListener != null) { + mPermissionsRequestItemClickedListener.onPermissionRequestDismissed(); + } + dismissDropDown(); + } + + private void dismissPopups() { + if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { + mAlternatesPopup.dismiss(); + } + if (mAddressPopup != null && mAddressPopup.isShowing()) { + mAddressPopup.dismiss(); + } + setSelection(getText().length()); + } + + /** + * Remove the chip and any text associated with it from the RecipientEditTextView. + */ + // Visible for testing. + /* package */void removeChip(DrawableRecipientChip chip) { + Spannable spannable = getSpannable(); + int spanStart = spannable.getSpanStart(chip); + int spanEnd = spannable.getSpanEnd(chip); + Editable text = getText(); + int toDelete = spanEnd; + boolean wasSelected = chip == mSelectedChip; + // Clear that there is a selected chip before updating any text. + if (wasSelected) { + mSelectedChip = null; + } + // Always remove trailing spaces when removing a chip. + while (toDelete >= 0 && toDelete < text.length() && text.charAt(toDelete) == ' ') { + toDelete++; + } + spannable.removeSpan(chip); + if (spanStart >= 0 && toDelete > 0) { + text.delete(spanStart, toDelete); + } + if (wasSelected) { + clearSelectedChip(); + } + } + + /** + * Replace this currently selected chip with a new chip + * that uses the contact data provided. + */ + // Visible for testing. + /*package*/ void replaceChip(DrawableRecipientChip chip, RecipientEntry entry) { + boolean wasSelected = chip == mSelectedChip; + if (wasSelected) { + mSelectedChip = null; + } + int start = getChipStart(chip); + int end = getChipEnd(chip); + getSpannable().removeSpan(chip); + Editable editable = getText(); + entry.setInReplacedChip(true); + CharSequence chipText = createChip(entry); + if (chipText != null) { + if (start == -1 || end == -1) { + Log.e(TAG, "The chip to replace does not exist but should."); + editable.insert(0, chipText); + } else { + if (!TextUtils.isEmpty(chipText)) { + // There may be a space to replace with this chip's new + // associated space. Check for it + int toReplace = end; + while (toReplace >= 0 && toReplace < editable.length() + && editable.charAt(toReplace) == ' ') { + toReplace++; + } + editable.replace(start, toReplace, chipText); + } + } + } + setCursorVisible(true); + if (wasSelected) { + clearSelectedChip(); + } + } + + /** + * Handle click events for a chip. When a selected chip receives a click + * event, see if that event was in the delete icon. If so, delete it. + * Otherwise, unselect the chip. + */ + public void onClick(DrawableRecipientChip chip) { + if (chip.isSelected()) { + clearSelectedChip(); + } + } + + private boolean chipsPending() { + return mPendingChipsCount > 0 || (mHiddenSpans != null && mHiddenSpans.size() > 0); + } + + @Override + public void removeTextChangedListener(TextWatcher watcher) { + mTextWatcher = null; + super.removeTextChangedListener(watcher); + } + + private boolean isValidEmailAddress(String input) { + return !TextUtils.isEmpty(input) && mValidator != null && + mValidator.isValid(input); + } + + private class RecipientTextWatcher implements TextWatcher { + + @Override + public void afterTextChanged(Editable s) { + // If the text has been set to null or empty, make sure we remove + // all the spans we applied. + if (TextUtils.isEmpty(s)) { + // Remove all the chips spans. + Spannable spannable = getSpannable(); + DrawableRecipientChip[] chips = spannable.getSpans(0, getText().length(), + DrawableRecipientChip.class); + for (DrawableRecipientChip chip : chips) { + spannable.removeSpan(chip); + } + if (mMoreChip != null) { + spannable.removeSpan(mMoreChip); + } + clearSelectedChip(); + return; + } + // Get whether there are any recipients pending addition to the + // view. If there are, don't do anything in the text watcher. + if (chipsPending()) { + return; + } + // If the user is editing a chip, don't clear it. + if (mSelectedChip != null) { + if (!isGeneratedContact(mSelectedChip)) { + setCursorVisible(true); + setSelection(getText().length()); + clearSelectedChip(); + } else { + return; + } + } + int length = s.length(); + // Make sure there is content there to parse and that it is + // not just the commit character. + if (length > 1) { + if (lastCharacterIsCommitCharacter(s)) { + commitByCharacter(); + return; + } + char last; + int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1; + int len = length() - 1; + if (end != len) { + last = s.charAt(end); + } else { + last = s.charAt(len); + } + if (last == COMMIT_CHAR_SPACE) { + if (!isPhoneQuery()) { + // Check if this is a valid email address. If it is, + // commit it. + String text = getText().toString(); + int tokenStart = mTokenizer.findTokenStart(text, getSelectionEnd()); + String sub = text.substring(tokenStart, mTokenizer.findTokenEnd(text, + tokenStart)); + if (isValidEmailAddress(sub)) { + commitByCharacter(); + } + } + } + } + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // The user deleted some text OR some text was replaced; check to + // see if the insertion point is on a space + // following a chip. + if (before - count == 1) { + // If the item deleted is a space, and the thing before the + // space is a chip, delete the entire span. + int selStart = getSelectionStart(); + DrawableRecipientChip[] repl = getSpannable().getSpans(selStart, selStart, + DrawableRecipientChip.class); + if (repl.length > 0) { + // There is a chip there! Just remove it. + DrawableRecipientChip toDelete = repl[0]; + Editable editable = getText(); + // Add the separator token. + int deleteStart = editable.getSpanStart(toDelete); + int deleteEnd = editable.getSpanEnd(toDelete) + 1; + if (deleteEnd > editable.length()) { + deleteEnd = editable.length(); + } + if (!mNoChipMode && mRecipientChipDeletedListener != null) { + mRecipientChipDeletedListener.onRecipientChipDeleted(toDelete.getEntry()); + } + editable.removeSpan(toDelete); + editable.delete(deleteStart, deleteEnd); + } + } else if (count > before) { + if (mSelectedChip != null + && isGeneratedContact(mSelectedChip)) { + if (lastCharacterIsCommitCharacter(s)) { + commitByCharacter(); + return; + } + } + } + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // Do nothing. + } + } + + public boolean lastCharacterIsCommitCharacter(CharSequence s) { + char last; + int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1; + int len = length() - 1; + if (end != len) { + last = s.charAt(end); + } else { + last = s.charAt(len); + } + return last == COMMIT_CHAR_COMMA || last == COMMIT_CHAR_SEMICOLON; + } + + public boolean isGeneratedContact(DrawableRecipientChip chip) { + long contactId = chip.getContactId(); + return contactId == RecipientEntry.INVALID_CONTACT + || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT); + } + + /** + * Handles pasting a {@link ClipData} to this {@link RecipientEditTextView}. + */ + // Visible for testing. + void handlePasteClip(ClipData clip) { + if (clip == null) { + // Do nothing. + return; + } + + final ClipDescription clipDesc = clip.getDescription(); + boolean containsSupportedType = clipDesc.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) + || clipDesc.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML); + if (!containsSupportedType) { + return; + } + + removeTextChangedListener(mTextWatcher); + + final ClipDescription clipDescription = clip.getDescription(); + for (int i = 0; i < clip.getItemCount(); i++) { + final String mimeType = clipDescription.getMimeType(i); + final boolean supportedType = ClipDescription.MIMETYPE_TEXT_PLAIN.equals(mimeType) + || ClipDescription.MIMETYPE_TEXT_HTML.equals(mimeType); + if (!supportedType) { + // Only plain text and html can be pasted. + continue; + } + + final CharSequence pastedItem = clip.getItemAt(i).getText(); + if (!TextUtils.isEmpty(pastedItem)) { + final Editable editable = getText(); + final int start = getSelectionStart(); + final int end = getSelectionEnd(); + if (start < 0 || end < 1) { + // No selection. + editable.append(pastedItem); + } else if (start == end) { + // Insert at position. + editable.insert(start, pastedItem); + } else { + editable.append(pastedItem, start, end); + } + handlePasteAndReplace(); + } + } + + mHandler.post(mAddTextWatcher); + } + + @Override + public boolean onTextContextMenuItem(int id) { + if (id == android.R.id.paste) { + ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService( + Context.CLIPBOARD_SERVICE); + handlePasteClip(clipboard.getPrimaryClip()); + return true; + } + return super.onTextContextMenuItem(id); + } + + private void handlePasteAndReplace() { + ArrayList created = handlePaste(); + if (created != null && created.size() > 0) { + // Perform reverse lookups on the pasted contacts. + IndividualReplacementTask replace = new IndividualReplacementTask(); + replace.execute(created); + } + } + + // Visible for testing. + /* package */ArrayList handlePaste() { + String text = getText().toString(); + int originalTokenStart = mTokenizer.findTokenStart(text, getSelectionEnd()); + String lastAddress = text.substring(originalTokenStart); + int tokenStart = originalTokenStart; + int prevTokenStart = 0; + DrawableRecipientChip findChip = null; + ArrayList created = new ArrayList(); + if (tokenStart != 0) { + // There are things before this! + while (tokenStart != 0 && findChip == null && tokenStart != prevTokenStart) { + prevTokenStart = tokenStart; + tokenStart = mTokenizer.findTokenStart(text, tokenStart); + findChip = findChip(tokenStart); + if (tokenStart == originalTokenStart && findChip == null) { + break; + } + } + if (tokenStart != originalTokenStart) { + if (findChip != null) { + tokenStart = prevTokenStart; + } + int tokenEnd; + DrawableRecipientChip createdChip; + while (tokenStart < originalTokenStart) { + tokenEnd = movePastTerminators(mTokenizer.findTokenEnd(getText().toString(), + tokenStart)); + commitChip(tokenStart, tokenEnd, getText()); + createdChip = findChip(tokenStart); + if (createdChip == null) { + break; + } + // +1 for the space at the end. + tokenStart = getSpannable().getSpanEnd(createdChip) + 1; + created.add(createdChip); + } + } + } + // Take a look at the last token. If the token has been completed with a + // commit character, create a chip. + if (isCompletedToken(lastAddress)) { + Editable editable = getText(); + tokenStart = editable.toString().indexOf(lastAddress, originalTokenStart); + commitChip(tokenStart, editable.length(), editable); + created.add(findChip(tokenStart)); + } + return created; + } + + // Visible for testing. + /* package */int movePastTerminators(int tokenEnd) { + if (tokenEnd >= length()) { + return tokenEnd; + } + char atEnd = getText().toString().charAt(tokenEnd); + if (atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON) { + tokenEnd++; + } + // This token had not only an end token character, but also a space + // separating it from the next token. + if (tokenEnd < length() && getText().toString().charAt(tokenEnd) == ' ') { + tokenEnd++; + } + return tokenEnd; + } + + private class RecipientReplacementTask extends AsyncTask { + private DrawableRecipientChip createFreeChip(RecipientEntry entry) { + try { + if (mNoChipMode) { + return null; + } + return constructChipSpan(entry); + } catch (NullPointerException e) { + Log.e(TAG, e.getMessage(), e); + return null; + } + } + + @Override + protected void onPreExecute() { + // Ensure everything is in chip-form already, so we don't have text that slowly gets + // replaced + final List originalRecipients = + new ArrayList(); + final DrawableRecipientChip[] existingChips = getSortedRecipients(); + Collections.addAll(originalRecipients, existingChips); + if (mHiddenSpans != null) { + originalRecipients.addAll(mHiddenSpans); + } + + final List replacements = + new ArrayList(originalRecipients.size()); + + for (final DrawableRecipientChip chip : originalRecipients) { + if (RecipientEntry.isCreatedRecipient(chip.getEntry().getContactId()) + && getSpannable().getSpanStart(chip) != -1) { + replacements.add(createFreeChip(chip.getEntry())); + } else { + replacements.add(null); + } + } + + processReplacements(originalRecipients, replacements); + } + + @Override + protected Void doInBackground(Void... params) { + if (mIndividualReplacements != null) { + mIndividualReplacements.cancel(true); + } + // For each chip in the list, look up the matching contact. + // If there is a match, replace that chip with the matching + // chip. + final ArrayList recipients = + new ArrayList(); + DrawableRecipientChip[] existingChips = getSortedRecipients(); + Collections.addAll(recipients, existingChips); + if (mHiddenSpans != null) { + recipients.addAll(mHiddenSpans); + } + ArrayList addresses = new ArrayList(); + for (DrawableRecipientChip chip : recipients) { + if (chip != null) { + addresses.add(createAddressText(chip.getEntry())); + } + } + final BaseRecipientAdapter adapter = getAdapter(); + adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() { + @Override + public void matchesFound(Map entries) { + final ArrayList replacements = + new ArrayList(); + for (final DrawableRecipientChip temp : recipients) { + RecipientEntry entry = null; + if (temp != null && RecipientEntry.isCreatedRecipient( + temp.getEntry().getContactId()) + && getSpannable().getSpanStart(temp) != -1) { + // Replace this. + entry = createValidatedEntry( + entries.get(tokenizeAddress(temp.getEntry() + .getDestination()))); + } + if (entry != null) { + replacements.add(createFreeChip(entry)); + } else { + replacements.add(null); + } + } + processReplacements(recipients, replacements); + } + + @Override + public void matchesNotFound(final Set unfoundAddresses) { + final List replacements = + new ArrayList(unfoundAddresses.size()); + + for (final DrawableRecipientChip temp : recipients) { + if (temp != null && RecipientEntry.isCreatedRecipient( + temp.getEntry().getContactId()) + && getSpannable().getSpanStart(temp) != -1) { + if (unfoundAddresses.contains( + temp.getEntry().getDestination())) { + replacements.add(createFreeChip(temp.getEntry())); + } else { + replacements.add(null); + } + } else { + replacements.add(null); + } + } + + processReplacements(recipients, replacements); + } + }); + return null; + } + + private void processReplacements(final List recipients, + final List replacements) { + if (replacements != null && replacements.size() > 0) { + final Runnable runnable = new Runnable() { + @Override + public void run() { + final Editable text = new SpannableStringBuilder(getText()); + int i = 0; + for (final DrawableRecipientChip chip : recipients) { + final DrawableRecipientChip replacement = replacements.get(i); + if (replacement != null) { + final RecipientEntry oldEntry = chip.getEntry(); + final RecipientEntry newEntry = replacement.getEntry(); + final boolean isBetter = + RecipientAlternatesAdapter.getBetterRecipient( + oldEntry, newEntry) == newEntry; + + if (isBetter) { + // Find the location of the chip in the text currently shown. + final int start = text.getSpanStart(chip); + if (start != -1) { + // Replacing the entirety of what the chip represented, + // including the extra space dividing it from other chips. + final int end = + Math.min(text.getSpanEnd(chip) + 1, text.length()); + text.removeSpan(chip); + // Make sure we always have just 1 space at the end to + // separate this chip from the next chip. + final SpannableString displayText = + new SpannableString(createAddressText( + replacement.getEntry()).trim() + " "); + displayText.setSpan(replacement, 0, + displayText.length() - 1, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + // Replace the old text we found with with the new display + // text, which now may also contain the display name of the + // recipient. + text.replace(start, end, displayText); + replacement.setOriginalText(displayText.toString()); + replacements.set(i, null); + + recipients.set(i, replacement); + } + } + } + i++; + } + setText(text); + } + }; + + if (Looper.myLooper() == Looper.getMainLooper()) { + runnable.run(); + } else { + mHandler.post(runnable); + } + } + } + } + + private class IndividualReplacementTask + extends AsyncTask, Void, Void> { + @Override + protected Void doInBackground(ArrayList... params) { + // For each chip in the list, look up the matching contact. + // If there is a match, replace that chip with the matching + // chip. + final ArrayList originalRecipients = params[0]; + ArrayList addresses = new ArrayList(); + for (DrawableRecipientChip chip : originalRecipients) { + if (chip != null) { + addresses.add(createAddressText(chip.getEntry())); + } + } + final BaseRecipientAdapter adapter = getAdapter(); + adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() { + + @Override + public void matchesFound(Map entries) { + for (final DrawableRecipientChip temp : originalRecipients) { + if (RecipientEntry.isCreatedRecipient(temp.getEntry() + .getContactId()) + && getSpannable().getSpanStart(temp) != -1) { + // Replace this. + final RecipientEntry entry = createValidatedEntry(entries + .get(tokenizeAddress(temp.getEntry().getDestination()) + .toLowerCase())); + if (entry != null) { + mHandler.post(new Runnable() { + @Override + public void run() { + replaceChip(temp, entry); + } + }); + } + } + } + } + + @Override + public void matchesNotFound(final Set unfoundAddresses) { + // No action required + } + }); + return null; + } + } + + + /** + * MoreImageSpan is a simple class created for tracking the existence of a + * more chip across activity restarts/ + */ + private class MoreImageSpan extends ReplacementDrawableSpan { + public MoreImageSpan(Drawable b) { + super(b); + setExtraMargin(mLineSpacingExtra); + } + } + + @Override + public boolean onDown(MotionEvent e) { + return false; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + // Do nothing. + return false; + } + + @Override + public void onLongPress(MotionEvent event) { + if (mSelectedChip != null) { + return; + } + float x = event.getX(); + float y = event.getY(); + final int offset = putOffsetInRange(x, y); + DrawableRecipientChip currentChip = findChip(offset); + if (currentChip != null) { + if (mDragEnabled) { + // Start drag-and-drop for the selected chip. + startDrag(currentChip); + } else { + // Copy the selected chip email address. + showCopyDialog(currentChip.getEntry().getDestination()); + } + } + } + + // The following methods are used to provide some functionality on older versions of Android + // These methods were copied out of JB MR2's TextView + ///////////////////////////////////////////////// + private int supportGetOffsetForPosition(float x, float y) { + if (getLayout() == null) return -1; + final int line = supportGetLineAtCoordinate(y); + return supportGetOffsetAtCoordinate(line, x); + } + + private float supportConvertToLocalHorizontalCoordinate(float x) { + x -= getTotalPaddingLeft(); + // Clamp the position to inside of the view. + x = Math.max(0.0f, x); + x = Math.min(getWidth() - getTotalPaddingRight() - 1, x); + x += getScrollX(); + return x; + } + + private int supportGetLineAtCoordinate(float y) { + y -= getTotalPaddingLeft(); + // Clamp the position to inside of the view. + y = Math.max(0.0f, y); + y = Math.min(getHeight() - getTotalPaddingBottom() - 1, y); + y += getScrollY(); + return getLayout().getLineForVertical((int) y); + } + + private int supportGetOffsetAtCoordinate(int line, float x) { + x = supportConvertToLocalHorizontalCoordinate(x); + return getLayout().getOffsetForHorizontal(line, x); + } + ///////////////////////////////////////////////// + + /** + * Enables drag-and-drop for chips. + */ + public void enableDrag() { + mDragEnabled = true; + } + + /** + * Starts drag-and-drop for the selected chip. + */ + private void startDrag(DrawableRecipientChip currentChip) { + String address = currentChip.getEntry().getDestination(); + ClipData data = ClipData.newPlainText(address, address + COMMIT_CHAR_COMMA); + + // Start drag mode. + startDrag(data, new RecipientChipShadow(currentChip), null, 0); + + // Remove the current chip, so drag-and-drop will result in a move. + // TODO (phamm): consider readd this chip if it's dropped outside a target. + removeChip(currentChip); + } + + /** + * Handles drag event. + */ + @Override + public boolean onDragEvent(@NonNull DragEvent event) { + switch (event.getAction()) { + case DragEvent.ACTION_DRAG_STARTED: + // Only handle plain text drag and drop. + return event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN); + case DragEvent.ACTION_DRAG_ENTERED: + requestFocus(); + return true; + case DragEvent.ACTION_DROP: + handlePasteClip(event.getClipData()); + return true; + } + return false; + } + + /** + * Drag shadow for a {@link DrawableRecipientChip}. + */ + private final class RecipientChipShadow extends DragShadowBuilder { + private final DrawableRecipientChip mChip; + + public RecipientChipShadow(DrawableRecipientChip chip) { + mChip = chip; + } + + @Override + public void onProvideShadowMetrics(@NonNull Point shadowSize, + @NonNull Point shadowTouchPoint) { + Rect rect = mChip.getBounds(); + shadowSize.set(rect.width(), rect.height()); + shadowTouchPoint.set(rect.centerX(), rect.centerY()); + } + + @Override + public void onDrawShadow(@NonNull Canvas canvas) { + mChip.draw(canvas); + } + } + + private void showCopyDialog(final String address) { + final Context context = getContext(); + if (!mAttachedToWindow || context == null || !(context instanceof Activity)) { + return; + } + + final DialogFragment fragment = CopyDialog.newInstance(address); + fragment.show(((Activity) context).getFragmentManager(), CopyDialog.TAG); + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + // Do nothing. + return false; + } + + @Override + public void onShowPress(MotionEvent e) { + // Do nothing. + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + // Do nothing. + return false; + } + + protected boolean isPhoneQuery() { + return getAdapter() != null + && getAdapter().getQueryType() == BaseRecipientAdapter.QUERY_TYPE_PHONE; + } + + @Override + public BaseRecipientAdapter getAdapter() { + return (BaseRecipientAdapter) super.getAdapter(); + } + + /** + * Append a new {@link RecipientEntry} to the end of the recipient chips, leaving any + * unfinished text at the end. + */ + public void appendRecipientEntry(final RecipientEntry entry) { + clearComposingText(); + + final Editable editable = getText(); + int chipInsertionPoint = 0; + + // Find the end of last chip and see if there's any unchipified text. + final DrawableRecipientChip[] recips = getSortedRecipients(); + if (recips != null && recips.length > 0) { + final DrawableRecipientChip last = recips[recips.length - 1]; + // The chip will be inserted at the end of last chip + 1. All the unfinished text after + // the insertion point will be kept untouched. + chipInsertionPoint = editable.getSpanEnd(last) + 1; + } + + final CharSequence chip = createChip(entry); + if (chip != null) { + editable.insert(chipInsertionPoint, chip); + } + } + + /** + * Remove all chips matching the given RecipientEntry. + */ + public void removeRecipientEntry(final RecipientEntry entry) { + final DrawableRecipientChip[] recips = getText() + .getSpans(0, getText().length(), DrawableRecipientChip.class); + + for (final DrawableRecipientChip recipient : recips) { + final RecipientEntry existingEntry = recipient.getEntry(); + if (existingEntry != null && existingEntry.isValid() && + existingEntry.isSamePerson(entry)) { + removeChip(recipient); + } + } + } + + public void setAlternatePopupAnchor(View v) { + mAlternatePopupAnchor = v; + } + + @Override + public void setVisibility(int visibility) { + super.setVisibility(visibility); + + if (visibility != GONE && mRequiresShrinkWhenNotGone) { + mRequiresShrinkWhenNotGone = false; + mHandler.post(mDelayedShrink); + } + } + + private static class ChipBitmapContainer { + Bitmap bitmap; + // information used for positioning the loaded icon + boolean loadIcon = true; + float left; + float top; + float right; + float bottom; + // information used for positioning the warning icon + float warningIconLeft; + float warningIconTop; + float warningIconRight; + float warningIconBottom; + } +} diff --git a/modules/chips/src/com/android/ex/chips/RecipientEntry.java b/modules/chips/src/com/android/ex/chips/RecipientEntry.java new file mode 100644 index 0000000..a42794f --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/RecipientEntry.java @@ -0,0 +1,377 @@ +/* + * 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.ex.chips; + +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.DisplayNameSources; +import androidx.annotation.DrawableRes; +import android.text.util.Rfc822Token; +import android.text.util.Rfc822Tokenizer; + +/** + * Represents one entry inside recipient auto-complete list. + */ +public class RecipientEntry { + /* package */ static final int INVALID_CONTACT = -1; + /** + * A GENERATED_CONTACT is one that was created based entirely on + * information passed in to the RecipientEntry from an external source + * that is not a real contact. + */ + /* package */ static final int GENERATED_CONTACT = -2; + + /** Used when {@link #mDestinationType} is invalid and thus shouldn't be used for display. */ + public static final int INVALID_DESTINATION_TYPE = -1; + + public static final int ENTRY_TYPE_PERSON = 0; + + /** + * Entry of this type represents the item in auto-complete that asks user to grant permissions + * to the app. This permission model is introduced in M platform. + * + *

Entries of this type should have {@link #mPermissions} set as well. + */ + public static final int ENTRY_TYPE_PERMISSION_REQUEST = 1; + + public static final int ENTRY_TYPE_SIZE = 2; + + private final int mEntryType; + + /** + * True when this entry is the first entry in a group, which should have a photo and display + * name, while the second or later entries won't. + */ + private boolean mIsFirstLevel; + private final String mDisplayName; + + /** Destination for this contact entry. Would be an email address or a phone number. */ + private final String mDestination; + /** Type of the destination like {@link Email#TYPE_HOME} */ + private final int mDestinationType; + /** + * Label of the destination which will be used when type was {@link Email#TYPE_CUSTOM}. + * Can be null when {@link #mDestinationType} is {@link #INVALID_DESTINATION_TYPE}. + */ + private final String mDestinationLabel; + /** ID for the person */ + private final long mContactId; + /** ID for the directory this contact came from, or null */ + private final Long mDirectoryId; + /** ID for the destination */ + private final long mDataId; + + private final Uri mPhotoThumbnailUri; + /** Configures showing the icon in the chip */ + private final boolean mShouldDisplayIcon; + + private boolean mIsValid; + /** + * This can be updated after this object being constructed, when the photo is fetched + * from remote directories. + */ + private byte[] mPhotoBytes; + + @DrawableRes private int mIndicatorIconId; + private String mIndicatorText; + + /** See {@link android.provider.ContactsContract.ContactsColumns#LOOKUP_KEY} */ + private final String mLookupKey; + + /** Should be used when type is {@link #ENTRY_TYPE_PERMISSION_REQUEST}. */ + private final String[] mPermissions; + + /** Whether RecipientEntry is in a replaced chip or not. */ + private boolean mInReplacedChip; + + protected RecipientEntry(int entryType, String displayName, String destination, + int destinationType, String destinationLabel, long contactId, Long directoryId, + long dataId, Uri photoThumbnailUri, boolean isFirstLevel, boolean isValid, + String lookupKey, String[] permissions) { + this(entryType, displayName, destination, destinationType, + destinationLabel, contactId, directoryId, dataId, photoThumbnailUri, + true /* shouldDisplayIcon */, isFirstLevel, isValid, lookupKey, permissions); + } + + protected RecipientEntry(int entryType, String displayName, String destination, + int destinationType, String destinationLabel, long contactId, Long directoryId, + long dataId, Uri photoThumbnailUri, boolean shouldDisplayIcon, + boolean isFirstLevel, boolean isValid, String lookupKey, String[] permissions) { + mEntryType = entryType; + mIsFirstLevel = isFirstLevel; + mDisplayName = displayName; + mDestination = destination; + mDestinationType = destinationType; + mDestinationLabel = destinationLabel; + mContactId = contactId; + mDirectoryId = directoryId; + mDataId = dataId; + mPhotoThumbnailUri = photoThumbnailUri; + mShouldDisplayIcon = shouldDisplayIcon; + mPhotoBytes = null; + mIsValid = isValid; + mLookupKey = lookupKey; + mIndicatorIconId = 0; + mIndicatorText = null; + mPermissions = permissions; + } + + protected RecipientEntry(int entryType, String displayName, String destination, + int destinationType, String destinationLabel, long contactId, Long directoryId, + long dataId, Uri photoThumbnailUri, boolean isFirstLevel, boolean isValid, + String lookupKey) { + this(entryType, displayName, destination, destinationType, destinationLabel, + contactId, directoryId, dataId, photoThumbnailUri, isFirstLevel, isValid, + lookupKey, null); + } + + public boolean isValid() { + return mIsValid; + } + + /** + * Determine if this was a RecipientEntry created from recipient info or + * an entry from contacts. + */ + public static boolean isCreatedRecipient(long id) { + return id == RecipientEntry.INVALID_CONTACT || id == RecipientEntry.GENERATED_CONTACT; + } + + /** + * Construct a RecipientEntry from just an address that has been entered. + * This address has not been resolved to a contact and therefore does not + * have a contact id or photo. + */ + public static RecipientEntry constructFakeEntry(final String address, final boolean isValid) { + final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address); + final String tokenizedAddress = tokens.length > 0 ? tokens[0].getAddress() : address; + + return new RecipientEntry(ENTRY_TYPE_PERSON, tokenizedAddress, tokenizedAddress, + INVALID_DESTINATION_TYPE, null, INVALID_CONTACT, null /* directoryId */, + INVALID_CONTACT, null, true, isValid, null /* lookupKey */, null /* permissions */); + } + + /** + * Construct a RecipientEntry from just a phone number. + */ + public static RecipientEntry constructFakePhoneEntry(final String phoneNumber, + final boolean isValid) { + return new RecipientEntry(ENTRY_TYPE_PERSON, phoneNumber, phoneNumber, + INVALID_DESTINATION_TYPE, null, INVALID_CONTACT, null /* directoryId */, + INVALID_CONTACT, null, true, isValid, null /* lookupKey */, null /* permissions */); + } + + /** + * Construct a RecipientEntry from just an address that has been entered + * with both an associated display name. This address has not been resolved + * to a contact and therefore does not have a contact id or photo. + */ + public static RecipientEntry constructGeneratedEntry(String display, String address, + boolean isValid) { + return new RecipientEntry(ENTRY_TYPE_PERSON, display, address, INVALID_DESTINATION_TYPE, + null, GENERATED_CONTACT, null /* directoryId */, GENERATED_CONTACT, null, true, + isValid, null /* lookupKey */, null /* permissions */); + } + + public static RecipientEntry constructTopLevelEntry(String displayName, int displayNameSource, + String destination, int destinationType, String destinationLabel, long contactId, + Long directoryId, long dataId, Uri photoThumbnailUri, boolean isValid, + String lookupKey) { + return new RecipientEntry(ENTRY_TYPE_PERSON, pickDisplayName(displayNameSource, + displayName, destination), destination, destinationType, destinationLabel, + contactId, directoryId, dataId, photoThumbnailUri, true, isValid, lookupKey, + null /* permissions */); + } + + public static RecipientEntry constructTopLevelEntry(String displayName, int displayNameSource, + String destination, int destinationType, String destinationLabel, long contactId, + Long directoryId, long dataId, String thumbnailUriAsString, boolean isValid, + String lookupKey) { + return new RecipientEntry(ENTRY_TYPE_PERSON, pickDisplayName(displayNameSource, + displayName, destination), destination, destinationType, destinationLabel, + contactId, directoryId, dataId, (thumbnailUriAsString != null + ? Uri.parse(thumbnailUriAsString) : null), true, isValid, lookupKey, + null /* permissions */); + } + + public static RecipientEntry constructSecondLevelEntry(String displayName, + int displayNameSource, String destination, int destinationType, + String destinationLabel, long contactId, Long directoryId, long dataId, + String thumbnailUriAsString, boolean isValid, String lookupKey) { + return new RecipientEntry(ENTRY_TYPE_PERSON, pickDisplayName(displayNameSource, + displayName, destination), destination, destinationType, destinationLabel, + contactId, directoryId, dataId, (thumbnailUriAsString != null + ? Uri.parse(thumbnailUriAsString) : null), false, isValid, lookupKey, + null /* permissions */); + } + + public static RecipientEntry constructPermissionEntry(String[] permissions) { + return new RecipientEntry( + ENTRY_TYPE_PERMISSION_REQUEST, + "" /* displayName */, + "" /* destination */, + Email.TYPE_CUSTOM, + "" /* destinationLabel */, + INVALID_CONTACT, + null /* directoryId */, + INVALID_CONTACT, + null /* photoThumbnailUri */, + true /* isFirstLevel*/, + false /* isValid */, + null /* lookupKey */, + permissions); + } + + /** + * @return the display name for the entry. If the display name source is larger than + * {@link DisplayNameSources#PHONE} we use the contact's display name, but if not, + * i.e. the display name came from an email address or a phone number, we don't use it + * to avoid confusion and just use the destination instead. + */ + private static String pickDisplayName(int displayNameSource, String displayName, + String destination) { + return (displayNameSource > DisplayNameSources.PHONE) ? displayName : destination; + } + + public int getEntryType() { + return mEntryType; + } + + public String getDisplayName() { + return mDisplayName; + } + + public String getDestination() { + return mDestination; + } + + public int getDestinationType() { + return mDestinationType; + } + + public String getDestinationLabel() { + return mDestinationLabel; + } + + public long getContactId() { + return mContactId; + } + + public Long getDirectoryId() { + return mDirectoryId; + } + + public long getDataId() { + return mDataId; + } + + public boolean isFirstLevel() { + return mIsFirstLevel; + } + + public Uri getPhotoThumbnailUri() { + return mPhotoThumbnailUri; + } + + /** Indicates whether the icon in the chip is displayed or not. */ + public boolean shouldDisplayIcon() { + return mShouldDisplayIcon; + } + + /** This can be called outside main Looper thread. */ + public synchronized void setPhotoBytes(byte[] photoBytes) { + mPhotoBytes = photoBytes; + } + + /** This can be called outside main Looper thread. */ + public synchronized byte[] getPhotoBytes() { + return mPhotoBytes; + } + + /** + * Used together with {@link #ENTRY_TYPE_PERMISSION_REQUEST} and indicates what permissions we + * need to ask user to grant. + */ + public String[] getPermissions() { + return mPermissions; + } + + public String getLookupKey() { + return mLookupKey; + } + + public boolean isSelectable() { + return mEntryType == ENTRY_TYPE_PERSON || mEntryType == ENTRY_TYPE_PERMISSION_REQUEST; + } + + @Override + public String toString() { + return mDisplayName + " <" + mDestination + ">, isValid=" + mIsValid; + } + + /** + * Returns if entry represents the same person as this instance. The default implementation + * checks whether the contact ids are the same, and subclasses may opt to override this. + */ + public boolean isSamePerson(final RecipientEntry entry) { + return entry != null && mContactId == entry.mContactId; + } + + /** + * Returns the resource ID for the indicator icon, or 0 if no icon should be displayed. + */ + @DrawableRes + public int getIndicatorIconId() { + return mIndicatorIconId; + } + + /** + * Sets the indicator icon to the given resource ID. Set to 0 to display no icon. + */ + public void setIndicatorIconId(@DrawableRes int indicatorIconId) { + mIndicatorIconId = indicatorIconId; + } + + /** + * Get the indicator text, or null if no text should be displayed. + */ + public String getIndicatorText() { + return mIndicatorText; + } + + /** + * Set the indicator text. Set to null for no text to be displayed. + */ + public void setIndicatorText(String indicatorText) { + mIndicatorText = indicatorText; + } + + /** + * Get whether this RecipientEntry is in a replaced chip or not. Replaced chip only occurs + * if {@link RecipientEditTextView} uses a replacement chip for the entry. + */ + public boolean getInReplacedChip() { + return mInReplacedChip; + } + + /** + * Sets {@link #mInReplacedChip} to {@param inReplacedChip}. + */ + public void setInReplacedChip(boolean inReplacedChip) { + mInReplacedChip = inReplacedChip; + } +} diff --git a/modules/chips/src/com/android/ex/chips/SingleRecipientArrayAdapter.java b/modules/chips/src/com/android/ex/chips/SingleRecipientArrayAdapter.java new file mode 100644 index 0000000..f08746e --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/SingleRecipientArrayAdapter.java @@ -0,0 +1,51 @@ +/* + * 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.ex.chips; + +import android.content.Context; +import android.graphics.drawable.StateListDrawable; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import com.android.ex.chips.DropdownChipLayouter.AdapterType; + +class SingleRecipientArrayAdapter extends ArrayAdapter { + private final DropdownChipLayouter mDropdownChipLayouter; + private final StateListDrawable mDeleteDrawable; + + public SingleRecipientArrayAdapter(Context context, RecipientEntry entry, + DropdownChipLayouter dropdownChipLayouter) { + this(context, entry, dropdownChipLayouter, null); + } + + public SingleRecipientArrayAdapter(Context context, RecipientEntry entry, + DropdownChipLayouter dropdownChipLayouter, StateListDrawable deleteDrawable) { + super(context, + dropdownChipLayouter.getAlternateItemLayoutResId(AdapterType.SINGLE_RECIPIENT), + new RecipientEntry[] { entry }); + + mDropdownChipLayouter = dropdownChipLayouter; + mDeleteDrawable = deleteDrawable; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + return mDropdownChipLayouter.bindView(convertView, parent, getItem(position), position, + AdapterType.SINGLE_RECIPIENT, null, mDeleteDrawable); + } +} diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/BaseRecipientChip.java b/modules/chips/src/com/android/ex/chips/recipientchip/BaseRecipientChip.java new file mode 100644 index 0000000..8012b5c --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/recipientchip/BaseRecipientChip.java @@ -0,0 +1,83 @@ +/* + * 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.ex.chips.recipientchip; + +import com.android.ex.chips.RecipientEntry; + +/** + * BaseRecipientChip defines an object that contains information relevant to a + * particular recipient. + */ +interface BaseRecipientChip { + + /** + * Set the selected state of the chip. + */ + void setSelected(boolean selected); + + /** + * Return true if the chip is selected. + */ + boolean isSelected(); + + /** + * Get the text displayed in the chip. + */ + CharSequence getDisplay(); + + /** + * Get the text value this chip represents. + */ + CharSequence getValue(); + + /** + * Get the id of the contact associated with this chip. + */ + long getContactId(); + + /** + * Get the directory id of the contact associated with this chip. + */ + Long getDirectoryId(); + + /** + * Get the directory lookup key associated with this chip, or null. + */ + String getLookupKey(); + + /** + * Get the id of the data associated with this chip. + */ + long getDataId(); + + /** + * Get associated RecipientEntry. + */ + RecipientEntry getEntry(); + + /** + * Set the text in the edittextview originally associated with this chip + * before any reverse lookups. + */ + void setOriginalText(String text); + + /** + * Set the text in the edittextview originally associated with this chip + * before any reverse lookups. + */ + CharSequence getOriginalText(); +} diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/DrawableRecipientChip.java b/modules/chips/src/com/android/ex/chips/recipientchip/DrawableRecipientChip.java new file mode 100644 index 0000000..49c18ee --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/recipientchip/DrawableRecipientChip.java @@ -0,0 +1,42 @@ +/* + * 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.ex.chips.recipientchip; + +import android.graphics.Canvas; +import android.graphics.Rect; + +/** + * RecipientChip defines a drawable object that contains information relevant to a + * particular recipient. + */ +public interface DrawableRecipientChip extends BaseRecipientChip { + /** + * Get the bounds of the chip; may be 0,0 if it is not visibly rendered. + */ + Rect getBounds(); + + /** + * Gets the bounds of the warning icon on the chip; may be 0,0 if not visibly rendered or if + * there is no warning icon. + */ + Rect getWarningIconBounds(); + + /** + * Draw the chip. + */ + void draw(Canvas canvas); +} diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/InvisibleRecipientChip.java b/modules/chips/src/com/android/ex/chips/recipientchip/InvisibleRecipientChip.java new file mode 100644 index 0000000..fed9dfe --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/recipientchip/InvisibleRecipientChip.java @@ -0,0 +1,121 @@ +/* + * 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.ex.chips.recipientchip; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.text.style.ReplacementSpan; + +import com.android.ex.chips.RecipientEntry; + +/** + * RecipientChip defines a span that contains information relevant to a + * particular recipient. + */ +public class InvisibleRecipientChip extends ReplacementSpan implements DrawableRecipientChip { + private final SimpleRecipientChip mDelegate; + private static final Rect NULL_RECTANGLE = new Rect(0, 0, 0, 0); + + public InvisibleRecipientChip(final RecipientEntry entry) { + super(); + + mDelegate = new SimpleRecipientChip(entry); + } + + @Override + public void setSelected(final boolean selected) { + mDelegate.setSelected(selected); + } + + @Override + public boolean isSelected() { + return mDelegate.isSelected(); + } + + @Override + public CharSequence getDisplay() { + return mDelegate.getDisplay(); + } + + @Override + public CharSequence getValue() { + return mDelegate.getValue(); + } + + @Override + public long getContactId() { + return mDelegate.getContactId(); + } + + @Override + public Long getDirectoryId() { + return mDelegate.getDirectoryId(); + } + + @Override + public String getLookupKey() { + return mDelegate.getLookupKey(); + } + + @Override + public long getDataId() { + return mDelegate.getDataId(); + } + + @Override + public RecipientEntry getEntry() { + return mDelegate.getEntry(); + } + + @Override + public void setOriginalText(final String text) { + mDelegate.setOriginalText(text); + } + + @Override + public CharSequence getOriginalText() { + return mDelegate.getOriginalText(); + } + + @Override + public void draw(final Canvas canvas, final CharSequence text, final int start, final int end, + final float x, final int top, final int y, final int bottom, final Paint paint) { + // Do nothing. + } + + @Override + public int getSize(final Paint paint, final CharSequence text, final int start, final int end, + final Paint.FontMetricsInt fm) { + return 0; + } + + @Override + public Rect getBounds() { + return NULL_RECTANGLE; + } + + @Override + public Rect getWarningIconBounds() { + return NULL_RECTANGLE; + } + + @Override + public void draw(final Canvas canvas) { + // do nothing. + } +} diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/ReplacementDrawableSpan.java b/modules/chips/src/com/android/ex/chips/recipientchip/ReplacementDrawableSpan.java new file mode 100644 index 0000000..5ef445d --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/recipientchip/ReplacementDrawableSpan.java @@ -0,0 +1,63 @@ +package com.android.ex.chips.recipientchip; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.text.style.ReplacementSpan; + +/** + * ReplacementSpan that properly draws the drawable that is centered around the text + * without changing the default text size or layout. + */ +public class ReplacementDrawableSpan extends ReplacementSpan { + protected static final Paint sWorkPaint = new Paint(); + + protected Drawable mDrawable; + private float mExtraMargin; + + public ReplacementDrawableSpan(Drawable drawable) { + super(); + mDrawable = drawable; + } + + public void setExtraMargin(float margin) { + mExtraMargin = margin; + } + + private void setupFontMetrics(Paint.FontMetricsInt fm, Paint paint) { + sWorkPaint.set(paint); + if (fm != null) { + sWorkPaint.getFontMetricsInt(fm); + + final Rect bounds = getBounds(); + final int textHeight = fm.descent - fm.ascent; + final int halfMargin = (int) mExtraMargin / 2; + fm.ascent = Math.min(fm.top, fm.top + (textHeight - bounds.bottom) / 2) - halfMargin; + fm.descent = Math.max(fm.bottom, fm.bottom + (bounds.bottom - textHeight) / 2) + + halfMargin; + fm.top = fm.ascent; + fm.bottom = fm.descent; + } + } + + @Override + public int getSize(Paint paint, CharSequence text, int i, int i2, Paint.FontMetricsInt fm) { + setupFontMetrics(fm, paint); + return getBounds().right; + } + + @Override + public void draw(Canvas canvas, CharSequence charSequence, int start, int end, float x, int top, + int y, int bottom, Paint paint) { + canvas.save(); + int transY = (bottom - mDrawable.getBounds().bottom + top) / 2; + canvas.translate(x, transY); + mDrawable.draw(canvas); + canvas.restore(); + } + + protected Rect getBounds() { + return mDrawable.getBounds(); + } +} diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/SimpleRecipientChip.java b/modules/chips/src/com/android/ex/chips/recipientchip/SimpleRecipientChip.java new file mode 100644 index 0000000..533f53f --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/recipientchip/SimpleRecipientChip.java @@ -0,0 +1,115 @@ +/* + * 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.ex.chips.recipientchip; + +import com.android.ex.chips.RecipientEntry; + +import android.text.TextUtils; + +class SimpleRecipientChip implements BaseRecipientChip { + private final CharSequence mDisplay; + + private final CharSequence mValue; + + private final long mContactId; + + private final Long mDirectoryId; + + private final String mLookupKey; + + private final long mDataId; + + private final RecipientEntry mEntry; + + private boolean mSelected = false; + + private CharSequence mOriginalText; + + public SimpleRecipientChip(final RecipientEntry entry) { + mDisplay = entry.getDisplayName(); + mValue = entry.getDestination().trim(); + mContactId = entry.getContactId(); + mDirectoryId = entry.getDirectoryId(); + mLookupKey = entry.getLookupKey(); + mDataId = entry.getDataId(); + mEntry = entry; + } + + @Override + public void setSelected(final boolean selected) { + mSelected = selected; + } + + @Override + public boolean isSelected() { + return mSelected; + } + + @Override + public CharSequence getDisplay() { + return mDisplay; + } + + @Override + public CharSequence getValue() { + return mValue; + } + + @Override + public long getContactId() { + return mContactId; + } + + @Override + public Long getDirectoryId() { + return mDirectoryId; + } + + @Override + public String getLookupKey() { + return mLookupKey; + } + + @Override + public long getDataId() { + return mDataId; + } + + @Override + public RecipientEntry getEntry() { + return mEntry; + } + + @Override + public void setOriginalText(final String text) { + if (TextUtils.isEmpty(text)) { + mOriginalText = text; + } else { + mOriginalText = text.trim(); + } + } + + @Override + public CharSequence getOriginalText() { + return !TextUtils.isEmpty(mOriginalText) ? mOriginalText : mEntry.getDestination(); + } + + @Override + public String toString() { + return mDisplay + " <" + mValue + ">"; + } +} \ No newline at end of file diff --git a/modules/chips/src/com/android/ex/chips/recipientchip/VisibleRecipientChip.java b/modules/chips/src/com/android/ex/chips/recipientchip/VisibleRecipientChip.java new file mode 100644 index 0000000..7976a89 --- /dev/null +++ b/modules/chips/src/com/android/ex/chips/recipientchip/VisibleRecipientChip.java @@ -0,0 +1,116 @@ +/* + * 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.ex.chips.recipientchip; + +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; + +import com.android.ex.chips.RecipientEntry; + +/** + * VisibleRecipientChip defines a ReplacementSpan that contains information relevant to a + * particular recipient and renders a background asset to go with it. + */ +public class VisibleRecipientChip extends ReplacementDrawableSpan implements DrawableRecipientChip { + private final SimpleRecipientChip mDelegate; + private Rect mWarningIconBounds = new Rect(0, 0, 0, 0); + + public VisibleRecipientChip(final Drawable drawable, final RecipientEntry entry) { + super(drawable); + mDelegate = new SimpleRecipientChip(entry); + } + + @Override + public void setSelected(final boolean selected) { + mDelegate.setSelected(selected); + } + + @Override + public boolean isSelected() { + return mDelegate.isSelected(); + } + + @Override + public CharSequence getDisplay() { + return mDelegate.getDisplay(); + } + + @Override + public CharSequence getValue() { + return mDelegate.getValue(); + } + + @Override + public long getContactId() { + return mDelegate.getContactId(); + } + + @Override + public Long getDirectoryId() { + return mDelegate.getDirectoryId(); + } + + @Override + public String getLookupKey() { + return mDelegate.getLookupKey(); + } + + @Override + public long getDataId() { + return mDelegate.getDataId(); + } + + @Override + public RecipientEntry getEntry() { + return mDelegate.getEntry(); + } + + @Override + public void setOriginalText(final String text) { + mDelegate.setOriginalText(text); + } + + @Override + public CharSequence getOriginalText() { + return mDelegate.getOriginalText(); + } + + @Override + public Rect getBounds() { + return super.getBounds(); + } + + @Override + public void draw(final Canvas canvas) { + mDrawable.draw(canvas); + } + + @Override + public String toString() { + return mDelegate.toString(); + } + + @Override + public Rect getWarningIconBounds() { + return mWarningIconBounds; + } + + public void setWarningIconBounds(Rect warningIconBounds) { + mWarningIconBounds = warningIconBounds; + } +} diff --git a/modules/chips/tests/Android.mk b/modules/chips/tests/Android.mk new file mode 100644 index 0000000..fa11974 --- /dev/null +++ b/modules/chips/tests/Android.mk @@ -0,0 +1,29 @@ +# 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. +LOCAL_PATH:= $(call my-dir) +include $(CLEAR_VARS) + +LOCAL_SRC_FILES := $(call all-java-files-under, src) + +LOCAL_PACKAGE_NAME := libChipsTests +LOCAL_MODULE_TAGS := tests +LOCAL_SDK_VERSION := current +LOCAL_CERTIFICATE := platform +LOCAL_STATIC_JAVA_LIBRARIES := libchips legacy-android-test junit +LOCAL_RESOURCE_DIR := frameworks/opt/chips/res/ +LOCAL_AAPT_FLAGS := --auto-add-overlay +LOCAL_AAPT_FLAGS += --extra-packages com.android.ex.chips + +include $(BUILD_PACKAGE) + diff --git a/modules/chips/tests/AndroidManifest.xml b/modules/chips/tests/AndroidManifest.xml new file mode 100644 index 0000000..b2b307e --- /dev/null +++ b/modules/chips/tests/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + diff --git a/modules/chips/tests/src/com/android/ex/chips/ChipsTest.java b/modules/chips/tests/src/com/android/ex/chips/ChipsTest.java new file mode 100644 index 0000000..6e48677 --- /dev/null +++ b/modules/chips/tests/src/com/android/ex/chips/ChipsTest.java @@ -0,0 +1,1056 @@ +/* + * 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.ex.chips; + +import android.annotation.TargetApi; +import android.content.ClipData; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.test.AndroidTestCase; +import android.test.suitebuilder.annotation.SmallTest; +import android.text.Editable; +import android.text.SpannableStringBuilder; +import android.text.util.Rfc822Tokenizer; +import android.widget.TextView; + +import com.android.ex.chips.recipientchip.DrawableRecipientChip; +import com.android.ex.chips.recipientchip.ReplacementDrawableSpan; +import com.android.ex.chips.recipientchip.VisibleRecipientChip; + +import java.util.regex.Pattern; + +@SmallTest +public class ChipsTest extends AndroidTestCase { + private DrawableRecipientChip[] mMockRecips; + + private RecipientEntry[] mMockEntries; + + private Rfc822Tokenizer mTokenizer; + + private Editable mEditable; + + class BaseMockRecipientEditTextView extends RecipientEditTextView { + + public BaseMockRecipientEditTextView(Context context) { + super(context, null); + mTokenizer = new Rfc822Tokenizer(); + setTokenizer(mTokenizer); + } + + @Override + public DrawableRecipientChip[] getSortedRecipients() { + return mMockRecips; + } + + @Override + public int getLineHeight() { + return 48; + } + + @Override + Drawable getChipBackground(RecipientEntry contact) { + return createChipBackground(); + } + + @Override + public int getViewWidth() { + return 100; + } + } + + class MockRecipientEditTextView extends BaseMockRecipientEditTextView { + + public MockRecipientEditTextView(Context context) { + super(context); + mTokenizer = new Rfc822Tokenizer(); + setTokenizer(mTokenizer); + } + + @Override + public DrawableRecipientChip[] getSortedRecipients() { + return mMockRecips; + } + + @Override + public Editable getText() { + return mEditable; + } + + @Override + public Editable getSpannable() { + return mEditable; + } + + @Override + public int getLineHeight() { + return 48; + } + + @Override + Drawable getChipBackground(RecipientEntry contact) { + return createChipBackground(); + } + + @Override + public int length() { + return mEditable != null ? mEditable.length() : 0; + } + + @Override + public String toString() { + return mEditable != null ? mEditable.toString() : ""; + } + + @Override + public int getViewWidth() { + return 100; + } + } + + private class TestBaseRecipientAdapter extends BaseRecipientAdapter { + public TestBaseRecipientAdapter(final Context context) { + super(context); + } + + public TestBaseRecipientAdapter(final Context context, final int preferredMaxResultCount, + final int queryMode) { + super(context, preferredMaxResultCount, queryMode); + } + } + + private MockRecipientEditTextView createViewForTesting() { + mEditable = new SpannableStringBuilder(); + MockRecipientEditTextView view = new MockRecipientEditTextView(getContext()); + view.setAdapter(new TestBaseRecipientAdapter(getContext())); + return view; + } + + public void testCreateDisplayText() { + RecipientEditTextView view = createViewForTesting(); + RecipientEntry entry = RecipientEntry.constructGeneratedEntry("User Name, Jr", + "user@username.com", true); + String testAddress = view.createAddressText(entry); + String testDisplay = view.createChipDisplayText(entry); + assertEquals("Expected a properly formatted RFC email address", + "\"User Name, Jr\" , ", testAddress); + assertEquals("Expected a displayable name", "User Name, Jr", testDisplay); + + RecipientEntry alreadyFormatted = + RecipientEntry.constructFakeEntry("user@username.com, ", true); + testAddress = view.createAddressText(alreadyFormatted); + testDisplay = view.createChipDisplayText(alreadyFormatted); + assertEquals("Expected a properly formatted RFC email address", ", ", + testAddress); + assertEquals("Expected a displayable name", "user@username.com", testDisplay); + + RecipientEntry alreadyFormattedNoSpace = RecipientEntry + .constructFakeEntry("user@username.com,", true); + testAddress = view.createAddressText(alreadyFormattedNoSpace); + assertEquals("Expected a properly formatted RFC email address", ", ", + testAddress); + + RecipientEntry alreadyNamed = RecipientEntry.constructGeneratedEntry("User Name", + "\"User Name, Jr\" ", true); + testAddress = view.createAddressText(alreadyNamed); + testDisplay = view.createChipDisplayText(alreadyNamed); + assertEquals( + "Expected address that used the name not the excess address name", + "User Name , ", testAddress); + assertEquals("Expected a displayable name", "User Name", testDisplay); + } + + public void testSanitizeBetween() { + // First, add 2 chips and then make sure we remove + // the extra content between them correctly. + populateMocks(2); + MockRecipientEditTextView view = createViewForTesting(); + String first = (String) mTokenizer.terminateToken("FIRST"); + String second = (String) mTokenizer.terminateToken("SECOND"); + String extra = "EXTRA"; + mEditable = new SpannableStringBuilder(); + mEditable.append(first + extra + second); + int firstStart = mEditable.toString().indexOf(first); + int firstEnd = firstStart + first.trim().length(); + int secondStart = mEditable.toString().indexOf(second); + int secondEnd = secondStart + second.trim().length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], secondStart, secondEnd, 0); + view.sanitizeBetween(); + String editableString = mEditable.toString(); + assertEquals(editableString.indexOf(extra), -1); + assertEquals(editableString.indexOf(first), firstStart); + assertEquals(editableString.indexOf(second), secondStart - extra.length()); + assertEquals(editableString, (first + second)); + + // Add 1 chip and make sure that we remove the extra stuff before it correctly. + mEditable = new SpannableStringBuilder(); + populateMocks(1); + mEditable.append(extra); + mEditable.append(first); + firstStart = mEditable.toString().indexOf(first); + firstEnd = firstStart + first.length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], firstStart, firstEnd, 0); + view.sanitizeBetween(); + assertEquals(mEditable.toString(), first); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), firstStart + - extra.length()); + } + + public void testSanitizeEnd() { + // First, add 2 chips and then make sure we remove + // the extra content between them correctly. + populateMocks(2); + MockRecipientEditTextView view = createViewForTesting(); + String first = (String) mTokenizer.terminateToken("FIRST"); + String second = (String) mTokenizer.terminateToken("SECOND"); + String extra = "EXTRA"; + mEditable = new SpannableStringBuilder(); + mEditable.append(first + second); + int firstStart = mEditable.toString().indexOf(first); + int firstEnd = firstStart + first.trim().length(); + int secondStart = mEditable.toString().indexOf(second); + int secondEnd = secondStart + second.trim().length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], secondStart, secondEnd, 0); + view.sanitizeEnd(); + String editableString = mEditable.toString(); + assertEquals(editableString.indexOf(extra), -1); + assertEquals(editableString.indexOf(first), firstStart); + assertEquals(editableString.indexOf(second), secondStart); + assertEquals(editableString, (first + second)); + mEditable.append(extra); + editableString = mEditable.toString(); + assertEquals(mEditable.toString(), (first + second + extra)); + view.sanitizeEnd(); + assertEquals(mEditable.toString(), (first + second)); + } + + public void testMoreChipPlainText() { + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + String first = (String) mTokenizer.terminateToken("FIRST"); + String second = (String) mTokenizer.terminateToken("SECOND"); + String third = (String) mTokenizer.terminateToken("THIRD"); + mEditable = new SpannableStringBuilder(); + mEditable.append(first+second+third); + int thirdStart = mEditable.toString().indexOf(third); + int thirdEnd = thirdStart + third.trim().length(); + view.createMoreChipPlainText(); + ReplacementDrawableSpan moreChip = view.getMoreChip(); + assertEquals(mEditable.getSpanStart(moreChip), thirdStart); + assertEquals(mEditable.getSpanEnd(moreChip), thirdEnd + 1); + } + + public void testCountTokens() { + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + String first = (String) mTokenizer.terminateToken("FIRST"); + String second = (String) mTokenizer.terminateToken("SECOND"); + String third = (String) mTokenizer.terminateToken("THIRD"); + String fourth = "FOURTH,"; + String fifth = "FIFTH,"; + mEditable = new SpannableStringBuilder(); + mEditable.append(first+second+third+fourth+fifth); + assertEquals(view.countTokens(mEditable), 5); + } + + public void testTooManyRecips() { + BaseMockRecipientEditTextView view = new BaseMockRecipientEditTextView(getContext()); + view.setMoreItem(createTestMoreItem()); + for (int i = 0; i < 100; i++) { + view.append(mTokenizer.terminateToken(i + "")); + } + assertEquals(view.countTokens(view.getText()), 100); + view.handlePendingChips(); + view.createMoreChip(); + ReplacementDrawableSpan moreChip = view.getMoreChip(); + // We show 2 chips then place a more chip. + int secondStart = view.getText().toString().indexOf( + (String) mTokenizer.terminateToken(RecipientEditTextView.CHIP_LIMIT + "")); + assertEquals(view.getText().getSpanStart(moreChip), secondStart); + assertEquals(view.getText().getSpanEnd(moreChip), view.length()); + assertEquals(view.getSortedRecipients(), null); + } + + public void testMoreChip() { + // Add 3 chips: this is the trigger point at which the more chip will be created. + // Test that adding the chips and then creating and removing the more chip, as if + // the user were focusing/ removing focus from the chips field. + populateMocks(3); + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + String first = (String) mTokenizer.terminateToken("FIRST"); + String second = (String) mTokenizer.terminateToken("SECOND"); + String third = (String) mTokenizer.terminateToken("THIRD"); + mEditable = new SpannableStringBuilder(); + mEditable.append(first+second+third); + + int firstStart = mEditable.toString().indexOf(first); + int firstEnd = firstStart + first.trim().length(); + int secondStart = mEditable.toString().indexOf(second); + int secondEnd = secondStart + second.trim().length(); + int thirdStart = mEditable.toString().indexOf(third); + int thirdEnd = thirdStart + third.trim().length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + + view.createMoreChip(); + assertEquals(mEditable.toString(), first+second+third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart); + // Find the more chip. + ReplacementDrawableSpan moreChip = view.getMoreChip(); + assertEquals(mEditable.getSpanStart(moreChip), thirdStart); + assertEquals(mEditable.getSpanEnd(moreChip), thirdEnd + 1); + + view.removeMoreChip(); + assertEquals(mEditable.toString(), first+second+third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), thirdStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), thirdEnd); + moreChip = view.getMoreChip(); + assertEquals(mEditable.getSpanStart(moreChip), -1); + + // Rinse and repeat, just in case! + view.createMoreChip(); + assertEquals(mEditable.toString(), first+second+third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart); + // Find the more chip. + moreChip = view.getMoreChip(); + assertEquals(mEditable.getSpanStart(moreChip), thirdStart); + assertEquals(mEditable.getSpanEnd(moreChip), thirdEnd + 1); + + view.removeMoreChip(); + assertEquals(mEditable.toString(), first+second+third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), thirdStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), thirdEnd); + moreChip = view.getMoreChip(); + assertEquals(mEditable.getSpanStart(moreChip), -1); + } + + public void testMoreChipLotsOfUsers() { + // Test adding and removing the more chip in the case where we have a lot of users. + populateMocks(10); + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + String first = (String) mTokenizer.terminateToken("FIRST"); + String second = (String) mTokenizer.terminateToken("SECOND"); + String third = (String) mTokenizer.terminateToken("THIRD"); + String fourth = (String) mTokenizer.terminateToken("FOURTH"); + String fifth = (String) mTokenizer.terminateToken("FIFTH"); + String sixth = (String) mTokenizer.terminateToken("SIXTH"); + String seventh = (String) mTokenizer.terminateToken("SEVENTH"); + String eigth = (String) mTokenizer.terminateToken("EIGHTH"); + String ninth = (String) mTokenizer.terminateToken("NINTH"); + String tenth = (String) mTokenizer.terminateToken("TENTH"); + mEditable = new SpannableStringBuilder(); + mEditable.append(first+second+third+fourth+fifth+sixth+seventh+eigth+ninth+tenth); + + int firstStart = mEditable.toString().indexOf(first); + int firstEnd = firstStart + first.trim().length(); + int secondStart = mEditable.toString().indexOf(second); + int secondEnd = secondStart + second.trim().length(); + int thirdStart = mEditable.toString().indexOf(third); + int thirdEnd = thirdStart + third.trim().length(); + int fourthStart = mEditable.toString().indexOf(fourth); + int fourthEnd = fourthStart + fourth.trim().length(); + int fifthStart = mEditable.toString().indexOf(fifth); + int fifthEnd = fifthStart + fifth.trim().length(); + int sixthStart = mEditable.toString().indexOf(sixth); + int sixthEnd = sixthStart + sixth.trim().length(); + int seventhStart = mEditable.toString().indexOf(seventh); + int seventhEnd = seventhStart + seventh.trim().length(); + int eighthStart = mEditable.toString().indexOf(eigth); + int eighthEnd = eighthStart + eigth.trim().length(); + int ninthStart = mEditable.toString().indexOf(ninth); + int ninthEnd = ninthStart + ninth.trim().length(); + int tenthStart = mEditable.toString().indexOf(tenth); + int tenthEnd = tenthStart + tenth.trim().length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 10], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 9], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 8], thirdStart, thirdEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 7], fourthStart, fourthEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 6], fifthStart, fifthEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 5], sixthStart, sixthEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 4], seventhStart, seventhEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], eighthStart, eighthEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], ninthStart, ninthEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], tenthStart, tenthEnd, 0); + + view.createMoreChip(); + assertEquals(mEditable.toString(), first + second + third + fourth + fifth + sixth + + seventh + eigth + ninth + tenth); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 10]), firstStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 9]), secondStart); + // Find the more chip. + ReplacementDrawableSpan moreChip = view.getMoreChip(); + assertEquals(mEditable.getSpanStart(moreChip), thirdStart); + assertEquals(mEditable.getSpanEnd(moreChip), tenthEnd + 1); + + view.removeMoreChip(); + assertEquals(mEditable.toString(), first + second + third + fourth + fifth + sixth + + seventh + eigth + ninth + tenth); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 10]), firstStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 9]), secondStart); + + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 8]), thirdStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 7]), fourthStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 6]), fifthStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 5]), sixthStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 4]), seventhStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), eighthStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), ninthStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), tenthStart); + moreChip = view.getMoreChip(); + assertEquals(mEditable.getSpanStart(moreChip), -1); + + } + + public void testMoreChipSpecialChars() { + // Make sure the more chip correctly handles extra tokenizer characters in the middle + // of chip text. + populateMocks(3); + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + String first = (String) mTokenizer.terminateToken("FI,RST"); + String second = (String) mTokenizer.terminateToken("SE,COND"); + String third = (String) mTokenizer.terminateToken("THI,RD"); + mEditable = new SpannableStringBuilder(); + mEditable.append(first+second+third); + + int firstStart = mEditable.toString().indexOf(first); + int firstEnd = firstStart + first.trim().length(); + int secondStart = mEditable.toString().indexOf(second); + int secondEnd = secondStart + second.trim().length(); + int thirdStart = mEditable.toString().indexOf(third); + int thirdEnd = thirdStart + third.trim().length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + + view.createMoreChip(); + assertEquals(mEditable.toString(), first+second+third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart); + // Find the more chip. + ReplacementDrawableSpan moreChip = view.getMoreChip(); + assertEquals(mEditable.getSpanStart(moreChip), thirdStart); + assertEquals(mEditable.getSpanEnd(moreChip), thirdEnd + 1); + + view.removeMoreChip(); + assertEquals(mEditable.toString(), first+second+third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), thirdStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), thirdEnd); + moreChip = view.getMoreChip(); + assertEquals(mEditable.getSpanStart(moreChip), -1); + } + + public void testMoreChipDupes() { + // Make sure the more chip is correctly added and removed when we have duplicate chips. + populateMocks(4); + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + String first = (String) mTokenizer.terminateToken("FIRST"); + String second = (String) mTokenizer.terminateToken("SECOND"); + String third = (String) mTokenizer.terminateToken("THIRD"); + mEditable = new SpannableStringBuilder(); + mEditable.append(first+second+third+third); + + int firstStart = mEditable.toString().indexOf(first); + int firstEnd = firstStart + first.trim().length(); + int secondStart = mEditable.toString().indexOf(second); + int secondEnd = secondStart + second.trim().length(); + int thirdStart = mEditable.toString().indexOf(third); + int thirdEnd = thirdStart + third.trim().length(); + int thirdNextStart = mEditable.toString().indexOf(third, thirdEnd); + int thirdNextEnd = thirdNextStart + third.trim().length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 4], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], thirdStart, thirdEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdNextStart, thirdNextEnd, 0); + + view.createMoreChip(); + assertEquals(mEditable.toString(), first+second+third+third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 4]), firstStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), secondStart); + // Find the more chip. + ReplacementDrawableSpan moreChip = view.getMoreChip(); + assertEquals(mEditable.getSpanStart(moreChip), thirdStart); + assertEquals(mEditable.getSpanEnd(moreChip), thirdNextEnd + 1); + + view.removeMoreChip(); + assertEquals(mEditable.toString(), first+second+third+third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 4]), firstStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 4]), firstEnd); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), secondStart); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), thirdStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), thirdEnd); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), thirdNextStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), thirdNextEnd); + moreChip = view.getMoreChip(); + assertEquals(mEditable.getSpanStart(moreChip), -1); + } + + public void testRemoveChip() { + // Create 3 chips to start and test removing chips in various postions. + populateMocks(3); + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + String first = (String) mTokenizer.terminateToken("FIRST"); + String second = (String) mTokenizer.terminateToken("SECOND"); + String third = (String) mTokenizer.terminateToken("THIRD"); + mEditable = new SpannableStringBuilder(); + mEditable.append(first + second + third); + + int firstStart = mEditable.toString().indexOf(first); + int firstEnd = firstStart + first.length(); + int secondStart = mEditable.toString().indexOf(second); + int secondEnd = secondStart + second.length(); + int thirdStart = mEditable.toString().indexOf(third); + int thirdEnd = thirdStart + third.length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + assertEquals(mEditable.toString(), first + second + third); + // Test removing the middle chip. + view.removeChip(mMockRecips[mMockRecips.length - 2]); + assertEquals(mEditable.toString(), first + third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), -1); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), -1); + int newThirdStart = mEditable.toString().indexOf(third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), newThirdStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), newThirdStart + + third.length()); + + // Test removing the first chip. + populateMocks(3); + view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + mEditable = new SpannableStringBuilder(); + mEditable.append(first + second + third); + + firstStart = mEditable.toString().indexOf(first); + firstEnd = firstStart + first.length(); + secondStart = mEditable.toString().indexOf(second); + secondEnd = secondStart + second.length(); + thirdStart = mEditable.toString().indexOf(third); + thirdEnd = thirdStart + third.length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + assertEquals(mEditable.toString(), first + second + third); + view.removeChip(mMockRecips[mMockRecips.length - 3]); + assertEquals(mEditable.toString(), second + third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), -1); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), -1); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), 0); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), second.length()); + newThirdStart = mEditable.toString().indexOf(third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), newThirdStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), newThirdStart + + third.length()); + + // Test removing the last chip. + populateMocks(3); + view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + mEditable = new SpannableStringBuilder(); + mEditable.append(first + second + third); + + firstStart = mEditable.toString().indexOf(first); + firstEnd = firstStart + first.length(); + secondStart = mEditable.toString().indexOf(second); + secondEnd = secondStart + second.length(); + thirdStart = mEditable.toString().indexOf(third); + thirdEnd = thirdStart + third.length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + assertEquals(mEditable.toString(), first + second + third); + view.removeChip(mMockRecips[mMockRecips.length - 1]); + assertEquals(mEditable.toString(), first + second); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), secondEnd); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), -1); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), -1); + } + + public void testReplaceChip() { + populateMocks(3); + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + view.setChipBackground(createChipBackground()); + view.setChipHeight(48); + String first = (String) mTokenizer.terminateToken("FIRST"); + String second = (String) mTokenizer.terminateToken("SECOND"); + String third = (String) mTokenizer.terminateToken("THIRD"); + mEditable = new SpannableStringBuilder(); + mEditable.append(first + second + third); + + // Test replacing the first chip with a new chip. + int firstStart = mEditable.toString().indexOf(first); + int firstEnd = firstStart + first.trim().length(); + int secondStart = mEditable.toString().indexOf(second); + int secondEnd = secondStart + second.trim().length(); + int thirdStart = mEditable.toString().indexOf(third); + int thirdEnd = thirdStart + third.trim().length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + assertEquals(mEditable.toString(), first + second + third); + view.replaceChip(mMockRecips[mMockRecips.length - 3], RecipientEntry + .constructGeneratedEntry("replacement", "replacement@replacement.com", true)); + assertEquals(mEditable.toString(), mTokenizer + .terminateToken("replacement ") + + second + third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), -1); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), -1); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), mEditable + .toString().indexOf(second)); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), mEditable + .toString().indexOf(second) + + second.trim().length()); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), mEditable + .toString().indexOf(third)); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), mEditable + .toString().indexOf(third) + + third.trim().length()); + DrawableRecipientChip[] spans = + mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class); + assertEquals(spans.length, 3); + spans = mEditable + .getSpans(0, mEditable.toString().indexOf(second) - 1, DrawableRecipientChip.class); + assertEquals((String) spans[0].getDisplay(), "replacement"); + + + // Test replacing the middle chip with a new chip. + mEditable = new SpannableStringBuilder(); + mEditable.append(first + second + third); + firstStart = mEditable.toString().indexOf(first); + firstEnd = firstStart + first.trim().length(); + secondStart = mEditable.toString().indexOf(second); + secondEnd = secondStart + second.trim().length(); + thirdStart = mEditable.toString().indexOf(third); + thirdEnd = thirdStart + third.trim().length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + assertEquals(mEditable.toString(), first + second + third); + view.replaceChip(mMockRecips[mMockRecips.length - 2], RecipientEntry + .constructGeneratedEntry("replacement", "replacement@replacement.com", true)); + assertEquals(mEditable.toString(), first + mTokenizer + .terminateToken("replacement ") + third); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), -1); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), -1); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), mEditable + .toString().indexOf(third)); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), mEditable + .toString().indexOf(third) + + third.trim().length()); + spans = mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class); + assertEquals(spans.length, 3); + spans = mEditable.getSpans(firstEnd, mEditable.toString().indexOf(third) - 1, + DrawableRecipientChip.class); + assertEquals((String) spans[0].getDisplay(), "replacement"); + + + // Test replacing the last chip with a new chip. + mEditable = new SpannableStringBuilder(); + mEditable.append(first + second + third); + firstStart = mEditable.toString().indexOf(first); + firstEnd = firstStart + first.trim().length(); + secondStart = mEditable.toString().indexOf(second); + secondEnd = secondStart + second.trim().length(); + thirdStart = mEditable.toString().indexOf(third); + thirdEnd = thirdStart + third.trim().length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + assertEquals(mEditable.toString(), first + second + third); + view.replaceChip(mMockRecips[mMockRecips.length - 1], RecipientEntry + .constructGeneratedEntry("replacement", "replacement@replacement.com", true)); + assertEquals(mEditable.toString(), first + second + mTokenizer + .terminateToken("replacement ")); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 3]), firstStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 3]), firstEnd); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 2]), secondStart); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 2]), secondEnd); + assertEquals(mEditable.getSpanStart(mMockRecips[mMockRecips.length - 1]), -1); + assertEquals(mEditable.getSpanEnd(mMockRecips[mMockRecips.length - 1]), -1); + spans = mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class); + assertEquals(spans.length, 3); + spans = mEditable + .getSpans(secondEnd, mEditable.length(), DrawableRecipientChip.class); + assertEquals((String) spans[0].getDisplay(), "replacement"); + } + + public void testHandlePaste() { + // Start with an empty edit field. + // Add an address; the text should be left as is. + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + view.setChipBackground(createChipBackground()); + view.setChipHeight(48); + mEditable = new SpannableStringBuilder(); + mEditable.append("user@user.com"); + view.setSelection(mEditable.length()); + view.handlePaste(); + assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, 0); + assertEquals(mEditable.toString(), "user@user.com"); + + // Test adding a single address to an empty chips field with a space at + // the end of it. The address should stay as text. + mEditable = new SpannableStringBuilder(); + String tokenizedUser = "user@user.com" + " "; + mEditable.append(tokenizedUser); + view.setSelection(mEditable.length()); + view.handlePaste(); + assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, 0); + assertEquals(mEditable.toString(), tokenizedUser); + + // Test adding a single address to an empty chips field with a semicolon at + // the end of it. The address should become a chip + mEditable = new SpannableStringBuilder(); + tokenizedUser = "user@user.com;"; + mEditable.append(tokenizedUser); + view.setSelection(mEditable.length()); + view.handlePaste(); + assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, 1); + + // Test adding 2 address to an empty chips field. The second to last + // address should become a chip and the last address should stay as + // text. + mEditable = new SpannableStringBuilder(); + mEditable.append("user1,user2@user.com"); + view.setSelection(mEditable.length()); + view.handlePaste(); + assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, 1); + assertEquals(mEditable.getSpans(0, mEditable.toString().indexOf("user2@user.com"), + DrawableRecipientChip.class).length, 1); + assertEquals(mEditable.toString(), ", user2@user.com"); + + // Test adding a single address to the end of existing chips. The existing + // chips should remain, and the last address should stay as text. + populateMocks(3); + String first = (String) mTokenizer.terminateToken("FIRST"); + String second = (String) mTokenizer.terminateToken("SECOND"); + String third = (String) mTokenizer.terminateToken("THIRD"); + mEditable = new SpannableStringBuilder(); + mEditable.append(first + second + third); + view.setSelection(mEditable.length()); + int firstStart = mEditable.toString().indexOf(first); + int firstEnd = firstStart + first.trim().length(); + int secondStart = mEditable.toString().indexOf(second); + int secondEnd = secondStart + second.trim().length(); + int thirdStart = mEditable.toString().indexOf(third); + int thirdEnd = thirdStart + third.trim().length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + + mEditable.append("user@user.com"); + view.setSelection(mEditable.length()); + view.handlePaste(); + assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, + mMockRecips.length); + assertEquals(mEditable.toString(), first + second + third + "user@user.com"); + + // Paste 2 addresses after existing chips. We expect the first address to be turned into + // a chip and the second to be left as text. + populateMocks(3); + mEditable = new SpannableStringBuilder(); + mEditable.append(first + second + third); + + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + + mEditable.append("user1, user2@user.com"); + view.setSelection(mEditable.length()); + view.handlePaste(); + assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, + mMockRecips.length + 1); + assertEquals(mEditable.getSpans(mEditable.toString().indexOf(""), mEditable + .toString().indexOf("user2@user.com") - 1, DrawableRecipientChip.class).length, 1); + assertEquals(mEditable.getSpans(mEditable.toString().indexOf("user2@user.com"), mEditable + .length(), DrawableRecipientChip.class).length, 0); + assertEquals(mEditable.toString(), first + second + third + ", user2@user.com"); + + // Paste 2 addresses after existing chips. We expect the first address to be turned into + // a chip and the second to be left as text. This removes the space seperator char between + // addresses. + populateMocks(3); + mEditable = new SpannableStringBuilder(); + mEditable.append(first + second + third); + + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + + mEditable.append("user1,user2@user.com"); + view.setSelection(mEditable.length()); + view.handlePaste(); + assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, + mMockRecips.length + 1); + assertEquals(mEditable.getSpans(mEditable.toString().indexOf(""), mEditable + .toString().indexOf("user2@user.com") - 1, DrawableRecipientChip.class).length, 1); + assertEquals(mEditable.getSpans(mEditable.toString().indexOf("user2@user.com"), mEditable + .length(), DrawableRecipientChip.class).length, 0); + assertEquals(mEditable.toString(), first + second + third + ", user2@user.com"); + + // Test a complete token pasted in at the end. It should be turned into a chip. + mEditable = new SpannableStringBuilder(); + mEditable.append("user1, user2@user.com,"); + view.setSelection(mEditable.length()); + view.handlePaste(); + assertEquals(mEditable.getSpans(0, mEditable.length(), DrawableRecipientChip.class).length, 2); + assertEquals(mEditable.getSpans(mEditable.toString().indexOf(""), mEditable + .toString().indexOf("user2@user.com") - 1, DrawableRecipientChip.class).length, 1); + assertEquals(mEditable.getSpans(mEditable.toString().indexOf("user2@user.com"), mEditable + .length(), DrawableRecipientChip.class).length, 1); + assertEquals(mEditable.toString(), ", , "); + } + + @TargetApi(16) + public void testHandlePasteClip() { + MockRecipientEditTextView view = createViewForTesting(); + + ClipData clipData = null; + mEditable = new SpannableStringBuilder(); + view.handlePasteClip(clipData); + assertEquals("", view.getText().toString()); + + clipData = ClipData.newPlainText("user label", ""); + mEditable = new SpannableStringBuilder(); + view.handlePasteClip(clipData); + assertEquals("", view.getText().toString()); + + clipData = ClipData.newHtmlText("user label", + "", "email"); + mEditable = new SpannableStringBuilder(); + view.handlePasteClip(clipData); + assertEquals("", view.getText().toString()); + + ClipData.Item clipImageData = new ClipData.Item(Uri.parse("content://my/image")); + clipData = new ClipData("user label", new String[]{"image/jpeg"}, clipImageData); + mEditable = new SpannableStringBuilder(); + view.handlePasteClip(clipData); + assertEquals("", view.getText().toString() + ); + } + + public void testGetPastTerminators() { + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + view.setChipBackground(createChipBackground()); + view.setChipHeight(48); + String test = "test"; + mEditable = new SpannableStringBuilder(); + mEditable.append(test); + assertEquals(view.movePastTerminators(mTokenizer.findTokenEnd(mEditable.toString(), 0)), + test.length()); + + test = "test,"; + mEditable = new SpannableStringBuilder(); + mEditable.append(test); + assertEquals(view.movePastTerminators(mTokenizer.findTokenEnd(mEditable.toString(), 0)), + test.length()); + + test = "test, "; + mEditable = new SpannableStringBuilder(); + mEditable.append(test); + assertEquals(view.movePastTerminators(mTokenizer.findTokenEnd(mEditable.toString(), 0)), + test.length()); + + test = "test;"; + mEditable = new SpannableStringBuilder(); + mEditable.append(test); + assertEquals(view.movePastTerminators(mTokenizer.findTokenEnd(mEditable.toString(), 0)), + test.length()); + + test = "test; "; + mEditable = new SpannableStringBuilder(); + mEditable.append(test); + assertEquals(view.movePastTerminators(mTokenizer.findTokenEnd(mEditable.toString(), 0)), + test.length()); + } + + public void testIsCompletedToken() { + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + view.setChipBackground(createChipBackground()); + view.setChipHeight(48); + assertTrue(view.isCompletedToken("test;")); + assertTrue(view.isCompletedToken("test,")); + assertFalse(view.isCompletedToken("test")); + assertFalse(view.isCompletedToken("test ")); + } + + public void testGetLastChip() { + populateMocks(3); + MockRecipientEditTextView view = createViewForTesting(); + view.setMoreItem(createTestMoreItem()); + view.setChipBackground(createChipBackground()); + view.setChipHeight(48); + String first = (String) mTokenizer.terminateToken("FIRST"); + String second = (String) mTokenizer.terminateToken("SECOND"); + String third = (String) mTokenizer.terminateToken("THIRD"); + mEditable = new SpannableStringBuilder(); + mEditable.append(first + second + third); + + // Test replacing the first chip with a new chip. + int firstStart = mEditable.toString().indexOf(first); + int firstEnd = firstStart + first.trim().length(); + int secondStart = mEditable.toString().indexOf(second); + int secondEnd = secondStart + second.trim().length(); + int thirdStart = mEditable.toString().indexOf(third); + int thirdEnd = thirdStart + third.trim().length(); + mEditable.setSpan(mMockRecips[mMockRecips.length - 3], firstStart, firstEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 2], secondStart, secondEnd, 0); + mEditable.setSpan(mMockRecips[mMockRecips.length - 1], thirdStart, thirdEnd, 0); + assertEquals(view.getLastChip(), mMockRecips[mMockRecips.length - 1]); + mEditable.append("extra"); + assertEquals(view.getLastChip(), mMockRecips[mMockRecips.length - 1]); + } + + private Drawable createChipBackground() { + Bitmap drawable = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888); + return new BitmapDrawable(getContext().getResources(), drawable); + } + + private TextView createTestMoreItem() { + TextView view = new TextView(getContext()); + view.setText("%1$s more..."); + return view; + } + + private void populateMocks(int size) { + mMockEntries = new RecipientEntry[size]; + for (int i = 0; i < size; i++) { + mMockEntries[i] = RecipientEntry.constructGeneratedEntry("user", + "user@username.com", true); + } + mMockRecips = new DrawableRecipientChip[size]; + for (int i = 0; i < size; i++) { + mMockRecips[i] = new VisibleRecipientChip(null, mMockEntries[i]); + } + } + + /** + *

+ * Ensure the original text is always accurate, regardless of the type of email. The original + * text is used to determine where to display the chip span. If this test fails, it means some + * text that should be turned into one whole chip may behave unexpectedly. + *

+ *

+ * For example, a bug was seen where + * + *

+     * "Android User" 
+     * 
+ * + * was converted to + * + *
+     * Android User [android@example.com]
+     * 
+ * + * where text inside [] is a chip. + *

+ */ + public void testCreateReplacementChipOriginalText() { + // Name in quotes + email address + testCreateReplacementChipOriginalText("\"Android User\" ,"); + // Name in quotes + email address without brackets + testCreateReplacementChipOriginalText("\"Android User\" android@example.com,"); + // Name in quotes + testCreateReplacementChipOriginalText("\"Android User\","); + // Name without quotes + email address + testCreateReplacementChipOriginalText("Android User ,"); + // Name without quotes + testCreateReplacementChipOriginalText("Android User,"); + // Email address + testCreateReplacementChipOriginalText(","); + // Email address without brackets + testCreateReplacementChipOriginalText("android@example.com,"); + } + + private void testCreateReplacementChipOriginalText(final String email) { + // No trailing space + attemptCreateReplacementChipOriginalText(email.trim()); + // Trailing space + attemptCreateReplacementChipOriginalText(email.trim() + " "); + } + + private void attemptCreateReplacementChipOriginalText(final String email) { + final RecipientEditTextView view = new RecipientEditTextView(getContext(), null); + + view.setText(email); + view.mPendingChips.add(email); + + view.createReplacementChip(0, email.length(), view.getText(), true); + // The "original text" should be the email without the comma or space(s) + assertEquals(email.replaceAll(",\\s*$", ""), + view.mTemporaryRecipients.get(0).getOriginalText().toString().trim()); + } + + public void testCreateTokenizedEntryForPhone() { + final String phonePattern = "[^\\d]*888[^\\d]*555[^\\d]*1234[^\\d]*"; + final String phone1 = "8885551234"; + final String phone2 = "888-555-1234"; + final String phone3 = "(888) 555-1234"; + + final RecipientEditTextView view = new RecipientEditTextView(getContext(), null); + final BaseRecipientAdapter adapter = new TestBaseRecipientAdapter(getContext(), 10, + BaseRecipientAdapter.QUERY_TYPE_PHONE); + view.setAdapter(adapter); + + final RecipientEntry entry1 = view.createTokenizedEntry(phone1); + final String destination1 = entry1.getDestination(); + assertTrue(phone1 + " failed with " + destination1, + Pattern.matches(phonePattern, destination1)); + + final RecipientEntry entry2 = view.createTokenizedEntry(phone2); + final String destination2 = entry2.getDestination(); + assertTrue(phone2 + " failed with " + destination2, + Pattern.matches(phonePattern, destination2)); + + final RecipientEntry entry3 = view.createTokenizedEntry(phone3); + final String destination3 = entry3.getDestination(); + assertTrue(phone3 + " failed with " + destination3, + Pattern.matches(phonePattern, destination3)); + } +} diff --git a/modules/chips/tests/src/com/android/ex/chips/RecipientAlternatesAdapterTest.java b/modules/chips/tests/src/com/android/ex/chips/RecipientAlternatesAdapterTest.java new file mode 100644 index 0000000..afb6a00 --- /dev/null +++ b/modules/chips/tests/src/com/android/ex/chips/RecipientAlternatesAdapterTest.java @@ -0,0 +1,167 @@ +/* + * 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.ex.chips; + +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.provider.ContactsContract.DisplayNameSources; +import android.test.AndroidTestCase; + +import com.android.ex.chips.RecipientAlternatesAdapter; +import com.android.ex.chips.RecipientEntry; + +public class RecipientAlternatesAdapterTest extends AndroidTestCase { + + public void testRemoveUndesiredDestinations() { + MatrixCursor c = new MatrixCursor(Queries.EMAIL.getProjection()); + Cursor result; + + // Test: Empty input + assertEquals(0, RecipientAlternatesAdapter.removeUndesiredDestinations(c, + null /* desiredMimeType */, null /* lookupKey */).getCount()); + + + // Test: One row + addRow(c, "a", "1@android.com", 1, "home", 1000, 2000, "x", 0); + + result = RecipientAlternatesAdapter.removeUndesiredDestinations(c, + null /* desiredMimeType */, null /* lookupKey */); + assertEquals(1, result.getCount()); + assertRow(result, 0, "a", "1@android.com", 1, "home", 1000, 2000, "x", 0); + + // Test: two unique rows, different destinations + addRow(c, "a", "2@android.com", 1, "home", 1000, 2000, "x", 0); + + result = RecipientAlternatesAdapter.removeUndesiredDestinations(c, + null /* desiredMimeType */, null /* lookupKey */); + assertEquals(2, result.getCount()); + assertRow(result, 0, "a", "1@android.com", 1, "home", 1000, 2000, "x", 0); + assertRow(result, 1, "a", "2@android.com", 1, "home", 1000, 2000, "x", 0); + + // Test: add a third row with a non-unique destination. + addRow(c, "ax", "1@android.com", 11, "homex", 10001, 2000, "xx", 1); + + // Third row should be removed. + result = RecipientAlternatesAdapter.removeUndesiredDestinations(c, + null /* desiredMimeType */, null /* lookupKey */); + assertEquals(2, result.getCount()); + assertRow(result, 0, "a", "1@android.com", 1, "home", 1000, 2000, "x", 0); + assertRow(result, 1, "a", "2@android.com", 1, "home", 1000, 2000, "x", 0); + + // Test: add a forth row with a non-unique destination again. + addRow(c, "ax", "2@android.com", 11, "homex", 10001, 2000, "xx", 1); + + // Forth row should also be removed. + result = RecipientAlternatesAdapter.removeUndesiredDestinations(c, + null /* desiredMimeType */, null /* lookupKey */); + assertEquals(2, result.getCount()); + assertRow(result, 0, "a", "1@android.com", 1, "home", 1000, 2000, "x", 0); + assertRow(result, 1, "a", "2@android.com", 1, "home", 1000, 2000, "x", 0); + } + + private static MatrixCursor addRow(MatrixCursor c, + String displayName, + String destination, + int destinationType, + String destinationLabel, + long contactId, + long dataId, + String photoUri, + int displayNameSource + ) { + c.addRow(new Object[] {displayName, destination, destinationType, destinationLabel, + contactId, dataId, photoUri, displayNameSource}); + return c; + } + + private static void assertRow(Cursor c, int position, + String displayName, + String destination, + int destinationType, + String destinationLabel, + long contactId, + long dataId, + String photoUri, + int displayNameSource + ) { + assertTrue(c.moveToPosition(position)); + assertEquals(displayName, c.getString(0)); + assertEquals(destination, c.getString(1)); + assertEquals(destinationType, c.getInt(2)); + assertEquals(destinationLabel, c.getString(3)); + assertEquals(contactId, c.getLong(4)); + assertEquals(dataId, c.getLong(5)); + assertEquals(photoUri, c.getString(6)); + assertEquals(displayNameSource, c.getInt(7)); + } + + public void testGetBetterRecipient() { + // Ensure that if either (but not both) parameters are null, the other is returned + { + final RecipientEntry entry1 = + RecipientEntry.constructFakeEntry("1@android.com", true); + final RecipientEntry entry2 = null; + + assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry1, entry2), entry1); + assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry2, entry1), entry1); + } + + // Ensure that if only one has a display name, it is used + { + final RecipientEntry entry1 = + RecipientEntry.constructTopLevelEntry("Android", DisplayNameSources.NICKNAME, + "1@android.com", 0, null, 0, null /* directoryId */, 0, (Uri) null, + true, null /* lookupKey */); + final RecipientEntry entry2 = RecipientEntry.constructFakeEntry("1@android.com", true); + + assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry1, entry2), entry1); + assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry2, entry1), entry1); + } + + // Ensure that if one has a display name different from its destination, and the other's + // is equal to its destination, we use the unique one + { + final RecipientEntry entry1 = + RecipientEntry.constructTopLevelEntry("Android", DisplayNameSources.NICKNAME, + "1@android.com", 0, null, 0, null /* directoryId */, 0, (Uri) null, + true, null /* lookupKey */); + final RecipientEntry entry2 = + RecipientEntry.constructTopLevelEntry("2@android.com", DisplayNameSources.EMAIL, + "2@android.com", 0, null, 0, null /* directoryId */, 0, (Uri) null, + true, null /* lookupKey */); + + assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry1, entry2), entry1); + assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry2, entry1), entry1); + } + + // Ensure that if only one has a photo, it is used + { + final RecipientEntry entry1 = + RecipientEntry.constructTopLevelEntry("Android", DisplayNameSources.NICKNAME, + "1@android.com", 0, null, 0, null /* directoryId */, 0, + Uri.parse("http://www.android.com"), true, null /* lookupKey */); + final RecipientEntry entry2 = + RecipientEntry.constructTopLevelEntry("Android", DisplayNameSources.EMAIL, + "2@android.com", 0, null, 0, null /* directoryId */, + 0, (Uri) null, true, null /* lookupKey */); + + assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry1, entry2), entry1); + assertEquals(RecipientAlternatesAdapter.getBetterRecipient(entry2, entry1), entry1); + } + } +} diff --git a/modules/colorpicker b/modules/colorpicker deleted file mode 160000 index 34d5137..0000000 --- a/modules/colorpicker +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 34d5137f881a0ffc9f6bb93c5d2e5bbe99265b00 diff --git a/modules/colorpicker/.gitignore b/modules/colorpicker/.gitignore new file mode 100644 index 0000000..7792e06 --- /dev/null +++ b/modules/colorpicker/.gitignore @@ -0,0 +1,2 @@ +bin/ +gen/ diff --git a/modules/colorpicker/Android.bp b/modules/colorpicker/Android.bp new file mode 100644 index 0000000..a25be55 --- /dev/null +++ b/modules/colorpicker/Android.bp @@ -0,0 +1,27 @@ +// Copyright (C) 2013 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_library { + name: "colorpicker", + sdk_version: "15", + srcs: [ + "src/**/*.java", + "src/**/*.logtags", + ], + resource_dirs: ["res"], +} diff --git a/modules/colorpicker/AndroidManifest.xml b/modules/colorpicker/AndroidManifest.xml new file mode 100644 index 0000000..555ef99 --- /dev/null +++ b/modules/colorpicker/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/modules/colorpicker/build.gradle b/modules/colorpicker/build.gradle new file mode 100644 index 0000000..4bbcfbd --- /dev/null +++ b/modules/colorpicker/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'com.android.library' +} + +android { + namespace 'com.android.colorpicker' + compileSdk 35 + + defaultConfig { + minSdk 33 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + sourceSets { + main { + manifest.srcFile 'AndroidManifest.xml' + java.srcDirs = ['src'] + res.srcDirs = ['res'] + } + } +} diff --git a/modules/colorpicker/project.properties b/modules/colorpicker/project.properties new file mode 100644 index 0000000..36f1594 --- /dev/null +++ b/modules/colorpicker/project.properties @@ -0,0 +1,15 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system edit +# "ant.properties", and override values to adapt the script to your +# project structure. +# +# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): +#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt + +# Project target. +target=android-15 +android.library=true diff --git a/modules/colorpicker/res/drawable-hdpi/ic_colorpicker_swatch_selected.png b/modules/colorpicker/res/drawable-hdpi/ic_colorpicker_swatch_selected.png new file mode 100644 index 0000000..3cbfe1a Binary files /dev/null and b/modules/colorpicker/res/drawable-hdpi/ic_colorpicker_swatch_selected.png differ diff --git a/modules/colorpicker/res/drawable-mdpi/ic_colorpicker_swatch_selected.png b/modules/colorpicker/res/drawable-mdpi/ic_colorpicker_swatch_selected.png new file mode 100644 index 0000000..acbdeca Binary files /dev/null and b/modules/colorpicker/res/drawable-mdpi/ic_colorpicker_swatch_selected.png differ diff --git a/modules/colorpicker/res/drawable-xhdpi/ic_colorpicker_swatch_selected.png b/modules/colorpicker/res/drawable-xhdpi/ic_colorpicker_swatch_selected.png new file mode 100644 index 0000000..812ff2c Binary files /dev/null and b/modules/colorpicker/res/drawable-xhdpi/ic_colorpicker_swatch_selected.png differ diff --git a/modules/colorpicker/res/drawable/color_picker_swatch.xml b/modules/colorpicker/res/drawable/color_picker_swatch.xml new file mode 100644 index 0000000..db71091 --- /dev/null +++ b/modules/colorpicker/res/drawable/color_picker_swatch.xml @@ -0,0 +1,16 @@ + + + \ No newline at end of file diff --git a/modules/colorpicker/res/layout/color_picker_dialog.xml b/modules/colorpicker/res/layout/color_picker_dialog.xml new file mode 100644 index 0000000..75e0366 --- /dev/null +++ b/modules/colorpicker/res/layout/color_picker_dialog.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/modules/colorpicker/res/layout/color_picker_swatch.xml b/modules/colorpicker/res/layout/color_picker_swatch.xml new file mode 100644 index 0000000..1495820 --- /dev/null +++ b/modules/colorpicker/res/layout/color_picker_swatch.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/modules/colorpicker/res/values/dimens.xml b/modules/colorpicker/res/values/dimens.xml new file mode 100644 index 0000000..fc78f2f --- /dev/null +++ b/modules/colorpicker/res/values/dimens.xml @@ -0,0 +1,21 @@ + + + + 64dip + 48dip + 8dip + 4dip + \ No newline at end of file diff --git a/modules/colorpicker/res/values/strings.xml b/modules/colorpicker/res/values/strings.xml new file mode 100644 index 0000000..a9bf37a --- /dev/null +++ b/modules/colorpicker/res/values/strings.xml @@ -0,0 +1,23 @@ + + + + + Select a Color + + Color %1$d + + Color %1$d selected + \ No newline at end of file diff --git a/modules/colorpicker/src/com/android/colorpicker/ColorPickerDialog.java b/modules/colorpicker/src/com/android/colorpicker/ColorPickerDialog.java new file mode 100644 index 0000000..0d24b49 --- /dev/null +++ b/modules/colorpicker/src/com/android/colorpicker/ColorPickerDialog.java @@ -0,0 +1,213 @@ +/* + * 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.colorpicker; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ProgressBar; + +import com.android.colorpicker.ColorPickerSwatch.OnColorSelectedListener; + +/** + * A dialog which takes in as input an array of colors and creates a palette allowing the user to + * select a specific color swatch, which invokes a listener. + */ +public class ColorPickerDialog extends DialogFragment implements OnColorSelectedListener { + + public static final int SIZE_LARGE = 1; + public static final int SIZE_SMALL = 2; + + protected AlertDialog mAlertDialog; + + protected static final String KEY_TITLE_ID = "title_id"; + protected static final String KEY_COLORS = "colors"; + protected static final String KEY_COLOR_CONTENT_DESCRIPTIONS = "color_content_descriptions"; + protected static final String KEY_SELECTED_COLOR = "selected_color"; + protected static final String KEY_COLUMNS = "columns"; + protected static final String KEY_SIZE = "size"; + + protected int mTitleResId = R.string.color_picker_default_title; + protected int[] mColors = null; + protected String[] mColorContentDescriptions = null; + protected int mSelectedColor; + protected int mColumns; + protected int mSize; + + private ColorPickerPalette mPalette; + private ProgressBar mProgress; + + protected OnColorSelectedListener mListener; + + public ColorPickerDialog() { + // Empty constructor required for dialog fragments. + } + + public static ColorPickerDialog newInstance(int titleResId, int[] colors, int selectedColor, + int columns, int size) { + ColorPickerDialog ret = new ColorPickerDialog(); + ret.initialize(titleResId, colors, selectedColor, columns, size); + return ret; + } + + public void initialize(int titleResId, int[] colors, int selectedColor, int columns, int size) { + setArguments(titleResId, columns, size); + setColors(colors, selectedColor); + } + + public void setArguments(int titleResId, int columns, int size) { + Bundle bundle = new Bundle(); + bundle.putInt(KEY_TITLE_ID, titleResId); + bundle.putInt(KEY_COLUMNS, columns); + bundle.putInt(KEY_SIZE, size); + setArguments(bundle); + } + + public void setOnColorSelectedListener(OnColorSelectedListener listener) { + mListener = listener; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (getArguments() != null) { + mTitleResId = getArguments().getInt(KEY_TITLE_ID); + mColumns = getArguments().getInt(KEY_COLUMNS); + mSize = getArguments().getInt(KEY_SIZE); + } + + if (savedInstanceState != null) { + mColors = savedInstanceState.getIntArray(KEY_COLORS); + mSelectedColor = (Integer) savedInstanceState.getSerializable(KEY_SELECTED_COLOR); + mColorContentDescriptions = savedInstanceState.getStringArray( + KEY_COLOR_CONTENT_DESCRIPTIONS); + } + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Activity activity = getActivity(); + + View view = LayoutInflater.from(getActivity()).inflate(R.layout.color_picker_dialog, null); + mProgress = (ProgressBar) view.findViewById(android.R.id.progress); + mPalette = (ColorPickerPalette) view.findViewById(R.id.color_picker); + mPalette.init(mSize, mColumns, this); + + if (mColors != null) { + showPaletteView(); + } + + mAlertDialog = new AlertDialog.Builder(activity) + .setTitle(mTitleResId) + .setView(view) + .create(); + + return mAlertDialog; + } + + @Override + public void onColorSelected(int color) { + if (mListener != null) { + mListener.onColorSelected(color); + } + + if (getTargetFragment() instanceof OnColorSelectedListener) { + final OnColorSelectedListener listener = + (OnColorSelectedListener) getTargetFragment(); + listener.onColorSelected(color); + } + + if (color != mSelectedColor) { + mSelectedColor = color; + // Redraw palette to show checkmark on newly selected color before dismissing. + mPalette.drawPalette(mColors, mSelectedColor); + } + + dismiss(); + } + + public void showPaletteView() { + if (mProgress != null && mPalette != null) { + mProgress.setVisibility(View.GONE); + refreshPalette(); + mPalette.setVisibility(View.VISIBLE); + } + } + + public void showProgressBarView() { + if (mProgress != null && mPalette != null) { + mProgress.setVisibility(View.VISIBLE); + mPalette.setVisibility(View.GONE); + } + } + + public void setColors(int[] colors, int selectedColor) { + if (mColors != colors || mSelectedColor != selectedColor) { + mColors = colors; + mSelectedColor = selectedColor; + refreshPalette(); + } + } + + public void setColors(int[] colors) { + if (mColors != colors) { + mColors = colors; + refreshPalette(); + } + } + + public void setSelectedColor(int color) { + if (mSelectedColor != color) { + mSelectedColor = color; + refreshPalette(); + } + } + + public void setColorContentDescriptions(String[] colorContentDescriptions) { + if (mColorContentDescriptions != colorContentDescriptions) { + mColorContentDescriptions = colorContentDescriptions; + refreshPalette(); + } + } + + private void refreshPalette() { + if (mPalette != null && mColors != null) { + mPalette.drawPalette(mColors, mSelectedColor, mColorContentDescriptions); + } + } + + public int[] getColors() { + return mColors; + } + + public int getSelectedColor() { + return mSelectedColor; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putIntArray(KEY_COLORS, mColors); + outState.putSerializable(KEY_SELECTED_COLOR, mSelectedColor); + outState.putStringArray(KEY_COLOR_CONTENT_DESCRIPTIONS, mColorContentDescriptions); + } +} diff --git a/modules/colorpicker/src/com/android/colorpicker/ColorPickerPalette.java b/modules/colorpicker/src/com/android/colorpicker/ColorPickerPalette.java new file mode 100644 index 0000000..9ca46c3 --- /dev/null +++ b/modules/colorpicker/src/com/android/colorpicker/ColorPickerPalette.java @@ -0,0 +1,194 @@ +/* + * 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.colorpicker; + +import android.content.Context; +import android.content.res.Resources; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TableLayout; +import android.widget.TableRow; + +import com.android.colorpicker.ColorPickerSwatch.OnColorSelectedListener; + +/** + * A color picker custom view which creates an grid of color squares. The number of squares per + * row (and the padding between the squares) is determined by the user. + */ +public class ColorPickerPalette extends TableLayout { + + public OnColorSelectedListener mOnColorSelectedListener; + + private String mDescription; + private String mDescriptionSelected; + + private int mSwatchLength; + private int mMarginSize; + private int mNumColumns; + + public ColorPickerPalette(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ColorPickerPalette(Context context) { + super(context); + } + + /** + * Initialize the size, columns, and listener. Size should be a pre-defined size (SIZE_LARGE + * or SIZE_SMALL) from ColorPickerDialogFragment. + */ + public void init(int size, int columns, OnColorSelectedListener listener) { + mNumColumns = columns; + Resources res = getResources(); + if (size == ColorPickerDialog.SIZE_LARGE) { + mSwatchLength = res.getDimensionPixelSize(R.dimen.color_swatch_large); + mMarginSize = res.getDimensionPixelSize(R.dimen.color_swatch_margins_large); + } else { + mSwatchLength = res.getDimensionPixelSize(R.dimen.color_swatch_small); + mMarginSize = res.getDimensionPixelSize(R.dimen.color_swatch_margins_small); + } + mOnColorSelectedListener = listener; + + mDescription = res.getString(R.string.color_swatch_description); + mDescriptionSelected = res.getString(R.string.color_swatch_description_selected); + } + + private TableRow createTableRow() { + TableRow row = new TableRow(getContext()); + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT); + row.setLayoutParams(params); + return row; + } + + /** + * Adds swatches to table in a serpentine format. + */ + public void drawPalette(int[] colors, int selectedColor) { + drawPalette(colors, selectedColor, null); + } + + /** + * Adds swatches to table in a serpentine format. + */ + public void drawPalette(int[] colors, int selectedColor, String[] colorContentDescriptions) { + if (colors == null) { + return; + } + + this.removeAllViews(); + int tableElements = 0; + int rowElements = 0; + int rowNumber = 0; + + // Fills the table with swatches based on the array of colors. + TableRow row = createTableRow(); + for (int color : colors) { + View colorSwatch = createColorSwatch(color, selectedColor); + setSwatchDescription(rowNumber, tableElements, rowElements, color == selectedColor, + colorSwatch, colorContentDescriptions); + addSwatchToRow(row, colorSwatch, rowNumber); + + tableElements++; + rowElements++; + if (rowElements == mNumColumns) { + addView(row); + row = createTableRow(); + rowElements = 0; + rowNumber++; + } + } + + // Create blank views to fill the row if the last row has not been filled. + if (rowElements > 0) { + while (rowElements != mNumColumns) { + addSwatchToRow(row, createBlankSpace(), rowNumber); + rowElements++; + } + addView(row); + } + } + + /** + * Appends a swatch to the end of the row for even-numbered rows (starting with row 0), + * to the beginning of a row for odd-numbered rows. + */ + private static void addSwatchToRow(TableRow row, View swatch, int rowNumber) { + if (rowNumber % 2 == 0) { + row.addView(swatch); + } else { + row.addView(swatch, 0); + } + } + + /** + * Add a content description to the specified swatch view. Because the colors get added in a + * snaking form, every other row will need to compensate for the fact that the colors are added + * in an opposite direction from their left->right/top->bottom order, which is how the system + * will arrange them for accessibility purposes. + */ + private void setSwatchDescription(int rowNumber, int index, int rowElements, boolean selected, + View swatch, String[] contentDescriptions) { + String description; + if (contentDescriptions != null && contentDescriptions.length > index) { + description = contentDescriptions[index]; + } else { + int accessibilityIndex; + if (rowNumber % 2 == 0) { + // We're in a regular-ordered row + accessibilityIndex = index + 1; + } else { + // We're in a backwards-ordered row. + int rowMax = ((rowNumber + 1) * mNumColumns); + accessibilityIndex = rowMax - rowElements; + } + + if (selected) { + description = String.format(mDescriptionSelected, accessibilityIndex); + } else { + description = String.format(mDescription, accessibilityIndex); + } + } + swatch.setContentDescription(description); + } + + /** + * Creates a blank space to fill the row. + */ + private ImageView createBlankSpace() { + ImageView view = new ImageView(getContext()); + TableRow.LayoutParams params = new TableRow.LayoutParams(mSwatchLength, mSwatchLength); + params.setMargins(mMarginSize, mMarginSize, mMarginSize, mMarginSize); + view.setLayoutParams(params); + return view; + } + + /** + * Creates a color swatch. + */ + private ColorPickerSwatch createColorSwatch(int color, int selectedColor) { + ColorPickerSwatch view = new ColorPickerSwatch(getContext(), color, + color == selectedColor, mOnColorSelectedListener); + TableRow.LayoutParams params = new TableRow.LayoutParams(mSwatchLength, mSwatchLength); + params.setMargins(mMarginSize, mMarginSize, mMarginSize, mMarginSize); + view.setLayoutParams(params); + return view; + } +} diff --git a/modules/colorpicker/src/com/android/colorpicker/ColorPickerSwatch.java b/modules/colorpicker/src/com/android/colorpicker/ColorPickerSwatch.java new file mode 100644 index 0000000..a204c64 --- /dev/null +++ b/modules/colorpicker/src/com/android/colorpicker/ColorPickerSwatch.java @@ -0,0 +1,80 @@ +/* + * 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.colorpicker; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; + +/** + * Creates a circular swatch of a specified color. Adds a checkmark if marked as checked. + */ +public class ColorPickerSwatch extends FrameLayout implements View.OnClickListener { + private int mColor; + private ImageView mSwatchImage; + private ImageView mCheckmarkImage; + private OnColorSelectedListener mOnColorSelectedListener; + + /** + * Interface for a callback when a color square is selected. + */ + public interface OnColorSelectedListener { + + /** + * Called when a specific color square has been selected. + */ + public void onColorSelected(int color); + } + + public ColorPickerSwatch(Context context, int color, boolean checked, + OnColorSelectedListener listener) { + super(context); + mColor = color; + mOnColorSelectedListener = listener; + + LayoutInflater.from(context).inflate(R.layout.color_picker_swatch, this); + mSwatchImage = (ImageView) findViewById(R.id.color_picker_swatch); + mCheckmarkImage = (ImageView) findViewById(R.id.color_picker_checkmark); + setColor(color); + setChecked(checked); + setOnClickListener(this); + } + + protected void setColor(int color) { + Drawable[] colorDrawable = new Drawable[] + {getContext().getResources().getDrawable(R.drawable.color_picker_swatch)}; + mSwatchImage.setImageDrawable(new ColorStateDrawable(colorDrawable, color)); + } + + private void setChecked(boolean checked) { + if (checked) { + mCheckmarkImage.setVisibility(View.VISIBLE); + } else { + mCheckmarkImage.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View v) { + if (mOnColorSelectedListener != null) { + mOnColorSelectedListener.onColorSelected(mColor); + } + } +} diff --git a/modules/colorpicker/src/com/android/colorpicker/ColorStateDrawable.java b/modules/colorpicker/src/com/android/colorpicker/ColorStateDrawable.java new file mode 100644 index 0000000..496f46c --- /dev/null +++ b/modules/colorpicker/src/com/android/colorpicker/ColorStateDrawable.java @@ -0,0 +1,72 @@ +/* + * 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.colorpicker; + +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; + +/** + * A drawable which sets its color filter to a color specified by the user, and changes to a + * slightly darker color when pressed or focused. + */ +public class ColorStateDrawable extends LayerDrawable { + + private static final float PRESSED_STATE_MULTIPLIER = 0.70f; + + private int mColor; + + public ColorStateDrawable(Drawable[] layers, int color) { + super(layers); + mColor = color; + } + + @Override + protected boolean onStateChange(int[] states) { + boolean pressedOrFocused = false; + for (int state : states) { + if (state == android.R.attr.state_pressed || state == android.R.attr.state_focused) { + pressedOrFocused = true; + break; + } + } + + if (pressedOrFocused) { + super.setColorFilter(getPressedColor(mColor), PorterDuff.Mode.SRC_ATOP); + } else { + super.setColorFilter(mColor, PorterDuff.Mode.SRC_ATOP); + } + + return super.onStateChange(states); + } + + /** + * Given a particular color, adjusts its value by a multiplier. + */ + private static int getPressedColor(int color) { + float[] hsv = new float[3]; + Color.colorToHSV(color, hsv); + hsv[2] = hsv[2] * PRESSED_STATE_MULTIPLIER; + return Color.HSVToColor(hsv); + } + + @Override + public boolean isStateful() { + return true; + } +} diff --git a/modules/colorpicker/src/com/android/colorpicker/HsvColorComparator.java b/modules/colorpicker/src/com/android/colorpicker/HsvColorComparator.java new file mode 100644 index 0000000..c44648f --- /dev/null +++ b/modules/colorpicker/src/com/android/colorpicker/HsvColorComparator.java @@ -0,0 +1,61 @@ +/* + * 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.colorpicker; + +import android.graphics.Color; + +import java.util.Comparator; + +/** + * A color comparator which compares based on hue, saturation, and value. + */ +public class HsvColorComparator implements Comparator { + + @Override + public int compare(Integer lhs, Integer rhs) { + float[] hsv = new float[3]; + Color.colorToHSV(lhs, hsv); + float hue1 = hsv[0]; + float sat1 = hsv[1]; + float val1 = hsv[2]; + + float[] hsv2 = new float[3]; + Color.colorToHSV(rhs, hsv2); + float hue2 = hsv2[0]; + float sat2 = hsv2[1]; + float val2 = hsv2[2]; + + if (hue1 < hue2) { + return 1; + } else if (hue1 > hue2) { + return -1; + } else { + if (sat1 < sat2) { + return 1; + } else if (sat1 > sat2) { + return -1; + } else { + if (val1 < val2) { + return 1; + } else if (val1 > val2) { + return -1; + } + } + } + return 0; + } +} diff --git a/modules/photoviewer b/modules/photoviewer deleted file mode 160000 index fb5c100..0000000 --- a/modules/photoviewer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fb5c100b4a415e69e507962dd6c32660d71dfc66 diff --git a/modules/photoviewer/.gitignore b/modules/photoviewer/.gitignore new file mode 100644 index 0000000..8d584b8 --- /dev/null +++ b/modules/photoviewer/.gitignore @@ -0,0 +1,7 @@ +*~ +*.bak +*.class +bin/ +gen/ +.classpath +.project diff --git a/modules/photoviewer/Android.bp b/modules/photoviewer/Android.bp new file mode 100644 index 0000000..7ac2e3c --- /dev/null +++ b/modules/photoviewer/Android.bp @@ -0,0 +1,74 @@ +// 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. + +//################################################# +// Build appcompat library + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_library { + name: "libphotoviewer_appcompat", + + libs: ["androidx.annotation_annotation"], + + static_libs: [ + "androidx.core_core", + "androidx.legacy_legacy-support-core-ui", + "androidx.legacy_legacy-support-core-utils", + "androidx.fragment_fragment", + "androidx.appcompat_appcompat", + ], + + sdk_version: "current", + srcs: [ + "src/**/*.java", + "appcompat/src/**/*.java", + "src/**/*.logtags", + ], + + resource_dirs: [ + "appcompat/res", + "res", + ], +} + +//################################################# +// Build non-appcompat library + +android_library { + name: "libphotoviewer", + + libs: ["androidx.annotation_annotation"], + + static_libs: [ + "androidx.core_core", + "androidx.legacy_legacy-support-core-ui", + "androidx.legacy_legacy-support-core-utils", + "androidx.fragment_fragment", + ], + + sdk_version: "current", + srcs: [ + "src/**/*.java", + "activity/src/**/*.java", + "src/**/*.logtags", + ], + + resource_dirs: [ + "activity/res", + "res", + ] +} diff --git a/modules/photoviewer/AndroidManifest.xml b/modules/photoviewer/AndroidManifest.xml new file mode 100644 index 0000000..3af8a1c --- /dev/null +++ b/modules/photoviewer/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/modules/photoviewer/OWNERS b/modules/photoviewer/OWNERS new file mode 100644 index 0000000..87560f9 --- /dev/null +++ b/modules/photoviewer/OWNERS @@ -0,0 +1 @@ +asapperstein@google.com diff --git a/modules/photoviewer/activity/res/values/themes.xml b/modules/photoviewer/activity/res/values/themes.xml new file mode 100644 index 0000000..20b23f9 --- /dev/null +++ b/modules/photoviewer/activity/res/values/themes.xml @@ -0,0 +1,40 @@ + + + + + + + + + diff --git a/modules/photoviewer/activity/src/com/android/ex/photo/ActionBarWrapper.java b/modules/photoviewer/activity/src/com/android/ex/photo/ActionBarWrapper.java new file mode 100644 index 0000000..6d4d4d2 --- /dev/null +++ b/modules/photoviewer/activity/src/com/android/ex/photo/ActionBarWrapper.java @@ -0,0 +1,76 @@ +package com.android.ex.photo; + +import android.app.ActionBar; +import android.graphics.drawable.Drawable; + +/** + * Wrapper around {@link ActionBar}. + */ +public class ActionBarWrapper implements ActionBarInterface { + + private final ActionBar mActionBar; + + private class MenuVisiblityListenerWrapper implements ActionBar.OnMenuVisibilityListener { + + private final ActionBarInterface.OnMenuVisibilityListener mWrapped; + + public MenuVisiblityListenerWrapper(ActionBarInterface.OnMenuVisibilityListener wrapped) { + mWrapped = wrapped; + } + + @Override + public void onMenuVisibilityChanged(boolean isVisible) { + mWrapped.onMenuVisibilityChanged(isVisible); + } + } + + public ActionBarWrapper(ActionBar actionBar) { + mActionBar = actionBar; + } + + @Override + public void setDisplayHomeAsUpEnabled(boolean showHomeAsUp) { + mActionBar.setDisplayHomeAsUpEnabled(showHomeAsUp); + } + + @Override + public void addOnMenuVisibilityListener(OnMenuVisibilityListener listener) { + mActionBar.addOnMenuVisibilityListener(new MenuVisiblityListenerWrapper(listener)); + } + + @Override + public void setDisplayOptionsShowTitle() { + mActionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE, ActionBar.DISPLAY_SHOW_TITLE); + } + + @Override + public CharSequence getTitle() { + return mActionBar.getTitle(); + } + + @Override + public void setTitle(CharSequence title) { + mActionBar.setTitle(title); + } + + @Override + public void setSubtitle(CharSequence subtitle) { + mActionBar.setSubtitle(subtitle); + } + + @Override + public void show() { + mActionBar.show(); + } + + @Override + public void hide() { + mActionBar.hide(); + } + + @Override + public void setLogo(Drawable logo) { + mActionBar.setLogo(logo); + } + +} diff --git a/modules/photoviewer/activity/src/com/android/ex/photo/PhotoViewActivity.java b/modules/photoviewer/activity/src/com/android/ex/photo/PhotoViewActivity.java new file mode 100644 index 0000000..5dd8171 --- /dev/null +++ b/modules/photoviewer/activity/src/com/android/ex/photo/PhotoViewActivity.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; +import android.view.Menu; +import android.view.MenuItem; + +/** + * Activity to view the contents of an album. + */ +public class PhotoViewActivity extends FragmentActivity + implements PhotoViewController.ActivityInterface { + + private PhotoViewController mController; + private ActionBarWrapper mActionBar; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mController = createController(); + mController.onCreate(savedInstanceState); + } + + public PhotoViewController createController() { + return new PhotoViewController(this); + } + + @Override + public PhotoViewController getController() { + return mController; + } + + @Override + protected void onStart() { + super.onStart(); + mController.onStart(); + } + + @Override + protected void onResume() { + super.onResume(); + mController.onResume(); + } + + @Override + protected void onPause() { + mController.onPause(); + super.onPause(); + } + + @Override + protected void onStop() { + mController.onStop(); + super.onStop(); + } + + @Override + protected void onDestroy() { + mController.onDestroy(); + super.onDestroy(); + } + + @Override + public void onBackPressed() { + if (!mController.onBackPressed()) { + super.onBackPressed(); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + mController.onSaveInstanceState(outState); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + return mController.onCreateOptionsMenu(menu) || super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + return mController.onPrepareOptionsMenu(menu) || super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + return mController.onOptionsItemSelected(item) || super.onOptionsItemSelected(item); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + mController.onActivityResult(requestCode, resultCode, data); + } + + @Override + public Context getContext() { + return this; + } + + @Override + public ActionBarInterface getActionBarInterface() { + if (mActionBar == null) { + mActionBar = new ActionBarWrapper(getActionBar()); + } + return mActionBar; + } + +} diff --git a/modules/photoviewer/appcompat/res/values/themes.xml b/modules/photoviewer/appcompat/res/values/themes.xml new file mode 100644 index 0000000..b04c0c7 --- /dev/null +++ b/modules/photoviewer/appcompat/res/values/themes.xml @@ -0,0 +1,39 @@ + + + + + + + + + diff --git a/modules/photoviewer/appcompat/src/com/android/ex/photo/ActionBarWrapper.java b/modules/photoviewer/appcompat/src/com/android/ex/photo/ActionBarWrapper.java new file mode 100644 index 0000000..621e2a8 --- /dev/null +++ b/modules/photoviewer/appcompat/src/com/android/ex/photo/ActionBarWrapper.java @@ -0,0 +1,77 @@ +package com.android.ex.photo; + + +import android.graphics.drawable.Drawable; +import androidx.appcompat.app.ActionBar; + +/** + * Wrapper around {@link ActionBar}. + */ +public class ActionBarWrapper implements ActionBarInterface { + + private final ActionBar mActionBar; + + private class MenuVisiblityListenerWrapper implements ActionBar.OnMenuVisibilityListener { + + private final ActionBarInterface.OnMenuVisibilityListener mWrapped; + + public MenuVisiblityListenerWrapper(ActionBarInterface.OnMenuVisibilityListener wrapped) { + mWrapped = wrapped; + } + + @Override + public void onMenuVisibilityChanged(boolean isVisible) { + mWrapped.onMenuVisibilityChanged(isVisible); + } + } + + public ActionBarWrapper(ActionBar actionBar) { + mActionBar = actionBar; + } + + @Override + public void setDisplayHomeAsUpEnabled(boolean showHomeAsUp) { + mActionBar.setDisplayHomeAsUpEnabled(showHomeAsUp); + } + + @Override + public void addOnMenuVisibilityListener(OnMenuVisibilityListener listener) { + mActionBar.addOnMenuVisibilityListener(new MenuVisiblityListenerWrapper(listener)); + } + + @Override + public void setDisplayOptionsShowTitle() { + mActionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE, ActionBar.DISPLAY_SHOW_TITLE); + } + + @Override + public CharSequence getTitle() { + return mActionBar.getTitle(); + } + + @Override + public void setTitle(CharSequence title) { + mActionBar.setTitle(title); + } + + @Override + public void setSubtitle(CharSequence subtitle) { + mActionBar.setSubtitle(subtitle); + } + + @Override + public void show() { + mActionBar.show(); + } + + @Override + public void hide() { + mActionBar.hide(); + } + + @Override + public void setLogo(Drawable logo) { + mActionBar.setLogo(logo); + } + +} diff --git a/modules/photoviewer/appcompat/src/com/android/ex/photo/PhotoViewActivity.java b/modules/photoviewer/appcompat/src/com/android/ex/photo/PhotoViewActivity.java new file mode 100644 index 0000000..919badd --- /dev/null +++ b/modules/photoviewer/appcompat/src/com/android/ex/photo/PhotoViewActivity.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import android.view.Menu; +import android.view.MenuItem; + +/** + * Activity to view the contents of an album. + */ +public class PhotoViewActivity extends AppCompatActivity + implements PhotoViewController.ActivityInterface { + + private PhotoViewController mController; + private ActionBarWrapper mActionBar; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mController = createController(); + mController.onCreate(savedInstanceState); + } + + protected PhotoViewController createController() { + return new PhotoViewController(this); + } + + @Override + public PhotoViewController getController() { + return mController; + } + + @Override + protected void onStart() { + super.onStart(); + mController.onStart(); + } + + @Override + protected void onResume() { + super.onResume(); + mController.onResume(); + } + + @Override + protected void onPause() { + mController.onPause(); + super.onPause(); + } + + @Override + protected void onStop() { + mController.onStop(); + super.onStop(); + } + + @Override + protected void onDestroy() { + mController.onDestroy(); + super.onDestroy(); + } + + @Override + public void onBackPressed() { + if (!mController.onBackPressed()) { + super.onBackPressed(); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + mController.onSaveInstanceState(outState); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + return mController.onCreateOptionsMenu(menu) || super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + return mController.onPrepareOptionsMenu(menu) || super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + return mController.onOptionsItemSelected(item) || super.onOptionsItemSelected(item); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + mController.onActivityResult(requestCode, resultCode, data); + } + + @Override + public Context getContext() { + return this; + } + + @Override + public ActionBarInterface getActionBarInterface() { + if (mActionBar == null) { + mActionBar = new ActionBarWrapper(getSupportActionBar()); + } + return mActionBar; + } + +} diff --git a/modules/photoviewer/build.gradle b/modules/photoviewer/build.gradle new file mode 100644 index 0000000..8285c43 --- /dev/null +++ b/modules/photoviewer/build.gradle @@ -0,0 +1,33 @@ +plugins { + id 'com.android.library' +} + +android { + namespace 'com.android.ex.photo' + compileSdk 35 + + defaultConfig { + minSdk 33 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + sourceSets { + main { + manifest.srcFile 'AndroidManifest.xml' + java.srcDirs = ['src', 'activity/src'] + res.srcDirs = ['res', 'activity/res'] + } + } +} + +dependencies { + implementation 'androidx.annotation:annotation:1.8.2' + implementation 'androidx.core:core:1.15.0' + implementation 'androidx.fragment:fragment:1.8.5' + implementation 'androidx.loader:loader:1.1.0' + implementation 'androidx.viewpager:viewpager:1.0.0' +} diff --git a/modules/photoviewer/project.properties b/modules/photoviewer/project.properties new file mode 100644 index 0000000..91d2b02 --- /dev/null +++ b/modules/photoviewer/project.properties @@ -0,0 +1,15 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system edit +# "ant.properties", and override values to adapt the script to your +# project structure. +# +# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): +#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt + +# Project target. +target=android-19 +android.library=true diff --git a/modules/photoviewer/res/drawable-hdpi/actionbar_translucent.9.png b/modules/photoviewer/res/drawable-hdpi/actionbar_translucent.9.png new file mode 100644 index 0000000..4b40967 Binary files /dev/null and b/modules/photoviewer/res/drawable-hdpi/actionbar_translucent.9.png differ diff --git a/modules/photoviewer/res/drawable-hdpi/ic_menu_refresh_holo_dark.png b/modules/photoviewer/res/drawable-hdpi/ic_menu_refresh_holo_dark.png new file mode 100644 index 0000000..69ac31b Binary files /dev/null and b/modules/photoviewer/res/drawable-hdpi/ic_menu_refresh_holo_dark.png differ diff --git a/modules/photoviewer/res/drawable-mdpi/actionbar_translucent.9.png b/modules/photoviewer/res/drawable-mdpi/actionbar_translucent.9.png new file mode 100644 index 0000000..a995d44 Binary files /dev/null and b/modules/photoviewer/res/drawable-mdpi/actionbar_translucent.9.png differ diff --git a/modules/photoviewer/res/drawable-mdpi/ic_menu_refresh_holo_dark.png b/modules/photoviewer/res/drawable-mdpi/ic_menu_refresh_holo_dark.png new file mode 100644 index 0000000..f68aacf Binary files /dev/null and b/modules/photoviewer/res/drawable-mdpi/ic_menu_refresh_holo_dark.png differ diff --git a/modules/photoviewer/res/drawable-xhdpi/actionbar_translucent.9.png b/modules/photoviewer/res/drawable-xhdpi/actionbar_translucent.9.png new file mode 100644 index 0000000..f4ed5fa Binary files /dev/null and b/modules/photoviewer/res/drawable-xhdpi/actionbar_translucent.9.png differ diff --git a/modules/photoviewer/res/drawable-xhdpi/ic_menu_refresh_holo_dark.png b/modules/photoviewer/res/drawable-xhdpi/ic_menu_refresh_holo_dark.png new file mode 100644 index 0000000..3db90ee Binary files /dev/null and b/modules/photoviewer/res/drawable-xhdpi/ic_menu_refresh_holo_dark.png differ diff --git a/modules/photoviewer/res/drawable-xxhdpi/actionbar_translucent.9.png b/modules/photoviewer/res/drawable-xxhdpi/actionbar_translucent.9.png new file mode 100644 index 0000000..13e4dbc Binary files /dev/null and b/modules/photoviewer/res/drawable-xxhdpi/actionbar_translucent.9.png differ diff --git a/modules/photoviewer/res/drawable-xxhdpi/ic_menu_refresh_holo_dark.png b/modules/photoviewer/res/drawable-xxhdpi/ic_menu_refresh_holo_dark.png new file mode 100644 index 0000000..088d76b Binary files /dev/null and b/modules/photoviewer/res/drawable-xxhdpi/ic_menu_refresh_holo_dark.png differ diff --git a/modules/photoviewer/res/drawable/default_image.xml b/modules/photoviewer/res/drawable/default_image.xml new file mode 100644 index 0000000..0a4369a --- /dev/null +++ b/modules/photoviewer/res/drawable/default_image.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/modules/photoviewer/res/layout-v11/photo_retry_button.xml b/modules/photoviewer/res/layout-v11/photo_retry_button.xml new file mode 100644 index 0000000..c7c7d64 --- /dev/null +++ b/modules/photoviewer/res/layout-v11/photo_retry_button.xml @@ -0,0 +1,12 @@ + + diff --git a/modules/photoviewer/res/layout/photo_activity_view.xml b/modules/photoviewer/res/layout/photo_activity_view.xml new file mode 100644 index 0000000..070a93c --- /dev/null +++ b/modules/photoviewer/res/layout/photo_activity_view.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + diff --git a/modules/photoviewer/res/layout/photo_fragment_view.xml b/modules/photoviewer/res/layout/photo_fragment_view.xml new file mode 100644 index 0000000..9fcd6ee --- /dev/null +++ b/modules/photoviewer/res/layout/photo_fragment_view.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/modules/photoviewer/res/layout/photo_retry_button.xml b/modules/photoviewer/res/layout/photo_retry_button.xml new file mode 100644 index 0000000..29a2508 --- /dev/null +++ b/modules/photoviewer/res/layout/photo_retry_button.xml @@ -0,0 +1,11 @@ + + diff --git a/modules/photoviewer/res/values/colors.xml b/modules/photoviewer/res/values/colors.xml new file mode 100644 index 0000000..50ef5de --- /dev/null +++ b/modules/photoviewer/res/values/colors.xml @@ -0,0 +1,24 @@ + + + + + #ff000000 + #d9000000 + #cc000000 + #fff + diff --git a/modules/photoviewer/res/values/config.xml b/modules/photoviewer/res/values/config.xml new file mode 100644 index 0000000..250b687 --- /dev/null +++ b/modules/photoviewer/res/values/config.xml @@ -0,0 +1,5 @@ + + + + true + \ No newline at end of file diff --git a/modules/photoviewer/res/values/constants.xml b/modules/photoviewer/res/values/constants.xml new file mode 100644 index 0000000..0fefe83 --- /dev/null +++ b/modules/photoviewer/res/values/constants.xml @@ -0,0 +1,21 @@ + + + + + 5000 + diff --git a/modules/photoviewer/res/values/dimen.xml b/modules/photoviewer/res/values/dimen.xml new file mode 100644 index 0000000..754c99b --- /dev/null +++ b/modules/photoviewer/res/values/dimen.xml @@ -0,0 +1,25 @@ + + + + + 280dip + 1dip + 200dip + 48dip + 32dip + diff --git a/modules/photoviewer/res/values/strings.xml b/modules/photoviewer/res/values/strings.xml new file mode 100644 index 0000000..d60afdb --- /dev/null +++ b/modules/photoviewer/res/values/strings.xml @@ -0,0 +1,26 @@ + + + + + %1$d of %2$d + Retry + + Couldn\'t load image + + %1$s, %2$s + diff --git a/modules/photoviewer/src/com/android/ex/photo/ActionBarInterface.java b/modules/photoviewer/src/com/android/ex/photo/ActionBarInterface.java new file mode 100644 index 0000000..3c21b56 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/ActionBarInterface.java @@ -0,0 +1,47 @@ +package com.android.ex.photo; + +import android.graphics.drawable.Drawable; +import androidx.annotation.Nullable; + + +/** + * Wrapper activity for an action bar. This wraps either a {@link android.app.ActionBar} or + * {@link androidx.appcompat.app.ActionBar}. + */ +public interface ActionBarInterface { + + public interface OnMenuVisibilityListener { + /** + * Called when an action bar menu is shown or hidden. Applications may want to use + * this to tune auto-hiding behavior for the action bar or pause/resume video playback, + * gameplay, or other activity within the main content area. + * + * @param isVisible True if an action bar menu is now visible, false if no action bar + * menus are visible. + */ + public void onMenuVisibilityChanged(boolean isVisible); + } + + public void setDisplayHomeAsUpEnabled(boolean showHomeAsUp); + + public void addOnMenuVisibilityListener(OnMenuVisibilityListener listener); + + /** + * Wrapper for {@code setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE, + * ActionBar.DISPLAY_SHOW_TITLE)}. + */ + public void setDisplayOptionsShowTitle(); + + @Nullable + public CharSequence getTitle(); + + public void setTitle(@Nullable CharSequence title); + + public void setSubtitle(@Nullable CharSequence subtitle); + + public void show(); + + public void hide(); + + public void setLogo(@Nullable Drawable logo); +} diff --git a/modules/photoviewer/src/com/android/ex/photo/Intents.java b/modules/photoviewer/src/com/android/ex/photo/Intents.java new file mode 100644 index 0000000..61e9382 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/Intents.java @@ -0,0 +1,351 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo; + +import android.app.Activity; +import android.content.ContentProvider; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import com.android.ex.photo.fragments.PhotoViewFragment; + +/** + * Build intents to start app activities + */ + +public class Intents { + // Intent extras + public static final String EXTRA_PHOTO_INDEX = "photo_index"; + public static final String EXTRA_INITIAL_PHOTO_URI = "initial_photo_uri"; + public static final String EXTRA_PHOTOS_URI = "photos_uri"; + public static final String EXTRA_RESOLVED_PHOTO_URI = "resolved_photo_uri"; + public static final String EXTRA_PROJECTION = "projection"; + public static final String EXTRA_THUMBNAIL_URI = "thumbnail_uri"; + public static final String EXTRA_CONTENT_DESCRIPTION = "content_description"; + public static final String EXTRA_MAX_INITIAL_SCALE = "max_scale"; + public static final String EXTRA_WATCH_NETWORK = "watch_network"; + public static final String EXTRA_ENABLE_TIMER_LIGHTS_OUT = "enable_timer_lights_out"; + + + // Parameters affecting the intro/exit animation + public static final String EXTRA_SCALE_UP_ANIMATION = "scale_up_animation"; + public static final String EXTRA_ANIMATION_START_X = "start_x_extra"; + public static final String EXTRA_ANIMATION_START_Y = "start_y_extra"; + public static final String EXTRA_ANIMATION_START_WIDTH = "start_width_extra"; + public static final String EXTRA_ANIMATION_START_HEIGHT = "start_height_extra"; + + // Parameters affecting the display and features + public static final String EXTRA_ACTION_BAR_HIDDEN_INITIALLY = "action_bar_hidden_initially"; + public static final String EXTRA_DISPLAY_THUMBS_FULLSCREEN = "display_thumbs_fullscreen"; + + /** + * Gets a photo view intent builder to display the photos from phone activity. + * + * @param context The context + * @return The intent builder + */ + public static PhotoViewIntentBuilder newPhotoViewActivityIntentBuilder(Context context) { + return new PhotoViewIntentBuilder(context, PhotoViewActivity.class); + } + + /** + * Gets a photo view intent builder to display the photo view fragment + * + * @param context The context + * @return The intent builder + */ + public static PhotoViewIntentBuilder newPhotoViewFragmentIntentBuilder(Context context) { + return newPhotoViewFragmentIntentBuilder(context, PhotoViewFragment.class); + } + + /** + * Gets a photo view intent builder to display the photo view fragment with a custom fragment + * subclass. + * + * @param context The context + * @param clazz Subclass of PhotoViewFragment to use + * @return The intent builder + */ + public static PhotoViewIntentBuilder newPhotoViewFragmentIntentBuilder(Context context, + Class clazz) { + return new PhotoViewIntentBuilder(context, clazz); + } + + /** Gets a new photo view intent builder */ + public static PhotoViewIntentBuilder newPhotoViewIntentBuilder( + Context context, Class cls) { + return new PhotoViewIntentBuilder(context, cls); + } + + /** Gets a new photo view intent builder */ + public static PhotoViewIntentBuilder newPhotoViewIntentBuilder( + Context context, String activityName) { + return new PhotoViewIntentBuilder(context, activityName); + } + + /** Builder to create a photo view intent */ + public static class PhotoViewIntentBuilder { + private final Intent mIntent; + + /** The index of the photo to show */ + private Integer mPhotoIndex; + /** The URI of the initial photo to show */ + private String mInitialPhotoUri; + /** The URI of the initial thumbnail to show */ + private String mInitialThumbnailUri; + /** The URI of the group of photos to display */ + private String mPhotosUri; + /** The URL of the photo to display */ + private String mResolvedPhotoUri; + /** The projection for the query to use; optional */ + private String[] mProjection; + /** The URI of a thumbnail of the photo to display */ + private String mThumbnailUri; + /** The content Description for the photo to show */ + private String mContentDescription; + /** The maximum scale to display images at before */ + private Float mMaxInitialScale; + /** True if lights out should automatically be invoked on a timer basis */ + private boolean mEnableTimerLightsOut; + /** + * True if the PhotoViewFragments should watch for network changes to restart their loaders + */ + private boolean mWatchNetwork; + /** true we want to run the image scale animation */ + private boolean mScaleAnimation; + /** The parameters for performing the scale up/scale down animations + * upon enter and exit. StartX and StartY represent the screen coordinates + * of the upper left corner of the start rectangle, startWidth and startHeight + * represent the width and height of the start rectangle. + */ + private int mStartX; + private int mStartY; + private int mStartWidth; + private int mStartHeight; + + private boolean mActionBarHiddenInitially; + private boolean mDisplayFullScreenThumbs; + + private PhotoViewIntentBuilder(Context context, Class cls) { + mIntent = new Intent(context, cls); + initialize(); + } + + private PhotoViewIntentBuilder(Context context, String activityName) { + mIntent = new Intent(); + mIntent.setClassName(context, activityName); + initialize(); + } + + private void initialize() { + mScaleAnimation = false; + mActionBarHiddenInitially = false; + mDisplayFullScreenThumbs = false; + mEnableTimerLightsOut = true; + } + + /** Sets auto lights out */ + public PhotoViewIntentBuilder setEnableTimerLightsOut(boolean enable) { + mEnableTimerLightsOut = enable; + return this; + } + + /** Sets the photo index */ + public PhotoViewIntentBuilder setPhotoIndex(Integer photoIndex) { + mPhotoIndex = photoIndex; + return this; + } + + /** Sets the initial photo URI */ + public PhotoViewIntentBuilder setInitialPhotoUri(String initialPhotoUri) { + mInitialPhotoUri = initialPhotoUri; + return this; + } + + /** Sets the photos URI */ + public PhotoViewIntentBuilder setPhotosUri(String photosUri) { + mPhotosUri = photosUri; + return this; + } + + /** Sets the query projection */ + public PhotoViewIntentBuilder setProjection(String[] projection) { + mProjection = projection; + return this; + } + + /** Sets the resolved photo URI. This method is for the case + * where the URI given to {@link PhotoViewActivity} points directly + * to a single image and does not need to be resolved via a query + * to the {@link ContentProvider}. If this value is set, it supersedes + * {@link #setPhotosUri(String)}. */ + public PhotoViewIntentBuilder setResolvedPhotoUri(String resolvedPhotoUri) { + mResolvedPhotoUri = resolvedPhotoUri; + return this; + } + + /** + * Sets the URI for a thumbnail preview of the photo. + */ + public PhotoViewIntentBuilder setThumbnailUri(String thumbnailUri) { + mThumbnailUri = thumbnailUri; + return this; + } + + /** + * Sets the content Description for the photo + */ + public PhotoViewIntentBuilder setContentDescription(String contentDescription) { + mContentDescription = contentDescription; + return this; + } + + /** + * Sets the maximum scale which an image is initially displayed at + */ + public PhotoViewIntentBuilder setMaxInitialScale(float maxScale) { + mMaxInitialScale = maxScale; + return this; + } + + /** + * Enable watching the network for connectivity changes. + * + * When a change is detected, bitmap loaders will be restarted if required. + */ + public PhotoViewIntentBuilder watchNetworkConnectivityChanges() { + mWatchNetwork = true; + return this; + } + + /** + * Enable a scale animation that animates the initial photo URI passed in using + * {@link #setInitialPhotoUri}. + * + * Note: To avoid janky transitions, particularly when exiting the photoviewer, ensure the + * following system UI flags are set on the root view of the relying app's activity + * (via @{link View.setSystemUiVisibility(int)}): + * {@code View.SYSTEM_UI_FLAG_VISIBLE | View.SYSTEM_UI_FLAG_LAYOUT_STABLE} + * As well, client should ensure {@code android:fitsSystemWindows} is set on the root + * content view. + */ + public PhotoViewIntentBuilder setScaleAnimation(int startX, int startY, + int startWidth, int startHeight) { + mScaleAnimation = true; + mStartX = startX; + mStartY = startY; + mStartWidth = startWidth; + mStartHeight = startHeight; + return this; + } + + // If this option is turned on, then the photoViewer will be initially + // displayed with the action bar hidden. This is as opposed to the default + // behavior, where the actionBar is initially shown. + public PhotoViewIntentBuilder setActionBarHiddenInitially( + boolean actionBarHiddenInitially) { + mActionBarHiddenInitially = actionBarHiddenInitially; + return this; + } + + // If this option is turned on, then the small, lo-res thumbnail will + // be scaled up to the maximum size to cover as much of the screen as + // possible while still maintaining the correct aspect ratio. This means + // that the image may appear blurry until the the full-res image is + // loaded. + // This is as opposed to the default behavior, where only part of the + // thumbnail is displayed in a small view in the center of the screen, + // and a loading spinner is displayed until the full-res image is loaded. + public PhotoViewIntentBuilder setDisplayThumbsFullScreen( + boolean displayFullScreenThumbs) { + mDisplayFullScreenThumbs = displayFullScreenThumbs; + return this; + } + + /** Build the intent */ + public Intent build() { + mIntent.setAction(Intent.ACTION_VIEW); + + // In Lollipop, each list of photos should appear as a document in the "Recents" + // list. In earlier versions, this flag was Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET. + mIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT + // FLAG_ACTIVITY_CLEAR_TOP is needed for the case where the app tries to + // display a different photo while there is an existing activity instance + // for that list of photos. Since the initial photo is specified as an + // extra, without FLAG_ACTIVITY_CLEAR_TOP, the activity instance would + // just get restarted and it would display whatever photo it was last + // displaying. FLAG_ACTIVITY_CLEAR_TOP causes a new instance to be created, + // and it will display the new initial photo. + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + if (mPhotoIndex != null) { + mIntent.putExtra(EXTRA_PHOTO_INDEX, (int) mPhotoIndex); + } + + if (mInitialPhotoUri != null) { + mIntent.putExtra(EXTRA_INITIAL_PHOTO_URI, mInitialPhotoUri); + } + + if (mInitialPhotoUri != null && mPhotoIndex != null) { + throw new IllegalStateException( + "specified both photo index and photo uri"); + } + + if (mPhotosUri != null) { + mIntent.putExtra(EXTRA_PHOTOS_URI, mPhotosUri); + mIntent.setData(Uri.parse(mPhotosUri)); + } + + if (mResolvedPhotoUri != null) { + mIntent.putExtra(EXTRA_RESOLVED_PHOTO_URI, mResolvedPhotoUri); + } + + if (mProjection != null) { + mIntent.putExtra(EXTRA_PROJECTION, mProjection); + } + + if (mThumbnailUri != null) { + mIntent.putExtra(EXTRA_THUMBNAIL_URI, mThumbnailUri); + } + + if (mContentDescription != null) { + mIntent.putExtra(EXTRA_CONTENT_DESCRIPTION, mContentDescription); + } + + if (mMaxInitialScale != null) { + mIntent.putExtra(EXTRA_MAX_INITIAL_SCALE, mMaxInitialScale); + } + + mIntent.putExtra(EXTRA_WATCH_NETWORK, mWatchNetwork); + + mIntent.putExtra(EXTRA_SCALE_UP_ANIMATION, mScaleAnimation); + if (mScaleAnimation) { + mIntent.putExtra(EXTRA_ANIMATION_START_X, mStartX); + mIntent.putExtra(EXTRA_ANIMATION_START_Y, mStartY); + mIntent.putExtra(EXTRA_ANIMATION_START_WIDTH, mStartWidth); + mIntent.putExtra(EXTRA_ANIMATION_START_HEIGHT, mStartHeight); + } + + mIntent.putExtra(EXTRA_ACTION_BAR_HIDDEN_INITIALLY, mActionBarHiddenInitially); + mIntent.putExtra(EXTRA_DISPLAY_THUMBS_FULLSCREEN, mDisplayFullScreenThumbs); + mIntent.putExtra(EXTRA_ENABLE_TIMER_LIGHTS_OUT, mEnableTimerLightsOut); + return mIntent; + } + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/PhotoViewCallbacks.java b/modules/photoviewer/src/com/android/ex/photo/PhotoViewCallbacks.java new file mode 100644 index 0000000..0d2d8d3 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/PhotoViewCallbacks.java @@ -0,0 +1,101 @@ +package com.android.ex.photo; + +import android.database.Cursor; +import android.os.Bundle; +import androidx.fragment.app.Fragment; +import androidx.loader.content.Loader; + +import com.android.ex.photo.adapters.PhotoPagerAdapter; +import com.android.ex.photo.fragments.PhotoViewFragment; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; + +public interface PhotoViewCallbacks { + + public static final int BITMAP_LOADER_AVATAR = 1; + public static final int BITMAP_LOADER_THUMBNAIL = 2; + public static final int BITMAP_LOADER_PHOTO = 3; + + /** + * Listener to be invoked for screen events. + */ + public static interface OnScreenListener { + + /** + * The full screen state has changed. + */ + public void onFullScreenChanged(boolean fullScreen); + + /** + * A new view has been activated and the previous view de-activated. + */ + public void onViewActivated(); + + /** + * This view is a candidate for being the next view. + * + * This will be called when the view is focused completely on the view immediately before + * or after this one, so that this view can reset itself if nessecary. + */ + public void onViewUpNext(); + + /** + * Called when a right-to-left touch move intercept is about to occur. + * + * @param origX the raw x coordinate of the initial touch + * @param origY the raw y coordinate of the initial touch + * @return {@code true} if the touch should be intercepted. + */ + public boolean onInterceptMoveLeft(float origX, float origY); + + /** + * Called when a left-to-right touch move intercept is about to occur. + * + * @param origX the raw x coordinate of the initial touch + * @param origY the raw y coordinate of the initial touch + * @return {@code true} if the touch should be intercepted. + */ + public boolean onInterceptMoveRight(float origX, float origY); + } + + public static interface CursorChangedListener { + /** + * Called when the cursor that contains the photo list data + * is updated. Note that there is no guarantee that the cursor + * will be at the proper position. + * @param cursor the cursor containing the photo list data + */ + public void onCursorChanged(Cursor cursor); + } + + public void addScreenListener(int position, OnScreenListener listener); + + public void removeScreenListener(int position); + + public void addCursorListener(CursorChangedListener listener); + + public void removeCursorListener(CursorChangedListener listener); + + public void setViewActivated(int position); + + public void onNewPhotoLoaded(int position); + + public void onFragmentPhotoLoadComplete(PhotoViewFragment fragment, + boolean success); + + public void toggleFullScreen(); + + public boolean isFragmentActive(Fragment fragment); + + public void onFragmentVisible(PhotoViewFragment fragment); + + public boolean isFragmentFullScreen(Fragment fragment); + + public void onCursorChanged(PhotoViewFragment fragment, Cursor cursor); + + public Loader onCreateBitmapLoader(int id, Bundle args, String uri); + + /** + * Returns the adapter associated with this activity. + */ + public PhotoPagerAdapter getAdapter(); +} diff --git a/modules/photoviewer/src/com/android/ex/photo/PhotoViewController.java b/modules/photoviewer/src/com/android/ex/photo/PhotoViewController.java new file mode 100644 index 0000000..0face96 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/PhotoViewController.java @@ -0,0 +1,1380 @@ +package com.android.ex.photo; + +import android.app.Activity; +import android.app.ActivityManager; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Process; +import androidx.annotation.IdRes; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.viewpager.widget.ViewPager.OnPageChangeListener; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewPropertyAnimator; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityManager; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.Animation.AnimationListener; +import android.view.animation.AnimationSet; +import android.view.animation.ScaleAnimation; +import android.view.animation.TranslateAnimation; +import android.widget.ImageView; + +import com.android.ex.photo.ActionBarInterface.OnMenuVisibilityListener; +import com.android.ex.photo.PhotoViewPager.InterceptType; +import com.android.ex.photo.PhotoViewPager.OnInterceptTouchListener; +import com.android.ex.photo.adapters.PhotoPagerAdapter; +import com.android.ex.photo.fragments.PhotoViewFragment; +import com.android.ex.photo.loaders.PhotoBitmapLoader; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; +import com.android.ex.photo.loaders.PhotoPagerLoader; +import com.android.ex.photo.provider.PhotoContract; +import com.android.ex.photo.util.ImageUtils; +import com.android.ex.photo.util.Util; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * This class implements all the logic of the photo view activity. An activity should use this class + * calling through from relevant activity methods to the methods of the same name here. + * + * To customize the photo viewer activity, you should subclass this and implement your + * customizations here. Then subclass {@link PhotoViewActivity} and override just + * {@link PhotoViewActivity#createController createController} to instantiate your controller + * subclass. + */ +public class PhotoViewController implements + LoaderManager.LoaderCallbacks, OnPageChangeListener, OnInterceptTouchListener, + OnMenuVisibilityListener, PhotoViewCallbacks { + + /** + * Defines the interface between the Activity and this class. + * + * The activity itself must delegate all appropriate method calls into this class, to the + * methods of the same name. + */ + public interface ActivityInterface { + public Context getContext(); + public Context getApplicationContext(); + public Intent getIntent(); + public void setContentView(int resId); + public T findViewById(int id); + public Resources getResources(); + public FragmentManager getSupportFragmentManager(); + public LoaderManager getSupportLoaderManager(); + public ActionBarInterface getActionBarInterface(); + public boolean onOptionsItemSelected(MenuItem item); + public void finish(); + public void overridePendingTransition(int enterAnim, int exitAnim); + public PhotoViewController getController(); + } + + private final static String TAG = "PhotoViewController"; + + private final static String STATE_INITIAL_URI_KEY = + "com.android.ex.PhotoViewFragment.INITIAL_URI"; + private final static String STATE_CURRENT_URI_KEY = + "com.android.ex.PhotoViewFragment.CURRENT_URI"; + private final static String STATE_CURRENT_INDEX_KEY = + "com.android.ex.PhotoViewFragment.CURRENT_INDEX"; + private final static String STATE_FULLSCREEN_KEY = + "com.android.ex.PhotoViewFragment.FULLSCREEN"; + private final static String STATE_ACTIONBARTITLE_KEY = + "com.android.ex.PhotoViewFragment.ACTIONBARTITLE"; + private final static String STATE_ACTIONBARSUBTITLE_KEY = + "com.android.ex.PhotoViewFragment.ACTIONBARSUBTITLE"; + private final static String STATE_ENTERANIMATIONFINISHED_KEY = + "com.android.ex.PhotoViewFragment.SCALEANIMATIONFINISHED"; + + protected final static String ARG_IMAGE_URI = "image_uri"; + + public static final int LOADER_PHOTO_LIST = 100; + + /** Count used when the real photo count is unknown [but, may be determined] */ + public static final int ALBUM_COUNT_UNKNOWN = -1; + + public static final int ENTER_ANIMATION_DURATION_MS = 250; + public static final int EXIT_ANIMATION_DURATION_MS = 250; + + /** Argument key for the dialog message */ + public static final String KEY_MESSAGE = "dialog_message"; + + public static int sMemoryClass; + public static int sMaxPhotoSize; // The maximum size (either width or height) + + private final ActivityInterface mActivity; + + private int mLastFlags; + + private final View.OnSystemUiVisibilityChangeListener mSystemUiVisibilityChangeListener; + + /** The URI of the photos we're viewing; may be {@code null} */ + private String mPhotosUri; + /** The uri of the initial photo */ + private String mInitialPhotoUri; + /** The index of the currently viewed photo */ + private int mCurrentPhotoIndex; + /** The uri of the currently viewed photo */ + private String mCurrentPhotoUri; + /** The query projection to use; may be {@code null} */ + private String[] mProjection; + /** The total number of photos; only valid if {@link #mIsEmpty} is {@code false}. */ + protected int mAlbumCount = ALBUM_COUNT_UNKNOWN; + /** {@code true} if the view is empty. Otherwise, {@code false}. */ + protected boolean mIsEmpty; + /** the main root view */ + protected View mRootView; + /** Background image that contains nothing, so it can be alpha faded from + * transparent to black without affecting any other views. */ + @Nullable + protected View mBackground; + /** The main pager; provides left/right swipe between photos */ + protected PhotoViewPager mViewPager; + /** The temporary image so that we can quickly scale up the fullscreen thumbnail */ + @Nullable + protected ImageView mTemporaryImage; + /** Adapter to create pager views */ + protected PhotoPagerAdapter mAdapter; + /** Whether or not we're in "full screen" mode */ + protected boolean mFullScreen; + /** The listeners wanting full screen state for each screen position */ + private final Map + mScreenListeners = new HashMap(); + /** The set of listeners wanting full screen state */ + private final Set mCursorListeners = new HashSet(); + /** When {@code true}, restart the loader when the activity becomes active */ + private boolean mKickLoader; + /** Don't attempt operations that may trigger a fragment transaction when the activity is + * destroyed */ + private boolean mIsDestroyedCompat; + /** Whether or not this activity is paused */ + protected boolean mIsPaused = true; + /** The maximum scale factor applied to images when they are initially displayed */ + protected float mMaxInitialScale; + /** The title in the actionbar */ + protected String mActionBarTitle; + /** The subtitle in the actionbar */ + protected String mActionBarSubtitle; + + private boolean mEnterAnimationFinished; + protected boolean mScaleAnimationEnabled; + protected int mAnimationStartX; + protected int mAnimationStartY; + protected int mAnimationStartWidth; + protected int mAnimationStartHeight; + + /** Whether lights out should invoked based on timer */ + protected boolean mIsTimerLightsOutEnabled; + protected boolean mActionBarHiddenInitially; + protected boolean mDisplayThumbsFullScreen; + + private final AccessibilityManager mAccessibilityManager; + + protected BitmapCallback mBitmapCallback; + protected final Handler mHandler = new Handler(); + + // TODO Find a better way to do this. We basically want the activity to display the + // "loading..." progress until the fragment takes over and shows it's own "loading..." + // progress [located in photo_header_view.xml]. We could potentially have all status displayed + // by the activity, but, that gets tricky when it comes to screen rotation. For now, we + // track the loading by this variable which is fragile and may cause phantom "loading..." + // text. + private long mEnterFullScreenDelayTime; + + private int lastAnnouncedTitle = -1; + + public PhotoViewController(ActivityInterface activity) { + mActivity = activity; + + // View.OnSystemUiVisibilityChangeListener is an API that was introduced in API level 11. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { + mSystemUiVisibilityChangeListener = null; + } else { + mSystemUiVisibilityChangeListener = new View.OnSystemUiVisibilityChangeListener() { + @Override + public void onSystemUiVisibilityChange(int visibility) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && + visibility == 0 && mLastFlags == 3846) { + setFullScreen(false /* fullscreen */, true /* setDelayedRunnable */); + } + } + }; + } + + mAccessibilityManager = (AccessibilityManager) + activity.getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); + } + + public PhotoPagerAdapter createPhotoPagerAdapter(Context context, + androidx.fragment.app.FragmentManager fm, Cursor c, float maxScale) { + return new PhotoPagerAdapter(context, fm, c, maxScale, mDisplayThumbsFullScreen); + } + + public PhotoViewController.ActivityInterface getActivity() { + return mActivity; + } + + public void onCreate(Bundle savedInstanceState) { + initMaxPhotoSize(); + final ActivityManager mgr = (ActivityManager) mActivity.getApplicationContext(). + getSystemService(Activity.ACTIVITY_SERVICE); + sMemoryClass = mgr.getMemoryClass(); + + final Intent intent = mActivity.getIntent(); + // uri of the photos to view; optional + if (intent.hasExtra(Intents.EXTRA_PHOTOS_URI)) { + mPhotosUri = intent.getStringExtra(Intents.EXTRA_PHOTOS_URI); + } + + mIsTimerLightsOutEnabled = intent.getBooleanExtra( + Intents.EXTRA_ENABLE_TIMER_LIGHTS_OUT, true); + + if (intent.getBooleanExtra(Intents.EXTRA_SCALE_UP_ANIMATION, false)) { + mScaleAnimationEnabled = true; + mAnimationStartX = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_X, 0); + mAnimationStartY = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_Y, 0); + mAnimationStartWidth = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_WIDTH, 0); + mAnimationStartHeight = intent.getIntExtra(Intents.EXTRA_ANIMATION_START_HEIGHT, 0); + } + mActionBarHiddenInitially = intent.getBooleanExtra( + Intents.EXTRA_ACTION_BAR_HIDDEN_INITIALLY, false) + && !Util.isTouchExplorationEnabled(mAccessibilityManager); + mDisplayThumbsFullScreen = intent.getBooleanExtra( + Intents.EXTRA_DISPLAY_THUMBS_FULLSCREEN, false); + + // projection for the query; optional + // If not set, the default projection is used. + // This projection must include the columns from the default projection. + if (intent.hasExtra(Intents.EXTRA_PROJECTION)) { + mProjection = intent.getStringArrayExtra(Intents.EXTRA_PROJECTION); + } else { + mProjection = null; + } + + // Set the max initial scale, defaulting to 1x + mMaxInitialScale = intent.getFloatExtra(Intents.EXTRA_MAX_INITIAL_SCALE, 1.0f); + mCurrentPhotoUri = null; + mCurrentPhotoIndex = -1; + + // We allow specifying the current photo by either index or uri. + // This is because some users may have live datasets that can change, + // adding new items to either the beginning or end of the set. For clients + // that do not need that capability, ability to specify the current photo + // by index is offered as a convenience. + if (intent.hasExtra(Intents.EXTRA_PHOTO_INDEX)) { + mCurrentPhotoIndex = intent.getIntExtra(Intents.EXTRA_PHOTO_INDEX, -1); + } + if (intent.hasExtra(Intents.EXTRA_INITIAL_PHOTO_URI)) { + mInitialPhotoUri = intent.getStringExtra(Intents.EXTRA_INITIAL_PHOTO_URI); + mCurrentPhotoUri = mInitialPhotoUri; + } + mIsEmpty = true; + + if (savedInstanceState != null) { + mInitialPhotoUri = savedInstanceState.getString(STATE_INITIAL_URI_KEY); + mCurrentPhotoUri = savedInstanceState.getString(STATE_CURRENT_URI_KEY); + mCurrentPhotoIndex = savedInstanceState.getInt(STATE_CURRENT_INDEX_KEY); + mFullScreen = savedInstanceState.getBoolean(STATE_FULLSCREEN_KEY, false) + && !Util.isTouchExplorationEnabled(mAccessibilityManager); + mActionBarTitle = savedInstanceState.getString(STATE_ACTIONBARTITLE_KEY); + mActionBarSubtitle = savedInstanceState.getString(STATE_ACTIONBARSUBTITLE_KEY); + mEnterAnimationFinished = savedInstanceState.getBoolean( + STATE_ENTERANIMATIONFINISHED_KEY, false); + } else { + mFullScreen = mActionBarHiddenInitially; + } + + mActivity.setContentView(getContentViewId()); + + // Create the adapter and add the view pager + mAdapter = createPhotoPagerAdapter(mActivity.getContext(), + mActivity.getSupportFragmentManager(), null, mMaxInitialScale); + final Resources resources = mActivity.getResources(); + mRootView = findViewById(getRootViewId()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + mRootView.setOnSystemUiVisibilityChangeListener(getSystemUiVisibilityChangeListener()); + } + mBackground = getBackground(); + mTemporaryImage = getTemporaryImage(); + mViewPager = (PhotoViewPager) findViewById(R.id.photo_view_pager); + mViewPager.setAdapter(mAdapter); + mViewPager.setOnPageChangeListener(this); + mViewPager.setOnInterceptTouchListener(this); + mViewPager.setPageMargin(resources.getDimensionPixelSize(R.dimen.photo_page_margin)); + + mBitmapCallback = new BitmapCallback(); + if (!mScaleAnimationEnabled || mEnterAnimationFinished) { + // We are not running the scale up animation. Just let the fragments + // display and handle the animation. + mActivity.getSupportLoaderManager().initLoader(LOADER_PHOTO_LIST, null, this); + // Make the background opaque immediately so that we don't see the activity + // behind this one. + if (hasBackground()) { + mBackground.setVisibility(View.VISIBLE); + } + } else { + // Attempt to load the initial image thumbnail. Once we have the + // image, animate it up. Once the animation is complete, we can kick off + // loading the ViewPager. After the primary fullres image is loaded, we will + // make our temporary image invisible and display the ViewPager. + mViewPager.setVisibility(View.GONE); + Bundle args = new Bundle(); + args.putString(ARG_IMAGE_URI, mInitialPhotoUri); + mActivity.getSupportLoaderManager().initLoader( + BITMAP_LOADER_THUMBNAIL, args, mBitmapCallback); + } + + mEnterFullScreenDelayTime = + resources.getInteger(R.integer.reenter_fullscreen_delay_time_in_millis); + + final ActionBarInterface actionBar = mActivity.getActionBarInterface(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.addOnMenuVisibilityListener(this); + actionBar.setDisplayOptionsShowTitle(); + // Set the title and subtitle immediately here, rather than waiting + // for the fragment to be initialized. + setActionBarTitles(actionBar); + } + + if (!mScaleAnimationEnabled) { + setLightsOutMode(mFullScreen); + } else { + // Keep lights out mode as false. This is to prevent jank cause by concurrent + // animations during the enter animation. + setLightsOutMode(false); + } + } + + private void initMaxPhotoSize() { + if (sMaxPhotoSize == 0) { + final DisplayMetrics metrics = new DisplayMetrics(); + final WindowManager wm = (WindowManager) + mActivity.getContext().getSystemService(Context.WINDOW_SERVICE); + final ImageUtils.ImageSize imageSize = ImageUtils.sUseImageSize; + wm.getDefaultDisplay().getMetrics(metrics); + switch (imageSize) { + case EXTRA_SMALL: + // Use a photo that's 80% of the "small" size + sMaxPhotoSize = (Math.min(metrics.heightPixels, metrics.widthPixels) * 800) / 1000; + break; + case SMALL: + // Fall through. + case NORMAL: + // Fall through. + default: + sMaxPhotoSize = Math.min(metrics.heightPixels, metrics.widthPixels); + break; + } + } + } + + public boolean onCreateOptionsMenu(Menu menu) { + return true; + } + + public boolean onPrepareOptionsMenu(Menu menu) { + return true; + } + + public void onActivityResult(int requestCode, int resultCode, Intent data) {} + + protected View findViewById(int id) { + return mActivity.findViewById(id); + } + + /** + * Returns the android id of the viewer's root view. Subclasses should override this method if + * they provide their own layout. + */ + @IdRes + protected int getRootViewId() { + return R.id.photo_activity_root_view; + } + + /** + * Returns the android layout id of the root layout that should be inflated for the viewer. + * Subclasses should override this method if they provide their own layout. + */ + @IdRes + protected int getContentViewId() { + return R.layout.photo_activity_view; + } + + /** + * Returns the android view for the viewer's background view, if it has one. Subclasses should + * override this if they have a different (or no) background view. + */ + @Nullable + protected View getBackground() { + return findViewById(R.id.photo_activity_background); + } + + /** + * Returns whether or not the view has a background object. Subclasses should override this if + * they do not contain a background object. + */ + protected boolean hasBackground() { + return mBackground != null; + } + + /** + * Returns the android view for the viewer's temporary image, if it has one. Subclasses should + * override this if they have a different (or no) temporary image view. + */ + @Nullable + protected ImageView getTemporaryImage() { + return (ImageView) findViewById(R.id.photo_activity_temporary_image); + } + + /** + * Returns whether or not the view has a temporary image view. Subclasses should override this + * if they do not use a temporary image. + */ + protected boolean hasTemporaryImage() { + return mTemporaryImage != null; + } + + public void onStart() {} + + public void onResume() { + setFullScreen(mFullScreen, false); + + mIsPaused = false; + if (mKickLoader) { + mKickLoader = false; + mActivity.getSupportLoaderManager().initLoader(LOADER_PHOTO_LIST, null, this); + } + } + + public void onPause() { + mIsPaused = true; + } + + public void onStop() {} + + public void onDestroy() { + mIsDestroyedCompat = true; + } + + private boolean isDestroyedCompat() { + return mIsDestroyedCompat; + } + + public boolean onBackPressed() { + // If we are in fullscreen mode, and the default is not full screen, then + // switch back to actionBar display mode. + if (mFullScreen && !mActionBarHiddenInitially) { + toggleFullScreen(); + } else { + if (mScaleAnimationEnabled) { + runExitAnimation(); + } else { + return false; + } + } + return true; + } + + public void onSaveInstanceState(Bundle outState) { + outState.putString(STATE_INITIAL_URI_KEY, mInitialPhotoUri); + outState.putString(STATE_CURRENT_URI_KEY, mCurrentPhotoUri); + outState.putInt(STATE_CURRENT_INDEX_KEY, mCurrentPhotoIndex); + outState.putBoolean(STATE_FULLSCREEN_KEY, mFullScreen); + outState.putString(STATE_ACTIONBARTITLE_KEY, mActionBarTitle); + outState.putString(STATE_ACTIONBARSUBTITLE_KEY, mActionBarSubtitle); + outState.putBoolean(STATE_ENTERANIMATIONFINISHED_KEY, mEnterAnimationFinished); + } + + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + mActivity.finish(); + return true; + default: + return false; + } + } + + @Override + public void addScreenListener(int position, OnScreenListener listener) { + mScreenListeners.put(position, listener); + } + + @Override + public void removeScreenListener(int position) { + mScreenListeners.remove(position); + } + + @Override + public synchronized void addCursorListener(CursorChangedListener listener) { + mCursorListeners.add(listener); + } + + @Override + public synchronized void removeCursorListener(CursorChangedListener listener) { + mCursorListeners.remove(listener); + } + + @Override + public boolean isFragmentFullScreen(Fragment fragment) { + if (mViewPager == null || mAdapter == null || mAdapter.getCount() == 0) { + return mFullScreen; + } + return mFullScreen || (mViewPager.getCurrentItem() != mAdapter.getItemPosition(fragment)); + } + + @Override + public void toggleFullScreen() { + setFullScreen(!mFullScreen, true); + } + + public void onPhotoRemoved(long photoId) { + final Cursor data = mAdapter.getCursor(); + if (data == null) { + // Huh?! How would this happen? + return; + } + + final int dataCount = data.getCount(); + if (dataCount <= 1) { + mActivity.finish(); + return; + } + + mActivity.getSupportLoaderManager().restartLoader(LOADER_PHOTO_LIST, null, this); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + if (id == LOADER_PHOTO_LIST) { + return new PhotoPagerLoader(mActivity.getContext(), Uri.parse(mPhotosUri), mProjection); + } + return null; + } + + @Override + public Loader onCreateBitmapLoader(int id, Bundle args, String uri) { + switch (id) { + case BITMAP_LOADER_AVATAR: + case BITMAP_LOADER_THUMBNAIL: + case BITMAP_LOADER_PHOTO: + return new PhotoBitmapLoader(mActivity.getContext(), uri); + default: + return null; + } + } + + @Override + public void onLoadFinished(Loader loader, Cursor data) { + final int id = loader.getId(); + if (id == LOADER_PHOTO_LIST) { + if (data == null || data.getCount() == 0) { + mIsEmpty = true; + mAdapter.swapCursor(null); + } else { + mAlbumCount = data.getCount(); + if (mCurrentPhotoUri != null) { + int index = 0; + // Clear query params. Compare only the path. + final int uriIndex = data.getColumnIndex(PhotoContract.PhotoViewColumns.URI); + final Uri currentPhotoUri; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + currentPhotoUri = Uri.parse(mCurrentPhotoUri).buildUpon() + .clearQuery().build(); + } else { + currentPhotoUri = Uri.parse(mCurrentPhotoUri).buildUpon() + .query(null).build(); + } + // Rewind data cursor to the start if it has already advanced. + data.moveToPosition(-1); + while (data.moveToNext()) { + final String uriString = data.getString(uriIndex); + final Uri uri; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + uri = Uri.parse(uriString).buildUpon().clearQuery().build(); + } else { + uri = Uri.parse(uriString).buildUpon().query(null).build(); + } + if (currentPhotoUri != null && currentPhotoUri.equals(uri)) { + mCurrentPhotoIndex = index; + break; + } + index++; + } + } + + // We're paused; don't do anything now, we'll get re-invoked + // when the activity becomes active again + if (mIsPaused) { + mKickLoader = true; + mAdapter.swapCursor(null); + return; + } + boolean wasEmpty = mIsEmpty; + mIsEmpty = false; + + mAdapter.swapCursor(data); + if (mViewPager.getAdapter() == null) { + mViewPager.setAdapter(mAdapter); + } + notifyCursorListeners(data); + + // Use an index of 0 if the index wasn't specified or couldn't be found + if (mCurrentPhotoIndex < 0) { + mCurrentPhotoIndex = 0; + } + + mViewPager.setCurrentItem(mCurrentPhotoIndex, false); + if (wasEmpty) { + setViewActivated(mCurrentPhotoIndex); + } + } + // Update the any action items + updateActionItems(); + } + } + + @Override + public void onLoaderReset(androidx.loader.content.Loader loader) { + // If the loader is reset, remove the reference in the adapter to this cursor + if (!isDestroyedCompat()) { + // This will cause a fragment transaction which can't happen if we're destroyed, + // but we don't care in that case because we're destroyed anyways. + mAdapter.swapCursor(null); + } + } + + public void updateActionItems() { + // Do nothing, but allow extending classes to do work + } + + private synchronized void notifyCursorListeners(Cursor data) { + // tell all of the objects listening for cursor changes + // that the cursor has changed + for (CursorChangedListener listener : mCursorListeners) { + listener.onCursorChanged(data); + } + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + if (positionOffset < 0.0001) { + OnScreenListener before = mScreenListeners.get(position - 1); + if (before != null) { + before.onViewUpNext(); + } + OnScreenListener after = mScreenListeners.get(position + 1); + if (after != null) { + after.onViewUpNext(); + } + } + } + + @Override + public void onPageSelected(int position) { + mCurrentPhotoIndex = position; + setViewActivated(position); + } + + @Override + public void onPageScrollStateChanged(int state) { + } + + @Override + public boolean isFragmentActive(Fragment fragment) { + if (mViewPager == null || mAdapter == null) { + return false; + } + return mViewPager.getCurrentItem() == mAdapter.getItemPosition(fragment); + } + + @Override + public void onFragmentVisible(PhotoViewFragment fragment) { + // Do nothing, we handle this in setViewActivated + } + + @Override + public InterceptType onTouchIntercept(float origX, float origY) { + boolean interceptLeft = false; + boolean interceptRight = false; + + for (OnScreenListener listener : mScreenListeners.values()) { + if (!interceptLeft) { + interceptLeft = listener.onInterceptMoveLeft(origX, origY); + } + if (!interceptRight) { + interceptRight = listener.onInterceptMoveRight(origX, origY); + } + } + + if (interceptLeft) { + if (interceptRight) { + return InterceptType.BOTH; + } + return InterceptType.LEFT; + } else if (interceptRight) { + return InterceptType.RIGHT; + } + return InterceptType.NONE; + } + + /** + * Updates the title bar according to the value of {@link #mFullScreen}. + */ + protected void setFullScreen(boolean fullScreen, boolean setDelayedRunnable) { + if (Util.isTouchExplorationEnabled(mAccessibilityManager)) { + // Disallow full screen mode when accessibility is enabled so that the action bar + // stays accessible. + fullScreen = false; + setDelayedRunnable = false; + } + + final boolean fullScreenChanged = (fullScreen != mFullScreen); + mFullScreen = fullScreen; + + if (mFullScreen) { + setLightsOutMode(true); + cancelEnterFullScreenRunnable(); + } else { + setLightsOutMode(false); + if (setDelayedRunnable) { + postEnterFullScreenRunnableWithDelay(); + } + } + + if (fullScreenChanged) { + for (OnScreenListener listener : mScreenListeners.values()) { + listener.onFullScreenChanged(mFullScreen); + } + } + } + + /** + * Posts a runnable to enter full screen after mEnterFullScreenDelayTime. This method is a + * no-op if mIsTimerLightsOutEnabled is set to false. + */ + private void postEnterFullScreenRunnableWithDelay() { + if (mIsTimerLightsOutEnabled) { + mHandler.postDelayed(mEnterFullScreenRunnable, mEnterFullScreenDelayTime); + } + } + + private void cancelEnterFullScreenRunnable() { + mHandler.removeCallbacks(mEnterFullScreenRunnable); + } + + protected void setLightsOutMode(boolean enabled) { + setImmersiveMode(enabled); + } + + private final Runnable mEnterFullScreenRunnable = new Runnable() { + @Override + public void run() { + setFullScreen(true, true); + } + }; + + @Override + public void setViewActivated(int position) { + OnScreenListener listener = mScreenListeners.get(position); + if (listener != null) { + listener.onViewActivated(); + } + final Cursor cursor = getCursorAtProperPosition(); + mCurrentPhotoIndex = position; + // FLAG: get the column indexes once in onLoadFinished(). + // That would make this more efficient, instead of looking these up + // repeatedly whenever we want them. + int uriIndex = cursor.getColumnIndex(PhotoContract.PhotoViewColumns.URI); + mCurrentPhotoUri = cursor.getString(uriIndex); + updateActionBar(); + if (mAccessibilityManager.isEnabled() && lastAnnouncedTitle != position) { + String announcement = getPhotoAccessibilityAnnouncement(position); + if (announcement != null) { + Util.announceForAccessibility(mRootView, mAccessibilityManager, announcement); + lastAnnouncedTitle = position; + } + } + + // Restart the timer to return to fullscreen. + cancelEnterFullScreenRunnable(); + postEnterFullScreenRunnableWithDelay(); + } + + /** + * Adjusts the activity title and subtitle to reflect the photo name and count. + */ + public void updateActionBar() { + final int position = mViewPager.getCurrentItem() + 1; + final boolean hasAlbumCount = mAlbumCount >= 0; + + final Cursor cursor = getCursorAtProperPosition(); + if (cursor != null) { + // FLAG: We should grab the indexes when we first get the cursor + // and store them so we don't need to do it each time. + final int photoNameIndex = cursor.getColumnIndex(PhotoContract.PhotoViewColumns.NAME); + mActionBarTitle = cursor.getString(photoNameIndex); + } else { + mActionBarTitle = null; + } + + if (mIsEmpty || !hasAlbumCount || position <= 0) { + mActionBarSubtitle = null; + } else { + mActionBarSubtitle = mActivity.getResources().getString( + R.string.photo_view_count, position, mAlbumCount); + } + + setActionBarTitles(mActivity.getActionBarInterface()); + } + + /** + * Returns a string used as an announcement for accessibility after the user moves to a new + * photo. It will be called after {@link #updateActionBar} has been called. + * @param position the index in the album of the currently active photo + * @return announcement for accessibility + */ + protected String getPhotoAccessibilityAnnouncement(int position) { + String announcement = mActionBarTitle; + if (mActionBarSubtitle != null) { + announcement = mActivity.getContext().getResources().getString( + R.string.titles, mActionBarTitle, mActionBarSubtitle); + } + return announcement; + } + + /** + * Sets the Action Bar title to {@link #mActionBarTitle} and the subtitle to + * {@link #mActionBarSubtitle} + */ + protected final void setActionBarTitles(ActionBarInterface actionBar) { + if (actionBar == null) { + return; + } + actionBar.setTitle(getInputOrEmpty(mActionBarTitle)); + actionBar.setSubtitle(getInputOrEmpty(mActionBarSubtitle)); + } + + /** + * If the input string is non-null, it is returned, otherwise an empty string is returned; + * @param in + * @return + */ + private static final String getInputOrEmpty(String in) { + if (in == null) { + return ""; + } + return in; + } + + /** + * Utility method that will return the cursor that contains the data + * at the current position so that it refers to the current image on screen. + * @return the cursor at the current position or + * null if no cursor exists or if the {@link PhotoViewPager} is null. + */ + public Cursor getCursorAtProperPosition() { + if (mViewPager == null) { + return null; + } + + final int position = mViewPager.getCurrentItem(); + final Cursor cursor = mAdapter.getCursor(); + + if (cursor == null) { + return null; + } + + cursor.moveToPosition(position); + + return cursor; + } + + public Cursor getCursor() { + return (mAdapter == null) ? null : mAdapter.getCursor(); + } + + @Override + public void onMenuVisibilityChanged(boolean isVisible) { + if (isVisible) { + cancelEnterFullScreenRunnable(); + } else { + postEnterFullScreenRunnableWithDelay(); + } + } + + @Override + public void onNewPhotoLoaded(int position) { + // do nothing + } + + protected void setPhotoIndex(int index) { + mCurrentPhotoIndex = index; + } + + @Override + public void onFragmentPhotoLoadComplete(PhotoViewFragment fragment, boolean success) { + if (hasTemporaryImage() && mTemporaryImage.getVisibility() != View.GONE && + TextUtils.equals(fragment.getPhotoUri(), mCurrentPhotoUri)) { + if (success) { + // The fragment for the current image is now ready for display. + if (hasTemporaryImage()) { + mTemporaryImage.setVisibility(View.GONE); + } + mViewPager.setVisibility(View.VISIBLE); + } else { + // This means that we are unable to load the fragment's photo. + // I'm not sure what the best thing to do here is, but at least if + // we display the viewPager, the fragment itself can decide how to + // display the failure of its own image. + Log.w(TAG, "Failed to load fragment image"); + if (hasTemporaryImage()) { + mTemporaryImage.setVisibility(View.GONE); + } + mViewPager.setVisibility(View.VISIBLE); + } + mActivity.getSupportLoaderManager().destroyLoader( + PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL); + } + } + + protected boolean isFullScreen() { + return mFullScreen; + } + + @Override + public void onCursorChanged(PhotoViewFragment fragment, Cursor cursor) { + // do nothing + } + + @Override + public PhotoPagerAdapter getAdapter() { + return mAdapter; + } + + public void onEnterAnimationComplete() { + mEnterAnimationFinished = true; + mViewPager.setVisibility(View.VISIBLE); + setLightsOutMode(mFullScreen); + } + + private void onExitAnimationComplete() { + mActivity.finish(); + mActivity.overridePendingTransition(0, 0); + } + + private void runEnterAnimation() { + final int totalWidth = mRootView.getMeasuredWidth(); + final int totalHeight = mRootView.getMeasuredHeight(); + + // FLAG: Need to handle the aspect ratio of the bitmap. If it's a portrait + // bitmap, then we need to position the view higher so that the middle + // pixels line up. + if (hasTemporaryImage()) { + mTemporaryImage.setVisibility(View.VISIBLE); + } + // We need to take a full screen image, and scale/translate it so that + // it appears at exactly the same location onscreen as it is in the + // prior activity. + // The final image will take either the full screen width or height (or both). + + final float scaleW = (float) mAnimationStartWidth / totalWidth; + final float scaleY = (float) mAnimationStartHeight / totalHeight; + final float scale = Math.max(scaleW, scaleY); + + final int translateX = calculateTranslate(mAnimationStartX, mAnimationStartWidth, + totalWidth, scale); + final int translateY = calculateTranslate(mAnimationStartY, mAnimationStartHeight, + totalHeight, scale); + + final int version = android.os.Build.VERSION.SDK_INT; + if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + if (hasBackground()) { + mBackground.setAlpha(0f); + mBackground.animate().alpha(1f).setDuration(ENTER_ANIMATION_DURATION_MS).start(); + mBackground.setVisibility(View.VISIBLE); + } + + if (hasTemporaryImage()) { + mTemporaryImage.setScaleX(scale); + mTemporaryImage.setScaleY(scale); + mTemporaryImage.setTranslationX(translateX); + mTemporaryImage.setTranslationY(translateY); + + Runnable endRunnable = new Runnable() { + @Override + public void run() { + PhotoViewController.this.onEnterAnimationComplete(); + } + }; + ViewPropertyAnimator animator = mTemporaryImage.animate().scaleX(1f).scaleY(1f) + .translationX(0).translationY(0).setDuration(ENTER_ANIMATION_DURATION_MS); + if (version >= Build.VERSION_CODES.JELLY_BEAN) { + animator.withEndAction(endRunnable); + } else { + mHandler.postDelayed(endRunnable, ENTER_ANIMATION_DURATION_MS); + } + animator.start(); + } + } else { + if (hasBackground()) { + final Animation alphaAnimation = new AlphaAnimation(0f, 1f); + alphaAnimation.setDuration(ENTER_ANIMATION_DURATION_MS); + mBackground.startAnimation(alphaAnimation); + mBackground.setVisibility(View.VISIBLE); + } + + if (hasTemporaryImage()) { + final Animation translateAnimation = new TranslateAnimation(translateX, + translateY, 0, 0); + translateAnimation.setDuration(ENTER_ANIMATION_DURATION_MS); + Animation scaleAnimation = new ScaleAnimation(scale, scale, 0, 0); + scaleAnimation.setDuration(ENTER_ANIMATION_DURATION_MS); + + AnimationSet animationSet = new AnimationSet(true); + animationSet.addAnimation(translateAnimation); + animationSet.addAnimation(scaleAnimation); + AnimationListener listener = new AnimationListener() { + @Override + public void onAnimationEnd(Animation arg0) { + PhotoViewController.this.onEnterAnimationComplete(); + } + + @Override + public void onAnimationRepeat(Animation arg0) { + } + + @Override + public void onAnimationStart(Animation arg0) { + } + }; + animationSet.setAnimationListener(listener); + mTemporaryImage.startAnimation(animationSet); + } + } + } + + private void runExitAnimation() { + Intent intent = mActivity.getIntent(); + // FLAG: should just fall back to a standard animation if either: + // 1. images have been added or removed since we've been here, or + // 2. we are currently looking at some image other than the one we + // started on. + + final int totalWidth = mRootView.getMeasuredWidth(); + final int totalHeight = mRootView.getMeasuredHeight(); + + // We need to take a full screen image, and scale/translate it so that + // it appears at exactly the same location onscreen as it is in the + // prior activity. + // The final image will take either the full screen width or height (or both). + final float scaleW = (float) mAnimationStartWidth / totalWidth; + final float scaleY = (float) mAnimationStartHeight / totalHeight; + final float scale = Math.max(scaleW, scaleY); + + final int translateX = calculateTranslate(mAnimationStartX, mAnimationStartWidth, + totalWidth, scale); + final int translateY = calculateTranslate(mAnimationStartY, mAnimationStartHeight, + totalHeight, scale); + final int version = android.os.Build.VERSION.SDK_INT; + if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + if (hasBackground()) { + mBackground.animate().alpha(0f).setDuration(EXIT_ANIMATION_DURATION_MS).start(); + mBackground.setVisibility(View.VISIBLE); + } + + Runnable endRunnable = new Runnable() { + @Override + public void run() { + PhotoViewController.this.onExitAnimationComplete(); + } + }; + // If the temporary image is still visible it means that we have + // not yet loaded the fullres image, so we need to animate + // the temporary image out. + ViewPropertyAnimator animator = null; + if (hasTemporaryImage() && mTemporaryImage.getVisibility() == View.VISIBLE) { + animator = mTemporaryImage.animate().scaleX(scale).scaleY(scale) + .translationX(translateX).translationY(translateY) + .setDuration(EXIT_ANIMATION_DURATION_MS); + } else { + animator = mViewPager.animate().scaleX(scale).scaleY(scale) + .translationX(translateX).translationY(translateY) + .setDuration(EXIT_ANIMATION_DURATION_MS); + } + // If the user has swiped to a different photo, fade out the current photo + // along with the scale animation. + if (!mInitialPhotoUri.equals(mCurrentPhotoUri)) { + animator.alpha(0f); + } + if (version >= android.os.Build.VERSION_CODES.JELLY_BEAN) { + animator.withEndAction(endRunnable); + } else { + mHandler.postDelayed(endRunnable, EXIT_ANIMATION_DURATION_MS); + } + animator.start(); + } else { + if (hasBackground()) { + final Animation alphaAnimation = new AlphaAnimation(1f, 0f); + alphaAnimation.setDuration(EXIT_ANIMATION_DURATION_MS); + mBackground.startAnimation(alphaAnimation); + mBackground.setVisibility(View.VISIBLE); + } + + final Animation scaleAnimation = new ScaleAnimation(1f, 1f, scale, scale); + scaleAnimation.setDuration(EXIT_ANIMATION_DURATION_MS); + AnimationListener listener = new AnimationListener() { + @Override + public void onAnimationEnd(Animation arg0) { + PhotoViewController.this.onExitAnimationComplete(); + } + + @Override + public void onAnimationRepeat(Animation arg0) { + } + + @Override + public void onAnimationStart(Animation arg0) { + } + }; + scaleAnimation.setAnimationListener(listener); + // If the temporary image is still visible it means that we have + // not yet loaded the fullres image, so we need to animate + // the temporary image out. + if (hasTemporaryImage() && mTemporaryImage.getVisibility() == View.VISIBLE) { + mTemporaryImage.startAnimation(scaleAnimation); + } else { + mViewPager.startAnimation(scaleAnimation); + } + } + } + + private int calculateTranslate(int start, int startSize, int totalSize, float scale) { + // Translation takes precedence over scale. What this means is that if + // we want an view's upper left corner to be a particular spot on screen, + // but that view is scaled to something other than 1, we need to take into + // account the pixels lost to scaling. + // So if we have a view that is 200x300, and we want it's upper left corner + // to be at 50x50, but it's scaled by 50%, we can't just translate it to 50x50. + // If we were to do that, the view's *visible* upper left corner would be at + // 100x200. We need to take into account the difference between the outside + // size of the view (i.e. the size prior to scaling) and the scaled size. + // scaleFromEdge is the difference between the visible left edge and the + // actual left edge, due to scaling. + // scaleFromTop is the difference between the visible top edge, and the + // actual top edge, due to scaling. + int scaleFromEdge = Math.round((totalSize - totalSize * scale) / 2); + + // The imageView is fullscreen, regardless of the aspect ratio of the actual image. + // This means that some portion of the imageView will be blank. We need to + // take into account the size of the blank area so that the actual image + // lines up with the starting image. + int blankSize = Math.round((totalSize * scale - startSize) / 2); + + return start - scaleFromEdge - blankSize; + } + + private void initTemporaryImage(Drawable drawable) { + if (mEnterAnimationFinished) { + // Forget this, we've already run the animation. + return; + } + if (hasTemporaryImage()) { + mTemporaryImage.setImageDrawable(drawable); + } + if (drawable != null) { + // We have not yet run the enter animation. Start it now. + int totalWidth = mRootView.getMeasuredWidth(); + if (totalWidth == 0) { + // the measure pass has not yet finished. We can't properly + // run out animation until that is done. Listen for the layout + // to occur, then fire the animation. + final View base = mRootView; + base.getViewTreeObserver().addOnGlobalLayoutListener( + new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int version = android.os.Build.VERSION.SDK_INT; + if (version >= android.os.Build.VERSION_CODES.JELLY_BEAN) { + base.getViewTreeObserver().removeOnGlobalLayoutListener(this); + } else { + base.getViewTreeObserver().removeGlobalOnLayoutListener(this); + } + runEnterAnimation(); + } + }); + } else { + // initiate the animation + runEnterAnimation(); + } + } + // Kick off the photo list loader + mActivity.getSupportLoaderManager().initLoader(LOADER_PHOTO_LIST, null, this); + } + + public void showActionBar() { + mActivity.getActionBarInterface().show(); + } + + public void hideActionBar() { + mActivity.getActionBarInterface().hide(); + } + + public boolean isScaleAnimationEnabled() { + return mScaleAnimationEnabled; + } + + public boolean isEnterAnimationFinished() { + return mEnterAnimationFinished; + } + + public View getRootView() { + return mRootView; + } + + private class BitmapCallback implements LoaderManager.LoaderCallbacks { + + @Override + public Loader onCreateLoader(int id, Bundle args) { + String uri = args.getString(ARG_IMAGE_URI); + switch (id) { + case PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL: + return onCreateBitmapLoader(PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL, + args, uri); + case PhotoViewCallbacks.BITMAP_LOADER_AVATAR: + return onCreateBitmapLoader(PhotoViewCallbacks.BITMAP_LOADER_AVATAR, + args, uri); + } + return null; + } + + @Override + public void onLoadFinished(Loader loader, BitmapResult result) { + Drawable drawable = result.getDrawable(mActivity.getResources()); + final ActionBarInterface actionBar = mActivity.getActionBarInterface(); + switch (loader.getId()) { + case PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL: + // We just loaded the initial thumbnail that we can display + // while waiting for the full viewPager to get initialized. + initTemporaryImage(drawable); + break; + case PhotoViewCallbacks.BITMAP_LOADER_AVATAR: + if (drawable == null) { + actionBar.setLogo(null); + } else { + actionBar.setLogo(drawable); + } + break; + } + } + + @Override + public void onLoaderReset(Loader loader) { + // Do nothing + } + } + + public void setImmersiveMode(boolean enabled) { + int flags = 0; + final int version = Build.VERSION.SDK_INT; + final boolean manuallyUpdateActionBar = version < Build.VERSION_CODES.JELLY_BEAN; + if (enabled && + (!isScaleAnimationEnabled() || isEnterAnimationFinished())) { + // Turning on immersive mode causes an animation. If the scale animation is enabled and + // the enter animation isn't yet complete, then an immersive mode animation should not + // occur, since two concurrent animations are very janky. + + // Disable immersive mode for seconary users to prevent b/12015090 (freezing crash) + // This is fixed in KK_MR2 but there is no way to differentiate between KK and KK_MR2. + if (version > Build.VERSION_CODES.KITKAT || + version == Build.VERSION_CODES.KITKAT && !kitkatIsSecondaryUser()) { + flags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_IMMERSIVE; + } else if (version >= Build.VERSION_CODES.JELLY_BEAN) { + // Clients that use the scale animation should set the following system UI flags to + // prevent janky animations on exit when the status bar is hidden: + // View.SYSTEM_UI_FLAG_VISIBLE | View.SYSTEM_UI_FLAG_STABLE + // As well, client should ensure `android:fitsSystemWindows` is set on the root + // content view. + flags = View.SYSTEM_UI_FLAG_LOW_PROFILE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_FULLSCREEN; + } else if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + flags = View.SYSTEM_UI_FLAG_LOW_PROFILE; + } else if (version >= Build.VERSION_CODES.HONEYCOMB) { + flags = View.STATUS_BAR_HIDDEN; + } + + if (manuallyUpdateActionBar) { + hideActionBar(); + } + } else { + if (version >= Build.VERSION_CODES.KITKAT) { + flags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE; + } else if (version >= Build.VERSION_CODES.JELLY_BEAN) { + flags = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE; + } else if (version >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + flags = View.SYSTEM_UI_FLAG_VISIBLE; + } else if (version >= Build.VERSION_CODES.HONEYCOMB) { + flags = View.STATUS_BAR_VISIBLE; + } + + if (manuallyUpdateActionBar) { + showActionBar(); + } + } + + if (version >= Build.VERSION_CODES.HONEYCOMB) { + mLastFlags = flags; + getRootView().setSystemUiVisibility(flags); + } + } + + /** + * Return true iff the app is being run as a secondary user on kitkat. + * + * This is a hack which we only know to work on kitkat. + */ + private boolean kitkatIsSecondaryUser() { + if (Build.VERSION.SDK_INT != Build.VERSION_CODES.KITKAT) { + throw new IllegalStateException("kitkatIsSecondary user is only callable on KitKat"); + } + return Process.myUid() > 100000; + } + + /** + * Note: This should only be called when API level is 11 or above. + */ + public View.OnSystemUiVisibilityChangeListener getSystemUiVisibilityChangeListener() { + return mSystemUiVisibilityChangeListener; + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/PhotoViewPager.java b/modules/photoviewer/src/com/android/ex/photo/PhotoViewPager.java new file mode 100644 index 0000000..4004a2a --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/PhotoViewPager.java @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo; + +import android.content.Context; +import androidx.core.view.MotionEventCompat; +import androidx.viewpager.widget.ViewPager; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +/** + * View pager for photo view fragments. Define our own class so we can specify the + * view pager in XML. + */ +public class PhotoViewPager extends ViewPager { + /** + * A type of intercept that should be performed + */ + public static enum InterceptType { NONE, LEFT, RIGHT, BOTH } + + /** + * Provides an ability to intercept touch events. + *

+ * {@link ViewPager} intercepts all touch events and we need to be able to override this + * behavior. Instead, we could perform a similar function by declaring a custom + * {@link android.view.ViewGroup} to contain the pager and intercept touch events at a higher + * level. + */ + public static interface OnInterceptTouchListener { + /** + * Called when a touch intercept is about to occur. + * + * @param origX the raw x coordinate of the initial touch + * @param origY the raw y coordinate of the initial touch + * @return Which type of touch, if any, should should be intercepted. + */ + public InterceptType onTouchIntercept(float origX, float origY); + } + + private static final int INVALID_POINTER = -1; + + private float mLastMotionX; + private int mActivePointerId; + /** The x coordinate where the touch originated */ + private float mActivatedX; + /** The y coordinate where the touch originated */ + private float mActivatedY; + private OnInterceptTouchListener mListener; + + public PhotoViewPager(Context context) { + super(context); + initialize(); + } + + public PhotoViewPager(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + private void initialize() { + // Set the page transformer to perform the transition animation + // for each page in the view. + setPageTransformer(true, new PageTransformer() { + @Override + public void transformPage(View page, float position) { + + // The >= 1 is needed so that the page + // (page A) that transitions behind the newly visible + // page (page B) that comes in from the left does not + // get the touch events because it is still on screen + // (page A is still technically on screen despite being + // invisible). This makes sure that when the transition + // has completely finished, we revert it to its default + // behavior and move it off of the screen. + if (position < 0 || position >= 1.f) { + page.setTranslationX(0); + page.setAlpha(1.f); + page.setScaleX(1); + page.setScaleY(1); + } else { + page.setTranslationX(-position * page.getWidth()); + page.setAlpha(Math.max(0,1.f - position)); + final float scale = Math.max(0, 1.f - position * 0.3f); + page.setScaleX(scale); + page.setScaleY(scale); + } + } + }); + } + + /** + * {@inheritDoc} + *

+ * We intercept touch event intercepts so we can prevent switching views when the + * current view is internally scrollable. + */ + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + final InterceptType intercept = (mListener != null) + ? mListener.onTouchIntercept(mActivatedX, mActivatedY) + : InterceptType.NONE; + final boolean ignoreScrollLeft = + (intercept == InterceptType.BOTH || intercept == InterceptType.LEFT); + final boolean ignoreScrollRight = + (intercept == InterceptType.BOTH || intercept == InterceptType.RIGHT); + + // Only check ability to page if we can't scroll in one / both directions + final int action = ev.getAction() & MotionEventCompat.ACTION_MASK; + + if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { + mActivePointerId = INVALID_POINTER; + } + + switch (action) { + case MotionEvent.ACTION_MOVE: { + if (ignoreScrollLeft || ignoreScrollRight) { + final int activePointerId = mActivePointerId; + if (activePointerId == INVALID_POINTER) { + // If we don't have a valid id, the touch down wasn't on content. + break; + } + + final int pointerIndex = + MotionEventCompat.findPointerIndex(ev, activePointerId); + final float x = MotionEventCompat.getX(ev, pointerIndex); + + if (ignoreScrollLeft && ignoreScrollRight) { + mLastMotionX = x; + return false; + } else if (ignoreScrollLeft && (x > mLastMotionX)) { + mLastMotionX = x; + return false; + } else if (ignoreScrollRight && (x < mLastMotionX)) { + mLastMotionX = x; + return false; + } + } + break; + } + + case MotionEvent.ACTION_DOWN: { + mLastMotionX = ev.getX(); + // Use the raw x/y as the children can be located anywhere and there isn't a + // single offset that would be meaningful + mActivatedX = ev.getRawX(); + mActivatedY = ev.getRawY(); + mActivePointerId = MotionEventCompat.getPointerId(ev, 0); + break; + } + + case MotionEventCompat.ACTION_POINTER_UP: { + final int pointerIndex = MotionEventCompat.getActionIndex(ev); + final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex); + if (pointerId == mActivePointerId) { + // Our active pointer going up; select a new active pointer + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mLastMotionX = MotionEventCompat.getX(ev, newPointerIndex); + mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex); + } + break; + } + } + + return super.onInterceptTouchEvent(ev); + } + + /** + * sets the intercept touch listener. + */ + public void setOnInterceptTouchListener(OnInterceptTouchListener l) { + mListener = l; + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/adapters/BaseCursorPagerAdapter.java b/modules/photoviewer/src/com/android/ex/photo/adapters/BaseCursorPagerAdapter.java new file mode 100644 index 0000000..47cde79 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/adapters/BaseCursorPagerAdapter.java @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo.adapters; + +import android.content.Context; +import android.database.Cursor; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import android.util.Log; +import android.util.SparseIntArray; +import android.view.View; + +import com.android.ex.photo.provider.PhotoContract; + +import java.util.HashMap; + +/** + * Page adapter for use with an BaseCursorLoader. Unlike other cursor adapters, this has no + * observers for automatic refresh. Instead, it depends upon external mechanisms to provide + * the update signal. + */ +public abstract class BaseCursorPagerAdapter extends BaseFragmentPagerAdapter { + private static final String TAG = "BaseCursorPagerAdapter"; + + protected Context mContext; + protected Cursor mCursor; + protected int mRowIDColumn; + /** Mapping of row ID to cursor position */ + protected SparseIntArray mItemPosition; + /** Mapping of instantiated object to row ID */ + protected final HashMap mObjectRowMap = new HashMap(); + + /** + * Constructor that always enables auto-requery. + * + * @param c The cursor from which to get the data. + * @param context The context + */ + public BaseCursorPagerAdapter(Context context, FragmentManager fm, Cursor c) { + super(fm); + init(context, c); + } + + /** + * Makes a fragment for the data pointed to by the cursor + * + * @param context Interface to application's global information + * @param cursor The cursor from which to get the data. The cursor is already + * moved to the correct position. + * @return the newly created fragment. + */ + public abstract Fragment getItem(Context context, Cursor cursor, int position); + + // TODO: This shouldn't just return null - maybe it needs to wait for a cursor to be supplied? + // See b/7103023 + @Override + public Fragment getItem(int position) { + if (mCursor != null && moveCursorTo(position)) { + return getItem(mContext, mCursor, position); + } + return null; + } + + @Override + public int getCount() { + if (mCursor != null) { + return mCursor.getCount(); + } else { + return 0; + } + } + + @Override + public Object instantiateItem(View container, int position) { + if (mCursor == null) { + throw new IllegalStateException("this should only be called when the cursor is valid"); + } + + final Integer rowId; + if (moveCursorTo(position)) { + rowId = mCursor.getString(mRowIDColumn).hashCode(); + } else { + rowId = null; + } + + // Create the fragment and bind cursor data + final Object obj = super.instantiateItem(container, position); + if (obj != null) { + mObjectRowMap.put(obj, rowId); + } + return obj; + } + + @Override + public void destroyItem(View container, int position, Object object) { + mObjectRowMap.remove(object); + + super.destroyItem(container, position, object); + } + + @Override + public int getItemPosition(Object object) { + final Integer rowId = mObjectRowMap.get(object); + if (rowId == null || mItemPosition == null) { + return POSITION_NONE; + } + + final int position = mItemPosition.get(rowId, POSITION_NONE); + return position; + } + + /** + * @return true if data is valid + */ + public boolean isDataValid() { + return mCursor != null; + } + + /** + * Returns the cursor. + */ + public Cursor getCursor() { + return mCursor; + } + + /** + * Returns the data item associated with the specified position in the data set. + */ + public Object getDataItem(int position) { + if (mCursor != null && moveCursorTo(position)) { + return mCursor; + } else { + return null; + } + } + + /** + * Returns the row id associated with the specified position in the list. + */ + public long getItemId(int position) { + if (mCursor != null && moveCursorTo(position)) { + return mCursor.getString(mRowIDColumn).hashCode(); + } else { + return 0; + } + } + + /** + * Swap in a new Cursor, returning the old Cursor. + * + * @param newCursor The new cursor to be used. + * @return Returns the previously set Cursor, or null if there was not one. + * If the given new Cursor is the same instance is the previously set + * Cursor, null is also returned. + */ + public Cursor swapCursor(Cursor newCursor) { + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "swapCursor old=" + (mCursor == null ? -1 : mCursor.getCount()) + + "; new=" + (newCursor == null ? -1 : newCursor.getCount())); + } + + if (newCursor == mCursor) { + return null; + } + Cursor oldCursor = mCursor; + mCursor = newCursor; + if (newCursor != null) { + mRowIDColumn = newCursor.getColumnIndex(PhotoContract.PhotoViewColumns.URI); + } else { + mRowIDColumn = -1; + } + + setItemPosition(); + notifyDataSetChanged(); // notify the observers about the new cursor + return oldCursor; + } + + /** + * Converts the cursor into a CharSequence. Subclasses should override this + * method to convert their results. The default implementation returns an + * empty String for null values or the default String representation of + * the value. + * + * @param cursor the cursor to convert to a CharSequence + * @return a CharSequence representing the value + */ + public CharSequence convertToString(Cursor cursor) { + return cursor == null ? "" : cursor.toString(); + } + + @Override + protected String makeFragmentName(int viewId, int index) { + if (moveCursorTo(index)) { + return "android:pager:" + viewId + ":" + mCursor.getString(mRowIDColumn).hashCode(); + } else { + return super.makeFragmentName(viewId, index); + } + } + + /** + * Moves the cursor to the given position + * + * @return {@code true} if the cursor's position was set. Otherwise, {@code false}. + */ + private boolean moveCursorTo(int position) { + if (mCursor != null && !mCursor.isClosed()) { + return mCursor.moveToPosition(position); + } + return false; + } + + /** + * Initialize the adapter. + */ + private void init(Context context, Cursor c) { + boolean cursorPresent = c != null; + mCursor = c; + mContext = context; + mRowIDColumn = cursorPresent + ? mCursor.getColumnIndex(PhotoContract.PhotoViewColumns.URI) : -1; + } + + /** + * Sets the {@link #mItemPosition} instance variable with the current mapping of + * row id to cursor position. + */ + private void setItemPosition() { + if (mCursor == null || mCursor.isClosed()) { + mItemPosition = null; + return; + } + + SparseIntArray itemPosition = new SparseIntArray(mCursor.getCount()); + + mCursor.moveToPosition(-1); + while (mCursor.moveToNext()) { + final int rowId = mCursor.getString(mRowIDColumn).hashCode(); + final int position = mCursor.getPosition(); + + itemPosition.append(rowId, position); + } + mItemPosition = itemPosition; + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/adapters/BaseFragmentPagerAdapter.java b/modules/photoviewer/src/com/android/ex/photo/adapters/BaseFragmentPagerAdapter.java new file mode 100644 index 0000000..f5518ed --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/adapters/BaseFragmentPagerAdapter.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo.adapters; + +import android.os.Parcelable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.collection.LruCache; +import androidx.viewpager.widget.PagerAdapter; +import android.util.Log; +import android.view.View; + +/** + * NOTE: This is a direct copy of {@link androidx.fragment.app.FragmentPagerAdapter} + * with four very important modifications. + *

+ *

    + *
  1. The method {@link #makeFragmentName(int, int)} is declared "protected" + * in our class. We need to be able to re-define the fragment's name according to data + * only available to sub-classes.
  2. + *
  3. The method {@link #isViewFromObject(View, Object)} has been reimplemented to search + * the entire view hierarchy for the given view.
  4. + *
  5. In method {@link #destroyItem(View, int, Object)}, the fragment is detached and + * added to a cache. If the fragment is evicted from the cache, it will be deleted. + * An album may contain thousands of photos and we want to avoid having thousands of + * fragments.
  6. + *
+ */ +public abstract class BaseFragmentPagerAdapter extends PagerAdapter { + /** The default size of {@link #mFragmentCache} */ + private static final int DEFAULT_CACHE_SIZE = 5; + private static final String TAG = "FragmentPagerAdapter"; + private static final boolean DEBUG = false; + + private final FragmentManager mFragmentManager; + private FragmentTransaction mCurTransaction = null; + private Fragment mCurrentPrimaryItem = null; + /** A cache to store detached fragments before they are removed */ + private LruCache mFragmentCache = new FragmentCache(DEFAULT_CACHE_SIZE); + + public BaseFragmentPagerAdapter(androidx.fragment.app.FragmentManager fm) { + mFragmentManager = fm; + } + + /** + * Return the Fragment associated with a specified position. + */ + public abstract Fragment getItem(int position); + + @Override + public void startUpdate(View container) { + } + + @Override + public Object instantiateItem(View container, int position) { + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + + // Do we already have this fragment? + String name = makeFragmentName(container.getId(), position); + + // Remove item from the cache + mFragmentCache.remove(name); + + Fragment fragment = mFragmentManager.findFragmentByTag(name); + if (fragment != null) { + if (DEBUG) Log.v(TAG, "Attaching item #" + position + ": f=" + fragment); + mCurTransaction.attach(fragment); + } else { + fragment = getItem(position); + if(fragment == null) { + if (DEBUG) Log.e(TAG, "NPE workaround for getItem(). See b/7103023"); + return null; + } + if (DEBUG) Log.v(TAG, "Adding item #" + position + ": f=" + fragment); + mCurTransaction.add(container.getId(), fragment, + makeFragmentName(container.getId(), position)); + } + if (fragment != mCurrentPrimaryItem) { + fragment.setMenuVisibility(false); + } + + return fragment; + } + + @Override + public void destroyItem(View container, int position, Object object) { + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + if (DEBUG) Log.v(TAG, "Detaching item #" + position + ": f=" + object + + " v=" + ((Fragment)object).getView()); + + Fragment fragment = (Fragment) object; + String name = fragment.getTag(); + if (name == null) { + // We prefer to get the name directly from the fragment, but, if the fragment is + // detached before the add transaction is committed, this could be 'null'. In + // that case, generate a name so we can still cache the fragment. + name = makeFragmentName(container.getId(), position); + } + + mFragmentCache.put(name, fragment); + mCurTransaction.detach(fragment); + } + + @Override + public void setPrimaryItem(View container, int position, Object object) { + Fragment fragment = (Fragment) object; + if (fragment != mCurrentPrimaryItem) { + if (mCurrentPrimaryItem != null) { + mCurrentPrimaryItem.setMenuVisibility(false); + } + if (fragment != null) { + fragment.setMenuVisibility(true); + } + mCurrentPrimaryItem = fragment; + } + + } + + @Override + public void finishUpdate(View container) { + if (mCurTransaction != null && !mFragmentManager.isDestroyed()) { + mCurTransaction.commitAllowingStateLoss(); + mCurTransaction = null; + mFragmentManager.executePendingTransactions(); + } + } + + @Override + public boolean isViewFromObject(View view, Object object) { + // Ascend the tree to determine if the view is a child of the fragment + View root = ((Fragment) object).getView(); + for (Object v = view; v instanceof View; v = ((View) v).getParent()) { + if (v == root) { + return true; + } + } + return false; + } + + @Override + public Parcelable saveState() { + return null; + } + + @Override + public void restoreState(Parcelable state, ClassLoader loader) { + } + + /** Creates a name for the fragment */ + protected String makeFragmentName(int viewId, int index) { + return "android:switcher:" + viewId + ":" + index; + } + + /** + * A cache of detached fragments. + */ + private class FragmentCache extends LruCache { + public FragmentCache(int size) { + super(size); + } + + @Override + protected void entryRemoved(boolean evicted, String key, + Fragment oldValue, Fragment newValue) { + // remove the fragment if it's evicted OR it's replaced by a new fragment + if (evicted || (newValue != null && oldValue != newValue)) { + mCurTransaction.remove(oldValue); + } + } + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/adapters/PhotoPagerAdapter.java b/modules/photoviewer/src/com/android/ex/photo/adapters/PhotoPagerAdapter.java new file mode 100644 index 0000000..6765159 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/adapters/PhotoPagerAdapter.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo.adapters; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import androidx.fragment.app.Fragment; +import androidx.collection.SimpleArrayMap; + +import com.android.ex.photo.Intents; +import com.android.ex.photo.Intents.PhotoViewIntentBuilder; +import com.android.ex.photo.fragments.PhotoViewFragment; +import com.android.ex.photo.provider.PhotoContract.PhotoViewColumns; +import com.android.ex.photo.provider.PhotoContract.PhotoQuery; + +/** + * Pager adapter for the photo view + */ +public class PhotoPagerAdapter extends BaseCursorPagerAdapter { + protected SimpleArrayMap mColumnIndices = + new SimpleArrayMap(PhotoQuery.PROJECTION.length); + protected final float mMaxScale; + protected boolean mDisplayThumbsFullScreen; + + public PhotoPagerAdapter( + Context context, androidx.fragment.app.FragmentManager fm, Cursor c, + float maxScale, boolean thumbsFullScreen) { + super(context, fm, c); + mMaxScale = maxScale; + mDisplayThumbsFullScreen = thumbsFullScreen; + } + + @Override + public Fragment getItem(Context context, Cursor cursor, int position) { + final String photoUri = getPhotoUri(cursor); + final String thumbnailUri = getThumbnailUri(cursor); + final String contentDescription = getPhotoName(cursor); + boolean loading = shouldShowLoadingIndicator(cursor); + boolean onlyShowSpinner = false; + if(photoUri == null && loading) { + onlyShowSpinner = true; + } + + // create new PhotoViewFragment + final PhotoViewIntentBuilder builder = + Intents.newPhotoViewFragmentIntentBuilder(mContext, getPhotoViewFragmentClass()); + builder + .setResolvedPhotoUri(photoUri) + .setThumbnailUri(thumbnailUri) + .setContentDescription(contentDescription) + .setDisplayThumbsFullScreen(mDisplayThumbsFullScreen) + .setMaxInitialScale(mMaxScale); + + return createPhotoViewFragment(builder.build(), position, onlyShowSpinner); + } + + protected Class getPhotoViewFragmentClass() { + return PhotoViewFragment.class; + } + + protected PhotoViewFragment createPhotoViewFragment( + Intent intent, int position, boolean onlyShowSpinner) { + return PhotoViewFragment.newInstance(intent, position, onlyShowSpinner); + } + + @Override + public Cursor swapCursor(Cursor newCursor) { + mColumnIndices.clear(); + + if (newCursor != null) { + for(String column : PhotoQuery.PROJECTION) { + mColumnIndices.put(column, newCursor.getColumnIndexOrThrow(column)); + } + + for(String column : PhotoQuery.OPTIONAL_COLUMNS) { + int index = newCursor.getColumnIndex(column); + if (index != -1) { + mColumnIndices.put(column, index); + } + } + } + + return super.swapCursor(newCursor); + } + + public String getPhotoUri(Cursor cursor) { + return getString(cursor, PhotoViewColumns.CONTENT_URI); + } + + public String getThumbnailUri(Cursor cursor) { + return getString(cursor, PhotoViewColumns.THUMBNAIL_URI); + } + + public String getContentType(Cursor cursor) { + return getString(cursor, PhotoViewColumns.CONTENT_TYPE); + } + + public String getPhotoName(Cursor cursor) { + return getString(cursor, PhotoViewColumns.NAME); + } + + public boolean shouldShowLoadingIndicator(Cursor cursor) { + String value = getString(cursor, PhotoViewColumns.LOADING_INDICATOR); + if (value == null) { + return false; + } else { + return Boolean.parseBoolean(value); + } + } + + private String getString(Cursor cursor, String column) { + if (mColumnIndices.containsKey(column)) { + return cursor.getString(mColumnIndices.get(column)); + } else { + return null; + } + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/fragments/PhotoViewFragment.java b/modules/photoviewer/src/com/android/ex/photo/fragments/PhotoViewFragment.java new file mode 100644 index 0000000..e44e1ea --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/fragments/PhotoViewFragment.java @@ -0,0 +1,606 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo.fragments; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Bundle; +import androidx.fragment.app.Fragment; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.android.ex.photo.Intents; +import com.android.ex.photo.PhotoViewCallbacks; +import com.android.ex.photo.PhotoViewCallbacks.CursorChangedListener; +import com.android.ex.photo.PhotoViewCallbacks.OnScreenListener; +import com.android.ex.photo.PhotoViewController.ActivityInterface; +import com.android.ex.photo.R; +import com.android.ex.photo.adapters.PhotoPagerAdapter; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; +import com.android.ex.photo.views.PhotoView; +import com.android.ex.photo.views.ProgressBarWrapper; + +/** + * Displays a photo. + */ +public class PhotoViewFragment extends Fragment implements + LoaderManager.LoaderCallbacks, + OnClickListener, + OnScreenListener, + CursorChangedListener { + + /** + * Interface for components that are internally scrollable left-to-right. + */ + public static interface HorizontallyScrollable { + /** + * Return {@code true} if the component needs to receive right-to-left + * touch movements. + * + * @param origX the raw x coordinate of the initial touch + * @param origY the raw y coordinate of the initial touch + */ + + public boolean interceptMoveLeft(float origX, float origY); + + /** + * Return {@code true} if the component needs to receive left-to-right + * touch movements. + * + * @param origX the raw x coordinate of the initial touch + * @param origY the raw y coordinate of the initial touch + */ + public boolean interceptMoveRight(float origX, float origY); + } + + protected final static String STATE_INTENT_KEY = + "com.android.mail.photo.fragments.PhotoViewFragment.INTENT"; + + protected final static String ARG_INTENT = "arg-intent"; + protected final static String ARG_POSITION = "arg-position"; + protected final static String ARG_SHOW_SPINNER = "arg-show-spinner"; + + /** The URL of a photo to display */ + protected String mResolvedPhotoUri; + protected String mThumbnailUri; + protected String mContentDescription; + /** The intent we were launched with */ + protected Intent mIntent; + protected PhotoViewCallbacks mCallback; + protected PhotoPagerAdapter mAdapter; + + protected BroadcastReceiver mInternetStateReceiver; + + protected PhotoView mPhotoView; + protected ImageView mPhotoPreviewImage; + protected TextView mEmptyText; + protected ImageView mRetryButton; + protected ProgressBarWrapper mPhotoProgressBar; + + protected int mPosition; + + /** Whether or not the fragment should make the photo full-screen */ + protected boolean mFullScreen; + + /** + * True if the PhotoViewFragment should watch the network state in order to restart loaders. + */ + protected boolean mWatchNetworkState; + + /** Whether or not this fragment will only show the loading spinner */ + protected boolean mOnlyShowSpinner; + + /** Whether or not the progress bar is showing valid information about the progress stated */ + protected boolean mProgressBarNeeded = true; + + protected View mPhotoPreviewAndProgress; + protected boolean mThumbnailShown; + + /** Whether or not there is currently a connection to the internet */ + protected boolean mConnected; + + /** Whether or not we can display the thumbnail at fullscreen size */ + protected boolean mDisplayThumbsFullScreen; + + /** Public no-arg constructor for allowing the framework to handle orientation changes */ + public PhotoViewFragment() { + // Do nothing. + } + + /** + * Create a {@link PhotoViewFragment}. + * @param intent + * @param position + * @param onlyShowSpinner + */ + public static PhotoViewFragment newInstance( + Intent intent, int position, boolean onlyShowSpinner) { + final PhotoViewFragment f = new PhotoViewFragment(); + initializeArguments(intent, position, onlyShowSpinner, f); + return f; + } + + public static void initializeArguments( + Intent intent, int position, boolean onlyShowSpinner, PhotoViewFragment f) { + final Bundle b = new Bundle(); + b.putParcelable(ARG_INTENT, intent); + b.putInt(ARG_POSITION, position); + b.putBoolean(ARG_SHOW_SPINNER, onlyShowSpinner); + f.setArguments(b); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + mCallback = getCallbacks(); + if (mCallback == null) { + throw new IllegalArgumentException( + "Activity must be a derived class of PhotoViewActivity"); + } + mAdapter = mCallback.getAdapter(); + if (mAdapter == null) { + throw new IllegalStateException("Callback reported null adapter"); + } + // Don't call until we've setup the entire view + setViewVisibility(); + } + + protected PhotoViewCallbacks getCallbacks() { + return ((ActivityInterface) getActivity()).getController(); + } + + @Override + public void onDetach() { + mCallback = null; + super.onDetach(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final Bundle bundle = getArguments(); + if (bundle == null) { + return; + } + mIntent = bundle.getParcelable(ARG_INTENT); + mDisplayThumbsFullScreen = mIntent.getBooleanExtra( + Intents.EXTRA_DISPLAY_THUMBS_FULLSCREEN, false); + + mPosition = bundle.getInt(ARG_POSITION); + mOnlyShowSpinner = bundle.getBoolean(ARG_SHOW_SPINNER); + mProgressBarNeeded = true; + + if (savedInstanceState != null) { + final Bundle state = savedInstanceState.getBundle(STATE_INTENT_KEY); + if (state != null) { + mIntent = new Intent().putExtras(state); + } + } + + if (mIntent != null) { + mResolvedPhotoUri = mIntent.getStringExtra(Intents.EXTRA_RESOLVED_PHOTO_URI); + mThumbnailUri = mIntent.getStringExtra(Intents.EXTRA_THUMBNAIL_URI); + mContentDescription = mIntent.getStringExtra(Intents.EXTRA_CONTENT_DESCRIPTION); + mWatchNetworkState = mIntent.getBooleanExtra(Intents.EXTRA_WATCH_NETWORK, false); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.photo_fragment_view, container, false); + initializeView(view); + return view; + } + + protected void initializeView(View view) { + mPhotoView = (PhotoView) view.findViewById(R.id.photo_view); + mPhotoView.setMaxInitialScale(mIntent.getFloatExtra(Intents.EXTRA_MAX_INITIAL_SCALE, 1)); + mPhotoView.setOnClickListener(this); + mPhotoView.setFullScreen(mFullScreen, false); + mPhotoView.enableImageTransforms(false); + mPhotoView.setContentDescription(mContentDescription); + + mPhotoPreviewAndProgress = view.findViewById(R.id.photo_preview); + mPhotoPreviewImage = (ImageView) view.findViewById(R.id.photo_preview_image); + mThumbnailShown = false; + final ProgressBar indeterminate = + (ProgressBar) view.findViewById(R.id.indeterminate_progress); + final ProgressBar determinate = + (ProgressBar) view.findViewById(R.id.determinate_progress); + mPhotoProgressBar = new ProgressBarWrapper(determinate, indeterminate, true); + mEmptyText = (TextView) view.findViewById(R.id.empty_text); + mRetryButton = (ImageView) view.findViewById(R.id.retry_button); + + // Don't call until we've setup the entire view + setViewVisibility(); + } + + @Override + public void onResume() { + super.onResume(); + mCallback.addScreenListener(mPosition, this); + mCallback.addCursorListener(this); + + if (mWatchNetworkState) { + if (mInternetStateReceiver == null) { + mInternetStateReceiver = new InternetStateBroadcastReceiver(); + } + getActivity().registerReceiver(mInternetStateReceiver, + new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + ConnectivityManager connectivityManager = (ConnectivityManager) + getActivity().getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetInfo = connectivityManager.getActiveNetworkInfo(); + if (activeNetInfo != null) { + mConnected = activeNetInfo.isConnected(); + } else { + // Best to set this to false, since it won't stop us from trying to download, + // only allow us to try re-download if we get notified that we do have a connection. + mConnected = false; + } + } + + if (!isPhotoBound()) { + mProgressBarNeeded = true; + mPhotoPreviewAndProgress.setVisibility(View.VISIBLE); + + getLoaderManager().initLoader(PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL, + null, this); + + // FLAG: If we are displaying thumbnails at fullscreen size, then we + // could defer the loading of the fullscreen image until the thumbnail + // has finished loading, or even until the user attempts to zoom in. + getLoaderManager().initLoader(PhotoViewCallbacks.BITMAP_LOADER_PHOTO, + null, this); + } + } + + @Override + public void onPause() { + // Remove listeners + if (mWatchNetworkState) { + getActivity().unregisterReceiver(mInternetStateReceiver); + } + mCallback.removeCursorListener(this); + mCallback.removeScreenListener(mPosition); + super.onPause(); + } + + @Override + public void onDestroyView() { + // Clean up views and other components + if (mPhotoView != null) { + mPhotoView.clear(); + mPhotoView = null; + } + super.onDestroyView(); + } + + public String getPhotoUri() { + return mResolvedPhotoUri; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + if (mIntent != null) { + outState.putParcelable(STATE_INTENT_KEY, mIntent.getExtras()); + } + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + if(mOnlyShowSpinner) { + return null; + } + String uri = null; + switch (id) { + case PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL: + uri = mThumbnailUri; + break; + case PhotoViewCallbacks.BITMAP_LOADER_PHOTO: + uri = mResolvedPhotoUri; + break; + } + return mCallback.onCreateBitmapLoader(id, args, uri); + } + + @Override + public void onLoadFinished(Loader loader, BitmapResult result) { + // If we don't have a view, the fragment has been paused. We'll get the cursor again later. + // If we're not added, the fragment has detached during the loading process. We no longer + // need the result. + if (getView() == null || !isAdded()) { + return; + } + + final Drawable data = result.getDrawable(getResources()); + + final int id = loader.getId(); + switch (id) { + case PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL: + if (mDisplayThumbsFullScreen) { + displayPhoto(result); + } else { + if (isPhotoBound()) { + // There is need to do anything with the thumbnail + // image, as the full size image is being shown. + return; + } + + if (data == null) { + // no preview, show default + mPhotoPreviewImage.setImageResource(R.drawable.default_image); + mThumbnailShown = false; + } else { + // show preview + mPhotoPreviewImage.setImageDrawable(data); + mThumbnailShown = true; + } + mPhotoPreviewImage.setVisibility(View.VISIBLE); + if (getResources().getBoolean(R.bool.force_thumbnail_no_scaling)) { + mPhotoPreviewImage.setScaleType(ImageView.ScaleType.CENTER); + } + enableImageTransforms(false); + } + break; + + case PhotoViewCallbacks.BITMAP_LOADER_PHOTO: + displayPhoto(result); + break; + default: + break; + } + + if (mProgressBarNeeded == false) { + // Hide the progress bar as it isn't needed anymore. + mPhotoProgressBar.setVisibility(View.GONE); + } + + if (data != null) { + mCallback.onNewPhotoLoaded(mPosition); + } + setViewVisibility(); + } + + private void displayPhoto(BitmapResult result) { + if (result.status == BitmapResult.STATUS_EXCEPTION) { + mProgressBarNeeded = false; + mEmptyText.setText(R.string.failed); + mEmptyText.setVisibility(View.VISIBLE); + mCallback.onFragmentPhotoLoadComplete(this, false /* success */); + } else { + mEmptyText.setVisibility(View.GONE); + final Drawable data = result.getDrawable(getResources()); + bindPhoto(data); + mCallback.onFragmentPhotoLoadComplete(this, true /* success */); + } + } + + /** + * Binds an image to the photo view. + */ + private void bindPhoto(Drawable drawable) { + if (drawable != null) { + if (mPhotoView != null) { + mPhotoView.bindDrawable(drawable); + } + enableImageTransforms(true); + mPhotoPreviewAndProgress.setVisibility(View.GONE); + mProgressBarNeeded = false; + } + } + + public Drawable getDrawable() { + return (mPhotoView != null ? mPhotoView.getDrawable() : null); + } + + /** + * Enable or disable image transformations. When transformations are enabled, this view + * consumes all touch events. + */ + public void enableImageTransforms(boolean enable) { + mPhotoView.enableImageTransforms(enable); + } + + @Override + public void onLoaderReset(Loader loader) { + // Do nothing + } + + @Override + public void onClick(View v) { + mCallback.toggleFullScreen(); + } + + @Override + public void onFullScreenChanged(boolean fullScreen) { + setViewVisibility(); + } + + @Override + public void onViewUpNext() { + resetViews(); + } + + @Override + public void onViewActivated() { + if (!mCallback.isFragmentActive(this)) { + // we're not in the foreground; reset our view + resetViews(); + } else { + if (!isPhotoBound()) { + // Restart the loader + getLoaderManager().restartLoader(PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL, + null, this); + } + mCallback.onFragmentVisible(this); + } + } + + /** + * Reset the views to their default states + */ + public void resetViews() { + if (mPhotoView != null) { + mPhotoView.resetTransformations(); + } + } + + @Override + public boolean onInterceptMoveLeft(float origX, float origY) { + if (!mCallback.isFragmentActive(this)) { + // we're not in the foreground; don't intercept any touches + return false; + } + + return (mPhotoView != null && mPhotoView.interceptMoveLeft(origX, origY)); + } + + @Override + public boolean onInterceptMoveRight(float origX, float origY) { + if (!mCallback.isFragmentActive(this)) { + // we're not in the foreground; don't intercept any touches + return false; + } + + return (mPhotoView != null && mPhotoView.interceptMoveRight(origX, origY)); + } + + /** + * Returns {@code true} if a photo has been bound. Otherwise, returns {@code false}. + */ + public boolean isPhotoBound() { + return (mPhotoView != null && mPhotoView.isPhotoBound()); + } + + /** + * Sets view visibility depending upon whether or not we're in "full screen" mode. + */ + private void setViewVisibility() { + final boolean fullScreen = mCallback == null ? false : mCallback.isFragmentFullScreen(this); + setFullScreen(fullScreen); + } + + /** + * Sets full-screen mode for the views. + */ + public void setFullScreen(boolean fullScreen) { + mFullScreen = fullScreen; + } + + @Override + public void onCursorChanged(Cursor cursor) { + if (mAdapter == null) { + // The adapter is set in onAttach(), and is guaranteed to be non-null. We have magically + // received an onCursorChanged without attaching to an activity. Ignore this cursor + // change. + return; + } + // FLAG: There is a problem here: + // If the cursor changes, and new items are added at an earlier position than + // the current item, we will switch photos here. Really we should probably + // try to find a photo with the same url and move the cursor to that position. + if (cursor.moveToPosition(mPosition) && !isPhotoBound()) { + mCallback.onCursorChanged(this, cursor); + + final LoaderManager manager = getLoaderManager(); + + final Loader fakePhotoLoader = manager.getLoader( + PhotoViewCallbacks.BITMAP_LOADER_PHOTO); + if (fakePhotoLoader != null) { + final PhotoBitmapLoaderInterface loader = (PhotoBitmapLoaderInterface) fakePhotoLoader; + mResolvedPhotoUri = mAdapter.getPhotoUri(cursor); + loader.setPhotoUri(mResolvedPhotoUri); + loader.forceLoad(); + } + + if (!mThumbnailShown) { + final Loader fakeThumbnailLoader = manager.getLoader( + PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL); + if (fakeThumbnailLoader != null) { + final PhotoBitmapLoaderInterface loader = (PhotoBitmapLoaderInterface) fakeThumbnailLoader; + mThumbnailUri = mAdapter.getThumbnailUri(cursor); + loader.setPhotoUri(mThumbnailUri); + loader.forceLoad(); + } + } + } + } + + public int getPosition() { + return mPosition; + } + + public ProgressBarWrapper getPhotoProgressBar() { + return mPhotoProgressBar; + } + + public TextView getEmptyText() { + return mEmptyText; + } + + public ImageView getRetryButton() { + return mRetryButton; + } + + public boolean isProgressBarNeeded() { + return mProgressBarNeeded; + } + + private class InternetStateBroadcastReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + // This is only created if we have the correct permissions, so + ConnectivityManager connectivityManager = (ConnectivityManager) + context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetInfo = connectivityManager.getActiveNetworkInfo(); + if (activeNetInfo == null || !activeNetInfo.isConnected()) { + mConnected = false; + return; + } + if (mConnected == false && !isPhotoBound()) { + if (mThumbnailShown == false) { + getLoaderManager().restartLoader(PhotoViewCallbacks.BITMAP_LOADER_THUMBNAIL, + null, PhotoViewFragment.this); + } + getLoaderManager().restartLoader(PhotoViewCallbacks.BITMAP_LOADER_PHOTO, + null, PhotoViewFragment.this); + mConnected = true; + mPhotoProgressBar.setVisibility(View.VISIBLE); + } + } + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/loaders/PhotoBitmapLoader.java b/modules/photoviewer/src/com/android/ex/photo/loaders/PhotoBitmapLoader.java new file mode 100644 index 0000000..27ad96a --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/loaders/PhotoBitmapLoader.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo.loaders; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.net.Uri; +import androidx.loader.content.AsyncTaskLoader; +import android.util.DisplayMetrics; + +import com.android.ex.photo.PhotoViewController; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; +import com.android.ex.photo.util.ImageUtils; + +/** + * Loader for the bitmap of a photo. + */ +public class PhotoBitmapLoader extends AsyncTaskLoader + implements PhotoBitmapLoaderInterface { + private String mPhotoUri; + private Bitmap mBitmap; + + public PhotoBitmapLoader(Context context, String photoUri) { + super(context); + mPhotoUri = photoUri; + } + + @Override + public void setPhotoUri(String photoUri) { + mPhotoUri = photoUri; + } + + @Override + public BitmapResult loadInBackground() { + BitmapResult result = new BitmapResult(); + Context context = getContext(); + if (context != null && mPhotoUri != null) { + final ContentResolver resolver = context.getContentResolver(); + try { + result = ImageUtils.createLocalBitmap(resolver, Uri.parse(mPhotoUri), + PhotoViewController.sMaxPhotoSize); + if (result.bitmap != null) { + result.bitmap.setDensity(DisplayMetrics.DENSITY_MEDIUM); + } + } catch (UnsupportedOperationException ex) { + // We got image bytes, but unable to decode to a Bitmap + result.status = BitmapResult.STATUS_EXCEPTION; + } + } + + return result; + } + + /** + * Called when there is new data to deliver to the client. The + * super class will take care of delivering it; the implementation + * here just adds a little more logic. + */ + @Override + public void deliverResult(BitmapResult result) { + Bitmap bitmap = result != null ? result.bitmap : null; + if (isReset()) { + // An async query came in while the loader is stopped. We + // don't need the result. + if (bitmap != null) { + onReleaseResources(bitmap); + } + return; + } + Bitmap oldBitmap = mBitmap; + mBitmap = bitmap; + + if (isStarted()) { + // If the Loader is currently started, we can immediately + // deliver its results. + super.deliverResult(result); + } + + // At this point we can release the resources associated with + // 'oldBitmap' if needed; now that the new result is delivered we + // know that it is no longer in use. + if (oldBitmap != null && oldBitmap != bitmap && !oldBitmap.isRecycled()) { + onReleaseResources(oldBitmap); + } + } + + /** + * Handles a request to start the Loader. + */ + @Override + protected void onStartLoading() { + if (mBitmap != null) { + // If we currently have a result available, deliver it + // immediately. + BitmapResult result = new BitmapResult(); + result.status = BitmapResult.STATUS_SUCCESS; + result.bitmap = mBitmap; + deliverResult(result); + } + + if (takeContentChanged() || mBitmap == null) { + // If the data has changed since the last time it was loaded + // or is not currently available, start a load. + forceLoad(); + } + } + + /** + * Handles a request to stop the Loader. + */ + @Override protected void onStopLoading() { + // Attempt to cancel the current load task if possible. + cancelLoad(); + } + + /** + * Handles a request to cancel a load. + */ + @Override + public void onCanceled(BitmapResult result) { + super.onCanceled(result); + + // At this point we can release the resources associated with 'bitmap' + // if needed. + if (result != null) { + onReleaseResources(result.bitmap); + } + } + + /** + * Handles a request to completely reset the Loader. + */ + @Override + protected void onReset() { + super.onReset(); + + // Ensure the loader is stopped + onStopLoading(); + + // At this point we can release the resources associated with 'bitmap' + // if needed. + if (mBitmap != null) { + onReleaseResources(mBitmap); + mBitmap = null; + } + } + + /** + * Helper function to take care of releasing resources associated + * with an actively loaded data set. + */ + protected void onReleaseResources(Bitmap bitmap) { + if (bitmap != null && !bitmap.isRecycled()) { + bitmap.recycle(); + } + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/loaders/PhotoBitmapLoaderInterface.java b/modules/photoviewer/src/com/android/ex/photo/loaders/PhotoBitmapLoaderInterface.java new file mode 100644 index 0000000..eecd5a1 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/loaders/PhotoBitmapLoaderInterface.java @@ -0,0 +1,46 @@ +package com.android.ex.photo.loaders; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; + +public interface PhotoBitmapLoaderInterface { + + public void setPhotoUri(String photoUri); + + public void forceLoad(); + + public static class BitmapResult { + public static final int STATUS_SUCCESS = 0; + public static final int STATUS_EXCEPTION = 1; + + public Drawable drawable; + public Bitmap bitmap; + public int status; + + /** + * Returns a drawable to be used in the {@link com.android.ex.photo.views.PhotoView}. + * Should return null if the drawable is not ready to be shown (for instance, if + * the underlying bitmap is null). + */ + public Drawable getDrawable(Resources resources) { + if (resources == null) { + throw new IllegalArgumentException("resources can not be null!"); + } + + if (drawable != null) { + return drawable; + } + + // Don't create a new drawable if there's no bitmap. PhotoViewFragment regards + // a null drawable as a signal to keep showing the loading stuff. + // b/12348405. + if (bitmap == null) { + return null; + } + + return new BitmapDrawable(resources, bitmap); + } + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/loaders/PhotoPagerLoader.java b/modules/photoviewer/src/com/android/ex/photo/loaders/PhotoPagerLoader.java new file mode 100644 index 0000000..77c02c4 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/loaders/PhotoPagerLoader.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo.loaders; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import androidx.loader.content.CursorLoader; + +import com.android.ex.photo.provider.PhotoContract; + +/** + * Loader for a set of photo IDs. + */ +public class PhotoPagerLoader extends CursorLoader { + private final Uri mPhotosUri; + private final String[] mProjection; + + public PhotoPagerLoader( + Context context, Uri photosUri, String[] projection) { + super(context); + mPhotosUri = photosUri; + mProjection = projection != null ? projection : PhotoContract.PhotoQuery.PROJECTION; + } + + @Override + public Cursor loadInBackground() { + Cursor returnCursor = null; + + final Uri loaderUri = mPhotosUri.buildUpon().appendQueryParameter( + PhotoContract.ContentTypeParameters.CONTENT_TYPE, "image/").build(); + setUri(loaderUri); + setProjection(mProjection); + returnCursor = super.loadInBackground(); + + return returnCursor; + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/provider/PhotoContract.java b/modules/photoviewer/src/com/android/ex/photo/provider/PhotoContract.java new file mode 100644 index 0000000..b79310e --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/provider/PhotoContract.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo.provider; + +import android.net.Uri; +import android.provider.OpenableColumns; + +public final class PhotoContract { + /** Columns for the view */ + public static interface PhotoViewColumns { + /** + * This column is a {@link Uri} that can be queried + * for this individual image (resulting cursor has one single row for this image). + */ + public static final String URI = "uri"; + /** + * This column is a {@link String} that can be queried for this + * individual image to return a displayable name. + */ + public static final String NAME = OpenableColumns.DISPLAY_NAME; + /** + * This column is a {@link Uri} that points to the downloaded local file. + * Can be null. + */ + public static final String CONTENT_URI = "contentUri"; + /** + * This column is a {@link Uri} that points to a thumbnail of the image + * that ideally is a local file. + * Can be null. + */ + public static final String THUMBNAIL_URI = "thumbnailUri"; + /** + * This string column is the MIME type. + */ + public static final String CONTENT_TYPE = "contentType"; + /** + * This boolean column indicates that a loading indicator should display permanently + * if no image urls are provided. + */ + public static final String LOADING_INDICATOR = "loadingIndicator"; + } + + public static interface PhotoQuery { + /** Projection of the returned cursor */ + public final static String[] PROJECTION = { + PhotoViewColumns.URI, + PhotoViewColumns.NAME, + PhotoViewColumns.CONTENT_URI, + PhotoViewColumns.THUMBNAIL_URI, + PhotoViewColumns.CONTENT_TYPE + }; + + public final static String[] OPTIONAL_COLUMNS = { + PhotoViewColumns.LOADING_INDICATOR + }; + } + + public static final class ContentTypeParameters { + /** + * Parameter used to specify which type of content to return. + * Allows multiple types to be specified. + */ + public static final String CONTENT_TYPE = "contentType"; + + private ContentTypeParameters() {} + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/util/Exif.java b/modules/photoviewer/src/com/android/ex/photo/util/Exif.java new file mode 100644 index 0000000..9d0c283 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/util/Exif.java @@ -0,0 +1,211 @@ +/* + * 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.ex.photo.util; + +import android.util.Log; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +public class Exif { + private static final String TAG = "CameraExif"; + + /** + * Returns the degrees in clockwise. Values are 0, 90, 180, or 270. + * @param inputStream The input stream will not be closed for you. + * @param byteSize Recommended parameter declaring the length of the input stream. If you + * pass in -1, we will have to read more from the input stream. + * @return 0, 90, 180, or 270. + */ + public static int getOrientation(final InputStream inputStream, final long byteSize) { + if (inputStream == null) { + return 0; + } + + /* + Looking at this algorithm, we never look ahead more than 8 bytes. As long as we call + advanceTo() at the end of every loop, we should never have to reallocate a larger buffer. + + Also, the most we ever read backwards is 4 bytes. pack() reads backwards if the encoding + is in little endian format. These following two lines potentially reads 4 bytes backwards: + + int tag = pack(jpeg, offset, 4, false); + count = pack(jpeg, offset - 2, 2, littleEndian); + + To be safe, we will always advance to some index-4, so we'll need 4 more for the +8 + look ahead, which makes it a +12 look ahead total. Use 16 just in case my analysis is off. + + This means we only need to allocate a single 16 byte buffer. + + Note: If you do not pass in byteSize parameter, a single large allocation will occur. + For a 1MB image, I see one 30KB allocation. This is due to the line containing: + + has(jpeg, byteSize, offset + length - 1) + + where length is a variable int (around 30KB above) read from the EXIF headers. + + This is still much better than allocating a 1MB byte[] which we were doing before. + */ + + final int lookAhead = 16; + final int readBackwards = 4; + final InputStreamBuffer jpeg = new InputStreamBuffer(inputStream, lookAhead, false); + + int offset = 0; + int length = 0; + + if (has(jpeg, byteSize, 1)) { + // JPEG image files begin with FF D8. Only JPEG images have EXIF data. + final boolean possibleJpegFormat = jpeg.get(0) == (byte) 0xFF + && jpeg.get(1) == (byte) 0xD8; + if (!possibleJpegFormat) { + return 0; + } + } + + // ISO/IEC 10918-1:1993(E) + while (has(jpeg, byteSize, offset + 3) && (jpeg.get(offset++) & 0xFF) == 0xFF) { + final int marker = jpeg.get(offset) & 0xFF; + + // Check if the marker is a padding. + if (marker == 0xFF) { + continue; + } + offset++; + + // Check if the marker is SOI or TEM. + if (marker == 0xD8 || marker == 0x01) { + continue; + } + // Check if the marker is EOI or SOS. + if (marker == 0xD9 || marker == 0xDA) { + // Loop ends. + jpeg.advanceTo(offset - readBackwards); + break; + } + + // Get the length and check if it is reasonable. + length = pack(jpeg, offset, 2, false); + if (length < 2 || !has(jpeg, byteSize, offset + length - 1)) { + Log.e(TAG, "Invalid length"); + return 0; + } + + // Break if the marker is EXIF in APP1. + if (marker == 0xE1 && length >= 8 && + pack(jpeg, offset + 2, 4, false) == 0x45786966 && + pack(jpeg, offset + 6, 2, false) == 0) { + offset += 8; + length -= 8; + // Loop ends. + jpeg.advanceTo(offset - readBackwards); + break; + } + + // Skip other markers. + offset += length; + length = 0; + + // Loop ends. + jpeg.advanceTo(offset - readBackwards); + } + + // JEITA CP-3451 Exif Version 2.2 + if (length > 8) { + // Identify the byte order. + int tag = pack(jpeg, offset, 4, false); + if (tag != 0x49492A00 && tag != 0x4D4D002A) { + Log.e(TAG, "Invalid byte order"); + return 0; + } + final boolean littleEndian = (tag == 0x49492A00); + + // Get the offset and check if it is reasonable. + int count = pack(jpeg, offset + 4, 4, littleEndian) + 2; + if (count < 10 || count > length) { + Log.e(TAG, "Invalid offset"); + return 0; + } + offset += count; + length -= count; + + // Offset has changed significantly. + jpeg.advanceTo(offset - readBackwards); + + // Get the count and go through all the elements. + count = pack(jpeg, offset - 2, 2, littleEndian); + + while (count-- > 0 && length >= 12) { + // Get the tag and check if it is orientation. + tag = pack(jpeg, offset, 2, littleEndian); + if (tag == 0x0112) { + // We do not really care about type and count, do we? + final int orientation = pack(jpeg, offset + 8, 2, littleEndian); + switch (orientation) { + case 1: + return 0; + case 3: + return 180; + case 6: + return 90; + case 8: + return 270; + } + Log.i(TAG, "Unsupported orientation"); + return 0; + } + offset += 12; + length -= 12; + + // Loop ends. + jpeg.advanceTo(offset - readBackwards); + } + } + + return 0; + } + + private static int pack(final InputStreamBuffer bytes, int offset, int length, + final boolean littleEndian) { + int step = 1; + if (littleEndian) { + offset += length - 1; + step = -1; + } + + int value = 0; + while (length-- > 0) { + value = (value << 8) | (bytes.get(offset) & 0xFF); + offset += step; + } + return value; + } + + private static boolean has(final InputStreamBuffer jpeg, final long byteSize, final int index) { + if (byteSize >= 0) { + return index < byteSize; + } else { + // For large values of index, this will cause the internal buffer to resize. + return jpeg.has(index); + } + } + + @Deprecated + public static int getOrientation(final byte[] jpeg) { + return getOrientation(new ByteArrayInputStream(jpeg), jpeg.length); + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/util/ImageUtils.java b/modules/photoviewer/src/com/android/ex/photo/util/ImageUtils.java new file mode 100644 index 0000000..53840e3 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/util/ImageUtils.java @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo.util; + +import android.content.ContentResolver; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Build; +import android.util.Base64; +import android.util.Log; + +import com.android.ex.photo.PhotoViewController; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; + +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.regex.Pattern; + + +/** + * Image utilities + */ +public class ImageUtils { + // Logging + private static final String TAG = "ImageUtils"; + + /** Minimum class memory class to use full-res photos */ + private final static long MIN_NORMAL_CLASS = 32; + /** Minimum class memory class to use small photos */ + private final static long MIN_SMALL_CLASS = 24; + + private static final String BASE64_URI_PREFIX = "base64,"; + private static final Pattern BASE64_IMAGE_URI_PATTERN = Pattern.compile("^(?:.*;)?base64,.*"); + + public static enum ImageSize { + EXTRA_SMALL, + SMALL, + NORMAL, + } + + public static final ImageSize sUseImageSize; + static { + // On HC and beyond, assume devices are more capable + if (Build.VERSION.SDK_INT >= 11) { + sUseImageSize = ImageSize.NORMAL; + } else { + if (PhotoViewController.sMemoryClass >= MIN_NORMAL_CLASS) { + // We have plenty of memory; use full sized photos + sUseImageSize = ImageSize.NORMAL; + } else if (PhotoViewController.sMemoryClass >= MIN_SMALL_CLASS) { + // We have slight less memory; use smaller sized photos + sUseImageSize = ImageSize.SMALL; + } else { + // We have little memory; use very small sized photos + sUseImageSize = ImageSize.EXTRA_SMALL; + } + } + } + + /** + * @return true if the MimeType type is image + */ + public static boolean isImageMimeType(String mimeType) { + return mimeType != null && mimeType.startsWith("image/"); + } + + /** + * Create a bitmap from a local URI + * + * @param resolver The ContentResolver + * @param uri The local URI + * @param maxSize The maximum size (either width or height) + * @return The new bitmap or null + */ + public static BitmapResult createLocalBitmap(final ContentResolver resolver, final Uri uri, + final int maxSize) { + final BitmapResult result = new BitmapResult(); + final InputStreamFactory factory = createInputStreamFactory(resolver, uri); + try { + final Point bounds = getImageBounds(factory); + if (bounds == null) { + result.status = BitmapResult.STATUS_EXCEPTION; + return result; + } + + final BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inSampleSize = Math.max(bounds.x / maxSize, bounds.y / maxSize); + result.bitmap = decodeStream(factory, null, opts); + result.status = BitmapResult.STATUS_SUCCESS; + return result; + + } catch (FileNotFoundException exception) { + // Do nothing - the photo will appear to be missing + } catch (IOException exception) { + result.status = BitmapResult.STATUS_EXCEPTION; + } catch (IllegalArgumentException exception) { + // Do nothing - the photo will appear to be missing + } catch (SecurityException exception) { + result.status = BitmapResult.STATUS_EXCEPTION; + } + return result; + } + + /** + * Wrapper around {@link BitmapFactory#decodeStream(InputStream, Rect, + * BitmapFactory.Options)} that returns {@code null} on {@link + * OutOfMemoryError}. + * + * @param factory Used to create input streams that holds the raw data to be decoded into a + * bitmap. + * @param outPadding If not null, return the padding rect for the bitmap if + * it exists, otherwise set padding to [-1,-1,-1,-1]. If + * no bitmap is returned (null) then padding is + * unchanged. + * @param opts null-ok; Options that control downsampling and whether the + * image should be completely decoded, or just is size returned. + * @return The decoded bitmap, or null if the image data could not be + * decoded, or, if opts is non-null, if opts requested only the + * size be returned (in opts.outWidth and opts.outHeight) + */ + public static Bitmap decodeStream(final InputStreamFactory factory, final Rect outPadding, + final BitmapFactory.Options opts) throws FileNotFoundException { + InputStream is = null; + try { + // Determine the orientation for this image + is = factory.createInputStream(); + final int orientation = Exif.getOrientation(is, -1); + if (is != null) { + is.close(); + } + + // Decode the bitmap + is = factory.createInputStream(); + final Bitmap originalBitmap = BitmapFactory.decodeStream(is, outPadding, opts); + + if (is != null && originalBitmap == null && !opts.inJustDecodeBounds) { + Log.w(TAG, "ImageUtils#decodeStream(InputStream, Rect, Options): " + + "Image bytes cannot be decoded into a Bitmap"); + throw new UnsupportedOperationException( + "Image bytes cannot be decoded into a Bitmap."); + } + + // Rotate the Bitmap based on the orientation + if (originalBitmap != null && orientation != 0) { + final Matrix matrix = new Matrix(); + matrix.postRotate(orientation); + return Bitmap.createBitmap(originalBitmap, 0, 0, originalBitmap.getWidth(), + originalBitmap.getHeight(), matrix, true); + } + return originalBitmap; + } catch (OutOfMemoryError oome) { + Log.e(TAG, "ImageUtils#decodeStream(InputStream, Rect, Options) threw an OOME", oome); + return null; + } catch (IOException ioe) { + Log.e(TAG, "ImageUtils#decodeStream(InputStream, Rect, Options) threw an IOE", ioe); + return null; + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + // Do nothing + } + } + } + } + + /** + * Gets the image bounds + * + * @param factory Used to create the InputStream. + * + * @return The image bounds + */ + private static Point getImageBounds(final InputStreamFactory factory) + throws IOException { + final BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inJustDecodeBounds = true; + decodeStream(factory, null, opts); + + return new Point(opts.outWidth, opts.outHeight); + } + + private static InputStreamFactory createInputStreamFactory(final ContentResolver resolver, + final Uri uri) { + final String scheme = uri.getScheme(); + if ("data".equals(scheme)) { + return new DataInputStreamFactory(resolver, uri); + } + return new BaseInputStreamFactory(resolver, uri); + } + + /** + * Utility class for when an InputStream needs to be read multiple times. For example, one pass + * may load EXIF orientation, and the second pass may do the actual Bitmap decode. + */ + public interface InputStreamFactory { + + /** + * Create a new InputStream. The caller of this method must be able to read the input + * stream starting from the beginning. + * @return + */ + InputStream createInputStream() throws FileNotFoundException; + } + + private static class BaseInputStreamFactory implements InputStreamFactory { + protected final ContentResolver mResolver; + protected final Uri mUri; + + public BaseInputStreamFactory(final ContentResolver resolver, final Uri uri) { + mResolver = resolver; + mUri = uri; + } + + @Override + public InputStream createInputStream() throws FileNotFoundException { + return mResolver.openInputStream(mUri); + } + } + + private static class DataInputStreamFactory extends BaseInputStreamFactory { + private byte[] mData; + + public DataInputStreamFactory(final ContentResolver resolver, final Uri uri) { + super(resolver, uri); + } + + @Override + public InputStream createInputStream() throws FileNotFoundException { + if (mData == null) { + mData = parseDataUri(mUri); + if (mData == null) { + return super.createInputStream(); + } + } + return new ByteArrayInputStream(mData); + } + + private byte[] parseDataUri(final Uri uri) { + final String ssp = uri.getSchemeSpecificPart(); + try { + if (ssp.startsWith(BASE64_URI_PREFIX)) { + final String base64 = ssp.substring(BASE64_URI_PREFIX.length()); + return Base64.decode(base64, Base64.URL_SAFE); + } else if (BASE64_IMAGE_URI_PATTERN.matcher(ssp).matches()){ + final String base64 = ssp.substring( + ssp.indexOf(BASE64_URI_PREFIX) + BASE64_URI_PREFIX.length()); + return Base64.decode(base64, Base64.DEFAULT); + } else { + return null; + } + } catch (IllegalArgumentException ex) { + Log.e(TAG, "Mailformed data URI: " + ex); + return null; + } + } + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/util/InputStreamBuffer.java b/modules/photoviewer/src/com/android/ex/photo/util/InputStreamBuffer.java new file mode 100644 index 0000000..6347b97 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/util/InputStreamBuffer.java @@ -0,0 +1,376 @@ +/* + * 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.ex.photo.util; + +import android.util.Log; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; + +/** + * Wrapper for {@link InputStream} that allows you to read bytes from it like a byte[]. An + * internal buffer is kept as small as possible to avoid large unnecessary allocations. + * + *

+ * Care must be taken so that the internal buffer is kept small. The best practice is to + * precalculate the maximum buffer size that you will need. For example, + * say you have a loop that reads bytes from index 0 to 10, + * skips to index N, reads from index N to N+10, etc. Then + * you know that the internal buffer can have a maximum size of 10, + * and you should set the bufferSize parameter to 10 in the constructor. + * + *

+ * Use {@link #advanceTo(int)} to declare that you will not need to access lesser indexes. This + * helps to keep the internal buffer small. In the above example, after reading bytes from index + * 0 to 10, you should call advanceTo(N) so that internal + * buffer becomes filled with bytes from index N to N+10. + * + *

+ * If you know that you are reading bytes from a strictly increasing or equal + * index, then you should set the autoAdvance parameter to true in the + * constructor. For complicated access patterns, or when you prefer to control the internal + * buffer yourself, set autoAdvance to false. When + * autoAdvance is enabled, every time an index is beyond the buffer length, + * the buffer will be shifted forward such that the index requested becomes the first element in + * the buffer. + * + *

+ * All public methods with parameter index are absolute indexed. The index is from + * the beginning of the wrapped input stream. + */ +public class InputStreamBuffer { + + private static final boolean DEBUG = false; + private static final int DEBUG_MAX_BUFFER_SIZE = 80; + private static final String TAG = "InputStreamBuffer"; + + private InputStream mInputStream; + private byte[] mBuffer; + private boolean mAutoAdvance; + /** Byte count the buffer is offset by. */ + private int mOffset = 0; + /** Number of bytes filled in the buffer. */ + private int mFilled = 0; + + /** + * Construct a new wrapper for an InputStream. + * + *

+ * If autoAdvance is true, behavior is undefined if you call {@link #get(int)} + * or {@link #has(int)} with an index N, then some arbitrary time later call {@link #get(int)} + * or {@link #has(int)} with an index M < N. The wrapper may return the right value, + * if the buffer happens to still contain index M, but more likely it will throw an + * {@link IllegalStateException}. + * + *

+ * If autoAdvance is false, you must be diligent and call {@link #advanceTo(int)} + * at the appropriate times to ensure that the internal buffer is not unnecessarily resized + * and reallocated. + * + * @param inputStream The input stream to wrap. The input stream will not be closed by the + * wrapper. + * @param bufferSize The initial size for the internal buffer. The buffer size should be + * carefully chosen to avoid resizing and reallocating the internal buffer. + * The internal buffer size used will be the least power of two greater + * than this parameter. + * @param autoAdvance Determines the behavior when you need to read an index that is beyond + * the internal buffer size. If true, the internal buffer will shift so + * that the requested index becomes the first element. If false, + * the internal buffer size will grow to the smallest power of 2 which is + * greater than the requested index. + */ + public InputStreamBuffer(final InputStream inputStream, int bufferSize, + final boolean autoAdvance) { + mInputStream = inputStream; + if (bufferSize <= 0) { + throw new IllegalArgumentException( + String.format("Buffer size %d must be positive.", bufferSize)); + } + bufferSize = leastPowerOf2(bufferSize); + mBuffer = new byte[bufferSize]; + mAutoAdvance = autoAdvance; + } + + /** + * Attempt to get byte at the requested index from the wrapped input stream. If the internal + * buffer contains the requested index, return immediately. If the index is less than the + * head of the buffer, or the index is greater or equal to the size of the wrapped input stream, + * a runtime exception is thrown. + * + *

+ * If the index is not in the internal buffer, but it can be requested from the input stream, + * {@link #fill(int)} will be called first, and the byte at the index returned. + * + *

+ * You should always call {@link #has(int)} with the same index, unless you are sure that no + * exceptions will be thrown as described above. + * + *

+ * Consider calling {@link #advanceTo(int)} if you know that you will never request a lesser + * index in the future. + * @param index The requested index. + * @return The byte at that index. + */ + public byte get(final int index) throws IllegalStateException, IndexOutOfBoundsException { + Trace.beginSection("get"); + if (has(index)) { + final int i = index - mOffset; + Trace.endSection(); + return mBuffer[i]; + } else { + Trace.endSection(); + throw new IndexOutOfBoundsException( + String.format("Index %d beyond length.", index)); + } + } + + /** + * Attempt to return whether the requested index is within the size of the wrapped input + * stream. One side effect is {@link #fill(int)} will be called. + * + *

+ * If this method returns true, it is guaranteed that {@link #get(int)} with the same index + * will not fail. That means that if the requested index is within the size of the wrapped + * input stream, but the index is less than the head of the internal buffer, + * a runtime exception is thrown. + * + *

+ * See {@link #get(int)} for caveats. A lot of the same warnings about exceptions and + * advanceTo() apply. + * @param index The requested index. + * @return True if requested index is within the size of the wrapped input stream. False if + * the index is beyond the size. + */ + public boolean has(final int index) throws IllegalStateException, IndexOutOfBoundsException { + Trace.beginSection("has"); + if (index < mOffset) { + Trace.endSection(); + throw new IllegalStateException( + String.format("Index %d is before buffer %d", index, mOffset)); + } + + final int i = index - mOffset; + + // Requested index not in internal buffer. + if (i >= mFilled || i >= mBuffer.length) { + Trace.endSection(); + return fill(index); + } + + Trace.endSection(); + return true; + } + + /** + * Attempts to advance the head of the buffer to the requested index. If the index is less + * than the head of the buffer, the internal state will not be changed. + * + *

+ * Advancing does not fill the internal buffer. The next {@link #get(int)} or + * {@link #has(int)} call will fill the buffer. + */ + public void advanceTo(final int index) throws IllegalStateException, IndexOutOfBoundsException { + Trace.beginSection("advance to"); + final int i = index - mOffset; + if (i <= 0) { + // noop + Trace.endSection(); + return; + } else if (i < mFilled) { + // Shift elements starting at i to position 0. + shiftToBeginning(i); + mOffset = index; + mFilled = mFilled - i; + } else if (mInputStream != null) { + // Burn some bytes from the input stream to match the new index. + int burn = i - mFilled; + boolean empty = false; + int fails = 0; + try { + while (burn > 0) { + final long burned = mInputStream.skip(burn); + if (burned <= 0) { + fails++; + } else { + burn -= burned; + } + + if (fails >= 5) { + empty = true; + break; + } + } + } catch (IOException ignored) { + empty = true; + } + + if (empty) { + //Mark input stream as consumed. + mInputStream = null; + } + + mOffset = index - burn; + mFilled = 0; + } else { + // Advancing beyond the input stream. + mOffset = index; + mFilled = 0; + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, String.format("advanceTo %d buffer: %s", i, this)); + } + Trace.endSection(); + } + + /** + * Attempt to fill the internal buffer fully. The buffer will be modified such that the + * requested index will always be in the buffer. If the index is less + * than the head of the buffer, a runtime exception is thrown. + * + *

+ * If the requested index is already in bounds of the buffer, then the buffer will just be + * filled. + * + *

+ * Otherwise, if autoAdvance was set to true in the constructor, + * {@link #advanceTo(int)} will be called with the requested index, + * and then the buffer filled. If autoAdvance was set to false, + * we allocate a single larger buffer of a least multiple-of-two size that can contain the + * requested index. The elements in the old buffer are copied over to the head of the new + * buffer. Then the entire buffer is filled. + * @param index The requested index. + * @return True if the byte at the requested index has been filled. False if the wrapped + * input stream ends before we reach the index. + */ + private boolean fill(final int index) { + Trace.beginSection("fill"); + if (index < mOffset) { + Trace.endSection(); + throw new IllegalStateException( + String.format("Index %d is before buffer %d", index, mOffset)); + } + + int i = index - mOffset; + // Can't fill buffer anymore if input stream is consumed. + if (mInputStream == null) { + Trace.endSection(); + return false; + } + + // Increase buffer size if necessary. + int length = i + 1; + if (length > mBuffer.length) { + if (mAutoAdvance) { + advanceTo(index); + i = index - mOffset; + } else { + length = leastPowerOf2(length); + Log.w(TAG, String.format( + "Increasing buffer length from %d to %d. Bad buffer size chosen, " + + "or advanceTo() not called.", + mBuffer.length, length)); + mBuffer = Arrays.copyOf(mBuffer, length); + } + } + + // Read from input stream to fill buffer. + int read = -1; + try { + read = mInputStream.read(mBuffer, mFilled, mBuffer.length - mFilled); + } catch (IOException ignored) { + } + + if (read != -1) { + mFilled = mFilled + read; + } else { + // Mark input stream as consumed. + mInputStream = null; + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, String.format("fill %d buffer: %s", i, this)); + } + + Trace.endSection(); + return i < mFilled; + } + + /** + * Modify the internal buffer so that all the bytes are shifted towards the head by + * i. In other words, the byte at index i will now be at index + * 0. Bytes from a lesser index are tossed. + * @param i How much to shift left. + */ + private void shiftToBeginning(final int i) { + if (i >= mBuffer.length) { + throw new IndexOutOfBoundsException( + String.format("Index %d out of bounds. Length %d", i, mBuffer.length)); + } + for (int j = 0; j + i < mFilled; j++) { + mBuffer[j] = mBuffer[j + i]; + } + } + + @Override + public String toString() { + if (DEBUG) { + return toDebugString(); + } + return String.format("+%d+%d [%d]", mOffset, mBuffer.length, mFilled); + } + + public String toDebugString() { + Trace.beginSection("to debug string"); + final StringBuilder sb = new StringBuilder(); + sb.append("+").append(mOffset); + sb.append("+").append(mBuffer.length); + sb.append(" ["); + for (int i = 0; i < mBuffer.length && i < DEBUG_MAX_BUFFER_SIZE; i++) { + if (i > 0) { + sb.append(","); + } + if (i < mFilled) { + sb.append(String.format("%02X", mBuffer[i])); + } else { + sb.append("__"); + } + } + if (mInputStream != null) { + sb.append("..."); + } + sb.append("]"); + + Trace.endSection(); + return sb.toString(); + } + + /** + * Calculate the least power of two greater than or equal to the input. + */ + private static int leastPowerOf2(int n) { + n--; + n |= n >> 1; + n |= n >> 2; + n |= n >> 4; + n |= n >> 8; + n |= n >> 16; + n++; + return n; + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/util/Trace.java b/modules/photoviewer/src/com/android/ex/photo/util/Trace.java new file mode 100644 index 0000000..9645ed4 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/util/Trace.java @@ -0,0 +1,51 @@ +/* + * 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.ex.photo.util; + +import android.os.Build; + +/** + * Stand-in for {@link android.os.Trace}. + */ +public abstract class Trace { + + /** + * Begins systrace tracing for a given tag. No-op on unsupported platform versions. + * + * @param tag systrace tag to use + * + * @see android.os.Trace#beginSection(String) + */ + public static void beginSection(String tag) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + android.os.Trace.beginSection(tag); + } + } + + /** + * Ends systrace tracing for the most recently begun section. No-op on unsupported platform + * versions. + * + * @see android.os.Trace#endSection() + */ + public static void endSection() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + android.os.Trace.endSection(); + } + } + +} diff --git a/modules/photoviewer/src/com/android/ex/photo/util/Util.java b/modules/photoviewer/src/com/android/ex/photo/util/Util.java new file mode 100644 index 0000000..d731307 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/util/Util.java @@ -0,0 +1,68 @@ +package com.android.ex.photo.util; + +import android.content.Context; +import android.os.Build; +import androidx.core.view.accessibility.AccessibilityEventCompat; +import androidx.core.view.accessibility.AccessibilityRecordCompat; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; + +public class Util { + public static boolean isTouchExplorationEnabled(AccessibilityManager accessibilityManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + return accessibilityManager.isTouchExplorationEnabled(); + } else { + return false; + } + } + + /** + * Make an announcement which is related to some sort of a context change. Also see + * {@link android.view.View#announceForAccessibility} + * @param view The view that triggered the announcement + * @param accessibilityManager AccessibilityManager instance. If it is null, the method can + * obtain an instance itself. + * @param text The announcement text + */ + public static void announceForAccessibility( + final View view, AccessibilityManager accessibilityManager, + final CharSequence text) { + // Jelly Bean added support for speaking text verbatim + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + view.announceForAccessibility(text); + return; + } + + final Context context = view.getContext().getApplicationContext(); + if (accessibilityManager == null) { + accessibilityManager = (AccessibilityManager) context.getSystemService( + Context.ACCESSIBILITY_SERVICE); + } + + if (!accessibilityManager.isEnabled()) { + return; + } + + final int eventType = AccessibilityEvent.TYPE_VIEW_FOCUSED; + + // Construct an accessibility event with the minimum recommended + // attributes. An event without a class name or package may be dropped. + final AccessibilityEvent event = AccessibilityEvent.obtain(eventType); + event.getText().add(text); + event.setEnabled(view.isEnabled()); + event.setClassName(view.getClass().getName()); + event.setPackageName(context.getPackageName()); + + // JellyBean MR1 requires a source view to set the window ID. + final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event); + record.setSource(view); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + view.getParent().requestSendAccessibilityEvent(view, event); + } else { + // Sends the event directly through the accessibility manager. + accessibilityManager.sendAccessibilityEvent(event); + } + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/views/PhotoView.java b/modules/photoviewer/src/com/android/ex/photo/views/PhotoView.java new file mode 100644 index 0000000..e64f0f0 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/views/PhotoView.java @@ -0,0 +1,1511 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo.views; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import androidx.core.view.GestureDetectorCompat; +import androidx.core.view.ScaleGestureDetectorCompat; +import android.util.AttributeSet; +import android.view.GestureDetector.OnDoubleTapListener; +import android.view.GestureDetector.OnGestureListener; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; +import android.view.ViewConfiguration; + +import com.android.ex.photo.R; +import com.android.ex.photo.fragments.PhotoViewFragment.HorizontallyScrollable; + +/** + * Layout for the photo list view header. + */ +public class PhotoView extends View implements OnGestureListener, + OnDoubleTapListener, ScaleGestureDetector.OnScaleGestureListener, + HorizontallyScrollable { + + public static final int TRANSLATE_NONE = 0; + public static final int TRANSLATE_X_ONLY = 1; + public static final int TRANSLATE_Y_ONLY = 2; + public static final int TRANSLATE_BOTH = 3; + + /** Zoom animation duration; in milliseconds */ + private final static long ZOOM_ANIMATION_DURATION = 200L; + /** Amount of time to wait after over-zooming before the zoom out animation; in milliseconds */ + private static final long ZOOM_CORRECTION_DELAY = 600L; + /** Rotate animation duration; in milliseconds */ + private final static long ROTATE_ANIMATION_DURATION = 500L; + /** Snap animation duration; in milliseconds */ + private static final long SNAP_DURATION = 100L; + /** Amount of time to wait before starting snap animation; in milliseconds */ + private static final long SNAP_DELAY = 250L; + /** By how much to scale the image when double click occurs */ + private final static float DOUBLE_TAP_SCALE_FACTOR = 2.0f; + /** Amount which can be zoomed in past the maximum scale, and then scaled back */ + private final static float SCALE_OVERZOOM_FACTOR = 1.5f; + /** Amount of translation needed before starting a snap animation */ + private final static float SNAP_THRESHOLD = 20.0f; + /** The width & height of the bitmap returned by {@link #getCroppedPhoto()} */ + private final static float CROPPED_SIZE = 256.0f; + + /** + * Touch slop used to determine if this double tap is valid for starting a scale or should be + * ignored. + */ + private static int sTouchSlopSquare; + + /** If {@code true}, the static values have been initialized */ + private static boolean sInitialized; + + // Various dimensions + /** Width & height of the crop region */ + private static int sCropSize; + + // Bitmaps + /** Video icon */ + private static Bitmap sVideoImage; + /** Video icon */ + private static Bitmap sVideoNotReadyImage; + + // Paints + /** Paint to partially dim the photo during crop */ + private static Paint sCropDimPaint; + /** Paint to highlight the cropped portion of the photo */ + private static Paint sCropPaint; + + /** The photo to display */ + private Drawable mDrawable; + /** The matrix used for drawing; this may be {@code null} */ + private Matrix mDrawMatrix; + /** A matrix to apply the scaling of the photo */ + private Matrix mMatrix = new Matrix(); + /** The original matrix for this image; used to reset any transformations applied by the user */ + private Matrix mOriginalMatrix = new Matrix(); + + /** The fixed height of this view. If {@code -1}, calculate the height */ + private int mFixedHeight = -1; + /** When {@code true}, the view has been laid out */ + private boolean mHaveLayout; + /** Whether or not the photo is full-screen */ + private boolean mFullScreen; + /** Whether or not this is a still image of a video */ + private byte[] mVideoBlob; + /** Whether or not this is a still image of a video */ + private boolean mVideoReady; + + /** Whether or not crop is allowed */ + private boolean mAllowCrop; + /** The crop region */ + private Rect mCropRect = new Rect(); + /** Actual crop size; may differ from {@link #sCropSize} if the screen is smaller */ + private int mCropSize; + /** The maximum amount of scaling to apply to images */ + private float mMaxInitialScaleFactor; + + /** Gesture detector */ + private GestureDetectorCompat mGestureDetector; + /** Gesture detector that detects pinch gestures */ + private ScaleGestureDetector mScaleGetureDetector; + /** An external click listener */ + private OnClickListener mExternalClickListener; + /** When {@code true}, allows gestures to scale / pan the image */ + private boolean mTransformsEnabled; + + // To support zooming + /** When {@code true}, a double tap scales the image by {@link #DOUBLE_TAP_SCALE_FACTOR} */ + private boolean mDoubleTapToZoomEnabled = true; + /** When {@code true}, prevents scale end gesture from falsely triggering a double click. */ + private boolean mDoubleTapDebounce; + /** When {@code false}, event is a scale gesture. Otherwise, event is a double touch. */ + private boolean mIsDoubleTouch; + /** Runnable that scales the image */ + private ScaleRunnable mScaleRunnable; + /** Minimum scale the image can have. */ + private float mMinScale; + /** Maximum scale to limit scaling to, 0 means no limit. */ + private float mMaxScale; + + // To support translation [i.e. panning] + /** Runnable that can move the image */ + private TranslateRunnable mTranslateRunnable; + private SnapRunnable mSnapRunnable; + + // To support rotation + /** The rotate runnable used to animate rotations of the image */ + private RotateRunnable mRotateRunnable; + /** The current rotation amount, in degrees */ + private float mRotation; + + // Convenience fields + // These are declared here not because they are important properties of the view. Rather, we + // declare them here to avoid object allocation during critical graphics operations; such as + // layout or drawing. + /** Source (i.e. the photo size) bounds */ + private RectF mTempSrc = new RectF(); + /** Destination (i.e. the display) bounds. The image is scaled to this size. */ + private RectF mTempDst = new RectF(); + /** Rectangle to handle translations */ + private RectF mTranslateRect = new RectF(); + /** Array to store a copy of the matrix values */ + private float[] mValues = new float[9]; + + /** + * Track whether a double tap event occurred. + */ + private boolean mDoubleTapOccurred; + + /** + * X and Y coordinates for the current down event. Since mDoubleTapOccurred only contains the + * information that there was a double tap event, use these to get the secondary tap + * information to determine if a user has moved beyond touch slop. + */ + private float mDownFocusX; + private float mDownFocusY; + + /** + * Whether the QuickSale gesture is enabled. + */ + private boolean mQuickScaleEnabled; + + public PhotoView(Context context) { + super(context); + initialize(); + } + + public PhotoView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(); + } + + public PhotoView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initialize(); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (mScaleGetureDetector == null || mGestureDetector == null) { + // We're being destroyed; ignore any touch events + return true; + } + + mScaleGetureDetector.onTouchEvent(event); + mGestureDetector.onTouchEvent(event); + final int action = event.getAction(); + + switch (action) { + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (!mTranslateRunnable.mRunning) { + snap(); + } + break; + } + + return true; + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + mDoubleTapOccurred = true; + if (!mQuickScaleEnabled) { + return scale(e); + } + return false; + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + final int action = e.getAction(); + boolean handled = false; + + switch (action) { + case MotionEvent.ACTION_DOWN: + if (mQuickScaleEnabled) { + mDownFocusX = e.getX(); + mDownFocusY = e.getY(); + } + break; + case MotionEvent.ACTION_UP: + if (mQuickScaleEnabled) { + handled = scale(e); + } + break; + case MotionEvent.ACTION_MOVE: + if (mQuickScaleEnabled && mDoubleTapOccurred) { + final int deltaX = (int) (e.getX() - mDownFocusX); + final int deltaY = (int) (e.getY() - mDownFocusY); + int distance = (deltaX * deltaX) + (deltaY * deltaY); + if (distance > sTouchSlopSquare) { + mDoubleTapOccurred = false; + } + } + break; + + } + return handled; + } + + private boolean scale(MotionEvent e) { + boolean handled = false; + if (mDoubleTapToZoomEnabled && mTransformsEnabled && mDoubleTapOccurred) { + if (!mDoubleTapDebounce) { + float currentScale = getScale(); + float targetScale; + float centerX, centerY; + + // Zoom out if not default scale, otherwise zoom in + if (currentScale > mMinScale) { + targetScale = mMinScale; + float relativeScale = targetScale / currentScale; + // Find the apparent origin for scaling that equals this scale and translate + centerX = (getWidth() / 2 - relativeScale * mTranslateRect.centerX()) / + (1 - relativeScale); + centerY = (getHeight() / 2 - relativeScale * mTranslateRect.centerY()) / + (1 - relativeScale); + } else { + targetScale = currentScale * DOUBLE_TAP_SCALE_FACTOR; + // Ensure the target scale is within our bounds + targetScale = Math.max(mMinScale, targetScale); + targetScale = Math.min(mMaxScale, targetScale); + float relativeScale = targetScale / currentScale; + float widthBuffer = (getWidth() - mTranslateRect.width()) / relativeScale; + float heightBuffer = (getHeight() - mTranslateRect.height()) / relativeScale; + // Clamp the center if it would result in uneven borders + if (mTranslateRect.width() <= widthBuffer * 2) { + centerX = mTranslateRect.centerX(); + } else { + centerX = Math.min(Math.max(mTranslateRect.left + widthBuffer, + e.getX()), mTranslateRect.right - widthBuffer); + } + if (mTranslateRect.height() <= heightBuffer * 2) { + centerY = mTranslateRect.centerY(); + } else { + centerY = Math.min(Math.max(mTranslateRect.top + heightBuffer, + e.getY()), mTranslateRect.bottom - heightBuffer); + } + } + + mScaleRunnable.start(currentScale, targetScale, centerX, centerY); + handled = true; + } + mDoubleTapDebounce = false; + } + mDoubleTapOccurred = false; + return handled; + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + if (mExternalClickListener != null && !mIsDoubleTouch) { + mExternalClickListener.onClick(this); + } + mIsDoubleTouch = false; + return true; + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + return false; + } + + @Override + public void onLongPress(MotionEvent e) { + } + + @Override + public void onShowPress(MotionEvent e) { + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + if (mTransformsEnabled && !mScaleRunnable.mRunning) { + translate(-distanceX, -distanceY); + } + return true; + } + + @Override + public boolean onDown(MotionEvent e) { + if (mTransformsEnabled) { + mTranslateRunnable.stop(); + mSnapRunnable.stop(); + } + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (mTransformsEnabled && !mScaleRunnable.mRunning) { + mTranslateRunnable.start(velocityX, velocityY); + } + return true; + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + if (mTransformsEnabled && !mScaleRunnable.mRunning) { + mIsDoubleTouch = false; + float currentScale = getScale(); + float newScale = currentScale * detector.getScaleFactor(); + scale(newScale, detector.getFocusX(), detector.getFocusY()); + } + return true; + } + + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + if (mTransformsEnabled && !mScaleRunnable.mRunning) { + mScaleRunnable.stop(); + mIsDoubleTouch = true; + } + return true; + } + + @Override + public void onScaleEnd(ScaleGestureDetector detector) { + if (mTransformsEnabled && mIsDoubleTouch) { + mDoubleTapDebounce = true; + resetTransformations(); + } + } + + @Override + public void setOnClickListener(OnClickListener listener) { + mExternalClickListener = listener; + } + + @Override + public boolean interceptMoveLeft(float origX, float origY) { + if (!mTransformsEnabled) { + // Allow intercept if we're not in transform mode + return false; + } else if (mTranslateRunnable.mRunning) { + // Don't allow touch intercept until we've stopped flinging + return true; + } else { + mMatrix.getValues(mValues); + mTranslateRect.set(mTempSrc); + mMatrix.mapRect(mTranslateRect); + + final float viewWidth = getWidth(); + final float transX = mValues[Matrix.MTRANS_X]; + final float drawWidth = mTranslateRect.right - mTranslateRect.left; + + if (!mTransformsEnabled || drawWidth <= viewWidth) { + // Allow intercept if not in transform mode or the image is smaller than the view + return false; + } else if (transX == 0) { + // We're at the left-side of the image; allow intercepting movements to the right + return false; + } else if (viewWidth >= drawWidth + transX) { + // We're at the right-side of the image; allow intercepting movements to the left + return true; + } else { + // We're in the middle of the image; don't allow touch intercept + return true; + } + } + } + + @Override + public boolean interceptMoveRight(float origX, float origY) { + if (!mTransformsEnabled) { + // Allow intercept if we're not in transform mode + return false; + } else if (mTranslateRunnable.mRunning) { + // Don't allow touch intercept until we've stopped flinging + return true; + } else { + mMatrix.getValues(mValues); + mTranslateRect.set(mTempSrc); + mMatrix.mapRect(mTranslateRect); + + final float viewWidth = getWidth(); + final float transX = mValues[Matrix.MTRANS_X]; + final float drawWidth = mTranslateRect.right - mTranslateRect.left; + + if (!mTransformsEnabled || drawWidth <= viewWidth) { + // Allow intercept if not in transform mode or the image is smaller than the view + return false; + } else if (transX == 0) { + // We're at the left-side of the image; allow intercepting movements to the right + return true; + } else if (viewWidth >= drawWidth + transX) { + // We're at the right-side of the image; allow intercepting movements to the left + return false; + } else { + // We're in the middle of the image; don't allow touch intercept + return true; + } + } + } + + /** + * Free all resources held by this view. + * The view is on its way to be collected and will not be reused. + */ + public void clear() { + mGestureDetector = null; + mScaleGetureDetector = null; + mDrawable = null; + mScaleRunnable.stop(); + mScaleRunnable = null; + mTranslateRunnable.stop(); + mTranslateRunnable = null; + mSnapRunnable.stop(); + mSnapRunnable = null; + mRotateRunnable.stop(); + mRotateRunnable = null; + setOnClickListener(null); + mExternalClickListener = null; + mDoubleTapOccurred = false; + } + + public void bindDrawable(Drawable drawable) { + boolean changed = false; + if (drawable != null && drawable != mDrawable) { + // Clear previous state. + if (mDrawable != null) { + mDrawable.setCallback(null); + } + + mDrawable = drawable; + + // Reset mMinScale to ensure the bounds / matrix are recalculated + mMinScale = 0f; + + // Set a callback? + mDrawable.setCallback(this); + + changed = true; + } + + configureBounds(changed); + invalidate(); + } + + /** + * Binds a bitmap to the view. + * + * @param photoBitmap the bitmap to bind. + */ + public void bindPhoto(Bitmap photoBitmap) { + boolean currentDrawableIsBitmapDrawable = mDrawable instanceof BitmapDrawable; + boolean changed = !(currentDrawableIsBitmapDrawable); + if (mDrawable != null && currentDrawableIsBitmapDrawable) { + final Bitmap drawableBitmap = ((BitmapDrawable) mDrawable).getBitmap(); + if (photoBitmap == drawableBitmap) { + // setting the same bitmap; do nothing + return; + } + + changed = photoBitmap != null && + (mDrawable.getIntrinsicWidth() != photoBitmap.getWidth() || + mDrawable.getIntrinsicHeight() != photoBitmap.getHeight()); + + // Reset mMinScale to ensure the bounds / matrix are recalculated + mMinScale = 0f; + mDrawable = null; + } + + if (mDrawable == null && photoBitmap != null) { + mDrawable = new BitmapDrawable(getResources(), photoBitmap); + } + + configureBounds(changed); + invalidate(); + } + + /** + * Returns the bound photo data if set. Otherwise, {@code null}. + */ + public Bitmap getPhoto() { + if (mDrawable != null && mDrawable instanceof BitmapDrawable) { + return ((BitmapDrawable) mDrawable).getBitmap(); + } + return null; + } + + /** + * Returns the bound drawable. May be {@code null} if no drawable is bound. + */ + public Drawable getDrawable() { + return mDrawable; + } + + /** + * Gets video data associated with this item. Returns {@code null} if this is not a video. + */ + public byte[] getVideoData() { + return mVideoBlob; + } + + /** + * Returns {@code true} if the photo represents a video. Otherwise, {@code false}. + */ + public boolean isVideo() { + return mVideoBlob != null; + } + + /** + * Returns {@code true} if the video is ready to play. Otherwise, {@code false}. + */ + public boolean isVideoReady() { + return mVideoBlob != null && mVideoReady; + } + + /** + * Returns {@code true} if a photo has been bound. Otherwise, {@code false}. + */ + public boolean isPhotoBound() { + return mDrawable != null; + } + + /** + * Hides the photo info portion of the header. As a side effect, this automatically enables + * or disables image transformations [eg zoom, pan, etc...] depending upon the value of + * fullScreen. If this is not desirable, enable / disable image transformations manually. + */ + public void setFullScreen(boolean fullScreen, boolean animate) { + if (fullScreen != mFullScreen) { + mFullScreen = fullScreen; + requestLayout(); + invalidate(); + } + } + + /** + * Enable or disable cropping of the displayed image. Cropping can only be enabled + * before the view has been laid out. Additionally, once cropping has been + * enabled, it cannot be disabled. + */ + public void enableAllowCrop(boolean allowCrop) { + if (allowCrop && mHaveLayout) { + throw new IllegalArgumentException("Cannot set crop after view has been laid out"); + } + if (!allowCrop && mAllowCrop) { + throw new IllegalArgumentException("Cannot unset crop mode"); + } + mAllowCrop = allowCrop; + } + + /** + * Gets a bitmap of the cropped region. If cropping is not enabled, returns {@code null}. + */ + public Bitmap getCroppedPhoto() { + if (!mAllowCrop) { + return null; + } + + final Bitmap croppedBitmap = Bitmap.createBitmap( + (int) CROPPED_SIZE, (int) CROPPED_SIZE, Bitmap.Config.ARGB_8888); + final Canvas croppedCanvas = new Canvas(croppedBitmap); + + // scale for the final dimensions + final int cropWidth = mCropRect.right - mCropRect.left; + final float scaleWidth = CROPPED_SIZE / cropWidth; + final float scaleHeight = CROPPED_SIZE / cropWidth; + + // translate to the origin & scale + final Matrix matrix = new Matrix(mDrawMatrix); + matrix.postTranslate(-mCropRect.left, -mCropRect.top); + matrix.postScale(scaleWidth, scaleHeight); + + // draw the photo + if (mDrawable != null) { + croppedCanvas.concat(matrix); + mDrawable.draw(croppedCanvas); + } + return croppedBitmap; + } + + /** + * Resets the image transformation to its original value. + */ + public void resetTransformations() { + // snap transformations; we don't animate + mMatrix.set(mOriginalMatrix); + + // Invalidate the view because if you move off this PhotoView + // to another one and come back, you want it to draw from scratch + // in case you were zoomed in or translated (since those settings + // are not preserved and probably shouldn't be). + invalidate(); + } + + /** + * Rotates the image 90 degrees, clockwise. + */ + public void rotateClockwise() { + rotate(90, true); + } + + /** + * Rotates the image 90 degrees, counter clockwise. + */ + public void rotateCounterClockwise() { + rotate(-90, true); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + // draw the photo + if (mDrawable != null) { + int saveCount = canvas.getSaveCount(); + canvas.save(); + + if (mDrawMatrix != null) { + canvas.concat(mDrawMatrix); + } + mDrawable.draw(canvas); + + canvas.restoreToCount(saveCount); + + if (mVideoBlob != null) { + final Bitmap videoImage = (mVideoReady ? sVideoImage : sVideoNotReadyImage); + final int drawLeft = (getWidth() - videoImage.getWidth()) / 2; + final int drawTop = (getHeight() - videoImage.getHeight()) / 2; + canvas.drawBitmap(videoImage, drawLeft, drawTop, null); + } + + // Extract the drawable's bounds (in our own copy, to not alter the image) + mTranslateRect.set(mDrawable.getBounds()); + if (mDrawMatrix != null) { + mDrawMatrix.mapRect(mTranslateRect); + } + + if (mAllowCrop) { + int previousSaveCount = canvas.getSaveCount(); + canvas.drawRect(0, 0, getWidth(), getHeight(), sCropDimPaint); + canvas.save(); + canvas.clipRect(mCropRect); + + if (mDrawMatrix != null) { + canvas.concat(mDrawMatrix); + } + + mDrawable.draw(canvas); + canvas.restoreToCount(previousSaveCount); + canvas.drawRect(mCropRect, sCropPaint); + } + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + mHaveLayout = true; + final int layoutWidth = getWidth(); + final int layoutHeight = getHeight(); + + if (mAllowCrop) { + mCropSize = Math.min(sCropSize, Math.min(layoutWidth, layoutHeight)); + final int cropLeft = (layoutWidth - mCropSize) / 2; + final int cropTop = (layoutHeight - mCropSize) / 2; + final int cropRight = cropLeft + mCropSize; + final int cropBottom = cropTop + mCropSize; + + // Create a crop region overlay. We need a separate canvas to be able to "punch + // a hole" through to the underlying image. + mCropRect.set(cropLeft, cropTop, cropRight, cropBottom); + } + configureBounds(changed); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (mFixedHeight != -1) { + super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(mFixedHeight, + MeasureSpec.AT_MOST)); + setMeasuredDimension(getMeasuredWidth(), mFixedHeight); + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + } + + @Override + public boolean verifyDrawable(Drawable drawable) { + return mDrawable == drawable || super.verifyDrawable(drawable); + } + + @Override + /** + * {@inheritDoc} + */ + public void invalidateDrawable(Drawable drawable) { + // Only invalidate this view if the passed in drawable is displayed within this view. If + // another drawable is passed in, have the parent view handle invalidation. + if (mDrawable == drawable) { + invalidate(); + } else { + super.invalidateDrawable(drawable); + } + } + + /** + * Forces a fixed height for this view. + * + * @param fixedHeight The height. If {@code -1}, use the measured height. + */ + public void setFixedHeight(int fixedHeight) { + final boolean adjustBounds = (fixedHeight != mFixedHeight); + mFixedHeight = fixedHeight; + setMeasuredDimension(getMeasuredWidth(), mFixedHeight); + if (adjustBounds) { + configureBounds(true); + requestLayout(); + } + } + + /** + * Enable or disable image transformations. When transformations are enabled, this view + * consumes all touch events. + */ + public void enableImageTransforms(boolean enable) { + mTransformsEnabled = enable; + if (!mTransformsEnabled) { + resetTransformations(); + } + } + + /** + * Configures the bounds of the photo. The photo will always be scaled to fit center. + */ + private void configureBounds(boolean changed) { + if (mDrawable == null || !mHaveLayout) { + return; + } + final int dwidth = mDrawable.getIntrinsicWidth(); + final int dheight = mDrawable.getIntrinsicHeight(); + + final int vwidth = getWidth(); + final int vheight = getHeight(); + + final boolean fits = (dwidth < 0 || vwidth == dwidth) && + (dheight < 0 || vheight == dheight); + + // We need to do the scaling ourself, so have the drawable use its native size. + mDrawable.setBounds(0, 0, dwidth, dheight); + + // Create a matrix with the proper transforms + if (changed || (mMinScale == 0 && mDrawable != null && mHaveLayout)) { + generateMatrix(); + generateScale(); + } + + if (fits || mMatrix.isIdentity()) { + // The bitmap fits exactly, no transform needed. + mDrawMatrix = null; + } else { + mDrawMatrix = mMatrix; + } + } + + /** + * Generates the initial transformation matrix for drawing. Additionally, it sets the + * minimum and maximum scale values. + */ + private void generateMatrix() { + final int dwidth = mDrawable.getIntrinsicWidth(); + final int dheight = mDrawable.getIntrinsicHeight(); + + final int vwidth = mAllowCrop ? sCropSize : getWidth(); + final int vheight = mAllowCrop ? sCropSize : getHeight(); + + final boolean fits = (dwidth < 0 || vwidth == dwidth) && + (dheight < 0 || vheight == dheight); + + if (fits && !mAllowCrop) { + mMatrix.reset(); + } else { + // Generate the required transforms for the photo + mTempSrc.set(0, 0, dwidth, dheight); + if (mAllowCrop) { + mTempDst.set(mCropRect); + } else { + mTempDst.set(0, 0, vwidth, vheight); + } + RectF scaledDestination = new RectF( + (vwidth / 2) - (dwidth * mMaxInitialScaleFactor / 2), + (vheight / 2) - (dheight * mMaxInitialScaleFactor / 2), + (vwidth / 2) + (dwidth * mMaxInitialScaleFactor / 2), + (vheight / 2) + (dheight * mMaxInitialScaleFactor / 2)); + if(mTempDst.contains(scaledDestination)) { + mMatrix.setRectToRect(mTempSrc, scaledDestination, Matrix.ScaleToFit.CENTER); + } else { + mMatrix.setRectToRect(mTempSrc, mTempDst, Matrix.ScaleToFit.CENTER); + } + } + mOriginalMatrix.set(mMatrix); + } + + private void generateScale() { + final int dwidth = mDrawable.getIntrinsicWidth(); + final int dheight = mDrawable.getIntrinsicHeight(); + + final int vwidth = mAllowCrop ? getCropSize() : getWidth(); + final int vheight = mAllowCrop ? getCropSize() : getHeight(); + + if (dwidth < vwidth && dheight < vheight && !mAllowCrop) { + mMinScale = 1.0f; + } else { + mMinScale = getScale(); + } + mMaxScale = Math.max(mMinScale * 4, 4); + } + + /** + * @return the size of the crop regions + */ + private int getCropSize() { + return mCropSize > 0 ? mCropSize : sCropSize; + } + + /** + * Returns the currently applied scale factor for the image. + *

+ * NOTE: This method overwrites any values stored in {@link #mValues}. + */ + private float getScale() { + mMatrix.getValues(mValues); + return mValues[Matrix.MSCALE_X]; + } + + /** + * Scales the image while keeping the aspect ratio. + * + * The given scale is capped so that the resulting scale of the image always remains + * between {@link #mMinScale} and {@link #mMaxScale}. + * + * If the image is smaller than the viewable area, it will be centered. + * + * @param newScale the new scale + * @param centerX the center horizontal point around which to scale + * @param centerY the center vertical point around which to scale + */ + private void scale(float newScale, float centerX, float centerY) { + // Rotate back to the original orientation + mMatrix.postRotate(-mRotation, getWidth() / 2, getHeight() / 2); + + // Ensure that mMinScale <= newScale <= mMaxScale + newScale = Math.max(newScale, mMinScale); + newScale = Math.min(newScale, mMaxScale * SCALE_OVERZOOM_FACTOR); + + float currentScale = getScale(); + + // Prepare to animate zoom out if over-zooming + if (newScale > mMaxScale && currentScale <= mMaxScale) { + Runnable zoomBackRunnable = new Runnable() { + @Override + public void run() { + // Scale back to the maximum if over-zoomed + float currentScale = getScale(); + if (currentScale > mMaxScale) { + // The number of times the crop amount pulled in can fit on the screen + float marginFit = 1 / (1 - mMaxScale / currentScale); + // The (negative) relative maximum distance from an image edge such that + // when scaled this far from the edge, all of the image off-screen in that + // direction is pulled in + float relativeDistance = 1 - marginFit; + float finalCenterX = getWidth() / 2; + float finalCenterY = getHeight() / 2; + // This center will pull all of the margin from the lesser side, over will + // expose trim + float maxX = mTranslateRect.left * relativeDistance; + float maxY = mTranslateRect.top * relativeDistance; + // This center will pull all of the margin from the greater side, over will + // expose trim + float minX = getWidth() * marginFit + mTranslateRect.right * + relativeDistance; + float minY = getHeight() * marginFit + mTranslateRect.bottom * + relativeDistance; + // Adjust center according to bounds to avoid bad crop + if (minX > maxX) { + // Border is inevitable due to small image size, so we split the crop + finalCenterX = (minX + maxX) / 2; + } else { + finalCenterX = Math.min(Math.max(minX, finalCenterX), maxX); + } + if (minY > maxY) { + // Border is inevitable due to small image size, so we split the crop + finalCenterY = (minY + maxY) / 2; + } else { + finalCenterY = Math.min(Math.max(minY, finalCenterY), maxY); + } + mScaleRunnable.start(currentScale, mMaxScale, finalCenterX, finalCenterY); + } + } + }; + postDelayed(zoomBackRunnable, ZOOM_CORRECTION_DELAY); + } + + float factor = newScale / currentScale; + + // Apply the scale factor + mMatrix.postScale(factor, factor, centerX, centerY); + + // Re-apply any rotation + mMatrix.postRotate(mRotation, getWidth() / 2, getHeight() / 2); + + invalidate(); + } + + /** + * Translates the image. + * + * This method will not allow the image to be translated outside of the visible area. + * + * @param tx how many pixels to translate horizontally + * @param ty how many pixels to translate vertically + * @return result of the translation, represented as either {@link TRANSLATE_NONE}, + * {@link TRANSLATE_X_ONLY}, {@link TRANSLATE_Y_ONLY}, or {@link TRANSLATE_BOTH} + */ + private int translate(float tx, float ty) { + mTranslateRect.set(mTempSrc); + mMatrix.mapRect(mTranslateRect); + + final float maxLeft = mAllowCrop ? mCropRect.left : 0.0f; + final float maxRight = mAllowCrop ? mCropRect.right : getWidth(); + float l = mTranslateRect.left; + float r = mTranslateRect.right; + + final float translateX; + if (mAllowCrop) { + // If we're cropping, allow the image to scroll off the edge of the screen + translateX = Math.max(maxLeft - mTranslateRect.right, + Math.min(maxRight - mTranslateRect.left, tx)); + } else { + // Otherwise, ensure the image never leaves the screen + if (r - l < maxRight - maxLeft) { + translateX = maxLeft + ((maxRight - maxLeft) - (r + l)) / 2; + } else { + translateX = Math.max(maxRight - r, Math.min(maxLeft - l, tx)); + } + } + + float maxTop = mAllowCrop ? mCropRect.top: 0.0f; + float maxBottom = mAllowCrop ? mCropRect.bottom : getHeight(); + float t = mTranslateRect.top; + float b = mTranslateRect.bottom; + + final float translateY; + if (mAllowCrop) { + // If we're cropping, allow the image to scroll off the edge of the screen + translateY = Math.max(maxTop - mTranslateRect.bottom, + Math.min(maxBottom - mTranslateRect.top, ty)); + } else { + // Otherwise, ensure the image never leaves the screen + if (b - t < maxBottom - maxTop) { + translateY = maxTop + ((maxBottom - maxTop) - (b + t)) / 2; + } else { + translateY = Math.max(maxBottom - b, Math.min(maxTop - t, ty)); + } + } + + // Do the translation + mMatrix.postTranslate(translateX, translateY); + invalidate(); + + boolean didTranslateX = translateX == tx; + boolean didTranslateY = translateY == ty; + if (didTranslateX && didTranslateY) { + return TRANSLATE_BOTH; + } else if (didTranslateX) { + return TRANSLATE_X_ONLY; + } else if (didTranslateY) { + return TRANSLATE_Y_ONLY; + } + return TRANSLATE_NONE; + } + + /** + * Snaps the image so it touches all edges of the view. + */ + private void snap() { + mTranslateRect.set(mTempSrc); + mMatrix.mapRect(mTranslateRect); + + // Determine how much to snap in the horizontal direction [if any] + float maxLeft = mAllowCrop ? mCropRect.left : 0.0f; + float maxRight = mAllowCrop ? mCropRect.right : getWidth(); + float l = mTranslateRect.left; + float r = mTranslateRect.right; + + final float translateX; + if (r - l < maxRight - maxLeft) { + // Image is narrower than view; translate to the center of the view + translateX = maxLeft + ((maxRight - maxLeft) - (r + l)) / 2; + } else if (l > maxLeft) { + // Image is off right-edge of screen; bring it into view + translateX = maxLeft - l; + } else if (r < maxRight) { + // Image is off left-edge of screen; bring it into view + translateX = maxRight - r; + } else { + translateX = 0.0f; + } + + // Determine how much to snap in the vertical direction [if any] + float maxTop = mAllowCrop ? mCropRect.top : 0.0f; + float maxBottom = mAllowCrop ? mCropRect.bottom : getHeight(); + float t = mTranslateRect.top; + float b = mTranslateRect.bottom; + + final float translateY; + if (b - t < maxBottom - maxTop) { + // Image is shorter than view; translate to the bottom edge of the view + translateY = maxTop + ((maxBottom - maxTop) - (b + t)) / 2; + } else if (t > maxTop) { + // Image is off bottom-edge of screen; bring it into view + translateY = maxTop - t; + } else if (b < maxBottom) { + // Image is off top-edge of screen; bring it into view + translateY = maxBottom - b; + } else { + translateY = 0.0f; + } + + if (Math.abs(translateX) > SNAP_THRESHOLD || Math.abs(translateY) > SNAP_THRESHOLD) { + mSnapRunnable.start(translateX, translateY); + } else { + mMatrix.postTranslate(translateX, translateY); + invalidate(); + } + } + + /** + * Rotates the image, either instantly or gradually + * + * @param degrees how many degrees to rotate the image, positive rotates clockwise + * @param animate if {@code true}, animate during the rotation. Otherwise, snap rotate. + */ + private void rotate(float degrees, boolean animate) { + if (animate) { + mRotateRunnable.start(degrees); + } else { + mRotation += degrees; + mMatrix.postRotate(degrees, getWidth() / 2, getHeight() / 2); + invalidate(); + } + } + + /** + * Initializes the header and any static values + */ + private void initialize() { + Context context = getContext(); + + if (!sInitialized) { + sInitialized = true; + + Resources resources = context.getApplicationContext().getResources(); + + sCropSize = resources.getDimensionPixelSize(R.dimen.photo_crop_width); + + sCropDimPaint = new Paint(); + sCropDimPaint.setAntiAlias(true); + sCropDimPaint.setColor(resources.getColor(R.color.photo_crop_dim_color)); + sCropDimPaint.setStyle(Style.FILL); + + sCropPaint = new Paint(); + sCropPaint.setAntiAlias(true); + sCropPaint.setColor(resources.getColor(R.color.photo_crop_highlight_color)); + sCropPaint.setStyle(Style.STROKE); + sCropPaint.setStrokeWidth(resources.getDimension(R.dimen.photo_crop_stroke_width)); + + final ViewConfiguration configuration = ViewConfiguration.get(context); + final int touchSlop = configuration.getScaledTouchSlop(); + sTouchSlopSquare = touchSlop * touchSlop; + } + + mGestureDetector = new GestureDetectorCompat(context, this, null); + mScaleGetureDetector = new ScaleGestureDetector(context, this); + mQuickScaleEnabled = ScaleGestureDetectorCompat.isQuickScaleEnabled(mScaleGetureDetector); + mScaleRunnable = new ScaleRunnable(this); + mTranslateRunnable = new TranslateRunnable(this); + mSnapRunnable = new SnapRunnable(this); + mRotateRunnable = new RotateRunnable(this); + } + + /** + * Runnable that animates an image scale operation. + */ + private static class ScaleRunnable implements Runnable { + + private final PhotoView mHeader; + + private float mCenterX; + private float mCenterY; + + private boolean mZoomingIn; + + private float mTargetScale; + private float mStartScale; + private float mVelocity; + private long mStartTime; + + private boolean mRunning; + private boolean mStop; + + public ScaleRunnable(PhotoView header) { + mHeader = header; + } + + /** + * Starts the animation. There is no target scale bounds check. + */ + public boolean start(float startScale, float targetScale, float centerX, float centerY) { + if (mRunning) { + return false; + } + + mCenterX = centerX; + mCenterY = centerY; + + // Ensure the target scale is within the min/max bounds + mTargetScale = targetScale; + mStartTime = System.currentTimeMillis(); + mStartScale = startScale; + mZoomingIn = mTargetScale > mStartScale; + mVelocity = (mTargetScale - mStartScale) / ZOOM_ANIMATION_DURATION; + mRunning = true; + mStop = false; + mHeader.post(this); + return true; + } + + /** + * Stops the animation in place. It does not snap the image to its final zoom. + */ + public void stop() { + mRunning = false; + mStop = true; + } + + @Override + public void run() { + if (mStop) { + return; + } + + // Scale + long now = System.currentTimeMillis(); + long ellapsed = now - mStartTime; + float newScale = (mStartScale + mVelocity * ellapsed); + mHeader.scale(newScale, mCenterX, mCenterY); + + // Stop when done + if (newScale == mTargetScale || (mZoomingIn == (newScale > mTargetScale))) { + mHeader.scale(mTargetScale, mCenterX, mCenterY); + stop(); + } + + if (!mStop) { + mHeader.post(this); + } + } + } + + /** + * Runnable that animates an image translation operation. + */ + private static class TranslateRunnable implements Runnable { + + private static final float DECELERATION_RATE = 20000f; + private static final long NEVER = -1L; + + private final PhotoView mHeader; + + private float mVelocityX; + private float mVelocityY; + + private float mDecelerationX; + private float mDecelerationY; + + private long mLastRunTime; + private boolean mRunning; + private boolean mStop; + + public TranslateRunnable(PhotoView header) { + mLastRunTime = NEVER; + mHeader = header; + } + + /** + * Starts the animation. + */ + public boolean start(float velocityX, float velocityY) { + if (mRunning) { + return false; + } + mLastRunTime = NEVER; + mVelocityX = velocityX; + mVelocityY = velocityY; + + float angle = (float) Math.atan2(mVelocityY, mVelocityX); + mDecelerationX = (float) (DECELERATION_RATE * Math.cos(angle)); + mDecelerationY = (float) (DECELERATION_RATE * Math.sin(angle)); + + mStop = false; + mRunning = true; + mHeader.post(this); + return true; + } + + /** + * Stops the animation in place. It does not snap the image to its final translation. + */ + public void stop() { + mRunning = false; + mStop = true; + } + + @Override + public void run() { + // See if we were told to stop: + if (mStop) { + return; + } + + // Translate according to current velocities and time delta: + long now = System.currentTimeMillis(); + float delta = (mLastRunTime != NEVER) ? (now - mLastRunTime) / 1000f : 0f; + final int translateResult = mHeader.translate(mVelocityX * delta, mVelocityY * delta); + mLastRunTime = now; + // Slow down: + float slowDownX = mDecelerationX * delta; + if (Math.abs(mVelocityX) > Math.abs(slowDownX)) { + mVelocityX -= slowDownX; + } else { + mVelocityX = 0f; + } + float slowDownY = mDecelerationY * delta; + if (Math.abs(mVelocityY) > Math.abs(slowDownY)) { + mVelocityY -= slowDownY; + } else { + mVelocityY = 0f; + } + + // Stop when done + if ((mVelocityX == 0f && mVelocityY == 0f) + || translateResult == TRANSLATE_NONE) { + stop(); + mHeader.snap(); + } else if (translateResult == TRANSLATE_X_ONLY) { + mDecelerationX = (mVelocityX > 0) ? DECELERATION_RATE : -DECELERATION_RATE; + mDecelerationY = 0; + mVelocityY = 0f; + } else if (translateResult == TRANSLATE_Y_ONLY) { + mDecelerationX = 0; + mDecelerationY = (mVelocityY > 0) ? DECELERATION_RATE : -DECELERATION_RATE; + mVelocityX = 0f; + } + + // See if we need to continue flinging: + if (mStop) { + return; + } + mHeader.post(this); + } + } + + /** + * Runnable that animates an image translation operation. + */ + private static class SnapRunnable implements Runnable { + + private static final long NEVER = -1L; + + private final PhotoView mHeader; + + private float mTranslateX; + private float mTranslateY; + + private long mStartRunTime; + private boolean mRunning; + private boolean mStop; + + public SnapRunnable(PhotoView header) { + mStartRunTime = NEVER; + mHeader = header; + } + + /** + * Starts the animation. + */ + public boolean start(float translateX, float translateY) { + if (mRunning) { + return false; + } + mStartRunTime = NEVER; + mTranslateX = translateX; + mTranslateY = translateY; + mStop = false; + mRunning = true; + mHeader.postDelayed(this, SNAP_DELAY); + return true; + } + + /** + * Stops the animation in place. It does not snap the image to its final translation. + */ + public void stop() { + mRunning = false; + mStop = true; + } + + @Override + public void run() { + // See if we were told to stop: + if (mStop) { + return; + } + + // Translate according to current velocities and time delta: + long now = System.currentTimeMillis(); + float delta = (mStartRunTime != NEVER) ? (now - mStartRunTime) : 0f; + + if (mStartRunTime == NEVER) { + mStartRunTime = now; + } + + float transX; + float transY; + if (delta >= SNAP_DURATION) { + transX = mTranslateX; + transY = mTranslateY; + } else { + transX = (mTranslateX / (SNAP_DURATION - delta)) * 10f; + transY = (mTranslateY / (SNAP_DURATION - delta)) * 10f; + if (Math.abs(transX) > Math.abs(mTranslateX) || Float.isNaN(transX)) { + transX = mTranslateX; + } + if (Math.abs(transY) > Math.abs(mTranslateY) || Float.isNaN(transY)) { + transY = mTranslateY; + } + } + + mHeader.translate(transX, transY); + mTranslateX -= transX; + mTranslateY -= transY; + + if (mTranslateX == 0 && mTranslateY == 0) { + stop(); + } + + // See if we need to continue flinging: + if (mStop) { + return; + } + mHeader.post(this); + } + } + + /** + * Runnable that animates an image rotation operation. + */ + private static class RotateRunnable implements Runnable { + + private static final long NEVER = -1L; + + private final PhotoView mHeader; + + private float mTargetRotation; + private float mAppliedRotation; + private float mVelocity; + private long mLastRuntime; + + private boolean mRunning; + private boolean mStop; + + public RotateRunnable(PhotoView header) { + mHeader = header; + } + + /** + * Starts the animation. + */ + public void start(float rotation) { + if (mRunning) { + return; + } + + mTargetRotation = rotation; + mVelocity = mTargetRotation / ROTATE_ANIMATION_DURATION; + mAppliedRotation = 0f; + mLastRuntime = NEVER; + mStop = false; + mRunning = true; + mHeader.post(this); + } + + /** + * Stops the animation in place. It does not snap the image to its final rotation. + */ + public void stop() { + mRunning = false; + mStop = true; + } + + @Override + public void run() { + if (mStop) { + return; + } + + if (mAppliedRotation != mTargetRotation) { + long now = System.currentTimeMillis(); + long delta = mLastRuntime != NEVER ? now - mLastRuntime : 0L; + float rotationAmount = mVelocity * delta; + if (mAppliedRotation < mTargetRotation + && mAppliedRotation + rotationAmount > mTargetRotation + || mAppliedRotation > mTargetRotation + && mAppliedRotation + rotationAmount < mTargetRotation) { + rotationAmount = mTargetRotation - mAppliedRotation; + } + mHeader.rotate(rotationAmount, false); + mAppliedRotation += rotationAmount; + if (mAppliedRotation == mTargetRotation) { + stop(); + } + mLastRuntime = now; + } + + if (mStop) { + return; + } + mHeader.post(this); + } + } + + public void setMaxInitialScale(float f) { + mMaxInitialScaleFactor = f; + } +} diff --git a/modules/photoviewer/src/com/android/ex/photo/views/ProgressBarWrapper.java b/modules/photoviewer/src/com/android/ex/photo/views/ProgressBarWrapper.java new file mode 100644 index 0000000..2669273 --- /dev/null +++ b/modules/photoviewer/src/com/android/ex/photo/views/ProgressBarWrapper.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2012 Google Inc. + * Licensed to The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.ex.photo.views; + +import android.view.View; +import android.widget.ProgressBar; + +/** + * This class wraps around two progress bars and is solely designed to fix + * a bug in the framework (b/6928449) that prevents a progress bar from + * gracefully switching back and forth between indeterminate and determinate + * modes. + */ +public class ProgressBarWrapper { + private final ProgressBar mDeterminate; + private final ProgressBar mIndeterminate; + private boolean mIsIndeterminate; + + public ProgressBarWrapper(ProgressBar determinate, + ProgressBar indeterminate, boolean isIndeterminate) { + mDeterminate = determinate; + mIndeterminate = indeterminate; + setIndeterminate(isIndeterminate); + } + + public void setIndeterminate(boolean isIndeterminate) { + mIsIndeterminate = isIndeterminate; + + setVisibility(mIsIndeterminate); + } + + public void setVisibility(int visibility) { + if (visibility == View.INVISIBLE || visibility == View.GONE) { + mIndeterminate.setVisibility(visibility); + mDeterminate.setVisibility(visibility); + } else { + setVisibility(mIsIndeterminate); + } + } + + private void setVisibility(boolean isIndeterminate) { + mIndeterminate.setVisibility(isIndeterminate ? View.VISIBLE : View.GONE); + mDeterminate.setVisibility(isIndeterminate ? View.GONE : View.VISIBLE); + } + + public void setMax(int max) { + mDeterminate.setMax(max); + } + + public void setProgress(int progress) { + mDeterminate.setProgress(progress); + } +} diff --git a/modules/vcard b/modules/vcard deleted file mode 160000 index f049d8c..0000000 --- a/modules/vcard +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f049d8cd3a6579d140d22fcdcbdbdfd5f7c020e3 diff --git a/modules/vcard/Android.bp b/modules/vcard/Android.bp new file mode 100644 index 0000000..8880675 --- /dev/null +++ b/modules/vcard/Android.bp @@ -0,0 +1,34 @@ +// 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +java_library { + name: "com.android.vcard", + + // 2.3 + sdk_version: "9", + + srcs: ["java/**/*.java"], + apex_available: [ + "//apex_available:platform", + "com.android.bt", + ], + + libs: [ + "framework-annotations-lib", + ], +} diff --git a/modules/vcard/AndroidManifest.xml b/modules/vcard/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/modules/vcard/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/modules/vcard/OWNERS b/modules/vcard/OWNERS new file mode 100644 index 0000000..90cc6f0 --- /dev/null +++ b/modules/vcard/OWNERS @@ -0,0 +1,3 @@ +# Default code reviewers picked from top 3 or more developers. +# Please update this list if you find better candidates. +johnshao@google.com diff --git a/modules/vcard/build.gradle b/modules/vcard/build.gradle new file mode 100644 index 0000000..cabd949 --- /dev/null +++ b/modules/vcard/build.gradle @@ -0,0 +1,29 @@ +plugins { + id 'com.android.library' +} + +android { + namespace 'com.android.vcard' + compileSdk 35 + + defaultConfig { + minSdk 33 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + sourceSets { + main { + manifest.srcFile 'AndroidManifest.xml' + java.srcDirs = ['java'] + res.srcDirs = [] + } + } +} + +dependencies { + implementation 'androidx.annotation:annotation:1.8.2' +} diff --git a/modules/vcard/java/com/android/vcard/JapaneseUtils.java b/modules/vcard/java/com/android/vcard/JapaneseUtils.java new file mode 100644 index 0000000..482280f --- /dev/null +++ b/modules/vcard/java/com/android/vcard/JapaneseUtils.java @@ -0,0 +1,382 @@ +/* + * 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.vcard; + +import androidx.annotation.VisibleForTesting; + +import java.util.HashMap; +import java.util.Map; + +/** + * TextUtils especially for Japanese. + */ +/* package */ class JapaneseUtils { + @VisibleForTesting + static final Map sHalfWidthMap = + new HashMap(); + + static { + sHalfWidthMap.put('\u3001', "\uFF64"); + sHalfWidthMap.put('\u3002', "\uFF61"); + sHalfWidthMap.put('\u300C', "\uFF62"); + sHalfWidthMap.put('\u300D', "\uFF63"); + sHalfWidthMap.put('\u301C', "~"); + sHalfWidthMap.put('\u3041', "\uFF67"); + sHalfWidthMap.put('\u3042', "\uFF71"); + sHalfWidthMap.put('\u3043', "\uFF68"); + sHalfWidthMap.put('\u3044', "\uFF72"); + sHalfWidthMap.put('\u3045', "\uFF69"); + sHalfWidthMap.put('\u3046', "\uFF73"); + sHalfWidthMap.put('\u3047', "\uFF6A"); + sHalfWidthMap.put('\u3048', "\uFF74"); + sHalfWidthMap.put('\u3049', "\uFF6B"); + sHalfWidthMap.put('\u304A', "\uFF75"); + sHalfWidthMap.put('\u304B', "\uFF76"); + sHalfWidthMap.put('\u304C', "\uFF76\uFF9E"); + sHalfWidthMap.put('\u304D', "\uFF77"); + sHalfWidthMap.put('\u304E', "\uFF77\uFF9E"); + sHalfWidthMap.put('\u304F', "\uFF78"); + sHalfWidthMap.put('\u3050', "\uFF78\uFF9E"); + sHalfWidthMap.put('\u3051', "\uFF79"); + sHalfWidthMap.put('\u3052', "\uFF79\uFF9E"); + sHalfWidthMap.put('\u3053', "\uFF7A"); + sHalfWidthMap.put('\u3054', "\uFF7A\uFF9E"); + sHalfWidthMap.put('\u3055', "\uFF7B"); + sHalfWidthMap.put('\u3056', "\uFF7B\uFF9E"); + sHalfWidthMap.put('\u3057', "\uFF7C"); + sHalfWidthMap.put('\u3058', "\uFF7C\uFF9E"); + sHalfWidthMap.put('\u3059', "\uFF7D"); + sHalfWidthMap.put('\u305A', "\uFF7D\uFF9E"); + sHalfWidthMap.put('\u305B', "\uFF7E"); + sHalfWidthMap.put('\u305C', "\uFF7E\uFF9E"); + sHalfWidthMap.put('\u305D', "\uFF7F"); + sHalfWidthMap.put('\u305E', "\uFF7F\uFF9E"); + sHalfWidthMap.put('\u305F', "\uFF80"); + sHalfWidthMap.put('\u3060', "\uFF80\uFF9E"); + sHalfWidthMap.put('\u3061', "\uFF81"); + sHalfWidthMap.put('\u3062', "\uFF81\uFF9E"); + sHalfWidthMap.put('\u3063', "\uFF6F"); + sHalfWidthMap.put('\u3064', "\uFF82"); + sHalfWidthMap.put('\u3065', "\uFF82\uFF9E"); + sHalfWidthMap.put('\u3066', "\uFF83"); + sHalfWidthMap.put('\u3067', "\uFF83\uFF9E"); + sHalfWidthMap.put('\u3068', "\uFF84"); + sHalfWidthMap.put('\u3069', "\uFF84\uFF9E"); + sHalfWidthMap.put('\u306A', "\uFF85"); + sHalfWidthMap.put('\u306B', "\uFF86"); + sHalfWidthMap.put('\u306C', "\uFF87"); + sHalfWidthMap.put('\u306D', "\uFF88"); + sHalfWidthMap.put('\u306E', "\uFF89"); + sHalfWidthMap.put('\u306F', "\uFF8A"); + sHalfWidthMap.put('\u3070', "\uFF8A\uFF9E"); + sHalfWidthMap.put('\u3071', "\uFF8A\uFF9F"); + sHalfWidthMap.put('\u3072', "\uFF8B"); + sHalfWidthMap.put('\u3073', "\uFF8B\uFF9E"); + sHalfWidthMap.put('\u3074', "\uFF8B\uFF9F"); + sHalfWidthMap.put('\u3075', "\uFF8C"); + sHalfWidthMap.put('\u3076', "\uFF8C\uFF9E"); + sHalfWidthMap.put('\u3077', "\uFF8C\uFF9F"); + sHalfWidthMap.put('\u3078', "\uFF8D"); + sHalfWidthMap.put('\u3079', "\uFF8D\uFF9E"); + sHalfWidthMap.put('\u307A', "\uFF8D\uFF9F"); + sHalfWidthMap.put('\u307B', "\uFF8E"); + sHalfWidthMap.put('\u307C', "\uFF8E\uFF9E"); + sHalfWidthMap.put('\u307D', "\uFF8E\uFF9F"); + sHalfWidthMap.put('\u307E', "\uFF8F"); + sHalfWidthMap.put('\u307F', "\uFF90"); + sHalfWidthMap.put('\u3080', "\uFF91"); + sHalfWidthMap.put('\u3081', "\uFF92"); + sHalfWidthMap.put('\u3082', "\uFF93"); + sHalfWidthMap.put('\u3083', "\uFF6C"); + sHalfWidthMap.put('\u3084', "\uFF94"); + sHalfWidthMap.put('\u3085', "\uFF6D"); + sHalfWidthMap.put('\u3086', "\uFF95"); + sHalfWidthMap.put('\u3087', "\uFF6E"); + sHalfWidthMap.put('\u3088', "\uFF96"); + sHalfWidthMap.put('\u3089', "\uFF97"); + sHalfWidthMap.put('\u308A', "\uFF98"); + sHalfWidthMap.put('\u308B', "\uFF99"); + sHalfWidthMap.put('\u308C', "\uFF9A"); + sHalfWidthMap.put('\u308D', "\uFF9B"); + sHalfWidthMap.put('\u308E', "\uFF9C"); + sHalfWidthMap.put('\u308F', "\uFF9C"); + sHalfWidthMap.put('\u3090', "\uFF72"); + sHalfWidthMap.put('\u3091', "\uFF74"); + sHalfWidthMap.put('\u3092', "\uFF66"); + sHalfWidthMap.put('\u3093', "\uFF9D"); + sHalfWidthMap.put('\u309B', "\uFF9E"); + sHalfWidthMap.put('\u309C', "\uFF9F"); + sHalfWidthMap.put('\u30A1', "\uFF67"); + sHalfWidthMap.put('\u30A2', "\uFF71"); + sHalfWidthMap.put('\u30A3', "\uFF68"); + sHalfWidthMap.put('\u30A4', "\uFF72"); + sHalfWidthMap.put('\u30A5', "\uFF69"); + sHalfWidthMap.put('\u30A6', "\uFF73"); + sHalfWidthMap.put('\u30A7', "\uFF6A"); + sHalfWidthMap.put('\u30A8', "\uFF74"); + sHalfWidthMap.put('\u30A9', "\uFF6B"); + sHalfWidthMap.put('\u30AA', "\uFF75"); + sHalfWidthMap.put('\u30AB', "\uFF76"); + sHalfWidthMap.put('\u30AC', "\uFF76\uFF9E"); + sHalfWidthMap.put('\u30AD', "\uFF77"); + sHalfWidthMap.put('\u30AE', "\uFF77\uFF9E"); + sHalfWidthMap.put('\u30AF', "\uFF78"); + sHalfWidthMap.put('\u30B0', "\uFF78\uFF9E"); + sHalfWidthMap.put('\u30B1', "\uFF79"); + sHalfWidthMap.put('\u30B2', "\uFF79\uFF9E"); + sHalfWidthMap.put('\u30B3', "\uFF7A"); + sHalfWidthMap.put('\u30B4', "\uFF7A\uFF9E"); + sHalfWidthMap.put('\u30B5', "\uFF7B"); + sHalfWidthMap.put('\u30B6', "\uFF7B\uFF9E"); + sHalfWidthMap.put('\u30B7', "\uFF7C"); + sHalfWidthMap.put('\u30B8', "\uFF7C\uFF9E"); + sHalfWidthMap.put('\u30B9', "\uFF7D"); + sHalfWidthMap.put('\u30BA', "\uFF7D\uFF9E"); + sHalfWidthMap.put('\u30BB', "\uFF7E"); + sHalfWidthMap.put('\u30BC', "\uFF7E\uFF9E"); + sHalfWidthMap.put('\u30BD', "\uFF7F"); + sHalfWidthMap.put('\u30BE', "\uFF7F\uFF9E"); + sHalfWidthMap.put('\u30BF', "\uFF80"); + sHalfWidthMap.put('\u30C0', "\uFF80\uFF9E"); + sHalfWidthMap.put('\u30C1', "\uFF81"); + sHalfWidthMap.put('\u30C2', "\uFF81\uFF9E"); + sHalfWidthMap.put('\u30C3', "\uFF6F"); + sHalfWidthMap.put('\u30C4', "\uFF82"); + sHalfWidthMap.put('\u30C5', "\uFF82\uFF9E"); + sHalfWidthMap.put('\u30C6', "\uFF83"); + sHalfWidthMap.put('\u30C7', "\uFF83\uFF9E"); + sHalfWidthMap.put('\u30C8', "\uFF84"); + sHalfWidthMap.put('\u30C9', "\uFF84\uFF9E"); + sHalfWidthMap.put('\u30CA', "\uFF85"); + sHalfWidthMap.put('\u30CB', "\uFF86"); + sHalfWidthMap.put('\u30CC', "\uFF87"); + sHalfWidthMap.put('\u30CD', "\uFF88"); + sHalfWidthMap.put('\u30CE', "\uFF89"); + sHalfWidthMap.put('\u30CF', "\uFF8A"); + sHalfWidthMap.put('\u30D0', "\uFF8A\uFF9E"); + sHalfWidthMap.put('\u30D1', "\uFF8A\uFF9F"); + sHalfWidthMap.put('\u30D2', "\uFF8B"); + sHalfWidthMap.put('\u30D3', "\uFF8B\uFF9E"); + sHalfWidthMap.put('\u30D4', "\uFF8B\uFF9F"); + sHalfWidthMap.put('\u30D5', "\uFF8C"); + sHalfWidthMap.put('\u30D6', "\uFF8C\uFF9E"); + sHalfWidthMap.put('\u30D7', "\uFF8C\uFF9F"); + sHalfWidthMap.put('\u30D8', "\uFF8D"); + sHalfWidthMap.put('\u30D9', "\uFF8D\uFF9E"); + sHalfWidthMap.put('\u30DA', "\uFF8D\uFF9F"); + sHalfWidthMap.put('\u30DB', "\uFF8E"); + sHalfWidthMap.put('\u30DC', "\uFF8E\uFF9E"); + sHalfWidthMap.put('\u30DD', "\uFF8E\uFF9F"); + sHalfWidthMap.put('\u30DE', "\uFF8F"); + sHalfWidthMap.put('\u30DF', "\uFF90"); + sHalfWidthMap.put('\u30E0', "\uFF91"); + sHalfWidthMap.put('\u30E1', "\uFF92"); + sHalfWidthMap.put('\u30E2', "\uFF93"); + sHalfWidthMap.put('\u30E3', "\uFF6C"); + sHalfWidthMap.put('\u30E4', "\uFF94"); + sHalfWidthMap.put('\u30E5', "\uFF6D"); + sHalfWidthMap.put('\u30E6', "\uFF95"); + sHalfWidthMap.put('\u30E7', "\uFF6E"); + sHalfWidthMap.put('\u30E8', "\uFF96"); + sHalfWidthMap.put('\u30E9', "\uFF97"); + sHalfWidthMap.put('\u30EA', "\uFF98"); + sHalfWidthMap.put('\u30EB', "\uFF99"); + sHalfWidthMap.put('\u30EC', "\uFF9A"); + sHalfWidthMap.put('\u30ED', "\uFF9B"); + sHalfWidthMap.put('\u30EE', "\uFF9C"); + sHalfWidthMap.put('\u30EF', "\uFF9C"); + sHalfWidthMap.put('\u30F0', "\uFF72"); + sHalfWidthMap.put('\u30F1', "\uFF74"); + sHalfWidthMap.put('\u30F2', "\uFF66"); + sHalfWidthMap.put('\u30F3', "\uFF9D"); + sHalfWidthMap.put('\u30F4', "\uFF73\uFF9E"); + sHalfWidthMap.put('\u30F5', "\uFF76"); + sHalfWidthMap.put('\u30F6', "\uFF79"); + sHalfWidthMap.put('\u30FB', "\uFF65"); + sHalfWidthMap.put('\u30FC', "\uFF70"); + sHalfWidthMap.put('\uFF01', "!"); + sHalfWidthMap.put('\uFF02', "\""); + sHalfWidthMap.put('\uFF03', "#"); + sHalfWidthMap.put('\uFF04', "$"); + sHalfWidthMap.put('\uFF05', "%"); + sHalfWidthMap.put('\uFF06', "&"); + sHalfWidthMap.put('\uFF07', "'"); + sHalfWidthMap.put('\uFF08', "("); + sHalfWidthMap.put('\uFF09', ")"); + sHalfWidthMap.put('\uFF0A', "*"); + sHalfWidthMap.put('\uFF0B', "+"); + sHalfWidthMap.put('\uFF0C', ","); + sHalfWidthMap.put('\uFF0D', "-"); + sHalfWidthMap.put('\uFF0E', "."); + sHalfWidthMap.put('\uFF0F', "/"); + sHalfWidthMap.put('\uFF10', "0"); + sHalfWidthMap.put('\uFF11', "1"); + sHalfWidthMap.put('\uFF12', "2"); + sHalfWidthMap.put('\uFF13', "3"); + sHalfWidthMap.put('\uFF14', "4"); + sHalfWidthMap.put('\uFF15', "5"); + sHalfWidthMap.put('\uFF16', "6"); + sHalfWidthMap.put('\uFF17', "7"); + sHalfWidthMap.put('\uFF18', "8"); + sHalfWidthMap.put('\uFF19', "9"); + sHalfWidthMap.put('\uFF1A', ":"); + sHalfWidthMap.put('\uFF1B', ";"); + sHalfWidthMap.put('\uFF1C', "<"); + sHalfWidthMap.put('\uFF1D', "="); + sHalfWidthMap.put('\uFF1E', ">"); + sHalfWidthMap.put('\uFF1F', "?"); + sHalfWidthMap.put('\uFF20', "@"); + sHalfWidthMap.put('\uFF21', "A"); + sHalfWidthMap.put('\uFF22', "B"); + sHalfWidthMap.put('\uFF23', "C"); + sHalfWidthMap.put('\uFF24', "D"); + sHalfWidthMap.put('\uFF25', "E"); + sHalfWidthMap.put('\uFF26', "F"); + sHalfWidthMap.put('\uFF27', "G"); + sHalfWidthMap.put('\uFF28', "H"); + sHalfWidthMap.put('\uFF29', "I"); + sHalfWidthMap.put('\uFF2A', "J"); + sHalfWidthMap.put('\uFF2B', "K"); + sHalfWidthMap.put('\uFF2C', "L"); + sHalfWidthMap.put('\uFF2D', "M"); + sHalfWidthMap.put('\uFF2E', "N"); + sHalfWidthMap.put('\uFF2F', "O"); + sHalfWidthMap.put('\uFF30', "P"); + sHalfWidthMap.put('\uFF31', "Q"); + sHalfWidthMap.put('\uFF32', "R"); + sHalfWidthMap.put('\uFF33', "S"); + sHalfWidthMap.put('\uFF34', "T"); + sHalfWidthMap.put('\uFF35', "U"); + sHalfWidthMap.put('\uFF36', "V"); + sHalfWidthMap.put('\uFF37', "W"); + sHalfWidthMap.put('\uFF38', "X"); + sHalfWidthMap.put('\uFF39', "Y"); + sHalfWidthMap.put('\uFF3A', "Z"); + sHalfWidthMap.put('\uFF3B', "["); + sHalfWidthMap.put('\uFF3C', "\\"); + sHalfWidthMap.put('\uFF3D', "]"); + sHalfWidthMap.put('\uFF3E', "^"); + sHalfWidthMap.put('\uFF3F', "_"); + sHalfWidthMap.put('\uFF41', "a"); + sHalfWidthMap.put('\uFF42', "b"); + sHalfWidthMap.put('\uFF43', "c"); + sHalfWidthMap.put('\uFF44', "d"); + sHalfWidthMap.put('\uFF45', "e"); + sHalfWidthMap.put('\uFF46', "f"); + sHalfWidthMap.put('\uFF47', "g"); + sHalfWidthMap.put('\uFF48', "h"); + sHalfWidthMap.put('\uFF49', "i"); + sHalfWidthMap.put('\uFF4A', "j"); + sHalfWidthMap.put('\uFF4B', "k"); + sHalfWidthMap.put('\uFF4C', "l"); + sHalfWidthMap.put('\uFF4D', "m"); + sHalfWidthMap.put('\uFF4E', "n"); + sHalfWidthMap.put('\uFF4F', "o"); + sHalfWidthMap.put('\uFF50', "p"); + sHalfWidthMap.put('\uFF51', "q"); + sHalfWidthMap.put('\uFF52', "r"); + sHalfWidthMap.put('\uFF53', "s"); + sHalfWidthMap.put('\uFF54', "t"); + sHalfWidthMap.put('\uFF55', "u"); + sHalfWidthMap.put('\uFF56', "v"); + sHalfWidthMap.put('\uFF57', "w"); + sHalfWidthMap.put('\uFF58', "x"); + sHalfWidthMap.put('\uFF59', "y"); + sHalfWidthMap.put('\uFF5A', "z"); + sHalfWidthMap.put('\uFF5B', "{"); + sHalfWidthMap.put('\uFF5C', "|"); + sHalfWidthMap.put('\uFF5D', "}"); + sHalfWidthMap.put('\uFF5E', "~"); + sHalfWidthMap.put('\uFF61', "\uFF61"); + sHalfWidthMap.put('\uFF62', "\uFF62"); + sHalfWidthMap.put('\uFF63', "\uFF63"); + sHalfWidthMap.put('\uFF64', "\uFF64"); + sHalfWidthMap.put('\uFF65', "\uFF65"); + sHalfWidthMap.put('\uFF66', "\uFF66"); + sHalfWidthMap.put('\uFF67', "\uFF67"); + sHalfWidthMap.put('\uFF68', "\uFF68"); + sHalfWidthMap.put('\uFF69', "\uFF69"); + sHalfWidthMap.put('\uFF6A', "\uFF6A"); + sHalfWidthMap.put('\uFF6B', "\uFF6B"); + sHalfWidthMap.put('\uFF6C', "\uFF6C"); + sHalfWidthMap.put('\uFF6D', "\uFF6D"); + sHalfWidthMap.put('\uFF6E', "\uFF6E"); + sHalfWidthMap.put('\uFF6F', "\uFF6F"); + sHalfWidthMap.put('\uFF70', "\uFF70"); + sHalfWidthMap.put('\uFF71', "\uFF71"); + sHalfWidthMap.put('\uFF72', "\uFF72"); + sHalfWidthMap.put('\uFF73', "\uFF73"); + sHalfWidthMap.put('\uFF74', "\uFF74"); + sHalfWidthMap.put('\uFF75', "\uFF75"); + sHalfWidthMap.put('\uFF76', "\uFF76"); + sHalfWidthMap.put('\uFF77', "\uFF77"); + sHalfWidthMap.put('\uFF78', "\uFF78"); + sHalfWidthMap.put('\uFF79', "\uFF79"); + sHalfWidthMap.put('\uFF7A', "\uFF7A"); + sHalfWidthMap.put('\uFF7B', "\uFF7B"); + sHalfWidthMap.put('\uFF7C', "\uFF7C"); + sHalfWidthMap.put('\uFF7D', "\uFF7D"); + sHalfWidthMap.put('\uFF7E', "\uFF7E"); + sHalfWidthMap.put('\uFF7F', "\uFF7F"); + sHalfWidthMap.put('\uFF80', "\uFF80"); + sHalfWidthMap.put('\uFF81', "\uFF81"); + sHalfWidthMap.put('\uFF82', "\uFF82"); + sHalfWidthMap.put('\uFF83', "\uFF83"); + sHalfWidthMap.put('\uFF84', "\uFF84"); + sHalfWidthMap.put('\uFF85', "\uFF85"); + sHalfWidthMap.put('\uFF86', "\uFF86"); + sHalfWidthMap.put('\uFF87', "\uFF87"); + sHalfWidthMap.put('\uFF88', "\uFF88"); + sHalfWidthMap.put('\uFF89', "\uFF89"); + sHalfWidthMap.put('\uFF8A', "\uFF8A"); + sHalfWidthMap.put('\uFF8B', "\uFF8B"); + sHalfWidthMap.put('\uFF8C', "\uFF8C"); + sHalfWidthMap.put('\uFF8D', "\uFF8D"); + sHalfWidthMap.put('\uFF8E', "\uFF8E"); + sHalfWidthMap.put('\uFF8F', "\uFF8F"); + sHalfWidthMap.put('\uFF90', "\uFF90"); + sHalfWidthMap.put('\uFF91', "\uFF91"); + sHalfWidthMap.put('\uFF92', "\uFF92"); + sHalfWidthMap.put('\uFF93', "\uFF93"); + sHalfWidthMap.put('\uFF94', "\uFF94"); + sHalfWidthMap.put('\uFF95', "\uFF95"); + sHalfWidthMap.put('\uFF96', "\uFF96"); + sHalfWidthMap.put('\uFF97', "\uFF97"); + sHalfWidthMap.put('\uFF98', "\uFF98"); + sHalfWidthMap.put('\uFF99', "\uFF99"); + sHalfWidthMap.put('\uFF9A', "\uFF9A"); + sHalfWidthMap.put('\uFF9B', "\uFF9B"); + sHalfWidthMap.put('\uFF9C', "\uFF9C"); + sHalfWidthMap.put('\uFF9D', "\uFF9D"); + sHalfWidthMap.put('\uFF9E', "\uFF9E"); + sHalfWidthMap.put('\uFF9F', "\uFF9F"); + sHalfWidthMap.put('\uFFE5', "\u005C\u005C"); + } + + /** + * Returns half-width version of that character if possible. Returns null if not possible + * @param ch input character + * @return CharSequence object if the mapping for ch exists. Return null otherwise. + */ + public static String tryGetHalfWidthText(final char ch) { + if (sHalfWidthMap.containsKey(ch)) { + return sHalfWidthMap.get(ch); + } else { + return null; + } + } +} diff --git a/modules/vcard/java/com/android/vcard/VCardBuilder.java b/modules/vcard/java/com/android/vcard/VCardBuilder.java new file mode 100644 index 0000000..c938d04 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardBuilder.java @@ -0,0 +1,2213 @@ +/* + * 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.vcard; + +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.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.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; + +import com.android.vcard.VCardUtils.PhoneNumberUtilsPort; + +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + *

+ * The class which lets users create their own vCard String. Typical usage is as follows: + *

+ *
final VCardBuilder builder = new VCardBuilder(vcardType);
+ * builder.appendNameProperties(contentValuesListMap.get(StructuredName.CONTENT_ITEM_TYPE))
+ *     .appendNickNames(contentValuesListMap.get(Nickname.CONTENT_ITEM_TYPE))
+ *     .appendPhones(contentValuesListMap.get(Phone.CONTENT_ITEM_TYPE))
+ *     .appendEmails(contentValuesListMap.get(Email.CONTENT_ITEM_TYPE))
+ *     .appendPostals(contentValuesListMap.get(StructuredPostal.CONTENT_ITEM_TYPE))
+ *     .appendOrganizations(contentValuesListMap.get(Organization.CONTENT_ITEM_TYPE))
+ *     .appendWebsites(contentValuesListMap.get(Website.CONTENT_ITEM_TYPE))
+ *     .appendPhotos(contentValuesListMap.get(Photo.CONTENT_ITEM_TYPE))
+ *     .appendNotes(contentValuesListMap.get(Note.CONTENT_ITEM_TYPE))
+ *     .appendEvents(contentValuesListMap.get(Event.CONTENT_ITEM_TYPE))
+ *     .appendIms(contentValuesListMap.get(Im.CONTENT_ITEM_TYPE))
+ *     .appendRelation(contentValuesListMap.get(Relation.CONTENT_ITEM_TYPE));
+ * return builder.toString();
+ */ +public class VCardBuilder { + private static final String LOG_TAG = VCardConstants.LOG_TAG; + + // If you add the other element, please check all the columns are able to be + // converted to String. + // + // e.g. BLOB is not what we can handle here now. + private static final Set sAllowedAndroidPropertySet = + Collections.unmodifiableSet(new HashSet(Arrays.asList( + Nickname.CONTENT_ITEM_TYPE, Event.CONTENT_ITEM_TYPE, + Relation.CONTENT_ITEM_TYPE))); + + public static final int DEFAULT_PHONE_TYPE = Phone.TYPE_HOME; + public static final int DEFAULT_POSTAL_TYPE = StructuredPostal.TYPE_HOME; + public static final int DEFAULT_EMAIL_TYPE = Email.TYPE_OTHER; + + private static final String VCARD_DATA_VCARD = "VCARD"; + private static final String VCARD_DATA_PUBLIC = "PUBLIC"; + + private static final String VCARD_PARAM_SEPARATOR = ";"; + public static final String VCARD_END_OF_LINE = "\r\n"; + private static final String VCARD_DATA_SEPARATOR = ":"; + private static final String VCARD_ITEM_SEPARATOR = ";"; + private static final String VCARD_WS = " "; + private static final String VCARD_PARAM_EQUAL = "="; + + private static final String VCARD_PARAM_ENCODING_QP = + "ENCODING=" + VCardConstants.PARAM_ENCODING_QP; + private static final String VCARD_PARAM_ENCODING_BASE64_V21 = + "ENCODING=" + VCardConstants.PARAM_ENCODING_BASE64; + private static final String VCARD_PARAM_ENCODING_BASE64_AS_B = + "ENCODING=" + VCardConstants.PARAM_ENCODING_B; + + private static final String SHIFT_JIS = "SHIFT_JIS"; + + private final int mVCardType; + + private final boolean mIsV30OrV40; + private final boolean mIsJapaneseMobilePhone; + private final boolean mOnlyOneNoteFieldIsAvailable; + private final boolean mIsDoCoMo; + private final boolean mShouldUseQuotedPrintable; + private final boolean mUsesAndroidProperty; + private final boolean mUsesDefactProperty; + private final boolean mAppendTypeParamName; + private final boolean mRefrainsQPToNameProperties; + private final boolean mNeedsToConvertPhoneticString; + + private final boolean mShouldAppendCharsetParam; + + private final String mCharset; + private final String mVCardCharsetParameter; + + private StringBuilder mBuilder; + private boolean mEndAppended; + + public VCardBuilder(final int vcardType) { + // Default charset should be used + this(vcardType, null); + } + + /** + * @param vcardType + * @param charset If null, we use default charset for export. + * @hide + */ + public VCardBuilder(final int vcardType, String charset) { + mVCardType = vcardType; + + if (VCardConfig.isVersion40(vcardType)) { + Log.w(LOG_TAG, "Should not use vCard 4.0 when building vCard. " + + "It is not officially published yet."); + } + + mIsV30OrV40 = VCardConfig.isVersion30(vcardType) || VCardConfig.isVersion40(vcardType); + mShouldUseQuotedPrintable = VCardConfig.shouldUseQuotedPrintable(vcardType); + mIsDoCoMo = VCardConfig.isDoCoMo(vcardType); + mIsJapaneseMobilePhone = VCardConfig.needsToConvertPhoneticString(vcardType); + mOnlyOneNoteFieldIsAvailable = VCardConfig.onlyOneNoteFieldIsAvailable(vcardType); + mUsesAndroidProperty = VCardConfig.usesAndroidSpecificProperty(vcardType); + mUsesDefactProperty = VCardConfig.usesDefactProperty(vcardType); + mRefrainsQPToNameProperties = VCardConfig.shouldRefrainQPToNameProperties(vcardType); + mAppendTypeParamName = VCardConfig.appendTypeParamName(vcardType); + mNeedsToConvertPhoneticString = VCardConfig.needsToConvertPhoneticString(vcardType); + + // vCard 2.1 requires charset. + // vCard 3.0 does not allow it but we found some devices use it to determine + // the exact charset. + // We currently append it only when charset other than UTF_8 is used. + mShouldAppendCharsetParam = + !(VCardConfig.isVersion30(vcardType) && "UTF-8".equalsIgnoreCase(charset)); + + if (VCardConfig.isDoCoMo(vcardType)) { + if (!SHIFT_JIS.equalsIgnoreCase(charset)) { + /* Log.w(LOG_TAG, + "The charset \"" + charset + "\" is used while " + + SHIFT_JIS + " is needed to be used."); */ + if (TextUtils.isEmpty(charset)) { + mCharset = SHIFT_JIS; + } else { + mCharset = charset; + } + } else { + mCharset = charset; + } + mVCardCharsetParameter = "CHARSET=" + SHIFT_JIS; + } else { + if (TextUtils.isEmpty(charset)) { + Log.i(LOG_TAG, + "Use the charset \"" + VCardConfig.DEFAULT_EXPORT_CHARSET + + "\" for export."); + mCharset = VCardConfig.DEFAULT_EXPORT_CHARSET; + mVCardCharsetParameter = "CHARSET=" + VCardConfig.DEFAULT_EXPORT_CHARSET; + } else { + mCharset = charset; + mVCardCharsetParameter = "CHARSET=" + charset; + } + } + clear(); + } + + public void clear() { + mBuilder = new StringBuilder(); + mEndAppended = false; + appendLine(VCardConstants.PROPERTY_BEGIN, VCARD_DATA_VCARD); + if (VCardConfig.isVersion40(mVCardType)) { + appendLine(VCardConstants.PROPERTY_VERSION, VCardConstants.VERSION_V40); + } else if (VCardConfig.isVersion30(mVCardType)) { + appendLine(VCardConstants.PROPERTY_VERSION, VCardConstants.VERSION_V30); + } else { + if (!VCardConfig.isVersion21(mVCardType)) { + Log.w(LOG_TAG, "Unknown vCard version detected."); + } + appendLine(VCardConstants.PROPERTY_VERSION, VCardConstants.VERSION_V21); + } + } + + private boolean containsNonEmptyName(final ContentValues contentValues) { + final String familyName = contentValues.getAsString(StructuredName.FAMILY_NAME); + final String middleName = contentValues.getAsString(StructuredName.MIDDLE_NAME); + final String givenName = contentValues.getAsString(StructuredName.GIVEN_NAME); + final String prefix = contentValues.getAsString(StructuredName.PREFIX); + final String suffix = contentValues.getAsString(StructuredName.SUFFIX); + final String phoneticFamilyName = + contentValues.getAsString(StructuredName.PHONETIC_FAMILY_NAME); + final String phoneticMiddleName = + contentValues.getAsString(StructuredName.PHONETIC_MIDDLE_NAME); + final String phoneticGivenName = + contentValues.getAsString(StructuredName.PHONETIC_GIVEN_NAME); + final String displayName = contentValues.getAsString(StructuredName.DISPLAY_NAME); + return !(TextUtils.isEmpty(familyName) && TextUtils.isEmpty(middleName) && + TextUtils.isEmpty(givenName) && TextUtils.isEmpty(prefix) && + TextUtils.isEmpty(suffix) && TextUtils.isEmpty(phoneticFamilyName) && + TextUtils.isEmpty(phoneticMiddleName) && TextUtils.isEmpty(phoneticGivenName) && + TextUtils.isEmpty(displayName)); + } + + private ContentValues getPrimaryContentValueWithStructuredName( + final List contentValuesList) { + ContentValues primaryContentValues = null; + ContentValues subprimaryContentValues = null; + for (ContentValues contentValues : contentValuesList) { + if (contentValues == null){ + continue; + } + Integer isSuperPrimary = contentValues.getAsInteger(StructuredName.IS_SUPER_PRIMARY); + if (isSuperPrimary != null && isSuperPrimary > 0) { + // We choose "super primary" ContentValues. + primaryContentValues = contentValues; + break; + } else if (primaryContentValues == null) { + // We choose the first "primary" ContentValues + // if "super primary" ContentValues does not exist. + final Integer isPrimary = contentValues.getAsInteger(StructuredName.IS_PRIMARY); + if (isPrimary != null && isPrimary > 0 && + containsNonEmptyName(contentValues)) { + primaryContentValues = contentValues; + // Do not break, since there may be ContentValues with "super primary" + // afterword. + } else if (subprimaryContentValues == null && + containsNonEmptyName(contentValues)) { + subprimaryContentValues = contentValues; + } + } + } + + if (primaryContentValues == null) { + if (subprimaryContentValues != null) { + // We choose the first ContentValues if any "primary" ContentValues does not exist. + primaryContentValues = subprimaryContentValues; + } else { + // There's no appropriate ContentValue with StructuredName. + primaryContentValues = new ContentValues(); + } + } + + return primaryContentValues; + } + + /** + * To avoid unnecessary complication in logic, we use this method to construct N, FN + * properties for vCard 4.0. + */ + private VCardBuilder appendNamePropertiesV40(final List contentValuesList) { + if (mIsDoCoMo || mNeedsToConvertPhoneticString) { + // Ignore all flags that look stale from the view of vCard 4.0 to + // simplify construction algorithm. Actually we don't have any vCard file + // available from real world yet, so we may need to re-enable some of these + // in the future. + Log.w(LOG_TAG, "Invalid flag is used in vCard 4.0 construction. Ignored."); + } + + if (contentValuesList == null || contentValuesList.isEmpty()) { + appendLine(VCardConstants.PROPERTY_FN, ""); + return this; + } + + // We have difficulty here. How can we appropriately handle StructuredName with + // missing parts necessary for displaying while it has suppremental information. + // + // e.g. How to handle non-empty phonetic names with empty structured names? + + final ContentValues contentValues = + getPrimaryContentValueWithStructuredName(contentValuesList); + String familyName = contentValues.getAsString(StructuredName.FAMILY_NAME); + final String middleName = contentValues.getAsString(StructuredName.MIDDLE_NAME); + final String givenName = contentValues.getAsString(StructuredName.GIVEN_NAME); + final String prefix = contentValues.getAsString(StructuredName.PREFIX); + final String suffix = contentValues.getAsString(StructuredName.SUFFIX); + final String formattedName = contentValues.getAsString(StructuredName.DISPLAY_NAME); + if (TextUtils.isEmpty(familyName) + && TextUtils.isEmpty(givenName) + && TextUtils.isEmpty(middleName) + && TextUtils.isEmpty(prefix) + && TextUtils.isEmpty(suffix)) { + if (TextUtils.isEmpty(formattedName)) { + appendLine(VCardConstants.PROPERTY_FN, ""); + return this; + } + familyName = formattedName; + } + + final String phoneticFamilyName = + contentValues.getAsString(StructuredName.PHONETIC_FAMILY_NAME); + final String phoneticMiddleName = + contentValues.getAsString(StructuredName.PHONETIC_MIDDLE_NAME); + final String phoneticGivenName = + contentValues.getAsString(StructuredName.PHONETIC_GIVEN_NAME); + final String escapedFamily = escapeCharacters(familyName); + final String escapedGiven = escapeCharacters(givenName); + final String escapedMiddle = escapeCharacters(middleName); + final String escapedPrefix = escapeCharacters(prefix); + final String escapedSuffix = escapeCharacters(suffix); + + mBuilder.append(VCardConstants.PROPERTY_N); + + if (!(TextUtils.isEmpty(phoneticFamilyName) && + TextUtils.isEmpty(phoneticMiddleName) && + TextUtils.isEmpty(phoneticGivenName))) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + final String sortAs = escapeCharacters(phoneticFamilyName) + + ';' + escapeCharacters(phoneticGivenName) + + ';' + escapeCharacters(phoneticMiddleName); + mBuilder.append("SORT-AS=").append( + VCardUtils.toStringAsV40ParamValue(sortAs)); + } + + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(escapedFamily); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(escapedGiven); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(escapedMiddle); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(escapedPrefix); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(escapedSuffix); + mBuilder.append(VCARD_END_OF_LINE); + + if (TextUtils.isEmpty(formattedName)) { + // Note: + // DISPLAY_NAME doesn't exist while some other elements do, which is usually + // weird in Android, as DISPLAY_NAME should (usually) be constructed + // from the others using locale information and its code points. + Log.w(LOG_TAG, "DISPLAY_NAME is empty."); + + final String escaped = escapeCharacters(VCardUtils.constructNameFromElements( + VCardConfig.getNameOrderType(mVCardType), + familyName, middleName, givenName, prefix, suffix)); + appendLine(VCardConstants.PROPERTY_FN, escaped); + } else { + final String escapedFormatted = escapeCharacters(formattedName); + mBuilder.append(VCardConstants.PROPERTY_FN); + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(escapedFormatted); + mBuilder.append(VCARD_END_OF_LINE); + } + + // We may need X- properties for phonetic names. + appendPhoneticNameFields(contentValues); + return this; + } + + /** + * For safety, we'll emit just one value around StructuredName, as external importers + * may get confused with multiple "N", "FN", etc. properties, though it is valid in + * vCard spec. + */ + public VCardBuilder appendNameProperties(final List contentValuesList) { + if (VCardConfig.isVersion40(mVCardType)) { + return appendNamePropertiesV40(contentValuesList); + } + + if (contentValuesList == null || contentValuesList.isEmpty()) { + if (VCardConfig.isVersion30(mVCardType)) { + // vCard 3.0 requires "N" and "FN" properties. + // vCard 4.0 does NOT require N, but we take care of possible backward + // compatibility issues. + appendLine(VCardConstants.PROPERTY_N, ""); + appendLine(VCardConstants.PROPERTY_FN, ""); + } else if (mIsDoCoMo) { + appendLine(VCardConstants.PROPERTY_N, ""); + } + return this; + } + + final ContentValues contentValues = + getPrimaryContentValueWithStructuredName(contentValuesList); + final String familyName = contentValues.getAsString(StructuredName.FAMILY_NAME); + final String middleName = contentValues.getAsString(StructuredName.MIDDLE_NAME); + final String givenName = contentValues.getAsString(StructuredName.GIVEN_NAME); + final String prefix = contentValues.getAsString(StructuredName.PREFIX); + final String suffix = contentValues.getAsString(StructuredName.SUFFIX); + final String displayName = contentValues.getAsString(StructuredName.DISPLAY_NAME); + + if (!TextUtils.isEmpty(familyName) || !TextUtils.isEmpty(givenName)) { + final boolean reallyAppendCharsetParameterToName = + shouldAppendCharsetParam(familyName, givenName, middleName, prefix, suffix); + final boolean reallyUseQuotedPrintableToName = + (!mRefrainsQPToNameProperties && + !(VCardUtils.containsOnlyNonCrLfPrintableAscii(familyName) && + VCardUtils.containsOnlyNonCrLfPrintableAscii(givenName) && + VCardUtils.containsOnlyNonCrLfPrintableAscii(middleName) && + VCardUtils.containsOnlyNonCrLfPrintableAscii(prefix) && + VCardUtils.containsOnlyNonCrLfPrintableAscii(suffix))); + + final String formattedName; + if (!TextUtils.isEmpty(displayName)) { + formattedName = displayName; + } else { + formattedName = VCardUtils.constructNameFromElements( + VCardConfig.getNameOrderType(mVCardType), + familyName, middleName, givenName, prefix, suffix); + } + final boolean reallyAppendCharsetParameterToFN = + shouldAppendCharsetParam(formattedName); + final boolean reallyUseQuotedPrintableToFN = + !mRefrainsQPToNameProperties && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(formattedName); + + final String encodedFamily; + final String encodedGiven; + final String encodedMiddle; + final String encodedPrefix; + final String encodedSuffix; + if (reallyUseQuotedPrintableToName) { + encodedFamily = encodeQuotedPrintable(familyName); + encodedGiven = encodeQuotedPrintable(givenName); + encodedMiddle = encodeQuotedPrintable(middleName); + encodedPrefix = encodeQuotedPrintable(prefix); + encodedSuffix = encodeQuotedPrintable(suffix); + } else { + encodedFamily = escapeCharacters(familyName); + encodedGiven = escapeCharacters(givenName); + encodedMiddle = escapeCharacters(middleName); + encodedPrefix = escapeCharacters(prefix); + encodedSuffix = escapeCharacters(suffix); + } + + final String encodedFormattedname = + (reallyUseQuotedPrintableToFN ? + encodeQuotedPrintable(formattedName) : escapeCharacters(formattedName)); + + mBuilder.append(VCardConstants.PROPERTY_N); + if (mIsDoCoMo) { + if (reallyAppendCharsetParameterToName) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + if (reallyUseQuotedPrintableToName) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCARD_PARAM_ENCODING_QP); + } + mBuilder.append(VCARD_DATA_SEPARATOR); + // DoCoMo phones require that all the elements in the "family name" field. + mBuilder.append(formattedName); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(VCARD_ITEM_SEPARATOR); + } else { + if (reallyAppendCharsetParameterToName) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + if (reallyUseQuotedPrintableToName) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCARD_PARAM_ENCODING_QP); + } + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(encodedFamily); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(encodedGiven); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(encodedMiddle); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(encodedPrefix); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(encodedSuffix); + } + mBuilder.append(VCARD_END_OF_LINE); + + // FN property + mBuilder.append(VCardConstants.PROPERTY_FN); + if (reallyAppendCharsetParameterToFN) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + if (reallyUseQuotedPrintableToFN) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCARD_PARAM_ENCODING_QP); + } + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(encodedFormattedname); + mBuilder.append(VCARD_END_OF_LINE); + } else if (!TextUtils.isEmpty(displayName)) { + + // N + buildSinglePartNameField(VCardConstants.PROPERTY_N, displayName); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(VCARD_END_OF_LINE); + + // FN + buildSinglePartNameField(VCardConstants.PROPERTY_FN, displayName); + mBuilder.append(VCARD_END_OF_LINE); + + } else if (VCardConfig.isVersion30(mVCardType)) { + appendLine(VCardConstants.PROPERTY_N, ""); + appendLine(VCardConstants.PROPERTY_FN, ""); + } else if (mIsDoCoMo) { + appendLine(VCardConstants.PROPERTY_N, ""); + } + + appendPhoneticNameFields(contentValues); + return this; + } + + private void buildSinglePartNameField(String property, String part) { + final boolean reallyUseQuotedPrintable = + (!mRefrainsQPToNameProperties && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(part)); + final String encodedPart = reallyUseQuotedPrintable ? + encodeQuotedPrintable(part) : + escapeCharacters(part); + + mBuilder.append(property); + + // Note: "CHARSET" param is not allowed in vCard 3.0, but we may add it + // when it would be useful or necessary for external importers, + // assuming the external importer allows this vioration of the spec. + if (shouldAppendCharsetParam(part)) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + if (reallyUseQuotedPrintable) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCARD_PARAM_ENCODING_QP); + } + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(encodedPart); + } + + /** + * Emits SOUND;IRMC, SORT-STRING, and de-fact values for phonetic names like X-PHONETIC-FAMILY. + */ + private void appendPhoneticNameFields(final ContentValues contentValues) { + final String phoneticFamilyName; + final String phoneticMiddleName; + final String phoneticGivenName; + { + final String tmpPhoneticFamilyName = + contentValues.getAsString(StructuredName.PHONETIC_FAMILY_NAME); + final String tmpPhoneticMiddleName = + contentValues.getAsString(StructuredName.PHONETIC_MIDDLE_NAME); + final String tmpPhoneticGivenName = + contentValues.getAsString(StructuredName.PHONETIC_GIVEN_NAME); + if (mNeedsToConvertPhoneticString) { + phoneticFamilyName = VCardUtils.toHalfWidthString(tmpPhoneticFamilyName); + phoneticMiddleName = VCardUtils.toHalfWidthString(tmpPhoneticMiddleName); + phoneticGivenName = VCardUtils.toHalfWidthString(tmpPhoneticGivenName); + } else { + phoneticFamilyName = tmpPhoneticFamilyName; + phoneticMiddleName = tmpPhoneticMiddleName; + phoneticGivenName = tmpPhoneticGivenName; + } + } + + if (TextUtils.isEmpty(phoneticFamilyName) + && TextUtils.isEmpty(phoneticMiddleName) + && TextUtils.isEmpty(phoneticGivenName)) { + if (mIsDoCoMo) { + mBuilder.append(VCardConstants.PROPERTY_SOUND); + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCardConstants.PARAM_TYPE_X_IRMC_N); + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(VCARD_END_OF_LINE); + } + return; + } + + if (VCardConfig.isVersion40(mVCardType)) { + // We don't want SORT-STRING anyway. + } else if (VCardConfig.isVersion30(mVCardType)) { + final String sortString = + VCardUtils.constructNameFromElements(mVCardType, + phoneticFamilyName, phoneticMiddleName, phoneticGivenName); + mBuilder.append(VCardConstants.PROPERTY_SORT_STRING); + if (VCardConfig.isVersion30(mVCardType) && shouldAppendCharsetParam(sortString)) { + // vCard 3.0 does not force us to use UTF-8 and actually we see some + // programs which emit this value. It is incorrect from the view of + // specification, but actually necessary for parsing vCard with non-UTF-8 + // charsets, expecting other parsers not get confused with this value. + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(escapeCharacters(sortString)); + mBuilder.append(VCARD_END_OF_LINE); + } else if (mIsJapaneseMobilePhone) { + // Note: There is no appropriate property for expressing + // phonetic name (Yomigana in Japanese) in vCard 2.1, while there is in + // vCard 3.0 (SORT-STRING). + // We use DoCoMo's way when the device is Japanese one since it is already + // supported by a lot of Japanese mobile phones. + // This is "X-" property, so any parser hopefully would not get + // confused with this. + // + // Also, DoCoMo's specification requires vCard composer to use just the first + // column. + // i.e. + // good: SOUND;X-IRMC-N:Miyakawa Daisuke;;;; + // bad : SOUND;X-IRMC-N:Miyakawa;Daisuke;;; + mBuilder.append(VCardConstants.PROPERTY_SOUND); + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCardConstants.PARAM_TYPE_X_IRMC_N); + + boolean reallyUseQuotedPrintable = + (!mRefrainsQPToNameProperties + && !(VCardUtils.containsOnlyNonCrLfPrintableAscii( + phoneticFamilyName) + && VCardUtils.containsOnlyNonCrLfPrintableAscii( + phoneticMiddleName) + && VCardUtils.containsOnlyNonCrLfPrintableAscii( + phoneticGivenName))); + + final String encodedPhoneticFamilyName; + final String encodedPhoneticMiddleName; + final String encodedPhoneticGivenName; + if (reallyUseQuotedPrintable) { + encodedPhoneticFamilyName = encodeQuotedPrintable(phoneticFamilyName); + encodedPhoneticMiddleName = encodeQuotedPrintable(phoneticMiddleName); + encodedPhoneticGivenName = encodeQuotedPrintable(phoneticGivenName); + } else { + encodedPhoneticFamilyName = escapeCharacters(phoneticFamilyName); + encodedPhoneticMiddleName = escapeCharacters(phoneticMiddleName); + encodedPhoneticGivenName = escapeCharacters(phoneticGivenName); + } + + if (shouldAppendCharsetParam(encodedPhoneticFamilyName, + encodedPhoneticMiddleName, encodedPhoneticGivenName)) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + mBuilder.append(VCARD_DATA_SEPARATOR); + { + boolean first = true; + if (!TextUtils.isEmpty(encodedPhoneticFamilyName)) { + mBuilder.append(encodedPhoneticFamilyName); + first = false; + } + if (!TextUtils.isEmpty(encodedPhoneticMiddleName)) { + if (first) { + first = false; + } else { + mBuilder.append(' '); + } + mBuilder.append(encodedPhoneticMiddleName); + } + if (!TextUtils.isEmpty(encodedPhoneticGivenName)) { + if (!first) { + mBuilder.append(' '); + } + mBuilder.append(encodedPhoneticGivenName); + } + } + mBuilder.append(VCARD_ITEM_SEPARATOR); // family;given + mBuilder.append(VCARD_ITEM_SEPARATOR); // given;middle + mBuilder.append(VCARD_ITEM_SEPARATOR); // middle;prefix + mBuilder.append(VCARD_ITEM_SEPARATOR); // prefix;suffix + mBuilder.append(VCARD_END_OF_LINE); + } + + if (mUsesDefactProperty) { + if (!TextUtils.isEmpty(phoneticGivenName)) { + final boolean reallyUseQuotedPrintable = + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(phoneticGivenName)); + final String encodedPhoneticGivenName; + if (reallyUseQuotedPrintable) { + encodedPhoneticGivenName = encodeQuotedPrintable(phoneticGivenName); + } else { + encodedPhoneticGivenName = escapeCharacters(phoneticGivenName); + } + mBuilder.append(VCardConstants.PROPERTY_X_PHONETIC_FIRST_NAME); + if (shouldAppendCharsetParam(phoneticGivenName)) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + if (reallyUseQuotedPrintable) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCARD_PARAM_ENCODING_QP); + } + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(encodedPhoneticGivenName); + mBuilder.append(VCARD_END_OF_LINE); + } // if (!TextUtils.isEmpty(phoneticGivenName)) + if (!TextUtils.isEmpty(phoneticMiddleName)) { + final boolean reallyUseQuotedPrintable = + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(phoneticMiddleName)); + final String encodedPhoneticMiddleName; + if (reallyUseQuotedPrintable) { + encodedPhoneticMiddleName = encodeQuotedPrintable(phoneticMiddleName); + } else { + encodedPhoneticMiddleName = escapeCharacters(phoneticMiddleName); + } + mBuilder.append(VCardConstants.PROPERTY_X_PHONETIC_MIDDLE_NAME); + if (shouldAppendCharsetParam(phoneticMiddleName)) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + if (reallyUseQuotedPrintable) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCARD_PARAM_ENCODING_QP); + } + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(encodedPhoneticMiddleName); + mBuilder.append(VCARD_END_OF_LINE); + } // if (!TextUtils.isEmpty(phoneticGivenName)) + if (!TextUtils.isEmpty(phoneticFamilyName)) { + final boolean reallyUseQuotedPrintable = + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(phoneticFamilyName)); + final String encodedPhoneticFamilyName; + if (reallyUseQuotedPrintable) { + encodedPhoneticFamilyName = encodeQuotedPrintable(phoneticFamilyName); + } else { + encodedPhoneticFamilyName = escapeCharacters(phoneticFamilyName); + } + mBuilder.append(VCardConstants.PROPERTY_X_PHONETIC_LAST_NAME); + if (shouldAppendCharsetParam(phoneticFamilyName)) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + if (reallyUseQuotedPrintable) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCARD_PARAM_ENCODING_QP); + } + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(encodedPhoneticFamilyName); + mBuilder.append(VCARD_END_OF_LINE); + } // if (!TextUtils.isEmpty(phoneticFamilyName)) + } + } + + public VCardBuilder appendNickNames(final List contentValuesList) { + final boolean useAndroidProperty; + if (mIsV30OrV40) { // These specifications have NICKNAME property. + useAndroidProperty = false; + } else if (mUsesAndroidProperty) { + useAndroidProperty = true; + } else { + // There's no way to add this field. + return this; + } + if (contentValuesList != null) { + for (ContentValues contentValues : contentValuesList) { + final String nickname = contentValues.getAsString(Nickname.NAME); + if (TextUtils.isEmpty(nickname)) { + continue; + } + if (useAndroidProperty) { + appendAndroidSpecificProperty(Nickname.CONTENT_ITEM_TYPE, contentValues); + } else { + appendLineWithCharsetAndQPDetection(VCardConstants.PROPERTY_NICKNAME, nickname); + } + } + } + return this; + } + + public VCardBuilder appendPhones(final List contentValuesList, + VCardPhoneNumberTranslationCallback translationCallback) { + boolean phoneLineExists = false; + if (contentValuesList != null) { + Set phoneSet = new HashSet(); + for (ContentValues contentValues : contentValuesList) { + final Integer typeAsObject = contentValues.getAsInteger(Phone.TYPE); + final String label = contentValues.getAsString(Phone.LABEL); + final Integer isPrimaryAsInteger = contentValues.getAsInteger(Phone.IS_PRIMARY); + final boolean isPrimary = (isPrimaryAsInteger != null ? + (isPrimaryAsInteger > 0) : false); + String phoneNumber = contentValues.getAsString(Phone.NUMBER); + if (phoneNumber != null) { + phoneNumber = phoneNumber.trim(); + } + if (TextUtils.isEmpty(phoneNumber)) { + continue; + } + + final int type = (typeAsObject != null ? typeAsObject : DEFAULT_PHONE_TYPE); + // Note: We prioritize this callback over FLAG_REFRAIN_PHONE_NUMBER_FORMATTING + // intentionally. In the future the flag will be replaced by callback + // mechanism entirely. + if (translationCallback != null) { + phoneNumber = translationCallback.onValueReceived( + phoneNumber, type, label, isPrimary); + if (!phoneSet.contains(phoneNumber)) { + phoneSet.add(phoneNumber); + appendTelLine(type, label, phoneNumber, isPrimary); + } + } else if (type == Phone.TYPE_PAGER || + VCardConfig.refrainPhoneNumberFormatting(mVCardType)) { + // Note: PAGER number needs unformatted "phone number". + phoneLineExists = true; + if (!phoneSet.contains(phoneNumber)) { + phoneSet.add(phoneNumber); + appendTelLine(type, label, phoneNumber, isPrimary); + } + } else { + final List phoneNumberList = splitPhoneNumbers(phoneNumber); + if (phoneNumberList.isEmpty()) { + continue; + } + phoneLineExists = true; + for (String actualPhoneNumber : phoneNumberList) { + if (!phoneSet.contains(actualPhoneNumber)) { + // 'p' and 'w' are the standard characters for pause and wait + // (see RFC 3601) + // so use those when exporting phone numbers via vCard. + String numberWithControlSequence = actualPhoneNumber + .replace(PhoneNumberUtils.PAUSE, 'p') + .replace(PhoneNumberUtils.WAIT, 'w'); + String formatted; + // TODO: remove this code and relevant test cases. vCard and any other + // codes using it shouldn't rely on the formatter here. + if (TextUtils.equals(numberWithControlSequence, actualPhoneNumber)) { + StringBuilder digitsOnlyBuilder = new StringBuilder(); + final int length = actualPhoneNumber.length(); + for (int i = 0; i < length; i++) { + final char ch = actualPhoneNumber.charAt(i); + if (Character.isDigit(ch) || ch == '+') { + digitsOnlyBuilder.append(ch); + } + } + final int phoneFormat = + VCardUtils.getPhoneNumberFormat(mVCardType); + formatted = PhoneNumberUtilsPort.formatNumber( + digitsOnlyBuilder.toString(), phoneFormat); + } else { + // Be conservative. + formatted = numberWithControlSequence; + } + + // In vCard 4.0, value type must be "a single URI value", + // not just a phone number. (Based on vCard 4.0 rev.13) + if (VCardConfig.isVersion40(mVCardType) + && !TextUtils.isEmpty(formatted) + && !formatted.startsWith("tel:")) { + formatted = "tel:" + formatted; + } + + // Pre-formatted string should be stored. + phoneSet.add(actualPhoneNumber); + appendTelLine(type, label, formatted, isPrimary); + } + } // for (String actualPhoneNumber : phoneNumberList) { + + // TODO: TEL with SIP URI? + } + } + } + + if (!phoneLineExists && mIsDoCoMo) { + appendTelLine(Phone.TYPE_HOME, "", "", false); + } + + return this; + } + + /** + *

+ * Splits a given string expressing phone numbers into several strings, and remove + * unnecessary characters inside them. The size of a returned list becomes 1 when + * no split is needed. + *

+ *

+ * The given number "may" have several phone numbers when the contact entry is corrupted + * because of its original source. + * e.g. "111-222-3333 (Miami)\n444-555-6666 (Broward; 305-653-6796 (Miami)" + *

+ *

+ * This kind of "phone numbers" will not be created with Android vCard implementation, + * but we may encounter them if the source of the input data has already corrupted + * implementation. + *

+ *

+ * To handle this case, this method first splits its input into multiple parts + * (e.g. "111-222-3333 (Miami)", "444-555-6666 (Broward", and 305653-6796 (Miami)") and + * removes unnecessary strings like "(Miami)". + *

+ *

+ * Do not call this method when trimming is inappropriate for its receivers. + *

+ */ + private List splitPhoneNumbers(final String phoneNumber) { + final List phoneList = new ArrayList(); + + StringBuilder builder = new StringBuilder(); + final int length = phoneNumber.length(); + for (int i = 0; i < length; i++) { + final char ch = phoneNumber.charAt(i); + if (ch == '\n' && builder.length() > 0) { + phoneList.add(builder.toString()); + builder = new StringBuilder(); + } else { + builder.append(ch); + } + } + if (builder.length() > 0) { + phoneList.add(builder.toString()); + } + return phoneList; + } + + public VCardBuilder appendEmails(final List contentValuesList) { + boolean emailAddressExists = false; + if (contentValuesList != null) { + final Set addressSet = new HashSet(); + for (ContentValues contentValues : contentValuesList) { + String emailAddress = contentValues.getAsString(Email.DATA); + if (emailAddress != null) { + emailAddress = emailAddress.trim(); + } + if (TextUtils.isEmpty(emailAddress)) { + continue; + } + Integer typeAsObject = contentValues.getAsInteger(Email.TYPE); + final int type = (typeAsObject != null ? + typeAsObject : DEFAULT_EMAIL_TYPE); + final String label = contentValues.getAsString(Email.LABEL); + Integer isPrimaryAsInteger = contentValues.getAsInteger(Email.IS_PRIMARY); + final boolean isPrimary = (isPrimaryAsInteger != null ? + (isPrimaryAsInteger > 0) : false); + emailAddressExists = true; + if (!addressSet.contains(emailAddress)) { + addressSet.add(emailAddress); + appendEmailLine(type, label, emailAddress, isPrimary); + } + } + } + + if (!emailAddressExists && mIsDoCoMo) { + appendEmailLine(Email.TYPE_HOME, "", "", false); + } + + return this; + } + + public VCardBuilder appendPostals(final List contentValuesList) { + if (contentValuesList == null || contentValuesList.isEmpty()) { + if (mIsDoCoMo) { + mBuilder.append(VCardConstants.PROPERTY_ADR); + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCardConstants.PARAM_TYPE_HOME); + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(VCARD_END_OF_LINE); + } + } else { + if (mIsDoCoMo) { + appendPostalsForDoCoMo(contentValuesList); + } else { + appendPostalsForGeneric(contentValuesList); + } + } + + return this; + } + + private static final Map sPostalTypePriorityMap; + + static { + sPostalTypePriorityMap = new HashMap(); + sPostalTypePriorityMap.put(StructuredPostal.TYPE_HOME, 0); + sPostalTypePriorityMap.put(StructuredPostal.TYPE_WORK, 1); + sPostalTypePriorityMap.put(StructuredPostal.TYPE_OTHER, 2); + sPostalTypePriorityMap.put(StructuredPostal.TYPE_CUSTOM, 3); + } + + /** + * Tries to append just one line. If there's no appropriate address + * information, append an empty line. + */ + private void appendPostalsForDoCoMo(final List contentValuesList) { + int currentPriority = Integer.MAX_VALUE; + int currentType = Integer.MAX_VALUE; + ContentValues currentContentValues = null; + for (final ContentValues contentValues : contentValuesList) { + if (contentValues == null) { + continue; + } + final Integer typeAsInteger = contentValues.getAsInteger(StructuredPostal.TYPE); + final Integer priorityAsInteger = sPostalTypePriorityMap.get(typeAsInteger); + final int priority = + (priorityAsInteger != null ? priorityAsInteger : Integer.MAX_VALUE); + if (priority < currentPriority) { + currentPriority = priority; + currentType = typeAsInteger; + currentContentValues = contentValues; + if (priority == 0) { + break; + } + } + } + + if (currentContentValues == null) { + Log.w(LOG_TAG, "Should not come here. Must have at least one postal data."); + return; + } + + final String label = currentContentValues.getAsString(StructuredPostal.LABEL); + appendPostalLine(currentType, label, currentContentValues, false, true); + } + + private void appendPostalsForGeneric(final List contentValuesList) { + for (final ContentValues contentValues : contentValuesList) { + if (contentValues == null) { + continue; + } + final Integer typeAsInteger = contentValues.getAsInteger(StructuredPostal.TYPE); + final int type = (typeAsInteger != null ? + typeAsInteger : DEFAULT_POSTAL_TYPE); + final String label = contentValues.getAsString(StructuredPostal.LABEL); + final Integer isPrimaryAsInteger = + contentValues.getAsInteger(StructuredPostal.IS_PRIMARY); + final boolean isPrimary = (isPrimaryAsInteger != null ? + (isPrimaryAsInteger > 0) : false); + appendPostalLine(type, label, contentValues, isPrimary, false); + } + } + + private static class PostalStruct { + final boolean reallyUseQuotedPrintable; + final boolean appendCharset; + final String addressData; + public PostalStruct(final boolean reallyUseQuotedPrintable, + final boolean appendCharset, final String addressData) { + this.reallyUseQuotedPrintable = reallyUseQuotedPrintable; + this.appendCharset = appendCharset; + this.addressData = addressData; + } + } + + /** + * @return null when there's no information available to construct the data. + */ + private PostalStruct tryConstructPostalStruct(ContentValues contentValues) { + // adr-value = 0*6(text-value ";") text-value + // ; PO Box, Extended Address, Street, Locality, Region, Postal + // ; Code, Country Name + final String rawPoBox = contentValues.getAsString(StructuredPostal.POBOX); + final String rawNeighborhood = contentValues.getAsString(StructuredPostal.NEIGHBORHOOD); + final String rawStreet = contentValues.getAsString(StructuredPostal.STREET); + final String rawLocality = contentValues.getAsString(StructuredPostal.CITY); + final String rawRegion = contentValues.getAsString(StructuredPostal.REGION); + final String rawPostalCode = contentValues.getAsString(StructuredPostal.POSTCODE); + final String rawCountry = contentValues.getAsString(StructuredPostal.COUNTRY); + final String[] rawAddressArray = new String[]{ + rawPoBox, rawNeighborhood, rawStreet, rawLocality, + rawRegion, rawPostalCode, rawCountry}; + if (!VCardUtils.areAllEmpty(rawAddressArray)) { + final boolean reallyUseQuotedPrintable = + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(rawAddressArray)); + final boolean appendCharset = + !VCardUtils.containsOnlyPrintableAscii(rawAddressArray); + final String encodedPoBox; + final String encodedStreet; + final String encodedLocality; + final String encodedRegion; + final String encodedPostalCode; + final String encodedCountry; + final String encodedNeighborhood; + + final String rawLocality2; + // This looks inefficient since we encode rawLocality and rawNeighborhood twice, + // but this is intentional. + // + // QP encoding may add line feeds when needed and the result of + // - encodeQuotedPrintable(rawLocality + " " + rawNeighborhood) + // may be different from + // - encodedLocality + " " + encodedNeighborhood. + // + // We use safer way. + if (TextUtils.isEmpty(rawLocality)) { + if (TextUtils.isEmpty(rawNeighborhood)) { + rawLocality2 = ""; + } else { + rawLocality2 = rawNeighborhood; + } + } else { + if (TextUtils.isEmpty(rawNeighborhood)) { + rawLocality2 = rawLocality; + } else { + rawLocality2 = rawLocality + " " + rawNeighborhood; + } + } + if (reallyUseQuotedPrintable) { + encodedPoBox = encodeQuotedPrintable(rawPoBox); + encodedStreet = encodeQuotedPrintable(rawStreet); + encodedLocality = encodeQuotedPrintable(rawLocality2); + encodedRegion = encodeQuotedPrintable(rawRegion); + encodedPostalCode = encodeQuotedPrintable(rawPostalCode); + encodedCountry = encodeQuotedPrintable(rawCountry); + } else { + encodedPoBox = escapeCharacters(rawPoBox); + encodedStreet = escapeCharacters(rawStreet); + encodedLocality = escapeCharacters(rawLocality2); + encodedRegion = escapeCharacters(rawRegion); + encodedPostalCode = escapeCharacters(rawPostalCode); + encodedCountry = escapeCharacters(rawCountry); + encodedNeighborhood = escapeCharacters(rawNeighborhood); + } + final StringBuilder addressBuilder = new StringBuilder(); + addressBuilder.append(encodedPoBox); + addressBuilder.append(VCARD_ITEM_SEPARATOR); // PO BOX ; Extended Address + addressBuilder.append(VCARD_ITEM_SEPARATOR); // Extended Address : Street + addressBuilder.append(encodedStreet); + addressBuilder.append(VCARD_ITEM_SEPARATOR); // Street : Locality + addressBuilder.append(encodedLocality); + addressBuilder.append(VCARD_ITEM_SEPARATOR); // Locality : Region + addressBuilder.append(encodedRegion); + addressBuilder.append(VCARD_ITEM_SEPARATOR); // Region : Postal Code + addressBuilder.append(encodedPostalCode); + addressBuilder.append(VCARD_ITEM_SEPARATOR); // Postal Code : Country + addressBuilder.append(encodedCountry); + return new PostalStruct( + reallyUseQuotedPrintable, appendCharset, addressBuilder.toString()); + } else { // VCardUtils.areAllEmpty(rawAddressArray) == true + // Try to use FORMATTED_ADDRESS instead. + final String rawFormattedAddress = + contentValues.getAsString(StructuredPostal.FORMATTED_ADDRESS); + if (TextUtils.isEmpty(rawFormattedAddress)) { + return null; + } + final boolean reallyUseQuotedPrintable = + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(rawFormattedAddress)); + final boolean appendCharset = + !VCardUtils.containsOnlyPrintableAscii(rawFormattedAddress); + final String encodedFormattedAddress; + if (reallyUseQuotedPrintable) { + encodedFormattedAddress = encodeQuotedPrintable(rawFormattedAddress); + } else { + encodedFormattedAddress = escapeCharacters(rawFormattedAddress); + } + + // We use the second value ("Extended Address") just because Japanese mobile phones + // do so. If the other importer expects the value be in the other field, some flag may + // be needed. + final StringBuilder addressBuilder = new StringBuilder(); + addressBuilder.append(VCARD_ITEM_SEPARATOR); // PO BOX ; Extended Address + addressBuilder.append(encodedFormattedAddress); + addressBuilder.append(VCARD_ITEM_SEPARATOR); // Extended Address : Street + addressBuilder.append(VCARD_ITEM_SEPARATOR); // Street : Locality + addressBuilder.append(VCARD_ITEM_SEPARATOR); // Locality : Region + addressBuilder.append(VCARD_ITEM_SEPARATOR); // Region : Postal Code + addressBuilder.append(VCARD_ITEM_SEPARATOR); // Postal Code : Country + return new PostalStruct( + reallyUseQuotedPrintable, appendCharset, addressBuilder.toString()); + } + } + + public VCardBuilder appendIms(final List contentValuesList) { + if (contentValuesList != null) { + for (ContentValues contentValues : contentValuesList) { + final Integer protocolAsObject = contentValues.getAsInteger(Im.PROTOCOL); + if (protocolAsObject == null) { + continue; + } + final String propertyName = VCardUtils.getPropertyNameForIm(protocolAsObject); + if (propertyName == null) { + continue; + } + String data = contentValues.getAsString(Im.DATA); + if (data != null) { + data = data.trim(); + } + if (TextUtils.isEmpty(data)) { + continue; + } + final String typeAsString; + { + final Integer typeAsInteger = contentValues.getAsInteger(Im.TYPE); + switch (typeAsInteger != null ? typeAsInteger : Im.TYPE_OTHER) { + case Im.TYPE_HOME: { + typeAsString = VCardConstants.PARAM_TYPE_HOME; + break; + } + case Im.TYPE_WORK: { + typeAsString = VCardConstants.PARAM_TYPE_WORK; + break; + } + case Im.TYPE_CUSTOM: { + final String label = contentValues.getAsString(Im.LABEL); + typeAsString = (label != null ? "X-" + label : null); + break; + } + case Im.TYPE_OTHER: // Ignore + default: { + typeAsString = null; + break; + } + } + } + + final List parameterList = new ArrayList(); + if (!TextUtils.isEmpty(typeAsString)) { + parameterList.add(typeAsString); + } + final Integer isPrimaryAsInteger = contentValues.getAsInteger(Im.IS_PRIMARY); + final boolean isPrimary = (isPrimaryAsInteger != null ? + (isPrimaryAsInteger > 0) : false); + if (isPrimary) { + parameterList.add(VCardConstants.PARAM_TYPE_PREF); + } + + appendLineWithCharsetAndQPDetection(propertyName, parameterList, data); + } + } + return this; + } + + public VCardBuilder appendWebsites(final List contentValuesList) { + if (contentValuesList != null) { + for (ContentValues contentValues : contentValuesList) { + String website = contentValues.getAsString(Website.URL); + if (website != null) { + website = website.trim(); + } + + // Note: vCard 3.0 does not allow any parameter addition toward "URL" + // property, while there's no document in vCard 2.1. + if (!TextUtils.isEmpty(website)) { + appendLineWithCharsetAndQPDetection(VCardConstants.PROPERTY_URL, website); + } + } + } + return this; + } + + public VCardBuilder appendOrganizations(final List contentValuesList) { + if (contentValuesList != null) { + for (ContentValues contentValues : contentValuesList) { + String company = contentValues.getAsString(Organization.COMPANY); + if (company != null) { + company = company.trim(); + } + String department = contentValues.getAsString(Organization.DEPARTMENT); + if (department != null) { + department = department.trim(); + } + String title = contentValues.getAsString(Organization.TITLE); + if (title != null) { + title = title.trim(); + } + + StringBuilder orgBuilder = new StringBuilder(); + if (!TextUtils.isEmpty(company)) { + orgBuilder.append(company); + } + if (!TextUtils.isEmpty(department)) { + if (orgBuilder.length() > 0) { + orgBuilder.append(';'); + } + orgBuilder.append(department); + } + final String orgline = orgBuilder.toString(); + appendLine(VCardConstants.PROPERTY_ORG, orgline, + !VCardUtils.containsOnlyPrintableAscii(orgline), + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(orgline))); + + if (!TextUtils.isEmpty(title)) { + appendLine(VCardConstants.PROPERTY_TITLE, title, + !VCardUtils.containsOnlyPrintableAscii(title), + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(title))); + } + } + } + return this; + } + + public VCardBuilder appendPhotos(final List contentValuesList) { + if (contentValuesList != null) { + for (ContentValues contentValues : contentValuesList) { + if (contentValues == null) { + continue; + } + byte[] data = contentValues.getAsByteArray(Photo.PHOTO); + if (data == null) { + continue; + } + final String photoType = VCardUtils.guessImageType(data); + if (photoType == null) { + Log.d(LOG_TAG, "Unknown photo type. Ignored."); + continue; + } + // TODO: check this works fine. + final String photoString = new String(Base64.encode(data, Base64.NO_WRAP)); + if (!TextUtils.isEmpty(photoString)) { + appendPhotoLine(photoString, photoType); + } + } + } + return this; + } + + public VCardBuilder appendNotes(final List contentValuesList) { + if (contentValuesList != null) { + if (mOnlyOneNoteFieldIsAvailable) { + final StringBuilder noteBuilder = new StringBuilder(); + boolean first = true; + for (final ContentValues contentValues : contentValuesList) { + String note = contentValues.getAsString(Note.NOTE); + if (note == null) { + note = ""; + } + if (note.length() > 0) { + if (first) { + first = false; + } else { + noteBuilder.append('\n'); + } + noteBuilder.append(note); + } + } + final String noteStr = noteBuilder.toString(); + // This means we scan noteStr completely twice, which is redundant. + // But for now, we assume this is not so time-consuming.. + final boolean shouldAppendCharsetInfo = + !VCardUtils.containsOnlyPrintableAscii(noteStr); + final boolean reallyUseQuotedPrintable = + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(noteStr)); + appendLine(VCardConstants.PROPERTY_NOTE, noteStr, + shouldAppendCharsetInfo, reallyUseQuotedPrintable); + } else { + for (ContentValues contentValues : contentValuesList) { + final String noteStr = contentValues.getAsString(Note.NOTE); + if (!TextUtils.isEmpty(noteStr)) { + final boolean shouldAppendCharsetInfo = + !VCardUtils.containsOnlyPrintableAscii(noteStr); + final boolean reallyUseQuotedPrintable = + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(noteStr)); + appendLine(VCardConstants.PROPERTY_NOTE, noteStr, + shouldAppendCharsetInfo, reallyUseQuotedPrintable); + } + } + } + } + return this; + } + + public VCardBuilder appendEvents(final List contentValuesList) { + // There's possibility where a given object may have more than one birthday, which + // is inappropriate. We just build one birthday. + if (contentValuesList != null) { + String primaryBirthday = null; + String secondaryBirthday = null; + for (final ContentValues contentValues : contentValuesList) { + if (contentValues == null) { + continue; + } + final Integer eventTypeAsInteger = contentValues.getAsInteger(Event.TYPE); + final int eventType; + if (eventTypeAsInteger != null) { + eventType = eventTypeAsInteger; + } else { + eventType = Event.TYPE_OTHER; + } + if (eventType == Event.TYPE_BIRTHDAY) { + final String birthdayCandidate = contentValues.getAsString(Event.START_DATE); + if (birthdayCandidate == null) { + continue; + } + final Integer isSuperPrimaryAsInteger = + contentValues.getAsInteger(Event.IS_SUPER_PRIMARY); + final boolean isSuperPrimary = (isSuperPrimaryAsInteger != null ? + (isSuperPrimaryAsInteger > 0) : false); + if (isSuperPrimary) { + // "super primary" birthday should the prefered one. + primaryBirthday = birthdayCandidate; + break; + } + final Integer isPrimaryAsInteger = + contentValues.getAsInteger(Event.IS_PRIMARY); + final boolean isPrimary = (isPrimaryAsInteger != null ? + (isPrimaryAsInteger > 0) : false); + if (isPrimary) { + // We don't break here since "super primary" birthday may exist later. + primaryBirthday = birthdayCandidate; + } else if (secondaryBirthday == null) { + // First entry is set to the "secondary" candidate. + secondaryBirthday = birthdayCandidate; + } + } else if (mUsesAndroidProperty) { + // Event types other than Birthday is not supported by vCard. + appendAndroidSpecificProperty(Event.CONTENT_ITEM_TYPE, contentValues); + } + } + if (primaryBirthday != null) { + appendLineWithCharsetAndQPDetection(VCardConstants.PROPERTY_BDAY, + primaryBirthday.trim()); + } else if (secondaryBirthday != null){ + appendLineWithCharsetAndQPDetection(VCardConstants.PROPERTY_BDAY, + secondaryBirthday.trim()); + } + } + return this; + } + + public VCardBuilder appendRelation(final List contentValuesList) { + if (mUsesAndroidProperty && contentValuesList != null) { + for (final ContentValues contentValues : contentValuesList) { + if (contentValues == null) { + continue; + } + appendAndroidSpecificProperty(Relation.CONTENT_ITEM_TYPE, contentValues); + } + } + return this; + } + + /** + * @param emitEveryTime If true, builder builds the line even when there's no entry. + */ + public void appendPostalLine(final int type, final String label, + final ContentValues contentValues, + final boolean isPrimary, final boolean emitEveryTime) { + final boolean reallyUseQuotedPrintable; + final boolean appendCharset; + final String addressValue; + { + PostalStruct postalStruct = tryConstructPostalStruct(contentValues); + if (postalStruct == null) { + if (emitEveryTime) { + reallyUseQuotedPrintable = false; + appendCharset = false; + addressValue = ""; + } else { + return; + } + } else { + reallyUseQuotedPrintable = postalStruct.reallyUseQuotedPrintable; + appendCharset = postalStruct.appendCharset; + addressValue = postalStruct.addressData; + } + } + + List parameterList = new ArrayList(); + if (isPrimary) { + parameterList.add(VCardConstants.PARAM_TYPE_PREF); + } + switch (type) { + case StructuredPostal.TYPE_HOME: { + parameterList.add(VCardConstants.PARAM_TYPE_HOME); + break; + } + case StructuredPostal.TYPE_WORK: { + parameterList.add(VCardConstants.PARAM_TYPE_WORK); + break; + } + case StructuredPostal.TYPE_CUSTOM: { + if (!TextUtils.isEmpty(label) + && VCardUtils.containsOnlyAlphaDigitHyphen(label)) { + // We're not sure whether the label is valid in the spec + // ("IANA-token" in the vCard 3.0 is unclear...) + // Just for safety, we add "X-" at the beggining of each label. + // Also checks the label obeys with vCard 3.0 spec. + parameterList.add("X-" + label); + } + break; + } + case StructuredPostal.TYPE_OTHER: { + // {@code TYPE_OTHER} will be treated as a "custom type" in that a "X-" will be + // similarly prepended. According to RFC 2426, this constitutes a valid "x-name", + // which constitutes a valid "adr-type". + parameterList.add("X-" + VCardConstants.PARAM_ADR_EXTRA_TYPE_OTHER); + break; + } + default: { + Log.e(LOG_TAG, "Unknown StructuredPostal type: " + type); + break; + } + } + + mBuilder.append(VCardConstants.PROPERTY_ADR); + if (!parameterList.isEmpty()) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + appendTypeParameters(parameterList); + } + if (appendCharset) { + // Strictly, vCard 3.0 does not allow exporters to emit charset information, + // but we will add it since the information should be useful for importers, + // + // Assume no parser does not emit error with this parameter in vCard 3.0. + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + if (reallyUseQuotedPrintable) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCARD_PARAM_ENCODING_QP); + } + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(addressValue); + mBuilder.append(VCARD_END_OF_LINE); + } + + public void appendEmailLine(final int type, final String label, + final String rawValue, final boolean isPrimary) { + final String typeAsString; + switch (type) { + case Email.TYPE_CUSTOM: { + if (VCardUtils.isMobilePhoneLabel(label)) { + typeAsString = VCardConstants.PARAM_TYPE_CELL; + } else if (!TextUtils.isEmpty(label) + && VCardUtils.containsOnlyAlphaDigitHyphen(label)) { + typeAsString = "X-" + label; + } else { + typeAsString = null; + } + break; + } + case Email.TYPE_HOME: { + typeAsString = VCardConstants.PARAM_TYPE_HOME; + break; + } + case Email.TYPE_WORK: { + typeAsString = VCardConstants.PARAM_TYPE_WORK; + break; + } + case Email.TYPE_OTHER: { + typeAsString = null; + break; + } + case Email.TYPE_MOBILE: { + typeAsString = VCardConstants.PARAM_TYPE_CELL; + break; + } + default: { + Log.e(LOG_TAG, "Unknown Email type: " + type); + typeAsString = null; + break; + } + } + + final List parameterList = new ArrayList(); + if (isPrimary) { + parameterList.add(VCardConstants.PARAM_TYPE_PREF); + } + if (!TextUtils.isEmpty(typeAsString)) { + parameterList.add(typeAsString); + } + + appendLineWithCharsetAndQPDetection(VCardConstants.PROPERTY_EMAIL, parameterList, + rawValue); + } + + public void appendTelLine(final Integer typeAsInteger, final String label, + final String encodedValue, boolean isPrimary) { + mBuilder.append(VCardConstants.PROPERTY_TEL); + mBuilder.append(VCARD_PARAM_SEPARATOR); + + final int type; + if (typeAsInteger == null) { + type = Phone.TYPE_OTHER; + } else { + type = typeAsInteger; + } + + ArrayList parameterList = new ArrayList(); + switch (type) { + case Phone.TYPE_HOME: { + parameterList.addAll( + Arrays.asList(VCardConstants.PARAM_TYPE_HOME)); + break; + } + case Phone.TYPE_WORK: { + parameterList.addAll( + Arrays.asList(VCardConstants.PARAM_TYPE_WORK)); + break; + } + case Phone.TYPE_FAX_HOME: { + parameterList.addAll( + Arrays.asList(VCardConstants.PARAM_TYPE_HOME, VCardConstants.PARAM_TYPE_FAX)); + break; + } + case Phone.TYPE_FAX_WORK: { + parameterList.addAll( + Arrays.asList(VCardConstants.PARAM_TYPE_WORK, VCardConstants.PARAM_TYPE_FAX)); + break; + } + case Phone.TYPE_MOBILE: { + parameterList.add(VCardConstants.PARAM_TYPE_CELL); + break; + } + case Phone.TYPE_PAGER: { + if (mIsDoCoMo) { + // Not sure about the reason, but previous implementation had + // used "VOICE" instead of "PAGER" + parameterList.add(VCardConstants.PARAM_TYPE_VOICE); + } else { + parameterList.add(VCardConstants.PARAM_TYPE_PAGER); + } + break; + } + case Phone.TYPE_OTHER: { + parameterList.add(VCardConstants.PARAM_TYPE_VOICE); + break; + } + case Phone.TYPE_CAR: { + parameterList.add(VCardConstants.PARAM_TYPE_CAR); + break; + } + case Phone.TYPE_COMPANY_MAIN: { + // There's no relevant field in vCard (at least 2.1). + parameterList.add(VCardConstants.PARAM_TYPE_WORK); + isPrimary = true; + break; + } + case Phone.TYPE_ISDN: { + parameterList.add(VCardConstants.PARAM_TYPE_ISDN); + break; + } + case Phone.TYPE_MAIN: { + isPrimary = true; + break; + } + case Phone.TYPE_OTHER_FAX: { + parameterList.add(VCardConstants.PARAM_TYPE_FAX); + break; + } + case Phone.TYPE_TELEX: { + parameterList.add(VCardConstants.PARAM_TYPE_TLX); + break; + } + case Phone.TYPE_WORK_MOBILE: { + parameterList.addAll( + Arrays.asList(VCardConstants.PARAM_TYPE_WORK, VCardConstants.PARAM_TYPE_CELL)); + break; + } + case Phone.TYPE_WORK_PAGER: { + parameterList.add(VCardConstants.PARAM_TYPE_WORK); + // See above. + if (mIsDoCoMo) { + parameterList.add(VCardConstants.PARAM_TYPE_VOICE); + } else { + parameterList.add(VCardConstants.PARAM_TYPE_PAGER); + } + break; + } + case Phone.TYPE_MMS: { + parameterList.add(VCardConstants.PARAM_TYPE_MSG); + break; + } + case Phone.TYPE_CUSTOM: { + if (TextUtils.isEmpty(label)) { + // Just ignore the custom type. + parameterList.add(VCardConstants.PARAM_TYPE_VOICE); + } else if (VCardUtils.isMobilePhoneLabel(label)) { + parameterList.add(VCardConstants.PARAM_TYPE_CELL); + } else if (mIsV30OrV40) { + // This label is appropriately encoded in appendTypeParameters. + parameterList.add(label); + } else { + final String upperLabel = label.toUpperCase(); + if (VCardUtils.isValidInV21ButUnknownToContactsPhoteType(upperLabel)) { + parameterList.add(upperLabel); + } else if (VCardUtils.containsOnlyAlphaDigitHyphen(label)) { + // Note: Strictly, vCard 2.1 does not allow "X-" parameter without + // "TYPE=" string. + parameterList.add("X-" + label); + } + } + break; + } + case Phone.TYPE_RADIO: + case Phone.TYPE_TTY_TDD: + default: { + break; + } + } + + if (isPrimary) { + parameterList.add(VCardConstants.PARAM_TYPE_PREF); + } + + if (parameterList.isEmpty()) { + appendUncommonPhoneType(mBuilder, type); + } else { + appendTypeParameters(parameterList); + } + + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(encodedValue); + mBuilder.append(VCARD_END_OF_LINE); + } + + /** + * Appends phone type string which may not be available in some devices. + */ + private void appendUncommonPhoneType(final StringBuilder builder, final Integer type) { + if (mIsDoCoMo) { + // The previous implementation for DoCoMo had been conservative + // about miscellaneous types. + builder.append(VCardConstants.PARAM_TYPE_VOICE); + } else { + String phoneType = VCardUtils.getPhoneTypeString(type); + if (phoneType != null) { + appendTypeParameter(phoneType); + } else { + Log.e(LOG_TAG, "Unknown or unsupported (by vCard) Phone type: " + type); + } + } + } + + /** + * @param encodedValue Must be encoded by BASE64 + * @param photoType + */ + public void appendPhotoLine(final String encodedValue, final String photoType) { + StringBuilder tmpBuilder = new StringBuilder(); + tmpBuilder.append(VCardConstants.PROPERTY_PHOTO); + tmpBuilder.append(VCARD_PARAM_SEPARATOR); + if (mIsV30OrV40) { + tmpBuilder.append(VCARD_PARAM_ENCODING_BASE64_AS_B); + } else { + tmpBuilder.append(VCARD_PARAM_ENCODING_BASE64_V21); + } + tmpBuilder.append(VCARD_PARAM_SEPARATOR); + appendTypeParameter(tmpBuilder, photoType); + tmpBuilder.append(VCARD_DATA_SEPARATOR); + tmpBuilder.append(encodedValue); + + final String tmpStr = tmpBuilder.toString(); + tmpBuilder = new StringBuilder(); + int lineCount = 0; + final int length = tmpStr.length(); + final int maxNumForFirstLine = VCardConstants.MAX_CHARACTER_NUMS_BASE64_V30 + - VCARD_END_OF_LINE.length(); + final int maxNumInGeneral = maxNumForFirstLine - VCARD_WS.length(); + int maxNum = maxNumForFirstLine; + for (int i = 0; i < length; i++) { + tmpBuilder.append(tmpStr.charAt(i)); + lineCount++; + if (lineCount > maxNum) { + tmpBuilder.append(VCARD_END_OF_LINE); + tmpBuilder.append(VCARD_WS); + maxNum = maxNumInGeneral; + lineCount = 0; + } + } + mBuilder.append(tmpBuilder.toString()); + mBuilder.append(VCARD_END_OF_LINE); + mBuilder.append(VCARD_END_OF_LINE); + } + + /** + * SIP (Session Initiation Protocol) is first supported in RFC 4770 as part of IMPP + * support. vCard 2.1 and old vCard 3.0 may not able to parse it, or expect X-SIP + * instead of "IMPP;sip:...". + * + * We honor RFC 4770 and don't allow vCard 3.0 to emit X-SIP at all. + */ + public VCardBuilder appendSipAddresses(final List contentValuesList) { + final boolean useXProperty; + if (mIsV30OrV40) { + useXProperty = false; + } else if (mUsesDefactProperty){ + useXProperty = true; + } else { + return this; + } + + if (contentValuesList != null) { + for (ContentValues contentValues : contentValuesList) { + String sipAddress = contentValues.getAsString(SipAddress.SIP_ADDRESS); + if (TextUtils.isEmpty(sipAddress)) { + continue; + } + if (useXProperty) { + // X-SIP does not contain "sip:" prefix. + if (sipAddress.startsWith("sip:")) { + if (sipAddress.length() == 4) { + continue; + } + sipAddress = sipAddress.substring(4); + } + // No type is available yet. + appendLineWithCharsetAndQPDetection(VCardConstants.PROPERTY_X_SIP, sipAddress); + } else { + if (!sipAddress.startsWith("sip:")) { + sipAddress = "sip:" + sipAddress; + } + final String propertyName; + if (VCardConfig.isVersion40(mVCardType)) { + // We have two ways to emit sip address: TEL and IMPP. Currently (rev.13) + // TEL seems appropriate but may change in the future. + propertyName = VCardConstants.PROPERTY_TEL; + } else { + // RFC 4770 (for vCard 3.0) + propertyName = VCardConstants.PROPERTY_IMPP; + } + appendLineWithCharsetAndQPDetection(propertyName, sipAddress); + } + } + } + return this; + } + + public void appendAndroidSpecificProperty( + final String mimeType, ContentValues contentValues) { + if (!sAllowedAndroidPropertySet.contains(mimeType)) { + return; + } + final List rawValueList = new ArrayList(); + for (int i = 1; i <= VCardConstants.MAX_DATA_COLUMN; i++) { + String value = contentValues.getAsString("data" + i); + if (value == null) { + value = ""; + } + rawValueList.add(value); + } + + boolean needCharset = + (mShouldAppendCharsetParam && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(rawValueList)); + boolean reallyUseQuotedPrintable = + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(rawValueList)); + mBuilder.append(VCardConstants.PROPERTY_X_ANDROID_CUSTOM); + if (needCharset) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + if (reallyUseQuotedPrintable) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCARD_PARAM_ENCODING_QP); + } + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(mimeType); // Should not be encoded. + for (String rawValue : rawValueList) { + final String encodedValue; + if (reallyUseQuotedPrintable) { + encodedValue = encodeQuotedPrintable(rawValue); + } else { + // TODO: one line may be too huge, which may be invalid in vCard 3.0 + // (which says "When generating a content line, lines longer than + // 75 characters SHOULD be folded"), though several + // (even well-known) applications do not care this. + encodedValue = escapeCharacters(rawValue); + } + mBuilder.append(VCARD_ITEM_SEPARATOR); + mBuilder.append(encodedValue); + } + mBuilder.append(VCARD_END_OF_LINE); + } + + public void appendLineWithCharsetAndQPDetection(final String propertyName, + final String rawValue) { + appendLineWithCharsetAndQPDetection(propertyName, null, rawValue); + } + + public void appendLineWithCharsetAndQPDetection( + final String propertyName, final List rawValueList) { + appendLineWithCharsetAndQPDetection(propertyName, null, rawValueList); + } + + public void appendLineWithCharsetAndQPDetection(final String propertyName, + final List parameterList, final String rawValue) { + final boolean needCharset = + !VCardUtils.containsOnlyPrintableAscii(rawValue); + final boolean reallyUseQuotedPrintable = + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(rawValue)); + appendLine(propertyName, parameterList, + rawValue, needCharset, reallyUseQuotedPrintable); + } + + public void appendLineWithCharsetAndQPDetection(final String propertyName, + final List parameterList, final List rawValueList) { + boolean needCharset = + (mShouldAppendCharsetParam && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(rawValueList)); + boolean reallyUseQuotedPrintable = + (mShouldUseQuotedPrintable && + !VCardUtils.containsOnlyNonCrLfPrintableAscii(rawValueList)); + appendLine(propertyName, parameterList, rawValueList, + needCharset, reallyUseQuotedPrintable); + } + + /** + * Appends one line with a given property name and value. + */ + public void appendLine(final String propertyName, final String rawValue) { + appendLine(propertyName, rawValue, false, false); + } + + public void appendLine(final String propertyName, final List rawValueList) { + appendLine(propertyName, rawValueList, false, false); + } + + public void appendLine(final String propertyName, + final String rawValue, final boolean needCharset, + boolean reallyUseQuotedPrintable) { + appendLine(propertyName, null, rawValue, needCharset, reallyUseQuotedPrintable); + } + + public void appendLine(final String propertyName, final List parameterList, + final String rawValue) { + appendLine(propertyName, parameterList, rawValue, false, false); + } + + public void appendLine(final String propertyName, final List parameterList, + final String rawValue, final boolean needCharset, + boolean reallyUseQuotedPrintable) { + mBuilder.append(propertyName); + if (parameterList != null && parameterList.size() > 0) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + appendTypeParameters(parameterList); + } + if (needCharset) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + + final String encodedValue; + if (reallyUseQuotedPrintable) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCARD_PARAM_ENCODING_QP); + encodedValue = encodeQuotedPrintable(rawValue); + } else { + // TODO: one line may be too huge, which may be invalid in vCard spec, though + // several (even well-known) applications do not care that violation. + encodedValue = escapeCharacters(rawValue); + } + + mBuilder.append(VCARD_DATA_SEPARATOR); + mBuilder.append(encodedValue); + mBuilder.append(VCARD_END_OF_LINE); + } + + public void appendLine(final String propertyName, final List rawValueList, + final boolean needCharset, boolean needQuotedPrintable) { + appendLine(propertyName, null, rawValueList, needCharset, needQuotedPrintable); + } + + public void appendLine(final String propertyName, final List parameterList, + final List rawValueList, final boolean needCharset, + final boolean needQuotedPrintable) { + mBuilder.append(propertyName); + if (parameterList != null && parameterList.size() > 0) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + appendTypeParameters(parameterList); + } + if (needCharset) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(mVCardCharsetParameter); + } + if (needQuotedPrintable) { + mBuilder.append(VCARD_PARAM_SEPARATOR); + mBuilder.append(VCARD_PARAM_ENCODING_QP); + } + + mBuilder.append(VCARD_DATA_SEPARATOR); + boolean first = true; + for (String rawValue : rawValueList) { + final String encodedValue; + if (needQuotedPrintable) { + encodedValue = encodeQuotedPrintable(rawValue); + } else { + // TODO: one line may be too huge, which may be invalid in vCard 3.0 + // (which says "When generating a content line, lines longer than + // 75 characters SHOULD be folded"), though several + // (even well-known) applications do not care this. + encodedValue = escapeCharacters(rawValue); + } + + if (first) { + first = false; + } else { + mBuilder.append(VCARD_ITEM_SEPARATOR); + } + mBuilder.append(encodedValue); + } + mBuilder.append(VCARD_END_OF_LINE); + } + + /** + * VCARD_PARAM_SEPARATOR must be appended before this method being called. + */ + private void appendTypeParameters(final List types) { + // We may have to make this comma separated form like "TYPE=DOM,WORK" in the future, + // which would be recommended way in vcard 3.0 though not valid in vCard 2.1. + boolean first = true; + for (final String typeValue : types) { + if (VCardConfig.isVersion30(mVCardType) || VCardConfig.isVersion40(mVCardType)) { + final String encoded = (VCardConfig.isVersion40(mVCardType) ? + VCardUtils.toStringAsV40ParamValue(typeValue) : + VCardUtils.toStringAsV30ParamValue(typeValue)); + if (TextUtils.isEmpty(encoded)) { + continue; + } + + if (first) { + first = false; + } else { + mBuilder.append(VCARD_PARAM_SEPARATOR); + } + appendTypeParameter(encoded); + } else { // vCard 2.1 + if (!VCardUtils.isV21Word(typeValue)) { + continue; + } + if (first) { + first = false; + } else { + mBuilder.append(VCARD_PARAM_SEPARATOR); + } + appendTypeParameter(typeValue); + } + } + } + + /** + * VCARD_PARAM_SEPARATOR must be appended before this method being called. + */ + private void appendTypeParameter(final String type) { + appendTypeParameter(mBuilder, type); + } + + private void appendTypeParameter(final StringBuilder builder, final String type) { + // Refrain from using appendType() so that "TYPE=" is not be appended when the + // device is DoCoMo's (just for safety). + // + // Note: In vCard 3.0, Type strings also can be like this: "TYPE=HOME,PREF" + if (VCardConfig.isVersion40(mVCardType) || + ((VCardConfig.isVersion30(mVCardType) || mAppendTypeParamName) && !mIsDoCoMo)) { + builder.append(VCardConstants.PARAM_TYPE).append(VCARD_PARAM_EQUAL); + } + builder.append(type); + } + + /** + * Returns true when the property line should contain charset parameter + * information. This method may return true even when vCard version is 3.0. + * + * Strictly, adding charset information is invalid in VCard 3.0. + * However we'll add the info only when charset we use is not UTF-8 + * in vCard 3.0 format, since parser side may be able to use the charset + * via this field, though we may encounter another problem by adding it. + * + * e.g. Japanese mobile phones use Shift_Jis while RFC 2426 + * recommends UTF-8. By adding this field, parsers may be able + * to know this text is NOT UTF-8 but Shift_Jis. + */ + private boolean shouldAppendCharsetParam(String...propertyValueList) { + if (!mShouldAppendCharsetParam) { + return false; + } + for (String propertyValue : propertyValueList) { + if (!VCardUtils.containsOnlyPrintableAscii(propertyValue)) { + return true; + } + } + return false; + } + + private String encodeQuotedPrintable(final String str) { + if (TextUtils.isEmpty(str)) { + return ""; + } + + final StringBuilder builder = new StringBuilder(); + int index = 0; + int lineCount = 0; + byte[] strArray = null; + + try { + strArray = str.getBytes(mCharset); + } catch (UnsupportedEncodingException e) { + Log.e(LOG_TAG, "Charset " + mCharset + " cannot be used. " + + "Try default charset"); + strArray = str.getBytes(); + } + while (index < strArray.length) { + builder.append(String.format("=%02X", strArray[index])); + index += 1; + lineCount += 3; + + if (lineCount >= 67) { + // Specification requires CRLF must be inserted before the + // length of the line + // becomes more than 76. + // Assuming that the next character is a multi-byte character, + // it will become + // 6 bytes. + // 76 - 6 - 3 = 67 + builder.append("=\r\n"); + lineCount = 0; + } + } + + return builder.toString(); + } + + /** + * Append '\' to the characters which should be escaped. The character set is different + * not only between vCard 2.1 and vCard 3.0 but also among each device. + * + * Note that Quoted-Printable string must not be input here. + */ + @SuppressWarnings("fallthrough") + private String escapeCharacters(final String unescaped) { + if (TextUtils.isEmpty(unescaped)) { + return ""; + } + + final StringBuilder tmpBuilder = new StringBuilder(); + final int length = unescaped.length(); + for (int i = 0; i < length; i++) { + final char ch = unescaped.charAt(i); + switch (ch) { + case ';': { + tmpBuilder.append('\\'); + tmpBuilder.append(';'); + break; + } + case '\r': { + if (i + 1 < length) { + char nextChar = unescaped.charAt(i); + if (nextChar == '\n') { + break; + } else { + // fall through + } + } else { + // fall through + } + } + case '\n': { + // In vCard 2.1, there's no specification about this, while + // vCard 3.0 explicitly requires this should be encoded to "\n". + tmpBuilder.append("\\n"); + break; + } + case '\\': { + if (mIsV30OrV40) { + tmpBuilder.append("\\\\"); + break; + } else { + // fall through + } + } + case '<': + case '>': { + if (mIsDoCoMo) { + tmpBuilder.append('\\'); + tmpBuilder.append(ch); + } else { + tmpBuilder.append(ch); + } + break; + } + case ',': { + if (mIsV30OrV40) { + tmpBuilder.append("\\,"); + } else { + tmpBuilder.append(ch); + } + break; + } + default: { + tmpBuilder.append(ch); + break; + } + } + } + return tmpBuilder.toString(); + } + + @Override + public String toString() { + if (!mEndAppended) { + if (mIsDoCoMo) { + appendLine(VCardConstants.PROPERTY_X_CLASS, VCARD_DATA_PUBLIC); + appendLine(VCardConstants.PROPERTY_X_REDUCTION, ""); + appendLine(VCardConstants.PROPERTY_X_NO, ""); + appendLine(VCardConstants.PROPERTY_X_DCM_HMN_MODE, ""); + } + appendLine(VCardConstants.PROPERTY_END, VCARD_DATA_VCARD); + mEndAppended = true; + } + return mBuilder.toString(); + } +} diff --git a/modules/vcard/java/com/android/vcard/VCardComposer.java b/modules/vcard/java/com/android/vcard/VCardComposer.java new file mode 100644 index 0000000..201cc34 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardComposer.java @@ -0,0 +1,721 @@ +/* + * 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.vcard; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.Entity; +import android.content.Entity.NamedContentValues; +import android.content.EntityIterator; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.net.Uri; +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.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; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.RawContacts; +import android.provider.ContactsContract.RawContactsEntity; +import android.provider.ContactsContract; +import android.text.TextUtils; +import android.util.Log; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + *

+ * The class for composing vCard from Contacts information. + *

+ *

+ * Usually, this class should be used like this. + *

+ *
VCardComposer composer = null;
+ * try {
+ *     composer = new VCardComposer(context);
+ *     composer.addHandler(
+ *             composer.new HandlerForOutputStream(outputStream));
+ *     if (!composer.init()) {
+ *         // Do something handling the situation.
+ *         return;
+ *     }
+ *     while (!composer.isAfterLast()) {
+ *         if (mCanceled) {
+ *             // Assume a user may cancel this operation during the export.
+ *             return;
+ *         }
+ *         if (!composer.createOneEntry()) {
+ *             // Do something handling the error situation.
+ *             return;
+ *         }
+ *     }
+ * } finally {
+ *     if (composer != null) {
+ *         composer.terminate();
+ *     }
+ * }
+ *

+ * Users have to manually take care of memory efficiency. Even one vCard may contain + * image of non-trivial size for mobile devices. + *

+ *

+ * {@link VCardBuilder} is used to build each vCard. + *

+ */ +public class VCardComposer { + private static final String LOG_TAG = "VCardComposer"; + private static final boolean DEBUG = false; + + public static final String FAILURE_REASON_FAILED_TO_GET_DATABASE_INFO = + "Failed to get database information"; + + public static final String FAILURE_REASON_NO_ENTRY = + "There's no exportable in the database"; + + public static final String FAILURE_REASON_NOT_INITIALIZED = + "The vCard composer object is not correctly initialized"; + + /** Should be visible only from developers... (no need to translate, hopefully) */ + public static final String FAILURE_REASON_UNSUPPORTED_URI = + "The Uri vCard composer received is not supported by the composer."; + + public static final String NO_ERROR = "No error"; + + // Strictly speaking, "Shift_JIS" is the most appropriate, but we use upper version here, + // since usual vCard devices for Japanese devices already use it. + private static final String SHIFT_JIS = "SHIFT_JIS"; + private static final String UTF_8 = "UTF-8"; + + private static final Map sImMap; + + static { + sImMap = new HashMap(); + sImMap.put(Im.PROTOCOL_AIM, VCardConstants.PROPERTY_X_AIM); + sImMap.put(Im.PROTOCOL_MSN, VCardConstants.PROPERTY_X_MSN); + sImMap.put(Im.PROTOCOL_YAHOO, VCardConstants.PROPERTY_X_YAHOO); + sImMap.put(Im.PROTOCOL_ICQ, VCardConstants.PROPERTY_X_ICQ); + sImMap.put(Im.PROTOCOL_JABBER, VCardConstants.PROPERTY_X_JABBER); + sImMap.put(Im.PROTOCOL_SKYPE, VCardConstants.PROPERTY_X_SKYPE_USERNAME); + // We don't add Google talk here since it has to be handled separately. + } + + private final int mVCardType; + private final ContentResolver mContentResolver; + + private final boolean mIsDoCoMo; + /** + * Used only when {@link #mIsDoCoMo} is true. Set to true when the first vCard for DoCoMo + * vCard is emitted. + */ + private boolean mFirstVCardEmittedInDoCoMoCase; + + private Cursor mCursor; + private EntityIterator mEntityIterator; + private Method mGetEntityIteratorMethod; + private boolean mCursorSuppliedFromOutside; + private int mIdColumn; + private Uri mContentUriForRawContactsEntity; + + private final String mCharset; + + private boolean mInitDone; + private String mErrorReason = NO_ERROR; + + /** + * Set to false when one of {@link #init()} variants is called, and set to true when + * {@link #terminate()} is called. Initially set to true. + */ + private boolean mTerminateCalled = true; + + private RawContactEntitlesInfoCallback mRawContactEntitlesInfoCallback; + + private static final String[] sContactsProjection = new String[] { + Contacts._ID, + }; + + public VCardComposer(Context context) { + this(context, VCardConfig.VCARD_TYPE_DEFAULT, null, true); + } + + /** + * The variant which sets charset to null and sets careHandlerErrors to true. + */ + public VCardComposer(Context context, int vcardType) { + this(context, vcardType, null, true); + } + + public VCardComposer(Context context, int vcardType, String charset) { + this(context, vcardType, charset, true); + } + + /** + * The variant which sets charset to null. + */ + public VCardComposer(final Context context, final int vcardType, + final boolean careHandlerErrors) { + this(context, vcardType, null, careHandlerErrors); + } + + /** + * Constructs for supporting call log entry vCard composing. + * + * @param context Context to be used during the composition. + * @param vcardType The type of vCard, typically available via {@link VCardConfig}. + * @param charset The charset to be used. Use null when you don't need the charset. + * @param careHandlerErrors If true, This object returns false everytime + */ + public VCardComposer(final Context context, final int vcardType, String charset, + final boolean careHandlerErrors) { + this(context, context.getContentResolver(), vcardType, charset, careHandlerErrors); + } + + /** + * Just for testing for now. + * @param resolver {@link ContentResolver} which used by this object. + * @hide + */ + public VCardComposer(final Context context, ContentResolver resolver, + final int vcardType, String charset, final boolean careHandlerErrors) { + // Not used right now + // mContext = context; + mVCardType = vcardType; + mContentResolver = resolver; + + mIsDoCoMo = VCardConfig.isDoCoMo(vcardType); + + charset = (TextUtils.isEmpty(charset) ? VCardConfig.DEFAULT_EXPORT_CHARSET : charset); + final boolean shouldAppendCharsetParam = !( + VCardConfig.isVersion30(vcardType) && UTF_8.equalsIgnoreCase(charset)); + + if (mIsDoCoMo || shouldAppendCharsetParam) { + if (SHIFT_JIS.equalsIgnoreCase(charset)) { + mCharset = charset; + } else { + /* Log.w(LOG_TAG, + "The charset \"" + charset + "\" is used while " + + SHIFT_JIS + " is needed to be used."); */ + if (TextUtils.isEmpty(charset)) { + mCharset = SHIFT_JIS; + } else { + mCharset = charset; + } + } + } else { + if (TextUtils.isEmpty(charset)) { + mCharset = UTF_8; + } else { + mCharset = charset; + } + } + + Log.d(LOG_TAG, "Use the charset \"" + mCharset + "\""); + } + + /** + * Initializes this object using default {@link Contacts#CONTENT_URI}. + * + * You can call this method or a variant of this method just once. In other words, you cannot + * reuse this object. + * + * @return Returns true when initialization is successful and all the other + * methods are available. Returns false otherwise. + */ + public boolean init() { + return init(null, null); + } + + /** + * Special variant of init(), which accepts a Uri for obtaining {@link RawContactsEntity} from + * {@link ContentResolver} with {@link Contacts#_ID}. + * + * String selection = Data.CONTACT_ID + "=?"; + * String[] selectionArgs = new String[] {contactId}; + * Cursor cursor = mContentResolver.query( + * contentUriForRawContactsEntity, null, selection, selectionArgs, null) + * + * + * You can call this method or a variant of this method just once. In other words, you cannot + * reuse this object. + * + * @deprecated Use {@link #init(Uri, String[], String, String[], String, Uri)} if you really + * need to change the default Uri. + */ + @Deprecated + public boolean initWithRawContactsEntityUri(Uri contentUriForRawContactsEntity) { + return init(Contacts.CONTENT_URI, sContactsProjection, null, null, null, + contentUriForRawContactsEntity); + } + + /** + * Initializes this object using default {@link Contacts#CONTENT_URI} and given selection + * arguments. + */ + public boolean init(final String selection, final String[] selectionArgs) { + return init(Contacts.CONTENT_URI, sContactsProjection, selection, selectionArgs, + null, null); + } + + /** + * Note that this is unstable interface, may be deleted in the future. + */ + public boolean init(final Uri contentUri, final String selection, + final String[] selectionArgs, final String sortOrder) { + return init(contentUri, sContactsProjection, selection, selectionArgs, sortOrder, null); + } + + /** + * @param contentUri Uri for obtaining the list of contactId. Used with + * {@link ContentResolver#query(Uri, String[], String, String[], String)} + * @param selection selection used with + * {@link ContentResolver#query(Uri, String[], String, String[], String)} + * @param selectionArgs selectionArgs used with + * {@link ContentResolver#query(Uri, String[], String, String[], String)} + * @param sortOrder sortOrder used with + * {@link ContentResolver#query(Uri, String[], String, String[], String)} + * @param contentUriForRawContactsEntity Uri for obtaining entries relevant to each + * contactId. + * Note that this is an unstable interface, may be deleted in the future. + */ + public boolean init(final Uri contentUri, final String selection, + final String[] selectionArgs, final String sortOrder, + final Uri contentUriForRawContactsEntity) { + return init(contentUri, sContactsProjection, selection, selectionArgs, sortOrder, + contentUriForRawContactsEntity); + } + + /** + * A variant of init(). Currently just for testing. Use other variants for init(). + * + * First we'll create {@link Cursor} for the list of contactId. + * + * + * Cursor cursorForId = mContentResolver.query( + * contentUri, projection, selection, selectionArgs, sortOrder); + * + * + * After that, we'll obtain data for each contactId in the list. + * + * + * Cursor cursorForContent = mContentResolver.query( + * contentUriForRawContactsEntity, null, + * Data.CONTACT_ID + "=?", new String[] {contactId}, null) + * + * + * {@link #createOneEntry()} or its variants let the caller obtain each entry from + * cursorForContent above. + * + * @param contentUri Uri for obtaining the list of contactId. Used with + * {@link ContentResolver#query(Uri, String[], String, String[], String)} + * @param projection projection used with + * {@link ContentResolver#query(Uri, String[], String, String[], String)} + * @param selection selection used with + * {@link ContentResolver#query(Uri, String[], String, String[], String)} + * @param selectionArgs selectionArgs used with + * {@link ContentResolver#query(Uri, String[], String, String[], String)} + * @param sortOrder sortOrder used with + * {@link ContentResolver#query(Uri, String[], String, String[], String)} + * @param contentUriForRawContactsEntity Uri for obtaining entries relevant to each + * contactId. + * @return true when successful + * + * @hide + */ + public boolean init(final Uri contentUri, final String[] projection, + final String selection, final String[] selectionArgs, + final String sortOrder, Uri contentUriForRawContactsEntity) { + if (!ContactsContract.AUTHORITY.equals(contentUri.getAuthority())) { + if (DEBUG) Log.d(LOG_TAG, "Unexpected contentUri: " + contentUri); + mErrorReason = FAILURE_REASON_UNSUPPORTED_URI; + return false; + } + + if (!initInterFirstPart(contentUriForRawContactsEntity)) { + return false; + } + if (!initInterCursorCreationPart(contentUri, projection, selection, selectionArgs, + sortOrder)) { + return false; + } + if (!initInterMainPart()) { + return false; + } + initEntityIterator(); + + return initInterLastPart(); + } + + /** + * Just for testing for now. Do not use. + * Execute before init. + * @hide + */ + public void setGetEntityIteratorMethod(Method getEntityIteratorMethod) { + mGetEntityIteratorMethod = getEntityIteratorMethod; + } + + /** + * Just for testing for now. Do not use. + * @hide + */ + public boolean init(Cursor cursor) { + return initWithCallback(cursor, null); + } + + /** + * @param cursor Cursor that used to get contact id + * @param rawContactEntitlesInfoCallback Callback that return RawContactEntitlesInfo + * Note that this is an unstable interface, may be deleted in the future. + * + * @return true when successful + */ + public boolean initWithCallback(Cursor cursor, + RawContactEntitlesInfoCallback rawContactEntitlesInfoCallback) { + if (!initInterFirstPart(null)) { + return false; + } + mCursorSuppliedFromOutside = true; + mCursor = cursor; + mRawContactEntitlesInfoCallback = rawContactEntitlesInfoCallback; + if (!initInterMainPart()) { + return false; + } + initEntityIterator(); + return initInterLastPart(); + } + + private boolean initInterFirstPart(Uri contentUriForRawContactsEntity) { + mContentUriForRawContactsEntity = + (contentUriForRawContactsEntity != null ? contentUriForRawContactsEntity : + RawContactsEntity.CONTENT_URI); + if (mInitDone) { + Log.e(LOG_TAG, "init() is already called"); + return false; + } + return true; + } + + private boolean initInterCursorCreationPart( + final Uri contentUri, final String[] projection, + final String selection, final String[] selectionArgs, final String sortOrder) { + mCursorSuppliedFromOutside = false; + mCursor = mContentResolver.query( + contentUri, projection, selection, selectionArgs, sortOrder); + if (mCursor == null) { + Log.e(LOG_TAG, String.format("Cursor became null unexpectedly")); + mErrorReason = FAILURE_REASON_FAILED_TO_GET_DATABASE_INFO; + return false; + } + return true; + } + + private boolean initInterMainPart() { + if (mCursor.getCount() == 0 || !mCursor.moveToFirst()) { + if (DEBUG) { + Log.d(LOG_TAG, + String.format("mCursor has an error (getCount: %d): ", mCursor.getCount())); + } + mErrorReason = FAILURE_REASON_NO_ENTRY; + closeCursorIfAppropriate(); + return false; + } + mIdColumn = mCursor.getColumnIndex(Data.CONTACT_ID); + if (mIdColumn < 0) { + mIdColumn = mCursor.getColumnIndex(Contacts._ID); + } + return mIdColumn >= 0; + } + + // Testing with 500 Contacts IDs, the export time using an EntityIterator containing all + // Contacts IDs is approximately 120ms (each EntityIterator containing one Contacts ID takes + // about 700ms). + // Initializing an EntityIterator with 500 Contacts IDs takes about 10ms. + private void initEntityIterator() { + StringBuilder selection = new StringBuilder(); + StringBuilder contactIds = new StringBuilder(); + Uri uri = mContentUriForRawContactsEntity; + + if (mRawContactEntitlesInfoCallback != null) { + RawContactEntitlesInfo rawContactEntitlesInfo = + mRawContactEntitlesInfoCallback.getRawContactEntitlesInfo( + mCursor.getLong(mIdColumn)); + uri = rawContactEntitlesInfo.rawContactEntitlesUri; + contactIds.append(rawContactEntitlesInfo.contactId); + } else { + do { + contactIds.append(mCursor.getString(mIdColumn)); + if (!mCursor.isLast()) { + contactIds.append(","); + } else { + break; + } + } while (mCursor.moveToNext()); + // There are some callers that assume that the cursor is at its first position after + // init. + // See b/402311014 + mCursor.moveToFirst(); + } + + selection.append(Data.CONTACT_ID).append(" IN ("); + selection.append(contactIds); + selection.append(")"); + + if (mGetEntityIteratorMethod != null) { + // Please note that this code is executed by unit tests only + try { + mEntityIterator = (EntityIterator) mGetEntityIteratorMethod.invoke(null, + mContentResolver, uri, selection.toString(), null, null); + } catch (IllegalArgumentException e) { + Log.e(LOG_TAG, "IllegalArgumentException has been thrown: " + + e.getMessage()); + } catch (IllegalAccessException e) { + Log.e(LOG_TAG, "IllegalAccessException has been thrown: " + + e.getMessage()); + } catch (InvocationTargetException e) { + Log.e(LOG_TAG, "InvocationTargetException has been thrown: ", e); + throw new RuntimeException("InvocationTargetException has been thrown"); + } + } else { + mEntityIterator = RawContacts + .newEntityIterator(mContentResolver.query(uri, null, + selection.toString(), null, Data.CONTACT_ID)); + } + } + + private boolean initInterLastPart() { + mInitDone = true; + mTerminateCalled = false; + return true; + } + + /** + * @return a vCard string. + */ + public String createOneEntry() { + if (!mInitDone) { + Log.w(LOG_TAG, "This object is not ready yet."); + return ""; + } + + if (mIsDoCoMo && !mFirstVCardEmittedInDoCoMoCase) { + mFirstVCardEmittedInDoCoMoCase = true; + // Previously we needed to emit empty data for this specific case, but actually + // this doesn't work now, as resolver doesn't return any data with "-1" contactId. + // TODO: re-introduce or remove this logic. Needs to modify unit test when we + // re-introduce the logic. + // return createOneEntryInternal("-1", getEntityIteratorMethod); + } + + return createOneEntryInternal(); + } + + /** + * Class that store rawContactEntitlesUri and contactId + */ + public static class RawContactEntitlesInfo { + public final Uri rawContactEntitlesUri; + public final long contactId; + public RawContactEntitlesInfo(Uri rawContactEntitlesUri, long contactId) { + this.rawContactEntitlesUri = rawContactEntitlesUri; + this.contactId = contactId; + } + } + + /** + * Listener for getting raw contact entitles info + */ + public interface RawContactEntitlesInfoCallback { + /** + * Callback to get RawContactEntitlesInfo from contact id + * + * @param contactId Contact id that you want to process. + * @return RawContactEntitlesInfo that ready to process. + */ + RawContactEntitlesInfo getRawContactEntitlesInfo(long contactId); + } + + private String createOneEntryInternal() { + if (!mEntityIterator.hasNext()) { + Log.w(LOG_TAG, "EntityIterator#hasNext() returned false"); + return ""; + } + final Map> contentValuesListMap = + new HashMap>(); + Entity entity = mEntityIterator.next(); + for (NamedContentValues namedContentValues : entity.getSubValues()) { + ContentValues contentValues = namedContentValues.values; + String key = contentValues.getAsString(Data.MIMETYPE); + if (key != null) { + List contentValuesList = contentValuesListMap.get(key); + if (contentValuesList == null) { + contentValuesList = new ArrayList(); + contentValuesListMap.put(key, contentValuesList); + } + contentValuesList.add(contentValues); + } + } + + return buildVCard(contentValuesListMap); + } + + private VCardPhoneNumberTranslationCallback mPhoneTranslationCallback; + /** + *

+ * Set a callback for phone number formatting. It will be called every time when this object + * receives a phone number for printing. + *

+ *

+ * When this is set {@link VCardConfig#FLAG_REFRAIN_PHONE_NUMBER_FORMATTING} will be ignored + * and the callback should be responsible for everything about phone number formatting. + *

+ *

+ * Caution: This interface will change. Please don't use without any strong reason. + *

+ */ + public void setPhoneNumberTranslationCallback(VCardPhoneNumberTranslationCallback callback) { + mPhoneTranslationCallback = callback; + } + + /** + * Builds and returns vCard using given map, whose key is CONTENT_ITEM_TYPE defined in + * {ContactsContract}. Developers can override this method to customize the output. + */ + public String buildVCard(final Map> contentValuesListMap) { + if (contentValuesListMap == null) { + Log.e(LOG_TAG, "The given map is null. Ignore and return empty String"); + return ""; + } else { + final VCardBuilder builder = new VCardBuilder(mVCardType, mCharset); + builder.appendNameProperties(contentValuesListMap.get( + StructuredName.CONTENT_ITEM_TYPE)) + .appendPhones(contentValuesListMap.get(Phone.CONTENT_ITEM_TYPE), + mPhoneTranslationCallback) + .appendRelation(contentValuesListMap.get(Relation.CONTENT_ITEM_TYPE)) + .appendIms(contentValuesListMap.get(Im.CONTENT_ITEM_TYPE)) + .appendSipAddresses(contentValuesListMap.get(SipAddress.CONTENT_ITEM_TYPE)); + + if ((mVCardType & VCardConfig.FLAG_REFRAIN_NICKNAME_EXPORT) == 0) { + builder.appendNickNames(contentValuesListMap.get(Nickname.CONTENT_ITEM_TYPE)); + } + if ((mVCardType & VCardConfig.FLAG_REFRAIN_EMAIL_EXPORT) == 0) { + builder.appendEmails(contentValuesListMap.get(Email.CONTENT_ITEM_TYPE)); + } + if ((mVCardType & VCardConfig.FLAG_REFRAIN_ADDRESS_EXPORT) == 0) { + builder.appendPostals(contentValuesListMap.get( + StructuredPostal.CONTENT_ITEM_TYPE)); + } + if ((mVCardType & VCardConfig.FLAG_REFRAIN_ORGANIZATION_EXPORT) == 0) { + builder.appendOrganizations(contentValuesListMap.get( + Organization.CONTENT_ITEM_TYPE)); + } + if ((mVCardType & VCardConfig.FLAG_REFRAIN_WEBSITES_EXPORT) == 0) { + builder.appendWebsites(contentValuesListMap.get(Website.CONTENT_ITEM_TYPE)); + } + if ((mVCardType & VCardConfig.FLAG_REFRAIN_IMAGE_EXPORT) == 0) { + builder.appendPhotos(contentValuesListMap.get(Photo.CONTENT_ITEM_TYPE)); + } + if ((mVCardType & VCardConfig.FLAG_REFRAIN_NOTES_EXPORT) == 0) { + builder.appendNotes(contentValuesListMap.get(Note.CONTENT_ITEM_TYPE)); + } + if ((mVCardType & VCardConfig.FLAG_REFRAIN_EVENTS_EXPORT) == 0) { + builder.appendEvents(contentValuesListMap.get(Event.CONTENT_ITEM_TYPE)); + } + return builder.toString(); + } + } + + public void terminate() { + closeCursorIfAppropriate(); + mTerminateCalled = true; + } + + private void closeCursorIfAppropriate() { + if (!mCursorSuppliedFromOutside && mCursor != null) { + try { + mCursor.close(); + } catch (SQLiteException e) { + Log.e(LOG_TAG, "SQLiteException on Cursor#close(): " + e.getMessage()); + } + mCursor = null; + } + if (mEntityIterator != null) { + try{ + mEntityIterator.close(); + } catch (SQLiteException e) { + Log.e(LOG_TAG, "SQLiteException on EntityIterator#close(): " + e.getMessage()); + } + mEntityIterator = null; + } + } + + @Override + protected void finalize() throws Throwable { + try { + if (!mTerminateCalled) { + Log.e(LOG_TAG, "finalized() is called before terminate() being called"); + } + } finally { + super.finalize(); + } + } + + /** + * @return returns the number of available entities. The return value is undefined + * when this object is not ready yet (typically when {{@link #init()} is not called + * or when {@link #terminate()} is already called). + */ + public int getCount() { + if (mCursor == null) { + Log.w(LOG_TAG, "This object is not ready yet."); + return 0; + } + return mCursor.getCount(); + } + + /** + * @return true when there's no entity to be built. The return value is undefined + * when this object is not ready yet. + */ + public boolean isAfterLast() { + if (mEntityIterator == null) { + Log.w(LOG_TAG, "This object is not ready yet."); + return false; + } + return !mEntityIterator.hasNext(); + } + + /** + * @return Returns the error reason. + */ + public String getErrorReason() { + return mErrorReason; + } +} diff --git a/modules/vcard/java/com/android/vcard/VCardConfig.java b/modules/vcard/java/com/android/vcard/VCardConfig.java new file mode 100644 index 0000000..675880e --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardConfig.java @@ -0,0 +1,554 @@ +/* + * 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.vcard; + +import android.telephony.PhoneNumberUtils; +import android.util.Log; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * The class representing VCard related configurations. Useful static methods are not in this class + * but in VCardUtils. + */ +public class VCardConfig { + private static final String LOG_TAG = VCardConstants.LOG_TAG; + + /* package */ static final int LOG_LEVEL_NONE = 0; + /* package */ static final int LOG_LEVEL_PERFORMANCE_MEASUREMENT = 0x1; + /* package */ static final int LOG_LEVEL_SHOW_WARNING = 0x2; + /* package */ static final int LOG_LEVEL_VERBOSE = + LOG_LEVEL_PERFORMANCE_MEASUREMENT | LOG_LEVEL_SHOW_WARNING; + + /* package */ static final int LOG_LEVEL = LOG_LEVEL_NONE; + + /** + *

+ * The charset used during import. + *

+ *

+ * We cannot determine which charset should be used to interpret lines in vCard, + * while Java requires us to specify it when InputStream is used. + * We need to rely on the mechanism due to some performance reason. + *

+ *

+ * In order to avoid "misinterpretation" of charset and lose any data in vCard, + * "ISO-8859-1" is first used for reading the stream. + * When a charset is specified in a property (with "CHARSET=..." parameter), + * the string is decoded to raw bytes and encoded into the specific charset, + *

+ *

+ * Unicode specification there's a one to one mapping between each byte in ISO-8859-1 + * and a codepoint, and Java specification requires runtime must have the charset. + * Thus, ISO-8859-1 is one effective mapping for intermediate mapping. + *

+ */ + public static final String DEFAULT_INTERMEDIATE_CHARSET = "ISO-8859-1"; + + /** + * The charset used when there's no information affbout what charset should be used to + * encode the binary given from vCard. + */ + public static final String DEFAULT_IMPORT_CHARSET = "UTF-8"; + public static final String DEFAULT_EXPORT_CHARSET = "UTF-8"; + + /** + * Do not use statically like "version == VERSION_V21" + */ + public static final int VERSION_21 = 0; + public static final int VERSION_30 = 1; + public static final int VERSION_40 = 2; + public static final int VERSION_MASK = 3; + + public static final int NAME_ORDER_DEFAULT = 0; + public static final int NAME_ORDER_EUROPE = 0x4; + public static final int NAME_ORDER_JAPANESE = 0x8; + private static final int NAME_ORDER_MASK = 0xC; + + // 0x10 is reserved for safety + + /** + *

+ * The flag indicating the vCard composer will add some "X-" properties used only in Android + * when the formal vCard specification does not have appropriate fields for that data. + *

+ *

+ * For example, Android accepts nickname information while vCard 2.1 does not. + * When this flag is on, vCard composer emits alternative "X-" property (like "X-NICKNAME") + * instead of just dropping it. + *

+ *

+ * vCard parser code automatically parses the field emitted even when this flag is off. + *

+ */ + private static final int FLAG_USE_ANDROID_PROPERTY = 0x80000000; + + /** + *

+ * The flag indicating the vCard composer will add some "X-" properties seen in the + * vCard data emitted by the other softwares/devices when the formal vCard specification + * does not have appropriate field(s) for that data. + *

+ *

+ * One example is X-PHONETIC-FIRST-NAME/X-PHONETIC-MIDDLE-NAME/X-PHONETIC-LAST-NAME, which are + * for phonetic name (how the name is pronounced), seen in the vCard emitted by some other + * non-Android devices/softwares. We chose to enable the vCard composer to use those + * defact properties since they are also useful for Android devices. + *

+ *

+ * Note for developers: only "X-" properties should be added with this flag. vCard 2.1/3.0 + * allows any kind of "X-" properties but does not allow non-"X-" properties (except IANA tokens + * in vCard 3.0). Some external parsers may get confused with non-valid, non-"X-" properties. + *

+ */ + private static final int FLAG_USE_DEFACT_PROPERTY = 0x40000000; + + /** + *

+ * The flag indicating some specific dialect seen in vCard of DoCoMo (one of Japanese + * mobile careers) should be used. This flag does not include any other information like + * that "the vCard is for Japanese". So it is "possible" that "the vCard should have DoCoMo's + * dialect but the name order should be European", but it is not recommended. + *

+ */ + private static final int FLAG_DOCOMO = 0x20000000; + + /** + *

+ * The flag indicating the vCard composer does "NOT" use Quoted-Printable toward "primary" + * properties even though it is required by vCard 2.1 (QP is prohibited in vCard 3.0). + *

+ *

+ * We actually cannot define what is the "primary" property. Note that this is NOT defined + * in vCard specification either. Also be aware that it is NOT related to "primary" notion + * used in {@link android.provider.ContactsContract}. + * This notion is just for vCard composition in Android. + *

+ *

+ * We added this Android-specific notion since some (incomplete) vCard exporters for vCard 2.1 + * do NOT use Quoted-Printable encoding toward some properties related names like "N", "FN", etc. + * even when their values contain non-ascii or/and CR/LF, while they use the encoding in the + * other properties like "ADR", "ORG", etc. + *

+ * We are afraid of the case where some vCard importer also forget handling QP presuming QP is + * not used in such fields. + *

+ *

+ * This flag is useful when some target importer you are going to focus on does not accept + * such properties with Quoted-Printable encoding. + *

+ *

+ * Again, we should not use this flag at all for complying vCard 2.1 spec. + *

+ *

+ * In vCard 3.0, Quoted-Printable is explicitly "prohibitted", so we don't need to care this + * kind of problem (hopefully). + *

+ * @hide + */ + public static final int FLAG_REFRAIN_QP_TO_NAME_PROPERTIES = 0x10000000; + + /** + *

+ * The flag indicating that phonetic name related fields must be converted to + * appropriate form. Note that "appropriate" is not defined in any vCard specification. + * This is Android-specific. + *

+ *

+ * One typical (and currently sole) example where we need this flag is the time when + * we need to emit Japanese phonetic names into vCard entries. The property values + * should be encoded into half-width katakana when the target importer is Japanese mobile + * phones', which are probably not able to parse full-width hiragana/katakana for + * historical reasons, while the vCard importers embedded to softwares for PC should be + * able to parse them as we expect. + *

+ */ + public static final int FLAG_CONVERT_PHONETIC_NAME_STRINGS = 0x08000000; + + /** + *

+ * The flag indicating the vCard composer "for 2.1" emits "TYPE=" string toward TYPE params + * every time possible. The default behavior does not emit it and is valid in the spec. + * In vCrad 3.0, this flag is unnecessary, since "TYPE=" is MUST in vCard 3.0 specification. + *

+ *

+ * Detail: + * How more than one TYPE fields are expressed is different between vCard 2.1 and vCard 3.0. + *

+ *

+ * e.g. + *

+ *
    + *
  1. Probably valid in both vCard 2.1 and vCard 3.0: "ADR;TYPE=DOM;TYPE=HOME:..."
  2. + *
  3. Valid in vCard 2.1 but not in vCard 3.0: "ADR;DOM;HOME:..."
  4. + *
  5. Valid in vCard 3.0 but not in vCard 2.1: "ADR;TYPE=DOM,HOME:..."
  6. + *
+ *

+ * If you are targeting to the importer which cannot accept TYPE params without "TYPE=" + * strings (which should be rare though), please use this flag. + *

+ *

+ * Example usage: + *

int type = (VCARD_TYPE_V21_GENERIC | FLAG_APPEND_TYPE_PARAM);
+ *

+ */ + public static final int FLAG_APPEND_TYPE_PARAM = 0x04000000; + + /** + *

+ * The flag indicating the vCard composer does touch nothing toward phone number Strings + * but leave it as is. + *

+ *

+ * The vCard specifications mention nothing toward phone numbers, while some devices + * do (wrongly, but with innevitable reasons). + * For example, there's a possibility Japanese mobile phones are expected to have + * just numbers, hypens, plus, etc. but not usual alphabets, while US mobile phones + * should get such characters. To make exported vCard simple for external parsers, + * we have used {@link PhoneNumberUtils#formatNumber(String)} during export, and + * removed unnecessary characters inside the number (e.g. "111-222-3333 (Miami)" + * becomes "111-222-3333"). + * Unfortunate side effect of that use was some control characters used in the other + * areas may be badly affected by the formatting. + *

+ *

+ * This flag disables that formatting, affecting both importer and exporter. + * If the user is aware of some side effects due to the implicit formatting, use this flag. + *

+ *

+ * Caution: this flag will be removed in the future, replaced by some richer functionality. + *

+ */ + public static final int FLAG_REFRAIN_PHONE_NUMBER_FORMATTING = 0x02000000; + + /** + *

+ * The flag asking exporter to refrain image export. + *

+ * @hide will be deleted in the near future. + */ + public static final int FLAG_REFRAIN_IMAGE_EXPORT = 0x00800000; + + /** + *

+ * The flag asking exporter to refrain events export. + *

+ * @hide will be deleted in the near future. + */ + public static final int FLAG_REFRAIN_EVENTS_EXPORT = 0x00400000; + + /** + *

+ * The flag asking exporter to refrain addess export. + *

+ * @hide will be deleted in the near future. + */ + public static final int FLAG_REFRAIN_ADDRESS_EXPORT = 0x00200000; + + /** + *

+ * The flag asking exporter to refrain email export. + *

+ * @hide will be deleted in the near future. + */ + public static final int FLAG_REFRAIN_EMAIL_EXPORT = 0x00100000; + + /** + *

+ * The flag asking exporter to refrain organization export. + *

+ * @hide will be deleted in the near future. + */ + public static final int FLAG_REFRAIN_ORGANIZATION_EXPORT = 0x00080000; + + /** + *

+ * The flag asking exporter to refrain notes export. + *

+ * @hide will be deleted in the near future. + */ + public static final int FLAG_REFRAIN_NOTES_EXPORT = 0x00040000; + + /** + *

+ * The flag asking exporter to refrain phonetic name export. + *

+ * @hide will be deleted in the near future. + */ + public static final int FLAG_REFRAIN_PHONETIC_NAME_EXPORT = 0x00020000; + + /** + *

+ * The flag asking exporter to refrain websites export. + *

+ * @hide will be deleted in the near future. + */ + public static final int FLAG_REFRAIN_WEBSITES_EXPORT = 0x00010000; + + /** + *

+ * The flag asking exporter to refrain nickname export. + *

+ * @hide will be deleted in the near future. + */ + public static final int FLAG_REFRAIN_NICKNAME_EXPORT = 0x00008000; + + //// The followings are VCard types available from importer/exporter. //// + + /** + *

+ * The type indicating nothing. Used by {@link VCardSourceDetector} when it + * was not able to guess the exact vCard type. + *

+ */ + public static final int VCARD_TYPE_UNKNOWN = 0; + + /** + *

+ * Generic vCard format with the vCard 2.1. When composing a vCard entry, + * the US convension will be used toward formatting some values. + *

+ *

+ * e.g. The order of the display name would be "Prefix Given Middle Family Suffix", + * while it should be "Prefix Family Middle Given Suffix" in Japan for example. + *

+ *

+ * Uses UTF-8 for the charset as a charset for exporting. Note that old vCard importer + * outside Android cannot accept it since vCard 2.1 specifically does not allow + * that charset, while we need to use it to support various languages around the world. + *

+ *

+ * If you want to use alternative charset, you should notify the charset to the other + * compontent to be used. + *

+ */ + public static final int VCARD_TYPE_V21_GENERIC = + (VERSION_21 | NAME_ORDER_DEFAULT | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY); + + /* package */ static String VCARD_TYPE_V21_GENERIC_STR = "v21_generic"; + + /** + *

+ * General vCard format with the version 3.0. Uses UTF-8 for the charset. + *

+ *

+ * Not fully ready yet. Use with caution when you use this. + *

+ */ + public static final int VCARD_TYPE_V30_GENERIC = + (VERSION_30 | NAME_ORDER_DEFAULT | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY); + + /* package */ static final String VCARD_TYPE_V30_GENERIC_STR = "v30_generic"; + + /** + * General vCard format with the version 4.0. + * @hide vCard 4.0 is not published yet. + */ + public static final int VCARD_TYPE_V40_GENERIC = + (VERSION_40 | NAME_ORDER_DEFAULT | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY); + + /* package */ static final String VCARD_TYPE_V40_GENERIC_STR = "v40_generic"; + + /** + *

+ * General vCard format for the vCard 2.1 with some Europe convension. Uses Utf-8. + * Currently, only name order is considered ("Prefix Middle Given Family Suffix") + *

+ */ + public static final int VCARD_TYPE_V21_EUROPE = + (VERSION_21 | NAME_ORDER_EUROPE | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY); + + /* package */ static final String VCARD_TYPE_V21_EUROPE_STR = "v21_europe"; + + /** + *

+ * General vCard format with the version 3.0 with some Europe convension. Uses UTF-8. + *

+ *

+ * Not ready yet. Use with caution when you use this. + *

+ */ + public static final int VCARD_TYPE_V30_EUROPE = + (VERSION_30 | NAME_ORDER_EUROPE | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY); + + /* package */ static final String VCARD_TYPE_V30_EUROPE_STR = "v30_europe"; + + /** + *

+ * The vCard 2.1 format for miscellaneous Japanese devices, using UTF-8 as default charset. + *

+ *

+ * Not ready yet. Use with caution when you use this. + *

+ */ + public static final int VCARD_TYPE_V21_JAPANESE = + (VERSION_21 | NAME_ORDER_JAPANESE | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY); + + /* package */ static final String VCARD_TYPE_V21_JAPANESE_STR = "v21_japanese_utf8"; + + /** + *

+ * The vCard 3.0 format for miscellaneous Japanese devices, using UTF-8 as default charset. + *

+ *

+ * Not ready yet. Use with caution when you use this. + *

+ */ + public static final int VCARD_TYPE_V30_JAPANESE = + (VERSION_30 | NAME_ORDER_JAPANESE | FLAG_USE_DEFACT_PROPERTY | FLAG_USE_ANDROID_PROPERTY); + + /* package */ static final String VCARD_TYPE_V30_JAPANESE_STR = "v30_japanese_utf8"; + + /** + *

+ * The vCard 2.1 based format which (partially) considers the convention in Japanese + * mobile phones, where phonetic names are translated to half-width katakana if + * possible, etc. It would be better to use Shift_JIS as a charset for maximum + * compatibility. + *

+ * @hide Should not be available world wide. + */ + public static final int VCARD_TYPE_V21_JAPANESE_MOBILE = + (VERSION_21 | NAME_ORDER_JAPANESE | + FLAG_CONVERT_PHONETIC_NAME_STRINGS | FLAG_REFRAIN_QP_TO_NAME_PROPERTIES); + + /* package */ static final String VCARD_TYPE_V21_JAPANESE_MOBILE_STR = "v21_japanese_mobile"; + + /** + *

+ * The vCard format used in DoCoMo, which is one of Japanese mobile phone careers. + *

+ *

+ * Base version is vCard 2.1, but the data has several DoCoMo-specific convensions. + * No Android-specific property nor defact property is included. The "Primary" properties + * are NOT encoded to Quoted-Printable. + *

+ * @hide Should not be available world wide. + */ + public static final int VCARD_TYPE_DOCOMO = + (VCARD_TYPE_V21_JAPANESE_MOBILE | FLAG_DOCOMO); + + /* package */ static final String VCARD_TYPE_DOCOMO_STR = "docomo"; + + public static int VCARD_TYPE_DEFAULT = VCARD_TYPE_V21_GENERIC; + + private static final Map sVCardTypeMap; + private static final Set sJapaneseMobileTypeSet; + + static { + sVCardTypeMap = new HashMap(); + sVCardTypeMap.put(VCARD_TYPE_V21_GENERIC_STR, VCARD_TYPE_V21_GENERIC); + sVCardTypeMap.put(VCARD_TYPE_V30_GENERIC_STR, VCARD_TYPE_V30_GENERIC); + sVCardTypeMap.put(VCARD_TYPE_V21_EUROPE_STR, VCARD_TYPE_V21_EUROPE); + sVCardTypeMap.put(VCARD_TYPE_V30_EUROPE_STR, VCARD_TYPE_V30_EUROPE); + sVCardTypeMap.put(VCARD_TYPE_V21_JAPANESE_STR, VCARD_TYPE_V21_JAPANESE); + sVCardTypeMap.put(VCARD_TYPE_V30_JAPANESE_STR, VCARD_TYPE_V30_JAPANESE); + sVCardTypeMap.put(VCARD_TYPE_V21_JAPANESE_MOBILE_STR, VCARD_TYPE_V21_JAPANESE_MOBILE); + sVCardTypeMap.put(VCARD_TYPE_DOCOMO_STR, VCARD_TYPE_DOCOMO); + + sJapaneseMobileTypeSet = new HashSet(); + sJapaneseMobileTypeSet.add(VCARD_TYPE_V21_JAPANESE); + sJapaneseMobileTypeSet.add(VCARD_TYPE_V30_JAPANESE); + sJapaneseMobileTypeSet.add(VCARD_TYPE_V21_JAPANESE_MOBILE); + sJapaneseMobileTypeSet.add(VCARD_TYPE_DOCOMO); + } + + public static int getVCardTypeFromString(final String vcardTypeString) { + final String loweredKey = vcardTypeString.toLowerCase(); + if (sVCardTypeMap.containsKey(loweredKey)) { + return sVCardTypeMap.get(loweredKey); + } else if ("default".equalsIgnoreCase(vcardTypeString)) { + return VCARD_TYPE_DEFAULT; + } else { + Log.e(LOG_TAG, "Unknown vCard type String: \"" + vcardTypeString + "\""); + return VCARD_TYPE_DEFAULT; + } + } + + public static boolean isVersion21(final int vcardType) { + return (vcardType & VERSION_MASK) == VERSION_21; + } + + public static boolean isVersion30(final int vcardType) { + return (vcardType & VERSION_MASK) == VERSION_30; + } + + public static boolean isVersion40(final int vcardType) { + return (vcardType & VERSION_MASK) == VERSION_40; + } + + public static boolean shouldUseQuotedPrintable(final int vcardType) { + return !isVersion30(vcardType); + } + + public static int getNameOrderType(final int vcardType) { + return vcardType & NAME_ORDER_MASK; + } + + public static boolean usesAndroidSpecificProperty(final int vcardType) { + return ((vcardType & FLAG_USE_ANDROID_PROPERTY) != 0); + } + + public static boolean usesDefactProperty(final int vcardType) { + return ((vcardType & FLAG_USE_DEFACT_PROPERTY) != 0); + } + + public static boolean showPerformanceLog() { + return (VCardConfig.LOG_LEVEL & VCardConfig.LOG_LEVEL_PERFORMANCE_MEASUREMENT) != 0; + } + + public static boolean shouldRefrainQPToNameProperties(final int vcardType) { + return (!shouldUseQuotedPrintable(vcardType) || + ((vcardType & FLAG_REFRAIN_QP_TO_NAME_PROPERTIES) != 0)); + } + + public static boolean appendTypeParamName(final int vcardType) { + return (isVersion30(vcardType) || ((vcardType & FLAG_APPEND_TYPE_PARAM) != 0)); + } + + /** + * @return true if the device is Japanese and some Japanese convension is + * applied to creating "formatted" something like FORMATTED_ADDRESS. + */ + public static boolean isJapaneseDevice(final int vcardType) { + // TODO: Some mask will be required so that this method wrongly interpret + // Japanese"-like" vCard type. + // e.g. VCARD_TYPE_V21_JAPANESE_SJIS | FLAG_APPEND_TYPE_PARAMS + return sJapaneseMobileTypeSet.contains(vcardType); + } + + /* package */ static boolean refrainPhoneNumberFormatting(final int vcardType) { + return ((vcardType & FLAG_REFRAIN_PHONE_NUMBER_FORMATTING) != 0); + } + + public static boolean needsToConvertPhoneticString(final int vcardType) { + return ((vcardType & FLAG_CONVERT_PHONETIC_NAME_STRINGS) != 0); + } + + public static boolean onlyOneNoteFieldIsAvailable(final int vcardType) { + return vcardType == VCARD_TYPE_DOCOMO; + } + + public static boolean isDoCoMo(final int vcardType) { + return ((vcardType & FLAG_DOCOMO) != 0); + } + + private VCardConfig() { + } +} \ No newline at end of file diff --git a/modules/vcard/java/com/android/vcard/VCardConstants.java b/modules/vcard/java/com/android/vcard/VCardConstants.java new file mode 100644 index 0000000..ed0c34e --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardConstants.java @@ -0,0 +1,190 @@ +/* + * 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.vcard; + +/** + * Constants used in both exporter and importer code. + */ +public class VCardConstants { + /* package */ static final String LOG_TAG = "vCard"; + public static final String VERSION_V21 = "2.1"; + public static final String VERSION_V30 = "3.0"; + public static final String VERSION_V40 = "4.0"; + + // Valid and supported property names. + public static final String PROPERTY_BEGIN = "BEGIN"; + public static final String PROPERTY_VERSION = "VERSION"; + public static final String PROPERTY_N = "N"; + public static final String PROPERTY_FN = "FN"; + public static final String PROPERTY_ADR = "ADR"; + public static final String PROPERTY_EMAIL = "EMAIL"; + public static final String PROPERTY_NOTE = "NOTE"; + public static final String PROPERTY_ORG = "ORG"; + public static final String PROPERTY_SOUND = "SOUND"; // Not fully supported. + public static final String PROPERTY_TEL = "TEL"; + public static final String PROPERTY_TITLE = "TITLE"; + public static final String PROPERTY_ROLE = "ROLE"; + public static final String PROPERTY_PHOTO = "PHOTO"; + public static final String PROPERTY_LOGO = "LOGO"; + public static final String PROPERTY_URL = "URL"; + public static final String PROPERTY_BDAY = "BDAY"; // Birthday (3.0, 4.0) + public static final String PROPERTY_ANNIVERSARY = "ANNIVERSARY"; // Date of marriage (4.0) + public static final String PROPERTY_NAME = "NAME"; // (3.0) + public static final String PROPERTY_NICKNAME = "NICKNAME"; // (3.0, 4.0) + public static final String PROPERTY_SORT_STRING = "SORT-STRING"; // (3.0, 4.0) + public static final String PROPERTY_IMPP = "IMPP"; // RFC 4770 (vCard 3.0) and vCard 4.0 + public static final String PROPERTY_END = "END"; + + // Valid property names which are not supported (not appropriately handled) by + // importer/exporter. Those properties will be read and discarded silently. + public static final String PROPERTY_REV = "REV"; + public static final String PROPERTY_AGENT = "AGENT"; // (3.0) + public static final String PROPERTY_GENDER = "GENDER"; // (4.0) + public static final String PROPERTY_XML = "XML"; // (4.0) + public static final String PROPERTY_FBURL = "FBURL"; // (4.0) + public static final String PROPERTY_PRODID = "PRODID"; // (4.0) + public static final String PROPERTY_RELATED = "RELATED"; // (4.0) + public static final String PROPERTY_CATEGORIES = "CATEGORIES"; // (4.0) + public static final String PROPERTY_CLIENTPIDMAP = "CLIENTPIDMAP"; // (4.0) + public static final String PROPERTY_CALURI = "CALURI"; // (4.0) + + + + // defact SIP property which had been used till RFC 4770. + public static final String PROPERTY_X_SIP = "X-SIP"; + + // Available in vCard 3.0. Shoud not use when composing vCard 2.1 file. + + // De-fact property values expressing phonetic names. + public static final String PROPERTY_X_PHONETIC_FIRST_NAME = "X-PHONETIC-FIRST-NAME"; + public static final String PROPERTY_X_PHONETIC_MIDDLE_NAME = "X-PHONETIC-MIDDLE-NAME"; + public static final String PROPERTY_X_PHONETIC_LAST_NAME = "X-PHONETIC-LAST-NAME"; + + // Properties both ContactsStruct and de-fact vCard extensions + // Shown in http://en.wikipedia.org/wiki/VCard support are defined here. + public static final String PROPERTY_X_AIM = "X-AIM"; + public static final String PROPERTY_X_MSN = "X-MSN"; + public static final String PROPERTY_X_YAHOO = "X-YAHOO"; + public static final String PROPERTY_X_ICQ = "X-ICQ"; + public static final String PROPERTY_X_JABBER = "X-JABBER"; + public static final String PROPERTY_X_GOOGLE_TALK = "X-GOOGLE-TALK"; + public static final String PROPERTY_X_SKYPE_USERNAME = "X-SKYPE-USERNAME"; + // Properties only ContactsStruct has. We alse use this. + public static final String PROPERTY_X_QQ = "X-QQ"; + public static final String PROPERTY_X_NETMEETING = "X-NETMEETING"; + + // Phone number for Skype, available as usual phone. + public static final String PROPERTY_X_SKYPE_PSTNNUMBER = "X-SKYPE-PSTNNUMBER"; + + // Property for Android-specific fields. + public static final String PROPERTY_X_ANDROID_CUSTOM = "X-ANDROID-CUSTOM"; + + // Properties for DoCoMo vCard. + public static final String PROPERTY_X_CLASS = "X-CLASS"; + public static final String PROPERTY_X_REDUCTION = "X-REDUCTION"; + public static final String PROPERTY_X_NO = "X-NO"; + public static final String PROPERTY_X_DCM_HMN_MODE = "X-DCM-HMN-MODE"; + + public static final String PARAM_TYPE = "TYPE"; + + public static final String PARAM_TYPE_HOME = "HOME"; + public static final String PARAM_TYPE_WORK = "WORK"; + public static final String PARAM_TYPE_FAX = "FAX"; + public static final String PARAM_TYPE_CELL = "CELL"; + public static final String PARAM_TYPE_VOICE = "VOICE"; + public static final String PARAM_TYPE_INTERNET = "INTERNET"; + + public static final String PARAM_VALUE = "VALUE"; + public static final String PARAM_CHARSET = "CHARSET"; + public static final String PARAM_ENCODING = "ENCODING"; + + // Abbreviation of "prefered" according to vCard 2.1 specification. + // We interpret this value as "primary" property during import/export. + // + // Note: Both vCard specs does not mention anything about the requirement for this parameter, + // but there may be some vCard importer which will get confused with more than + // one "PREF"s in one property name, while Android accepts them. + public static final String PARAM_TYPE_PREF = "PREF"; + + // Phone type parameters valid in vCard and known to ContactsContract, but not so common. + public static final String PARAM_TYPE_CAR = "CAR"; + public static final String PARAM_TYPE_ISDN = "ISDN"; + public static final String PARAM_TYPE_PAGER = "PAGER"; + public static final String PARAM_TYPE_TLX = "TLX"; // Telex + + // Phone types existing in vCard 2.1 but not known to ContactsContract. + public static final String PARAM_TYPE_MODEM = "MODEM"; + public static final String PARAM_TYPE_MSG = "MSG"; + public static final String PARAM_TYPE_BBS = "BBS"; + public static final String PARAM_TYPE_VIDEO = "VIDEO"; + + public static final String PARAM_ENCODING_7BIT = "7BIT"; + public static final String PARAM_ENCODING_8BIT = "8BIT"; + public static final String PARAM_ENCODING_QP = "QUOTED-PRINTABLE"; + public static final String PARAM_ENCODING_BASE64 = "BASE64"; // Available in vCard 2.1 + public static final String PARAM_ENCODING_B = "B"; // Available in vCard 3.0 + + // TYPE parameters for Phones, which are not formally valid in vCard (at least 2.1). + // These types are basically encoded to "X-" parameters when composing vCard. + // Parser passes these when "X-" is added to the parameter or not. + public static final String PARAM_PHONE_EXTRA_TYPE_CALLBACK = "CALLBACK"; + public static final String PARAM_PHONE_EXTRA_TYPE_RADIO = "RADIO"; + public static final String PARAM_PHONE_EXTRA_TYPE_TTY_TDD = "TTY-TDD"; + public static final String PARAM_PHONE_EXTRA_TYPE_ASSISTANT = "ASSISTANT"; + // vCard composer translates this type to "WORK" + "PREF". Just for parsing. + public static final String PARAM_PHONE_EXTRA_TYPE_COMPANY_MAIN = "COMPANY-MAIN"; + // vCard composer translates this type to "VOICE" Just for parsing. + public static final String PARAM_PHONE_EXTRA_TYPE_OTHER = "OTHER"; + + // TYPE parameters for postal addresses. + public static final String PARAM_ADR_TYPE_PARCEL = "PARCEL"; + public static final String PARAM_ADR_TYPE_DOM = "DOM"; + public static final String PARAM_ADR_TYPE_INTL = "INTL"; + // {@link VCardBuilder} translates this type to "X-OTHER". + public static final String PARAM_ADR_EXTRA_TYPE_OTHER = "OTHER"; + + public static final String PARAM_LANGUAGE = "LANGUAGE"; + + // SORT-AS parameter introduced in vCard 4.0 (as of rev.13) + public static final String PARAM_SORT_AS = "SORT-AS"; + + // TYPE parameters not officially valid but used in some vCard exporter. + // Do not use in composer side. + public static final String PARAM_EXTRA_TYPE_COMPANY = "COMPANY"; + + public interface ImportOnly { + public static final String PROPERTY_X_NICKNAME = "X-NICKNAME"; + // Some device emits this "X-" parameter for expressing Google Talk, + // which is specifically invalid but should be always properly accepted, and emitted + // in some special case (for that device/application). + public static final String PROPERTY_X_GOOGLE_TALK_WITH_SPACE = "X-GOOGLE TALK"; + } + + //// Mainly for package constants. + + // DoCoMo specific type parameter. Used with "SOUND" property, which is alternate of + // SORT-STRING invCard 3.0. + /* package */ static final String PARAM_TYPE_X_IRMC_N = "X-IRMC-N"; + + // Used in unit test. + public static final int MAX_DATA_COLUMN = 15; + + /* package */ static final int MAX_CHARACTER_NUMS_QP = 76; + static final int MAX_CHARACTER_NUMS_BASE64_V30 = 75; + + private VCardConstants() { + } +} \ No newline at end of file diff --git a/modules/vcard/java/com/android/vcard/VCardEntry.java b/modules/vcard/java/com/android/vcard/VCardEntry.java new file mode 100644 index 0000000..04b7b24 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardEntry.java @@ -0,0 +1,2693 @@ +/* + * 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.vcard; + +import com.android.vcard.VCardUtils.PhoneNumberUtilsPort; + +import android.accounts.Account; +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.net.Uri; +import android.provider.ContactsContract; +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.SipAddress; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; +import android.provider.ContactsContract.CommonDataKinds.Website; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.RawContacts; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.util.Log; +import android.util.Pair; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents one vCard entry, which should start with "BEGIN:VCARD" and end + * with "END:VCARD". This class is for bridging between real vCard data and + * Android's {@link ContactsContract}, which means some aspects of vCard are + * dropped before this object being constructed. Raw vCard data should be first + * supplied with {@link #addProperty(VCardProperty)}. After supplying all data, + * user should call {@link #consolidateFields()} to prepare some additional + * information which is constructable from supplied raw data. TODO: preserve raw + * data using {@link VCardProperty}. If it may just waste memory, this at least + * should contain them when it cannot convert vCard as a string to Android's + * Contacts representation. Those raw properties should _not_ be used for + * {@link #isIgnorable()}. + */ +public class VCardEntry { + private static final String LOG_TAG = VCardConstants.LOG_TAG; + + private static final int DEFAULT_ORGANIZATION_TYPE = Organization.TYPE_WORK; + + private static final Map sImMap = new HashMap(); + + static { + sImMap.put(VCardConstants.PROPERTY_X_AIM, Im.PROTOCOL_AIM); + sImMap.put(VCardConstants.PROPERTY_X_MSN, Im.PROTOCOL_MSN); + sImMap.put(VCardConstants.PROPERTY_X_YAHOO, Im.PROTOCOL_YAHOO); + sImMap.put(VCardConstants.PROPERTY_X_ICQ, Im.PROTOCOL_ICQ); + sImMap.put(VCardConstants.PROPERTY_X_JABBER, Im.PROTOCOL_JABBER); + sImMap.put(VCardConstants.PROPERTY_X_SKYPE_USERNAME, Im.PROTOCOL_SKYPE); + sImMap.put(VCardConstants.PROPERTY_X_GOOGLE_TALK, Im.PROTOCOL_GOOGLE_TALK); + sImMap.put(VCardConstants.ImportOnly.PROPERTY_X_GOOGLE_TALK_WITH_SPACE, + Im.PROTOCOL_GOOGLE_TALK); + } + + /** + * Whether to insert this VCardEntry as RawContacts.STARRED + */ + private boolean mStarred = false; + + public void setStarred(boolean val) { + mStarred = val; + } + public boolean getStarred() { + return mStarred; + } + + public enum EntryLabel { + NAME, + PHONE, + EMAIL, + POSTAL_ADDRESS, + ORGANIZATION, + IM, + PHOTO, + WEBSITE, + SIP, + NICKNAME, + NOTE, + BIRTHDAY, + ANNIVERSARY, + ANDROID_CUSTOM + } + + public static interface EntryElement { + // Also need to inherit toString(), equals(). + public EntryLabel getEntryLabel(); + + public void constructInsertOperation(List operationList, + int backReferenceIndex); + + public boolean isEmpty(); + } + + // TODO: vCard 4.0 logically has multiple formatted names and we need to + // select the most preferable one using PREF parameter. + // + // e.g. (based on rev.13) + // FN;PREF=1:John M. Doe + // FN;PREF=2:John Doe + // FN;PREF=3;John + public static class NameData implements EntryElement { + private String mFamily; + private String mGiven; + private String mMiddle; + private String mPrefix; + private String mSuffix; + + // Used only when no family nor given name is found. + private String mFormatted; + + private String mPhoneticFamily; + private String mPhoneticGiven; + private String mPhoneticMiddle; + + // For "SORT-STRING" in vCard 3.0. + private String mSortString; + + /** + * Not in vCard but for {@link StructuredName#DISPLAY_NAME}. This field + * is constructed by VCardEntry on demand. Consider using + * {@link VCardEntry#getDisplayName()}. + */ + // This field should reflect the other Elem fields like Email, + // PostalAddress, etc., while + // This is static class which cannot see other data. Thus we ask + // VCardEntry to populate it. + public String displayName; + + public boolean emptyStructuredName() { + return TextUtils.isEmpty(mFamily) && TextUtils.isEmpty(mGiven) + && TextUtils.isEmpty(mMiddle) && TextUtils.isEmpty(mPrefix) + && TextUtils.isEmpty(mSuffix); + } + + public boolean emptyPhoneticStructuredName() { + return TextUtils.isEmpty(mPhoneticFamily) && TextUtils.isEmpty(mPhoneticGiven) + && TextUtils.isEmpty(mPhoneticMiddle); + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(StructuredName.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); + + if (!TextUtils.isEmpty(mGiven)) { + builder.withValue(StructuredName.GIVEN_NAME, mGiven); + } + if (!TextUtils.isEmpty(mFamily)) { + builder.withValue(StructuredName.FAMILY_NAME, mFamily); + } + if (!TextUtils.isEmpty(mMiddle)) { + builder.withValue(StructuredName.MIDDLE_NAME, mMiddle); + } + if (!TextUtils.isEmpty(mPrefix)) { + builder.withValue(StructuredName.PREFIX, mPrefix); + } + if (!TextUtils.isEmpty(mSuffix)) { + builder.withValue(StructuredName.SUFFIX, mSuffix); + } + + boolean phoneticNameSpecified = false; + + if (!TextUtils.isEmpty(mPhoneticGiven)) { + builder.withValue(StructuredName.PHONETIC_GIVEN_NAME, mPhoneticGiven); + phoneticNameSpecified = true; + } + if (!TextUtils.isEmpty(mPhoneticFamily)) { + builder.withValue(StructuredName.PHONETIC_FAMILY_NAME, mPhoneticFamily); + phoneticNameSpecified = true; + } + if (!TextUtils.isEmpty(mPhoneticMiddle)) { + builder.withValue(StructuredName.PHONETIC_MIDDLE_NAME, mPhoneticMiddle); + phoneticNameSpecified = true; + } + + // SORT-STRING is used only when phonetic names aren't specified in + // the original vCard. + if (!phoneticNameSpecified) { + builder.withValue(StructuredName.PHONETIC_GIVEN_NAME, mSortString); + } + + builder.withValue(StructuredName.DISPLAY_NAME, displayName); + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return (TextUtils.isEmpty(mFamily) && TextUtils.isEmpty(mMiddle) + && TextUtils.isEmpty(mGiven) && TextUtils.isEmpty(mPrefix) + && TextUtils.isEmpty(mSuffix) && TextUtils.isEmpty(mFormatted) + && TextUtils.isEmpty(mPhoneticFamily) && TextUtils.isEmpty(mPhoneticMiddle) + && TextUtils.isEmpty(mPhoneticGiven) && TextUtils.isEmpty(mSortString)); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof NameData)) { + return false; + } + NameData nameData = (NameData) obj; + + return (TextUtils.equals(mFamily, nameData.mFamily) + && TextUtils.equals(mMiddle, nameData.mMiddle) + && TextUtils.equals(mGiven, nameData.mGiven) + && TextUtils.equals(mPrefix, nameData.mPrefix) + && TextUtils.equals(mSuffix, nameData.mSuffix) + && TextUtils.equals(mFormatted, nameData.mFormatted) + && TextUtils.equals(mPhoneticFamily, nameData.mPhoneticFamily) + && TextUtils.equals(mPhoneticMiddle, nameData.mPhoneticMiddle) + && TextUtils.equals(mPhoneticGiven, nameData.mPhoneticGiven) + && TextUtils.equals(mSortString, nameData.mSortString)); + } + + @Override + public int hashCode() { + final String[] hashTargets = new String[] {mFamily, mMiddle, mGiven, mPrefix, mSuffix, + mFormatted, mPhoneticFamily, mPhoneticMiddle, + mPhoneticGiven, mSortString}; + int hash = 0; + for (String hashTarget : hashTargets) { + hash = hash * 31 + (hashTarget != null ? hashTarget.hashCode() : 0); + } + return hash; + } + + @Override + public String toString() { + return String.format("family: %s, given: %s, middle: %s, prefix: %s, suffix: %s", + mFamily, mGiven, mMiddle, mPrefix, mSuffix); + } + + @Override + public final EntryLabel getEntryLabel() { + return EntryLabel.NAME; + } + + public String getFamily() { + return mFamily; + } + + public String getMiddle() { + return mMiddle; + } + + public String getGiven() { + return mGiven; + } + + public String getPrefix() { + return mPrefix; + } + + public String getSuffix() { + return mSuffix; + } + + public String getFormatted() { + return mFormatted; + } + + public String getSortString() { + return mSortString; + } + + /** @hide Just for testing. */ + public void setFamily(String family) { mFamily = family; } + /** @hide Just for testing. */ + public void setMiddle(String middle) { mMiddle = middle; } + /** @hide Just for testing. */ + public void setGiven(String given) { mGiven = given; } + /** @hide Just for testing. */ + public void setPrefix(String prefix) { mPrefix = prefix; } + /** @hide Just for testing. */ + public void setSuffix(String suffix) { mSuffix = suffix; } + } + + public static class PhoneData implements EntryElement { + private final String mNumber; + private final int mType; + private final String mLabel; + + // isPrimary is (not final but) changable, only when there's no + // appropriate one existing + // in the original VCard. + private boolean mIsPrimary; + + public PhoneData(String data, int type, String label, boolean isPrimary) { + mNumber = data; + mType = type; + mLabel = label; + mIsPrimary = isPrimary; + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(Phone.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE); + + builder.withValue(Phone.TYPE, mType); + if (mType == Phone.TYPE_CUSTOM) { + builder.withValue(Phone.LABEL, mLabel); + } + builder.withValue(Phone.NUMBER, mNumber); + if (mIsPrimary) { + builder.withValue(Phone.IS_PRIMARY, 1); + } + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return TextUtils.isEmpty(mNumber); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof PhoneData)) { + return false; + } + PhoneData phoneData = (PhoneData) obj; + return (mType == phoneData.mType + && TextUtils.equals(mNumber, phoneData.mNumber) + && TextUtils.equals(mLabel, phoneData.mLabel) + && (mIsPrimary == phoneData.mIsPrimary)); + } + + @Override + public int hashCode() { + int hash = mType; + hash = hash * 31 + (mNumber != null ? mNumber.hashCode() : 0); + hash = hash * 31 + (mLabel != null ? mLabel.hashCode() : 0); + hash = hash * 31 + (mIsPrimary ? 1231 : 1237); + return hash; + } + + @Override + public String toString() { + return String.format("type: %d, data: %s, label: %s, isPrimary: %s", mType, mNumber, + mLabel, mIsPrimary); + } + + @Override + public final EntryLabel getEntryLabel() { + return EntryLabel.PHONE; + } + + public String getNumber() { + return mNumber; + } + + public int getType() { + return mType; + } + + public String getLabel() { + return mLabel; + } + + public boolean isPrimary() { + return mIsPrimary; + } + } + + public static class EmailData implements EntryElement { + private final String mAddress; + private final int mType; + // Used only when TYPE is TYPE_CUSTOM. + private final String mLabel; + private final boolean mIsPrimary; + + public EmailData(String data, int type, String label, boolean isPrimary) { + mType = type; + mAddress = data; + mLabel = label; + mIsPrimary = isPrimary; + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(Email.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE); + + builder.withValue(Email.TYPE, mType); + if (mType == Email.TYPE_CUSTOM) { + builder.withValue(Email.LABEL, mLabel); + } + builder.withValue(Email.DATA, mAddress); + if (mIsPrimary) { + builder.withValue(Data.IS_PRIMARY, 1); + } + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return TextUtils.isEmpty(mAddress); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof EmailData)) { + return false; + } + EmailData emailData = (EmailData) obj; + return (mType == emailData.mType + && TextUtils.equals(mAddress, emailData.mAddress) + && TextUtils.equals(mLabel, emailData.mLabel) + && (mIsPrimary == emailData.mIsPrimary)); + } + + @Override + public int hashCode() { + int hash = mType; + hash = hash * 31 + (mAddress != null ? mAddress.hashCode() : 0); + hash = hash * 31 + (mLabel != null ? mLabel.hashCode() : 0); + hash = hash * 31 + (mIsPrimary ? 1231 : 1237); + return hash; + } + + @Override + public String toString() { + return String.format("type: %d, data: %s, label: %s, isPrimary: %s", mType, mAddress, + mLabel, mIsPrimary); + } + + @Override + public final EntryLabel getEntryLabel() { + return EntryLabel.EMAIL; + } + + public String getAddress() { + return mAddress; + } + + public int getType() { + return mType; + } + + public String getLabel() { + return mLabel; + } + + public boolean isPrimary() { + return mIsPrimary; + } + } + + public static class PostalData implements EntryElement { + // Determined by vCard specification. + // - PO Box, Extended Addr, Street, Locality, Region, Postal Code, Country Name + private static final int ADDR_MAX_DATA_SIZE = 7; + private final String mPobox; + private final String mExtendedAddress; + private final String mStreet; + private final String mLocalty; + private final String mRegion; + private final String mPostalCode; + private final String mCountry; + private final int mType; + private final String mLabel; + private boolean mIsPrimary; + + /** We keep this for {@link StructuredPostal#FORMATTED_ADDRESS} */ + // TODO: need better way to construct formatted address. + private int mVCardType; + + public PostalData(String pobox, String extendedAddress, String street, String localty, + String region, String postalCode, String country, int type, String label, + boolean isPrimary, int vcardType) { + mType = type; + mPobox = pobox; + mExtendedAddress = extendedAddress; + mStreet = street; + mLocalty = localty; + mRegion = region; + mPostalCode = postalCode; + mCountry = country; + mLabel = label; + mIsPrimary = isPrimary; + mVCardType = vcardType; + } + + /** + * Accepts raw propertyValueList in vCard and constructs PostalData. + */ + public static PostalData constructPostalData(final List propValueList, + final int type, final String label, boolean isPrimary, int vcardType) { + final String[] dataArray = new String[ADDR_MAX_DATA_SIZE]; + + int size = propValueList.size(); + if (size > ADDR_MAX_DATA_SIZE) { + size = ADDR_MAX_DATA_SIZE; + } + + // adr-value = 0*6(text-value ";") text-value + // ; PO Box, Extended Address, Street, Locality, Region, Postal Code, Country Name + // + // Use Iterator assuming List may be LinkedList, though actually it is + // always ArrayList in the current implementation. + int i = 0; + for (String addressElement : propValueList) { + dataArray[i] = addressElement; + if (++i >= size) { + break; + } + } + while (i < ADDR_MAX_DATA_SIZE) { + dataArray[i++] = null; + } + + return new PostalData(dataArray[0], dataArray[1], dataArray[2], dataArray[3], + dataArray[4], dataArray[5], dataArray[6], type, label, isPrimary, vcardType); + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(StructuredPostal.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, StructuredPostal.CONTENT_ITEM_TYPE); + + builder.withValue(StructuredPostal.TYPE, mType); + if (mType == StructuredPostal.TYPE_CUSTOM) { + builder.withValue(StructuredPostal.LABEL, mLabel); + } + + final String streetString; + if (TextUtils.isEmpty(mStreet)) { + if (TextUtils.isEmpty(mExtendedAddress)) { + streetString = null; + } else { + streetString = mExtendedAddress; + } + } else { + if (TextUtils.isEmpty(mExtendedAddress)) { + streetString = mStreet; + } else { + streetString = mStreet + " " + mExtendedAddress; + } + } + builder.withValue(StructuredPostal.POBOX, mPobox); + builder.withValue(StructuredPostal.STREET, streetString); + builder.withValue(StructuredPostal.CITY, mLocalty); + builder.withValue(StructuredPostal.REGION, mRegion); + builder.withValue(StructuredPostal.POSTCODE, mPostalCode); + builder.withValue(StructuredPostal.COUNTRY, mCountry); + + builder.withValue(StructuredPostal.FORMATTED_ADDRESS, getFormattedAddress(mVCardType)); + if (mIsPrimary) { + builder.withValue(Data.IS_PRIMARY, 1); + } + operationList.add(builder.build()); + } + + public String getFormattedAddress(final int vcardType) { + StringBuilder builder = new StringBuilder(); + boolean empty = true; + final String[] dataArray = new String[] { + mPobox, mExtendedAddress, mStreet, mLocalty, mRegion, mPostalCode, mCountry + }; + if (VCardConfig.isJapaneseDevice(vcardType)) { + // In Japan, the order is reversed. + for (int i = ADDR_MAX_DATA_SIZE - 1; i >= 0; i--) { + String addressPart = dataArray[i]; + if (!TextUtils.isEmpty(addressPart)) { + if (!empty) { + builder.append(' '); + } else { + empty = false; + } + builder.append(addressPart); + } + } + } else { + for (int i = 0; i < ADDR_MAX_DATA_SIZE; i++) { + String addressPart = dataArray[i]; + if (!TextUtils.isEmpty(addressPart)) { + if (!empty) { + builder.append(' '); + } else { + empty = false; + } + builder.append(addressPart); + } + } + } + + return builder.toString().trim(); + } + + @Override + public boolean isEmpty() { + return (TextUtils.isEmpty(mPobox) + && TextUtils.isEmpty(mExtendedAddress) + && TextUtils.isEmpty(mStreet) + && TextUtils.isEmpty(mLocalty) + && TextUtils.isEmpty(mRegion) + && TextUtils.isEmpty(mPostalCode) + && TextUtils.isEmpty(mCountry)); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof PostalData)) { + return false; + } + final PostalData postalData = (PostalData) obj; + return (mType == postalData.mType) + && (mType == StructuredPostal.TYPE_CUSTOM ? TextUtils.equals(mLabel, + postalData.mLabel) : true) + && (mIsPrimary == postalData.mIsPrimary) + && TextUtils.equals(mPobox, postalData.mPobox) + && TextUtils.equals(mExtendedAddress, postalData.mExtendedAddress) + && TextUtils.equals(mStreet, postalData.mStreet) + && TextUtils.equals(mLocalty, postalData.mLocalty) + && TextUtils.equals(mRegion, postalData.mRegion) + && TextUtils.equals(mPostalCode, postalData.mPostalCode) + && TextUtils.equals(mCountry, postalData.mCountry); + } + + @Override + public int hashCode() { + int hash = mType; + hash = hash * 31 + (mLabel != null ? mLabel.hashCode() : 0); + hash = hash * 31 + (mIsPrimary ? 1231 : 1237); + + final String[] hashTargets = new String[] {mPobox, mExtendedAddress, mStreet, + mLocalty, mRegion, mPostalCode, mCountry}; + for (String hashTarget : hashTargets) { + hash = hash * 31 + (hashTarget != null ? hashTarget.hashCode() : 0); + } + return hash; + } + + @Override + public String toString() { + return String.format("type: %d, label: %s, isPrimary: %s, pobox: %s, " + + "extendedAddress: %s, street: %s, localty: %s, region: %s, postalCode %s, " + + "country: %s", mType, mLabel, mIsPrimary, mPobox, mExtendedAddress, mStreet, + mLocalty, mRegion, mPostalCode, mCountry); + } + + @Override + public final EntryLabel getEntryLabel() { + return EntryLabel.POSTAL_ADDRESS; + } + + public String getPobox() { + return mPobox; + } + + public String getExtendedAddress() { + return mExtendedAddress; + } + + public String getStreet() { + return mStreet; + } + + public String getLocalty() { + return mLocalty; + } + + public String getRegion() { + return mRegion; + } + + public String getPostalCode() { + return mPostalCode; + } + + public String getCountry() { + return mCountry; + } + + public int getType() { + return mType; + } + + public String getLabel() { + return mLabel; + } + + public boolean isPrimary() { + return mIsPrimary; + } + } + + public static class OrganizationData implements EntryElement { + // non-final is Intentional: we may change the values since this info is separated into + // two parts in vCard: "ORG" + "TITLE", and we have to cope with each field in different + // timing. + private String mOrganizationName; + private String mDepartmentName; + private String mTitle; + private final String mPhoneticName; // We won't have this in "TITLE" property. + private final int mType; + private boolean mIsPrimary; + + public OrganizationData(final String organizationName, final String departmentName, + final String titleName, final String phoneticName, int type, + final boolean isPrimary) { + mType = type; + mOrganizationName = organizationName; + mDepartmentName = departmentName; + mTitle = titleName; + mPhoneticName = phoneticName; + mIsPrimary = isPrimary; + } + + public String getFormattedString() { + final StringBuilder builder = new StringBuilder(); + if (!TextUtils.isEmpty(mOrganizationName)) { + builder.append(mOrganizationName); + } + + if (!TextUtils.isEmpty(mDepartmentName)) { + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(mDepartmentName); + } + + if (!TextUtils.isEmpty(mTitle)) { + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(mTitle); + } + + return builder.toString(); + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(Organization.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, Organization.CONTENT_ITEM_TYPE); + builder.withValue(Organization.TYPE, mType); + if (mOrganizationName != null) { + builder.withValue(Organization.COMPANY, mOrganizationName); + } + if (mDepartmentName != null) { + builder.withValue(Organization.DEPARTMENT, mDepartmentName); + } + if (mTitle != null) { + builder.withValue(Organization.TITLE, mTitle); + } + if (mPhoneticName != null) { + builder.withValue(Organization.PHONETIC_NAME, mPhoneticName); + } + if (mIsPrimary) { + builder.withValue(Organization.IS_PRIMARY, 1); + } + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return TextUtils.isEmpty(mOrganizationName) && TextUtils.isEmpty(mDepartmentName) + && TextUtils.isEmpty(mTitle) && TextUtils.isEmpty(mPhoneticName); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof OrganizationData)) { + return false; + } + OrganizationData organization = (OrganizationData) obj; + return (mType == organization.mType + && TextUtils.equals(mOrganizationName, organization.mOrganizationName) + && TextUtils.equals(mDepartmentName, organization.mDepartmentName) + && TextUtils.equals(mTitle, organization.mTitle) + && (mIsPrimary == organization.mIsPrimary)); + } + + @Override + public int hashCode() { + int hash = mType; + hash = hash * 31 + (mOrganizationName != null ? mOrganizationName.hashCode() : 0); + hash = hash * 31 + (mDepartmentName != null ? mDepartmentName.hashCode() : 0); + hash = hash * 31 + (mTitle != null ? mTitle.hashCode() : 0); + hash = hash * 31 + (mIsPrimary ? 1231 : 1237); + return hash; + } + + @Override + public String toString() { + return String.format( + "type: %d, organization: %s, department: %s, title: %s, isPrimary: %s", mType, + mOrganizationName, mDepartmentName, mTitle, mIsPrimary); + } + + @Override + public final EntryLabel getEntryLabel() { + return EntryLabel.ORGANIZATION; + } + + public String getOrganizationName() { + return mOrganizationName; + } + + public String getDepartmentName() { + return mDepartmentName; + } + + public String getTitle() { + return mTitle; + } + + public String getPhoneticName() { + return mPhoneticName; + } + + public int getType() { + return mType; + } + + public boolean isPrimary() { + return mIsPrimary; + } + } + + public static class ImData implements EntryElement { + private final String mAddress; + private final int mProtocol; + private final String mCustomProtocol; + private final int mType; + private final boolean mIsPrimary; + + public ImData(final int protocol, final String customProtocol, final String address, + final int type, final boolean isPrimary) { + mProtocol = protocol; + mCustomProtocol = customProtocol; + mType = type; + mAddress = address; + mIsPrimary = isPrimary; + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(Im.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE); + builder.withValue(Im.TYPE, mType); + builder.withValue(Im.PROTOCOL, mProtocol); + builder.withValue(Im.DATA, mAddress); + if (mProtocol == Im.PROTOCOL_CUSTOM) { + builder.withValue(Im.CUSTOM_PROTOCOL, mCustomProtocol); + } + if (mIsPrimary) { + builder.withValue(Data.IS_PRIMARY, 1); + } + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return TextUtils.isEmpty(mAddress); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ImData)) { + return false; + } + ImData imData = (ImData) obj; + return (mType == imData.mType + && mProtocol == imData.mProtocol + && TextUtils.equals(mCustomProtocol, imData.mCustomProtocol) + && TextUtils.equals(mAddress, imData.mAddress) + && (mIsPrimary == imData.mIsPrimary)); + } + + @Override + public int hashCode() { + int hash = mType; + hash = hash * 31 + mProtocol; + hash = hash * 31 + (mCustomProtocol != null ? mCustomProtocol.hashCode() : 0); + hash = hash * 31 + (mAddress != null ? mAddress.hashCode() : 0); + hash = hash * 31 + (mIsPrimary ? 1231 : 1237); + return hash; + } + + @Override + public String toString() { + return String.format( + "type: %d, protocol: %d, custom_protcol: %s, data: %s, isPrimary: %s", mType, + mProtocol, mCustomProtocol, mAddress, mIsPrimary); + } + + @Override + public final EntryLabel getEntryLabel() { + return EntryLabel.IM; + } + + public String getAddress() { + return mAddress; + } + + /** + * One of the value available for {@link Im#PROTOCOL}. e.g. + * {@link Im#PROTOCOL_GOOGLE_TALK} + */ + public int getProtocol() { + return mProtocol; + } + + public String getCustomProtocol() { + return mCustomProtocol; + } + + public int getType() { + return mType; + } + + public boolean isPrimary() { + return mIsPrimary; + } + } + + public static class PhotoData implements EntryElement { + // private static final String FORMAT_FLASH = "SWF"; + + // used when type is not defined in ContactsContract. + private final String mFormat; + private final boolean mIsPrimary; + + private final byte[] mBytes; + + private Integer mHashCode = null; + + public PhotoData(String format, byte[] photoBytes, boolean isPrimary) { + mFormat = format; + mBytes = photoBytes; + mIsPrimary = isPrimary; + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(Photo.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE); + builder.withValue(Photo.PHOTO, mBytes); + if (mIsPrimary) { + builder.withValue(Photo.IS_PRIMARY, 1); + } + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return mBytes == null || mBytes.length == 0; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof PhotoData)) { + return false; + } + PhotoData photoData = (PhotoData) obj; + return (TextUtils.equals(mFormat, photoData.mFormat) + && Arrays.equals(mBytes, photoData.mBytes) + && (mIsPrimary == photoData.mIsPrimary)); + } + + @Override + public int hashCode() { + if (mHashCode != null) { + return mHashCode; + } + + int hash = mFormat != null ? mFormat.hashCode() : 0; + hash = hash * 31; + if (mBytes != null) { + for (byte b : mBytes) { + hash += b; + } + } + + hash = hash * 31 + (mIsPrimary ? 1231 : 1237); + mHashCode = hash; + return hash; + } + + @Override + public String toString() { + return String.format("format: %s: size: %d, isPrimary: %s", mFormat, mBytes.length, + mIsPrimary); + } + + @Override + public final EntryLabel getEntryLabel() { + return EntryLabel.PHOTO; + } + + public String getFormat() { + return mFormat; + } + + public byte[] getBytes() { + return mBytes; + } + + public boolean isPrimary() { + return mIsPrimary; + } + } + + public static class NicknameData implements EntryElement { + private final String mNickname; + + public NicknameData(String nickname) { + mNickname = nickname; + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(Nickname.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, Nickname.CONTENT_ITEM_TYPE); + builder.withValue(Nickname.TYPE, Nickname.TYPE_DEFAULT); + builder.withValue(Nickname.NAME, mNickname); + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return TextUtils.isEmpty(mNickname); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof NicknameData)) { + return false; + } + NicknameData nicknameData = (NicknameData) obj; + return TextUtils.equals(mNickname, nicknameData.mNickname); + } + + @Override + public int hashCode() { + return mNickname != null ? mNickname.hashCode() : 0; + } + + @Override + public String toString() { + return "nickname: " + mNickname; + } + + @Override + public EntryLabel getEntryLabel() { + return EntryLabel.NICKNAME; + } + + public String getNickname() { + return mNickname; + } + } + + public static class NoteData implements EntryElement { + public final String mNote; + + public NoteData(String note) { + mNote = note; + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(Note.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, Note.CONTENT_ITEM_TYPE); + builder.withValue(Note.NOTE, mNote); + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return TextUtils.isEmpty(mNote); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof NoteData)) { + return false; + } + NoteData noteData = (NoteData) obj; + return TextUtils.equals(mNote, noteData.mNote); + } + + @Override + public int hashCode() { + return mNote != null ? mNote.hashCode() : 0; + } + + @Override + public String toString() { + return "note: " + mNote; + } + + @Override + public EntryLabel getEntryLabel() { + return EntryLabel.NOTE; + } + + public String getNote() { + return mNote; + } + } + + public static class WebsiteData implements EntryElement { + private final String mWebsite; + + public WebsiteData(String website) { + mWebsite = website; + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(Website.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, Website.CONTENT_ITEM_TYPE); + builder.withValue(Website.URL, mWebsite); + // There's no information about the type of URL in vCard. + // We use TYPE_HOMEPAGE for safety. + builder.withValue(Website.TYPE, Website.TYPE_HOMEPAGE); + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return TextUtils.isEmpty(mWebsite); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof WebsiteData)) { + return false; + } + WebsiteData websiteData = (WebsiteData) obj; + return TextUtils.equals(mWebsite, websiteData.mWebsite); + } + + @Override + public int hashCode() { + return mWebsite != null ? mWebsite.hashCode() : 0; + } + + @Override + public String toString() { + return "website: " + mWebsite; + } + + @Override + public EntryLabel getEntryLabel() { + return EntryLabel.WEBSITE; + } + + public String getWebsite() { + return mWebsite; + } + } + + public static class BirthdayData implements EntryElement { + private final String mBirthday; + + public BirthdayData(String birthday) { + mBirthday = birthday; + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(Event.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, Event.CONTENT_ITEM_TYPE); + builder.withValue(Event.START_DATE, mBirthday); + builder.withValue(Event.TYPE, Event.TYPE_BIRTHDAY); + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return TextUtils.isEmpty(mBirthday); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof BirthdayData)) { + return false; + } + BirthdayData birthdayData = (BirthdayData) obj; + return TextUtils.equals(mBirthday, birthdayData.mBirthday); + } + + @Override + public int hashCode() { + return mBirthday != null ? mBirthday.hashCode() : 0; + } + + @Override + public String toString() { + return "birthday: " + mBirthday; + } + + @Override + public EntryLabel getEntryLabel() { + return EntryLabel.BIRTHDAY; + } + + public String getBirthday() { + return mBirthday; + } + } + + public static class AnniversaryData implements EntryElement { + private final String mAnniversary; + + public AnniversaryData(String anniversary) { + mAnniversary = anniversary; + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(Event.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, Event.CONTENT_ITEM_TYPE); + builder.withValue(Event.START_DATE, mAnniversary); + builder.withValue(Event.TYPE, Event.TYPE_ANNIVERSARY); + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return TextUtils.isEmpty(mAnniversary); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof AnniversaryData)) { + return false; + } + AnniversaryData anniversaryData = (AnniversaryData) obj; + return TextUtils.equals(mAnniversary, anniversaryData.mAnniversary); + } + + @Override + public int hashCode() { + return mAnniversary != null ? mAnniversary.hashCode() : 0; + } + + @Override + public String toString() { + return "anniversary: " + mAnniversary; + } + + @Override + public EntryLabel getEntryLabel() { + return EntryLabel.ANNIVERSARY; + } + + public String getAnniversary() { return mAnniversary; } + } + + public static class SipData implements EntryElement { + /** + * Note that schema part ("sip:") is automatically removed. e.g. + * "sip:username:password@host:port" becomes + * "username:password@host:port" + */ + private final String mAddress; + private final int mType; + private final String mLabel; + private final boolean mIsPrimary; + + public SipData(String rawSip, int type, String label, boolean isPrimary) { + if (rawSip.startsWith("sip:")) { + mAddress = rawSip.substring(4); + } else { + mAddress = rawSip; + } + mType = type; + mLabel = label; + mIsPrimary = isPrimary; + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(SipAddress.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, SipAddress.CONTENT_ITEM_TYPE); + builder.withValue(SipAddress.SIP_ADDRESS, mAddress); + builder.withValue(SipAddress.TYPE, mType); + if (mType == SipAddress.TYPE_CUSTOM) { + builder.withValue(SipAddress.LABEL, mLabel); + } + if (mIsPrimary) { + builder.withValue(SipAddress.IS_PRIMARY, mIsPrimary); + } + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return TextUtils.isEmpty(mAddress); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof SipData)) { + return false; + } + SipData sipData = (SipData) obj; + return (mType == sipData.mType + && TextUtils.equals(mLabel, sipData.mLabel) + && TextUtils.equals(mAddress, sipData.mAddress) + && (mIsPrimary == sipData.mIsPrimary)); + } + + @Override + public int hashCode() { + int hash = mType; + hash = hash * 31 + (mLabel != null ? mLabel.hashCode() : 0); + hash = hash * 31 + (mAddress != null ? mAddress.hashCode() : 0); + hash = hash * 31 + (mIsPrimary ? 1231 : 1237); + return hash; + } + + @Override + public String toString() { + return "sip: " + mAddress; + } + + @Override + public EntryLabel getEntryLabel() { + return EntryLabel.SIP; + } + + /** + * @return Address part of the sip data. The schema ("sip:") isn't contained here. + */ + public String getAddress() { return mAddress; } + public int getType() { return mType; } + public String getLabel() { return mLabel; } + } + + /** + * Some Contacts data in Android cannot be converted to vCard + * representation. VCardEntry preserves those data using this class. + */ + public static class AndroidCustomData implements EntryElement { + private final String mMimeType; + + private final List mDataList; // 1 .. VCardConstants.MAX_DATA_COLUMN + + public AndroidCustomData(String mimeType, List dataList) { + mMimeType = mimeType; + mDataList = dataList; + } + + public static AndroidCustomData constructAndroidCustomData(List list) { + String mimeType; + List dataList; + + if (list == null) { + mimeType = null; + dataList = null; + } else if (list.size() < 2) { + mimeType = list.get(0); + dataList = null; + } else { + final int max = (list.size() < VCardConstants.MAX_DATA_COLUMN + 1) ? list.size() + : VCardConstants.MAX_DATA_COLUMN + 1; + mimeType = list.get(0); + dataList = list.subList(1, max); + } + + return new AndroidCustomData(mimeType, dataList); + } + + @Override + public void constructInsertOperation(List operationList, + int backReferenceIndex) { + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(Data.CONTENT_URI); + builder.withValueBackReference(GroupMembership.RAW_CONTACT_ID, backReferenceIndex); + builder.withValue(Data.MIMETYPE, mMimeType); + for (int i = 0; i < mDataList.size(); i++) { + String value = mDataList.get(i); + if (!TextUtils.isEmpty(value)) { + // 1-origin + builder.withValue("data" + (i + 1), value); + } + } + operationList.add(builder.build()); + } + + @Override + public boolean isEmpty() { + return TextUtils.isEmpty(mMimeType) || mDataList == null || mDataList.size() == 0; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof AndroidCustomData)) { + return false; + } + AndroidCustomData data = (AndroidCustomData) obj; + if (!TextUtils.equals(mMimeType, data.mMimeType)) { + return false; + } + if (mDataList == null) { + return data.mDataList == null; + } else { + final int size = mDataList.size(); + if (size != data.mDataList.size()) { + return false; + } + for (int i = 0; i < size; i++) { + if (!TextUtils.equals(mDataList.get(i), data.mDataList.get(i))) { + return false; + } + } + return true; + } + } + + @Override + public int hashCode() { + int hash = mMimeType != null ? mMimeType.hashCode() : 0; + if (mDataList != null) { + for (String data : mDataList) { + hash = hash * 31 + (data != null ? data.hashCode() : 0); + } + } + return hash; + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder(); + builder.append("android-custom: " + mMimeType + ", data: "); + builder.append(mDataList == null ? "null" : Arrays.toString(mDataList.toArray())); + return builder.toString(); + } + + @Override + public EntryLabel getEntryLabel() { + return EntryLabel.ANDROID_CUSTOM; + } + + public String getMimeType() { return mMimeType; } + public List getDataList() { return mDataList; } + } + + private final NameData mNameData = new NameData(); + private List mPhoneList; + private List mEmailList; + private List mPostalList; + private List mOrganizationList; + private List mImList; + private List mPhotoList; + private List mWebsiteList; + private List mSipList; + private List mNicknameList; + private List mNoteList; + private List mAndroidCustomDataList; + private BirthdayData mBirthday; + private AnniversaryData mAnniversary; + private List> mUnknownXData; + + /** + * Inner iterator interface. + */ + public interface EntryElementIterator { + public void onIterationStarted(); + + public void onIterationEnded(); + + /** + * Called when there are one or more {@link EntryElement} instances + * associated with {@link EntryLabel}. + */ + public void onElementGroupStarted(EntryLabel label); + + /** + * Called after all {@link EntryElement} instances for + * {@link EntryLabel} provided on {@link #onElementGroupStarted(EntryLabel)} + * being processed by {@link #onElement(EntryElement)} + */ + public void onElementGroupEnded(); + + /** + * @return should be true when child wants to continue the operation. + * False otherwise. + */ + public boolean onElement(EntryElement elem); + } + + public final void iterateAllData(EntryElementIterator iterator) { + iterator.onIterationStarted(); + iterator.onElementGroupStarted(mNameData.getEntryLabel()); + iterator.onElement(mNameData); + iterator.onElementGroupEnded(); + + iterateOneList(mPhoneList, iterator); + iterateOneList(mEmailList, iterator); + iterateOneList(mPostalList, iterator); + iterateOneList(mOrganizationList, iterator); + iterateOneList(mImList, iterator); + iterateOneList(mPhotoList, iterator); + iterateOneList(mWebsiteList, iterator); + iterateOneList(mSipList, iterator); + iterateOneList(mNicknameList, iterator); + iterateOneList(mNoteList, iterator); + iterateOneList(mAndroidCustomDataList, iterator); + + if (mBirthday != null) { + iterator.onElementGroupStarted(mBirthday.getEntryLabel()); + iterator.onElement(mBirthday); + iterator.onElementGroupEnded(); + } + if (mAnniversary != null) { + iterator.onElementGroupStarted(mAnniversary.getEntryLabel()); + iterator.onElement(mAnniversary); + iterator.onElementGroupEnded(); + } + iterator.onIterationEnded(); + } + + private void iterateOneList(List elemList, + EntryElementIterator iterator) { + if (elemList != null && elemList.size() > 0) { + iterator.onElementGroupStarted(elemList.get(0).getEntryLabel()); + for (EntryElement elem : elemList) { + iterator.onElement(elem); + } + iterator.onElementGroupEnded(); + } + } + + private class IsIgnorableIterator implements EntryElementIterator { + private boolean mEmpty = true; + + @Override + public void onIterationStarted() { + } + + @Override + public void onIterationEnded() { + } + + @Override + public void onElementGroupStarted(EntryLabel label) { + } + + @Override + public void onElementGroupEnded() { + } + + @Override + public boolean onElement(EntryElement elem) { + if (!elem.isEmpty()) { + mEmpty = false; + // exit now + return false; + } else { + return true; + } + } + + public boolean getResult() { + return mEmpty; + } + } + + private class ToStringIterator implements EntryElementIterator { + private StringBuilder mBuilder; + + private boolean mFirstElement; + + @Override + public void onIterationStarted() { + mBuilder = new StringBuilder(); + mBuilder.append("[[hash: " + VCardEntry.this.hashCode() + "\n"); + } + + @Override + public void onElementGroupStarted(EntryLabel label) { + mBuilder.append(label.toString() + ": "); + mFirstElement = true; + } + + @Override + public boolean onElement(EntryElement elem) { + if (!mFirstElement) { + mBuilder.append(", "); + mFirstElement = false; + } + mBuilder.append("[").append(elem.toString()).append("]"); + return true; + } + + @Override + public void onElementGroupEnded() { + mBuilder.append("\n"); + } + + @Override + public void onIterationEnded() { + mBuilder.append("]]\n"); + } + + @Override + public String toString() { + return mBuilder.toString(); + } + } + + private class InsertOperationConstrutor implements EntryElementIterator { + private final List mOperationList; + + private final int mBackReferenceIndex; + + public InsertOperationConstrutor(List operationList, + int backReferenceIndex) { + mOperationList = operationList; + mBackReferenceIndex = backReferenceIndex; + } + + @Override + public void onIterationStarted() { + } + + @Override + public void onIterationEnded() { + } + + @Override + public void onElementGroupStarted(EntryLabel label) { + } + + @Override + public void onElementGroupEnded() { + } + + @Override + public boolean onElement(EntryElement elem) { + if (!elem.isEmpty()) { + elem.constructInsertOperation(mOperationList, mBackReferenceIndex); + } + return true; + } + } + + private final int mVCardType; + private Account mAccount; + + public Account getAccount() { + return mAccount; + } + + public void setAccount(Account account) { + mAccount = account; + } + + private List mChildren; + + @Override + public String toString() { + ToStringIterator iterator = new ToStringIterator(); + iterateAllData(iterator); + return iterator.toString(); + } + + public VCardEntry() { + this(VCardConfig.VCARD_TYPE_V21_GENERIC); + } + + public VCardEntry(int vcardType) { + this(vcardType, null); + } + + public VCardEntry(int vcardType, Account account) { + mVCardType = vcardType; + mAccount = account; + } + + private void addPhone(int type, String data, String label, boolean isPrimary) { + if (mPhoneList == null) { + mPhoneList = new ArrayList(); + } + final StringBuilder builder = new StringBuilder(); + final String trimmed = data.trim(); + final String formattedNumber; + if (type == Phone.TYPE_PAGER || VCardConfig.refrainPhoneNumberFormatting(mVCardType)) { + formattedNumber = trimmed; + } else { + // TODO: from the view of vCard spec these auto conversions should be removed. + // Note that some other codes (like the phone number formatter) or modules expect this + // auto conversion (bug 5178723), so just omitting this code won't be preferable enough + // (bug 4177894) + boolean hasPauseOrWait = false; + final int length = trimmed.length(); + for (int i = 0; i < length; i++) { + char ch = trimmed.charAt(i); + // See RFC 3601 and docs for PhoneNumberUtils for more info. + if (ch == 'p' || ch == 'P') { + builder.append(PhoneNumberUtils.PAUSE); + hasPauseOrWait = true; + } else if (ch == 'w' || ch == 'W') { + builder.append(PhoneNumberUtils.WAIT); + hasPauseOrWait = true; + } else if (PhoneNumberUtils.is12Key(ch) || (i == 0 && ch == '+')) { + builder.append(ch); + } + } + if (!hasPauseOrWait) { + final int formattingType = VCardUtils.getPhoneNumberFormat(mVCardType); + formattedNumber = PhoneNumberUtilsPort.formatNumber( + builder.toString(), formattingType); + } else { + formattedNumber = builder.toString(); + } + } + PhoneData phoneData = new PhoneData(formattedNumber, type, label, isPrimary); + mPhoneList.add(phoneData); + } + + private void addSip(String sipData, int type, String label, boolean isPrimary) { + if (mSipList == null) { + mSipList = new ArrayList(); + } + mSipList.add(new SipData(sipData, type, label, isPrimary)); + } + + private void addNickName(final String nickName) { + if (mNicknameList == null) { + mNicknameList = new ArrayList(); + } + mNicknameList.add(new NicknameData(nickName)); + } + + private void addEmail(int type, String data, String label, boolean isPrimary) { + if (mEmailList == null) { + mEmailList = new ArrayList(); + } + mEmailList.add(new EmailData(data, type, label, isPrimary)); + } + + private void addPostal(int type, List propValueList, String label, boolean isPrimary) { + if (mPostalList == null) { + mPostalList = new ArrayList(0); + } + mPostalList.add(PostalData.constructPostalData(propValueList, type, label, isPrimary, + mVCardType)); + } + + /** + * Should be called via {@link #handleOrgValue(int, List, Map, boolean)} or + * {@link #handleTitleValue(String)}. + */ + private void addNewOrganization(final String organizationName, final String departmentName, + final String titleName, final String phoneticName, int type, final boolean isPrimary) { + if (mOrganizationList == null) { + mOrganizationList = new ArrayList(); + } + mOrganizationList.add(new OrganizationData(organizationName, departmentName, titleName, + phoneticName, type, isPrimary)); + } + + private static final List sEmptyList = Collections + .unmodifiableList(new ArrayList(0)); + + private String buildSinglePhoneticNameFromSortAsParam(Map> paramMap) { + final Collection sortAsCollection = paramMap.get(VCardConstants.PARAM_SORT_AS); + if (sortAsCollection != null && sortAsCollection.size() != 0) { + if (sortAsCollection.size() > 1) { + Log.w(LOG_TAG, + "Incorrect multiple SORT_AS parameters detected: " + + Arrays.toString(sortAsCollection.toArray())); + } + final List sortNames = VCardUtils.constructListFromValue(sortAsCollection + .iterator().next(), mVCardType); + final StringBuilder builder = new StringBuilder(); + for (final String elem : sortNames) { + builder.append(elem); + } + return builder.toString(); + } else { + return null; + } + } + + /** + * Set "ORG" related values to the appropriate data. If there's more than + * one {@link OrganizationData} objects, this input data are attached to the + * last one which does not have valid values (not including empty but only + * null). If there's no {@link OrganizationData} object, a new + * {@link OrganizationData} is created, whose title is set to null. + */ + private void handleOrgValue(final int type, List orgList, + Map> paramMap, boolean isPrimary) { + final String phoneticName = buildSinglePhoneticNameFromSortAsParam(paramMap); + if (orgList == null) { + orgList = sEmptyList; + } + final String organizationName; + final String departmentName; + final int size = orgList.size(); + switch (size) { + case 0: { + organizationName = ""; + departmentName = null; + break; + } + case 1: { + organizationName = orgList.get(0); + departmentName = null; + break; + } + default: { // More than 1. + organizationName = orgList.get(0); + // We're not sure which is the correct string for department. + // In order to keep all the data, concatinate the rest of elements. + StringBuilder builder = new StringBuilder(); + for (int i = 1; i < size; i++) { + if (i > 1) { + builder.append(' '); + } + builder.append(orgList.get(i)); + } + departmentName = builder.toString(); + } + } + if (mOrganizationList == null) { + // Create new first organization entry, with "null" title which may be + // added via handleTitleValue(). + addNewOrganization(organizationName, departmentName, null, phoneticName, type, + isPrimary); + return; + } + for (OrganizationData organizationData : mOrganizationList) { + // Not use TextUtils.isEmpty() since ORG was set but the elements might be empty. + // e.g. "ORG;PREF:;" -> Both companyName and departmentName become empty but not null. + if (organizationData.mOrganizationName == null + && organizationData.mDepartmentName == null) { + // Probably the "TITLE" property comes before the "ORG" property via + // handleTitleLine(). + organizationData.mOrganizationName = organizationName; + organizationData.mDepartmentName = departmentName; + organizationData.mIsPrimary = isPrimary; + return; + } + } + // No OrganizatioData is available. Create another one, with "null" title, which may be + // added via handleTitleValue(). + addNewOrganization(organizationName, departmentName, null, phoneticName, type, isPrimary); + } + + /** + * Set "title" value to the appropriate data. If there's more than one + * OrganizationData objects, this input is attached to the last one which + * does not have valid title value (not including empty but only null). If + * there's no OrganizationData object, a new OrganizationData is created, + * whose company name is set to null. + */ + private void handleTitleValue(final String title) { + if (mOrganizationList == null) { + // Create new first organization entry, with "null" other info, which may be + // added via handleOrgValue(). + addNewOrganization(null, null, title, null, DEFAULT_ORGANIZATION_TYPE, false); + return; + } + for (OrganizationData organizationData : mOrganizationList) { + if (organizationData.mTitle == null) { + organizationData.mTitle = title; + return; + } + } + // No Organization is available. Create another one, with "null" other info, which may be + // added via handleOrgValue(). + addNewOrganization(null, null, title, null, DEFAULT_ORGANIZATION_TYPE, false); + } + + private void addIm(int protocol, String customProtocol, String propValue, int type, + boolean isPrimary) { + if (mImList == null) { + mImList = new ArrayList(); + } + mImList.add(new ImData(protocol, customProtocol, propValue, type, isPrimary)); + } + + private void addNote(final String note) { + if (mNoteList == null) { + mNoteList = new ArrayList(1); + } + mNoteList.add(new NoteData(note)); + } + + private void addPhotoBytes(String formatName, byte[] photoBytes, boolean isPrimary) { + if (mPhotoList == null) { + mPhotoList = new ArrayList(1); + } + final PhotoData photoData = new PhotoData(formatName, photoBytes, isPrimary); + mPhotoList.add(photoData); + } + + /** + * Tries to extract paramMap, constructs SORT-AS parameter values, and store + * them in appropriate phonetic name variables. This method does not care + * the vCard version. Even when we have SORT-AS parameters in invalid + * versions (i.e. 2.1 and 3.0), we scilently accept them so that we won't + * drop meaningful information. If we had this parameter in the N field of + * vCard 3.0, and the contact data also have SORT-STRING, we will prefer + * SORT-STRING, since it is regitimate property to be understood. + */ + private void tryHandleSortAsName(final Map> paramMap) { + if (VCardConfig.isVersion30(mVCardType) + && !(TextUtils.isEmpty(mNameData.mPhoneticFamily) + && TextUtils.isEmpty(mNameData.mPhoneticMiddle) && TextUtils + .isEmpty(mNameData.mPhoneticGiven))) { + return; + } + + final Collection sortAsCollection = paramMap.get(VCardConstants.PARAM_SORT_AS); + if (sortAsCollection != null && sortAsCollection.size() != 0) { + if (sortAsCollection.size() > 1) { + Log.w(LOG_TAG, + "Incorrect multiple SORT_AS parameters detected: " + + Arrays.toString(sortAsCollection.toArray())); + } + final List sortNames = VCardUtils.constructListFromValue(sortAsCollection + .iterator().next(), mVCardType); + int size = sortNames.size(); + if (size > 3) { + size = 3; + } + switch (size) { + case 3: + mNameData.mPhoneticMiddle = sortNames.get(2); //$FALL-THROUGH$ + case 2: + mNameData.mPhoneticGiven = sortNames.get(1); //$FALL-THROUGH$ + default: + mNameData.mPhoneticFamily = sortNames.get(0); + break; + } + } + } + + @SuppressWarnings("fallthrough") + private void handleNProperty(final List paramValues, + Map> paramMap) { + // in vCard 4.0, SORT-AS parameter is available. + tryHandleSortAsName(paramMap); + + // Family, Given, Middle, Prefix, Suffix. (1 - 5) + int size; + if (paramValues == null || (size = paramValues.size()) < 1) { + return; + } + if (size > 5) { + size = 5; + } + + switch (size) { + // Fall-through. + case 5: + mNameData.mSuffix = paramValues.get(4); + case 4: + mNameData.mPrefix = paramValues.get(3); + case 3: + mNameData.mMiddle = paramValues.get(2); + case 2: + mNameData.mGiven = paramValues.get(1); + default: + mNameData.mFamily = paramValues.get(0); + } + } + + /** + * Note: Some Japanese mobile phones use this field for phonetic name, since + * vCard 2.1 does not have "SORT-STRING" type. Also, in some cases, the + * field has some ';'s in it. Assume the ';' means the same meaning in N + * property + */ + @SuppressWarnings("fallthrough") + private void handlePhoneticNameFromSound(List elems) { + if (!(TextUtils.isEmpty(mNameData.mPhoneticFamily) + && TextUtils.isEmpty(mNameData.mPhoneticMiddle) && TextUtils + .isEmpty(mNameData.mPhoneticGiven))) { + // This means the other properties like "X-PHONETIC-FIRST-NAME" was already found. + // Ignore "SOUND;X-IRMC-N". + return; + } + + int size; + if (elems == null || (size = elems.size()) < 1) { + return; + } + + // Assume that the order is "Family, Given, Middle". + // This is not from specification but mere assumption. Some Japanese + // phones use this order. + if (size > 3) { + size = 3; + } + + if (elems.get(0).length() > 0) { + boolean onlyFirstElemIsNonEmpty = true; + for (int i = 1; i < size; i++) { + if (elems.get(i).length() > 0) { + onlyFirstElemIsNonEmpty = false; + break; + } + } + if (onlyFirstElemIsNonEmpty) { + final String[] namesArray = elems.get(0).split(" "); + final int nameArrayLength = namesArray.length; + if (nameArrayLength == 3) { + // Assume the string is "Family Middle Given". + mNameData.mPhoneticFamily = namesArray[0]; + mNameData.mPhoneticMiddle = namesArray[1]; + mNameData.mPhoneticGiven = namesArray[2]; + } else if (nameArrayLength == 2) { + // Assume the string is "Family Given" based on the Japanese mobile + // phones' preference. + mNameData.mPhoneticFamily = namesArray[0]; + mNameData.mPhoneticGiven = namesArray[1]; + } else { + mNameData.mPhoneticGiven = elems.get(0); + } + return; + } + } + + switch (size) { + // fallthrough + case 3: + mNameData.mPhoneticMiddle = elems.get(2); + case 2: + mNameData.mPhoneticGiven = elems.get(1); + default: + mNameData.mPhoneticFamily = elems.get(0); + } + } + + public void addProperty(final VCardProperty property) { + final String propertyName = property.getName(); + final Map> paramMap = property.getParameterMap(); + final List propertyValueList = property.getValueList(); + byte[] propertyBytes = property.getByteValue(); + + if ((propertyValueList == null || propertyValueList.size() == 0) + && propertyBytes == null) { + return; + } + final String propValue = (propertyValueList != null + ? listToString(propertyValueList).trim() + : null); + + if (propertyName.equals(VCardConstants.PROPERTY_VERSION)) { + // vCard version. Ignore this. + } else if (propertyName.equals(VCardConstants.PROPERTY_FN)) { + mNameData.mFormatted = propValue; + } else if (propertyName.equals(VCardConstants.PROPERTY_NAME)) { + // Only in vCard 3.0. Use this if FN doesn't exist though it is + // required in vCard 3.0. + if (TextUtils.isEmpty(mNameData.mFormatted)) { + mNameData.mFormatted = propValue; + } + } else if (propertyName.equals(VCardConstants.PROPERTY_N)) { + handleNProperty(propertyValueList, paramMap); + } else if (propertyName.equals(VCardConstants.PROPERTY_SORT_STRING)) { + mNameData.mSortString = propValue; + } else if (propertyName.equals(VCardConstants.PROPERTY_NICKNAME) + || propertyName.equals(VCardConstants.ImportOnly.PROPERTY_X_NICKNAME)) { + addNickName(propValue); + } else if (propertyName.equals(VCardConstants.PROPERTY_SOUND)) { + Collection typeCollection = paramMap.get(VCardConstants.PARAM_TYPE); + if (typeCollection != null + && typeCollection.contains(VCardConstants.PARAM_TYPE_X_IRMC_N)) { + // As of 2009-10-08, Parser side does not split a property value into separated + // values using ';' (in other words, propValueList.size() == 1), + // which is correct behavior from the view of vCard 2.1. + // But we want it to be separated, so do the separation here. + final List phoneticNameList = VCardUtils.constructListFromValue(propValue, + mVCardType); + handlePhoneticNameFromSound(phoneticNameList); + } else { + // Ignore this field since Android cannot understand what it is. + } + } else if (propertyName.equals(VCardConstants.PROPERTY_ADR)) { + boolean valuesAreAllEmpty = true; + for (String value : propertyValueList) { + if (!TextUtils.isEmpty(value)) { + valuesAreAllEmpty = false; + break; + } + } + if (valuesAreAllEmpty) { + return; + } + + int type = -1; + String label = null; + boolean isPrimary = false; + final Collection typeCollection = paramMap.get(VCardConstants.PARAM_TYPE); + if (typeCollection != null) { + for (final String typeStringOrg : typeCollection) { + final String typeStringUpperCase = typeStringOrg.toUpperCase(); + if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_PREF)) { + isPrimary = true; + } else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_HOME)) { + type = StructuredPostal.TYPE_HOME; + label = null; + } else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_WORK) + || typeStringUpperCase + .equalsIgnoreCase(VCardConstants.PARAM_EXTRA_TYPE_COMPANY)) { + // "COMPANY" seems emitted by Windows Mobile, which is not + // specifically supported by vCard 2.1. We assume this is same + // as "WORK". + type = StructuredPostal.TYPE_WORK; + label = null; + } else if (typeStringUpperCase.equals(VCardConstants.PARAM_ADR_TYPE_PARCEL) + || typeStringUpperCase.equals(VCardConstants.PARAM_ADR_TYPE_DOM) + || typeStringUpperCase.equals(VCardConstants.PARAM_ADR_TYPE_INTL)) { + // We do not have any appropriate way to store this information. + } else if (type < 0) { // If no other type is specified before. + type = StructuredPostal.TYPE_CUSTOM; + if (typeStringUpperCase.startsWith("X-")) { // If X- or x- + label = typeStringOrg.substring(2); + } else { + label = typeStringOrg; + } + // {@link ContactsContract} has a {@link StructuredPostal.TYPE_OTHER}, so + // if the custom type is "other", map it from {@code TYPE_CUSTOM} to + // {@code TYPE_OTHER}. + if (VCardConstants.PARAM_ADR_EXTRA_TYPE_OTHER.equals(label.toUpperCase())) { + type = StructuredPostal.TYPE_OTHER; + label = null; + } + } + } + } + // We use "HOME" as default + if (type < 0) { + type = StructuredPostal.TYPE_HOME; + } + + addPostal(type, propertyValueList, label, isPrimary); + } else if (propertyName.equals(VCardConstants.PROPERTY_EMAIL)) { + int type = -1; + String label = null; + boolean isPrimary = false; + final Collection typeCollection = paramMap.get(VCardConstants.PARAM_TYPE); + if (typeCollection != null) { + for (final String typeStringOrg : typeCollection) { + final String typeStringUpperCase = typeStringOrg.toUpperCase(); + if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_PREF)) { + isPrimary = true; + } else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_HOME)) { + type = Email.TYPE_HOME; + } else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_WORK)) { + type = Email.TYPE_WORK; + } else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_CELL)) { + type = Email.TYPE_MOBILE; + } else if (type < 0) { // If no other type is specified before + if (typeStringUpperCase.startsWith("X-")) { // If X- or x- + label = typeStringOrg.substring(2); + } else { + label = typeStringOrg; + } + type = Email.TYPE_CUSTOM; + } + } + } + if (type < 0) { + type = Email.TYPE_OTHER; + } + addEmail(type, propValue, label, isPrimary); + } else if (propertyName.equals(VCardConstants.PROPERTY_ORG)) { + // vCard specification does not specify other types. + final int type = Organization.TYPE_WORK; + boolean isPrimary = false; + Collection typeCollection = paramMap.get(VCardConstants.PARAM_TYPE); + if (typeCollection != null) { + for (String typeString : typeCollection) { + if (typeString.equals(VCardConstants.PARAM_TYPE_PREF)) { + isPrimary = true; + } + } + } + handleOrgValue(type, propertyValueList, paramMap, isPrimary); + } else if (propertyName.equals(VCardConstants.PROPERTY_TITLE)) { + handleTitleValue(propValue); + } else if (propertyName.equals(VCardConstants.PROPERTY_ROLE)) { + // This conflicts with TITLE. Ignore for now... + // handleTitleValue(propValue); + } else if (propertyName.equals(VCardConstants.PROPERTY_PHOTO) + || propertyName.equals(VCardConstants.PROPERTY_LOGO)) { + Collection paramMapValue = paramMap.get("VALUE"); + if (paramMapValue != null && paramMapValue.contains("URL")) { + // Currently we do not have appropriate example for testing this case. + } else { + final Collection typeCollection = paramMap.get("TYPE"); + String formatName = null; + boolean isPrimary = false; + if (typeCollection != null) { + for (String typeValue : typeCollection) { + if (VCardConstants.PARAM_TYPE_PREF.equals(typeValue)) { + isPrimary = true; + } else if (formatName == null) { + formatName = typeValue; + } + } + } + addPhotoBytes(formatName, propertyBytes, isPrimary); + } + } else if (propertyName.equals(VCardConstants.PROPERTY_TEL)) { + String phoneNumber = null; + boolean isSip = false; + if (VCardConfig.isVersion40(mVCardType)) { + // Given propValue is in URI format, not in phone number format used until + // vCard 3.0. + if (propValue.startsWith("sip:")) { + isSip = true; + } else if (propValue.startsWith("tel:")) { + phoneNumber = propValue.substring(4); + } else { + // We don't know appropriate way to handle the other schemas. Also, + // we may still have non-URI phone number. To keep given data as much as + // we can, just save original value here. + phoneNumber = propValue; + } + } else { + phoneNumber = propValue; + } + + if (isSip) { + final Collection typeCollection = paramMap.get(VCardConstants.PARAM_TYPE); + handleSipCase(propValue, typeCollection); + } else { + if (propValue.length() == 0) { + return; + } + + final Collection typeCollection = paramMap.get(VCardConstants.PARAM_TYPE); + final Object typeObject = VCardUtils.getPhoneTypeFromStrings(typeCollection, + phoneNumber); + final int type; + final String label; + if (typeObject instanceof Integer) { + type = (Integer) typeObject; + label = null; + } else { + type = Phone.TYPE_CUSTOM; + label = typeObject.toString(); + } + + final boolean isPrimary; + if (typeCollection != null && + typeCollection.contains(VCardConstants.PARAM_TYPE_PREF)) { + isPrimary = true; + } else { + isPrimary = false; + } + + addPhone(type, phoneNumber, label, isPrimary); + } + } else if (propertyName.equals(VCardConstants.PROPERTY_X_SKYPE_PSTNNUMBER)) { + // The phone number available via Skype. + Collection typeCollection = paramMap.get(VCardConstants.PARAM_TYPE); + final int type = Phone.TYPE_OTHER; + final boolean isPrimary; + if (typeCollection != null + && typeCollection.contains(VCardConstants.PARAM_TYPE_PREF)) { + isPrimary = true; + } else { + isPrimary = false; + } + addPhone(type, propValue, null, isPrimary); + } else if (sImMap.containsKey(propertyName)) { + final int protocol = sImMap.get(propertyName); + boolean isPrimary = false; + int type = -1; + final Collection typeCollection = paramMap.get(VCardConstants.PARAM_TYPE); + if (typeCollection != null) { + for (String typeString : typeCollection) { + if (typeString.equals(VCardConstants.PARAM_TYPE_PREF)) { + isPrimary = true; + } else if (type < 0) { + if (typeString.equalsIgnoreCase(VCardConstants.PARAM_TYPE_HOME)) { + type = Im.TYPE_HOME; + } else if (typeString.equalsIgnoreCase(VCardConstants.PARAM_TYPE_WORK)) { + type = Im.TYPE_WORK; + } + } + } + } + if (type < 0) { + type = Im.TYPE_HOME; + } + addIm(protocol, null, propValue, type, isPrimary); + } else if (propertyName.equals(VCardConstants.PROPERTY_NOTE)) { + addNote(propValue); + } else if (propertyName.equals(VCardConstants.PROPERTY_URL)) { + if (mWebsiteList == null) { + mWebsiteList = new ArrayList(1); + } + mWebsiteList.add(new WebsiteData(propValue)); + } else if (propertyName.equals(VCardConstants.PROPERTY_BDAY)) { + mBirthday = new BirthdayData(propValue); + } else if (propertyName.equals(VCardConstants.PROPERTY_ANNIVERSARY)) { + mAnniversary = new AnniversaryData(propValue); + } else if (propertyName.equals(VCardConstants.PROPERTY_X_PHONETIC_FIRST_NAME)) { + mNameData.mPhoneticGiven = propValue; + } else if (propertyName.equals(VCardConstants.PROPERTY_X_PHONETIC_MIDDLE_NAME)) { + mNameData.mPhoneticMiddle = propValue; + } else if (propertyName.equals(VCardConstants.PROPERTY_X_PHONETIC_LAST_NAME)) { + mNameData.mPhoneticFamily = propValue; + } else if (propertyName.equals(VCardConstants.PROPERTY_IMPP)) { + // See also RFC 4770 (for vCard 3.0) + if (propValue.startsWith("sip:")) { + final Collection typeCollection = paramMap.get(VCardConstants.PARAM_TYPE); + handleSipCase(propValue, typeCollection); + } + } else if (propertyName.equals(VCardConstants.PROPERTY_X_SIP)) { + if (!TextUtils.isEmpty(propValue)) { + final Collection typeCollection = paramMap.get(VCardConstants.PARAM_TYPE); + handleSipCase(propValue, typeCollection); + } + } else if (propertyName.equals(VCardConstants.PROPERTY_X_ANDROID_CUSTOM)) { + final List customPropertyList = VCardUtils.constructListFromValue(propValue, + mVCardType); + handleAndroidCustomProperty(customPropertyList); + } else if (propertyName.toUpperCase().startsWith("X-")) { + // Catch all for X- properties. The caller can decide what to do with these. + if (mUnknownXData == null) { + mUnknownXData = new ArrayList>(); + } + mUnknownXData.add(new Pair(propertyName, propValue)); + } else { + } + // Be careful when adding some logic here, as some blocks above may use "return". + } + + /** + * @param propValue may contain "sip:" at the beginning. + * @param typeCollection + */ + private void handleSipCase(String propValue, Collection typeCollection) { + if (TextUtils.isEmpty(propValue)) { + return; + } + if (propValue.startsWith("sip:")) { + propValue = propValue.substring(4); + if (propValue.length() == 0) { + return; + } + } + + int type = -1; + String label = null; + boolean isPrimary = false; + if (typeCollection != null) { + for (final String typeStringOrg : typeCollection) { + final String typeStringUpperCase = typeStringOrg.toUpperCase(); + if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_PREF)) { + isPrimary = true; + } else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_HOME)) { + type = SipAddress.TYPE_HOME; + } else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_WORK)) { + type = SipAddress.TYPE_WORK; + } else if (type < 0) { // If no other type is specified before + if (typeStringUpperCase.startsWith("X-")) { // If X- or x- + label = typeStringOrg.substring(2); + } else { + label = typeStringOrg; + } + type = SipAddress.TYPE_CUSTOM; + } + } + } + if (type < 0) { + type = SipAddress.TYPE_OTHER; + } + addSip(propValue, type, label, isPrimary); + } + + public void addChild(VCardEntry child) { + if (mChildren == null) { + mChildren = new ArrayList(); + } + mChildren.add(child); + } + + private void handleAndroidCustomProperty(final List customPropertyList) { + if (mAndroidCustomDataList == null) { + mAndroidCustomDataList = new ArrayList(); + } + mAndroidCustomDataList + .add(AndroidCustomData.constructAndroidCustomData(customPropertyList)); + } + + /** + * Construct the display name. The constructed data must not be null. + */ + private String constructDisplayName() { + String displayName = null; + // FullName (created via "FN" or "NAME" field) is prefered. + if (!TextUtils.isEmpty(mNameData.mFormatted)) { + displayName = mNameData.mFormatted; + } else if (!mNameData.emptyStructuredName()) { + displayName = VCardUtils.constructNameFromElements(mVCardType, mNameData.mFamily, + mNameData.mMiddle, mNameData.mGiven, mNameData.mPrefix, mNameData.mSuffix); + } else if (!mNameData.emptyPhoneticStructuredName()) { + displayName = VCardUtils.constructNameFromElements(mVCardType, + mNameData.mPhoneticFamily, mNameData.mPhoneticMiddle, mNameData.mPhoneticGiven); + } else if (mEmailList != null && mEmailList.size() > 0) { + displayName = mEmailList.get(0).mAddress; + } else if (mPhoneList != null && mPhoneList.size() > 0) { + displayName = mPhoneList.get(0).mNumber; + } else if (mPostalList != null && mPostalList.size() > 0) { + displayName = mPostalList.get(0).getFormattedAddress(mVCardType); + } else if (mOrganizationList != null && mOrganizationList.size() > 0) { + displayName = mOrganizationList.get(0).getFormattedString(); + } + if (displayName == null) { + displayName = ""; + } + return displayName; + } + + /** + * Consolidate several fielsds (like mName) using name candidates, + */ + public void consolidateFields() { + mNameData.displayName = constructDisplayName(); + } + + /** + * @return true when this object has nothing meaningful for Android's + * Contacts, and thus is "ignorable" for Android's Contacts. This + * does not mean an original vCard is really empty. Even when the + * original vCard has some fields, this may ignore it if those + * fields cannot be transcoded into Android's Contacts + * representation. + */ + public boolean isIgnorable() { + IsIgnorableIterator iterator = new IsIgnorableIterator(); + iterateAllData(iterator); + return iterator.getResult(); + } + + /** + * Constructs the list of insert operation for this object. When the + * operationList argument is null, this method creates a new ArrayList and + * return it. The returned object is filled with new insert operations for + * this object. When operationList argument is not null, this method appends + * those new operations into the object instead of creating a new ArrayList. + * + * @param resolver {@link ContentResolver} object to be used in this method. + * @param operationList object to be filled. You can use this argument to + * concatinate operation lists. If null, this method creates a + * new array object. + * @return If operationList argument is null, new object with new insert + * operations. If it is not null, the operationList object with + * operations inserted by this method. + */ + public ArrayList constructInsertOperations(ContentResolver resolver, + ArrayList operationList) { + if (operationList == null) { + operationList = new ArrayList(); + } + + if (isIgnorable()) { + return operationList; + } + + final int backReferenceIndex = operationList.size(); + + // After applying the batch the first result's Uri is returned so it is important that + // the RawContact is the first operation that gets inserted into the list. + ContentProviderOperation.Builder builder = ContentProviderOperation + .newInsert(RawContacts.CONTENT_URI); + if (mAccount != null) { + builder.withValue(RawContacts.ACCOUNT_NAME, mAccount.name); + builder.withValue(RawContacts.ACCOUNT_TYPE, mAccount.type); + } else { + builder.withValue(RawContacts.ACCOUNT_NAME, null); + builder.withValue(RawContacts.ACCOUNT_TYPE, null); + } + // contacts favorites + if (getStarred()) { + builder.withValue(RawContacts.STARRED, 1); + } + operationList.add(builder.build()); + + int start = operationList.size(); + iterateAllData(new InsertOperationConstrutor(operationList, backReferenceIndex)); + int end = operationList.size(); + + return operationList; + } + + public static VCardEntry buildFromResolver(ContentResolver resolver) { + return buildFromResolver(resolver, Contacts.CONTENT_URI); + } + + public static VCardEntry buildFromResolver(ContentResolver resolver, Uri uri) { + return null; + } + + private String listToString(List list) { + final int size = list.size(); + if (size > 1) { + StringBuilder builder = new StringBuilder(); + int i = 0; + for (String type : list) { + builder.append(type); + if (i < size - 1) { + builder.append(";"); + } + } + return builder.toString(); + } else if (size == 1) { + return list.get(0); + } else { + return ""; + } + } + + public final NameData getNameData() { + return mNameData; + } + + public final List getNickNameList() { + return mNicknameList; + } + + public final String getBirthday() { + return mBirthday != null ? mBirthday.mBirthday : null; + } + + public final List getNotes() { + return mNoteList; + } + + public final List getPhoneList() { + return mPhoneList; + } + + public final List getEmailList() { + return mEmailList; + } + + public final List getPostalList() { + return mPostalList; + } + + public final List getOrganizationList() { + return mOrganizationList; + } + + public final List getImList() { + return mImList; + } + + public final List getPhotoList() { + return mPhotoList; + } + + public final List getWebsiteList() { + return mWebsiteList; + } + + /** + * @hide this interface may be changed for better support of vCard 4.0 (UID) + */ + public final List getChildlen() { + return mChildren; + } + + public String getDisplayName() { + if (mNameData.displayName == null) { + mNameData.displayName = constructDisplayName(); + } + return mNameData.displayName; + } + + public List> getUnknownXData() { + return mUnknownXData; + } +} diff --git a/modules/vcard/java/com/android/vcard/VCardEntryCommitter.java b/modules/vcard/java/com/android/vcard/VCardEntryCommitter.java new file mode 100644 index 0000000..8c49da7 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardEntryCommitter.java @@ -0,0 +1,118 @@ +/* + * 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.vcard; + +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.OperationApplicationException; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.ContactsContract; +import android.util.Log; + +import java.util.ArrayList; + +/** + *

+ * {@link VCardEntryHandler} implementation which commits the entry to ContentResolver. + *

+ *

+ * Note:
+ * Each vCard may contain big photo images encoded by BASE64, + * If we store all vCard entries in memory, OutOfMemoryError may be thrown. + * Thus, this class push each VCard entry into ContentResolver immediately. + *

+ */ +public class VCardEntryCommitter implements VCardEntryHandler { + public static String LOG_TAG = VCardConstants.LOG_TAG; + + private final ContentResolver mContentResolver; + private long mTimeToCommit; + // Set the default maximum batch size to 20 + private int mMaxBatchSize = 20; + private ArrayList mOperationList; + private final ArrayList mCreatedUris = new ArrayList(); + + public VCardEntryCommitter(ContentResolver resolver) { + mContentResolver = resolver; + } + + @Override + public void onStart() { + } + + @Override + public void onEnd() { + if (mOperationList != null) { + mCreatedUris.add(pushIntoContentResolver(mOperationList)); + } + + if (VCardConfig.showPerformanceLog()) { + Log.d(LOG_TAG, String.format("time to commit entries: %d ms", mTimeToCommit)); + } + } + + //because the max batch size is 500 defined in ContactsProvider,so we can enlarge this batch + //size to reduce db open/close times. From testing results, we can see performance better + //when batch size is more bigger.And also each vcardEntry may have some operation records. + //So we can set threshold as 450, batch operations will be executed when threshold reached. + //Testing result. + //batch size : 100 200 300 400 450 490 20 + //consume time(10000 contacts): 178s 143s 127s 124s 119s 117s 195s + //consume time (1000 contacts): 17.3s 13.9s 12.6s 12.2s 11.8s 11.6s 19.8s + public void setMaxBatchSize(int batchSize) { + mMaxBatchSize = batchSize; + } + + @Override + public void onEntryCreated(final VCardEntry vcardEntry) { + final long start = System.currentTimeMillis(); + mOperationList = vcardEntry.constructInsertOperations(mContentResolver, mOperationList); + if (mOperationList != null && mOperationList.size() >= mMaxBatchSize) { + mCreatedUris.add(pushIntoContentResolver(mOperationList)); + mOperationList = null; + } + mTimeToCommit += System.currentTimeMillis() - start; + } + + private Uri pushIntoContentResolver(ArrayList operationList) { + try { + final ContentProviderResult[] results = mContentResolver.applyBatch( + ContactsContract.AUTHORITY, operationList); + + // the first result is always the raw_contact. return it's uri so + // that it can be found later. do null checking for badly behaving + // ContentResolvers + return ((results == null || results.length == 0 || results[0] == null) + ? null : results[0].uri); + } catch (RemoteException e) { + Log.e(LOG_TAG, String.format("%s: %s", e.toString(), e.getMessage())); + return null; + } catch (OperationApplicationException e) { + Log.e(LOG_TAG, String.format("%s: %s", e.toString(), e.getMessage())); + return null; + } + } + + /** + * Returns the list of created Uris. This list should not be modified by the caller as it is + * not a clone. + */ + public ArrayList getCreatedUris() { + return mCreatedUris; + } +} \ No newline at end of file diff --git a/modules/vcard/java/com/android/vcard/VCardEntryConstructor.java b/modules/vcard/java/com/android/vcard/VCardEntryConstructor.java new file mode 100644 index 0000000..8f677af --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardEntryConstructor.java @@ -0,0 +1,132 @@ +/* + * 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.vcard; + +import android.accounts.Account; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + *

+ * The {@link VCardInterpreter} implementation which enables {@link VCardEntryHandler} objects + * to easily handle each vCard entry. + *

+ *

+ * This class understand details inside vCard and translates it to {@link VCardEntry}. + * Then the class throw it to {@link VCardEntryHandler} registered via + * {@link #addEntryHandler(VCardEntryHandler)}, so that all those registered objects + * are able to handle the {@link VCardEntry} object. + *

+ *

+ * If you want to know the detail inside vCard, it would be better to implement + * {@link VCardInterpreter} directly, instead of relying on this class and + * {@link VCardEntry} created by the object. + *

+ */ +public class VCardEntryConstructor implements VCardInterpreter { + private static String LOG_TAG = VCardConstants.LOG_TAG; + + /** + * Represents current stack of VCardEntry. Used to support nested vCard (vCard 2.1). + */ + private final List mEntryStack = new ArrayList(); + private VCardEntry mCurrentEntry; + + private final int mVCardType; + private final Account mAccount; + + private final List mEntryHandlers = new ArrayList(); + + public VCardEntryConstructor() { + this(VCardConfig.VCARD_TYPE_V21_GENERIC, null, null); + } + + public VCardEntryConstructor(final int vcardType) { + this(vcardType, null, null); + } + + public VCardEntryConstructor(final int vcardType, final Account account) { + this(vcardType, account, null); + } + + /** + * @deprecated targetCharset is not used anymore. + * Use {@link #VCardEntryConstructor(int, Account)} + */ + @Deprecated + public VCardEntryConstructor(final int vcardType, final Account account, + String targetCharset) { + mVCardType = vcardType; + mAccount = account; + } + + public void addEntryHandler(VCardEntryHandler entryHandler) { + mEntryHandlers.add(entryHandler); + } + + @Override + public void onVCardStarted() { + for (VCardEntryHandler entryHandler : mEntryHandlers) { + entryHandler.onStart(); + } + } + + @Override + public void onVCardEnded() { + for (VCardEntryHandler entryHandler : mEntryHandlers) { + entryHandler.onEnd(); + } + } + + public void clear() { + mCurrentEntry = null; + mEntryStack.clear(); + } + + @Override + public void onEntryStarted() { + mCurrentEntry = new VCardEntry(mVCardType, mAccount); + mEntryStack.add(mCurrentEntry); + } + + @Override + public void onEntryEnded() { + mCurrentEntry.consolidateFields(); + for (VCardEntryHandler entryHandler : mEntryHandlers) { + entryHandler.onEntryCreated(mCurrentEntry); + } + + final int size = mEntryStack.size(); + if (size > 1) { + VCardEntry parent = mEntryStack.get(size - 2); + parent.addChild(mCurrentEntry); + mCurrentEntry = parent; + } else { + mCurrentEntry = null; + } + mEntryStack.remove(size - 1); + } + + @Override + public void onPropertyCreated(VCardProperty property) { + mCurrentEntry.addProperty(property); + } +} diff --git a/modules/vcard/java/com/android/vcard/VCardEntryCounter.java b/modules/vcard/java/com/android/vcard/VCardEntryCounter.java new file mode 100644 index 0000000..4bbf34a --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardEntryCounter.java @@ -0,0 +1,48 @@ +/* + * 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.vcard; + +/** + * The class which just counts the number of vCard entries in the specified input. + */ +public class VCardEntryCounter implements VCardInterpreter { + private int mCount; + + public int getCount() { + return mCount; + } + + @Override + public void onVCardStarted() { + } + + @Override + public void onVCardEnded() { + } + + @Override + public void onEntryStarted() { + } + + @Override + public void onEntryEnded() { + mCount++; + } + + @Override + public void onPropertyCreated(VCardProperty property) { + } +} diff --git a/modules/vcard/java/com/android/vcard/VCardEntryHandler.java b/modules/vcard/java/com/android/vcard/VCardEntryHandler.java new file mode 100644 index 0000000..1c26dd1 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardEntryHandler.java @@ -0,0 +1,55 @@ +/* + * 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.vcard; + +/** + *

+ * The interface called by {@link VCardEntryConstructor}. + *

+ *

+ * This class is useful when you don't want to know vCard data in detail. If you want to know + * it, it would be better to consider using {@link VCardInterpreter}. + *

+ */ +public interface VCardEntryHandler { + /** + * Called when the parsing started. + */ + public void onStart(); + + /** + * The method called when one vCard entry is created. Children come before their parent in + * nested vCard files. + * + * e.g. + * In the following vCard, the entry for "entry2" comes before one for "entry1". + * + * BEGIN:VCARD + * N:entry1 + * BEGIN:VCARD + * N:entry2 + * END:VCARD + * END:VCARD + * + */ + public void onEntryCreated(final VCardEntry entry); + + /** + * Called when the parsing ended. + * Able to be use this method for showing performance log, etc. + */ + public void onEnd(); +} diff --git a/modules/vcard/java/com/android/vcard/VCardInterpreter.java b/modules/vcard/java/com/android/vcard/VCardInterpreter.java new file mode 100644 index 0000000..ff64cf5 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardInterpreter.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.vcard; + +import java.util.List; + +/** + *

+ * The interface which should be implemented by the classes which have to analyze each + * vCard entry minutely. + *

+ *

+ * Here, there are several terms specific to vCard (and this library). + *

+ *

+ * The term "entry" is one vCard representation in the input, which should start with "BEGIN:VCARD" + * and end with "END:VCARD". + *

+ *

+ * The term "property" is one line in vCard entry, which consists of "group", "property name", + * "parameter(param) names and values", and "property values". + *

+ *

+ * e.g. group1.propName;paramName1=paramValue1;paramName2=paramValue2;propertyValue1;propertyValue2... + *

+ */ +public interface VCardInterpreter { + /** + * Called when vCard interpretation started. + */ + void onVCardStarted(); + + /** + * Called when vCard interpretation finished. + */ + void onVCardEnded(); + + /** + * Called when parsing one vCard entry started. + * More specifically, this method is called when "BEGIN:VCARD" is read. + * + * This may be called before {@link #onEntryEnded()} is called, as vCard 2.1 accepts nested + * vCard. + * + * + * BEGIN:VCARD + * BEGIN:VCARD + * VERSION:2.1 + * N:test;;;; + * END:VCARD + * END:VCARD + * + */ + void onEntryStarted(); + + /** + * Called when parsing one vCard entry ended. + * More specifically, this method is called when "END:VCARD" is read. + */ + void onEntryEnded(); + + /** + * Called when a property is created. + */ + void onPropertyCreated(VCardProperty property); +} diff --git a/modules/vcard/java/com/android/vcard/VCardParser.java b/modules/vcard/java/com/android/vcard/VCardParser.java new file mode 100644 index 0000000..2cc4932 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardParser.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.vcard; + +import com.android.vcard.exception.VCardException; + +import java.io.IOException; +import java.io.InputStream; + +public abstract class VCardParser { + + /** + * Registers one {@link VCardInterpreter} instance, which receives events along with + * vCard parsing. + * + * @param interpreter + */ + public abstract void addInterpreter(VCardInterpreter interpreter); + + /** + *

Parses a whole InputStream as a vCard file and lets registered {@link VCardInterpreter} + * instances handle callbacks.

+ * + *

This method reads a whole InputStream. If you just want to parse one vCard entry inside + * a vCard file with multiple entries, try {@link #parseOne(InputStream)}.

+ * + * @param is The source to parse. + * @throws IOException, VCardException + */ + public abstract void parse(InputStream is) throws IOException, VCardException; + + /** + *

Parses the first vCard entry in InputStream and lets registered {@link VCardInterpreter} + * instances handle callbacks.

+ * + *

This method finishes itself when the first entry ended.

+ * + *

Note that, registered {@link VCardInterpreter} may still see multiple + * {@link VCardInterpreter#onEntryStarted()} / {@link VCardInterpreter#onEntryEnded()} calls + * even with this method.

+ * + *

This happens when the first entry contains nested vCards, which is allowed in vCard 2.1. + * See the following example.

+ * + * + * BEGIN:VCARD + * N:a + * BEGIN:VCARD + * N:b + * END:VCARD + * END:VCARD + * + * + *

With this vCard, registered interpreters will grab two + * {@link VCardInterpreter#onEntryStarted()} and {@link VCardInterpreter#onEntryEnded()} + * calls. Callers should handle the situation by themselves.

+ * + * @param is The source to parse. + * @throws IOException, VCardException + */ + public abstract void parseOne(InputStream is) throws IOException, VCardException; + + /** + * @deprecated use {@link #addInterpreter(VCardInterpreter)} and + * {@link #parse(InputStream)} + */ + @Deprecated + public void parse(InputStream is, VCardInterpreter interpreter) + throws IOException, VCardException { + addInterpreter(interpreter); + parse(is); + } + + /** + *

+ * Cancel parsing vCard. Useful when you want to stop the parse in the other threads. + *

+ *

+ * Actual cancel is done after parsing the current vcard. + *

+ */ + public abstract void cancel(); +} diff --git a/modules/vcard/java/com/android/vcard/VCardParserImpl_V21.java b/modules/vcard/java/com/android/vcard/VCardParserImpl_V21.java new file mode 100644 index 0000000..cb5c76a --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardParserImpl_V21.java @@ -0,0 +1,1060 @@ +/* + * 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.vcard; + +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; + +import com.android.vcard.exception.VCardAgentNotSupportedException; +import com.android.vcard.exception.VCardException; +import com.android.vcard.exception.VCardInvalidCommentLineException; +import com.android.vcard.exception.VCardInvalidLineException; +import com.android.vcard.exception.VCardVersionException; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + *

+ * Basic implementation achieving vCard parsing. Based on vCard 2.1. + *

+ * @hide + */ +/* package */ class VCardParserImpl_V21 { + private static final String LOG_TAG = VCardConstants.LOG_TAG; + + protected static final class CustomBufferedReader extends BufferedReader { + private long mTime; + + /** + * Needed since "next line" may be null due to end of line. + */ + private boolean mNextLineIsValid; + private String mNextLine; + + public CustomBufferedReader(Reader in) { + super(in); + } + + @Override + public String readLine() throws IOException { + if (mNextLineIsValid) { + final String ret = mNextLine; + mNextLine = null; + mNextLineIsValid = false; + return ret; + } + + final long start = System.currentTimeMillis(); + final String line = super.readLine(); + final long end = System.currentTimeMillis(); + mTime += end - start; + return line; + } + + /** + * Read one line, but make this object store it in its queue. + */ + public String peekLine() throws IOException { + if (!mNextLineIsValid) { + final long start = System.currentTimeMillis(); + final String line = super.readLine(); + final long end = System.currentTimeMillis(); + mTime += end - start; + + mNextLine = line; + mNextLineIsValid = true; + } + + return mNextLine; + } + + public long getTotalmillisecond() { + return mTime; + } + } + + private static final String DEFAULT_ENCODING = "8BIT"; + private static final String DEFAULT_CHARSET = "UTF-8"; + + protected final String mIntermediateCharset; + + private final List mInterpreterList = new ArrayList(); + private boolean mCanceled; + + /** + *

+ * The encoding type for deconding byte streams. This member variable is + * reset to a default encoding every time when a new item comes. + *

+ *

+ * "Encoding" in vCard is different from "Charset". It is mainly used for + * addresses, notes, images. "7BIT", "8BIT", "BASE64", and + * "QUOTED-PRINTABLE" are known examples. + *

+ */ + protected String mCurrentEncoding; + + protected String mCurrentCharset; + + /** + *

+ * The reader object to be used internally. + *

+ *

+ * Developers should not directly read a line from this object. Use + * getLine() unless there some reason. + *

+ */ + protected CustomBufferedReader mReader; + + /** + *

+ * Set for storing unkonwn TYPE attributes, which is not acceptable in vCard + * specification, but happens to be seen in real world vCard. + *

+ *

+ * We just accept those invalid types after emitting a warning for each of it. + *

+ */ + protected final Set mUnknownTypeSet = new HashSet(); + + /** + *

+ * Set for storing unkonwn VALUE attributes, which is not acceptable in + * vCard specification, but happens to be seen in real world vCard. + *

+ *

+ * We just accept those invalid types after emitting a warning for each of it. + *

+ */ + protected final Set mUnknownValueSet = new HashSet(); + + + public VCardParserImpl_V21() { + this(VCardConfig.VCARD_TYPE_DEFAULT); + } + + public VCardParserImpl_V21(int vcardType) { + mIntermediateCharset = VCardConfig.DEFAULT_INTERMEDIATE_CHARSET; + } + + /** + * @return true when a given property name is a valid property name. + */ + protected boolean isValidPropertyName(final String propertyName) { + if (!(getKnownPropertyNameSet().contains(propertyName.toUpperCase()) || + propertyName.startsWith("X-")) + && !mUnknownTypeSet.contains(propertyName)) { + mUnknownTypeSet.add(propertyName); + Log.w(LOG_TAG, "Property name unsupported by vCard 2.1: " + propertyName); + } + return true; + } + + /** + * @return String. It may be null, or its length may be 0 + * @throws IOException + */ + protected String getLine() throws IOException { + return mReader.readLine(); + } + + protected String peekLine() throws IOException { + return mReader.peekLine(); + } + + /** + * @return String with it's length > 0 + * @throws IOException + * @throws VCardException when the stream reached end of line + */ + protected String getNonEmptyLine() throws IOException, VCardException { + String line; + while (true) { + line = getLine(); + if (line == null) { + throw new VCardException("Reached end of buffer."); + } else if (line.trim().length() > 0) { + return line; + } + } + } + + /** + * + * vcard = "BEGIN" [ws] ":" [ws] "VCARD" [ws] 1*CRLF + * items *CRLF + * "END" [ws] ":" [ws] "VCARD" + * + * @return False when reaching end of file. + */ + private boolean parseOneVCard() throws IOException, VCardException { + // reset for this entire vCard. + mCurrentEncoding = DEFAULT_ENCODING; + mCurrentCharset = DEFAULT_CHARSET; + + // allow parsing of vcards that have mime data leading up to BEGIN:VCARD + boolean allowGarbage = true; + if (!readBeginVCard(allowGarbage)) { + return false; + } + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onEntryStarted(); + } + parseItems(); + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onEntryEnded(); + } + return true; + } + + /** + * @return True when successful. False when reaching the end of line + * @throws IOException + * @throws VCardException + */ + protected boolean readBeginVCard(boolean allowGarbage) throws IOException, VCardException { + // TODO: use consructPropertyLine(). + String line; + do { + while (true) { + line = getLine(); + if (line == null) { + return false; + } else if (line.trim().length() > 0) { + break; + } + } + final String[] strArray = line.split(":", 2); + final int length = strArray.length; + + // Although vCard 2.1/3.0 specification does not allow lower cases, + // we found vCard file emitted by some external vCard expoter have such + // invalid Strings. + // e.g. BEGIN:vCard + if (length == 2 && strArray[0].trim().equalsIgnoreCase("BEGIN") + && strArray[1].trim().equalsIgnoreCase("VCARD")) { + return true; + } else if (!allowGarbage) { + throw new VCardException("Expected String \"BEGIN:VCARD\" did not come " + + "(Instead, \"" + line + "\" came)"); + } + } while (allowGarbage); + + throw new VCardException("Reached where must not be reached."); + } + + /** + * Parses lines other than the first "BEGIN:VCARD". Takes care of "END:VCARD"n and + * "BEGIN:VCARD" in nested vCard. + */ + /* + * items = *CRLF item / item + * + * Note: BEGIN/END aren't include in the original spec while this method handles them. + */ + protected void parseItems() throws IOException, VCardException { + boolean ended = false; + + try { + ended = parseItem(); + } catch (VCardInvalidCommentLineException e) { + Log.e(LOG_TAG, "Invalid line which looks like some comment was found. Ignored."); + } + + while (!ended) { + try { + ended = parseItem(); + } catch (VCardInvalidCommentLineException e) { + Log.e(LOG_TAG, "Invalid line which looks like some comment was found. Ignored."); + } + } + } + + /* + * item = [groups "."] name [params] ":" value CRLF / [groups "."] "ADR" + * [params] ":" addressparts CRLF / [groups "."] "ORG" [params] ":" orgparts + * CRLF / [groups "."] "N" [params] ":" nameparts CRLF / [groups "."] + * "AGENT" [params] ":" vcard CRLF + */ + protected boolean parseItem() throws IOException, VCardException { + // Reset for an item. + mCurrentEncoding = DEFAULT_ENCODING; + + final String line = getNonEmptyLine(); + final VCardProperty propertyData; + try { + propertyData = constructPropertyData(line); + } catch (VCardInvalidLineException e) { + Log.w(LOG_TAG, "VCardInvalidLineException: ignoring", e); + return false; + } + + final String propertyNameUpper = propertyData.getName().toUpperCase(); + final String propertyRawValue = propertyData.getRawValue(); + + if (propertyNameUpper.equals(VCardConstants.PROPERTY_BEGIN)) { + if (propertyRawValue.equalsIgnoreCase("VCARD")) { + handleNest(); + } else { + throw new VCardException("Unknown BEGIN type: " + propertyRawValue); + } + } else if (propertyNameUpper.equals(VCardConstants.PROPERTY_END)) { + if (propertyRawValue.equalsIgnoreCase("VCARD")) { + return true; // Ended. + } else { + throw new VCardException("Unknown END type: " + propertyRawValue); + } + } else { + parseItemInter(propertyData, propertyNameUpper); + } + return false; + } + + private void parseItemInter(VCardProperty property, String propertyNameUpper) + throws IOException, VCardException { + String propertyRawValue = property.getRawValue(); + if (propertyNameUpper.equals(VCardConstants.PROPERTY_AGENT)) { + handleAgent(property); + } else if (isValidPropertyName(propertyNameUpper)) { + if (propertyNameUpper.equals(VCardConstants.PROPERTY_VERSION) && + !propertyRawValue.equals(getVersionString())) { + throw new VCardVersionException( + "Incompatible version: " + propertyRawValue + " != " + getVersionString()); + } + handlePropertyValue(property, propertyNameUpper); + } else { + Log.w(LOG_TAG, "Unknown property name: \"" + propertyNameUpper + "\", ignoring"); + } + } + + private void handleNest() throws IOException, VCardException { + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onEntryStarted(); + } + parseItems(); + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onEntryEnded(); + } + } + + // For performance reason, the states for group and property name are merged into one. + static private final int STATE_GROUP_OR_PROPERTY_NAME = 0; + static private final int STATE_PARAMS = 1; + // vCard 3.0 specification allows double-quoted parameters, while vCard 2.1 does not. + static private final int STATE_PARAMS_IN_DQUOTE = 2; + + protected VCardProperty constructPropertyData(String line) throws VCardException { + final VCardProperty propertyData = new VCardProperty(); + + final int length = line.length(); + if (length > 0 && line.charAt(0) == '#') { + throw new VCardInvalidCommentLineException(); + } + + int state = STATE_GROUP_OR_PROPERTY_NAME; + int nameIndex = 0; + + // This loop is developed so that we don't have to take care of bottle neck here. + // Refactor carefully when you need to do so. + for (int i = 0; i < length; i++) { + final char ch = line.charAt(i); + switch (state) { + case STATE_GROUP_OR_PROPERTY_NAME: { + if (ch == ':') { // End of a property name. + final String propertyName = line.substring(nameIndex, i); + propertyData.setName(propertyName); + propertyData.setRawValue( i < length - 1 ? line.substring(i + 1) : ""); + return propertyData; + } else if (ch == '.') { // Each group is followed by the dot. + final String groupName = line.substring(nameIndex, i); + if (groupName.length() == 0) { + Log.w(LOG_TAG, "Empty group found. Ignoring."); + } else { + propertyData.addGroup(groupName); + } + nameIndex = i + 1; // Next should be another group or a property name. + } else if (ch == ';') { // End of property name and beginneng of parameters. + final String propertyName = line.substring(nameIndex, i); + propertyData.setName(propertyName); + nameIndex = i + 1; + state = STATE_PARAMS; // Start parameter parsing. + } + // TODO: comma support (in vCard 3.0 and 4.0). + break; + } + case STATE_PARAMS: { + if (ch == '"') { + if (VCardConstants.VERSION_V21.equalsIgnoreCase(getVersionString())) { + Log.w(LOG_TAG, "Double-quoted params found in vCard 2.1. " + + "Silently allow it"); + } + state = STATE_PARAMS_IN_DQUOTE; + } else if (ch == ';') { // Starts another param. + handleParams(propertyData, line.substring(nameIndex, i)); + nameIndex = i + 1; + } else if (ch == ':') { // End of param and beginenning of values. + handleParams(propertyData, line.substring(nameIndex, i)); + propertyData.setRawValue(i < length - 1 ? line.substring(i + 1) : ""); + return propertyData; + } + break; + } + case STATE_PARAMS_IN_DQUOTE: { + if (ch == '"') { + if (VCardConstants.VERSION_V21.equalsIgnoreCase(getVersionString())) { + Log.w(LOG_TAG, "Double-quoted params found in vCard 2.1. " + + "Silently allow it"); + } + state = STATE_PARAMS; + } + break; + } + } + } + + throw new VCardInvalidLineException("Invalid line: \"" + line + "\""); + } + + /* + * params = ";" [ws] paramlist paramlist = paramlist [ws] ";" [ws] param / + * param param = "TYPE" [ws] "=" [ws] ptypeval / "VALUE" [ws] "=" [ws] + * pvalueval / "ENCODING" [ws] "=" [ws] pencodingval / "CHARSET" [ws] "=" + * [ws] charsetval / "LANGUAGE" [ws] "=" [ws] langval / "X-" word [ws] "=" + * [ws] word / knowntype + */ + protected void handleParams(VCardProperty propertyData, String params) + throws VCardException { + final String[] strArray = params.split("=", 2); + if (strArray.length == 2) { + final String paramName = strArray[0].trim().toUpperCase(); + String paramValue = strArray[1].trim(); + if (paramName.equals("TYPE")) { + handleType(propertyData, paramValue); + } else if (paramName.equals("VALUE")) { + handleValue(propertyData, paramValue); + } else if (paramName.equals("ENCODING")) { + handleEncoding(propertyData, paramValue.toUpperCase()); + } else if (paramName.equals("CHARSET")) { + handleCharset(propertyData, paramValue); + } else if (paramName.equals("LANGUAGE")) { + handleLanguage(propertyData, paramValue); + } else if (paramName.startsWith("X-")) { + handleAnyParam(propertyData, paramName, paramValue); + } else { + throw new VCardException("Unknown type \"" + paramName + "\""); + } + } else { + handleParamWithoutName(propertyData, strArray[0]); + } + } + + /** + * vCard 3.0 parser implementation may throw VCardException. + */ + protected void handleParamWithoutName(VCardProperty propertyData, final String paramValue) { + handleType(propertyData, paramValue); + } + + /* + * ptypeval = knowntype / "X-" word + */ + protected void handleType(VCardProperty propertyData, final String ptypeval) { + if (!(getKnownTypeSet().contains(ptypeval.toUpperCase()) + || ptypeval.startsWith("X-")) + && !mUnknownTypeSet.contains(ptypeval)) { + mUnknownTypeSet.add(ptypeval); + Log.w(LOG_TAG, String.format("TYPE unsupported by %s: %s", getVersion(), ptypeval)); + } + propertyData.addParameter(VCardConstants.PARAM_TYPE, ptypeval); + } + + /* + * pvalueval = "INLINE" / "URL" / "CONTENT-ID" / "CID" / "X-" word + */ + protected void handleValue(VCardProperty propertyData, final String pvalueval) { + if (!(getKnownValueSet().contains(pvalueval.toUpperCase()) + || pvalueval.startsWith("X-") + || mUnknownValueSet.contains(pvalueval))) { + mUnknownValueSet.add(pvalueval); + Log.w(LOG_TAG, String.format( + "The value unsupported by TYPE of %s: %s", getVersion(), pvalueval)); + } + propertyData.addParameter(VCardConstants.PARAM_VALUE, pvalueval); + } + + /* + * pencodingval = "7BIT" / "8BIT" / "QUOTED-PRINTABLE" / "BASE64" / "X-" word + */ + protected void handleEncoding(VCardProperty propertyData, String pencodingval) + throws VCardException { + if (getAvailableEncodingSet().contains(pencodingval) || + pencodingval.startsWith("X-")) { + propertyData.addParameter(VCardConstants.PARAM_ENCODING, pencodingval); + // Update encoding right away, as this is needed to understanding other params. + mCurrentEncoding = pencodingval.toUpperCase(); + } else { + throw new VCardException("Unknown encoding \"" + pencodingval + "\""); + } + } + + /** + *

+ * vCard 2.1 specification only allows us-ascii and iso-8859-xxx (See RFC 1521), + * but recent vCard files often contain other charset like UTF-8, SHIFT_JIS, etc. + * We allow any charset. + *

+ */ + protected void handleCharset(VCardProperty propertyData, String charsetval) { + mCurrentCharset = charsetval; + propertyData.addParameter(VCardConstants.PARAM_CHARSET, charsetval); + } + + /** + * See also Section 7.1 of RFC 1521 + */ + protected void handleLanguage(VCardProperty propertyData, String langval) + throws VCardException { + String[] strArray = langval.split("-"); + if (strArray.length != 2) { + throw new VCardException("Invalid Language: \"" + langval + "\""); + } + String tmp = strArray[0]; + int length = tmp.length(); + for (int i = 0; i < length; i++) { + if (!isAsciiLetter(tmp.charAt(i))) { + throw new VCardException("Invalid Language: \"" + langval + "\""); + } + } + tmp = strArray[1]; + length = tmp.length(); + for (int i = 0; i < length; i++) { + if (!isAsciiLetter(tmp.charAt(i))) { + throw new VCardException("Invalid Language: \"" + langval + "\""); + } + } + propertyData.addParameter(VCardConstants.PARAM_LANGUAGE, langval); + } + + private boolean isAsciiLetter(char ch) { + if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) { + return true; + } + return false; + } + + /** + * Mainly for "X-" type. This accepts any kind of type without check. + */ + protected void handleAnyParam( + VCardProperty propertyData, String paramName, String paramValue) { + propertyData.addParameter(paramName, paramValue); + } + + protected void handlePropertyValue(VCardProperty property, String propertyName) + throws IOException, VCardException { + final String propertyNameUpper = property.getName().toUpperCase(); + String propertyRawValue = property.getRawValue(); + final String sourceCharset = VCardConfig.DEFAULT_INTERMEDIATE_CHARSET; + final Collection charsetCollection = + property.getParameters(VCardConstants.PARAM_CHARSET); + String targetCharset = + ((charsetCollection != null) ? charsetCollection.iterator().next() : null); + if (TextUtils.isEmpty(targetCharset)) { + targetCharset = VCardConfig.DEFAULT_IMPORT_CHARSET; + } + + // TODO: have "separableProperty" which reflects vCard spec.. + if (propertyNameUpper.equals(VCardConstants.PROPERTY_ADR) + || propertyNameUpper.equals(VCardConstants.PROPERTY_ORG) + || propertyNameUpper.equals(VCardConstants.PROPERTY_N)) { + handleAdrOrgN(property, propertyRawValue, sourceCharset, targetCharset); + return; + } + + if (mCurrentEncoding.equals(VCardConstants.PARAM_ENCODING_QP) || + // If encoding attribute is missing, then attempt to detect QP encoding. + // This is to handle a bug where the android exporter was creating FN properties + // with missing encoding. b/7292017 + (propertyNameUpper.equals(VCardConstants.PROPERTY_FN) && + property.getParameters(VCardConstants.PARAM_ENCODING) == null && + VCardUtils.appearsLikeAndroidVCardQuotedPrintable(propertyRawValue)) + ) { + final String quotedPrintablePart = getQuotedPrintablePart(propertyRawValue); + final String propertyEncodedValue = + VCardUtils.parseQuotedPrintable(quotedPrintablePart, + false, sourceCharset, targetCharset); + property.setRawValue(quotedPrintablePart); + property.setValues(propertyEncodedValue); + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onPropertyCreated(property); + } + } else if (mCurrentEncoding.equals(VCardConstants.PARAM_ENCODING_BASE64) + || mCurrentEncoding.equals(VCardConstants.PARAM_ENCODING_B)) { + // It is very rare, but some BASE64 data may be so big that + // OutOfMemoryError occurs. To ignore such cases, use try-catch. + try { + final String base64Property = getBase64(propertyRawValue); + try { + property.setByteValue(Base64.decode(base64Property, Base64.DEFAULT)); + } catch (IllegalArgumentException e) { + throw new VCardException("Decode error on base64 photo: " + propertyRawValue); + } + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onPropertyCreated(property); + } + } catch (OutOfMemoryError error) { + Log.e(LOG_TAG, "OutOfMemoryError happened during parsing BASE64 data!"); + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onPropertyCreated(property); + } + } + } else { + if (!(mCurrentEncoding.equals("7BIT") || mCurrentEncoding.equals("8BIT") || + mCurrentEncoding.startsWith("X-"))) { + Log.w(LOG_TAG, + String.format("The encoding \"%s\" is unsupported by vCard %s", + mCurrentEncoding, getVersionString())); + } + + // Some device uses line folding defined in RFC 2425, which is not allowed + // in vCard 2.1 (while needed in vCard 3.0). + // + // e.g. + // BEGIN:VCARD + // VERSION:2.1 + // N:;Omega;;; + // EMAIL;INTERNET:"Omega" + // + // FN:Omega + // END:VCARD + // + // The vCard above assumes that email address should become: + // "Omega" + // + // But vCard 2.1 requires Quote-Printable when a line contains line break(s). + // + // For more information about line folding, + // see "5.8.1. Line delimiting and folding" in RFC 2425. + // + // We take care of this case more formally in vCard 3.0, so we only need to + // do this in vCard 2.1. + if (getVersion() == VCardConfig.VERSION_21) { + StringBuilder builder = null; + while (true) { + final String nextLine = peekLine(); + // We don't need to care too much about this exceptional case, + // but we should not wrongly eat up "END:VCARD", since it critically + // breaks this parser's state machine. + // Thus we roughly look over the next line and confirm it is at least not + // "END:VCARD". This extra fee is worth paying. This is exceptional + // anyway. + if (!TextUtils.isEmpty(nextLine) && + nextLine.charAt(0) == ' ' && + !"END:VCARD".contains(nextLine.toUpperCase())) { + getLine(); // Drop the next line. + + if (builder == null) { + builder = new StringBuilder(); + builder.append(propertyRawValue); + } + builder.append(nextLine.substring(1)); + } else { + break; + } + } + if (builder != null) { + propertyRawValue = builder.toString(); + } + } + + ArrayList propertyValueList = new ArrayList(); + String value = maybeUnescapeText(VCardUtils.convertStringCharset( + propertyRawValue, sourceCharset, targetCharset)); + propertyValueList.add(value); + property.setValues(propertyValueList); + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onPropertyCreated(property); + } + } + } + + private void handleAdrOrgN(VCardProperty property, String propertyRawValue, + String sourceCharset, String targetCharset) throws VCardException, IOException { + List encodedValueList = new ArrayList(); + + // vCard 2.1 does not allow QUOTED-PRINTABLE here, but some softwares/devices emit + // such data. + if (mCurrentEncoding.equals(VCardConstants.PARAM_ENCODING_QP)) { + // First we retrieve Quoted-Printable String from vCard entry, which may include + // multiple lines. + final String quotedPrintablePart = getQuotedPrintablePart(propertyRawValue); + + // "Raw value" from the view of users should contain all part of QP string. + // TODO: add test for this handling + property.setRawValue(quotedPrintablePart); + + // We split Quoted-Printable String using semi-colon before decoding it, as + // the Quoted-Printable may have semi-colon, which confuses splitter. + final List quotedPrintableValueList = + VCardUtils.constructListFromValue(quotedPrintablePart, getVersion()); + for (String quotedPrintableValue : quotedPrintableValueList) { + String encoded = VCardUtils.parseQuotedPrintable(quotedPrintableValue, + false, sourceCharset, targetCharset); + encodedValueList.add(encoded); + } + } else { + final String propertyValue = VCardUtils.convertStringCharset( + getPotentialMultiline(propertyRawValue), sourceCharset, targetCharset); + final List valueList = + VCardUtils.constructListFromValue(propertyValue, getVersion()); + for (String value : valueList) { + encodedValueList.add(value); + } + } + + property.setValues(encodedValueList); + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onPropertyCreated(property); + } + } + + /** + *

+ * Parses and returns Quoted-Printable. + *

+ * + * @param firstString The string following a parameter name and attributes. + * Example: "string" in + * "ADR:ENCODING=QUOTED-PRINTABLE:string\n\r". + * @return whole Quoted-Printable string, including a given argument and + * following lines. Excludes the last empty line following to Quoted + * Printable lines. + * @throws IOException + * @throws VCardException + */ + private String getQuotedPrintablePart(String firstString) + throws IOException, VCardException { + // Specifically, there may be some padding between = and CRLF. + // See the following: + // + // qp-line := *(qp-segment transport-padding CRLF) + // qp-part transport-padding + // qp-segment := qp-section *(SPACE / TAB) "=" + // ; Maximum length of 76 characters + // + // e.g. (from RFC 2045) + // Now's the time = + // for all folk to come= + // to the aid of their country. + if (firstString.trim().endsWith("=")) { + // remove "transport-padding" + int pos = firstString.length() - 1; + while (firstString.charAt(pos) != '=') { + } + StringBuilder builder = new StringBuilder(); + builder.append(firstString.substring(0, pos + 1)); + builder.append("\r\n"); + String line; + while (true) { + line = getLine(); + if (line == null) { + throw new VCardException("File ended during parsing a Quoted-Printable String"); + } + if (line.trim().endsWith("=")) { + // remove "transport-padding" + pos = line.length() - 1; + while (line.charAt(pos) != '=') { + } + builder.append(line.substring(0, pos + 1)); + builder.append("\r\n"); + } else { + builder.append(line); + break; + } + } + return builder.toString(); + } else { + return firstString; + } + } + + /** + * Given the first line of a property, checks consecutive lines after it and builds a new + * multi-line value if it exists. + * + * @param firstString The first line of the property. + * @return A new property, potentially built from multiple lines. + * @throws IOException + */ + private String getPotentialMultiline(String firstString) throws IOException { + final StringBuilder builder = new StringBuilder(); + builder.append(firstString); + + while (true) { + final String line = peekLine(); + if (line == null || line.length() == 0) { + break; + } + + final String propertyName = getPropertyNameUpperCase(line); + if (propertyName != null) { + break; + } + + // vCard 2.1 does not allow multi-line of adr but microsoft vcards may have it. + // We will consider the next line to be a part of a multi-line value if it does not + // contain a property name (i.e. a colon or semi-colon). + // Consume the line. + getLine(); + builder.append(" ").append(line); + } + + return builder.toString(); + } + + protected String getBase64(String firstString) throws IOException, VCardException { + final StringBuilder builder = new StringBuilder(); + builder.append(firstString); + + while (true) { + final String line = peekLine(); + if (line == null) { + throw new VCardException("File ended during parsing BASE64 binary"); + } + + // vCard 2.1 requires two spaces at the end of BASE64 strings, but some vCard doesn't + // have them. We try to detect those cases using colon and semi-colon, given BASE64 + // does not contain it. + // E.g. + // TEL;TYPE=WORK:+5555555 + // or + // END:VCARD + String propertyName = getPropertyNameUpperCase(line); + if (getKnownPropertyNameSet().contains(propertyName) || + VCardConstants.PROPERTY_X_ANDROID_CUSTOM.equals(propertyName)) { + Log.w(LOG_TAG, "Found a next property during parsing a BASE64 string, " + + "which must not contain semi-colon or colon. Treat the line as next " + + "property."); + Log.w(LOG_TAG, "Problematic line: " + line.trim()); + break; + } + + // Consume the line. + getLine(); + + if (line.length() == 0) { + break; + } + // Trim off any extraneous whitespace to handle 2.1 implementations + // that use 3.0 style line continuations. This is safe because space + // isn't a Base64 encoding value. + builder.append(line.trim()); + } + + return builder.toString(); + } + + /** + * Extracts the property name portion of a given vCard line. + *

+ * Properties must contain a colon. + *

+ * E.g. + * TEL;TYPE=WORK:+5555555 // returns "TEL" + * END:VCARD // returns "END" + * TEL; // returns null + * + * @param line The vCard line. + * @return The property name portion. {@literal null} if no property name found. + */ + private String getPropertyNameUpperCase(String line) { + final int colonIndex = line.indexOf(":"); + if (colonIndex > -1) { + final int semiColonIndex = line.indexOf(";"); + + // Find the minimum index that is greater than -1. + final int minIndex; + if (colonIndex == -1) { + minIndex = semiColonIndex; + } else if (semiColonIndex == -1) { + minIndex = colonIndex; + } else { + minIndex = Math.min(colonIndex, semiColonIndex); + } + return line.substring(0, minIndex).toUpperCase(); + } + return null; + } + + /* + * vCard 2.1 specifies AGENT allows one vcard entry. Currently we emit an + * error toward the AGENT property. + * // TODO: Support AGENT property. + * item = + * ... / [groups "."] "AGENT" [params] ":" vcard CRLF vcard = "BEGIN" [ws] + * ":" [ws] "VCARD" [ws] 1*CRLF items *CRLF "END" [ws] ":" [ws] "VCARD" + */ + protected void handleAgent(final VCardProperty property) throws VCardException { + if (!property.getRawValue().toUpperCase().contains("BEGIN:VCARD")) { + // Apparently invalid line seen in Windows Mobile 6.5. Ignore them. + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onPropertyCreated(property); + } + return; + } else { + throw new VCardAgentNotSupportedException("AGENT Property is not supported now."); + } + } + + /** + * For vCard 3.0. + */ + protected String maybeUnescapeText(final String text) { + return text; + } + + /** + * Returns unescaped String if the character should be unescaped. Return + * null otherwise. e.g. In vCard 2.1, "\;" should be unescaped into ";" + * while "\x" should not be. + */ + protected String maybeUnescapeCharacter(final char ch) { + return unescapeCharacter(ch); + } + + /* package */ static String unescapeCharacter(final char ch) { + // Original vCard 2.1 specification does not allow transformation + // "\:" -> ":", "\," -> ",", and "\\" -> "\", but previous + // implementation of + // this class allowed them, so keep it as is. + if (ch == '\\' || ch == ';' || ch == ':' || ch == ',') { + return String.valueOf(ch); + } else { + return null; + } + } + + /** + * @return {@link VCardConfig#VERSION_21} + */ + protected int getVersion() { + return VCardConfig.VERSION_21; + } + + /** + * @return {@link VCardConfig#VERSION_30} + */ + protected String getVersionString() { + return VCardConstants.VERSION_V21; + } + + protected Set getKnownPropertyNameSet() { + return VCardParser_V21.sKnownPropertyNameSet; + } + + protected Set getKnownTypeSet() { + return VCardParser_V21.sKnownTypeSet; + } + + protected Set getKnownValueSet() { + return VCardParser_V21.sKnownValueSet; + } + + protected Set getAvailableEncodingSet() { + return VCardParser_V21.sAvailableEncoding; + } + + protected String getDefaultEncoding() { + return DEFAULT_ENCODING; + } + + protected String getDefaultCharset() { + return DEFAULT_CHARSET; + } + + protected String getCurrentCharset() { + return mCurrentCharset; + } + + public void addInterpreter(VCardInterpreter interpreter) { + mInterpreterList.add(interpreter); + } + + public void parse(InputStream is) throws IOException, VCardException { + if (is == null) { + throw new NullPointerException("InputStream must not be null."); + } + + final InputStreamReader tmpReader = new InputStreamReader(is, mIntermediateCharset); + mReader = new CustomBufferedReader(tmpReader); + + final long start = System.currentTimeMillis(); + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onVCardStarted(); + } + + // vcard_file = [wsls] vcard [wsls] + while (true) { + synchronized (this) { + if (mCanceled) { + Log.i(LOG_TAG, "Cancel request has come. exitting parse operation."); + break; + } + } + if (!parseOneVCard()) { + break; + } + } + + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onVCardEnded(); + } + } + + public void parseOne(InputStream is) throws IOException, VCardException { + if (is == null) { + throw new NullPointerException("InputStream must not be null."); + } + + final InputStreamReader tmpReader = new InputStreamReader(is, mIntermediateCharset); + mReader = new CustomBufferedReader(tmpReader); + + final long start = System.currentTimeMillis(); + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onVCardStarted(); + } + parseOneVCard(); + for (VCardInterpreter interpreter : mInterpreterList) { + interpreter.onVCardEnded(); + } + } + + public final synchronized void cancel() { + Log.i(LOG_TAG, "ParserImpl received cancel operation."); + mCanceled = true; + } +} diff --git a/modules/vcard/java/com/android/vcard/VCardParserImpl_V30.java b/modules/vcard/java/com/android/vcard/VCardParserImpl_V30.java new file mode 100644 index 0000000..550cc1e --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardParserImpl_V30.java @@ -0,0 +1,363 @@ +/* + * 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.vcard; + +import android.util.Log; + +import com.android.vcard.exception.VCardException; + +import java.io.IOException; +import java.util.Set; + +/** + *

+ * Basic implementation achieving vCard 3.0 parsing. + *

+ *

+ * This class inherits vCard 2.1 implementation since technically they are similar, + * while specifically there's logical no relevance between them. + * So that developers are not confused with the inheritance, + * {@link VCardParser_V30} does not inherit {@link VCardParser_V21}, while + * {@link VCardParserImpl_V30} inherits {@link VCardParserImpl_V21}. + *

+ * @hide + */ +/* package */ class VCardParserImpl_V30 extends VCardParserImpl_V21 { + private static final String LOG_TAG = VCardConstants.LOG_TAG; + + private String mPreviousLine; + private boolean mEmittedAgentWarning = false; + + public VCardParserImpl_V30() { + super(); + } + + public VCardParserImpl_V30(int vcardType) { + super(vcardType); + } + + @Override + protected int getVersion() { + return VCardConfig.VERSION_30; + } + + @Override + protected String getVersionString() { + return VCardConstants.VERSION_V30; + } + + @Override + protected String peekLine() throws IOException { + if (mPreviousLine != null) { + String ret = mPreviousLine; + return ret; + } else { + return mReader.peekLine(); + } + } + + @Override + protected String getLine() throws IOException { + if (mPreviousLine != null) { + String ret = mPreviousLine; + mPreviousLine = null; + return ret; + } else { + return mReader.readLine(); + } + } + + /** + * vCard 3.0 requires that the line with space at the beginning of the line + * must be combined with previous line. + */ + @Override + protected String getNonEmptyLine() throws IOException, VCardException { + String line; + StringBuilder builder = null; + while ((line = mReader.readLine()) != null) { + // Skip empty lines in order to accomodate implementations that + // send line termination variations such as \r\r\n. + if (line.length() == 0) { + continue; + } else if (line.charAt(0) == ' ' || line.charAt(0) == '\t') { + // RFC 2425 describes line continuation as \r\n followed by + // a single ' ' or '\t' whitespace character. + if (builder == null) { + builder = new StringBuilder(); + } + if (mPreviousLine != null) { + builder.append(mPreviousLine); + mPreviousLine = null; + } + builder.append(line.substring(1)); + } else { + if (builder != null || mPreviousLine != null) { + break; + } + mPreviousLine = line; + } + } + + String ret = null; + if (builder != null) { + ret = builder.toString(); + } else if (mPreviousLine != null) { + ret = mPreviousLine; + } + mPreviousLine = line; + if (ret == null) { + throw new VCardException("Reached end of buffer."); + } + return ret; + } + + /* + * vcard = [group "."] "BEGIN" ":" "VCARD" 1 * CRLF + * 1 * (contentline) + * ;A vCard object MUST include the VERSION, FN and N types. + * [group "."] "END" ":" "VCARD" 1 * CRLF + */ + @Override + protected boolean readBeginVCard(boolean allowGarbage) throws IOException, VCardException { + // TODO: vCard 3.0 supports group. + return super.readBeginVCard(allowGarbage); + } + + /** + * vCard 3.0 allows iana-token as paramType, while vCard 2.1 does not. + */ + @Override + protected void handleParams(VCardProperty propertyData, final String params) + throws VCardException { + try { + super.handleParams(propertyData, params); + } catch (VCardException e) { + // maybe IANA type + String[] strArray = params.split("=", 2); + if (strArray.length == 2) { + handleAnyParam(propertyData, strArray[0], strArray[1]); + } else { + // Must not come here in the current implementation. + throw new VCardException( + "Unknown params value: " + params); + } + } + } + + @Override + protected void handleAnyParam( + VCardProperty propertyData, final String paramName, final String paramValue) { + splitAndPutParam(propertyData, paramName, paramValue); + } + + @Override + protected void handleParamWithoutName(VCardProperty property, final String paramValue) { + handleType(property, paramValue); + } + + /* + * vCard 3.0 defines + * + * param = param-name "=" param-value *("," param-value) + * param-name = iana-token / x-name + * param-value = ptext / quoted-string + * quoted-string = DQUOTE QSAFE-CHAR DQUOTE + * QSAFE-CHAR = WSP / %x21 / %x23-7E / NON-ASCII + * ; Any character except CTLs, DQUOTE + * + * QSAFE-CHAR must not contain DQUOTE, including escaped one (\"). + */ + @Override + protected void handleType(VCardProperty property, final String paramValue) { + splitAndPutParam(property, VCardConstants.PARAM_TYPE, paramValue); + } + + /** + * Splits parameter values into pieces in accordance with vCard 3.0 specification and + * puts pieces into mInterpreter. + */ + /* + * param-value = ptext / quoted-string + * quoted-string = DQUOTE QSAFE-CHAR DQUOTE + * QSAFE-CHAR = WSP / %x21 / %x23-7E / NON-ASCII + * ; Any character except CTLs, DQUOTE + * + * QSAFE-CHAR must not contain DQUOTE, including escaped one (\") + */ + private void splitAndPutParam(VCardProperty property, String paramName, String paramValue) { + // "comma,separated:inside.dquote",pref + // --> + // - comma,separated:inside.dquote + // - pref + // + // Note: Though there's a code, we don't need to take much care of + // wrongly-added quotes like the example above, as they induce + // parse errors at the top level (when splitting a line into parts). + StringBuilder builder = null; // Delay initialization. + boolean insideDquote = false; + final int length = paramValue.length(); + for (int i = 0; i < length; i++) { + final char ch = paramValue.charAt(i); + if (ch == '"') { + if (insideDquote) { + // End of Dquote. + property.addParameter(paramName, encodeParamValue(builder.toString())); + builder = null; + insideDquote = false; + } else { + if (builder != null) { + if (builder.length() > 0) { + // e.g. + // pref"quoted" + Log.w(LOG_TAG, "Unexpected Dquote inside property."); + } else { + // e.g. + // pref,"quoted" + // "quoted",pref + property.addParameter(paramName, encodeParamValue(builder.toString())); + } + } + insideDquote = true; + } + } else if (ch == ',' && !insideDquote) { + if (builder == null) { + Log.w(LOG_TAG, "Comma is used before actual string comes. (" + + paramValue + ")"); + } else { + property.addParameter(paramName, encodeParamValue(builder.toString())); + builder = null; + } + } else { + // To stop creating empty StringBuffer at the end of parameter, + // we delay creating this object until this point. + if (builder == null) { + builder = new StringBuilder(); + } + builder.append(ch); + } + } + if (insideDquote) { + // e.g. + // "non-quote-at-end + Log.d(LOG_TAG, "Dangling Dquote."); + } + if (builder != null) { + if (builder.length() == 0) { + Log.w(LOG_TAG, "Unintended behavior. We must not see empty StringBuilder " + + "at the end of parameter value parsing."); + } else { + property.addParameter(paramName, encodeParamValue(builder.toString())); + } + } + } + + /** + * Encode a param value using UTF-8. + */ + protected String encodeParamValue(String paramValue) { + return VCardUtils.convertStringCharset( + paramValue, VCardConfig.DEFAULT_INTERMEDIATE_CHARSET, "UTF-8"); + } + + @Override + protected void handleAgent(VCardProperty property) { + // The way how vCard 3.0 supports "AGENT" is completely different from vCard 2.1. + // + // e.g. + // AGENT:BEGIN:VCARD\nFN:Joe Friday\nTEL:+1-919-555-7878\n + // TITLE:Area Administrator\, Assistant\n EMAIL\;TYPE=INTERN\n + // ET:jfriday@host.com\nEND:VCARD\n + // + // TODO: fix this. + // + // issue: + // vCard 3.0 also allows this as an example. + // + // AGENT;VALUE=uri: + // CID:JQPUBLIC.part3.960129T083020.xyzMail@host3.com + // + // This is not vCard. Should we support this? + // + // Just ignore the line for now, since we cannot know how to handle it... + if (!mEmittedAgentWarning) { + Log.w(LOG_TAG, "AGENT in vCard 3.0 is not supported yet. Ignore it"); + mEmittedAgentWarning = true; + } + } + + /** + * This is only called from handlePropertyValue(), which has already + * read the first line of this property. With v3.0, the getNonEmptyLine() + * routine has already concatenated all following continuation lines. + * The routine is implemented in the V21 parser to concatenate v2.1 style + * data blocks, but is unnecessary here. + */ + @Override + protected String getBase64(final String firstString) + throws IOException, VCardException { + return firstString; + } + + /** + * ESCAPED-CHAR = "\\" / "\;" / "\," / "\n" / "\N") + * ; \\ encodes \, \n or \N encodes newline + * ; \; encodes ;, \, encodes , + * + * Note: Apple escapes ':' into '\:' while does not escape '\' + */ + @Override + protected String maybeUnescapeText(final String text) { + return unescapeText(text); + } + + public static String unescapeText(final String text) { + StringBuilder builder = new StringBuilder(); + final int length = text.length(); + for (int i = 0; i < length; i++) { + char ch = text.charAt(i); + if (ch == '\\' && i < length - 1) { + final char next_ch = text.charAt(++i); + if (next_ch == 'n' || next_ch == 'N') { + builder.append("\n"); + } else { + builder.append(next_ch); + } + } else { + builder.append(ch); + } + } + return builder.toString(); + } + + @Override + protected String maybeUnescapeCharacter(final char ch) { + return unescapeCharacter(ch); + } + + public static String unescapeCharacter(final char ch) { + if (ch == 'n' || ch == 'N') { + return "\n"; + } else { + return String.valueOf(ch); + } + } + + @Override + protected Set getKnownPropertyNameSet() { + return VCardParser_V30.sKnownPropertyNameSet; + } +} diff --git a/modules/vcard/java/com/android/vcard/VCardParserImpl_V40.java b/modules/vcard/java/com/android/vcard/VCardParserImpl_V40.java new file mode 100644 index 0000000..34478d2 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardParserImpl_V40.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.vcard; + +import java.util.Set; + + +/** + *

+ * Basic implementation parsing vCard 4.0. + *

+ *

+ * vCard 4.0 is not published yet. Also this implementation is premature. + *

+ * @hide + */ +/* package */ class VCardParserImpl_V40 extends VCardParserImpl_V30 { + // private static final String LOG_TAG = VCardConstants.LOG_TAG; + + public VCardParserImpl_V40() { + super(); + } + + public VCardParserImpl_V40(final int vcardType) { + super(vcardType); + } + + @Override + protected int getVersion() { + return VCardConfig.VERSION_40; + } + + @Override + protected String getVersionString() { + return VCardConstants.VERSION_V40; + } + + /** + * We escape "\N" into new line for safety. + */ + @Override + protected String maybeUnescapeText(final String text) { + return unescapeText(text); + } + + public static String unescapeText(final String text) { + // TODO: more strictly, vCard 4.0 requires different type of unescaping rule + // toward each property. + final StringBuilder builder = new StringBuilder(); + final int length = text.length(); + for (int i = 0; i < length; i++) { + char ch = text.charAt(i); + if (ch == '\\' && i < length - 1) { + final char next_ch = text.charAt(++i); + if (next_ch == 'n' || next_ch == 'N') { + builder.append("\n"); + } else { + builder.append(next_ch); + } + } else { + builder.append(ch); + } + } + return builder.toString(); + } + + public static String unescapeCharacter(final char ch) { + if (ch == 'n' || ch == 'N') { + return "\n"; + } else { + return String.valueOf(ch); + } + } + + @Override + protected Set getKnownPropertyNameSet() { + return VCardParser_V40.sKnownPropertyNameSet; + } +} \ No newline at end of file diff --git a/modules/vcard/java/com/android/vcard/VCardParser_V21.java b/modules/vcard/java/com/android/vcard/VCardParser_V21.java new file mode 100644 index 0000000..d0887af --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardParser_V21.java @@ -0,0 +1,120 @@ +/* + * 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.vcard; + +import com.android.vcard.exception.VCardException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + *

+ * vCard parser for vCard 2.1. See the specification for more detail about the spec itself. + *

+ *

+ * The spec is written in 1996, and currently various types of "vCard 2.1" exist. + * To handle real the world vCard formats appropriately and effectively, this class does not + * obey with strict vCard 2.1. + * In stead, not only vCard spec but also real world vCard is considered. + *

+ * e.g. A lot of devices and softwares let vCard importer/exporter to use + * the PNG format to determine the type of image, while it is not allowed in + * the original specification. As of 2010, we can see even the FLV format + * (possible in Japanese mobile phones). + *

+ */ +public final class VCardParser_V21 extends VCardParser { + /** + * A unmodifiable Set storing the property names available in the vCard 2.1 specification. + */ + /* package */ static final Set sKnownPropertyNameSet = + Collections.unmodifiableSet(new HashSet( + Arrays.asList("BEGIN", "END", "LOGO", "PHOTO", "LABEL", "FN", "TITLE", "SOUND", + "VERSION", "TEL", "EMAIL", "TZ", "GEO", "NOTE", "URL", + "BDAY", "ROLE", "REV", "UID", "KEY", "MAILER"))); + + /** + * A unmodifiable Set storing the types known in vCard 2.1. + */ + /* package */ static final Set sKnownTypeSet = + Collections.unmodifiableSet(new HashSet( + Arrays.asList("DOM", "INTL", "POSTAL", "PARCEL", "HOME", "WORK", + "PREF", "VOICE", "FAX", "MSG", "CELL", "PAGER", "BBS", + "MODEM", "CAR", "ISDN", "VIDEO", "AOL", "APPLELINK", + "ATTMAIL", "CIS", "EWORLD", "INTERNET", "IBMMAIL", + "MCIMAIL", "POWERSHARE", "PRODIGY", "TLX", "X400", "GIF", + "CGM", "WMF", "BMP", "MET", "PMB", "DIB", "PICT", "TIFF", + "PDF", "PS", "JPEG", "QTIME", "MPEG", "MPEG2", "AVI", + "WAVE", "AIFF", "PCM", "X509", "PGP"))); + + /** + * A unmodifiable Set storing the values for the type "VALUE", available in the vCard 2.1. + */ + /* package */ static final Set sKnownValueSet = + Collections.unmodifiableSet(new HashSet( + Arrays.asList("INLINE", "URL", "CONTENT-ID", "CID"))); + + /** + *

+ * A unmodifiable Set storing the values for the type "ENCODING", available in the vCard 2.1. + *

+ *

+ * Though vCard 2.1 specification does not allow "B" encoding, some data may have it. + * We allow it for safety. + *

+ */ + /* package */ static final Set sAvailableEncoding = + Collections.unmodifiableSet(new HashSet( + Arrays.asList(VCardConstants.PARAM_ENCODING_7BIT, + VCardConstants.PARAM_ENCODING_8BIT, + VCardConstants.PARAM_ENCODING_QP, + VCardConstants.PARAM_ENCODING_BASE64, + VCardConstants.PARAM_ENCODING_B))); + + private final VCardParserImpl_V21 mVCardParserImpl; + + public VCardParser_V21() { + mVCardParserImpl = new VCardParserImpl_V21(); + } + + public VCardParser_V21(int vcardType) { + mVCardParserImpl = new VCardParserImpl_V21(vcardType); + } + + @Override + public void addInterpreter(VCardInterpreter interpreter) { + mVCardParserImpl.addInterpreter(interpreter); + } + + @Override + public void parse(InputStream is) throws IOException, VCardException { + mVCardParserImpl.parse(is); + } + + @Override + public void parseOne(InputStream is) throws IOException, VCardException { + mVCardParserImpl.parseOne(is); + } + + @Override + public void cancel() { + mVCardParserImpl.cancel(); + } +} diff --git a/modules/vcard/java/com/android/vcard/VCardParser_V30.java b/modules/vcard/java/com/android/vcard/VCardParser_V30.java new file mode 100644 index 0000000..c7ae8c8 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardParser_V30.java @@ -0,0 +1,99 @@ +/* + * 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.vcard; + +import com.android.vcard.exception.VCardException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + *

+ * vCard parser for vCard 3.0. See RFC 2426 for more detail. + *

+ *

+ * This parser allows vCard format which is not allowed in the RFC, since + * we have seen several vCard 3.0 files which don't comply with it. + *

+ *

+ * e.g. vCard 3.0 does not allow "CHARSET" attribute, but some actual files + * have it and they uses non UTF-8 charsets. UTF-8 is recommended in RFC 2426, + * but it is not a must. We silently allow "CHARSET". + *

+ */ +public class VCardParser_V30 extends VCardParser { + /* package */ static final Set sKnownPropertyNameSet = + Collections.unmodifiableSet(new HashSet(Arrays.asList( + "BEGIN", "END", "LOGO", "PHOTO", "LABEL", "FN", "TITLE", "SOUND", + "VERSION", "TEL", "EMAIL", "TZ", "GEO", "NOTE", "URL", + "BDAY", "ROLE", "REV", "UID", "KEY", "MAILER", // 2.1 + "NAME", "PROFILE", "SOURCE", "NICKNAME", "CLASS", + "SORT-STRING", "CATEGORIES", "PRODID", // 3.0 + "IMPP"))); // RFC 4770 + + /** + *

+ * A unmodifiable Set storing the values for the type "ENCODING", available in the vCard 3.0. + *

+ *

+ * Though vCard 2.1 specification does not allow "7BIT" or "BASE64", we allow them for safety. + *

+ *

+ * "QUOTED-PRINTABLE" is not allowed in vCard 3.0 and not in this parser either, + * because the encoding ambiguates how the vCard file to be parsed. + *

+ */ + /* package */ static final Set sAcceptableEncoding = + Collections.unmodifiableSet(new HashSet(Arrays.asList( + VCardConstants.PARAM_ENCODING_7BIT, + VCardConstants.PARAM_ENCODING_8BIT, + VCardConstants.PARAM_ENCODING_BASE64, + VCardConstants.PARAM_ENCODING_B))); + + private final VCardParserImpl_V30 mVCardParserImpl; + + public VCardParser_V30() { + mVCardParserImpl = new VCardParserImpl_V30(); + } + + public VCardParser_V30(int vcardType) { + mVCardParserImpl = new VCardParserImpl_V30(vcardType); + } + + @Override + public void addInterpreter(VCardInterpreter interpreter) { + mVCardParserImpl.addInterpreter(interpreter); + } + + @Override + public void parse(InputStream is) throws IOException, VCardException { + mVCardParserImpl.parse(is); + } + + @Override + public void parseOne(InputStream is) throws IOException, VCardException { + mVCardParserImpl.parseOne(is); + } + + @Override + public void cancel() { + mVCardParserImpl.cancel(); + } +} diff --git a/modules/vcard/java/com/android/vcard/VCardParser_V40.java b/modules/vcard/java/com/android/vcard/VCardParser_V40.java new file mode 100644 index 0000000..de9c2a1 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardParser_V40.java @@ -0,0 +1,87 @@ +/* + * 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.vcard; + +import com.android.vcard.exception.VCardException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + *

+ * vCard parser for vCard 4.0. DO NOT USE IN PRODUCTION. + *

+ *

+ * Currently this parser is based on vCard 4.0 specification rev 15 (partly). + * Note that some of current implementation lack basic capability required in vCard 4.0. + * (e.g. PHOTO has data parameter in rev 15 while this implementation requires "ENCODING=b") + *

+ */ +public class VCardParser_V40 extends VCardParser { + /* package */ static final Set sKnownPropertyNameSet = + Collections.unmodifiableSet(new HashSet(Arrays.asList( + "BEGIN", "END", "VERSION", + "SOURCE", "KIND", "FN", "N", "NICKNAME", + "PHOTO", "BDAY", "ANNIVERSARY", "GENDER", "ADR", "TEL", + "EMAIL", "IMPP", "LANG", "TZ", "GEO", "TITLE", "ROLE", + "LOGO", "ORG", "MEMBER", "RELATED", "CATEGORIES", + "NOTE", "PRODID", "REV", "SOUND", "UID", "CLIENTPIDMAP", + "URL", "KEY", "FBURL", "CALENDRURI", "CALURI", "XML"))); + + /** + *

+ * A unmodifiable Set storing the values for the type "ENCODING", available in vCard 4.0. + *

+ */ + /* package */ static final Set sAcceptableEncoding = + Collections.unmodifiableSet(new HashSet(Arrays.asList( + VCardConstants.PARAM_ENCODING_8BIT, + VCardConstants.PARAM_ENCODING_B))); + + private final VCardParserImpl_V40 mVCardParserImpl; + + public VCardParser_V40() { + mVCardParserImpl = new VCardParserImpl_V40(); + } + + public VCardParser_V40(int vcardType) { + mVCardParserImpl = new VCardParserImpl_V40(vcardType); + } + + @Override + public void addInterpreter(VCardInterpreter interpreter) { + mVCardParserImpl.addInterpreter(interpreter); + } + + @Override + public void parse(InputStream is) throws IOException, VCardException { + mVCardParserImpl.parse(is); + } + + @Override + public void parseOne(InputStream is) throws IOException, VCardException { + mVCardParserImpl.parseOne(is); + } + + @Override + public void cancel() { + mVCardParserImpl.cancel(); + } +} \ No newline at end of file diff --git a/modules/vcard/java/com/android/vcard/VCardPhoneNumberTranslationCallback.java b/modules/vcard/java/com/android/vcard/VCardPhoneNumberTranslationCallback.java new file mode 100644 index 0000000..b02f8d1 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardPhoneNumberTranslationCallback.java @@ -0,0 +1,35 @@ +/* + * 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.vcard; + +/** + * Callback functionality which can be used with + * {@link VCardComposer#setPhoneNumberTranslationCallback(VCardPhoneNumberTranslationCallback)}. + * See the doc for the method. + * + *

+ * TODO: this should be more generic + *

+ * + * @hide This will change + */ +public interface VCardPhoneNumberTranslationCallback { + /** + * Called when a phone number is being handled. + * @return formatted phone number. + */ + public String onValueReceived(String rawValue, int type, String label, boolean isPrimary); +} diff --git a/modules/vcard/java/com/android/vcard/VCardProperty.java b/modules/vcard/java/com/android/vcard/VCardProperty.java new file mode 100644 index 0000000..2e1289d --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardProperty.java @@ -0,0 +1,171 @@ +/* + * 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.vcard; + +import android.util.Log; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +/** + * Represents vCard's property, or logical "one line" of each vCard entry. + * + * e.g. + * Given a vCard below, objects for N:name, TEL:1111111111 are + * "property". + * + * + * BEGIN:VCARD + * N:name + * TEL:1111111111 + * END:VCARD + * + * + * vCard's property has three elements: name, parameter (or param), and value. Name is the name + * of each property. Parameter or param is additional information for the property. Value is one + * or multiple values representing the parameter. + * + * e.g. + * N;CHARSET=UTF-8:Joe;Due;M.;Mr.;Ph.D., has "N" for name, "CHALSET=UTF-8" for param, + * and "Joe;Due;M.;Mr.;Ph.D." for value. + * + * Usually params are represented as "key=value" pair, but not always + * (The property TEL;WORK;VOICE:(111) 555-1212 has two params without key "TYPE", + * which are same as "TYPE=WORK" and "TYPE=VOICE". In vCard 3.0, we can even express them as + * "TYPE=WORK,VOICE"). + * + * Sometimes (not always) value can be separated by semi-colon. In the example above "Joe;Due;;;" + * should be interpreted as five strings: "Joe" (for family name), "Due" (for given name), "M." + * (for middle name), "Mr." (for prefix), and "Ph.D." (for suffix). Whether the value is + * separable or not is specified by vCard specs. + */ +public class VCardProperty { + private static final String LOG_TAG = VCardConstants.LOG_TAG; + private String mName; + private List mGroupList; + + private Map> mParameterMap = + new HashMap>(); + private String mRawValue; + + private List mValueList; + private byte[] mByteValue; + + public void setName(String name) { + if (mName != null) { + Log.w(LOG_TAG, String.format("Property name is re-defined " + + "(existing: %s, requested: %s", mName, name)); + } + mName = name; + } + + public void addGroup(String group) { + if (mGroupList == null) { + mGroupList = new ArrayList(); + } + mGroupList.add(group); + } + + public void setParameter(final String paramName, final String paramValue) { + mParameterMap.clear(); + addParameter(paramName, paramValue); + } + + public void addParameter(final String paramName, final String paramValue) { + Collection values; + if (!mParameterMap.containsKey(paramName)) { + if (paramName.equals("TYPE")) { + values = new HashSet(); + } else { + values = new ArrayList(); + } + mParameterMap.put(paramName, values); + } else { + values = mParameterMap.get(paramName); + } + values.add(paramValue); + } + + public void setRawValue(String rawValue) { + mRawValue = rawValue; + } + + // TODO: would be much better to have translateRawValue() functionality instead of forcing + // VCardParserImpl does this job. + + public void setValues(String... propertyValues) { + mValueList = Arrays.asList(propertyValues); + } + + public void setValues(List propertyValueList) { + mValueList = propertyValueList; + } + + public void addValues(String... propertyValues) { + if (mValueList == null) { + mValueList = Arrays.asList(propertyValues); + } else { + mValueList.addAll(Arrays.asList(propertyValues)); + } + } + + public void addValues(List propertyValueList) { + if (mValueList == null) { + mValueList = new ArrayList(propertyValueList); + } else { + mValueList.addAll(propertyValueList); + } + } + + public void setByteValue(byte[] byteValue) { + mByteValue = byteValue; + } + + public String getName() { + return mName; + } + + public List getGroupList() { + return mGroupList; + } + + public Map> getParameterMap() { + return mParameterMap; + } + + public Collection getParameters(String type) { + return mParameterMap.get(type); + } + + public String getRawValue() { + return mRawValue; + } + + public List getValueList() { + return mValueList; + } + + public byte[] getByteValue() { + return mByteValue; + } +} + + diff --git a/modules/vcard/java/com/android/vcard/VCardSourceDetector.java b/modules/vcard/java/com/android/vcard/VCardSourceDetector.java new file mode 100644 index 0000000..0a8318b --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardSourceDetector.java @@ -0,0 +1,186 @@ +/* + * 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.vcard; + +import android.text.TextUtils; +import android.util.Log; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + *

+ * The class which tries to detects the source of a vCard file from its contents. + *

+ *

+ * The specification of vCard (including both 2.1 and 3.0) is not so strict as to + * guess its format just by reading beginning few lines (usually we can, but in + * some most pessimistic case, we cannot until at almost the end of the file). + * Also we cannot store all vCard entries in memory, while there's no specification + * how big the vCard entry would become after the parse. + *

+ *

+ * This class is usually used for the "first scan", in which we can understand which vCard + * version is used (and how many entries exist in a file). + *

+ */ +public class VCardSourceDetector implements VCardInterpreter { + private static final String LOG_TAG = VCardConstants.LOG_TAG; + + private static Set APPLE_SIGNS = new HashSet(Arrays.asList( + "X-PHONETIC-FIRST-NAME", "X-PHONETIC-MIDDLE-NAME", "X-PHONETIC-LAST-NAME", + "X-ABADR", "X-ABUID")); + + private static Set JAPANESE_MOBILE_PHONE_SIGNS = new HashSet(Arrays.asList( + "X-GNO", "X-GN", "X-REDUCTION")); + + private static Set WINDOWS_MOBILE_PHONE_SIGNS = new HashSet(Arrays.asList( + "X-MICROSOFT-ASST_TEL", "X-MICROSOFT-ASSISTANT", "X-MICROSOFT-OFFICELOC")); + + // Note: these signes appears before the signs of the other type (e.g. "X-GN"). + // In other words, Japanese FOMA mobile phones are detected as FOMA, not JAPANESE_MOBILE_PHONES. + private static Set FOMA_SIGNS = new HashSet(Arrays.asList( + "X-SD-VERN", "X-SD-FORMAT_VER", "X-SD-CATEGORIES", "X-SD-CLASS", "X-SD-DCREATED", + "X-SD-DESCRIPTION")); + private static String TYPE_FOMA_CHARSET_SIGN = "X-SD-CHAR_CODE"; + + /** + * Represents that no estimation is available. Users of this class is able to this + * constant when you don't want to let a vCard parser rely on estimation for parse type. + */ + public static final int PARSE_TYPE_UNKNOWN = 0; + + // For Apple's software, which does not mean this type is effective for all its products. + // We confirmed they usually use UTF-8, but not sure about vCard type. + private static final int PARSE_TYPE_APPLE = 1; + // For Japanese mobile phones, which are usually using Shift_JIS as a charset. + private static final int PARSE_TYPE_MOBILE_PHONE_JP = 2; + // For some of mobile phones released from DoCoMo. + private static final int PARSE_TYPE_DOCOMO_FOMA = 3; + // For Japanese Windows Mobile phones. It's version is supposed to be 6.5. + private static final int PARSE_TYPE_WINDOWS_MOBILE_V65_JP = 4; + + private int mParseType = PARSE_TYPE_UNKNOWN; + + private int mVersion = -1; // -1 == unknown + + // Some mobile phones (like FOMA) tells us the charset of the data. + private String mSpecifiedCharset; + + @Override + public void onVCardStarted() { + } + + @Override + public void onVCardEnded() { + } + + @Override + public void onEntryStarted() { + } + + @Override + public void onEntryEnded() { + } + + @Override + public void onPropertyCreated(VCardProperty property) { + final String propertyName = property.getName(); + final List valueList = property.getValueList(); + + if (propertyName.equalsIgnoreCase(VCardConstants.PROPERTY_VERSION) + && valueList.size() > 0) { + final String versionString = valueList.get(0); + if (versionString.equals(VCardConstants.VERSION_V21)) { + mVersion = VCardConfig.VERSION_21; + } else if (versionString.equals(VCardConstants.VERSION_V30)) { + mVersion = VCardConfig.VERSION_30; + } else if (versionString.equals(VCardConstants.VERSION_V40)) { + mVersion = VCardConfig.VERSION_40; + } else { + Log.w(LOG_TAG, "Invalid version string: " + versionString); + } + } else if (propertyName.equalsIgnoreCase(TYPE_FOMA_CHARSET_SIGN)) { + mParseType = PARSE_TYPE_DOCOMO_FOMA; + if (valueList.size() > 0) { + mSpecifiedCharset = valueList.get(0); + } + } + if (mParseType != PARSE_TYPE_UNKNOWN) { + return; + } + if (WINDOWS_MOBILE_PHONE_SIGNS.contains(propertyName)) { + mParseType = PARSE_TYPE_WINDOWS_MOBILE_V65_JP; + } else if (FOMA_SIGNS.contains(propertyName)) { + mParseType = PARSE_TYPE_DOCOMO_FOMA; + } else if (JAPANESE_MOBILE_PHONE_SIGNS.contains(propertyName)) { + mParseType = PARSE_TYPE_MOBILE_PHONE_JP; + } else if (APPLE_SIGNS.contains(propertyName)) { + mParseType = PARSE_TYPE_APPLE; + } + } + + /** + * @return The available type can be used with vCard parser. You probably need to + * use {{@link #getEstimatedCharset()} to understand the charset to be used. + */ + public int getEstimatedType() { + switch (mParseType) { + case PARSE_TYPE_DOCOMO_FOMA: + return VCardConfig.VCARD_TYPE_DOCOMO; + case PARSE_TYPE_MOBILE_PHONE_JP: + return VCardConfig.VCARD_TYPE_V21_JAPANESE_MOBILE; + case PARSE_TYPE_APPLE: + case PARSE_TYPE_WINDOWS_MOBILE_V65_JP: + default: { + if (mVersion == VCardConfig.VERSION_21) { + return VCardConfig.VCARD_TYPE_V21_GENERIC; + } else if (mVersion == VCardConfig.VERSION_30) { + return VCardConfig.VCARD_TYPE_V30_GENERIC; + } else if (mVersion == VCardConfig.VERSION_40) { + return VCardConfig.VCARD_TYPE_V40_GENERIC; + } else { + return VCardConfig.VCARD_TYPE_UNKNOWN; + } + } + } + } + + /** + *

+ * Returns charset String guessed from the source's properties. + * This method must be called after parsing target file(s). + *

+ * @return Charset String. Null is returned if guessing the source fails. + */ + public String getEstimatedCharset() { + if (TextUtils.isEmpty(mSpecifiedCharset)) { + return mSpecifiedCharset; + } + switch (mParseType) { + case PARSE_TYPE_WINDOWS_MOBILE_V65_JP: + case PARSE_TYPE_DOCOMO_FOMA: + case PARSE_TYPE_MOBILE_PHONE_JP: + return "SHIFT_JIS"; + case PARSE_TYPE_APPLE: + return "UTF-8"; + default: + return null; + } + } +} diff --git a/modules/vcard/java/com/android/vcard/VCardUtils.java b/modules/vcard/java/com/android/vcard/VCardUtils.java new file mode 100644 index 0000000..aa335d3 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/VCardUtils.java @@ -0,0 +1,855 @@ +/* + * 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.vcard; + +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.telephony.PhoneNumberUtils; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.util.Log; + +import com.android.vcard.exception.VCardException; + +import java.io.ByteArrayOutputStream; +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Utilities for VCard handling codes. + */ +public class VCardUtils { + private static final String LOG_TAG = VCardConstants.LOG_TAG; + + /** + * See org.apache.commons.codec.DecoderException + */ + private static class DecoderException extends Exception { + public DecoderException(String pMessage) { + super(pMessage); + } + } + + /** + * See org.apache.commons.codec.net.QuotedPrintableCodec + */ + private static class QuotedPrintableCodecPort { + private static byte ESCAPE_CHAR = '='; + public static final byte[] decodeQuotedPrintable(byte[] bytes) + throws DecoderException { + if (bytes == null) { + return null; + } + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + for (int i = 0; i < bytes.length; i++) { + int b = bytes[i]; + if (b == ESCAPE_CHAR) { + try { + int u = Character.digit((char) bytes[++i], 16); + int l = Character.digit((char) bytes[++i], 16); + if (u == -1 || l == -1) { + throw new DecoderException("Invalid quoted-printable encoding"); + } + buffer.write((char) ((u << 4) + l)); + } catch (ArrayIndexOutOfBoundsException e) { + throw new DecoderException("Invalid quoted-printable encoding"); + } + } else { + buffer.write(b); + } + } + return buffer.toByteArray(); + } + } + + /** + * Ported methods which are hidden in {@link PhoneNumberUtils}. + */ + public static class PhoneNumberUtilsPort { + public static String formatNumber(String source, int defaultFormattingType) { + final SpannableStringBuilder text = new SpannableStringBuilder(source); + PhoneNumberUtils.formatNumber(text, defaultFormattingType); + return text.toString(); + } + } + + /** + * Ported methods which are hidden in {@link TextUtils}. + */ + public static class TextUtilsPort { + public static boolean isPrintableAscii(final char c) { + final int asciiFirst = 0x20; + final int asciiLast = 0x7E; // included + return (asciiFirst <= c && c <= asciiLast) || c == '\r' || c == '\n'; + } + + public static boolean isPrintableAsciiOnly(final CharSequence str) { + final int len = str.length(); + for (int i = 0; i < len; i++) { + if (!isPrintableAscii(str.charAt(i))) { + return false; + } + } + return true; + } + } + + // Note that not all types are included in this map/set, since, for example, TYPE_HOME_FAX is + // converted to two parameter Strings. These only contain some minor fields valid in both + // vCard and current (as of 2009-08-07) Contacts structure. + private static final Map sKnownPhoneTypesMap_ItoS; + private static final Set sPhoneTypesUnknownToContactsSet; + private static final Map sKnownPhoneTypeMap_StoI; + private static final Map sKnownImPropNameMap_ItoS; + private static final Set sMobilePhoneLabelSet; + + static { + sKnownPhoneTypesMap_ItoS = new HashMap(); + sKnownPhoneTypeMap_StoI = new HashMap(); + + sKnownPhoneTypesMap_ItoS.put(Phone.TYPE_CAR, VCardConstants.PARAM_TYPE_CAR); + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_TYPE_CAR, Phone.TYPE_CAR); + sKnownPhoneTypesMap_ItoS.put(Phone.TYPE_PAGER, VCardConstants.PARAM_TYPE_PAGER); + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_TYPE_PAGER, Phone.TYPE_PAGER); + sKnownPhoneTypesMap_ItoS.put(Phone.TYPE_ISDN, VCardConstants.PARAM_TYPE_ISDN); + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_TYPE_ISDN, Phone.TYPE_ISDN); + + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_TYPE_HOME, Phone.TYPE_HOME); + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_TYPE_WORK, Phone.TYPE_WORK); + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_TYPE_CELL, Phone.TYPE_MOBILE); + + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_PHONE_EXTRA_TYPE_OTHER, Phone.TYPE_OTHER); + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_PHONE_EXTRA_TYPE_CALLBACK, + Phone.TYPE_CALLBACK); + sKnownPhoneTypeMap_StoI.put( + VCardConstants.PARAM_PHONE_EXTRA_TYPE_COMPANY_MAIN, Phone.TYPE_COMPANY_MAIN); + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_PHONE_EXTRA_TYPE_RADIO, Phone.TYPE_RADIO); + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_PHONE_EXTRA_TYPE_TTY_TDD, + Phone.TYPE_TTY_TDD); + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_PHONE_EXTRA_TYPE_ASSISTANT, + Phone.TYPE_ASSISTANT); + // OTHER (default in Android) should correspond to VOICE (default in vCard). + sKnownPhoneTypeMap_StoI.put(VCardConstants.PARAM_TYPE_VOICE, Phone.TYPE_OTHER); + + sPhoneTypesUnknownToContactsSet = new HashSet(); + sPhoneTypesUnknownToContactsSet.add(VCardConstants.PARAM_TYPE_MODEM); + sPhoneTypesUnknownToContactsSet.add(VCardConstants.PARAM_TYPE_MSG); + sPhoneTypesUnknownToContactsSet.add(VCardConstants.PARAM_TYPE_BBS); + sPhoneTypesUnknownToContactsSet.add(VCardConstants.PARAM_TYPE_VIDEO); + + sKnownImPropNameMap_ItoS = new HashMap(); + sKnownImPropNameMap_ItoS.put(Im.PROTOCOL_AIM, VCardConstants.PROPERTY_X_AIM); + sKnownImPropNameMap_ItoS.put(Im.PROTOCOL_MSN, VCardConstants.PROPERTY_X_MSN); + sKnownImPropNameMap_ItoS.put(Im.PROTOCOL_YAHOO, VCardConstants.PROPERTY_X_YAHOO); + sKnownImPropNameMap_ItoS.put(Im.PROTOCOL_SKYPE, VCardConstants.PROPERTY_X_SKYPE_USERNAME); + sKnownImPropNameMap_ItoS.put(Im.PROTOCOL_GOOGLE_TALK, + VCardConstants.PROPERTY_X_GOOGLE_TALK); + sKnownImPropNameMap_ItoS.put(Im.PROTOCOL_ICQ, VCardConstants.PROPERTY_X_ICQ); + sKnownImPropNameMap_ItoS.put(Im.PROTOCOL_JABBER, VCardConstants.PROPERTY_X_JABBER); + sKnownImPropNameMap_ItoS.put(Im.PROTOCOL_QQ, VCardConstants.PROPERTY_X_QQ); + sKnownImPropNameMap_ItoS.put(Im.PROTOCOL_NETMEETING, VCardConstants.PROPERTY_X_NETMEETING); + + // \u643A\u5E2F\u96FB\u8A71 = Full-width Hiragana "Keitai-Denwa" (mobile phone) + // \u643A\u5E2F = Full-width Hiragana "Keitai" (mobile phone) + // \u30B1\u30A4\u30BF\u30A4 = Full-width Katakana "Keitai" (mobile phone) + // \uFF79\uFF72\uFF80\uFF72 = Half-width Katakana "Keitai" (mobile phone) + sMobilePhoneLabelSet = new HashSet(Arrays.asList( + "MOBILE", "\u643A\u5E2F\u96FB\u8A71", "\u643A\u5E2F", "\u30B1\u30A4\u30BF\u30A4", + "\uFF79\uFF72\uFF80\uFF72")); + } + + public static String getPhoneTypeString(Integer type) { + return sKnownPhoneTypesMap_ItoS.get(type); + } + + /** + * Returns Interger when the given types can be parsed as known type. Returns String object + * when not, which should be set to label. + */ + public static Object getPhoneTypeFromStrings(Collection types, + String number) { + if (number == null) { + number = ""; + } + int type = -1; + String label = null; + boolean isFax = false; + boolean hasPref = false; + + if (types != null) { + for (final String typeStringOrg : types) { + if (typeStringOrg == null) { + continue; + } + final String typeStringUpperCase = typeStringOrg.toUpperCase(); + if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_PREF)) { + hasPref = true; + } else if (typeStringUpperCase.equals(VCardConstants.PARAM_TYPE_FAX)) { + isFax = true; + } else { + final String labelCandidate; + if (typeStringUpperCase.startsWith("X-") && type < 0) { + labelCandidate = typeStringOrg.substring(2); + } else { + labelCandidate = typeStringOrg; + } + if (labelCandidate.length() == 0) { + continue; + } + // e.g. "home" -> TYPE_HOME + final Integer tmp = sKnownPhoneTypeMap_StoI.get(labelCandidate.toUpperCase()); + if (tmp != null) { + final int typeCandidate = tmp; + // 1. If a type isn't specified yet, we'll choose the new type candidate. + // 2. If the current type is default one (OTHER) or custom one, we'll + // prefer more specific types specified in the vCard. Note that OTHER and + // the other different types may appear simultaneously here, since vCard + // allow to have VOICE and HOME/WORK in one line. + // e.g. "TEL;WORK;VOICE:1" -> WORK + OTHER -> Type should be WORK + // 3. TYPE_PAGER is prefered when the number contains @ surronded by + // a pager number and a domain name. + // e.g. + // o 1111@domain.com + // x @domain.com + // x 1111@ + final int indexOfAt = number.indexOf("@"); + if ((typeCandidate == Phone.TYPE_PAGER + && 0 < indexOfAt && indexOfAt < number.length() - 1) + || type < 0 + || type == Phone.TYPE_CUSTOM + || type == Phone.TYPE_OTHER) { + type = tmp; + } + } else if (type < 0) { + type = Phone.TYPE_CUSTOM; + label = labelCandidate; + } + } + } + } + if (type < 0) { + if (hasPref) { + type = Phone.TYPE_MAIN; + } else { + // default to TYPE_OTHER + type = Phone.TYPE_OTHER; + } + } + if (isFax) { + if (type == Phone.TYPE_HOME) { + type = Phone.TYPE_FAX_HOME; + } else if (type == Phone.TYPE_WORK) { + type = Phone.TYPE_FAX_WORK; + } else if (type == Phone.TYPE_OTHER) { + type = Phone.TYPE_OTHER_FAX; + } + } + if (type == Phone.TYPE_CUSTOM) { + return label; + } else { + return type; + } + } + + @SuppressWarnings("deprecation") + public static boolean isMobilePhoneLabel(final String label) { + // For backward compatibility. + // Detail: Until Donut, there isn't TYPE_MOBILE for email while there is now. + // To support mobile type at that time, this custom label had been used. + return ("_AUTO_CELL".equals(label) || sMobilePhoneLabelSet.contains(label)); + } + + public static boolean isValidInV21ButUnknownToContactsPhoteType(final String label) { + return sPhoneTypesUnknownToContactsSet.contains(label); + } + + public static String getPropertyNameForIm(final int protocol) { + return sKnownImPropNameMap_ItoS.get(protocol); + } + + public static String[] sortNameElements(final int nameOrder, + final String familyName, final String middleName, final String givenName) { + final String[] list = new String[3]; + final int nameOrderType = VCardConfig.getNameOrderType(nameOrder); + switch (nameOrderType) { + case VCardConfig.NAME_ORDER_JAPANESE: { + if (containsOnlyPrintableAscii(familyName) && + containsOnlyPrintableAscii(givenName)) { + list[0] = givenName; + list[1] = middleName; + list[2] = familyName; + } else { + list[0] = familyName; + list[1] = middleName; + list[2] = givenName; + } + break; + } + case VCardConfig.NAME_ORDER_EUROPE: { + list[0] = middleName; + list[1] = givenName; + list[2] = familyName; + break; + } + default: { + list[0] = givenName; + list[1] = middleName; + list[2] = familyName; + break; + } + } + return list; + } + + public static int getPhoneNumberFormat(final int vcardType) { + if (VCardConfig.isJapaneseDevice(vcardType)) { + return PhoneNumberUtils.FORMAT_JAPAN; + } else { + return PhoneNumberUtils.FORMAT_NANP; + } + } + + public static String constructNameFromElements(final int nameOrder, + final String familyName, final String middleName, final String givenName) { + return constructNameFromElements(nameOrder, familyName, middleName, givenName, + null, null); + } + + public static String constructNameFromElements(final int nameOrder, + final String familyName, final String middleName, final String givenName, + final String prefix, final String suffix) { + final StringBuilder builder = new StringBuilder(); + final String[] nameList = sortNameElements(nameOrder, familyName, middleName, givenName); + boolean first = true; + if (!TextUtils.isEmpty(prefix)) { + first = false; + builder.append(prefix); + } + for (final String namePart : nameList) { + if (!TextUtils.isEmpty(namePart)) { + if (first) { + first = false; + } else { + builder.append(' '); + } + builder.append(namePart); + } + } + if (!TextUtils.isEmpty(suffix)) { + if (!first) { + builder.append(' '); + } + builder.append(suffix); + } + return builder.toString(); + } + + /** + * Splits the given value into pieces using the delimiter ';' inside it. + * + * Escaped characters in those values are automatically unescaped into original form. + */ + public static List constructListFromValue(final String value, + final int vcardType) { + final List list = new ArrayList(); + StringBuilder builder = new StringBuilder(); + final int length = value.length(); + for (int i = 0; i < length; i++) { + char ch = value.charAt(i); + if (ch == '\\' && i < length - 1) { + char nextCh = value.charAt(i + 1); + final String unescapedString; + if (VCardConfig.isVersion40(vcardType)) { + unescapedString = VCardParserImpl_V40.unescapeCharacter(nextCh); + } else if (VCardConfig.isVersion30(vcardType)) { + unescapedString = VCardParserImpl_V30.unescapeCharacter(nextCh); + } else { + if (!VCardConfig.isVersion21(vcardType)) { + // Unknown vCard type + Log.w(LOG_TAG, "Unknown vCard type"); + } + unescapedString = VCardParserImpl_V21.unescapeCharacter(nextCh); + } + + if (unescapedString != null) { + builder.append(unescapedString); + i++; + } else { + builder.append(ch); + } + } else if (ch == ';') { + list.add(builder.toString()); + builder = new StringBuilder(); + } else { + builder.append(ch); + } + } + list.add(builder.toString()); + return list; + } + + public static boolean containsOnlyPrintableAscii(final String...values) { + if (values == null) { + return true; + } + return containsOnlyPrintableAscii(Arrays.asList(values)); + } + + public static boolean containsOnlyPrintableAscii(final Collection values) { + if (values == null) { + return true; + } + for (final String value : values) { + if (TextUtils.isEmpty(value)) { + continue; + } + if (!TextUtilsPort.isPrintableAsciiOnly(value)) { + return false; + } + } + return true; + } + + /** + *

+ * This is useful when checking the string should be encoded into quoted-printable + * or not, which is required by vCard 2.1. + *

+ *

+ * See the definition of "7bit" in vCard 2.1 spec for more information. + *

+ */ + public static boolean containsOnlyNonCrLfPrintableAscii(final String...values) { + if (values == null) { + return true; + } + return containsOnlyNonCrLfPrintableAscii(Arrays.asList(values)); + } + + public static boolean containsOnlyNonCrLfPrintableAscii(final Collection values) { + if (values == null) { + return true; + } + final int asciiFirst = 0x20; + final int asciiLast = 0x7E; // included + for (final String value : values) { + if (TextUtils.isEmpty(value)) { + continue; + } + final int length = value.length(); + for (int i = 0; i < length; i = value.offsetByCodePoints(i, 1)) { + final int c = value.codePointAt(i); + if (!(asciiFirst <= c && c <= asciiLast)) { + return false; + } + } + } + return true; + } + + private static final Set sUnAcceptableAsciiInV21WordSet = + new HashSet(Arrays.asList('[', ']', '=', ':', '.', ',', ' ')); + + /** + *

+ * This is useful since vCard 3.0 often requires the ("X-") properties and groups + * should contain only alphabets, digits, and hyphen. + *

+ *

+ * Note: It is already known some devices (wrongly) outputs properties with characters + * which should not be in the field. One example is "X-GOOGLE TALK". We accept + * such kind of input but must never output it unless the target is very specific + * to the device which is able to parse the malformed input. + *

+ */ + public static boolean containsOnlyAlphaDigitHyphen(final String...values) { + if (values == null) { + return true; + } + return containsOnlyAlphaDigitHyphen(Arrays.asList(values)); + } + + public static boolean containsOnlyAlphaDigitHyphen(final Collection values) { + if (values == null) { + return true; + } + final int upperAlphabetFirst = 0x41; // A + final int upperAlphabetAfterLast = 0x5b; // [ + final int lowerAlphabetFirst = 0x61; // a + final int lowerAlphabetAfterLast = 0x7b; // { + final int digitFirst = 0x30; // 0 + final int digitAfterLast = 0x3A; // : + final int hyphen = '-'; + for (final String str : values) { + if (TextUtils.isEmpty(str)) { + continue; + } + final int length = str.length(); + for (int i = 0; i < length; i = str.offsetByCodePoints(i, 1)) { + int codepoint = str.codePointAt(i); + if (!((lowerAlphabetFirst <= codepoint && codepoint < lowerAlphabetAfterLast) || + (upperAlphabetFirst <= codepoint && codepoint < upperAlphabetAfterLast) || + (digitFirst <= codepoint && codepoint < digitAfterLast) || + (codepoint == hyphen))) { + return false; + } + } + } + return true; + } + + public static boolean containsOnlyWhiteSpaces(final String...values) { + if (values == null) { + return true; + } + return containsOnlyWhiteSpaces(Arrays.asList(values)); + } + + public static boolean containsOnlyWhiteSpaces(final Collection values) { + if (values == null) { + return true; + } + for (final String str : values) { + if (TextUtils.isEmpty(str)) { + continue; + } + final int length = str.length(); + for (int i = 0; i < length; i = str.offsetByCodePoints(i, 1)) { + if (!Character.isWhitespace(str.codePointAt(i))) { + return false; + } + } + } + return true; + } + + /** + *

+ * Returns true when the given String is categorized as "word" specified in vCard spec 2.1. + *

+ *

+ * vCard 2.1 specifies:
+ * word = <any printable 7bit us-ascii except []=:., > + *

+ */ + public static boolean isV21Word(final String value) { + if (TextUtils.isEmpty(value)) { + return true; + } + final int asciiFirst = 0x20; + final int asciiLast = 0x7E; // included + final int length = value.length(); + for (int i = 0; i < length; i = value.offsetByCodePoints(i, 1)) { + final int c = value.codePointAt(i); + if (!(asciiFirst <= c && c <= asciiLast) || + sUnAcceptableAsciiInV21WordSet.contains((char)c)) { + return false; + } + } + return true; + } + + private static final int[] sEscapeIndicatorsV30 = new int[]{ + ':', ';', ',', ' ' + }; + + private static final int[] sEscapeIndicatorsV40 = new int[]{ + ';', ':' + }; + + /** + *

+ * Returns String available as parameter value in vCard 3.0. + *

+ *

+ * RFC 2426 requires vCard composer to quote parameter values when it contains + * semi-colon, for example (See RFC 2426 for more information). + * This method checks whether the given String can be used without quotes. + *

+ *

+ * Note: We remove DQUOTE inside the given value silently for now. + *

+ */ + public static String toStringAsV30ParamValue(String value) { + return toStringAsParamValue(value, sEscapeIndicatorsV30); + } + + public static String toStringAsV40ParamValue(String value) { + return toStringAsParamValue(value, sEscapeIndicatorsV40); + } + + private static String toStringAsParamValue(String value, final int[] escapeIndicators) { + if (TextUtils.isEmpty(value)) { + value = ""; + } + final int asciiFirst = 0x20; + final int asciiLast = 0x7E; // included + final StringBuilder builder = new StringBuilder(); + final int length = value.length(); + boolean needQuote = false; + for (int i = 0; i < length; i = value.offsetByCodePoints(i, 1)) { + final int codePoint = value.codePointAt(i); + if (codePoint < asciiFirst || codePoint == '"') { + // CTL characters and DQUOTE are never accepted. Remove them. + continue; + } + builder.appendCodePoint(codePoint); + for (int indicator : escapeIndicators) { + if (codePoint == indicator) { + needQuote = true; + break; + } + } + } + + final String result = builder.toString(); + return ((result.isEmpty() || VCardUtils.containsOnlyWhiteSpaces(result)) + ? "" + : (needQuote ? ('"' + result + '"') + : result)); + } + + public static String toHalfWidthString(final String orgString) { + if (TextUtils.isEmpty(orgString)) { + return null; + } + final StringBuilder builder = new StringBuilder(); + final int length = orgString.length(); + for (int i = 0; i < length; i = orgString.offsetByCodePoints(i, 1)) { + // All Japanese character is able to be expressed by char. + // Do not need to use String#codepPointAt(). + final char ch = orgString.charAt(i); + final String halfWidthText = JapaneseUtils.tryGetHalfWidthText(ch); + if (halfWidthText != null) { + builder.append(halfWidthText); + } else { + builder.append(ch); + } + } + return builder.toString(); + } + + /** + * Guesses the format of input image. Currently just the first few bytes are used. + * The type "GIF", "PNG", or "JPEG" is returned when possible. Returns null when + * the guess failed. + * @param input Image as byte array. + * @return The image type or null when the type cannot be determined. + */ + public static String guessImageType(final byte[] input) { + if (input == null) { + return null; + } + if (input.length >= 3 && input[0] == 'G' && input[1] == 'I' && input[2] == 'F') { + return "GIF"; + } else if (input.length >= 4 && input[0] == (byte) 0x89 + && input[1] == 'P' && input[2] == 'N' && input[3] == 'G') { + // Note: vCard 2.1 officially does not support PNG, but we may have it and + // using X- word like "X-PNG" may not let importers know it is PNG. + // So we use the String "PNG" as is... + return "PNG"; + } else if (input.length >= 2 && input[0] == (byte) 0xff + && input[1] == (byte) 0xd8) { + return "JPEG"; + } else { + return null; + } + } + + /** + * @return True when all the given values are null or empty Strings. + */ + public static boolean areAllEmpty(final String...values) { + if (values == null) { + return true; + } + + for (final String value : values) { + if (!TextUtils.isEmpty(value)) { + return false; + } + } + return true; + } + + /** + * Checks to see if a string looks like it could be an android generated quoted printable. + * + * Identification of quoted printable is not 100% reliable since it's just ascii. But given + * the high number and exact location of generated = signs, there is a high likely-hood that + * it would be. + * + * @return True if it appears like android quoted printable. False otherwise. + */ + public static boolean appearsLikeAndroidVCardQuotedPrintable(String value) { + + // Quoted printable is always in multiple of 3s. With optional 1 '=' at end. + final int remainder = (value.length() % 3); + if (value.length() < 2 || (remainder != 1 && remainder != 0)) { + return false; + } + for (int i = 0; i < value.length(); i += 3) { + if (value.charAt(i) != '=') { + return false; + } + } + return true; + } + + //// The methods bellow may be used by unit test. + + /** + * Unquotes given Quoted-Printable value. value must not be null. + */ + public static String parseQuotedPrintable( + final String value, boolean strictLineBreaking, + String sourceCharset, String targetCharset) { + // "= " -> " ", "=\t" -> "\t". + // Previous code had done this replacement. Keep on the safe side. + final String quotedPrintable; + { + final StringBuilder builder = new StringBuilder(); + final int length = value.length(); + for (int i = 0; i < length; i++) { + char ch = value.charAt(i); + if (ch == '=' && i < length - 1) { + char nextCh = value.charAt(i + 1); + if (nextCh == ' ' || nextCh == '\t') { + builder.append(nextCh); + i++; + continue; + } + } + builder.append(ch); + } + quotedPrintable = builder.toString(); + } + + String[] lines; + if (strictLineBreaking) { + lines = quotedPrintable.split("\r\n"); + } else { + StringBuilder builder = new StringBuilder(); + final int length = quotedPrintable.length(); + ArrayList list = new ArrayList(); + for (int i = 0; i < length; i++) { + char ch = quotedPrintable.charAt(i); + if (ch == '\n') { + list.add(builder.toString()); + builder = new StringBuilder(); + } else if (ch == '\r') { + list.add(builder.toString()); + builder = new StringBuilder(); + if (i < length - 1) { + char nextCh = quotedPrintable.charAt(i + 1); + if (nextCh == '\n') { + i++; + } + } + } else { + builder.append(ch); + } + } + final String lastLine = builder.toString(); + if (lastLine.length() > 0) { + list.add(lastLine); + } + lines = list.toArray(new String[0]); + } + + final StringBuilder builder = new StringBuilder(); + for (String line : lines) { + if (line.endsWith("=")) { + line = line.substring(0, line.length() - 1); + } + builder.append(line); + } + + final String rawString = builder.toString(); + if (TextUtils.isEmpty(rawString)) { + Log.w(LOG_TAG, "Given raw string is empty."); + } + + byte[] rawBytes = null; + try { + rawBytes = rawString.getBytes(sourceCharset); + } catch (UnsupportedEncodingException e) { + Log.w(LOG_TAG, "Failed to decode: " + sourceCharset); + rawBytes = rawString.getBytes(); + } + + byte[] decodedBytes = null; + try { + decodedBytes = QuotedPrintableCodecPort.decodeQuotedPrintable(rawBytes); + } catch (DecoderException e) { + Log.e(LOG_TAG, "DecoderException is thrown."); + decodedBytes = rawBytes; + } + + try { + return new String(decodedBytes, targetCharset); + } catch (UnsupportedEncodingException e) { + Log.e(LOG_TAG, "Failed to encode: charset=" + targetCharset); + return new String(decodedBytes); + } + } + + public static final VCardParser getAppropriateParser(int vcardType) + throws VCardException { + if (VCardConfig.isVersion21(vcardType)) { + return new VCardParser_V21(); + } else if (VCardConfig.isVersion30(vcardType)) { + return new VCardParser_V30(); + } else if (VCardConfig.isVersion40(vcardType)) { + return new VCardParser_V40(); + } else { + throw new VCardException("Version is not specified"); + } + } + + public static final String convertStringCharset( + String originalString, String sourceCharset, String targetCharset) { + if (sourceCharset.equalsIgnoreCase(targetCharset)) { + return originalString; + } + final Charset charset = Charset.forName(sourceCharset); + final ByteBuffer byteBuffer = charset.encode(originalString); + // byteBuffer.array() "may" return byte array which is larger than + // byteBuffer.remaining(). Here, we keep on the safe side. + final byte[] bytes = new byte[byteBuffer.remaining()]; + byteBuffer.get(bytes); + try { + return new String(bytes, targetCharset); + } catch (UnsupportedEncodingException e) { + Log.e(LOG_TAG, "Failed to encode: charset=" + targetCharset); + return null; + } + } + + // TODO: utilities for vCard 4.0: datetime, timestamp, integer, float, and boolean + + private VCardUtils() { + } +} diff --git a/modules/vcard/java/com/android/vcard/exception/VCardAgentNotSupportedException.java b/modules/vcard/java/com/android/vcard/exception/VCardAgentNotSupportedException.java new file mode 100644 index 0000000..c408716 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/exception/VCardAgentNotSupportedException.java @@ -0,0 +1,27 @@ +/* + * 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.vcard.exception; + +public class VCardAgentNotSupportedException extends VCardNotSupportedException { + public VCardAgentNotSupportedException() { + super(); + } + + public VCardAgentNotSupportedException(String message) { + super(message); + } + +} \ No newline at end of file diff --git a/modules/vcard/java/com/android/vcard/exception/VCardException.java b/modules/vcard/java/com/android/vcard/exception/VCardException.java new file mode 100644 index 0000000..3ad7fd3 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/exception/VCardException.java @@ -0,0 +1,35 @@ +/* + * 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.vcard.exception; + +public class VCardException extends java.lang.Exception { + /** + * Constructs a VCardException object + */ + public VCardException() { + super(); + } + + /** + * Constructs a VCardException object + * + * @param message the error message + */ + public VCardException(String message) { + super(message); + } + +} diff --git a/modules/vcard/java/com/android/vcard/exception/VCardInvalidCommentLineException.java b/modules/vcard/java/com/android/vcard/exception/VCardInvalidCommentLineException.java new file mode 100644 index 0000000..342769e --- /dev/null +++ b/modules/vcard/java/com/android/vcard/exception/VCardInvalidCommentLineException.java @@ -0,0 +1,32 @@ +/* + * 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.vcard.exception; + +/** + * Thrown when the vCard has some line starting with '#'. In the specification, + * both vCard 2.1 and vCard 3.0 does not allow such line, but some actual exporter emit + * such lines. + */ +public class VCardInvalidCommentLineException extends VCardInvalidLineException { + public VCardInvalidCommentLineException() { + super(); + } + + public VCardInvalidCommentLineException(final String message) { + super(message); + } +} diff --git a/modules/vcard/java/com/android/vcard/exception/VCardInvalidLineException.java b/modules/vcard/java/com/android/vcard/exception/VCardInvalidLineException.java new file mode 100644 index 0000000..5c2250f --- /dev/null +++ b/modules/vcard/java/com/android/vcard/exception/VCardInvalidLineException.java @@ -0,0 +1,31 @@ +/* + * 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.vcard.exception; + +/** + * Thrown when the vCard has some line starting with '#'. In the specification, + * both vCard 2.1 and vCard 3.0 does not allow such line, but some actual exporter emit + * such lines. + */ +public class VCardInvalidLineException extends VCardException { + public VCardInvalidLineException() { + super(); + } + + public VCardInvalidLineException(final String message) { + super(message); + } +} diff --git a/modules/vcard/java/com/android/vcard/exception/VCardNestedException.java b/modules/vcard/java/com/android/vcard/exception/VCardNestedException.java new file mode 100644 index 0000000..2b9b1ac --- /dev/null +++ b/modules/vcard/java/com/android/vcard/exception/VCardNestedException.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.vcard.exception; + +/** + * VCardException thrown when VCard is nested without VCardParser's being notified. + */ +public class VCardNestedException extends VCardNotSupportedException { + public VCardNestedException() { + super(); + } + public VCardNestedException(String message) { + super(message); + } +} diff --git a/modules/vcard/java/com/android/vcard/exception/VCardNotSupportedException.java b/modules/vcard/java/com/android/vcard/exception/VCardNotSupportedException.java new file mode 100644 index 0000000..61ff752 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/exception/VCardNotSupportedException.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.vcard.exception; + +/** + * The exception which tells that the input VCard is probably valid from the view of + * specification but not supported in the current framework for now. + * + * This is a kind of a good news from the view of development. + * It may be good to ask users to send a report with the VCard example + * for the future development. + */ +public class VCardNotSupportedException extends VCardException { + public VCardNotSupportedException() { + super(); + } + public VCardNotSupportedException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/modules/vcard/java/com/android/vcard/exception/VCardVersionException.java b/modules/vcard/java/com/android/vcard/exception/VCardVersionException.java new file mode 100644 index 0000000..047c580 --- /dev/null +++ b/modules/vcard/java/com/android/vcard/exception/VCardVersionException.java @@ -0,0 +1,28 @@ +/* + * 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.vcard.exception; + +/** + * VCardException used only when the version of the vCard is different. + */ +public class VCardVersionException extends VCardException { + public VCardVersionException() { + super(); + } + public VCardVersionException(String message) { + super(message); + } +} diff --git a/modules/vcard/tests/Android.bp b/modules/vcard/tests/Android.bp new file mode 100644 index 0000000..89f2bc5 --- /dev/null +++ b/modules/vcard/tests/Android.bp @@ -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 { + default_team: "trendy_team_pixel_connectivity_bt", + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_test { + name: "AndroidVCardTests", + + certificate: "platform", + platform_apis: true, + test_suites: ["device-tests"], + srcs: ["src/**/*.java"], + libs: [ + "android.test.runner.stubs.system", + "android.test.base.stubs.system", + "android.test.mock.stubs.system", + ], + static_libs: [ + "com.android.vcard", + "junit", + "androidx.test.ext.truth", + "androidx.test.rules", + "mockito-target", + "truth", + ], + + jacoco: { + include_filter: ["com.android.vcard.*"], + exclude_filter: ["com.android.vcard.tests.*"], + }, +} diff --git a/modules/vcard/tests/AndroidManifest.xml b/modules/vcard/tests/AndroidManifest.xml new file mode 100644 index 0000000..f12ed17 --- /dev/null +++ b/modules/vcard/tests/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/modules/vcard/tests/AndroidTest.xml b/modules/vcard/tests/AndroidTest.xml new file mode 100644 index 0000000..0f7ce2c --- /dev/null +++ b/modules/vcard/tests/AndroidTest.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/modules/vcard/tests/res/raw/v21_adr_multiple_line.vcf b/modules/vcard/tests/res/raw/v21_adr_multiple_line.vcf new file mode 100644 index 0000000..0970671 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_adr_multiple_line.vcf @@ -0,0 +1,10 @@ +BEGIN:VCARD +VERSION:2.1 +N:bogus +URL:http://bogus.com/ +ADR;HOME:;;Grindelberg 999;Hamburg;;99999;Deutschland +ADR;HOME:;;Hermann v. Brevern\ +9999999\ +Packstation 999;Hamburg;;99999;Deutschland +BDAY;VALUE=DATE:20081203 +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_android_custom_prop.vcf b/modules/vcard/tests/res/raw/v21_android_custom_prop.vcf new file mode 100644 index 0000000..0d0b237 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_android_custom_prop.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +X-ANDROID-CUSTOM:custom_mime1;1;2;3;4;5;6;7;8;9;10;11;12;13;14;15 +X-ANDROID-CUSTOM:custom_mime2;a;b;c;d;e;f;g;h;i;j;k;l;m;n;o;p +X-ANDROID-CUSTOM:custom_mime3 +X-ANDROID-CUSTOM:custom_mime4;z +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_backslash.vcf b/modules/vcard/tests/res/raw/v21_backslash.vcf new file mode 100644 index 0000000..f2955c7 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_backslash.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:2.1 +N:;A\;B\\;C\\\;;D;\:E;\\\\; +FN:A;B\C\;D:E\\ +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_base64_no_2_crlf.vcf b/modules/vcard/tests/res/raw/v21_base64_no_2_crlf.vcf new file mode 100644 index 0000000..ad365aa --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_base64_no_2_crlf.vcf @@ -0,0 +1,78 @@ +BEGIN:VCARD +VERSION:2.1 +N:name +FN:fullname +PHOTO;ENCODING=BASE64;TYPE=JPEG: + /9j/4QoPRXhpZgAATU0AKgAAAAgADQEOAAIAAAAPAAAAqgEPAAIAAAAHAAAAugEQAAIAAAAG + AAAAwgESAAMAAAABAAEAAAEaAAUAAAABAAAAyAEbAAUAAAABAAAA0AEoAAMAAAABAAIAAAEx + AAIAAAAOAAAA2AEyAAIAAAAUAAAA5gITAAMAAAABAAEAAIKYAAIAAAAOAAAA+odpAAQAAAAB + AAABhMSlAAcAAAB8AAABCAAABB4yMDA4MTAyOTEzNTUzMQAARG9Db01vAABEOTA1aQAAAABI + AAAAAQAAAEgAAAABRDkwNWkgVmVyMS4wMAAyMDA4OjEwOjI5IDEzOjU1OjQ3ACAgICAgICAg + ICAgICAAUHJpbnRJTQAwMzAwAAAABgABABQAFAACAQAAAAADAAAANAEABQAAAAEBAQAAAAEQ + gAAAAAAAEQkAACcQAAAPCwAAJxAAAAWXAAAnEAAACLAAACcQAAAcAQAAJxAAAAJeAAAnEAAA + AIsAACcQAAADywAAJxAAABvlAAAnEAAogpoABQAAAAEAAANqgp0ABQAAAAEAAANyiCIAAwAA + AAEAAgAAkAAABwAAAAQwMjIwkAMAAgAAABQAAAN6kAQAAgAAABQAAAOOkQEABwAAAAQBAgMA + kQIABQAAAAEAAAOikgEACgAAAAEAAAOqkgIABQAAAAEAAAOykgQACgAAAAEAAAO6kgUABQAA + AAEAAAPCkgcAAwAAAAEAAgAAkggAAwAAAAEAAAAAkgkAAwAAAAEAAAAAkgoABQAAAAEAAAPK + knwABwAAAAEAAAAAkoYABwAAABYAAAPSoAAABwAAAAQwMTAwoAEAAwAAAAEAAQAAoAIAAwAA + AAEAYAAAoAMAAwAAAAEASAAAoAUABAAAAAEAAAQAog4ABQAAAAEAAAPoog8ABQAAAAEAAAPw + ohAAAwAAAAEAAgAAohcAAwAAAAEAAgAAowAABwAAAAEDAAAAowEABwAAAAEBAAAApAEAAwAA + AAEAAAAApAIAAwAAAAEAAAAApAMAAwAAAAEAAAAApAQABQAAAAEAAAP4pAUAAwAAAAEAHQAA + pAYAAwAAAAEAAAAApAcAAwAAAAEAAAAApAgAAwAAAAEAAAAApAkAAwAAAAEAAAAApAoAAwAA + AAEAAAAApAwAAwAAAAEAAgAAAAAAAAAAAFMAACcQAAABXgAAAGQyMDA4OjEwOjI5IDEzOjU1 + OjMxADIwMDg6MTA6MjkgMTM6NTU6NDcAAAApiAAAGwAAAAKyAAAAZAAAAV4AAABkAAAAAAAA + AGQAAAAlAAAACgAADpIAAAPoAAAAAAAAAAAyMDA4MTAyOTEzNTUzMQAAICoAAAAKAAAq4gAA + AAoAAAAAAAAAAQACAAEAAgAAAARSOTgAAAIABwAAAAQwMTAwAAAAAAAGAQMAAwAAAAEABgAA + ARoABQAAAAEAAARsARsABQAAAAEAAAR0ASgAAwAAAAEAAgAAAgEABAAAAAEAAAR8AgIABAAA + AAEAAAWLAAAAAAAAAEgAAAABAAAASAAAAAH/2P/bAIQAIBYYHBgUIBwaHCQiICYwUDQwLCww + YkZKOlB0Znp4cmZwboCQuJyAiK6KbnCg2qKuvsTO0M58muLy4MjwuMrOxgEiJCQwKjBeNDRe + xoRwhMbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbG + /8AAEQgAeACgAwEhAAIRAQMRAf/EAaIAAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKCxAA + AgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK + FhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG + h4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl + 5ufo6erx8vP09fb3+Pn6AQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgsRAAIBAgQEAwQH + BQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBka + JicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKT + lJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz + 9PX29/j5+v/aAAwDAQACEQMRAD8AFFSqKkZIoqRVpgSKKeBTEOApwFADsUYpgIRSEUANIppF + ICNhUTCgCMio2FICJhULCgC0oqVaAJFFSqKBkgFOApiHCnCgB2KMUCENJQA0imEUDGMKiYUA + RtUbUgIWqJhQBZSpFoAlWpVoGPFPFMQ7tSK2ODQA4yKO9HmKe9FxAzDHFIOlAAaYaAGNUTUD + ImqNqQETVE1AE6VKKAJFNSqaAHg08GmANIFFQM5Y5qJMBuT60ZNQIcrkVYSQMKuLGKaaasQx + qiagZE1RtSAjaomoAkQ1KpoAlU1IpoAkU07OBTArO+5qkV12Y71lfUBmaKkCRSuznrTFba2a + oCwGyM0E1qIjY1GxoGRNUZNICNqiagByGplNAEimpFNMB4YDvSucpxSYEIU04KazsAu1qArU + WELtPpTSposBNETt5pxNaoCNjUbGgCNjUZoGRtUTUgFU1KpoAkBqQHigCFnO7rUqOdlZp6gA + c+tODn1pXAXzD60eYfWncQvmNSGQ07gOMhCVEJGz1ptgS5yKYxqwGE1GxoAiamGkMapqVTQB + Kpp+eKAICfmqWM/Kaz6gANOBqQFzRmmAuaTNACsfkqMHmm9wJs8U0mtRDGNRsaAI2phpDI1N + SqaAJFNSA8UCISfmqSM/Kaz6jAHmnA1ICg0uaAFzSZpgKx+SmDrTe4E2eKaTWoiMmmMaAIzT + DSGRKakU0ASKaeDTERseakjPyms+oxAacDUgOBpc0gFzSZpgOY/KKYv3qrqIlpprQBjGoyaA + GGmmkMgU1IppgPBqQGgQu0Gn4wvFKwEQpwNZDHZpc0ALmigRKBleaQKBWtgA001QDGqM0gGm + mGkMrqakBoAepp4NMRIDTwaAE2A008GokgHxjd1pzKFpW0uAg5NSBBTirgOpDWgDTTTQAw0w + 0gGGmmgZWBp4pASKaeDTEOBp4NADwajbrUyBEkXWnSUdAGr1qeiAMSkNWAhphoAaaYaQDDTT + SGVRTwaYDxTwaBDwaeDQA4GlK5oauIeo20pGaLaAKqgU6hKwBSGmAhphoAaaYaQxhpppDKgN + PFMB4p4oEPFOBpgPBp4NAhwpwoAWloAKSgBDTTQMYaYaQDTTTSGA/9n/2wCEAAoHBwgHBgoI + CAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9 + PjsBCgsLDg0OHBAQHDsoIig7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7 + Ozs7Ozs7Ozs7Ozs7O//AABEIAEgAYAMBIQACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAA + AQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNC + scEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hp + anN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS + 09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcI + CQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVi + ctEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4 + eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY + 2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AJ7SLgcVr20I4rNFGvbQAAHFaEUX + SrQi5HCMdKk8oY6VSJYx4hjpVaWMelAFC4iGDxWPdR8mkxmRdxjBrEvI+tZjN20xtHNbNqAc + UIDXg6Cr0WKtCY8XKQOyzOB3FKNWsyceZ+lS6sY6NkjvPSdwImBHUmmy4q076oCjOODWPdgc + 0MpGPdAYNYl4o5rNjNKzkyorZtXxihAa1vIDip7m9Frb7/4jwKcnyxbEzN3ieJppZsyZ4U1H + urzZau4mWVlNrGk0UuWPVa1YroXEIkHfrXZh5W90RWncAHmsi6bJNdQ0ZNw3BrGuiMGs2Mks + puBzWzbzdOaEBeOpR2oUtkk9hTru7iuo4m8wgemKyqTi04sBsfkEf68j8KlUQZz9o/SuZRj3 + JYriAji4/Sp7W6htbV2aXcu70ramoxle4gN7HcIXjbis+4k5NdaaauhmVcv1rHuW61DGiG1m + 6c1s20/TmgAv5vmj57VKk3+ixnPc1xVV70h9CVJuOtSrL71hFgxzScUkkn+iY/2q1i9xDrGT + 9y31pJ5Otd1L+GhMy7mTrWXO2SapjRn28vTmta3nxjmgGOvJd2w1Kkv+ipz/ABGuOoveYdCe + ObjrU6y5rlsA8ycUksn+ij/eNaw6iJLNsW59zTJn6816FP4EJmbO+Saz5m602UjIgk4HNadv + LwKaBl+MpIMOMipp490SCJeF7CoqQvF2JuRqWQ4YEGrSiQJuKnHrXByMpki73GFBNXIoh9n2 + SrnnOK6MPTbd3sSwIVF2qMCqkzHmuy1lYRnTHrVGWpZaMKB+BWlbycYoQM0IZDxzV+GU8c1a + IYy5Y+dnHatAsfsAHfArmS1mPoh1gT8x9qtk1rQX7tCe5DIapzGtGBQm71SlqGWjnIH6Vowt + zmhAy/E3vV6F6tEMuxlWIyAfrVxCCAO1VZEEyYA4AApxNGwyJ+lVJRUsaKMw61SlFQzRAP/Z +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_blackberry_photo.vcf b/modules/vcard/tests/res/raw/v21_blackberry_photo.vcf new file mode 100644 index 0000000..fa31571 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_blackberry_photo.vcf @@ -0,0 +1,44 @@ +BEGIN:VCARD +VERSION:2.1 +FN:boogie +N:boogie +PHOTO;ENCODING=BASE64:iVBORw0KGgoAAAANSUhEUgAAADIAAAA6CAYAAAGFazpKAAAAGXRFWHRTb + 2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAB6pJREFUeNpi/P//PwMUgBiMDEiABcaYtFeCgY2 + F9/+//78ZshweMCJL/udml0DWBDYFJPn/+N12ht9/vyJLMjx+f/g/EwMeAJJktFSuxJCQFbRlhOlkB + Dkkw/42TA7iapBXoBgEGH7/+fYfJgYQQIxI/mTA5sf/Mw6qghlQYxnBOuYeMUDRlmxzgZFx+gGV/7i + cjt1PSE6FA7AYzHkgIyftlfoPczpcAuoHuD8AAgjZk2B87E7bf2Sw80r2f3Q1KJ4HxuV/RkZmBi42U + XDsgILSUDad4eKTeXC/w3zzf/I+6f9//n7/z8LMwcDMxMrw888HsAYQOP94JpwNDDyIOhCHlZmL4fS + DCQxMjKwMhMDzT2cQ4SbErc5ADBDiUkXx039o+gRzfv35jMLOc36B4id4mgFKMP7++w3MASlCZiPnB + IAAQk86GNkEXQwjHYBCBqoIhrGmQTB49/U2w9KTDuBQBAFQELOzCICSHtaU9n/VGS+GaPMDcAFnjT5 + wxN58se4/hh+AefL/tecrsQYrSBMwBUByN0jh6QcTGUwV8sGxjg38+fuDYckJu//vvt5iYAGlH1i44 + 4p5RsbfDO+/3QWrY2IgEvz//xd/9sKZ7ViZuYlSCErdsFD6Dw0lcH7AHkrfGHKdniIKKxBWlwgBJzZ + YhMHYIMWgfANTh559/4My/o3na/+jseFqSPY0RgHx9ssteAmCzIZhgADC0IDuRKQSBqeaxcdt8arBW + fwi5R9QIQIOPFCyAkUJiAYBUAWjJRnOAK0VGHEZgsuS/6AsteK0K8Pff7/BUbf6rC8wKG6gKIq1OMz + w7ttthq2XkhhA6Qaa3RiJseQ/sFYDayQVIKUlRmyW/EcrRaCplZNkNnLhIsStCi914LUeCOjLQHyAK + +UT4xNghQEvubz15pGRfskANLEEVuzQ1BKsPgHVwjzskgxKIu4M2FpCpAJQhQYyy1G9E9SKAkc8OLn + FWBxCyYAUAkY37SmEMyOMMe2AAryeALlMXSIIzEZOQcji2DIjrjhhxFdM4PMBWalLlFcXngdkBK3gB + knwG6GL4wQAAURMAUnIR//x+YKQT/7vuppDKCH8BzYp4MUK0RUQel2x6ozPf3wASZ6B7PoE5FJQGxT + U2gDlZFCLFwZAFTKwF8NAKLjwWfIf2O5h+PTjEbhOgSVX9AIRZHGg0WpwpiM1df0Hdj0Yvvx8Dm7OZ + drfgTe1YQBUSYFKXFBbavPFWHDRjiv+mHAFEax79vDtPnBTHB2AWn5ff74AWw7KrEtO2JKUhP8j52Z + SAKjuwBZsLNgUgywgtq2HDJ68OwKzBMMn/2FVLnL1SWkVjFT9grvI4I41qKkDKkJCjTfD+aQAUApUF + w9kcNLoZph+UAUcGsI8GiDz/rOAbIS1yp9/OI1IEUT0zVBSEDNE/d3X2+Hx+eLjOczUhZzRaFbHQ5v + Ug78hwcUuSp/WyjC2hB2YfGGFH6xLQAkAjR4glxxgS0B9DNAoCKjdFQPsClAKQOaBWi+gdhds9Adrq + x556IIUADIYub0Fy34sWGo1qjTuCJbCIC+D6gpQCYBc5MP6wrjENaXCSevOQccR4T0upFEeFHHoaCX + eBh4TKS1BIoKIkWr55NvP10Msx4OqUlikIg0KM4Ja8DBx0EgWyUMY6C1I0KDoo3eH0FuJuMSxYoAA7 + ZpNS1tBFIYnIbShxU9oqWi1oCBVKC6SjRW6U7vsRvwRCsW12/6CQhf+AimCXRQRoSvBjYuKpWLAjeD + Cj4UfwaK2YPuMnGFyM9f7lcSkeOByQzLJve/cOee87zsJYpC3kmyW+cNMi/T1VMzZNL9lz1ZNizCOx + 9zqS5s8Rj4AAFndO15LVmFiPhEzk7Ct+fUx1fvkrdbN1AIoXvHixq07vdjV55Zsjz43ZTtVV/uI6mj + OafK78nNaTeaXEz2NJEAMCIQLJq34rmHJmnRJkux524h63TebCExUICWVGQEkxJybYfaPij8CwQCiO + dutnja9UofFTS2yZBIsVRAJUBQg7GOoxY0JTW/tfjWRWzLeGWK5cLDoK20AIZxeknx+fdzoLSYHe+D + d0OdIRSQsEF8QcnHek+USZnnZ3/GOjwMmHXY5ITgQLlB+Lm4f8p4960GH/R3XZ1yLa4alTinPBqpvB + PomVQpbv95GdozoJdgA2TtZU5e/T3Ui2pWEz6wZqnpc/jmz7X/dq75tz+higvZl6WGS2L52WcIRuCn + b+wtqbPCTcRXZuopDv+MEHEh6EVXyy/dJnVPkE/qaLQkMFvQHYNI36Itlm+ime58X7mRJ2Rofmuq1y + mXM+dV+abLTC7w+ViNFWv0n0RBAXObJ/RNpGCBUrag2Y90BoQ6zL3qly/Avs7nf0ZpX+Rfv7/wmYcP + 8RUPuj4PXuZ4pw5R9d5O91IDOzn8+atUQuVmca4eP5Ww7GacXVQriuk5WT6ohk93VySuiR4S4Ff7xL + zgQ2pvzztHXsiIh4qmrdbhkLOESXa7x6PooSjGW1JWAzLEF5wVCItpkU4Kdt5WtaacwGx34WDY+itT + NxFynsfKGDh1yydTW10rKauumsz9+8Kxi48PwqaoadLZyI7B53vR/cCXqtUvptT3q9RtfMyBBfSZVo + fGh4i/V4q4YNEfoiwAAAABJRU5ErkJggg== +TEL;TYPE=WORK:+5555555 +TEL;TYPE=CELL:+5555556 +EMAIL;TYPE=INTERNET:forrestgump@walladalla.com +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_complicated.vcf b/modules/vcard/tests/res/raw/v21_complicated.vcf new file mode 100644 index 0000000..e7aec00 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_complicated.vcf @@ -0,0 +1,107 @@ +BEGIN:VCARD +VERSION:2.1 +N:Gump;Forrest;Hoge;Pos;Tao +FN:Joe Due +ORG:Gump Shrimp Co.;Sales Dept.\;Manager;Fish keeper +ROLE:Fish Cake Keeper! +X-CLASS:PUBLIC +TITLE:Shrimp Man +TEL;WORK;VOICE:(111) 555-1212 +TEL;HOME;VOICE:(404) 555-1212 +TEL;FAX:(404) 555-1212P*#55 +TEL;CELL:0311111111 +TEL;VIDEO:0322222222 +TEL;VOICE:0333333333 +ADR;WORK:;;100 Waters Edge;Baytown;LA;30314;United States of America +LABEL;WORK;ENCODING=QUOTED-PRINTABLE:100 Waters Edge=0D=0ABaytown, LA 30314=0D=0AUnited States of America +ADR;HOME:;;42 Plantation St.;Baytown;LA;30314;United States of America +LABEL;HOME;ENCODING=QUOTED-PRINTABLE:42 Plantation St.=0D=0A= +Baytown, LA 30314=0D=0A= +United States of America +EMAIL;PREF;INTERNET:forrestgump@walladalla.com +EMAIL;CELL:cell@example.com +NOTE:The following note is the example from RFC 2045. +NOTE;ENCODING=QUOTED-PRINTABLE:Now's the time = +for all folk to come= + to the aid of their country. + +PHOTO;ENCODING=BASE64;TYPE=JPEG: + /9j/4QoPRXhpZgAATU0AKgAAAAgADQEOAAIAAAAPAAAAqgEPAAIAAAAHAAAAugEQAAIAAAAG + AAAAwgESAAMAAAABAAEAAAEaAAUAAAABAAAAyAEbAAUAAAABAAAA0AEoAAMAAAABAAIAAAEx + AAIAAAAOAAAA2AEyAAIAAAAUAAAA5gITAAMAAAABAAEAAIKYAAIAAAAOAAAA+odpAAQAAAAB + AAABhMSlAAcAAAB8AAABCAAABB4yMDA4MTAyOTEzNTUzMQAARG9Db01vAABEOTA1aQAAAABI + AAAAAQAAAEgAAAABRDkwNWkgVmVyMS4wMAAyMDA4OjEwOjI5IDEzOjU1OjQ3ACAgICAgICAg + ICAgICAAUHJpbnRJTQAwMzAwAAAABgABABQAFAACAQAAAAADAAAANAEABQAAAAEBAQAAAAEQ + gAAAAAAAEQkAACcQAAAPCwAAJxAAAAWXAAAnEAAACLAAACcQAAAcAQAAJxAAAAJeAAAnEAAA + AIsAACcQAAADywAAJxAAABvlAAAnEAAogpoABQAAAAEAAANqgp0ABQAAAAEAAANyiCIAAwAA + AAEAAgAAkAAABwAAAAQwMjIwkAMAAgAAABQAAAN6kAQAAgAAABQAAAOOkQEABwAAAAQBAgMA + kQIABQAAAAEAAAOikgEACgAAAAEAAAOqkgIABQAAAAEAAAOykgQACgAAAAEAAAO6kgUABQAA + AAEAAAPCkgcAAwAAAAEAAgAAkggAAwAAAAEAAAAAkgkAAwAAAAEAAAAAkgoABQAAAAEAAAPK + knwABwAAAAEAAAAAkoYABwAAABYAAAPSoAAABwAAAAQwMTAwoAEAAwAAAAEAAQAAoAIAAwAA + AAEAYAAAoAMAAwAAAAEASAAAoAUABAAAAAEAAAQAog4ABQAAAAEAAAPoog8ABQAAAAEAAAPw + ohAAAwAAAAEAAgAAohcAAwAAAAEAAgAAowAABwAAAAEDAAAAowEABwAAAAEBAAAApAEAAwAA + AAEAAAAApAIAAwAAAAEAAAAApAMAAwAAAAEAAAAApAQABQAAAAEAAAP4pAUAAwAAAAEAHQAA + pAYAAwAAAAEAAAAApAcAAwAAAAEAAAAApAgAAwAAAAEAAAAApAkAAwAAAAEAAAAApAoAAwAA + AAEAAAAApAwAAwAAAAEAAgAAAAAAAAAAAFMAACcQAAABXgAAAGQyMDA4OjEwOjI5IDEzOjU1 + OjMxADIwMDg6MTA6MjkgMTM6NTU6NDcAAAApiAAAGwAAAAKyAAAAZAAAAV4AAABkAAAAAAAA + AGQAAAAlAAAACgAADpIAAAPoAAAAAAAAAAAyMDA4MTAyOTEzNTUzMQAAICoAAAAKAAAq4gAA + AAoAAAAAAAAAAQACAAEAAgAAAARSOTgAAAIABwAAAAQwMTAwAAAAAAAGAQMAAwAAAAEABgAA + ARoABQAAAAEAAARsARsABQAAAAEAAAR0ASgAAwAAAAEAAgAAAgEABAAAAAEAAAR8AgIABAAA + AAEAAAWLAAAAAAAAAEgAAAABAAAASAAAAAH/2P/bAIQAIBYYHBgUIBwaHCQiICYwUDQwLCww + YkZKOlB0Znp4cmZwboCQuJyAiK6KbnCg2qKuvsTO0M58muLy4MjwuMrOxgEiJCQwKjBeNDRe + xoRwhMbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbG + /8AAEQgAeACgAwEhAAIRAQMRAf/EAaIAAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKCxAA + AgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK + FhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG + h4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl + 5ufo6erx8vP09fb3+Pn6AQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgsRAAIBAgQEAwQH + BQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBka + JicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKT + lJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz + 9PX29/j5+v/aAAwDAQACEQMRAD8AFFSqKkZIoqRVpgSKKeBTEOApwFADsUYpgIRSEUANIppF + ICNhUTCgCMio2FICJhULCgC0oqVaAJFFSqKBkgFOApiHCnCgB2KMUCENJQA0imEUDGMKiYUA + RtUbUgIWqJhQBZSpFoAlWpVoGPFPFMQ7tSK2ODQA4yKO9HmKe9FxAzDHFIOlAAaYaAGNUTUD + ImqNqQETVE1AE6VKKAJFNSqaAHg08GmANIFFQM5Y5qJMBuT60ZNQIcrkVYSQMKuLGKaaasQx + qiagZE1RtSAjaomoAkQ1KpoAlU1IpoAkU07OBTArO+5qkV12Y71lfUBmaKkCRSuznrTFba2a + oCwGyM0E1qIjY1GxoGRNUZNICNqiagByGplNAEimpFNMB4YDvSucpxSYEIU04KazsAu1qArU + WELtPpTSposBNETt5pxNaoCNjUbGgCNjUZoGRtUTUgFU1KpoAkBqQHigCFnO7rUqOdlZp6gA + c+tODn1pXAXzD60eYfWncQvmNSGQ07gOMhCVEJGz1ptgS5yKYxqwGE1GxoAiamGkMapqVTQB + Kpp+eKAICfmqWM/Kaz6gANOBqQFzRmmAuaTNACsfkqMHmm9wJs8U0mtRDGNRsaAI2phpDI1N + SqaAJFNSA8UCISfmqSM/Kaz6jAHmnA1ICg0uaAFzSZpgKx+SmDrTe4E2eKaTWoiMmmMaAIzT + DSGRKakU0ASKaeDTERseakjPyms+oxAacDUgOBpc0gFzSZpgOY/KKYv3qrqIlpprQBjGoyaA + GGmmkMgU1IppgPBqQGgQu0Gn4wvFKwEQpwNZDHZpc0ALmigRKBleaQKBWtgA001QDGqM0gGm + mGkMrqakBoAepp4NMRIDTwaAE2A008GokgHxjd1pzKFpW0uAg5NSBBTirgOpDWgDTTTQAw0w + 0gGGmmgZWBp4pASKaeDTEOBp4NADwajbrUyBEkXWnSUdAGr1qeiAMSkNWAhphoAaaYaQDDTT + SGVRTwaYDxTwaBDwaeDQA4GlK5oauIeo20pGaLaAKqgU6hKwBSGmAhphoAaaYaQxhpppDKgN + PFMB4p4oEPFOBpgPBp4NAhwpwoAWloAKSgBDTTQMYaYaQDTTTSGA/9n/2wCEAAoHBwgHBgoI + CAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9 + PjsBCgsLDg0OHBAQHDsoIig7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7 + Ozs7Ozs7Ozs7Ozs7O//AABEIAEgAYAMBIQACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAA + AQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNC + scEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hp + anN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS + 09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcI + CQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVi + ctEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4 + eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY + 2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AJ7SLgcVr20I4rNFGvbQAAHFaEUX + SrQi5HCMdKk8oY6VSJYx4hjpVaWMelAFC4iGDxWPdR8mkxmRdxjBrEvI+tZjN20xtHNbNqAc + UIDXg6Cr0WKtCY8XKQOyzOB3FKNWsyceZ+lS6sY6NkjvPSdwImBHUmmy4q076oCjOODWPdgc + 0MpGPdAYNYl4o5rNjNKzkyorZtXxihAa1vIDip7m9Frb7/4jwKcnyxbEzN3ieJppZsyZ4U1H + urzZau4mWVlNrGk0UuWPVa1YroXEIkHfrXZh5W90RWncAHmsi6bJNdQ0ZNw3BrGuiMGs2Mks + puBzWzbzdOaEBeOpR2oUtkk9hTru7iuo4m8wgemKyqTi04sBsfkEf68j8KlUQZz9o/SuZRj3 + JYriAji4/Sp7W6htbV2aXcu70ramoxle4gN7HcIXjbis+4k5NdaaauhmVcv1rHuW61DGiG1m + 6c1s20/TmgAv5vmj57VKk3+ixnPc1xVV70h9CVJuOtSrL71hFgxzScUkkn+iY/2q1i9xDrGT + 9y31pJ5Otd1L+GhMy7mTrWXO2SapjRn28vTmta3nxjmgGOvJd2w1Kkv+ipz/ABGuOoveYdCe + ObjrU6y5rlsA8ycUksn+ij/eNaw6iJLNsW59zTJn6816FP4EJmbO+Saz5m602UjIgk4HNadv + LwKaBl+MpIMOMipp490SCJeF7CoqQvF2JuRqWQ4YEGrSiQJuKnHrXByMpki73GFBNXIoh9n2 + SrnnOK6MPTbd3sSwIVF2qMCqkzHmuy1lYRnTHrVGWpZaMKB+BWlbycYoQM0IZDxzV+GU8c1a + IYy5Y+dnHatAsfsAHfArmS1mPoh1gT8x9qtk1rQX7tCe5DIapzGtGBQm71SlqGWjnIH6Vowt + zmhAy/E3vV6F6tEMuxlWIyAfrVxCCAO1VZEEyYA4AApxNGwyJ+lVJRUsaKMw61SlFQzRAP/Z + +X-ATTRIBUTE:Some String +BDAY:19800101 +GEO:35.6563854,139.6994233 +URL:http://www.example.com/ +REV:20080424T195243Z +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_im.vcf b/modules/vcard/tests/res/raw/v21_im.vcf new file mode 100644 index 0000000..cc1aabb --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_im.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:2.1 +X-ANDROID-CUSTOM:vnd.android.cursor.item/nickname;Nick;1;;;;;;;;;;;;; +X-GOOGLE-TALK:hhh@gmail.com +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_invalid_comment_line.vcf b/modules/vcard/tests/res/raw/v21_invalid_comment_line.vcf new file mode 100644 index 0000000..70d038b --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_invalid_comment_line.vcf @@ -0,0 +1,10 @@ +BEGIN:vCard +VERSION:2.1 +UID:357 +N:;Conference Call +FN:Conference Call +# This line must be ignored. +NOTE;ENCODING=QUOTED-PRINTABLE:This is an (sharp ->= +#<- sharp) example. This message must NOT be ignored. +# This line must be ignored too. +END:vCard diff --git a/modules/vcard/tests/res/raw/v21_invalid_multiple_line.vcf b/modules/vcard/tests/res/raw/v21_invalid_multiple_line.vcf new file mode 100644 index 0000000..9c81fd5 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_invalid_multiple_line.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:2.1 +N:;Omega;;; +EMAIL;INTERNET:"Omega" + +FN:Omega +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_japanese_1.vcf b/modules/vcard/tests/res/raw/v21_japanese_1.vcf new file mode 100644 index 0000000..7e23493 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_japanese_1.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:2.1 +N;CHARSET=SHIFT_JIS:ˆÀ“¡ƒ�ƒCƒh;;;; +SOUND;X-IRMC-N;CHARSET=SHIFT_JIS:±ÝÄÞ³Û²ÄÞ;;;; +TEL;PREF;VOICE:0300000000 +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_japanese_2.vcf b/modules/vcard/tests/res/raw/v21_japanese_2.vcf new file mode 100644 index 0000000..5dbbf9d --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_japanese_2.vcf @@ -0,0 +1,10 @@ +BEGIN:VCARD +VERSION:2.1 +FN;CHARSET=SHIFT_JIS:ˆÀ“¡ ƒ�ƒCƒh 1 +N;CHARSET=SHIFT_JIS:ˆÀ“¡;ƒ�ƒCƒh1;;; +SOUND;X-IRMC-N;CHARSET=SHIFT_JIS:±ÝÄÞ³;Û²ÄÞ1;;; +ADR;HOME;CHARSET=SHIFT_JIS;ENCODING=QUOTED-PRINTABLE:;=93=8C=8B=9E=93=73= +=8F=61=92=4A=8B=E6=8D=F7=8B=75=92=AC26-1=83=5A=83=8B=83=8A=83=41=83=93= +=83=5E=83=8F=81=5B6=8A=4B;;;;150-8512; +NOTE;CHARSET=SHIFT_JIS;ENCODING=QUOTED-PRINTABLE:=83=81=83=82 +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_japanese_3.vcf b/modules/vcard/tests/res/raw/v21_japanese_3.vcf new file mode 100644 index 0000000..e54124d --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_japanese_3.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:2.1 +FN;CHARSET=SHIFT_JIS:ˆÉ”\ Œ\ˆê ˆÉ—\ +N;CHARSET=SHIFT_JIS:ˆÉ”\;Œ\ˆê;ˆÉ—\;; +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_malformed_photo.vcf b/modules/vcard/tests/res/raw/v21_malformed_photo.vcf new file mode 100644 index 0000000..6edb520 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_malformed_photo.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:2.1 +N:name +FN:fullname +PHOTO;ENCODING=BASE64;TYPE=JPEG:qgEPAAIAAAAHAAAAugEQAAIAAAAG:ASDF== + +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_multiple_entry.vcf b/modules/vcard/tests/res/raw/v21_multiple_entry.vcf new file mode 100644 index 0000000..668d669 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_multiple_entry.vcf @@ -0,0 +1,33 @@ +BEGIN:VCARD +VERSION:2.1 +N;CHARSET=SHIFT_JIS:ˆÀ“¡ƒ�ƒCƒh3;;;; +SOUND;X-IRMC-N;CHARSET=SHIFT_JIS:±ÝÄÞ³Û²ÄÞ3;;;; +TEL;X-NEC-SECRET:9 +TEL;X-NEC-HOTEL:10 +TEL;X-NEC-SCHOOL:11 +TEL;HOME;FAX:12 +END:VCARD + + +BEGIN:VCARD +VERSION:2.1 +N;CHARSET=SHIFT_JIS:ˆÀ“¡ƒ�ƒCƒh4;;;; +SOUND;X-IRMC-N;CHARSET=SHIFT_JIS:±ÝÄÞ³Û²ÄÞ4;;;; +TEL;MODEM:13 +TEL;PAGER:14 +TEL;X-NEC-FAMILY:15 +TEL;X-NEC-GIRL:16 +END:VCARD + + +BEGIN:VCARD +VERSION:2.1 +N;CHARSET=SHIFT_JIS:ˆÀ“¡ƒ�ƒCƒh5;;;; +SOUND;X-IRMC-N;CHARSET=SHIFT_JIS:±ÝÄÞ³Û²ÄÞ5;;;; +TEL;X-NEC-BOY:17 +TEL;X-NEC-FRIEND:18 +TEL;X-NEC-PHS:19 +TEL;X-NEC-RESTAURANT:20 +END:VCARD + + diff --git a/modules/vcard/tests/res/raw/v21_nest.vcf b/modules/vcard/tests/res/raw/v21_nest.vcf new file mode 100644 index 0000000..6f29d93 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_nest.vcf @@ -0,0 +1,13 @@ +BEGIN:VCARD +VERSION:2.1 +N:parent +BEGIN:VCARD +VERSION:2.1 +N:nest1 +END:VCARD +BEGIN:VCARD +VERSION:2.1 +N:nest2 +END:VCARD +TEL:1 +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_nest_with_mime.vcf b/modules/vcard/tests/res/raw/v21_nest_with_mime.vcf new file mode 100644 index 0000000..e850821 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_nest_with_mime.vcf @@ -0,0 +1,16 @@ +Content-Type: text/x-vcard +CHARSET=utf-8 +profile=vCard +BEGIN:VCARD +VERSION:2.1 +N:parent +BEGIN:VCARD +VERSION:2.1 +N:nest1 +END:VCARD +BEGIN:VCARD +VERSION:2.1 +N:nest2 +END:VCARD +TEL:1 +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_org_before_title.vcf b/modules/vcard/tests/res/raw/v21_org_before_title.vcf new file mode 100644 index 0000000..dc7a916 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_org_before_title.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:2.1 +FN:Normal Guy +ORG:Company;Organization;Devision;Room;Sheet No. +TITLE:Excellent Janitor +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_pref_handling.vcf b/modules/vcard/tests/res/raw/v21_pref_handling.vcf new file mode 100644 index 0000000..5105310 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_pref_handling.vcf @@ -0,0 +1,15 @@ +BEGIN:VCARD +VERSION:2.1 +FN:Smith +TEL;HOME:1 +TEL;WORK;PREF:2 +TEL;ISDN:3 +EMAIL;PREF;HOME:test@example.com +EMAIL;CELL;PREF:test2@examination.com +ORG:Company +TITLE:Engineer +ORG:Mystery +TITLE:Blogger +ORG:Poetry +TITLE:Poet +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_simple_1.vcf b/modules/vcard/tests/res/raw/v21_simple_1.vcf new file mode 100644 index 0000000..24bbcb5 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_simple_1.vcf @@ -0,0 +1,3 @@ +BEGIN:VCARD +N:Ando;Roid; +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_simple_2.vcf b/modules/vcard/tests/res/raw/v21_simple_2.vcf new file mode 100644 index 0000000..bcd2971 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_simple_2.vcf @@ -0,0 +1,3 @@ +BEGIN:VCARD +FN:Ando Roid +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_simple_3.vcf b/modules/vcard/tests/res/raw/v21_simple_3.vcf new file mode 100644 index 0000000..a4d6e3a --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_simple_3.vcf @@ -0,0 +1,4 @@ +BEGIN:VCARD +N:Ando;Roid; +FN:Ando Roid +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_sip.vcf b/modules/vcard/tests/res/raw/v21_sip.vcf new file mode 100644 index 0000000..6fd4ec5 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_sip.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:2.1 +FN:Android +X-SIP:888 +X-SIP:sip:90-180-360 +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_title_before_org.vcf b/modules/vcard/tests/res/raw/v21_title_before_org.vcf new file mode 100644 index 0000000..fd493df --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_title_before_org.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:2.1 +FN:Nice Guy +TITLE:Cool Title +ORG:Marverous;Perfect;Great;Good;Bad;Poor +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_winmo_65.vcf b/modules/vcard/tests/res/raw/v21_winmo_65.vcf new file mode 100644 index 0000000..0aafbc5 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_winmo_65.vcf @@ -0,0 +1,10 @@ +BEGIN:VCARD +VERSION:2.1 +N:Example;;;; +FN:Example +ANNIVERSARY;VALUE=DATE:20091010 +AGENT:Invalid line which must be handled correctly. +X-CLASS:PUBLIC +X-REDUCTION: +X-NO: +END:VCARD diff --git a/modules/vcard/tests/res/raw/v21_x_android_custom_after_photo.vcf b/modules/vcard/tests/res/raw/v21_x_android_custom_after_photo.vcf new file mode 100644 index 0000000..5fd7db1 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_x_android_custom_after_photo.vcf @@ -0,0 +1,42 @@ +BEGIN:VCARD +VERSION:2.1 +N:Ando;Roid; +PHOTO;ENCODING=BASE64:iVBORw0KGgoAAAANSUhEUgAAADIAAAA6CAYAAAGFazpKAAAAGXRFWHRTb + 2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAB6pJREFUeNpi/P//PwMUgBiMDEiABcaYtFeCgY2 + F9/+//78ZshweMCJL/udml0DWBDYFJPn/+N12ht9/vyJLMjx+f/g/EwMeAJJktFSuxJCQFbRlhOlkB + Dkkw/42TA7iapBXoBgEGH7/+fYfJgYQQIxI/mTA5sf/Mw6qghlQYxnBOuYeMUDRlmxzgZFx+gGV/7i + cjt1PSE6FA7AYzHkgIyftlfoPczpcAuoHuD8AAgjZk2B87E7bf2Sw80r2f3Q1KJ4HxuV/RkZmBi42U + XDsgILSUDad4eKTeXC/w3zzf/I+6f9//n7/z8LMwcDMxMrw888HsAYQOP94JpwNDDyIOhCHlZmL4fS + DCQxMjKwMhMDzT2cQ4SbErc5ADBDiUkXx039o+gRzfv35jMLOc36B4id4mgFKMP7++w3MASlCZiPnB + IAAQk86GNkEXQwjHYBCBqoIhrGmQTB49/U2w9KTDuBQBAFQELOzCICSHtaU9n/VGS+GaPMDcAFnjT5 + wxN58se4/hh+AefL/tecrsQYrSBMwBUByN0jh6QcTGUwV8sGxjg38+fuDYckJu//vvt5iYAGlH1i44 + 4p5RsbfDO+/3QWrY2IgEvz//xd/9sKZ7ViZuYlSCErdsFD6Dw0lcH7AHkrfGHKdniIKKxBWlwgBJzZ + YhMHYIMWgfANTh559/4My/o3na/+jseFqSPY0RgHx9ssteAmCzIZhgADC0IDuRKQSBqeaxcdt8arBW + fwi5R9QIQIOPFCyAkUJiAYBUAWjJRnOAK0VGHEZgsuS/6AsteK0K8Pff7/BUbf6rC8wKG6gKIq1OMz + w7ttthq2XkhhA6Qaa3RiJseQ/sFYDayQVIKUlRmyW/EcrRaCplZNkNnLhIsStCi914LUeCOjLQHyAK + +UT4xNghQEvubz15pGRfskANLEEVuzQ1BKsPgHVwjzskgxKIu4M2FpCpAJQhQYyy1G9E9SKAkc8OLn + FWBxCyYAUAkY37SmEMyOMMe2AAryeALlMXSIIzEZOQcji2DIjrjhhxFdM4PMBWalLlFcXngdkBK3gB + knwG6GL4wQAAURMAUnIR//x+YKQT/7vuppDKCH8BzYp4MUK0RUQel2x6ozPf3wASZ6B7PoE5FJQGxT + U2gDlZFCLFwZAFTKwF8NAKLjwWfIf2O5h+PTjEbhOgSVX9AIRZHGg0WpwpiM1df0Hdj0Yvvx8Dm7OZ + drfgTe1YQBUSYFKXFBbavPFWHDRjiv+mHAFEax79vDtPnBTHB2AWn5ff74AWw7KrEtO2JKUhP8j52Z + SAKjuwBZsLNgUgywgtq2HDJ68OwKzBMMn/2FVLnL1SWkVjFT9grvI4I41qKkDKkJCjTfD+aQAUApUF + w9kcNLoZph+UAUcGsI8GiDz/rOAbIS1yp9/OI1IEUT0zVBSEDNE/d3X2+Hx+eLjOczUhZzRaFbHQ5v + Ug78hwcUuSp/WyjC2hB2YfGGFH6xLQAkAjR4glxxgS0B9DNAoCKjdFQPsClAKQOaBWi+gdhds9Adrq + x556IIUADIYub0Fy34sWGo1qjTuCJbCIC+D6gpQCYBc5MP6wrjENaXCSevOQccR4T0upFEeFHHoaCX + eBh4TKS1BIoKIkWr55NvP10Msx4OqUlikIg0KM4Ja8DBx0EgWyUMY6C1I0KDoo3eH0FuJuMSxYoAA7 + ZpNS1tBFIYnIbShxU9oqWi1oCBVKC6SjRW6U7vsRvwRCsW12/6CQhf+AimCXRQRoSvBjYuKpWLAjeD + Cj4UfwaK2YPuMnGFyM9f7lcSkeOByQzLJve/cOee87zsJYpC3kmyW+cNMi/T1VMzZNL9lz1ZNizCOx + 9zqS5s8Rj4AAFndO15LVmFiPhEzk7Ct+fUx1fvkrdbN1AIoXvHixq07vdjV55Zsjz43ZTtVV/uI6mj + OafK78nNaTeaXEz2NJEAMCIQLJq34rmHJmnRJkux524h63TebCExUICWVGQEkxJybYfaPij8CwQCiO + dutnja9UofFTS2yZBIsVRAJUBQg7GOoxY0JTW/tfjWRWzLeGWK5cLDoK20AIZxeknx+fdzoLSYHe+D + d0OdIRSQsEF8QcnHek+USZnnZ3/GOjwMmHXY5ITgQLlB+Lm4f8p4960GH/R3XZ1yLa4alTinPBqpvB + PomVQpbv95GdozoJdgA2TtZU5e/T3Ui2pWEz6wZqnpc/jmz7X/dq75tz+higvZl6WGS2L52WcIRuCn + b+wtqbPCTcRXZuopDv+MEHEh6EVXyy/dJnVPkE/qaLQkMFvQHYNI36Itlm+ime58X7mRJ2Rofmuq1y + mXM+dV+abLTC7w+ViNFWv0n0RBAXObJ/RNpGCBUrag2Y90BoQ6zL3qly/Avs7nf0ZpX+Rfv7/wmYcP + 8RUPuj4PXuZ4pw5R9d5O91IDOzn8+atUQuVmca4eP5Ww7GacXVQriuk5WT6ohk93VySuiR4S4Ff7xL + zgQ2pvzztHXsiIh4qmrdbhkLOESXa7x6PooSjGW1JWAzLEF5wVCItpkU4Kdt5WtaacwGx34WDY+itT + NxFynsfKGDh1yydTW10rKauumsz9+8Kxi48PwqaoadLZyI7B53vR/cCXqtUvptT3q9RtfMyBBfSZVo + fGh4i/V4q4YNEfoiwAAAABJRU5ErkJggg== +X-ANDROID-CUSTOM:vnd.android.cursor.item/contact_event;1999-07-10;1;;;;;;;;;;;;; +BDAY:1975-08-20 +END:VCARD \ No newline at end of file diff --git a/modules/vcard/tests/res/raw/v21_x_param.vcf b/modules/vcard/tests/res/raw/v21_x_param.vcf new file mode 100644 index 0000000..5fdb825 --- /dev/null +++ b/modules/vcard/tests/res/raw/v21_x_param.vcf @@ -0,0 +1,9 @@ +BEGIN:VCARD +VERSION:2.1 +N:Ando;Roid; +ADR;X-custom:pobox;street +TEL;X-CuStoMpRop:1 +TEL;custompropertywithoutx:2 +EMAIL;X-cUstomPrOperty:email@example.com +EMAIL;CUSTOMPROPERTYWITHOUTX:email2@example.com +END:VCARD diff --git a/modules/vcard/tests/res/raw/v30_adr_types.vcf b/modules/vcard/tests/res/raw/v30_adr_types.vcf new file mode 100644 index 0000000..62add7e --- /dev/null +++ b/modules/vcard/tests/res/raw/v30_adr_types.vcf @@ -0,0 +1,8 @@ +BEGIN:VCARD +VERSION:3.0 +N:Familyname;Givenname;;; +FN:Givenname Familyname +ADR;TYPE=WORK:;;1010 Technology Pkwy\, Silicon City\, Somecountry;;;; +ADR;TYPE=X-Other:;;123 Main St\, Anytown\, Anywhere;;;; +ADR;TYPE=X-School:;;112358 Academic Lane\, College Town\, Someplace Great;;;; +END:VCARD diff --git a/modules/vcard/tests/res/raw/v30_comma_separated.vcf b/modules/vcard/tests/res/raw/v30_comma_separated.vcf new file mode 100644 index 0000000..b2afa84 --- /dev/null +++ b/modules/vcard/tests/res/raw/v30_comma_separated.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:3.0 +N;TYPE=PREF,HOME:F;G;M;; +TEL;TYPE="COMMA,SEPARATED:INSIDE.DQUOTE",PREF:1 +END:VCARD diff --git a/modules/vcard/tests/res/raw/v30_complicated.vcf b/modules/vcard/tests/res/raw/v30_complicated.vcf new file mode 100644 index 0000000..a8547ee --- /dev/null +++ b/modules/vcard/tests/res/raw/v30_complicated.vcf @@ -0,0 +1,107 @@ +BEGIN:VCARD +VERSION:3.0 +N:Gump;Forrest;Hoge;Pos;Tao +FN:Joe Due +ORG:Gump Shrimp Co.;Sales Dept.\;Manager;Fish keeper +ROLE:Fish Cake Keeper! +X-CLASS:PUBLIC +TITLE:Shrimp Man +TEL;WORK;VOICE:(111) 555-1212 +TEL;HOME;VOICE:(404) 555-1212 +TEL;FAX:(404) 555-1212P*#55 +TEL;CELL:0311111111 +TEL;VIDEO:0322222222 +TEL;VOICE:0333333333 +ADR;WORK:;;100 Waters Edge;Baytown;LA;30314;United States of America +LABEL;WORK;ENCODING=QUOTED-PRINTABLE:100 Waters Edge=0D=0ABaytown, LA 30314=0D=0AUnited States of America +ADR;HOME:;;42 Plantation St.;Baytown;LA;30314;United States of America +LABEL;HOME;ENCODING=QUOTED-PRINTABLE:42 Plantation St.=0D=0A= +Baytown, LA 30314=0D=0A= +United States of America +EMAIL;PREF;INTERNET:forrestgump@walladalla.com +EMAIL;CELL:cell@example.com +NOTE:The following note is the example from RFC 2045. +NOTE;ENCODING=QUOTED-PRINTABLE:Now's the time = +for all folk to come= + to the aid of their country. + +PHOTO;ENCODING=BASE64;TYPE=JPEG: + /9j/4QoPRXhpZgAATU0AKgAAAAgADQEOAAIAAAAPAAAAqgEPAAIAAAAHAAAAugEQAAIAAAAG + AAAAwgESAAMAAAABAAEAAAEaAAUAAAABAAAAyAEbAAUAAAABAAAA0AEoAAMAAAABAAIAAAEx + AAIAAAAOAAAA2AEyAAIAAAAUAAAA5gITAAMAAAABAAEAAIKYAAIAAAAOAAAA+odpAAQAAAAB + AAABhMSlAAcAAAB8AAABCAAABB4yMDA4MTAyOTEzNTUzMQAARG9Db01vAABEOTA1aQAAAABI + AAAAAQAAAEgAAAABRDkwNWkgVmVyMS4wMAAyMDA4OjEwOjI5IDEzOjU1OjQ3ACAgICAgICAg + ICAgICAAUHJpbnRJTQAwMzAwAAAABgABABQAFAACAQAAAAADAAAANAEABQAAAAEBAQAAAAEQ + gAAAAAAAEQkAACcQAAAPCwAAJxAAAAWXAAAnEAAACLAAACcQAAAcAQAAJxAAAAJeAAAnEAAA + AIsAACcQAAADywAAJxAAABvlAAAnEAAogpoABQAAAAEAAANqgp0ABQAAAAEAAANyiCIAAwAA + AAEAAgAAkAAABwAAAAQwMjIwkAMAAgAAABQAAAN6kAQAAgAAABQAAAOOkQEABwAAAAQBAgMA + kQIABQAAAAEAAAOikgEACgAAAAEAAAOqkgIABQAAAAEAAAOykgQACgAAAAEAAAO6kgUABQAA + AAEAAAPCkgcAAwAAAAEAAgAAkggAAwAAAAEAAAAAkgkAAwAAAAEAAAAAkgoABQAAAAEAAAPK + knwABwAAAAEAAAAAkoYABwAAABYAAAPSoAAABwAAAAQwMTAwoAEAAwAAAAEAAQAAoAIAAwAA + AAEAYAAAoAMAAwAAAAEASAAAoAUABAAAAAEAAAQAog4ABQAAAAEAAAPoog8ABQAAAAEAAAPw + ohAAAwAAAAEAAgAAohcAAwAAAAEAAgAAowAABwAAAAEDAAAAowEABwAAAAEBAAAApAEAAwAA + AAEAAAAApAIAAwAAAAEAAAAApAMAAwAAAAEAAAAApAQABQAAAAEAAAP4pAUAAwAAAAEAHQAA + pAYAAwAAAAEAAAAApAcAAwAAAAEAAAAApAgAAwAAAAEAAAAApAkAAwAAAAEAAAAApAoAAwAA + AAEAAAAApAwAAwAAAAEAAgAAAAAAAAAAAFMAACcQAAABXgAAAGQyMDA4OjEwOjI5IDEzOjU1 + OjMxADIwMDg6MTA6MjkgMTM6NTU6NDcAAAApiAAAGwAAAAKyAAAAZAAAAV4AAABkAAAAAAAA + AGQAAAAlAAAACgAADpIAAAPoAAAAAAAAAAAyMDA4MTAyOTEzNTUzMQAAICoAAAAKAAAq4gAA + AAoAAAAAAAAAAQACAAEAAgAAAARSOTgAAAIABwAAAAQwMTAwAAAAAAAGAQMAAwAAAAEABgAA + ARoABQAAAAEAAARsARsABQAAAAEAAAR0ASgAAwAAAAEAAgAAAgEABAAAAAEAAAR8AgIABAAA + AAEAAAWLAAAAAAAAAEgAAAABAAAASAAAAAH/2P/bAIQAIBYYHBgUIBwaHCQiICYwUDQwLCww + YkZKOlB0Znp4cmZwboCQuJyAiK6KbnCg2qKuvsTO0M58muLy4MjwuMrOxgEiJCQwKjBeNDRe + xoRwhMbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbG + /8AAEQgAeACgAwEhAAIRAQMRAf/EAaIAAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKCxAA + AgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK + FhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG + h4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl + 5ufo6erx8vP09fb3+Pn6AQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgsRAAIBAgQEAwQH + BQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBka + JicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKT + lJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz + 9PX29/j5+v/aAAwDAQACEQMRAD8AFFSqKkZIoqRVpgSKKeBTEOApwFADsUYpgIRSEUANIppF + ICNhUTCgCMio2FICJhULCgC0oqVaAJFFSqKBkgFOApiHCnCgB2KMUCENJQA0imEUDGMKiYUA + RtUbUgIWqJhQBZSpFoAlWpVoGPFPFMQ7tSK2ODQA4yKO9HmKe9FxAzDHFIOlAAaYaAGNUTUD + ImqNqQETVE1AE6VKKAJFNSqaAHg08GmANIFFQM5Y5qJMBuT60ZNQIcrkVYSQMKuLGKaaasQx + qiagZE1RtSAjaomoAkQ1KpoAlU1IpoAkU07OBTArO+5qkV12Y71lfUBmaKkCRSuznrTFba2a + oCwGyM0E1qIjY1GxoGRNUZNICNqiagByGplNAEimpFNMB4YDvSucpxSYEIU04KazsAu1qArU + WELtPpTSposBNETt5pxNaoCNjUbGgCNjUZoGRtUTUgFU1KpoAkBqQHigCFnO7rUqOdlZp6gA + c+tODn1pXAXzD60eYfWncQvmNSGQ07gOMhCVEJGz1ptgS5yKYxqwGE1GxoAiamGkMapqVTQB + Kpp+eKAICfmqWM/Kaz6gANOBqQFzRmmAuaTNACsfkqMHmm9wJs8U0mtRDGNRsaAI2phpDI1N + SqaAJFNSA8UCISfmqSM/Kaz6jAHmnA1ICg0uaAFzSZpgKx+SmDrTe4E2eKaTWoiMmmMaAIzT + DSGRKakU0ASKaeDTERseakjPyms+oxAacDUgOBpc0gFzSZpgOY/KKYv3qrqIlpprQBjGoyaA + GGmmkMgU1IppgPBqQGgQu0Gn4wvFKwEQpwNZDHZpc0ALmigRKBleaQKBWtgA001QDGqM0gGm + mGkMrqakBoAepp4NMRIDTwaAE2A008GokgHxjd1pzKFpW0uAg5NSBBTirgOpDWgDTTTQAw0w + 0gGGmmgZWBp4pASKaeDTEOBp4NADwajbrUyBEkXWnSUdAGr1qeiAMSkNWAhphoAaaYaQDDTT + SGVRTwaYDxTwaBDwaeDQA4GlK5oauIeo20pGaLaAKqgU6hKwBSGmAhphoAaaYaQxhpppDKgN + PFMB4p4oEPFOBpgPBp4NAhwpwoAWloAKSgBDTTQMYaYaQDTTTSGA/9n/2wCEAAoHBwgHBgoI + CAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9 + PjsBCgsLDg0OHBAQHDsoIig7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7 + Ozs7Ozs7Ozs7Ozs7O//AABEIAEgAYAMBIQACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAA + AQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNC + scEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hp + anN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS + 09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcI + CQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVi + ctEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4 + eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY + 2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AJ7SLgcVr20I4rNFGvbQAAHFaEUX + SrQi5HCMdKk8oY6VSJYx4hjpVaWMelAFC4iGDxWPdR8mkxmRdxjBrEvI+tZjN20xtHNbNqAc + UIDXg6Cr0WKtCY8XKQOyzOB3FKNWsyceZ+lS6sY6NkjvPSdwImBHUmmy4q076oCjOODWPdgc + 0MpGPdAYNYl4o5rNjNKzkyorZtXxihAa1vIDip7m9Frb7/4jwKcnyxbEzN3ieJppZsyZ4U1H + urzZau4mWVlNrGk0UuWPVa1YroXEIkHfrXZh5W90RWncAHmsi6bJNdQ0ZNw3BrGuiMGs2Mks + puBzWzbzdOaEBeOpR2oUtkk9hTru7iuo4m8wgemKyqTi04sBsfkEf68j8KlUQZz9o/SuZRj3 + JYriAji4/Sp7W6htbV2aXcu70ramoxle4gN7HcIXjbis+4k5NdaaauhmVcv1rHuW61DGiG1m + 6c1s20/TmgAv5vmj57VKk3+ixnPc1xVV70h9CVJuOtSrL71hFgxzScUkkn+iY/2q1i9xDrGT + 9y31pJ5Otd1L+GhMy7mTrWXO2SapjRn28vTmta3nxjmgGOvJd2w1Kkv+ipz/ABGuOoveYdCe + ObjrU6y5rlsA8ycUksn+ij/eNaw6iJLNsW59zTJn6816FP4EJmbO+Saz5m602UjIgk4HNadv + LwKaBl+MpIMOMipp490SCJeF7CoqQvF2JuRqWQ4YEGrSiQJuKnHrXByMpki73GFBNXIoh9n2 + SrnnOK6MPTbd3sSwIVF2qMCqkzHmuy1lYRnTHrVGWpZaMKB+BWlbycYoQM0IZDxzV+GU8c1a + IYy5Y+dnHatAsfsAHfArmS1mPoh1gT8x9qtk1rQX7tCe5DIapzGtGBQm71SlqGWjnIH6Vowt + zmhAy/E3vV6F6tEMuxlWIyAfrVxCCAO1VZEEyYA4AApxNGwyJ+lVJRUsaKMw61SlFQzRAP/Z + +X-ATTRIBUTE:Some String +BDAY:19800101 +GEO:35.6563854,139.6994233 +URL:http://www.example.com/ +REV:20080424T195243Z +END:VCARD diff --git a/modules/vcard/tests/res/raw/v30_ios_613_multiline.vcf b/modules/vcard/tests/res/raw/v30_ios_613_multiline.vcf new file mode 100644 index 0000000..4e737bd --- /dev/null +++ b/modules/vcard/tests/res/raw/v30_ios_613_multiline.vcf @@ -0,0 +1,104 @@ +BEGIN:VCARD +VERSION:3.0 +N:Smith;Bob;;; +FN:Bob Smith +ORG:Smith Auto Repair; +item1.EMAIL;type=INTERNET;type=pref:bob@smithauto.com +TEL;type=CELL;type=VOICE;type=pref:(650) 555-1212 +item2.URL;type=pref:www.smithauto.com +item2.X-ABLabel:_$!!$_ +PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD/4gxYSUNDX1BST0ZJTEUA + AQEAAAxITGlubwIQAABtbnRyUkdCIFhZWiAHzgACAAkABgAxAABhY3NwTVNGVAAAAABJRUMgc1 + JHQgAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAABFjcHJ0AAABUAAAADNkZXNjAAABhAAAAGx3dHB0AAAB8A + AAABRia3B0AAACBAAAABRyWFlaAAACGAAAABRnWFlaAAACLAAAABRiWFlaAAACQAAAABRkbW5k + AAACVAAAAHBkbWRkAAACxAAAAIh2dWVkAAADTAAAAIZ2aWV3AAAD1AAAACRsdW1pAAAD+AAAAB + RtZWFzAAAEDAAAACR0ZWNoAAAEMAAAAAxyVFJDAAAEPAAACAxnVFJDAAAEPAAACAxiVFJDAAAE + PAAACAx0ZXh0AAAAAENvcHlyaWdodCAoYykgMTk5OCBIZXdsZXR0LVBhY2thcmQgQ29tcGFueQ + AAZGVzYwAAAAAAAAASc1JHQiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAABJzUkdCIElFQzYxOTY2 + LTIuMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWF + laIAAAAAAAAPNRAAEAAAABFsxYWVogAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAABvogAAOPUA + AAOQWFlaIAAAAAAAAGKZAAC3hQAAGNpYWVogAAAAAAAAJKAAAA+EAAC2z2Rlc2MAAAAAAAAAFk + lFQyBodHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAFklFQyBodHRwOi8vd3d3LmllYy5jaAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABkZXNjAAAAAAAAAC + 5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAtIHNSR0IAAAAAAAAAAAAA + AC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAtIHNSR0IAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAsUmVmZXJlbmNlIFZpZXdpbmcgQ29uZGl0aW9uIGlu + IElFQzYxOTY2LTIuMQAAAAAAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENvbmRpdGlvbiBpbi + BJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZpZXcAAAAAABOk/gAUXy4A + EM8UAAPtzAAEEwsAA1yeAAAAAVhZWiAAAAAAAEwJVgBQAAAAVx/nbWVhcwAAAAAAAAABAAAAAA + AAAAAAAAAAAAAAAAAAAo8AAAACc2lnIAAAAABDUlQgY3VydgAAAAAAAAQAAAAABQAKAA8AFAAZ + AB4AIwAoAC0AMgA3ADsAQABFAEoATwBUAFkAXgBjAGgAbQByAHcAfACBAIYAiwCQAJUAmgCfAK + QAqQCuALIAtwC8AMEAxgDLANAA1QDbAOAA5QDrAPAA9gD7AQEBBwENARMBGQEfASUBKwEyATgB + PgFFAUwBUgFZAWABZwFuAXUBfAGDAYsBkgGaAaEBqQGxAbkBwQHJAdEB2QHhAekB8gH6AgMCDA + IUAh0CJgIvAjgCQQJLAlQCXQJnAnECegKEAo4CmAKiAqwCtgLBAssC1QLgAusC9QMAAwsDFgMh + Ay0DOANDA08DWgNmA3IDfgOKA5YDogOuA7oDxwPTA+AD7AP5BAYEEwQgBC0EOwRIBFUEYwRxBH + 4EjASaBKgEtgTEBNME4QTwBP4FDQUcBSsFOgVJBVgFZwV3BYYFlgWmBbUFxQXVBeUF9gYGBhYG + JwY3BkgGWQZqBnsGjAadBq8GwAbRBuMG9QcHBxkHKwc9B08HYQd0B4YHmQesB78H0gflB/gICw + gfCDIIRghaCG4IggiWCKoIvgjSCOcI+wkQCSUJOglPCWQJeQmPCaQJugnPCeUJ+woRCicKPQpU + CmoKgQqYCq4KxQrcCvMLCwsiCzkLUQtpC4ALmAuwC8gL4Qv5DBIMKgxDDFwMdQyODKcMwAzZDP + MNDQ0mDUANWg10DY4NqQ3DDd4N+A4TDi4OSQ5kDn8Omw62DtIO7g8JDyUPQQ9eD3oPlg+zD88P + 7BAJECYQQxBhEH4QmxC5ENcQ9RETETERTxFtEYwRqhHJEegSBxImEkUSZBKEEqMSwxLjEwMTIx + NDE2MTgxOkE8UT5RQGFCcUSRRqFIsUrRTOFPAVEhU0FVYVeBWbFb0V4BYDFiYWSRZsFo8WshbW + FvoXHRdBF2UXiReuF9IX9xgbGEAYZRiKGK8Y1Rj6GSAZRRlrGZEZtxndGgQaKhpRGncanhrFGu + wbFBs7G2MbihuyG9ocAhwqHFIcexyjHMwc9R0eHUcdcB2ZHcMd7B4WHkAeah6UHr4e6R8THz4f + aR+UH78f6iAVIEEgbCCYIMQg8CEcIUghdSGhIc4h+yInIlUigiKvIt0jCiM4I2YjlCPCI/AkHy + RNJHwkqyTaJQklOCVoJZclxyX3JicmVyaHJrcm6CcYJ0kneierJ9woDSg/KHEooijUKQYpOClr + KZ0p0CoCKjUqaCqbKs8rAis2K2krnSvRLAUsOSxuLKIs1y0MLUEtdi2rLeEuFi5MLoIuty7uLy + QvWi+RL8cv/jA1MGwwpDDbMRIxSjGCMbox8jIqMmMymzLUMw0zRjN/M7gz8TQrNGU0njTYNRM1 + TTWHNcI1/TY3NnI2rjbpNyQ3YDecN9c4FDhQOIw4yDkFOUI5fzm8Ofk6Njp0OrI67zstO2s7qj + voPCc8ZTykPOM9Ij1hPaE94D4gPmA+oD7gPyE/YT+iP+JAI0BkQKZA50EpQWpBrEHuQjBCckK1 + QvdDOkN9Q8BEA0RHRIpEzkUSRVVFmkXeRiJGZ0arRvBHNUd7R8BIBUhLSJFI10kdSWNJqUnwSj + dKfUrESwxLU0uaS+JMKkxyTLpNAk1KTZNN3E4lTm5Ot08AT0lPk0/dUCdQcVC7UQZRUFGbUeZS + MVJ8UsdTE1NfU6pT9lRCVI9U21UoVXVVwlYPVlxWqVb3V0RXklfgWC9YfVjLWRpZaVm4WgdaVl + qmWvVbRVuVW+VcNVyGXNZdJ114XcleGl5sXr1fD19hX7NgBWBXYKpg/GFPYaJh9WJJYpxi8GND + Y5dj62RAZJRk6WU9ZZJl52Y9ZpJm6Gc9Z5Nn6Wg/aJZo7GlDaZpp8WpIap9q92tPa6dr/2xXbK + 9tCG1gbbluEm5rbsRvHm94b9FwK3CGcOBxOnGVcfByS3KmcwFzXXO4dBR0cHTMdSh1hXXhdj52 + m3b4d1Z3s3gReG54zHkqeYl553pGeqV7BHtje8J8IXyBfOF9QX2hfgF+Yn7CfyN/hH/lgEeAqI + EKgWuBzYIwgpKC9INXg7qEHYSAhOOFR4Wrhg6GcobXhzuHn4gEiGmIzokziZmJ/opkisqLMIuW + i/yMY4zKjTGNmI3/jmaOzo82j56QBpBukNaRP5GokhGSepLjk02TtpQglIqU9JVflcmWNJaflw + qXdZfgmEyYuJkkmZCZ/JpomtWbQpuvnByciZz3nWSd0p5Anq6fHZ+Ln/qgaaDYoUehtqImopaj + BqN2o+akVqTHpTilqaYapoum/adup+CoUqjEqTepqaocqo+rAqt1q+msXKzQrUStuK4trqGvFq + +LsACwdbDqsWCx1rJLssKzOLOutCW0nLUTtYq2AbZ5tvC3aLfguFm40blKucK6O7q1uy67p7wh + vJu9Fb2Pvgq+hL7/v3q/9cBwwOzBZ8Hjwl/C28NYw9TEUcTOxUvFyMZGxsPHQce/yD3IvMk6yb + nKOMq3yzbLtsw1zLXNNc21zjbOts83z7jQOdC60TzRvtI/0sHTRNPG1EnUy9VO1dHWVdbY11zX + 4Nhk2OjZbNnx2nba+9uA3AXcit0Q3ZbeHN6i3ynfr+A24L3hROHM4lPi2+Nj4+vkc+T85YTmDe + aW5x/nqegy6LzpRunQ6lvq5etw6/vshu0R7ZzuKO6070DvzPBY8OXxcvH/8ozzGfOn9DT0wvVQ + 9d72bfb794r4Gfio+Tj5x/pX+uf7d/wH/Jj9Kf26/kv+3P9t////4QBMRXhpZgAATU0AKgAAAA + gAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAMqADAAQAAAABAAAA + MgAAAAD/2wBDAAIBAQIBAQICAQICAgICAwUDAwMDAwYEBAMFBwYHBwcGBgYHCAsJBwgKCAYGCQ + 0JCgsLDAwMBwkNDg0MDgsMDAv/2wBDAQICAgMCAwUDAwULCAYICwsLCwsLCwsLCwsLCwsLCwsL + CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwv/wAARCAAyADIDASIAAhEBAxEB/8QAHw + AAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQR + BRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRk + dISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKz + tLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQ + EBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJB + UQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1 + RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3 + uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD9/K + 4L9oT9oLR/2efBo1PxGr3V3csYrGyjYCS6kAyeT91RwWbnGRwSQD1eqeMtI0PVbax1rVNOs769 + IFvbz3KRyz5OBsQnLcgjgV8u/tsfBi9+LXxcink8a+B9GtdPsUggstV1XyJ4ySXdzHsON2V5zy + FFfHca53i8qyurUyqKniLqK1Xut9Wm7aLVJ7u2ljKtNxi+Xc8u+Inxa+K/gvwnpeqw3UvhPwx4 + qnm1HS7XTpk2xhyHZQeZFU7t4UkD5jgDoM34S/HL4weNvGVtonw88Vaxfanfq6xxXU6SJgKWYk + zAhcAE56+nart7+y3qmpWFpa6l8U/hvcWtgCltDL4id47cNyRGpTCg98Ypuk/sq6joGoxXuhfF + H4a2V5Ad0U9v4haKWM9PlZUBHUjj1r+Y54fiGWMp1YyrqkuW6VeKlZJc6i01GKvflXLaKsraHm + 2qXvrb1Po39jv9o2y15T8PfFGm2/h/xJ4eD2ywRybor3y2IkZSST5m4MzAk7slgTzj3+vgj4e/ + s36j4O+JOj67afEr4aSXljqEdySmvFpZjvG5clPmLgsvPXdX27rPxG8P+HdftdK1/W9JstTvsf + Z7Se7SOabPA2oTk5PA9e1f0D4c55ja+Wyo5vFQdJqMW3H3ov4E+XTmVraJX00vc78PNuNpm1RR + RX6YdB+cX7dF5NP+1B4oaaV2a3MCxEtzEBBGQF9MEk/U079tO5k1D4yWs967SzTaDp0ju3JZjD + kk/U1D+3Ef+MnvF3+/D/6Tx0/9sf8A5K3Yf9i9pv8A6Ir+JeIpN1M8Tf8AzEx/9Krnjz+36/5n + lG0ego2D0H5UtFfntjnLnhtAfEumcD/j8h7f9NFrv/2zLuWf9pzxi8sjs8V0io27lAsMe0A9sd + q4Hw1/yMum/wDX3D/6MWu+/bR/5Oc8af8AXzH/AOk8VfRUdMgxH/X6l/6brmi/hv1X6n6PaDM1 + xoVlJMxZ3gRmJ6klRk0U3w3n/hHbD/r2j/8AQRRX960XenH0R7iPNfin+xn4J+L/AI/TxH4sgv + 1vSEFxHb3Hlw3oTgeauCc4AXKkZAr5O/4KDwJbftLXkcCKkcem2iqqjAUBCAAK/QiuH+LvwN8I + /Eq3m1HxzoGn6lf2dq6wzyofMUAEgZBGQCSQDnGa/N+OuAKOe5dWpZbGFKrOcak5NW53FS3aTd + /eb23v3bOetQU4tR0Z+YdFdz+zJ4YsPGvx68K6V4ptY73T767KTwSZ2yqI3bBxz1Ar03/goJ8I + fDfwk8ReGIfhxpFtpMV7bXDzrCG/eFWjCkkk9AT+Z9a/lTC8MYjF5JiM9jOPsqU1BrXmbfLqtL + W95de55apNwc+x4N4a/wCRl03P/P3D/wCjFr9GPiJ+yB4G+KXxCj8S+LtPnk1DKGdI7hkhvNgA + XzUHXgAcYyAAc15l+wd8C/CHib4OaT4k8QaBYXuuQ307JdzKXZSknyHBOOMDHHGK+nK/ofws4C + pUMqlXzOMKtPEezqRi1flspNN3Vr+9bS/XV3PQw1C0Ly1vqIqhVAUAAcADtRS0V+4nYFVtYUNp + N0GAIMLgg9/lNFFZ1vgl6MD82/2POP2mvBX/AF+t/wCiZK9o/wCCpqKNd8FMANxgvATjkgNBj+ + Z/Oiiv5EyP/k3eZ/8AX6H50jyof7vL1/yPWf8AgnuAP2YNJI6m6u8+/wC/evbKKK/pbgf/AJJ7 + L/8ArzT/APSUejR/hx9Aooor6k0P/9k= +END:VCARD diff --git a/modules/vcard/tests/res/raw/v30_japanese.vcf b/modules/vcard/tests/res/raw/v30_japanese.vcf new file mode 100644 index 0000000..8ab7674 --- /dev/null +++ b/modules/vcard/tests/res/raw/v30_japanese.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:3.0 +FN;CHARSET=SHIFT_JIS:ˆÉ”\ Œ\ˆê ˆÉ—\ +N;CHARSET=SHIFT_JIS:ˆÉ”\;Œ\ˆê;ˆÉ—\;; +END:VCARD diff --git a/modules/vcard/tests/res/raw/v30_multibyte_param.vcf b/modules/vcard/tests/res/raw/v30_multibyte_param.vcf new file mode 100644 index 0000000..b3488fc --- /dev/null +++ b/modules/vcard/tests/res/raw/v30_multibyte_param.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:3.0 +N:F;G;M;; +TEL;TYPE="è´¹":1 +END:VCARD diff --git a/modules/vcard/tests/res/raw/v30_pager.vcf b/modules/vcard/tests/res/raw/v30_pager.vcf new file mode 100644 index 0000000..f437943 --- /dev/null +++ b/modules/vcard/tests/res/raw/v30_pager.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:3.0 +N:F;G;M;; +TEL;TYPE=PAGER,WORK,MSG:6101231234@pagersample.com +END:VCARD diff --git a/modules/vcard/tests/res/raw/v30_pause_wait.vcf b/modules/vcard/tests/res/raw/v30_pause_wait.vcf new file mode 100644 index 0000000..e8fc2e9 --- /dev/null +++ b/modules/vcard/tests/res/raw/v30_pause_wait.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:3.0 +FN:Pause Wait +N:Pause;Wait;;; +TEL:p1234p5678w9 +END:VCARD diff --git a/modules/vcard/tests/res/raw/v30_simple.vcf b/modules/vcard/tests/res/raw/v30_simple.vcf new file mode 100644 index 0000000..227f43c --- /dev/null +++ b/modules/vcard/tests/res/raw/v30_simple.vcf @@ -0,0 +1,13 @@ +BEGIN:VCARD +VERSION:3.0 +FN:And Roid +N:And;Roid;;; +ORG:Open;Handset; Alliance +SORT-STRING:android +TEL;TYPE=PREF;TYPE=VOICE:0300000000 +CLASS:PUBLIC +X-GNO:0 +X-GN:group0 +X-REDUCTION:0 +REV:20081031T065854Z +END:VCARD diff --git a/modules/vcard/tests/res/raw/v30_sip.vcf b/modules/vcard/tests/res/raw/v30_sip.vcf new file mode 100644 index 0000000..7a3a62f --- /dev/null +++ b/modules/vcard/tests/res/raw/v30_sip.vcf @@ -0,0 +1,5 @@ +BEGIN:VCARD +VERSION:3.0 +FN:Android +IMPP;TYPE=personal:sip:android@android.example.com +END:VCARD diff --git a/modules/vcard/tests/res/raw/v40_sip.vcf b/modules/vcard/tests/res/raw/v40_sip.vcf new file mode 100644 index 0000000..46914dd --- /dev/null +++ b/modules/vcard/tests/res/raw/v40_sip.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:4.0 +FN:安藤 ロイド +N:安藤;ロイド;;; +TEL;TYPE=HOME:tel:1 +TEL;TYPE=HOME:sip:example@example.com +END:VCARD diff --git a/modules/vcard/tests/res/raw/v40_sort_as.vcf b/modules/vcard/tests/res/raw/v40_sort_as.vcf new file mode 100644 index 0000000..8d4c7af --- /dev/null +++ b/modules/vcard/tests/res/raw/v40_sort_as.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:4.0 +FN:安藤 ロイド +N;SORT-AS="ã�‚ã‚“ã�©ã�†;ã‚�ã�„ã�©":安藤;ロイド;;; +ORG;TYPE=WORK;SORT-AS="ã��ーã��ã‚‹;ã�‘ã‚“ã�•ã��ã�¶ã‚‚ã‚“":グーグル;検索部門 +END:VCARD diff --git a/modules/vcard/tests/src/com/android/vcard/tests/JapaneseUtilsTest.java b/modules/vcard/tests/src/com/android/vcard/tests/JapaneseUtilsTest.java new file mode 100644 index 0000000..452944e --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/JapaneseUtilsTest.java @@ -0,0 +1,31 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.vcard; + +import com.android.vcard.JapaneseUtils; + +import junit.framework.TestCase; + +public class JapaneseUtilsTest extends TestCase { + static final char TEST_CONTAINED_CHAR = '\uFFE5'; + static final char TEST_UNCONTAINED_CHAR = '\uFFF5'; + + public void testTryGetHalfWidthText() { + assertNull(JapaneseUtils.tryGetHalfWidthText(TEST_UNCONTAINED_CHAR)); + assertEquals(JapaneseUtils.tryGetHalfWidthText(TEST_CONTAINED_CHAR), "\u005C\u005C"); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/VCardBuilderTest.java b/modules/vcard/tests/src/com/android/vcard/tests/VCardBuilderTest.java new file mode 100644 index 0000000..f7af0ff --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/VCardBuilderTest.java @@ -0,0 +1,58 @@ +/* + * 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.vcard.tests; + +import android.content.ContentValues; +import android.provider.ContactsContract; + +import com.android.vcard.VCardBuilder; +import com.android.vcard.VCardConfig; +import com.google.android.collect.Lists; + +import junit.framework.TestCase; + +import java.util.ArrayList; + +/** + * Unit test for VCardBuilder. + */ +public class VCardBuilderTest extends TestCase { + + public void testVCardNameFieldFromDisplayName() { + final ArrayList contentList = Lists.newArrayList(); + + final ContentValues values = new ContentValues(); + values.put(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, "ने"); + contentList.add(values); + + final VCardBuilder builder = new VCardBuilder(VCardConfig.VCARD_TYPE_DEFAULT); + builder.appendNameProperties(contentList); + final String actual = builder.toString(); + + final String expectedCommon = ";CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:" + + "=E0=A4=A8=E0=A5=87"; + + final String expectedName = "N" + expectedCommon + ";;;;"; + final String expectedFullName = "FN" + expectedCommon; + + assertTrue("Actual value:\n" + actual + " expected to contain\n" + expectedName + + "\nbut does not.", actual.contains(expectedName)); + assertTrue("Actual value:\n" + actual + " expected to contain\n" + expectedFullName + + "\nbut does not.", actual.contains(expectedFullName)); + } +} + diff --git a/modules/vcard/tests/src/com/android/vcard/tests/VCardEntryTests.java b/modules/vcard/tests/src/com/android/vcard/tests/VCardEntryTests.java new file mode 100644 index 0000000..69bd058 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/VCardEntryTests.java @@ -0,0 +1,436 @@ +/* + * 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.vcard.tests; + +import com.android.vcard.VCardConfig; +import com.android.vcard.VCardConstants; +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardEntry.AndroidCustomData; +import com.android.vcard.VCardEntry.AnniversaryData; +import com.android.vcard.VCardEntry.BirthdayData; +import com.android.vcard.VCardEntry.EmailData; +import com.android.vcard.VCardEntry.EntryElement; +import com.android.vcard.VCardEntry.EntryLabel; +import com.android.vcard.VCardEntry.ImData; +import com.android.vcard.VCardEntry.NameData; +import com.android.vcard.VCardEntry.NicknameData; +import com.android.vcard.VCardEntry.NoteData; +import com.android.vcard.VCardEntry.OrganizationData; +import com.android.vcard.VCardEntry.PhoneData; +import com.android.vcard.VCardEntry.PhotoData; +import com.android.vcard.VCardEntry.PostalData; +import com.android.vcard.VCardEntry.SipData; +import com.android.vcard.VCardEntryConstructor; +import com.android.vcard.VCardEntryHandler; +import com.android.vcard.VCardInterpreter; +import com.android.vcard.VCardProperty; + +import android.accounts.Account; +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.CommonDataKinds.Organization; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; +import android.provider.ContactsContract.CommonDataKinds.SipAddress; +import android.test.AndroidTestCase; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class VCardEntryTests extends AndroidTestCase { + private class MockVCardEntryHandler implements VCardEntryHandler { + private List mEntries = new ArrayList(); + private boolean mOnStartCalled; + private boolean mOnEndCalled; + + @Override + public void onStart() { + assertFalse(mOnStartCalled); + mOnStartCalled = true; + } + + @Override + public void onEntryCreated(VCardEntry entry) { + assertTrue(mOnStartCalled); + assertFalse(mOnEndCalled); + mEntries.add(entry); + } + + @Override + public void onEnd() { + assertTrue(mOnStartCalled); + assertFalse(mOnEndCalled); + mOnEndCalled = true; + } + + public List getEntries() { + return mEntries; + } + } + + /** + * Tests VCardEntry and related clasess can handle nested classes given + * {@link VCardInterpreter} is called appropriately. + * + * This test manually calls VCardInterpreter's callback mechanism and checks + * {@link VCardEntryConstructor} constructs {@link VCardEntry} per given calls. + * + * Intended vCard is as follows: + * + * BEGIN:VCARD + * N:test1 + * BEGIN:VCARD + * N:test2 + * END:VCARD + * TEL:1 + * END:VCARD + * + */ + public void testNestHandling() { + VCardEntryConstructor entryConstructor = new VCardEntryConstructor(); + MockVCardEntryHandler entryHandler = new MockVCardEntryHandler(); + entryConstructor.addEntryHandler(entryHandler); + + entryConstructor.onVCardStarted(); + entryConstructor.onEntryStarted(); + VCardProperty property = new VCardProperty(); + property.setName(VCardConstants.PROPERTY_N); + property.setValues("test1"); + entryConstructor.onPropertyCreated(property); + + entryConstructor.onEntryStarted(); // begin nest + property = new VCardProperty(); + property.setName(VCardConstants.PROPERTY_N); + property.setValues("test2"); + entryConstructor.onPropertyCreated(property); + entryConstructor.onEntryEnded(); // end nest + + property = new VCardProperty(); + property.setName(VCardConstants.PROPERTY_TEL); + property.setValues("1"); + entryConstructor.onPropertyCreated(property); + entryConstructor.onEntryEnded(); + entryConstructor.onVCardEnded(); + + List entries = entryHandler.getEntries(); + assertEquals(2, entries.size()); + VCardEntry parent = entries.get(1); + VCardEntry child = entries.get(0); + assertEquals("test1", parent.getDisplayName()); + assertEquals("test2", child.getDisplayName()); + List phoneList = parent.getPhoneList(); + assertNotNull(phoneList); + assertEquals(1, phoneList.size()); + assertEquals("1", phoneList.get(0).getNumber()); + } + + private class MockEntryElementIterator implements VCardEntry.EntryElementIterator { + private boolean mStartCalled; + private boolean mEndCalled; + private EntryLabel mLabel; + private final Map mExpectedElements = + new HashMap(); + + public void addExpectedElement(EntryElement elem) { + mExpectedElements.put(elem.getEntryLabel(), elem); + } + + @Override + public void onIterationStarted() { + assertFalse(mStartCalled); + assertFalse(mEndCalled); + assertNull(mLabel); + mStartCalled = true; + } + + @Override + public void onIterationEnded() { + assertTrue(mStartCalled); + assertFalse(mEndCalled); + assertNull(mLabel); + assertTrue("Expected Elements remaining: " + + Arrays.toString(mExpectedElements.values().toArray()), + mExpectedElements.isEmpty()); + } + + @Override + public void onElementGroupStarted(EntryLabel label) { + assertTrue(mStartCalled); + assertFalse(mEndCalled); + assertNull(mLabel); + mLabel = label; + } + + @Override + public void onElementGroupEnded() { + assertTrue(mStartCalled); + assertFalse(mEndCalled); + assertNotNull(mLabel); + mLabel = null; + } + + @Override + public boolean onElement(EntryElement elem) { + EntryElement expectedElem = mExpectedElements.remove(elem.getEntryLabel()); + assertNotNull("Unexpected elem: " + elem.toString(), expectedElem); + assertEquals(expectedElem, elem); + return true; + } + } + + /** + * Tests every element in VCardEntry is iterated by + * {@link VCardEntry#iterateAllData(com.android.vcard.VCardEntry.EntryElementIterator)}. + */ + public void testEntryElementIterator() { + VCardEntry entry = new VCardEntry(); + MockEntryElementIterator iterator = new MockEntryElementIterator(); + + VCardProperty property = new VCardProperty(); + property.setName("N"); + property.setValues("family", "given", "middle", "prefix", "suffix"); + entry.addProperty(property); + NameData nameData = new NameData(); + nameData.setFamily("family"); + nameData.setGiven("given"); + nameData.setMiddle("middle"); + nameData.setPrefix("prefix"); + nameData.setSuffix("suffix"); + iterator.addExpectedElement(nameData); + + property = new VCardProperty(); + property.setName("TEL"); + property.setParameter("TYPE", "HOME"); + property.setValues("1"); + entry.addProperty(property); + PhoneData phoneData = new PhoneData("1", Phone.TYPE_HOME, null, false); + iterator.addExpectedElement(phoneData); + + property = new VCardProperty(); + property.setName("EMAIL"); + property.setParameter("TYPE", "WORK"); + property.setValues("email"); + entry.addProperty(property); + EmailData emailData = new EmailData("email", Email.TYPE_WORK, null, false); + iterator.addExpectedElement(emailData); + + property = new VCardProperty(); + property.setName("ADR"); + property.setParameter("TYPE", "HOME"); + property.setValues(null, null, "street"); + entry.addProperty(property); + PostalData postalData = new PostalData(null, null, "street", null, null, null, + null, StructuredPostal.TYPE_HOME, null, false, + VCardConfig.VCARD_TYPE_DEFAULT); + iterator.addExpectedElement(postalData); + + property = new VCardProperty(); + property.setName("ORG"); + property.setValues("organization", "depertment"); + entry.addProperty(property); + OrganizationData organizationData = new OrganizationData( + "organization", "depertment", null, null, Organization.TYPE_WORK, false); + iterator.addExpectedElement(organizationData); + + property = new VCardProperty(); + property.setName("X-GOOGLE-TALK"); + property.setParameter("TYPE", "WORK"); + property.setValues("googletalk"); + entry.addProperty(property); + ImData imData = new ImData( + Im.PROTOCOL_GOOGLE_TALK, null, "googletalk", Im.TYPE_WORK, false); + iterator.addExpectedElement(imData); + + property = new VCardProperty(); + property.setName("PHOTO"); + property.setParameter("TYPE", "PNG"); + byte[] photoBytes = new byte[] {1}; + property.setByteValue(photoBytes); + entry.addProperty(property); + PhotoData photoData = new PhotoData("PNG", photoBytes, false); + iterator.addExpectedElement(photoData); + + property = new VCardProperty(); + property.setName("X-SIP"); + property.setValues("sipdata"); + entry.addProperty(property); + SipData sipData = new SipData("sip:sipdata", SipAddress.TYPE_OTHER, null, false); + iterator.addExpectedElement(sipData); + + property = new VCardProperty(); + property.setName("NICKNAME"); + property.setValues("nickname"); + entry.addProperty(property); + NicknameData nicknameData = new NicknameData("nickname"); + iterator.addExpectedElement(nicknameData); + + property = new VCardProperty(); + property.setName("NOTE"); + property.setValues("note"); + entry.addProperty(property); + NoteData noteData = new NoteData("note"); + iterator.addExpectedElement(noteData); + + property = new VCardProperty(); + property.setName("BDAY"); + property.setValues("birthday"); + entry.addProperty(property); + BirthdayData birthdayData = new BirthdayData("birthday"); + iterator.addExpectedElement(birthdayData); + + property = new VCardProperty(); + property.setName("ANNIVERSARY"); + property.setValues("anniversary"); + entry.addProperty(property); + AnniversaryData anniversaryData = new AnniversaryData("anniversary"); + iterator.addExpectedElement(anniversaryData); + + property = new VCardProperty(); + property.setName("X-ANDROID-CUSTOM"); + property.setValues("mime;value"); + entry.addProperty(property); + AndroidCustomData androidCustom = new AndroidCustomData("mime", Arrays.asList("value")); + iterator.addExpectedElement(androidCustom); + + entry.iterateAllData(iterator); + } + + public void testToString() { + VCardEntry entry = new VCardEntry(); + VCardProperty property = new VCardProperty(); + property.setName("N"); + property.setValues("Family", "Given", "Middle", "Prefix", "Suffix"); + entry.addProperty(property); + entry.consolidateFields(); + + String result = entry.toString(); + assertNotNull(result); + + assertTrue(result.contains(String.valueOf(entry.hashCode()))); + assertTrue(result.contains(VCardEntry.EntryLabel.NAME.toString())); + assertTrue(result.contains("Family")); + assertTrue(result.contains("Given")); + assertTrue(result.contains("Middle")); + assertTrue(result.contains("Prefix")); + assertTrue(result.contains("Suffix")); + } + + /** + * Tests that VCardEntry emits correct insert operation for name field. + */ + public void testConstructInsertOperationsInsertName() { + VCardEntry entry = new VCardEntry(); + VCardProperty property = new VCardProperty(); + property.setName("N"); + property.setValues("Family", "Given", "Middle", "Prefix", "Suffix"); + entry.addProperty(property); + entry.consolidateFields(); + + NameData nameData = entry.getNameData(); + assertEquals("Family", nameData.getFamily()); + assertEquals("Given", nameData.getGiven()); + assertEquals("Middle", nameData.getMiddle()); + assertEquals("Prefix", nameData.getPrefix()); + assertEquals("Suffix", nameData.getSuffix()); + + ContentResolver resolver = getContext().getContentResolver(); + ArrayList operationList = + new ArrayList(); + entry.constructInsertOperations(resolver, operationList); + + // Need too many details for testing these. Just check basics. + // TODO: introduce nice-to-test mechanism here. + assertEquals(2, operationList.size()); + assertEquals(ContentProviderOperation.TYPE_INSERT, operationList.get(0).getType()); + assertEquals(ContentProviderOperation.TYPE_INSERT, operationList.get(1).getType()); + } + + /** + * Tests that VCardEntry refrains from emitting unnecessary insert operation. + */ + public void testConstructInsertOperationsEmptyData() { + VCardEntry entry = new VCardEntry(); + ContentResolver resolver = getContext().getContentResolver(); + ArrayList operationList = + new ArrayList(); + entry.constructInsertOperations(resolver, operationList); + assertEquals(0, operationList.size()); + } + + /** + * Tests that VCardEntry can add an account via the constructor + */ + public void testConstructor_withAccount_canGetAccount() { + Account account = new Account("test-type", "test-name"); + VCardEntry entry = new VCardEntry(VCardConfig.VCARD_TYPE_V21_GENERIC, account); + assertEquals(entry.getAccount(), account); + } + + /** + * Tests that VCardEntry can add a null account via the constructor + */ + public void testConstructor_withNullAccount_canGetNullAccount() { + VCardEntry entry = new VCardEntry(VCardConfig.VCARD_TYPE_V21_GENERIC, null); + assertEquals(entry.getAccount(), null); + } + + /** + * Tests that VCardEntry can add an account via the setter, updating a null account to a value + */ + public void testSetAccount_fromNullToValue_canGetAccount() { + Account account = new Account("test-type", "test-name"); + VCardEntry entry = new VCardEntry(); + assertEquals(entry.getAccount(), null); + entry.setAccount(account); + assertEquals(entry.getAccount(), account); + } + + /** + * Tests that VCardEntry can add an account via the setter, changing one account to another + */ + public void testSetAccount_fromToNewValue_canGetAccount() { + Account account1 = new Account("test-type-1", "test-name-1"); + VCardEntry entry = new VCardEntry(VCardConfig.VCARD_TYPE_V21_GENERIC, account1); + assertEquals(entry.getAccount(), account1); + + Account account2 = new Account("test-type-2", "test-name-2"); + entry.setAccount(account2); + assertEquals(entry.getAccount(), account2); + } + + /** + * Tests that VCardEntry can add an account via the setter, changing one account to another + */ + public void testSetAccount_setValueTwice_canGetAccount() { + VCardEntry entry = new VCardEntry(); + assertEquals(entry.getAccount(), null); + + Account account1 = new Account("test-type-1", "test-name-1"); + entry.setAccount(account1); + assertEquals(entry.getAccount(), account1); + + Account account2 = new Account("test-type-2", "test-name-2"); + entry.setAccount(account2); + assertEquals(entry.getAccount(), account2); + } + + // TODO: add bunch of test for constructInsertOperations.. +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/VCardExporterTests.java b/modules/vcard/tests/src/com/android/vcard/tests/VCardExporterTests.java new file mode 100644 index 0000000..910e3f7 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/VCardExporterTests.java @@ -0,0 +1,1418 @@ +/* + * 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.vcard.tests; + +import com.android.vcard.VCardConfig; +import com.android.vcard.VCardConstants; +import com.android.vcard.tests.testutils.ContactEntry; +import com.android.vcard.tests.testutils.PropertyNodesVerifierElem; +import com.android.vcard.tests.testutils.PropertyNodesVerifierElem.TypeSet; +import com.android.vcard.tests.testutils.VCardTestsBase; + +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.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 java.util.Arrays; + +/** + * Tests for the code related to vCard exporter, inculding vCard composer. + * This test class depends on vCard importer code, so if tests for vCard importer fail, + * the result of this class will not be reliable. + */ +public class VCardExporterTests extends VCardTestsBase { + private static final byte[] sPhotoByteArray = + VCardImporterTests.sPhotoByteArrayForComplicatedCase; + + public void testSimpleV21() { + mVerifier.initForExportTest(V21); + mVerifier.addInputEntry().addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "Ando") + .put(StructuredName.GIVEN_NAME, "Roid"); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNode("FN", "Roid Ando") + .addExpectedNode("N", "Ando;Roid;;;", + Arrays.asList("Ando", "Roid", "", "", "")); + } + + private void testStructuredNameBasic(int vcardType) { + mVerifier.initForExportTest(vcardType); + mVerifier.addInputEntry().addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "AppropriateFamilyName") + .put(StructuredName.GIVEN_NAME, "AppropriateGivenName") + .put(StructuredName.MIDDLE_NAME, "AppropriateMiddleName") + .put(StructuredName.PREFIX, "AppropriatePrefix") + .put(StructuredName.SUFFIX, "AppropriateSuffix") + .put(StructuredName.DISPLAY_NAME, "DISPLAY NAME"); + + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", + "AppropriateFamilyName;AppropriateGivenName;AppropriateMiddleName;" + + "AppropriatePrefix;AppropriateSuffix", + Arrays.asList("AppropriateFamilyName", "AppropriateGivenName", + "AppropriateMiddleName", "AppropriatePrefix", "AppropriateSuffix")) + .addExpectedNodeWithOrder("FN", "DISPLAY NAME"); + } + + public void testStructuredNameBasicV21() { + testStructuredNameBasic(V21); + } + + public void testStructuredNameBasicV30() { + testStructuredNameBasic(V30); + } + + public void testStructuredNameBasicV40() { + testStructuredNameBasic(V40); + } + + /** + * Test that only "primary" StructuredName is emitted, so that our vCard file + * will not confuse the external importer, assuming there may be some importer + * which presume that there's only one property toward each of "N", "FN", etc. + * Note that more than one "N", "FN", etc. properties are acceptable in vCard spec. + */ + private void testStructuredNameUsePrimaryCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + final ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "DoNotEmitFamilyName1") + .put(StructuredName.GIVEN_NAME, "DoNotEmitGivenName1") + .put(StructuredName.MIDDLE_NAME, "DoNotEmitMiddleName1") + .put(StructuredName.PREFIX, "DoNotEmitPrefix1") + .put(StructuredName.SUFFIX, "DoNotEmitSuffix1") + .put(StructuredName.DISPLAY_NAME, "DoNotEmitDisplayName1"); + + // With "IS_PRIMARY=1". This is what we should use. + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "AppropriateFamilyName") + .put(StructuredName.GIVEN_NAME, "AppropriateGivenName") + .put(StructuredName.MIDDLE_NAME, "AppropriateMiddleName") + .put(StructuredName.PREFIX, "AppropriatePrefix") + .put(StructuredName.SUFFIX, "AppropriateSuffix") + .put(StructuredName.DISPLAY_NAME, "AppropriateDisplayName") + .put(StructuredName.IS_PRIMARY, 1); + + // With "IS_PRIMARY=1", but we should ignore this time, since this is second, not first. + // vCard 2.1 does not specify anything about the number of N properties. We choose not + // emitting this property. + // vCard 3.0 does (There must be one N property) + // vCard 4.0 (rev13) does (cardinality (0, 1)). + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "DoNotEmitFamilyName2") + .put(StructuredName.GIVEN_NAME, "DoNotEmitGivenName2") + .put(StructuredName.MIDDLE_NAME, "DoNotEmitMiddleName2") + .put(StructuredName.PREFIX, "DoNotEmitPrefix2") + .put(StructuredName.SUFFIX, "DoNotEmitSuffix2") + .put(StructuredName.DISPLAY_NAME, "DoNotEmitDisplayName2") + .put(StructuredName.IS_PRIMARY, 1); + + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", + "AppropriateFamilyName;AppropriateGivenName;AppropriateMiddleName;" + + "AppropriatePrefix;AppropriateSuffix", + Arrays.asList("AppropriateFamilyName", "AppropriateGivenName", + "AppropriateMiddleName", "AppropriatePrefix", "AppropriateSuffix")) + .addExpectedNodeWithOrder("FN", "AppropriateDisplayName"); + } + + public void testStructuredNameUsePrimaryV21() { + testStructuredNameUsePrimaryCommon(V21); + } + + public void testStructuredNameUsePrimaryV30() { + testStructuredNameUsePrimaryCommon(V30); + } + + public void testStructuredNameUsePrimaryV40() { + testStructuredNameUsePrimaryCommon(V40); + } + + /** + * Tests that only "super primary" StructuredName is emitted. + * See also the comment in {@link #testStructuredNameUsePrimaryCommon(int)}. + */ + private void testStructuredNameUseSuperPrimaryCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + final ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "DoNotEmitFamilyName1") + .put(StructuredName.GIVEN_NAME, "DoNotEmitGivenName1") + .put(StructuredName.MIDDLE_NAME, "DoNotEmitMiddleName1") + .put(StructuredName.PREFIX, "DoNotEmitPrefix1") + .put(StructuredName.SUFFIX, "DoNotEmitSuffix1") + .put(StructuredName.DISPLAY_NAME, "DoNotEmitDisplay1"); + + // With "IS_PRIMARY=1", but we should ignore this time. + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "DoNotEmitFamilyName2") + .put(StructuredName.GIVEN_NAME, "DoNotEmitGivenName2") + .put(StructuredName.MIDDLE_NAME, "DoNotEmitMiddleName2") + .put(StructuredName.PREFIX, "DoNotEmitPrefix2") + .put(StructuredName.SUFFIX, "DoNotEmitSuffix2") + .put(StructuredName.DISPLAY_NAME, "DoNotEmitDisplay2") + .put(StructuredName.IS_PRIMARY, 1); + + // With "IS_SUPER_PRIMARY=1". This is what we should use. + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "AppropriateFamilyName") + .put(StructuredName.GIVEN_NAME, "AppropriateGivenName") + .put(StructuredName.MIDDLE_NAME, "AppropriateMiddleName") + .put(StructuredName.PREFIX, "AppropriatePrefix") + .put(StructuredName.SUFFIX, "AppropriateSuffix") + .put(StructuredName.DISPLAY_NAME, "AppropriateDisplayName") + .put(StructuredName.IS_SUPER_PRIMARY, 1); + + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "DoNotEmitFamilyName3") + .put(StructuredName.GIVEN_NAME, "DoNotEmitGivenName3") + .put(StructuredName.MIDDLE_NAME, "DoNotEmitMiddleName3") + .put(StructuredName.PREFIX, "DoNotEmitPrefix3") + .put(StructuredName.SUFFIX, "DoNotEmitSuffix3") + .put(StructuredName.DISPLAY_NAME, "DoNotEmitDisplay3") + .put(StructuredName.IS_PRIMARY, 1); + + final PropertyNodesVerifierElem elem = mVerifier.addPropertyNodesVerifierElem(); + elem.addExpectedNodeWithOrder("N", + "AppropriateFamilyName;AppropriateGivenName;AppropriateMiddleName;" + + "AppropriatePrefix;AppropriateSuffix", + Arrays.asList("AppropriateFamilyName", "AppropriateGivenName", + "AppropriateMiddleName", "AppropriatePrefix", "AppropriateSuffix")); + + elem.addExpectedNodeWithOrder("FN", "AppropriateDisplayName"); + } + + public void testStructuredNameUseSuperPrimaryV21() { + testStructuredNameUseSuperPrimaryCommon(V21); + } + + public void testStructuredNameUseSuperPrimaryV30() { + testStructuredNameUseSuperPrimaryCommon(V30); + } + + public void testStructuredNameUseSuperPrimaryV40() { + testStructuredNameUseSuperPrimaryCommon(V40); + } + + /** + * Tests phonetic names field are handled correctly. + * + * vCard 2.1 does not have any field corresponding to them. + * vCard 3.0 has SORT-STRING property, which does not support multiple values inside it. + * vCard 4.0 (rev13) has SORT-AS parameter, which has three values (family, given, middle) + * inside it. + */ + private void testStructuredNamePhoneticNameCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + final ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "AppropriateFamilyName") + .put(StructuredName.GIVEN_NAME, "AppropriateGivenName") + .put(StructuredName.MIDDLE_NAME, "AppropriateMiddleName") + .put(StructuredName.PREFIX, "AppropriatePrefix") + .put(StructuredName.SUFFIX, "AppropriateSuffix") + .put(StructuredName.DISPLAY_NAME, "AppropriateDisplayName") + .put(StructuredName.PHONETIC_FAMILY_NAME, "AppropriatePhoneticFamily") + .put(StructuredName.PHONETIC_GIVEN_NAME, "AppropriatePhoneticGiven") + .put(StructuredName.PHONETIC_MIDDLE_NAME, "AppropriatePhoneticMiddle"); + + final PropertyNodesVerifierElem elem = mVerifier.addPropertyNodesVerifierElem(); + if (VCardConfig.isVersion40(vcardType)) { + final ContentValues contentValues = new ContentValues(); + contentValues.put("SORT-AS", + "AppropriateFamilyName;AppropriateGivenName;AppropriateMiddleName"); + // vCard 4.0 (rev13) now uses SORT-AS parameter, which is not compatible with + // either 2.1 nor 3.0. + elem.addExpectedNodeWithOrder("N", + "AppropriateFamilyName;AppropriateGivenName;AppropriateMiddleName;" + + "AppropriatePrefix;AppropriateSuffix", + Arrays.asList("AppropriateFamilyName", "AppropriateGivenName", + "AppropriateMiddleName", "AppropriatePrefix", "AppropriateSuffix"), + contentValues); + } else { + elem.addExpectedNodeWithOrder("N", + "AppropriateFamilyName;AppropriateGivenName;AppropriateMiddleName;" + + "AppropriatePrefix;AppropriateSuffix", + Arrays.asList("AppropriateFamilyName", "AppropriateGivenName", + "AppropriateMiddleName", "AppropriatePrefix", "AppropriateSuffix")); + if (VCardConfig.isVersion30(vcardType)) { + elem.addExpectedNode("SORT-STRING", + "AppropriatePhoneticGiven AppropriatePhoneticMiddle" + + " AppropriatePhoneticFamily"); + } + } + + elem.addExpectedNodeWithOrder("FN", "AppropriateDisplayName") + .addExpectedNode("X-PHONETIC-FIRST-NAME", "AppropriatePhoneticGiven") + .addExpectedNode("X-PHONETIC-MIDDLE-NAME", "AppropriatePhoneticMiddle") + .addExpectedNode("X-PHONETIC-LAST-NAME", "AppropriatePhoneticFamily"); + } + + public void testStructuredNamePhoneticNameV21() { + testStructuredNamePhoneticNameCommon(V21); + } + + public void testStructuredNamePhoneticNameV30() { + testStructuredNamePhoneticNameCommon(V30); + } + + public void testStructuredNamePhoneticNameV40() { + testStructuredNamePhoneticNameCommon(V40); + } + + // TODO: need to add test cases confirming escaping, empty values, etc. + + /** + * Confirms all the other sides of the handling is correctly interpreted at one time. + * + * A kind of regression test for StructuredName handling. + */ + private void testStructuredNameComplicatedCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + final ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "DoNotEmitFamilyName1") + .put(StructuredName.GIVEN_NAME, "DoNotEmitGivenName1") + .put(StructuredName.MIDDLE_NAME, "DoNotEmitMiddleName1") + .put(StructuredName.PREFIX, "DoNotEmitPrefix1") + .put(StructuredName.SUFFIX, "DoNotEmitSuffix1") + .put(StructuredName.PHONETIC_FAMILY_NAME, "DoNotEmitPhoneticFamily1") + .put(StructuredName.PHONETIC_GIVEN_NAME, "DoNotEmitPhoneticGiven1") + .put(StructuredName.PHONETIC_MIDDLE_NAME, "DoNotEmitPhoneticMiddle1"); + + // With "IS_PRIMARY=1", but we should ignore this time. + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "DoNotEmitFamilyName2") + .put(StructuredName.GIVEN_NAME, "DoNotEmitGivenName2") + .put(StructuredName.MIDDLE_NAME, "DoNotEmitMiddleName2") + .put(StructuredName.PREFIX, "DoNotEmitPrefix2") + .put(StructuredName.SUFFIX, "DoNotEmitSuffix2") + .put(StructuredName.PHONETIC_FAMILY_NAME, "DoNotEmitPhoneticFamily2") + .put(StructuredName.PHONETIC_GIVEN_NAME, "DoNotEmitPhoneticGiven2") + .put(StructuredName.PHONETIC_MIDDLE_NAME, "DoNotEmitPhoneticMiddle2") + .put(StructuredName.IS_PRIMARY, 1); + + // With "IS_SUPER_PRIMARY=1". This is what we should use. + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "AppropriateFamilyName") + .put(StructuredName.GIVEN_NAME, "AppropriateGivenName") + .put(StructuredName.MIDDLE_NAME, "AppropriateMiddleName") + .put(StructuredName.PREFIX, "AppropriatePrefix") + .put(StructuredName.SUFFIX, "AppropriateSuffix") + .put(StructuredName.PHONETIC_FAMILY_NAME, "AppropriatePhoneticFamily") + .put(StructuredName.PHONETIC_GIVEN_NAME, "AppropriatePhoneticGiven") + .put(StructuredName.PHONETIC_MIDDLE_NAME, "AppropriatePhoneticMiddle") + .put(StructuredName.IS_SUPER_PRIMARY, 1); + + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "DoNotEmitFamilyName3") + .put(StructuredName.GIVEN_NAME, "DoNotEmitGivenName3") + .put(StructuredName.MIDDLE_NAME, "DoNotEmitMiddleName3") + .put(StructuredName.PREFIX, "DoNotEmitPrefix3") + .put(StructuredName.SUFFIX, "DoNotEmitSuffix3") + .put(StructuredName.PHONETIC_FAMILY_NAME, "DoNotEmitPhoneticFamily3") + .put(StructuredName.PHONETIC_GIVEN_NAME, "DoNotEmitPhoneticGiven3") + .put(StructuredName.PHONETIC_MIDDLE_NAME, "DoNotEmitPhoneticMiddle3") + .put(StructuredName.IS_PRIMARY, 1); + + final PropertyNodesVerifierElem elem = mVerifier.addPropertyNodesVerifierElem(); + if (VCardConfig.isVersion40(vcardType)) { + final ContentValues contentValues = new ContentValues(); + contentValues.put("SORT-AS", + "AppropriateFamilyName;AppropriateGivenName;AppropriateMiddleName"); + // vCard 4.0 (rev13) now uses SORT-AS parameter, which is not compatible with + // either 2.1 nor 3.0. + elem.addExpectedNodeWithOrder("N", + "AppropriateFamilyName;AppropriateGivenName;AppropriateMiddleName;" + + "AppropriatePrefix;AppropriateSuffix", + Arrays.asList("AppropriateFamilyName", "AppropriateGivenName", + "AppropriateMiddleName", "AppropriatePrefix", "AppropriateSuffix"), + contentValues); + } else { + elem.addExpectedNodeWithOrder("N", + "AppropriateFamilyName;AppropriateGivenName;AppropriateMiddleName;" + + "AppropriatePrefix;AppropriateSuffix", + Arrays.asList("AppropriateFamilyName", "AppropriateGivenName", + "AppropriateMiddleName", "AppropriatePrefix", "AppropriateSuffix")); + if (VCardConfig.isVersion30(vcardType)) { + elem.addExpectedNode("SORT-STRING", + "AppropriatePhoneticGiven AppropriatePhoneticMiddle" + + " AppropriatePhoneticFamily"); + } + } + + elem.addExpectedNodeWithOrder("FN", + "AppropriatePrefix AppropriateGivenName " + + "AppropriateMiddleName AppropriateFamilyName AppropriateSuffix") + .addExpectedNode("X-PHONETIC-FIRST-NAME", "AppropriatePhoneticGiven") + .addExpectedNode("X-PHONETIC-MIDDLE-NAME", "AppropriatePhoneticMiddle") + .addExpectedNode("X-PHONETIC-LAST-NAME", "AppropriatePhoneticFamily"); + } + + public void testStructuredNameComplicatedV21() { + testStructuredNameComplicatedCommon(V21); + } + + public void testStructuredNameComplicatedV30() { + testStructuredNameComplicatedCommon(V30); + } + + public void testStructuredNameComplicatedV40() { + testStructuredNameComplicatedCommon(V40); + } + + public void testNickNameV30() { + mVerifier.initForExportTest(V30); + mVerifier.addInputEntry().addContentValues(Nickname.CONTENT_ITEM_TYPE) + .put(Nickname.NAME, "Nicky"); + + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNodeWithOrder("NICKNAME", "Nicky"); + } + + public void testNickNameV40() { + mVerifier.initForExportTest(V40); + mVerifier.addInputEntry().addContentValues(Nickname.CONTENT_ITEM_TYPE) + .put(Nickname.NAME, "Nicky"); + + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNodeWithOrder("NICKNAME", "Nicky"); + } + + private void testPhoneBasicCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + mVerifier.addInputEntry().addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "1") + .put(Phone.TYPE, Phone.TYPE_HOME); + if (VCardConfig.isVersion40(vcardType)) { + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "tel:1", new TypeSet("HOME")); + } else { + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "1", new TypeSet("HOME")); + } + } + + public void testPhoneBasicV21() { + testPhoneBasicCommon(V21); + } + + public void testPhoneBasicV30() { + testPhoneBasicCommon(V30); + } + + public void testPhoneBasicV40() { + testPhoneBasicCommon(V40); + } + + public void testPhoneRefrainFormatting() { + mVerifier.initForExportTest(V21 | VCardConfig.FLAG_REFRAIN_PHONE_NUMBER_FORMATTING); + mVerifier.addInputEntry().addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "1234567890(abcdefghijklmnopqrstuvwxyz)") + .put(Phone.TYPE, Phone.TYPE_HOME); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "1234567890(abcdefghijklmnopqrstuvwxyz)", + new TypeSet("HOME")); + } + + /** + * Tests that vCard composer emits corresponding type param which we expect. + */ + private void testPhoneVariousTypeSupport(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "10") + .put(Phone.TYPE, Phone.TYPE_HOME); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "20") + .put(Phone.TYPE, Phone.TYPE_WORK); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "30") + .put(Phone.TYPE, Phone.TYPE_FAX_HOME); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "40") + .put(Phone.TYPE, Phone.TYPE_FAX_WORK); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "50") + .put(Phone.TYPE, Phone.TYPE_MOBILE); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "60") + .put(Phone.TYPE, Phone.TYPE_PAGER); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "70") + .put(Phone.TYPE, Phone.TYPE_OTHER); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "80") + .put(Phone.TYPE, Phone.TYPE_CAR); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "90") + .put(Phone.TYPE, Phone.TYPE_COMPANY_MAIN); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "100") + .put(Phone.TYPE, Phone.TYPE_ISDN); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "110") + .put(Phone.TYPE, Phone.TYPE_MAIN); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "120") + .put(Phone.TYPE, Phone.TYPE_OTHER_FAX); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "130") + .put(Phone.TYPE, Phone.TYPE_TELEX); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "140") + .put(Phone.TYPE, Phone.TYPE_WORK_MOBILE); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "150") + .put(Phone.TYPE, Phone.TYPE_WORK_PAGER); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "160") + .put(Phone.TYPE, Phone.TYPE_MMS); + + if (VCardConfig.isVersion40(vcardType)) { + // Currently pager type has special logic... + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "tel:10", new TypeSet("HOME")) + .addExpectedNode("TEL", "tel:20", new TypeSet("WORK")) + .addExpectedNode("TEL", "tel:30", new TypeSet("HOME", "FAX")) + .addExpectedNode("TEL", "tel:40", new TypeSet("WORK", "FAX")) + .addExpectedNode("TEL", "tel:50", new TypeSet("CELL")) + .addExpectedNode("TEL", "60", new TypeSet("PAGER")) + .addExpectedNode("TEL", "tel:70", new TypeSet("VOICE")) + .addExpectedNode("TEL", "tel:80", new TypeSet("CAR")) + .addExpectedNode("TEL", "tel:90", new TypeSet("WORK", "PREF")) + .addExpectedNode("TEL", "tel:100", new TypeSet("ISDN")) + .addExpectedNode("TEL", "tel:110", new TypeSet("PREF")) + .addExpectedNode("TEL", "tel:120", new TypeSet("FAX")) + .addExpectedNode("TEL", "tel:130", new TypeSet("TLX")) + .addExpectedNode("TEL", "tel:140", new TypeSet("WORK", "CELL")) + .addExpectedNode("TEL", "tel:150", new TypeSet("WORK", "PAGER")) + .addExpectedNode("TEL", "tel:160", new TypeSet("MSG")); + } else { + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "10", new TypeSet("HOME")) + .addExpectedNode("TEL", "20", new TypeSet("WORK")) + .addExpectedNode("TEL", "30", new TypeSet("HOME", "FAX")) + .addExpectedNode("TEL", "40", new TypeSet("WORK", "FAX")) + .addExpectedNode("TEL", "50", new TypeSet("CELL")) + .addExpectedNode("TEL", "60", new TypeSet("PAGER")) + .addExpectedNode("TEL", "70", new TypeSet("VOICE")) + .addExpectedNode("TEL", "80", new TypeSet("CAR")) + .addExpectedNode("TEL", "90", new TypeSet("WORK", "PREF")) + .addExpectedNode("TEL", "100", new TypeSet("ISDN")) + .addExpectedNode("TEL", "110", new TypeSet("PREF")) + .addExpectedNode("TEL", "120", new TypeSet("FAX")) + .addExpectedNode("TEL", "130", new TypeSet("TLX")) + .addExpectedNode("TEL", "140", new TypeSet("WORK", "CELL")) + .addExpectedNode("TEL", "150", new TypeSet("WORK", "PAGER")) + .addExpectedNode("TEL", "160", new TypeSet("MSG")); + } + } + + public void testPhoneVariousTypeSupportV21() { + testPhoneVariousTypeSupport(V21); + } + + public void testPhoneVariousTypeSupportV30() { + testPhoneVariousTypeSupport(V30); + } + + public void testPhoneVariousTypeSupportV40() { + testPhoneVariousTypeSupport(V40); + } + + /** + * Tests that "PREF"s are emitted appropriately. + */ + private void testPhonePrefHandlingCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "1") + .put(Phone.TYPE, Phone.TYPE_HOME); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "2") + .put(Phone.TYPE, Phone.TYPE_WORK) + .put(Phone.IS_PRIMARY, 1); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "3") + .put(Phone.TYPE, Phone.TYPE_FAX_HOME) + .put(Phone.IS_PRIMARY, 1); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "4") + .put(Phone.TYPE, Phone.TYPE_FAX_WORK); + + if (VCardConfig.isVersion40(vcardType)) { + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "tel:4", new TypeSet("WORK", "FAX")) + .addExpectedNode("TEL", "tel:3", new TypeSet("HOME", "FAX", "PREF")) + .addExpectedNode("TEL", "tel:2", new TypeSet("WORK", "PREF")) + .addExpectedNode("TEL", "tel:1", new TypeSet("HOME")); + } else { + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "4", new TypeSet("WORK", "FAX")) + .addExpectedNode("TEL", "3", new TypeSet("HOME", "FAX", "PREF")) + .addExpectedNode("TEL", "2", new TypeSet("WORK", "PREF")) + .addExpectedNode("TEL", "1", new TypeSet("HOME")); + } + } + + public void testPhonePrefHandlingV21() { + testPhonePrefHandlingCommon(V21); + } + + public void testPhonePrefHandlingV30() { + testPhonePrefHandlingCommon(V30); + } + + public void testPhonePrefHandlingV40() { + testPhonePrefHandlingCommon(V40); + } + + private void testMiscPhoneTypeHandling(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "1") + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "Modem"); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "2") + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "MSG"); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "3") + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "BBS"); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "4") + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "VIDEO"); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "5") + .put(Phone.TYPE, Phone.TYPE_CUSTOM); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "6") + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "_AUTO_CELL"); // The old indicator for the type mobile. + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "7") + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "\u643A\u5E2F"); // Mobile phone in Japanese Kanji + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "8") + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "invalid"); + PropertyNodesVerifierElem elem = mVerifier.addPropertyNodesVerifierElemWithEmptyName(); + if (VCardConfig.isVersion40(vcardType)) { + // vCard 4.0 (rev.13) does not allow bare number but URI. + elem.addExpectedNode("TEL", "tel:1", new TypeSet("Modem")) + .addExpectedNode("TEL", "tel:2", new TypeSet("MSG")) + .addExpectedNode("TEL", "tel:3", new TypeSet("BBS")) + .addExpectedNode("TEL", "tel:4", new TypeSet("VIDEO")) + .addExpectedNode("TEL", "tel:5", new TypeSet("VOICE")) + .addExpectedNode("TEL", "tel:6", new TypeSet("CELL")) + .addExpectedNode("TEL", "tel:7", new TypeSet("CELL")) + .addExpectedNode("TEL", "tel:8", new TypeSet("invalid")); + } else if (VCardConfig.isVersion30(vcardType)) { + // vCard 3.0 accepts "invalid". Also stop using toUpper() + elem.addExpectedNode("TEL", "1", new TypeSet("Modem")) + .addExpectedNode("TEL", "2", new TypeSet("MSG")) + .addExpectedNode("TEL", "3", new TypeSet("BBS")) + .addExpectedNode("TEL", "4", new TypeSet("VIDEO")) + .addExpectedNode("TEL", "5", new TypeSet("VOICE")) + .addExpectedNode("TEL", "6", new TypeSet("CELL")) + .addExpectedNode("TEL", "7", new TypeSet("CELL")) + .addExpectedNode("TEL", "8", new TypeSet("invalid")); + } else { + elem.addExpectedNode("TEL", "1", new TypeSet("MODEM")) + .addExpectedNode("TEL", "2", new TypeSet("MSG")) + .addExpectedNode("TEL", "3", new TypeSet("BBS")) + .addExpectedNode("TEL", "4", new TypeSet("VIDEO")) + .addExpectedNode("TEL", "5", new TypeSet("VOICE")) + .addExpectedNode("TEL", "6", new TypeSet("CELL")) + .addExpectedNode("TEL", "7", new TypeSet("CELL")) + .addExpectedNode("TEL", "8", new TypeSet("X-invalid")); + } + } + + public void testPhoneTypeHandlingV21() { + testMiscPhoneTypeHandling(V21); + } + + public void testPhoneTypeHandlingV30() { + testMiscPhoneTypeHandling(V30); + } + + public void testPhoneTypeHandlingV40() { + testMiscPhoneTypeHandling(V40); + } + + private void testEmailBasicCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + mVerifier.addInputEntry().addContentValues(Email.CONTENT_ITEM_TYPE) + .put(Email.DATA, "sample@example.com"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("EMAIL", "sample@example.com"); + } + + public void testEmailBasicV21() { + testEmailBasicCommon(V21); + } + + public void testEmailBasicV30() { + testEmailBasicCommon(V30); + } + + public void testEmailBasicV40() { + testEmailBasicCommon(V40); + } + + private void testEmailVariousTypeSupportCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Email.CONTENT_ITEM_TYPE) + .put(Email.DATA, "type_home@example.com") + .put(Email.TYPE, Email.TYPE_HOME); + entry.addContentValues(Email.CONTENT_ITEM_TYPE) + .put(Email.DATA, "type_work@example.com") + .put(Email.TYPE, Email.TYPE_WORK); + entry.addContentValues(Email.CONTENT_ITEM_TYPE) + .put(Email.DATA, "type_mobile@example.com") + .put(Email.TYPE, Email.TYPE_MOBILE); + entry.addContentValues(Email.CONTENT_ITEM_TYPE) + .put(Email.DATA, "type_other@example.com") + .put(Email.TYPE, Email.TYPE_OTHER); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("EMAIL", "type_home@example.com", new TypeSet("HOME")) + .addExpectedNode("EMAIL", "type_work@example.com", new TypeSet("WORK")) + .addExpectedNode("EMAIL", "type_mobile@example.com", new TypeSet("CELL")) + .addExpectedNode("EMAIL", "type_other@example.com"); + } + + public void testEmailVariousTypeSupportV21() { + testEmailVariousTypeSupportCommon(V21); + } + + public void testEmailVariousTypeSupportV30() { + testEmailVariousTypeSupportCommon(V30); + } + + public void testEmailVariousTypeSupportV40() { + testEmailVariousTypeSupportCommon(V40); + } + + private void testEmailPrefHandlingCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Email.CONTENT_ITEM_TYPE) + .put(Email.DATA, "type_home@example.com") + .put(Email.TYPE, Email.TYPE_HOME) + .put(Email.IS_PRIMARY, 1); + entry.addContentValues(Email.CONTENT_ITEM_TYPE) + .put(Email.DATA, "type_notype@example.com") + .put(Email.IS_PRIMARY, 1); + + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("EMAIL", "type_notype@example.com", new TypeSet("PREF")) + .addExpectedNode("EMAIL", "type_home@example.com", new TypeSet("HOME", "PREF")); + } + + public void testEmailPrefHandlingV21() { + testEmailPrefHandlingCommon(V21); + } + + public void testEmailPrefHandlingV30() { + testEmailPrefHandlingCommon(V30); + } + + public void testEmailPrefHandlingV40() { + testEmailPrefHandlingCommon(V40); + } + + private void testPostalAddressCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + mVerifier.addInputEntry().addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.POBOX, "Pobox") + .put(StructuredPostal.NEIGHBORHOOD, "Neighborhood") + .put(StructuredPostal.STREET, "Street") + .put(StructuredPostal.CITY, "City") + .put(StructuredPostal.REGION, "Region") + .put(StructuredPostal.POSTCODE, "100") + .put(StructuredPostal.COUNTRY, "Country") + .put(StructuredPostal.FORMATTED_ADDRESS, "Formatted Address") + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_WORK); + // adr-value = 0*6(text-value ";") text-value + // ; PO Box, Extended Address, Street, Locality, Region, Postal Code, + // ; Country Name + // + // The NEIGHBORHOOD field is appended after the CITY field. + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("ADR", + Arrays.asList("Pobox", "", "Street", "City Neighborhood", + "Region", "100", "Country"), new TypeSet("WORK")); + } + + public void testPostalAddressV21() { + testPostalAddressCommon(V21); + } + + public void testPostalAddressV30() { + testPostalAddressCommon(V30); + } + + public void testPostalAddressV40() { + testPostalAddressCommon(V40); + } + + private void testPostalAddressNonNeighborhood(int vcardType) { + mVerifier.initForExportTest(vcardType); + mVerifier.addInputEntry().addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.CITY, "City"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("ADR", + Arrays.asList("", "", "", "City", "", "", ""), new TypeSet("HOME")); + } + + public void testPostalAddressNonNeighborhoodV21() { + testPostalAddressNonNeighborhood(V21); + } + + public void testPostalAddressNonNeighborhoodV30() { + testPostalAddressNonNeighborhood(V30); + } + + public void testPostalAddressNonNeighborhoodV40() { + testPostalAddressNonNeighborhood(V40); + } + + private void testPostalAddressNonCity(int vcardType) { + mVerifier.initForExportTest(vcardType); + mVerifier.addInputEntry().addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.NEIGHBORHOOD, "Neighborhood"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("ADR", + Arrays.asList("", "", "", "Neighborhood", "", "", ""), new TypeSet("HOME")); + } + + public void testPostalAddressNonCityV21() { + testPostalAddressNonCity(V21); + } + + public void testPostalAddressNonCityV30() { + testPostalAddressNonCity(V30); + } + + public void testPostalAddressNonCityV40() { + testPostalAddressNonCity(V40); + } + + private void testPostalOnlyWithFormattedAddressCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + mVerifier.addInputEntry().addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.REGION, "") // Must be ignored. + .put(StructuredPostal.FORMATTED_ADDRESS, + "Formatted address CA 123-334 United Statue"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNodeWithOrder("ADR", ";Formatted address CA 123-334 United Statue;;;;;", + Arrays.asList("", "Formatted address CA 123-334 United Statue", + "", "", "", "", ""), new TypeSet("HOME")); + } + + public void testPostalOnlyWithFormattedAddressV21() { + testPostalOnlyWithFormattedAddressCommon(V21); + } + + public void testPostalOnlyWithFormattedAddressV30() { + testPostalOnlyWithFormattedAddressCommon(V30); + } + + public void testPostalOnlyWithFormattedAddressV40() { + testPostalOnlyWithFormattedAddressCommon(V40); + } + + /** + * Tests that the vCard composer honors formatted data when it is available + * even when it is partial. + */ + private void testPostalWithBothStructuredAndFormattedCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + mVerifier.addInputEntry().addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.POBOX, "Pobox") + .put(StructuredPostal.COUNTRY, "Country") + .put(StructuredPostal.FORMATTED_ADDRESS, + "Formatted address CA 123-334 United Statue"); // Should be ignored + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("ADR", "Pobox;;;;;;Country", + Arrays.asList("Pobox", "", "", "", "", "", "Country"), + new TypeSet("HOME")); + } + + public void testPostalWithBothStructuredAndFormattedV21() { + testPostalWithBothStructuredAndFormattedCommon(V21); + } + + public void testPostalWithBothStructuredAndFormattedV30() { + testPostalWithBothStructuredAndFormattedCommon(V30); + } + + public void testPostalWithBothStructuredAndFormattedV40() { + testPostalWithBothStructuredAndFormattedCommon(V40); + } + + private void testPostalAddressTypeHandlingCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.STREET, "1010 Technology Pkwy") + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_WORK); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.STREET, "123 Main St") + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_OTHER); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.STREET, "112358 Academic Lane") + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_CUSTOM) + .put(StructuredPostal.LABEL, "School"); + + PropertyNodesVerifierElem elem = mVerifier.addPropertyNodesVerifierElemWithEmptyName(); + elem.addExpectedNode("ADR", + Arrays.asList("", "", "1010 Technology Pkwy", "", "", "", ""), + new TypeSet(VCardConstants.PARAM_TYPE_WORK)); + elem.addExpectedNode("ADR", + Arrays.asList("", "", "123 Main St", "", "", "", ""), + new TypeSet("X-" + VCardConstants.PARAM_ADR_EXTRA_TYPE_OTHER)); + elem.addExpectedNode("ADR", + Arrays.asList("", "", "112358 Academic Lane", "", "", "", ""), + new TypeSet("X-School")); + } + + public void testPostalAddressTypeHandlingV21() { + testPostalAddressTypeHandlingCommon(V21); + } + + public void testPostalAddressTypeHandlingV30() { + testPostalAddressTypeHandlingCommon(V30); + } + + public void testPostalAddressTypeHandlingV40() { + testPostalAddressTypeHandlingCommon(V40); + } + + private void testOrganizationCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Organization.CONTENT_ITEM_TYPE) + .put(Organization.COMPANY, "CompanyX") + .put(Organization.DEPARTMENT, "DepartmentY") + .put(Organization.TITLE, "TitleZ") + .put(Organization.JOB_DESCRIPTION, "Description Rambda") // Ignored. + .put(Organization.OFFICE_LOCATION, "Mountain View") // Ignored. + .put(Organization.PHONETIC_NAME, "PhoneticName!") // Ignored + .put(Organization.SYMBOL, "(^o^)/~~"); // Ignore him (her). + entry.addContentValues(Organization.CONTENT_ITEM_TYPE) + .putNull(Organization.COMPANY) + .put(Organization.DEPARTMENT, "DepartmentXX") + .putNull(Organization.TITLE); + entry.addContentValues(Organization.CONTENT_ITEM_TYPE) + .put(Organization.COMPANY, "CompanyXYZ") + .putNull(Organization.DEPARTMENT) + .put(Organization.TITLE, "TitleXYZYX"); + // Currently we do not use group but depend on the order. + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNodeWithOrder("ORG", "CompanyX;DepartmentY", + Arrays.asList("CompanyX", "DepartmentY")) + .addExpectedNodeWithOrder("TITLE", "TitleZ") + .addExpectedNodeWithOrder("ORG", "DepartmentXX") + .addExpectedNodeWithOrder("ORG", "CompanyXYZ") + .addExpectedNodeWithOrder("TITLE", "TitleXYZYX"); + } + + public void testOrganizationV21() { + testOrganizationCommon(V21); + } + + public void testOrganizationV30() { + testOrganizationCommon(V30); + } + + public void testOrganizationV40() { + testOrganizationCommon(V40); + } + + private void testImVariousTypeSupportCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_AIM) + .put(Im.DATA, "aim"); + entry.addContentValues(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_MSN) + .put(Im.DATA, "msn"); + entry.addContentValues(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_YAHOO) + .put(Im.DATA, "yahoo"); + entry.addContentValues(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_SKYPE) + .put(Im.DATA, "skype"); + entry.addContentValues(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_QQ) + .put(Im.DATA, "qq"); + entry.addContentValues(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_GOOGLE_TALK) + .put(Im.DATA, "google talk"); + entry.addContentValues(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_ICQ) + .put(Im.DATA, "icq"); + entry.addContentValues(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_JABBER) + .put(Im.DATA, "jabber"); + entry.addContentValues(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_NETMEETING) + .put(Im.DATA, "netmeeting"); + + // No determined way to express unknown type... + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("X-JABBER", "jabber") + .addExpectedNode("X-ICQ", "icq") + .addExpectedNode("X-GOOGLE-TALK", "google talk") + .addExpectedNode("X-QQ", "qq") + .addExpectedNode("X-SKYPE-USERNAME", "skype") + .addExpectedNode("X-YAHOO", "yahoo") + .addExpectedNode("X-MSN", "msn") + .addExpectedNode("X-NETMEETING", "netmeeting") + .addExpectedNode("X-AIM", "aim"); + } + + public void testImBasiV21() { + testImVariousTypeSupportCommon(V21); + } + + public void testImBasicV30() { + testImVariousTypeSupportCommon(V30); + } + + public void testImBasicV40() { + testImVariousTypeSupportCommon(V40); + } + + private void testImPrefHandlingCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_AIM) + .put(Im.DATA, "aim1"); + entry.addContentValues(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_AIM) + .put(Im.DATA, "aim2") + .put(Im.TYPE, Im.TYPE_HOME) + .put(Im.IS_PRIMARY, 1); + + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("X-AIM", "aim1") + .addExpectedNode("X-AIM", "aim2", new TypeSet("HOME", "PREF")); + } + + public void testImPrefHandlingV21() { + testImPrefHandlingCommon(V21); + } + + public void testImPrefHandlingV30() { + testImPrefHandlingCommon(V30); + } + + public void testImPrefHandlingV40() { + testImPrefHandlingCommon(V40); + } + + private void testWebsiteCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Website.CONTENT_ITEM_TYPE) + .put(Website.URL, "http://website.example.android.com/index.html") + .put(Website.TYPE, Website.TYPE_BLOG); + entry.addContentValues(Website.CONTENT_ITEM_TYPE) + .put(Website.URL, "ftp://ftp.example.android.com/index.html") + .put(Website.TYPE, Website.TYPE_FTP); + + // We drop TYPE information since vCard (especially 3.0) does not allow us to emit it. + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("URL", "ftp://ftp.example.android.com/index.html") + .addExpectedNode("URL", "http://website.example.android.com/index.html"); + } + + public void testWebsiteV21() { + testWebsiteCommon(V21); + } + + public void testWebsiteV30() { + testWebsiteCommon(V30); + } + + public void testWebsiteV40() { + testWebsiteCommon(V40); + } + + private String getAndroidPropValue(final String mimeType, String value, Integer type) { + return getAndroidPropValue(mimeType, value, type, null); + } + + private String getAndroidPropValue(final String mimeType, String value, + Integer type, String label) { + return (mimeType + ";" + value + ";" + + (type != null ? type : "") + ";" + + (label != null ? label : "") + ";;;;;;;;;;;;"); + } + + private void testEventCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Event.CONTENT_ITEM_TYPE) + .put(Event.TYPE, Event.TYPE_ANNIVERSARY) + .put(Event.START_DATE, "1982-06-16"); + entry.addContentValues(Event.CONTENT_ITEM_TYPE) + .put(Event.TYPE, Event.TYPE_BIRTHDAY) + .put(Event.START_DATE, "2008-10-22"); + entry.addContentValues(Event.CONTENT_ITEM_TYPE) + .put(Event.TYPE, Event.TYPE_OTHER) + .put(Event.START_DATE, "2018-03-12"); + entry.addContentValues(Event.CONTENT_ITEM_TYPE) + .put(Event.TYPE, Event.TYPE_CUSTOM) + .put(Event.LABEL, "The last day") + .put(Event.START_DATE, "When the Tower of Hanoi with 64 rings is completed."); + entry.addContentValues(Event.CONTENT_ITEM_TYPE) + .put(Event.TYPE, Event.TYPE_BIRTHDAY) + .put(Event.START_DATE, "2009-05-19"); // Should be ignored. + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("BDAY", "2008-10-22") + .addExpectedNode("X-ANDROID-CUSTOM", + getAndroidPropValue( + Event.CONTENT_ITEM_TYPE, "1982-06-16", Event.TYPE_ANNIVERSARY)) + .addExpectedNode("X-ANDROID-CUSTOM", + getAndroidPropValue( + Event.CONTENT_ITEM_TYPE, "2018-03-12", Event.TYPE_OTHER)) + .addExpectedNode("X-ANDROID-CUSTOM", + getAndroidPropValue( + Event.CONTENT_ITEM_TYPE, + "When the Tower of Hanoi with 64 rings is completed.", + Event.TYPE_CUSTOM, "The last day")); + } + + public void testEventV21() { + testEventCommon(V21); + } + + public void testEventV30() { + testEventCommon(V30); + } + + public void testEventV40() { + testEventCommon(V40); + } + + private void testNoteCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Note.CONTENT_ITEM_TYPE) + .put(Note.NOTE, "note1"); + entry.addContentValues(Note.CONTENT_ITEM_TYPE) + .put(Note.NOTE, "note2") + .put(Note.IS_PRIMARY, 1); // Just ignored. + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNodeWithOrder("NOTE", "note1") + .addExpectedNodeWithOrder("NOTE", "note2"); + } + + public void testNoteV21() { + testNoteCommon(V21); + } + + public void testNoteV30() { + testNoteCommon(V30); + } + + public void testNoteV40() { + testNoteCommon(V40); + } + + private void testPhotoCommon(int vcardType) { + final boolean useB = + (VCardConfig.isVersion30(vcardType) || VCardConfig.isVersion40(vcardType)); + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "PhotoTest"); + entry.addContentValues(Photo.CONTENT_ITEM_TYPE) + .put(Photo.PHOTO, sPhotoByteArray); + + ContentValues contentValuesForPhoto = new ContentValues(); + contentValuesForPhoto.put("ENCODING", (useB ? "b" : "BASE64")); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNode("FN", "PhotoTest") + .addExpectedNode("N", "PhotoTest;;;;", + Arrays.asList("PhotoTest", "", "", "", "")) + .addExpectedNodeWithOrder("PHOTO", null, null, sPhotoByteArray, + contentValuesForPhoto, new TypeSet("JPEG"), null); + } + + public void testPhotoV21() { + testPhotoCommon(V21); + } + + public void testPhotoV30() { + testPhotoCommon(V30); + } + + public void testPhotoV40() { + testPhotoCommon(V40); + } + + private void testRelationCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + mVerifier.addInputEntry().addContentValues(Relation.CONTENT_ITEM_TYPE) + .put(Relation.TYPE, Relation.TYPE_MOTHER) + .put(Relation.NAME, "Ms. Mother"); + mVerifier.addContentValuesVerifierElem().addExpected(Relation.CONTENT_ITEM_TYPE) + .put(Relation.TYPE, Relation.TYPE_MOTHER) + .put(Relation.NAME, "Ms. Mother"); + } + + public void testRelationV21() { + testRelationCommon(V21); + } + + public void testRelationV30() { + testRelationCommon(V30); + } + + public void testV30HandleEscape() { + mVerifier.initForExportTest(V30); + mVerifier.addInputEntry().addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\\") + .put(StructuredName.GIVEN_NAME, ";") + .put(StructuredName.MIDDLE_NAME, ",") + .put(StructuredName.PREFIX, "\n") + .put(StructuredName.DISPLAY_NAME, "[<{Unescaped:Asciis}>]"); + // Verifies the vCard String correctly escapes each character which must be escaped. + mVerifier.addLineVerifierElem() + .addExpected("N:\\\\;\\;;\\,;\\n;") + .addExpected("FN:[<{Unescaped:Asciis}>]"); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNode("FN", "[<{Unescaped:Asciis}>]") + .addExpectedNode("N", Arrays.asList("\\", ";", ",", "\n", "")); + } + + /** + * There's no "NICKNAME" property in vCard 2.1, while there is in vCard 3.0. + * We use Android-specific "X-ANDROID-CUSTOM" property. + * This test verifies the functionality. + */ + public void testNickNameV21() { + mVerifier.initForExportTest(V21); + mVerifier.addInputEntry().addContentValues(Nickname.CONTENT_ITEM_TYPE) + .put(Nickname.NAME, "Nicky"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("X-ANDROID-CUSTOM", + Nickname.CONTENT_ITEM_TYPE + ";Nicky;;;;;;;;;;;;;;"); + mVerifier.addContentValuesVerifierElem().addExpected(Nickname.CONTENT_ITEM_TYPE) + .put(Nickname.NAME, "Nicky"); + } + + public void testTolerateBrokenPhoneNumberEntryV21() { + mVerifier.initForExportTest(V21); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_HOME) + .put(Phone.NUMBER, "111-222-3333 (Miami)\n444-5555-666 (Tokyo)\n" + + "777-888-9999 (Chicago)\n111-222-3333 (Miami)"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "111-222-3333", new TypeSet("HOME")) + .addExpectedNode("TEL", "444-555-5666", new TypeSet("HOME")) + .addExpectedNode("TEL", "777-888-9999", new TypeSet("HOME")); + } + + private void testPickUpNonEmptyContentValuesCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.IS_PRIMARY, 1); // Empty name. Should be ignored. + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "family1") // Not primary. Should be ignored. + .put(StructuredName.DISPLAY_NAME, "display"); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.IS_PRIMARY, 1) + .put(StructuredName.FAMILY_NAME, "family2") // This entry is what we want. + .put(StructuredName.DISPLAY_NAME, "display"); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.IS_PRIMARY, 1) + .put(StructuredName.FAMILY_NAME, "family3") + .put(StructuredName.DISPLAY_NAME, "display"); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "family4") + .put(StructuredName.DISPLAY_NAME, "display"); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNode("N", Arrays.asList("family2", "", "", "", "")) + .addExpectedNode("FN", "display"); + } + + public void testPickUpNonEmptyContentValuesV21() { + testPickUpNonEmptyContentValuesCommon(V21); + } + + public void testPickUpNonEmptyContentValuesV30() { + testPickUpNonEmptyContentValuesCommon(V30); + } + + public void testPickUpNonEmptyContentValuesV40() { + testPickUpNonEmptyContentValuesCommon(V40); + } + + public void testUseMultiByteTypeV30() { + mVerifier.initForExportTest(V30); + final ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "\u96FB\u8A71") + .put(Phone.NUMBER, "1"); + mVerifier.addLineVerifierElem() + .addExpected("N:") + .addExpected("FN:") + .addExpected("TEL;TYPE=\u96FB\u8A71:1"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "1", new TypeSet("\u96FB\u8A71")); + } + + public void testSipAddressV21() { + mVerifier.initForExportTest(V21); + final ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(SipAddress.CONTENT_ITEM_TYPE) + .put(SipAddress.SIP_ADDRESS, "gold"); + mVerifier.addLineVerifierElem() + .addExpected("X-SIP:gold"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("X-SIP", "gold"); + } + + public void testSipAddressV30() { + mVerifier.initForExportTest(V30); + final ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(SipAddress.CONTENT_ITEM_TYPE) + .put(SipAddress.SIP_ADDRESS, "android@example.com"); + mVerifier.addLineVerifierElem() + .addExpected("N:") + .addExpected("FN:") + .addExpected("IMPP:sip:android@example.com"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("IMPP", "sip:android@example.com"); + } + + public void testPauseAndWaitConversionV30() { + mVerifier.initForExportTest(V30); + final ContactEntry entry = mVerifier.addInputEntry(); + // Insert numbers with PAUSE (',' internally, 'p' for outside) and + // WAIT (';' internally, 'w' for outside) + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_HOME) + .put(Phone.NUMBER, "111,222;333"); + mVerifier.addLineVerifierElem() + .addExpected("N:") + .addExpected("FN:") + .addExpected("TEL;TYPE=HOME:111p222w333"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "111p222w333", new TypeSet("HOME")); + } + + public void testSipAddressV40() { + mVerifier.initForExportTest(V40); + final ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(SipAddress.CONTENT_ITEM_TYPE) + .put(SipAddress.SIP_ADDRESS, "android@example.com"); + mVerifier.addLineVerifierElem() + .addExpected("FN:") + .addExpected("TEL:sip:android@example.com"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "sip:android@example.com"); + } + + public void testRefrainFlags() { + int vCardType = V30; + vCardType |= VCardConfig.FLAG_REFRAIN_ADDRESS_EXPORT | + VCardConfig.FLAG_REFRAIN_EMAIL_EXPORT | + VCardConfig.FLAG_REFRAIN_EMAIL_EXPORT | + VCardConfig.FLAG_REFRAIN_ORGANIZATION_EXPORT | + VCardConfig.FLAG_REFRAIN_WEBSITES_EXPORT | + VCardConfig.FLAG_REFRAIN_NOTES_EXPORT | + VCardConfig.FLAG_REFRAIN_NICKNAME_EXPORT | + VCardConfig.FLAG_REFRAIN_EVENTS_EXPORT | + VCardConfig.FLAG_REFRAIN_IMAGE_EXPORT; + + mVerifier.initForExportTest(vCardType); + + final ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.DISPLAY_NAME, StructuredName.CONTENT_ITEM_TYPE); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.FORMATTED_ADDRESS, StructuredPostal.CONTENT_ITEM_TYPE); + entry.addContentValues(Email.CONTENT_ITEM_TYPE) + .put(Email.ADDRESS, Email.CONTENT_ITEM_TYPE); + entry.addContentValues(Organization.CONTENT_ITEM_TYPE) + .put(Organization.COMPANY, Organization.CONTENT_ITEM_TYPE); + entry.addContentValues(Website.CONTENT_ITEM_TYPE) + .put(Website.URL, Website.CONTENT_ITEM_TYPE); + entry.addContentValues(Note.CONTENT_ITEM_TYPE) + .put(Note.NOTE, Note.CONTENT_ITEM_TYPE); + entry.addContentValues(Nickname.CONTENT_ITEM_TYPE) + .put(Nickname.NAME, Nickname.CONTENT_ITEM_TYPE); + entry.addContentValues(Event.CONTENT_ITEM_TYPE) + .put(Event.START_DATE, Event.CONTENT_ITEM_TYPE); + entry.addContentValues(Photo.CONTENT_ITEM_TYPE) + .put(Photo.PHOTO_FILE_ID, Photo.CONTENT_ITEM_TYPE); + + final String vcard = mVerifier.buildVCardForExportTest(); + + assertFalse("Address should not be present.", + vcard.contains(StructuredPostal.CONTENT_ITEM_TYPE)); + assertFalse("Email should not be present.", + vcard.contains(Email.CONTENT_ITEM_TYPE)); + assertFalse("Organization should not be present.", + vcard.contains(Organization.CONTENT_ITEM_TYPE)); + assertFalse("Website should not be present.", + vcard.contains(Website.CONTENT_ITEM_TYPE)); + assertFalse("Note should not be present.", + vcard.contains(Note.CONTENT_ITEM_TYPE)); + assertFalse("Nickname should not be present.", + vcard.contains(Nickname.CONTENT_ITEM_TYPE)); + assertFalse("Event should not be present.", + vcard.contains(Event.CONTENT_ITEM_TYPE)); + assertFalse("Photo should not be present.", + vcard.contains(Photo.CONTENT_ITEM_TYPE)); + assertTrue("Name should be present. ", + vcard.contains(StructuredName.CONTENT_ITEM_TYPE)); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/VCardImporterNestTests.java b/modules/vcard/tests/src/com/android/vcard/tests/VCardImporterNestTests.java new file mode 100644 index 0000000..0a36e05 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/VCardImporterNestTests.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.vcard.tests; + +import com.android.vcard.tests.testutils.VCardTestsBase; + +/** + * Test cases specific for nested vCard. + */ +public class VCardImporterNestTests extends VCardTestsBase { + public void testSimpleNest() { + mVerifier.initForImportTest(V21, R.raw.v21_nest); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "nest1"); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "nest2"); + // Parent comes last. + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "parent") + .addExpectedNodeWithOrder("TEL", "1"); + } + + public void testSimpleNestWithMime() { + mVerifier.initForImportTest(V21, R.raw.v21_nest_with_mime); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "nest1"); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "nest2"); + // Parent comes last. + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "parent") + .addExpectedNodeWithOrder("TEL", "1"); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/VCardImporterTests.java b/modules/vcard/tests/src/com/android/vcard/tests/VCardImporterTests.java new file mode 100644 index 0000000..6ec02b6 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/VCardImporterTests.java @@ -0,0 +1,1417 @@ +/* + * 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.vcard.tests; + +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.Photo; +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 com.android.vcard.VCardConfig; +import com.android.vcard.tests.testutils.ContentValuesVerifier; +import com.android.vcard.tests.testutils.ContentValuesVerifierElem; +import com.android.vcard.tests.testutils.PropertyNodesVerifierElem.TypeSet; +import com.android.vcard.tests.testutils.VCardTestsBase; + +import java.util.Arrays; + +public class VCardImporterTests extends VCardTestsBase { + // Push data into int array at first since values like 0x80 are + // interpreted as int by the compiler and casting all of them is + // cumbersome... + private static final int[] sPhotoIntArrayForComplicatedCase = { + 0xff, 0xd8, 0xff, 0xe1, 0x0a, 0x0f, 0x45, 0x78, 0x69, 0x66, 0x00, + 0x00, 0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x0d, + 0x01, 0x0e, 0x00, 0x02, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, + 0xaa, 0x01, 0x0f, 0x00, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, + 0x00, 0xba, 0x01, 0x10, 0x00, 0x02, 0x00, 0x00, 0x00, 0x06, 0x00, + 0x00, 0x00, 0xc2, 0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0x01, 0x1a, 0x00, 0x05, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0xc8, 0x01, 0x1b, 0x00, 0x05, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0xd0, 0x01, 0x28, 0x00, 0x03, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x01, 0x31, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x00, 0xd8, 0x01, 0x32, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0xe6, 0x02, 0x13, + 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x82, + 0x98, 0x00, 0x02, 0x00, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x00, 0xfa, + 0x87, 0x69, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, + 0x84, 0xc4, 0xa5, 0x00, 0x07, 0x00, 0x00, 0x00, 0x7c, 0x00, 0x00, + 0x01, 0x08, 0x00, 0x00, 0x04, 0x1e, 0x32, 0x30, 0x30, 0x38, 0x31, + 0x30, 0x32, 0x39, 0x31, 0x33, 0x35, 0x35, 0x33, 0x31, 0x00, 0x00, + 0x44, 0x6f, 0x43, 0x6f, 0x4d, 0x6f, 0x00, 0x00, 0x44, 0x39, 0x30, + 0x35, 0x69, 0x00, 0x00, 0x00, 0x00, 0x48, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x48, 0x00, 0x00, 0x00, 0x01, 0x44, 0x39, 0x30, + 0x35, 0x69, 0x20, 0x56, 0x65, 0x72, 0x31, 0x2e, 0x30, 0x30, 0x00, + 0x32, 0x30, 0x30, 0x38, 0x3a, 0x31, 0x30, 0x3a, 0x32, 0x39, 0x20, + 0x31, 0x33, 0x3a, 0x35, 0x35, 0x3a, 0x34, 0x37, 0x00, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x00, 0x50, 0x72, 0x69, 0x6e, 0x74, 0x49, 0x4d, 0x00, 0x30, 0x33, + 0x30, 0x30, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x14, 0x00, + 0x14, 0x00, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, + 0x00, 0x34, 0x01, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, + 0x00, 0x00, 0x00, 0x01, 0x10, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x11, 0x09, 0x00, 0x00, 0x27, 0x10, 0x00, 0x00, 0x0f, 0x0b, 0x00, + 0x00, 0x27, 0x10, 0x00, 0x00, 0x05, 0x97, 0x00, 0x00, 0x27, 0x10, + 0x00, 0x00, 0x08, 0xb0, 0x00, 0x00, 0x27, 0x10, 0x00, 0x00, 0x1c, + 0x01, 0x00, 0x00, 0x27, 0x10, 0x00, 0x00, 0x02, 0x5e, 0x00, 0x00, + 0x27, 0x10, 0x00, 0x00, 0x00, 0x8b, 0x00, 0x00, 0x27, 0x10, 0x00, + 0x00, 0x03, 0xcb, 0x00, 0x00, 0x27, 0x10, 0x00, 0x00, 0x1b, 0xe5, + 0x00, 0x00, 0x27, 0x10, 0x00, 0x28, 0x82, 0x9a, 0x00, 0x05, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x03, 0x6a, 0x82, 0x9d, 0x00, 0x05, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x03, 0x72, 0x88, 0x22, 0x00, + 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x90, 0x00, + 0x00, 0x07, 0x00, 0x00, 0x00, 0x04, 0x30, 0x32, 0x32, 0x30, 0x90, + 0x03, 0x00, 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x03, 0x7a, + 0x90, 0x04, 0x00, 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x03, + 0x8e, 0x91, 0x01, 0x00, 0x07, 0x00, 0x00, 0x00, 0x04, 0x01, 0x02, + 0x03, 0x00, 0x91, 0x02, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x03, 0xa2, 0x92, 0x01, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x03, 0xaa, 0x92, 0x02, 0x00, 0x05, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x03, 0xb2, 0x92, 0x04, 0x00, 0x0a, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x03, 0xba, 0x92, 0x05, 0x00, 0x05, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x03, 0xc2, 0x92, 0x07, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x92, 0x08, 0x00, + 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x92, 0x09, + 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x92, + 0x0a, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x03, 0xca, + 0x92, 0x7c, 0x00, 0x07, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x92, 0x86, 0x00, 0x07, 0x00, 0x00, 0x00, 0x16, 0x00, 0x00, + 0x03, 0xd2, 0xa0, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x04, 0x30, + 0x31, 0x30, 0x30, 0xa0, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0xa0, 0x02, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x60, 0x00, 0x00, 0xa0, 0x03, 0x00, 0x03, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x48, 0x00, 0x00, 0xa0, 0x05, 0x00, 0x04, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x04, 0x00, 0xa2, 0x0e, 0x00, 0x05, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x03, 0xe8, 0xa2, 0x0f, 0x00, + 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x03, 0xf0, 0xa2, 0x10, + 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0xa2, + 0x17, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, + 0xa3, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, + 0x00, 0xa3, 0x01, 0x00, 0x07, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, + 0x00, 0x00, 0xa4, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, 0xa4, 0x02, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0xa4, 0x03, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0xa4, 0x04, 0x00, 0x05, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x03, 0xf8, 0xa4, 0x05, 0x00, 0x03, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x1d, 0x00, 0x00, 0xa4, 0x06, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xa4, 0x07, 0x00, + 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xa4, 0x08, + 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xa4, + 0x09, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0xa4, 0x0a, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0xa4, 0x0c, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x53, 0x00, + 0x00, 0x27, 0x10, 0x00, 0x00, 0x01, 0x5e, 0x00, 0x00, 0x00, 0x64, + 0x32, 0x30, 0x30, 0x38, 0x3a, 0x31, 0x30, 0x3a, 0x32, 0x39, 0x20, + 0x31, 0x33, 0x3a, 0x35, 0x35, 0x3a, 0x33, 0x31, 0x00, 0x32, 0x30, + 0x30, 0x38, 0x3a, 0x31, 0x30, 0x3a, 0x32, 0x39, 0x20, 0x31, 0x33, + 0x3a, 0x35, 0x35, 0x3a, 0x34, 0x37, 0x00, 0x00, 0x00, 0x29, 0x88, + 0x00, 0x00, 0x1b, 0x00, 0x00, 0x00, 0x02, 0xb2, 0x00, 0x00, 0x00, + 0x64, 0x00, 0x00, 0x01, 0x5e, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x25, 0x00, + 0x00, 0x00, 0x0a, 0x00, 0x00, 0x0e, 0x92, 0x00, 0x00, 0x03, 0xe8, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x32, 0x30, 0x30, + 0x38, 0x31, 0x30, 0x32, 0x39, 0x31, 0x33, 0x35, 0x35, 0x33, 0x31, + 0x00, 0x00, 0x20, 0x2a, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, 0x2a, + 0xe2, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, + 0x04, 0x52, 0x39, 0x38, 0x00, 0x00, 0x02, 0x00, 0x07, 0x00, 0x00, + 0x00, 0x04, 0x30, 0x31, 0x30, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x06, 0x01, 0x03, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, + 0x00, 0x00, 0x01, 0x1a, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x04, 0x6c, 0x01, 0x1b, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x04, 0x74, 0x01, 0x28, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x02, 0x00, 0x00, 0x02, 0x01, 0x00, 0x04, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x04, 0x7c, 0x02, 0x02, 0x00, 0x04, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x05, 0x8b, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x48, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x48, 0x00, 0x00, 0x00, 0x01, 0xff, 0xd8, 0xff, 0xdb, 0x00, 0x84, + 0x00, 0x20, 0x16, 0x18, 0x1c, 0x18, 0x14, 0x20, 0x1c, 0x1a, 0x1c, + 0x24, 0x22, 0x20, 0x26, 0x30, 0x50, 0x34, 0x30, 0x2c, 0x2c, 0x30, + 0x62, 0x46, 0x4a, 0x3a, 0x50, 0x74, 0x66, 0x7a, 0x78, 0x72, 0x66, + 0x70, 0x6e, 0x80, 0x90, 0xb8, 0x9c, 0x80, 0x88, 0xae, 0x8a, 0x6e, + 0x70, 0xa0, 0xda, 0xa2, 0xae, 0xbe, 0xc4, 0xce, 0xd0, 0xce, 0x7c, + 0x9a, 0xe2, 0xf2, 0xe0, 0xc8, 0xf0, 0xb8, 0xca, 0xce, 0xc6, 0x01, + 0x22, 0x24, 0x24, 0x30, 0x2a, 0x30, 0x5e, 0x34, 0x34, 0x5e, 0xc6, + 0x84, 0x70, 0x84, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, + 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, + 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, + 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, + 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xc6, 0xff, 0xc0, + 0x00, 0x11, 0x08, 0x00, 0x78, 0x00, 0xa0, 0x03, 0x01, 0x21, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x01, 0xa2, 0x00, + 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, + 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x10, 0x00, 0x02, 0x01, 0x03, + 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, + 0x7d, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, + 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, + 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, + 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, + 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, + 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, + 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, + 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, + 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, + 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, + 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, + 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, + 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0x01, 0x00, + 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, + 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, + 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, + 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, + 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, + 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, 0x15, + 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, + 0x18, 0x19, 0x1a, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, + 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, + 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, + 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, + 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, + 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, + 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, + 0xda, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, + 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, + 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, + 0x14, 0x54, 0xaa, 0x2a, 0x46, 0x48, 0xa2, 0xa4, 0x55, 0xa6, 0x04, + 0x8a, 0x29, 0xe0, 0x53, 0x10, 0xe0, 0x29, 0xc0, 0x50, 0x03, 0xb1, + 0x46, 0x29, 0x80, 0x84, 0x52, 0x11, 0x40, 0x0d, 0x22, 0x9a, 0x45, + 0x20, 0x23, 0x61, 0x51, 0x30, 0xa0, 0x08, 0xc8, 0xa8, 0xd8, 0x52, + 0x02, 0x26, 0x15, 0x0b, 0x0a, 0x00, 0xb4, 0xa2, 0xa5, 0x5a, 0x00, + 0x91, 0x45, 0x4a, 0xa2, 0x81, 0x92, 0x01, 0x4e, 0x02, 0x98, 0x87, + 0x0a, 0x70, 0xa0, 0x07, 0x62, 0x8c, 0x50, 0x21, 0x0d, 0x25, 0x00, + 0x34, 0x8a, 0x61, 0x14, 0x0c, 0x63, 0x0a, 0x89, 0x85, 0x00, 0x46, + 0xd5, 0x1b, 0x52, 0x02, 0x16, 0xa8, 0x98, 0x50, 0x05, 0x94, 0xa9, + 0x16, 0x80, 0x25, 0x5a, 0x95, 0x68, 0x18, 0xf1, 0x4f, 0x14, 0xc4, + 0x3b, 0xb5, 0x22, 0xb6, 0x38, 0x34, 0x00, 0xe3, 0x22, 0x8e, 0xf4, + 0x79, 0x8a, 0x7b, 0xd1, 0x71, 0x03, 0x30, 0xc7, 0x14, 0x83, 0xa5, + 0x00, 0x06, 0x98, 0x68, 0x01, 0x8d, 0x51, 0x35, 0x03, 0x22, 0x6a, + 0x8d, 0xa9, 0x01, 0x13, 0x54, 0x4d, 0x40, 0x13, 0xa5, 0x4a, 0x28, + 0x02, 0x45, 0x35, 0x2a, 0x9a, 0x00, 0x78, 0x34, 0xf0, 0x69, 0x80, + 0x34, 0x81, 0x45, 0x40, 0xce, 0x58, 0xe6, 0xa2, 0x4c, 0x06, 0xe4, + 0xfa, 0xd1, 0x93, 0x50, 0x21, 0xca, 0xe4, 0x55, 0x84, 0x90, 0x30, + 0xab, 0x8b, 0x18, 0xa6, 0x9a, 0x6a, 0xc4, 0x31, 0xaa, 0x26, 0xa0, + 0x64, 0x4d, 0x51, 0xb5, 0x20, 0x23, 0x6a, 0x89, 0xa8, 0x02, 0x44, + 0x35, 0x2a, 0x9a, 0x00, 0x95, 0x4d, 0x48, 0xa6, 0x80, 0x24, 0x53, + 0x4e, 0xce, 0x05, 0x30, 0x2b, 0x3b, 0xee, 0x6a, 0x91, 0x5d, 0x76, + 0x63, 0xbd, 0x65, 0x7d, 0x40, 0x66, 0x68, 0xa9, 0x02, 0x45, 0x2b, + 0xb3, 0x9e, 0xb4, 0xc5, 0x6d, 0xad, 0x9a, 0xa0, 0x2c, 0x06, 0xc8, + 0xcd, 0x04, 0xd6, 0xa2, 0x23, 0x63, 0x51, 0xb1, 0xa0, 0x64, 0x4d, + 0x51, 0x93, 0x48, 0x08, 0xda, 0xa2, 0x6a, 0x00, 0x72, 0x1a, 0x99, + 0x4d, 0x00, 0x48, 0xa6, 0xa4, 0x53, 0x4c, 0x07, 0x86, 0x03, 0xbd, + 0x2b, 0x9c, 0xa7, 0x14, 0x98, 0x10, 0x85, 0x34, 0xe0, 0xa6, 0xb3, + 0xb0, 0x0b, 0xb5, 0xa8, 0x0a, 0xd4, 0x58, 0x42, 0xed, 0x3e, 0x94, + 0xd2, 0xa6, 0x8b, 0x01, 0x34, 0x44, 0xed, 0xe6, 0x9c, 0x4d, 0x6a, + 0x80, 0x8d, 0x8d, 0x46, 0xc6, 0x80, 0x23, 0x63, 0x51, 0x9a, 0x06, + 0x46, 0xd5, 0x13, 0x52, 0x01, 0x54, 0xd4, 0xaa, 0x68, 0x02, 0x40, + 0x6a, 0x40, 0x78, 0xa0, 0x08, 0x59, 0xce, 0xee, 0xb5, 0x2a, 0x39, + 0xd9, 0x59, 0xa7, 0xa8, 0x00, 0x73, 0xeb, 0x4e, 0x0e, 0x7d, 0x69, + 0x5c, 0x05, 0xf3, 0x0f, 0xad, 0x1e, 0x61, 0xf5, 0xa7, 0x71, 0x0b, + 0xe6, 0x35, 0x21, 0x90, 0xd3, 0xb8, 0x0e, 0x32, 0x10, 0x95, 0x10, + 0x91, 0xb3, 0xd6, 0x9b, 0x60, 0x4b, 0x9c, 0x8a, 0x63, 0x1a, 0xb0, + 0x18, 0x4d, 0x46, 0xc6, 0x80, 0x22, 0x6a, 0x61, 0xa4, 0x31, 0xaa, + 0x6a, 0x55, 0x34, 0x01, 0x2a, 0x9a, 0x7e, 0x78, 0xa0, 0x08, 0x09, + 0xf9, 0xaa, 0x58, 0xcf, 0xca, 0x6b, 0x3e, 0xa0, 0x00, 0xd3, 0x81, + 0xa9, 0x01, 0x73, 0x46, 0x69, 0x80, 0xb9, 0xa4, 0xcd, 0x00, 0x2b, + 0x1f, 0x92, 0xa3, 0x07, 0x9a, 0x6f, 0x70, 0x26, 0xcf, 0x14, 0xd2, + 0x6b, 0x51, 0x0c, 0x63, 0x51, 0xb1, 0xa0, 0x08, 0xda, 0x98, 0x69, + 0x0c, 0x8d, 0x4d, 0x4a, 0xa6, 0x80, 0x24, 0x53, 0x52, 0x03, 0xc5, + 0x02, 0x21, 0x27, 0xe6, 0xa9, 0x23, 0x3f, 0x29, 0xac, 0xfa, 0x8c, + 0x01, 0xe6, 0x9c, 0x0d, 0x48, 0x0a, 0x0d, 0x2e, 0x68, 0x01, 0x73, + 0x49, 0x9a, 0x60, 0x2b, 0x1f, 0x92, 0x98, 0x3a, 0xd3, 0x7b, 0x81, + 0x36, 0x78, 0xa6, 0x93, 0x5a, 0x88, 0x8c, 0x9a, 0x63, 0x1a, 0x00, + 0x8c, 0xd3, 0x0d, 0x21, 0x91, 0x29, 0xa9, 0x14, 0xd0, 0x04, 0x8a, + 0x69, 0xe0, 0xd3, 0x11, 0x1b, 0x1e, 0x6a, 0x48, 0xcf, 0xca, 0x6b, + 0x3e, 0xa3, 0x10, 0x1a, 0x70, 0x35, 0x20, 0x38, 0x1a, 0x5c, 0xd2, + 0x01, 0x73, 0x49, 0x9a, 0x60, 0x39, 0x8f, 0xca, 0x29, 0x8b, 0xf7, + 0xaa, 0xba, 0x88, 0x96, 0x9a, 0x6b, 0x40, 0x18, 0xc6, 0xa3, 0x26, + 0x80, 0x18, 0x69, 0xa6, 0x90, 0xc8, 0x14, 0xd4, 0x8a, 0x69, 0x80, + 0xf0, 0x6a, 0x40, 0x68, 0x10, 0xbb, 0x41, 0xa7, 0xe3, 0x0b, 0xc5, + 0x2b, 0x01, 0x10, 0xa7, 0x03, 0x59, 0x0c, 0x76, 0x69, 0x73, 0x40, + 0x0b, 0x9a, 0x28, 0x11, 0x28, 0x19, 0x5e, 0x69, 0x02, 0x81, 0x5a, + 0xd8, 0x00, 0xd3, 0x4d, 0x50, 0x0c, 0x6a, 0x8c, 0xd2, 0x01, 0xa6, + 0x98, 0x69, 0x0c, 0xae, 0xa6, 0xa4, 0x06, 0x80, 0x1e, 0xa6, 0x9e, + 0x0d, 0x31, 0x12, 0x03, 0x4f, 0x06, 0x80, 0x13, 0x60, 0x34, 0xd3, + 0xc1, 0xa8, 0x92, 0x01, 0xf1, 0x8d, 0xdd, 0x69, 0xcc, 0xa1, 0x69, + 0x5b, 0x4b, 0x80, 0x83, 0x93, 0x52, 0x04, 0x14, 0xe2, 0xae, 0x03, + 0xa9, 0x0d, 0x68, 0x03, 0x4d, 0x34, 0xd0, 0x03, 0x0d, 0x30, 0xd2, + 0x01, 0x86, 0x9a, 0x68, 0x19, 0x58, 0x1a, 0x78, 0xa4, 0x04, 0x8a, + 0x69, 0xe0, 0xd3, 0x10, 0xe0, 0x69, 0xe0, 0xd0, 0x03, 0xc1, 0xa8, + 0xdb, 0xad, 0x4c, 0x81, 0x12, 0x45, 0xd6, 0x9d, 0x25, 0x1d, 0x00, + 0x6a, 0xf5, 0xa9, 0xe8, 0x80, 0x31, 0x29, 0x0d, 0x58, 0x08, 0x69, + 0x86, 0x80, 0x1a, 0x69, 0x86, 0x90, 0x0c, 0x34, 0xd3, 0x48, 0x65, + 0x51, 0x4f, 0x06, 0x98, 0x0f, 0x14, 0xf0, 0x68, 0x10, 0xf0, 0x69, + 0xe0, 0xd0, 0x03, 0x81, 0xa5, 0x2b, 0x9a, 0x1a, 0xb8, 0x87, 0xa8, + 0xdb, 0x4a, 0x46, 0x68, 0xb6, 0x80, 0x2a, 0xa8, 0x14, 0xea, 0x12, + 0xb0, 0x05, 0x21, 0xa6, 0x02, 0x1a, 0x61, 0xa0, 0x06, 0x9a, 0x61, + 0xa4, 0x31, 0x86, 0x9a, 0x69, 0x0c, 0xa8, 0x0d, 0x3c, 0x53, 0x01, + 0xe2, 0x9e, 0x28, 0x10, 0xf1, 0x4e, 0x06, 0x98, 0x0f, 0x06, 0x9e, + 0x0d, 0x02, 0x1c, 0x29, 0xc2, 0x80, 0x16, 0x96, 0x80, 0x0a, 0x4a, + 0x00, 0x43, 0x4d, 0x34, 0x0c, 0x61, 0xa6, 0x1a, 0x40, 0x34, 0xd3, + 0x4d, 0x21, 0x80, 0xff, 0xd9, 0xff, 0xdb, 0x00, 0x84, 0x00, 0x0a, + 0x07, 0x07, 0x08, 0x07, 0x06, 0x0a, 0x08, 0x08, 0x08, 0x0b, 0x0a, + 0x0a, 0x0b, 0x0e, 0x18, 0x10, 0x0e, 0x0d, 0x0d, 0x0e, 0x1d, 0x15, + 0x16, 0x11, 0x18, 0x23, 0x1f, 0x25, 0x24, 0x22, 0x1f, 0x22, 0x21, + 0x26, 0x2b, 0x37, 0x2f, 0x26, 0x29, 0x34, 0x29, 0x21, 0x22, 0x30, + 0x41, 0x31, 0x34, 0x39, 0x3b, 0x3e, 0x3e, 0x3e, 0x25, 0x2e, 0x44, + 0x49, 0x43, 0x3c, 0x48, 0x37, 0x3d, 0x3e, 0x3b, 0x01, 0x0a, 0x0b, + 0x0b, 0x0e, 0x0d, 0x0e, 0x1c, 0x10, 0x10, 0x1c, 0x3b, 0x28, 0x22, + 0x28, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, + 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, + 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, + 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, + 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0x3b, 0xff, 0xc0, 0x00, 0x11, + 0x08, 0x00, 0x48, 0x00, 0x60, 0x03, 0x01, 0x21, 0x00, 0x02, 0x11, + 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x01, 0xa2, 0x00, 0x00, 0x01, + 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, + 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d, 0x01, + 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, + 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, + 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, + 0x62, 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, + 0x26, 0x27, 0x28, 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, + 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, + 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, + 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, + 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, + 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, + 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, + 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, + 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, + 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0x01, 0x00, 0x03, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, + 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, + 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, + 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, + 0xa1, 0xb1, 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, + 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, + 0x1a, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, + 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, + 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, + 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, + 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, + 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, + 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, + 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe2, + 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, 0xf4, + 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x0c, 0x03, + 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, 0x9e, 0xd2, + 0x2e, 0x07, 0x15, 0xaf, 0x6d, 0x08, 0xe2, 0xb3, 0x45, 0x1a, 0xf6, + 0xd0, 0x00, 0x01, 0xc5, 0x68, 0x45, 0x17, 0x4a, 0xb4, 0x22, 0xe4, + 0x70, 0x8c, 0x74, 0xa9, 0x3c, 0xa1, 0x8e, 0x95, 0x48, 0x96, 0x31, + 0xe2, 0x18, 0xe9, 0x55, 0xa5, 0x8c, 0x7a, 0x50, 0x05, 0x0b, 0x88, + 0x86, 0x0f, 0x15, 0x8f, 0x75, 0x1f, 0x26, 0x93, 0x19, 0x91, 0x77, + 0x18, 0xc1, 0xac, 0x4b, 0xc8, 0xfa, 0xd6, 0x63, 0x37, 0x6d, 0x31, + 0xb4, 0x73, 0x5b, 0x36, 0xa0, 0x1c, 0x50, 0x80, 0xd7, 0x83, 0xa0, + 0xab, 0xd1, 0x62, 0xad, 0x09, 0x8f, 0x17, 0x29, 0x03, 0xb2, 0xcc, + 0xe0, 0x77, 0x14, 0xa3, 0x56, 0xb3, 0x27, 0x1e, 0x67, 0xe9, 0x52, + 0xea, 0xc6, 0x3a, 0x36, 0x48, 0xef, 0x3d, 0x27, 0x70, 0x22, 0x60, + 0x47, 0x52, 0x69, 0xb2, 0xe2, 0xad, 0x3b, 0xea, 0x80, 0xa3, 0x38, + 0xe0, 0xd6, 0x3d, 0xd8, 0x1c, 0xd0, 0xca, 0x46, 0x3d, 0xd0, 0x18, + 0x35, 0x89, 0x78, 0xa3, 0x9a, 0xcd, 0x8c, 0xd2, 0xb3, 0x93, 0x2a, + 0x2b, 0x66, 0xd5, 0xf1, 0x8a, 0x10, 0x1a, 0xd6, 0xf2, 0x03, 0x8a, + 0x9e, 0xe6, 0xf4, 0x5a, 0xdb, 0xef, 0xfe, 0x23, 0xc0, 0xa7, 0x27, + 0xcb, 0x16, 0xc4, 0xcc, 0xdd, 0xe2, 0x78, 0x9a, 0x69, 0x66, 0xcc, + 0x99, 0xe1, 0x4d, 0x47, 0xba, 0xbc, 0xd9, 0x6a, 0xee, 0x26, 0x59, + 0x59, 0x4d, 0xac, 0x69, 0x34, 0x52, 0xe5, 0x8f, 0x55, 0xad, 0x58, + 0xae, 0x85, 0xc4, 0x22, 0x41, 0xdf, 0xad, 0x76, 0x61, 0xe5, 0x6f, + 0x74, 0x45, 0x69, 0xdc, 0x00, 0x79, 0xac, 0x8b, 0xa6, 0xc9, 0x35, + 0xd4, 0x34, 0x64, 0xdc, 0x37, 0x06, 0xb1, 0xae, 0x88, 0xc1, 0xac, + 0xd8, 0xc9, 0x2c, 0xa6, 0xe0, 0x73, 0x5b, 0x36, 0xf3, 0x74, 0xe6, + 0x84, 0x05, 0xe3, 0xa9, 0x47, 0x6a, 0x14, 0xb6, 0x49, 0x3d, 0x85, + 0x3a, 0xee, 0xee, 0x2b, 0xa8, 0xe2, 0x6f, 0x30, 0x81, 0xe9, 0x8a, + 0xca, 0xa4, 0xe2, 0xd3, 0x8b, 0x01, 0xb1, 0xf9, 0x04, 0x7f, 0xaf, + 0x23, 0xf0, 0xa9, 0x54, 0x41, 0x9c, 0xfd, 0xa3, 0xf4, 0xae, 0x65, + 0x18, 0xf7, 0x25, 0x8a, 0xe2, 0x02, 0x38, 0xb8, 0xfd, 0x2a, 0x7b, + 0x5b, 0xa8, 0x6d, 0x6d, 0x5d, 0x9a, 0x5d, 0xcb, 0xbb, 0xd2, 0xb6, + 0xa6, 0xa3, 0x19, 0x5e, 0xe2, 0x03, 0x7b, 0x1d, 0xc2, 0x17, 0x8d, + 0xb8, 0xac, 0xfb, 0x89, 0x39, 0x35, 0xd6, 0x9a, 0x6a, 0xe8, 0x66, + 0x55, 0xcb, 0xf5, 0xac, 0x7b, 0x96, 0xeb, 0x50, 0xc6, 0x88, 0x6d, + 0x66, 0xe9, 0xcd, 0x6c, 0xdb, 0x4f, 0xd3, 0x9a, 0x00, 0x2f, 0xe6, + 0xf9, 0xa3, 0xe7, 0xb5, 0x4a, 0x93, 0x7f, 0xa2, 0xc6, 0x73, 0xdc, + 0xd7, 0x15, 0x55, 0xef, 0x48, 0x7d, 0x09, 0x52, 0x6e, 0x3a, 0xd4, + 0xab, 0x2f, 0xbd, 0x61, 0x16, 0x0c, 0x73, 0x49, 0xc5, 0x24, 0x92, + 0x7f, 0xa2, 0x63, 0xfd, 0xaa, 0xd6, 0x2f, 0x71, 0x0e, 0xb1, 0x93, + 0xf7, 0x2d, 0xf5, 0xa4, 0x9e, 0x4e, 0xb5, 0xdd, 0x4b, 0xf8, 0x68, + 0x4c, 0xcb, 0xb9, 0x93, 0xad, 0x65, 0xce, 0xd9, 0x26, 0xa9, 0x8d, + 0x19, 0xf6, 0xf2, 0xf4, 0xe6, 0xb5, 0xad, 0xe7, 0xc6, 0x39, 0xa0, + 0x18, 0xeb, 0xc9, 0x77, 0x6c, 0x35, 0x2a, 0x4b, 0xfe, 0x8a, 0x9c, + 0xff, 0x00, 0x11, 0xae, 0x3a, 0x8b, 0xde, 0x61, 0xd0, 0x9e, 0x39, + 0xb8, 0xeb, 0x53, 0xac, 0xb9, 0xae, 0x5b, 0x00, 0xf3, 0x27, 0x14, + 0x92, 0xc9, 0xfe, 0x8a, 0x3f, 0xde, 0x35, 0xac, 0x3a, 0x88, 0x92, + 0xcd, 0xb1, 0x6e, 0x7d, 0xcd, 0x32, 0x67, 0xeb, 0xcd, 0x7a, 0x14, + 0xfe, 0x04, 0x26, 0x66, 0xce, 0xf9, 0x26, 0xb3, 0xe6, 0x6e, 0xb4, + 0xd9, 0x48, 0xc8, 0x82, 0x4e, 0x07, 0x35, 0xa7, 0x6f, 0x2f, 0x02, + 0x9a, 0x06, 0x5f, 0x8c, 0xa4, 0x83, 0x0e, 0x32, 0x2a, 0x69, 0xe3, + 0xdd, 0x12, 0x08, 0x97, 0x85, 0xec, 0x2a, 0x2a, 0x42, 0xf1, 0x76, + 0x26, 0xe4, 0x6a, 0x59, 0x0e, 0x18, 0x10, 0x6a, 0xd2, 0x89, 0x02, + 0x6e, 0x2a, 0x71, 0xeb, 0x5c, 0x1c, 0x8c, 0xa6, 0x48, 0xbb, 0xdc, + 0x61, 0x41, 0x35, 0x72, 0x28, 0x87, 0xd9, 0xf6, 0x4a, 0xb9, 0xe7, + 0x38, 0xae, 0x8c, 0x3d, 0x36, 0xdd, 0xde, 0xc4, 0xb0, 0x21, 0x51, + 0x76, 0xa8, 0xc0, 0xaa, 0x93, 0x31, 0xe6, 0xbb, 0x2d, 0x65, 0x61, + 0x19, 0xd3, 0x1e, 0xb5, 0x46, 0x5a, 0x96, 0x5a, 0x30, 0xa0, 0x7e, + 0x05, 0x69, 0x5b, 0xc9, 0xc6, 0x28, 0x40, 0xcd, 0x08, 0x64, 0x3c, + 0x73, 0x57, 0xe1, 0x94, 0xf1, 0xcd, 0x5a, 0x21, 0x8c, 0xb9, 0x63, + 0xe7, 0x67, 0x1d, 0xab, 0x40, 0xb1, 0xfb, 0x00, 0x1d, 0xf0, 0x2b, + 0x99, 0x2d, 0x66, 0x3e, 0x88, 0x75, 0x81, 0x3f, 0x31, 0xf6, 0xab, + 0x64, 0xd6, 0xb4, 0x17, 0xee, 0xd0, 0x9e, 0xe4, 0x32, 0x1a, 0xa7, + 0x31, 0xad, 0x18, 0x14, 0x26, 0xef, 0x54, 0xa5, 0xa8, 0x65, 0xa3, + 0x9c, 0x81, 0xfa, 0x56, 0x8c, 0x2d, 0xce, 0x68, 0x40, 0xcb, 0xf1, + 0x37, 0xbd, 0x5e, 0x85, 0xea, 0xd1, 0x0c, 0xbb, 0x19, 0x56, 0x23, + 0x20, 0x1f, 0xad, 0x5c, 0x42, 0x08, 0x03, 0xb5, 0x55, 0x91, 0x04, + 0xc9, 0x80, 0x38, 0x00, 0x0a, 0x71, 0x34, 0x6c, 0x32, 0x27, 0xe9, + 0x55, 0x25, 0x15, 0x2c, 0x68, 0xa3, 0x30, 0xeb, 0x54, 0xa5, 0x15, + 0x0c, 0xd1, 0x00, 0xff, 0xd9}; + + /* package */ static final byte[] sPhotoByteArrayForComplicatedCase; + + static { + final int length = sPhotoIntArrayForComplicatedCase.length; + sPhotoByteArrayForComplicatedCase = new byte[length]; + for (int i = 0; i < length; i++) { + sPhotoByteArrayForComplicatedCase[i] = (byte)sPhotoIntArrayForComplicatedCase[i]; + } + } + + public void testV21SimpleCase1_Parsing() { + mVerifier.initForImportTest(V21, R.raw.v21_simple_1); + mVerifier.addPropertyNodesVerifierElemWithoutVersion() // no "VERSION:2.1" line. + .addExpectedNodeWithOrder("N", "Ando;Roid;", Arrays.asList("Ando", "Roid", "")); + } + + public void testV21SimpleCase1_Type_Generic() { + mVerifier.initForImportTest(VCardConfig.VCARD_TYPE_V21_GENERIC, R.raw.v21_simple_1); + mVerifier.addContentValuesVerifierElem() + .addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "Ando") + .put(StructuredName.GIVEN_NAME, "Roid") + .put(StructuredName.DISPLAY_NAME, "Roid Ando"); + } + + public void testV21SimpleCase1_Type_Japanese() { + mVerifier.initForImportTest(VCardConfig.VCARD_TYPE_V21_JAPANESE, R.raw.v21_simple_1); + mVerifier.addContentValuesVerifierElem() + .addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "Ando") + .put(StructuredName.GIVEN_NAME, "Roid") + // If name-related strings only contains printable Ascii, + // the order is remained to be US's: + // "Prefix Given Middle Family Suffix" + .put(StructuredName.DISPLAY_NAME, "Roid Ando"); + } + + public void testV21SimpleCase2() { + mVerifier.initForImportTest(VCardConfig.VCARD_TYPE_V21_JAPANESE, R.raw.v21_simple_2); + mVerifier.addContentValuesVerifierElem() + .addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.DISPLAY_NAME, "Ando Roid"); + } + + public void testV21SimpleCase3() { + mVerifier.initForImportTest(V21, R.raw.v21_simple_3); + mVerifier.addContentValuesVerifierElem() + .addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "Ando") + .put(StructuredName.GIVEN_NAME, "Roid") + // "FN" field should be prefered since it should contain the original + // order intended by the author of the file. + .put(StructuredName.DISPLAY_NAME, "Ando Roid"); + } + + /** + * Tests ';' is properly handled by VCardParser implementation. + */ + public void testV21BackslashCase_Parsing() { + mVerifier.initForImportTest(V21, R.raw.v21_backslash); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", ";A;B\\;C\\;;D;:E;\\\\;", + Arrays.asList("", "A;B\\", "C\\;", "D", ":E", "\\\\", "")) + .addExpectedNodeWithOrder("FN", "A;B\\C\\;D:E\\\\"); + + } + + /** + * Tests ContactStruct correctly ignores redundant fields in "N" property values and + * inserts name related data. + */ + public void testV21BackslashCase() { + mVerifier.initForImportTest(V21, R.raw.v21_backslash); + mVerifier.addContentValuesVerifierElem() + .addExpected(StructuredName.CONTENT_ITEM_TYPE) + // FAMILY_NAME is empty and removed in this test... + .put(StructuredName.GIVEN_NAME, "A;B\\") + .put(StructuredName.MIDDLE_NAME, "C\\;") + .put(StructuredName.PREFIX, "D") + .put(StructuredName.SUFFIX, ":E") + .put(StructuredName.DISPLAY_NAME, "A;B\\C\\;D:E\\\\"); + } + + public void testOrgBeforTitle() { + mVerifier.initForImportTest(V21, R.raw.v21_org_before_title); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.DISPLAY_NAME, "Normal Guy"); + elem.addExpected(Organization.CONTENT_ITEM_TYPE) + .put(Organization.COMPANY, "Company") + .put(Organization.DEPARTMENT, "Organization Devision Room Sheet No.") + .put(Organization.TITLE, "Excellent Janitor") + .put(Organization.TYPE, Organization.TYPE_WORK); + } + + public void testTitleBeforOrg() { + mVerifier.initForImportTest(V21, R.raw.v21_title_before_org); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.DISPLAY_NAME, "Nice Guy"); + elem.addExpected(Organization.CONTENT_ITEM_TYPE) + .put(Organization.COMPANY, "Marverous") + .put(Organization.DEPARTMENT, "Perfect Great Good Bad Poor") + .put(Organization.TITLE, "Cool Title") + .put(Organization.TYPE, Organization.TYPE_WORK); + } + + /** + * Verifies that vCard importer correctly interpret "PREF" attribute to IS_PRIMARY. + * The data contain three cases: one "PREF", no "PREF" and multiple "PREF", in each type. + */ + public void testV21PrefToIsPrimary() { + mVerifier.initForImportTest(V21, R.raw.v21_pref_handling); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.DISPLAY_NAME, "Smith"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "1") + .put(Phone.TYPE, Phone.TYPE_HOME); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "2") + .put(Phone.TYPE, Phone.TYPE_WORK) + .put(Phone.IS_PRIMARY, 1); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "3") + .put(Phone.TYPE, Phone.TYPE_ISDN); + elem.addExpected(Email.CONTENT_ITEM_TYPE) + .put(Email.DATA, "test@example.com") + .put(Email.TYPE, Email.TYPE_HOME) + .put(Email.IS_PRIMARY, 1); + elem.addExpected(Email.CONTENT_ITEM_TYPE) + .put(Email.DATA, "test2@examination.com") + .put(Email.TYPE, Email.TYPE_MOBILE) + .put(Email.IS_PRIMARY, 1); + elem.addExpected(Organization.CONTENT_ITEM_TYPE) + .put(Organization.COMPANY, "Company") + .put(Organization.TITLE, "Engineer") + .put(Organization.TYPE, Organization.TYPE_WORK); + elem.addExpected(Organization.CONTENT_ITEM_TYPE) + .put(Organization.COMPANY, "Mystery") + .put(Organization.TITLE, "Blogger") + .put(Organization.TYPE, Organization.TYPE_WORK); + elem.addExpected(Organization.CONTENT_ITEM_TYPE) + .put(Organization.COMPANY, "Poetry") + .put(Organization.TITLE, "Poet") + .put(Organization.TYPE, Organization.TYPE_WORK); + } + + /** + * Tests all the properties in a complicated vCard are correctly parsed by the VCardParser for + * both v2.1 and v3.0 cards. + */ + public void testV21ComplicatedCase_Parsing() { + testComplicatedCase_Parsing(V21, R.raw.v21_complicated); + } + + public void testV30adr_types_various() { + mVerifier.initForImportTest(V30, R.raw.v30_adr_types); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "Familyname") + .put(StructuredName.GIVEN_NAME, "Givenname") + .put(StructuredName.DISPLAY_NAME, "Givenname Familyname"); + elem.addExpected(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_WORK) + .put(StructuredPostal.STREET, + "1010 Technology Pkwy, Silicon City, Somecountry") + .put(StructuredPostal.FORMATTED_ADDRESS, + "1010 Technology Pkwy, Silicon City, Somecountry"); + elem.addExpected(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_OTHER) + .put(StructuredPostal.STREET, + "123 Main St, Anytown, Anywhere") + .put(StructuredPostal.FORMATTED_ADDRESS, + "123 Main St, Anytown, Anywhere"); + elem.addExpected(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_CUSTOM) + .put(StructuredPostal.LABEL, "School") + .put(StructuredPostal.STREET, + "112358 Academic Lane, College Town, Someplace Great") + .put(StructuredPostal.FORMATTED_ADDRESS, + "112358 Academic Lane, College Town, Someplace Great"); + } + + public void testV30ComplicatedCase_Parsing() { + testComplicatedCase_Parsing(V30, R.raw.v30_complicated); + } + + public void testComplicatedCase_Parsing(int vcardType, int resId) { + mVerifier.initForImportTest(vcardType, resId); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "Gump;Forrest;Hoge;Pos;Tao", + Arrays.asList("Gump", "Forrest", "Hoge", "Pos", "Tao")) + .addExpectedNodeWithOrder("FN", "Joe Due") + .addExpectedNodeWithOrder("ORG", + "Gump Shrimp Co.;Sales Dept.;Manager;Fish keeper", + Arrays.asList("Gump Shrimp Co.", "Sales Dept.;Manager", "Fish keeper")) + .addExpectedNodeWithOrder("ROLE", "Fish Cake Keeper!") + .addExpectedNodeWithOrder("TITLE", "Shrimp Man") + .addExpectedNodeWithOrder("X-CLASS", "PUBLIC") + .addExpectedNodeWithOrder("TEL", "(111) 555-1212", new TypeSet("WORK", "VOICE")) + .addExpectedNodeWithOrder("TEL", "(404) 555-1212", new TypeSet("HOME", "VOICE")) + .addExpectedNodeWithOrder("TEL", "(404) 555-1212P*#55", new TypeSet("FAX")) + .addExpectedNodeWithOrder("TEL", "0311111111", new TypeSet("CELL")) + .addExpectedNodeWithOrder("TEL", "0322222222", new TypeSet("VIDEO")) + .addExpectedNodeWithOrder("TEL", "0333333333", new TypeSet("VOICE")) + .addExpectedNodeWithOrder("ADR", + ";;100 Waters Edge;Baytown;LA;30314;United States of America", + Arrays.asList("", "", "100 Waters Edge", "Baytown", + "LA", "30314", "United States of America"), + null, null, new TypeSet("WORK"), null) + .addExpectedNodeWithOrder("LABEL", + "100 Waters Edge\r\nBaytown, LA 30314\r\nUnited States of America", + null, null, mContentValuesForQP, new TypeSet("WORK"), null) + .addExpectedNodeWithOrder("ADR", + ";;42 Plantation St.;Baytown;LA;30314;United States of America", + Arrays.asList("", "", "42 Plantation St.", "Baytown", + "LA", "30314", "United States of America"), null, null, + new TypeSet("HOME"), null) + .addExpectedNodeWithOrder("LABEL", + "42 Plantation St.\r\nBaytown, LA 30314\r\nUnited States of America", + null, null, mContentValuesForQP, + new TypeSet("HOME"), null) + .addExpectedNodeWithOrder("EMAIL", "forrestgump@walladalla.com", + new TypeSet("PREF", "INTERNET")) + .addExpectedNodeWithOrder("EMAIL", "cell@example.com", new TypeSet("CELL")) + .addExpectedNodeWithOrder("NOTE", + "The following note is the example from RFC 2045.") + .addExpectedNodeWithOrder("NOTE", + "Now's the time for all folk to come to the aid of their country.", + null, null, mContentValuesForQP, null, null) + .addExpectedNodeWithOrder("PHOTO", null, + null, sPhotoByteArrayForComplicatedCase, mContentValuesForBase64V21, + new TypeSet("JPEG"), null) + .addExpectedNodeWithOrder("X-ATTRIBUTE", "Some String") + .addExpectedNodeWithOrder("BDAY", "19800101") + .addExpectedNodeWithOrder("GEO", "35.6563854,139.6994233") + .addExpectedNodeWithOrder("URL", "http://www.example.com/") + .addExpectedNodeWithOrder("REV", "20080424T195243Z"); + } + + /** + * Checks ContactStruct correctly inserts values in a complicated vCard + * into ContentResolver. + */ + public void testV21ComplicatedCase() { + testComplicatedCase(V21, R.raw.v21_complicated); + } + + public void testV30ComplicatedCase() { + testComplicatedCase(V30, R.raw.v30_complicated); + } + public void testComplicatedCase(int vcardType, int resId) { + mVerifier.initForImportTest(vcardType, resId); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "Gump") + .put(StructuredName.GIVEN_NAME, "Forrest") + .put(StructuredName.MIDDLE_NAME, "Hoge") + .put(StructuredName.PREFIX, "Pos") + .put(StructuredName.SUFFIX, "Tao") + .put(StructuredName.DISPLAY_NAME, "Joe Due"); + elem.addExpected(Organization.CONTENT_ITEM_TYPE) + .put(Organization.TYPE, Organization.TYPE_WORK) + .put(Organization.COMPANY, "Gump Shrimp Co.") + .put(Organization.DEPARTMENT, "Sales Dept.;Manager Fish keeper") + .put(Organization.TITLE, "Shrimp Man"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_WORK) + // Phone number is expected to be formated with NAMP format in default. + .put(Phone.NUMBER, "111-555-1212"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_HOME) + .put(Phone.NUMBER, "404-555-1212"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_OTHER_FAX) + .put(Phone.NUMBER, "4045551212,*#55"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_MOBILE) + .put(Phone.NUMBER, "031-111-1111"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "VIDEO") + .put(Phone.NUMBER, "032-222-2222"); + // vCard's VOICE type should become OTHER type on Android devices. + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_OTHER) + .put(Phone.NUMBER, "033-333-3333"); + elem.addExpected(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_WORK) + .put(StructuredPostal.COUNTRY, "United States of America") + .put(StructuredPostal.POSTCODE, "30314") + .put(StructuredPostal.REGION, "LA") + .put(StructuredPostal.CITY, "Baytown") + .put(StructuredPostal.STREET, "100 Waters Edge") + .put(StructuredPostal.FORMATTED_ADDRESS, + "100 Waters Edge Baytown LA 30314 United States of America"); + elem.addExpected(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_HOME) + .put(StructuredPostal.COUNTRY, "United States of America") + .put(StructuredPostal.POSTCODE, "30314") + .put(StructuredPostal.REGION, "LA") + .put(StructuredPostal.CITY, "Baytown") + .put(StructuredPostal.STREET, "42 Plantation St.") + .put(StructuredPostal.FORMATTED_ADDRESS, + "42 Plantation St. Baytown LA 30314 United States of America"); + elem.addExpected(Email.CONTENT_ITEM_TYPE) + // "TYPE=INTERNET" -> TYPE_CUSTOM + the label "INTERNET" + .put(Email.TYPE, Email.TYPE_CUSTOM) + .put(Email.LABEL, "INTERNET") + .put(Email.DATA, "forrestgump@walladalla.com") + .put(Email.IS_PRIMARY, 1); + elem.addExpected(Email.CONTENT_ITEM_TYPE) + .put(Email.TYPE, Email.TYPE_MOBILE) + .put(Email.DATA, "cell@example.com"); + elem.addExpected(Note.CONTENT_ITEM_TYPE) + .put(Note.NOTE, "The following note is the example from RFC 2045."); + elem.addExpected(Note.CONTENT_ITEM_TYPE) + .put(Note.NOTE, + "Now's the time for all folk to come to the aid of their country."); + elem.addExpected(Photo.CONTENT_ITEM_TYPE) + // No information about its image format can be inserted. + .put(Photo.PHOTO, sPhotoByteArrayForComplicatedCase); + elem.addExpected(Event.CONTENT_ITEM_TYPE) + .put(Event.START_DATE, "19800101") + .put(Event.TYPE, Event.TYPE_BIRTHDAY); + elem.addExpected(Website.CONTENT_ITEM_TYPE) + .put(Website.URL, "http://www.example.com/") + .put(Website.TYPE, Website.TYPE_HOMEPAGE); + } + + public void testInvalidMultipleLineV21() { + mVerifier.initForImportTest(V21, R.raw.v21_invalid_multiple_line); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.GIVEN_NAME, "Omega") + .put(StructuredName.DISPLAY_NAME, "Omega"); + elem.addExpected(Email.CONTENT_ITEM_TYPE) + .put(Email.TYPE, Email.TYPE_CUSTOM) + .put(Email.LABEL, "INTERNET") + .put(Email.ADDRESS, "\"Omega\" "); + } + + public void testAdrMultipleLineV21() { + ContentValues contentValuesForValue = new ContentValues(); + contentValuesForValue.put("VALUE", "DATE"); + + mVerifier.initForImportTest(V21, R.raw.v21_adr_multiple_line); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "bogus") + .addExpectedNodeWithOrder("URL", "http://bogus.com/") + .addExpectedNodeWithOrder("ADR", + ";;Grindelberg 999;Hamburg;;99999;Deutschland", + Arrays.asList("", "", "Grindelberg 999", "Hamburg", "", "99999", + "Deutschland"), + new TypeSet("HOME")) + .addExpectedNodeWithOrder("ADR", ";;Hermann v. Brevern\\ 9999999\\ " + + "Packstation 999;Hamburg;;99999;Deutschland", + Arrays.asList("", "", "Hermann v. Brevern\\ 9999999\\ Packstation 999", + "Hamburg", "", "99999", "Deutschland"), + new TypeSet("HOME")) + .addExpectedNodeWithOrder("BDAY", "20081203", contentValuesForValue); + } + + public void testV30Simple_Parsing() { + mVerifier.initForImportTest(V30, R.raw.v30_simple); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("FN", "And Roid") + .addExpectedNodeWithOrder("N", "And;Roid;;;", + Arrays.asList("And", "Roid", "", "", "")) + .addExpectedNodeWithOrder("ORG", "Open;Handset; Alliance", + Arrays.asList("Open", "Handset", " Alliance")) + .addExpectedNodeWithOrder("SORT-STRING", "android") + .addExpectedNodeWithOrder("TEL", "0300000000", new TypeSet("PREF", "VOICE")) + .addExpectedNodeWithOrder("CLASS", "PUBLIC") + .addExpectedNodeWithOrder("X-GNO", "0") + .addExpectedNodeWithOrder("X-GN", "group0") + .addExpectedNodeWithOrder("X-REDUCTION", "0") + .addExpectedNodeWithOrder("REV", "20081031T065854Z"); + } + + public void testV30Simple() { + mVerifier.initForImportTest(V30, R.raw.v30_simple); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "And") + .put(StructuredName.GIVEN_NAME, "Roid") + .put(StructuredName.DISPLAY_NAME, "And Roid") + .put(StructuredName.PHONETIC_GIVEN_NAME, "android"); + elem.addExpected(Organization.CONTENT_ITEM_TYPE) + .put(Organization.COMPANY, "Open") + .put(Organization.DEPARTMENT, "Handset Alliance") + .put(Organization.TYPE, Organization.TYPE_WORK); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_OTHER) + .put(Phone.NUMBER, "030-000-0000") + .put(Phone.IS_PRIMARY, 1); + } + + public void testV21Japanese1_Parsing() { + // Though Japanese careers append ";;;;" at the end of the value of "SOUND", + // vCard 2.1/3.0 specification does not allow multiple values. + // Do not need to handle it as multiple values. + mVerifier.initForImportTest(VCardConfig.VCARD_TYPE_V21_JAPANESE, R.raw.v21_japanese_1); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "\u5B89\u85E4\u30ED\u30A4\u30C9;;;;", + Arrays.asList("\u5B89\u85E4\u30ED\u30A4\u30C9", "", "", "", ""), + null, mContentValuesForSJis, null, null) + .addExpectedNodeWithOrder("SOUND", + "\uFF71\uFF9D\uFF84\uFF9E\uFF73\uFF9B\uFF72\uFF84\uFF9E;;;;", + null, null, mContentValuesForSJis, + new TypeSet("X-IRMC-N"), null) + .addExpectedNodeWithOrder("TEL", "0300000000", null, null, null, + new TypeSet("VOICE", "PREF"), null); + } + + private void testV21Japanese1Common(int resId, int vcardType, boolean japanese) { + mVerifier.initForImportTest(vcardType, resId); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\u5B89\u85E4\u30ED\u30A4\u30C9") + .put(StructuredName.DISPLAY_NAME, "\u5B89\u85E4\u30ED\u30A4\u30C9") + // While vCard parser does not split "SOUND" property values, + // ContactStruct care it. + .put(StructuredName.PHONETIC_GIVEN_NAME, + "\uFF71\uFF9D\uFF84\uFF9E\uFF73\uFF9B\uFF72\uFF84\uFF9E"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + // Phone number formatting is different. + .put(Phone.NUMBER, (japanese ? "03-0000-0000" : "030-000-0000")) + .put(Phone.TYPE, Phone.TYPE_OTHER) + .put(Phone.IS_PRIMARY, 1); + } + + /** + * Verifies vCard with Japanese can be parsed correctly with + * {@link com.android.vcard.VCardConfig#VCARD_TYPE_V21_GENERIC}. + */ + public void testV21Japanese1_Type_Generic_Utf8() { + testV21Japanese1Common( + R.raw.v21_japanese_1, VCardConfig.VCARD_TYPE_V21_GENERIC, false); + } + + /** + * Verifies vCard with Japanese can be parsed correctly with + * {@link com.android.vcard.VCardConfig#VCARD_TYPE_V21_JAPANESE}. + */ + public void testV21Japanese1_Type_Japanese_Sjis() { + testV21Japanese1Common( + R.raw.v21_japanese_1, VCardConfig.VCARD_TYPE_V21_JAPANESE, true); + } + + /** + * Verifies vCard with Japanese can be parsed correctly with + * {@link com.android.vcard.VCardConfig#VCARD_TYPE_V21_JAPANESE}. + * since vCard 2.1 specifies the charset of each line if it contains non-Ascii. + */ + public void testV21Japanese1_Type_Japanese_Utf8() { + testV21Japanese1Common( + R.raw.v21_japanese_1, VCardConfig.VCARD_TYPE_V21_JAPANESE, true); + } + + public void testV21Japanese2_Parsing() { + mVerifier.initForImportTest(VCardConfig.VCARD_TYPE_V21_JAPANESE, R.raw.v21_japanese_2); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "\u5B89\u85E4;\u30ED\u30A4\u30C9\u0031;;;", + Arrays.asList("\u5B89\u85E4", "\u30ED\u30A4\u30C9\u0031", + "", "", ""), + null, mContentValuesForSJis, null, null) + .addExpectedNodeWithOrder("FN", "\u5B89\u85E4\u0020\u30ED\u30A4\u30C9\u0020\u0031", + null, null, mContentValuesForSJis, null, null) + .addExpectedNodeWithOrder("SOUND", + "\uFF71\uFF9D\uFF84\uFF9E\uFF73;\uFF9B\uFF72\uFF84\uFF9E\u0031;;;", + null, null, mContentValuesForSJis, + new TypeSet("X-IRMC-N"), null) + .addExpectedNodeWithOrder("ADR", + ";\u6771\u4EAC\u90FD\u6E0B\u8C37\u533A\u685C" + + "\u4E18\u753A\u0032\u0036\u002D\u0031\u30BB" + + "\u30EB\u30EA\u30A2\u30F3\u30BF\u30EF\u30FC\u0036" + + "\u968E;;;;150-8512;", + Arrays.asList("", + "\u6771\u4EAC\u90FD\u6E0B\u8C37\u533A\u685C" + + "\u4E18\u753A\u0032\u0036\u002D\u0031\u30BB" + + "\u30EB\u30EA\u30A2\u30F3\u30BF\u30EF\u30FC" + + "\u0036\u968E", "", "", "", "150-8512", ""), + null, mContentValuesForQPAndSJis, new TypeSet("HOME"), null) + .addExpectedNodeWithOrder("NOTE", "\u30E1\u30E2", null, null, + mContentValuesForQPAndSJis, null, null); + } + + public void testV21Japanese2_Type_Generic_Utf8() { + mVerifier.initForImportTest(V21, R.raw.v21_japanese_2); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\u5B89\u85E4") + .put(StructuredName.GIVEN_NAME, "\u30ED\u30A4\u30C9\u0031") + .put(StructuredName.DISPLAY_NAME, + "\u5B89\u85E4\u0020\u30ED\u30A4\u30C9\u0020\u0031") + // ContactStruct should correctly split "SOUND" property into several elements, + // even though VCardParser side does not care it. + .put(StructuredName.PHONETIC_FAMILY_NAME, "\uFF71\uFF9D\uFF84\uFF9E\uFF73") + .put(StructuredName.PHONETIC_GIVEN_NAME, "\uFF9B\uFF72\uFF84\uFF9E\u0031"); + elem.addExpected(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.POSTCODE, "150-8512") + .put(StructuredPostal.STREET, + "\u6771\u4EAC\u90FD\u6E0B\u8C37\u533A\u685C" + + "\u4E18\u753A\u0032\u0036\u002D\u0031\u30BB" + + "\u30EB\u30EA\u30A2\u30F3\u30BF\u30EF\u30FC" + + "\u0036\u968E") + .put(StructuredPostal.FORMATTED_ADDRESS, + "\u6771\u4EAC\u90FD\u6E0B\u8C37\u533A\u685C" + + "\u4E18\u753A\u0032\u0036\u002D\u0031\u30BB" + + "\u30EB\u30EA\u30A2\u30F3\u30BF\u30EF\u30FC" + + "\u0036\u968E 150-8512") + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_HOME); + elem.addExpected(Note.CONTENT_ITEM_TYPE) + .put(Note.NOTE, "\u30E1\u30E2"); + } + + public void testV21Japanese3_Parsing() { + mVerifier.initForImportTest(VCardConfig.VCARD_TYPE_V21_JAPANESE, R.raw.v21_japanese_3); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "\u4f0a\u80fd;\u572d\u4e00;\u4f0a\u4e88;;", + Arrays.asList("\u4f0a\u80fd", "\u572d\u4e00", "\u4f0a\u4e88", + "", ""), + null, mContentValuesForSJis, null, null) + .addExpectedNodeWithOrder("FN", "\u4f0a\u80fd\u0020\u572d\u4e00\u0020\u4f0a\u4e88", + null, null, mContentValuesForSJis, null, null); + } + + public void testV21Japanese3_Parsing_Utf8() { + mVerifier.initForImportTest(V21, R.raw.v21_japanese_3); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "\u4f0a\u80fd;\u572d\u4e00;\u4f0a\u4e88;;", + Arrays.asList("\u4f0a\u80fd", "\u572d\u4e00", "\u4f0a\u4e88", + "", ""), + null, mContentValuesForSJis, null, null) + .addExpectedNodeWithOrder("FN", "\u4f0a\u80fd\u0020\u572d\u4e00\u0020\u4f0a\u4e88", + null, null, mContentValuesForSJis, null, null); + } + + public void testV30Japanese_Parsing() { + mVerifier.initForImportTest(V30, R.raw.v30_japanese); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "\u4f0a\u80fd;\u572d\u4e00;\u4f0a\u4e88;;", + Arrays.asList("\u4f0a\u80fd", "\u572d\u4e00", "\u4f0a\u4e88", + "", ""), + null, mContentValuesForSJis, null, null) + .addExpectedNodeWithOrder("FN", "\u4f0a\u80fd\u0020\u572d\u4e00\u0020\u4f0a\u4e88", + null, null, mContentValuesForSJis, null, null); + } + + public void testV21MultipleEntryCase_Parse() { + mVerifier.initForImportTest(VCardConfig.VCARD_TYPE_V21_JAPANESE, R.raw.v21_multiple_entry); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "\u5B89\u85E4\u30ED\u30A4\u30C9\u0033;;;;", + Arrays.asList("\u5B89\u85E4\u30ED\u30A4\u30C9\u0033", "", "", "", ""), + null, mContentValuesForSJis, null, null) + .addExpectedNodeWithOrder("SOUND", + "\uFF71\uFF9D\uFF84\uFF9E\uFF73\uFF9B\uFF72\uFF84\uFF9E\u0033;;;;", + null, null, mContentValuesForSJis, + new TypeSet("X-IRMC-N"), null) + .addExpectedNodeWithOrder("TEL", "9", new TypeSet("X-NEC-SECRET")) + .addExpectedNodeWithOrder("TEL", "10", new TypeSet("X-NEC-HOTEL")) + .addExpectedNodeWithOrder("TEL", "11", new TypeSet("X-NEC-SCHOOL")) + .addExpectedNodeWithOrder("TEL", "12", new TypeSet("FAX", "HOME")); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "\u5B89\u85E4\u30ED\u30A4\u30C9\u0034;;;;", + Arrays.asList("\u5B89\u85E4\u30ED\u30A4\u30C9\u0034", "", "", "", ""), + null, mContentValuesForSJis, null, null) + .addExpectedNodeWithOrder("SOUND", + "\uFF71\uFF9D\uFF84\uFF9E\uFF73\uFF9B\uFF72\uFF84\uFF9E\u0034;;;;", + null, null, mContentValuesForSJis, + new TypeSet("X-IRMC-N"), null) + .addExpectedNodeWithOrder("TEL", "13", new TypeSet("MODEM")) + .addExpectedNodeWithOrder("TEL", "14", new TypeSet("PAGER")) + .addExpectedNodeWithOrder("TEL", "15", new TypeSet("X-NEC-FAMILY")) + .addExpectedNodeWithOrder("TEL", "16", new TypeSet("X-NEC-GIRL")); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "\u5B89\u85E4\u30ED\u30A4\u30C9\u0035;;;;", + Arrays.asList("\u5B89\u85E4\u30ED\u30A4\u30C9\u0035", "", "", "", ""), + null, mContentValuesForSJis, null, null) + .addExpectedNodeWithOrder("SOUND", + "\uFF71\uFF9D\uFF84\uFF9E\uFF73\uFF9B\uFF72\uFF84\uFF9E\u0035;;;;", + null, null, mContentValuesForSJis, + new TypeSet("X-IRMC-N"), null) + .addExpectedNodeWithOrder("TEL", "17", new TypeSet("X-NEC-BOY")) + .addExpectedNodeWithOrder("TEL", "18", new TypeSet("X-NEC-FRIEND")) + .addExpectedNodeWithOrder("TEL", "19", new TypeSet("X-NEC-PHS")) + .addExpectedNodeWithOrder("TEL", "20", new TypeSet("X-NEC-RESTAURANT")); + } + + public void testV21MultipleEntryCase() { + mVerifier.initForImportTest(VCardConfig.VCARD_TYPE_V21_JAPANESE, R.raw.v21_multiple_entry); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\u5B89\u85E4\u30ED\u30A4\u30C9\u0033") + .put(StructuredName.DISPLAY_NAME, "\u5B89\u85E4\u30ED\u30A4\u30C9\u0033") + .put(StructuredName.PHONETIC_GIVEN_NAME, + "\uFF71\uFF9D\uFF84\uFF9E\uFF73\uFF9B\uFF72\uFF84\uFF9E\u0033"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "NEC-SECRET") + .put(Phone.NUMBER, "9"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "NEC-HOTEL") + .put(Phone.NUMBER, "10"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "NEC-SCHOOL") + .put(Phone.NUMBER, "11"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_FAX_HOME) + .put(Phone.NUMBER, "12"); + + elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\u5B89\u85E4\u30ED\u30A4\u30C9\u0034") + .put(StructuredName.DISPLAY_NAME, "\u5B89\u85E4\u30ED\u30A4\u30C9\u0034") + .put(StructuredName.PHONETIC_GIVEN_NAME, + "\uFF71\uFF9D\uFF84\uFF9E\uFF73\uFF9B\uFF72\uFF84\uFF9E\u0034"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "MODEM") + .put(Phone.NUMBER, "13"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_PAGER) + .put(Phone.NUMBER, "14"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "NEC-FAMILY") + .put(Phone.NUMBER, "15"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "NEC-GIRL") + .put(Phone.NUMBER, "16"); + + elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\u5B89\u85E4\u30ED\u30A4\u30C9\u0035") + .put(StructuredName.DISPLAY_NAME, "\u5B89\u85E4\u30ED\u30A4\u30C9\u0035") + .put(StructuredName.PHONETIC_GIVEN_NAME, + "\uFF71\uFF9D\uFF84\uFF9E\uFF73\uFF9B\uFF72\uFF84\uFF9E\u0035"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "NEC-BOY") + .put(Phone.NUMBER, "17"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "NEC-FRIEND") + .put(Phone.NUMBER, "18"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "NEC-PHS") + .put(Phone.NUMBER, "19"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "NEC-RESTAURANT") + .put(Phone.NUMBER, "20"); + } + + public void testIgnoreAgentV21_Parse() { + mVerifier.initForImportTest(V21, R.raw.v21_winmo_65); + ContentValues contentValuesForValue = new ContentValues(); + contentValuesForValue.put("VALUE", "DATE"); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", Arrays.asList("Example", "", "", "", "")) + .addExpectedNodeWithOrder("FN", "Example") + .addExpectedNodeWithOrder("ANNIVERSARY", "20091010", contentValuesForValue) + .addExpectedNodeWithOrder("AGENT", "") + .addExpectedNodeWithOrder("X-CLASS", "PUBLIC") + .addExpectedNodeWithOrder("X-REDUCTION", "") + .addExpectedNodeWithOrder("X-NO", ""); + } + + public void testIgnoreAgentV21() { + mVerifier.initForImportTest(V21, R.raw.v21_winmo_65); + ContentValuesVerifier verifier = new ContentValuesVerifier(); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "Example") + .put(StructuredName.DISPLAY_NAME, "Example"); + elem.addExpected(Event.CONTENT_ITEM_TYPE) + .put(Event.TYPE, Event.TYPE_ANNIVERSARY) + .put(Event.START_DATE, "20091010"); + } + + public void testTolerateInvalidCommentLikeLineV21() { + mVerifier.initForImportTest(V21, R.raw.v21_invalid_comment_line); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.GIVEN_NAME, "Conference Call") + .put(StructuredName.DISPLAY_NAME, "Conference Call"); + elem.addExpected(Note.CONTENT_ITEM_TYPE) + .put(Note.NOTE, "This is an (sharp ->#<- sharp) example. " + + "This message must NOT be ignored."); + } + + public void testPagerV30_Parse() { + mVerifier.initForImportTest(V30, R.raw.v30_pager); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", Arrays.asList("F", "G", "M", "", "")) + .addExpectedNodeWithOrder("TEL", "6101231234@pagersample.com", + new TypeSet("WORK", "MSG", "PAGER")); + } + + public void testPagerV30() { + mVerifier.initForImportTest(V30, R.raw.v30_pager); + ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "F") + .put(StructuredName.MIDDLE_NAME, "M") + .put(StructuredName.GIVEN_NAME, "G") + .put(StructuredName.DISPLAY_NAME, "G M F"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_PAGER) + .put(Phone.NUMBER, "6101231234@pagersample.com"); + } + + public void testMultiBytePropV30_Parse() { + mVerifier.initForImportTest(V30, R.raw.v30_multibyte_param); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", Arrays.asList("F", "G", "M", "", "")) + .addExpectedNodeWithOrder("TEL", "1", new TypeSet("\u8D39")); + } + + public void testMultiBytePropV30() { + mVerifier.initForImportTest(V30, R.raw.v30_multibyte_param); + final ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "F") + .put(StructuredName.MIDDLE_NAME, "M") + .put(StructuredName.GIVEN_NAME, "G") + .put(StructuredName.DISPLAY_NAME, "G M F"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "\u8D39") + .put(Phone.NUMBER, "1"); + } + + public void testCommaSeparatedParamsV30_Parse() { + mVerifier.initForImportTest(V30, R.raw.v30_comma_separated); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", Arrays.asList("F", "G", "M", "", ""), + new TypeSet("PREF", "HOME")) + .addExpectedNodeWithOrder("TEL", "1", + new TypeSet("COMMA,SEPARATED:INSIDE.DQUOTE", "PREF")); + } + + public void testSortAsV40_Parse() { + mVerifier.initForImportTest(V40, R.raw.v40_sort_as); + + final ContentValues contentValuesForSortAsN = new ContentValues(); + contentValuesForSortAsN.put("SORT-AS", + "\u3042\u3093\u3069\u3046;\u308D\u3044\u3069"); + final ContentValues contentValuesForSortAsOrg = new ContentValues(); + contentValuesForSortAsOrg.put("SORT-AS", + "\u3050\u30FC\u3050\u308B;\u3051\u3093\u3055\u304F\u3076\u3082\u3093"); + + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("FN", "\u5B89\u85E4\u0020\u30ED\u30A4\u30C9") + .addExpectedNodeWithOrder("N", + Arrays.asList("\u5B89\u85E4", "\u30ED\u30A4\u30C9", "", "", ""), + contentValuesForSortAsN) + .addExpectedNodeWithOrder("ORG", + Arrays.asList("\u30B0\u30FC\u30B0\u30EB", "\u691C\u7D22\u90E8\u9580"), + contentValuesForSortAsOrg, new TypeSet("WORK")); + } + + public void testSortAsV40() { + mVerifier.initForImportTest(V40, R.raw.v40_sort_as); + final ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\u5B89\u85E4") + .put(StructuredName.GIVEN_NAME, "\u30ED\u30A4\u30C9") + .put(StructuredName.DISPLAY_NAME, "\u5B89\u85E4\u0020\u30ED\u30A4\u30C9") + .put(StructuredName.PHONETIC_FAMILY_NAME, "\u3042\u3093\u3069\u3046") + .put(StructuredName.PHONETIC_GIVEN_NAME, + "\u308D\u3044\u3069"); + elem.addExpected(Organization.CONTENT_ITEM_TYPE) + .put(Organization.TYPE, Organization.TYPE_WORK) + .put(Organization.COMPANY, "\u30B0\u30FC\u30B0\u30EB") + .put(Organization.DEPARTMENT, "\u691C\u7D22\u90E8\u9580") + .put(Organization.PHONETIC_NAME, + "\u3050\u30FC\u3050\u308B\u3051\u3093\u3055\u304F\u3076\u3082\u3093"); + } + + public void testIMV21_Parse() { + mVerifier.initForImportTest(V21, R.raw.v21_im); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("X-ANDROID-CUSTOM", + Arrays.asList("vnd.android.cursor.item/nickname", "Nick", "1", + "", "", "", "", "", "", "", "", "", "", "", "", "")) // 13 + .addExpectedNodeWithOrder("X-GOOGLE-TALK", "hhh@gmail.com"); + } + + public void testIMV21() { + mVerifier.initForImportTest(V21, R.raw.v21_im); + final ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(Nickname.CONTENT_ITEM_TYPE) + .put(Nickname.NAME, "Nick") + .put(Nickname.TYPE, "1"); + elem.addExpected(Im.CONTENT_ITEM_TYPE) + .put(Im.PROTOCOL, Im.PROTOCOL_GOOGLE_TALK) + .put(Im.TYPE, Im.TYPE_HOME) + .put(Im.DATA, "hhh@gmail.com"); + } + + public void testSipV30_Parse() { + mVerifier.initForImportTest(V30, R.raw.v30_sip); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("FN", "Android") + .addExpectedNodeWithOrder("IMPP", "sip:android@android.example.com", + new TypeSet("personal")); + } + + public void testSipV30() { + mVerifier.initForImportTest(V30, R.raw.v30_sip); + final ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.DISPLAY_NAME, "Android"); + // Type is ignored silently. + elem.addExpected(SipAddress.CONTENT_ITEM_TYPE) + .put(SipAddress.TYPE, SipAddress.TYPE_CUSTOM) + .put(SipAddress.LABEL, "personal") + .put(SipAddress.SIP_ADDRESS, "android@android.example.com"); + } + + public void testSipV21_Parse() { + mVerifier.initForImportTest(V21, R.raw.v21_sip); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("FN", "Android") + .addExpectedNodeWithOrder("X-SIP", "888") + .addExpectedNodeWithOrder("X-SIP", "sip:90-180-360"); + } + + public void testSipV21() { + mVerifier.initForImportTest(V21, R.raw.v21_sip); + final ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.DISPLAY_NAME, "Android"); + elem.addExpected(SipAddress.CONTENT_ITEM_TYPE) + .put(SipAddress.TYPE, SipAddress.TYPE_OTHER) + .put(SipAddress.SIP_ADDRESS, "888"); + // "sip:" should be removed. + elem.addExpected(SipAddress.CONTENT_ITEM_TYPE) + .put(SipAddress.TYPE, SipAddress.TYPE_OTHER) + .put(SipAddress.SIP_ADDRESS, "90-180-360"); + } + + public void testSipV40() { + mVerifier.initForImportTest(V40, R.raw.v40_sip); + final ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\u5B89\u85E4") + .put(StructuredName.GIVEN_NAME, "\u30ED\u30A4\u30C9") + .put(StructuredName.DISPLAY_NAME, "\u5B89\u85E4\u0020\u30ED\u30A4\u30C9"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_HOME) + .put(Phone.NUMBER, "1"); + elem.addExpected(SipAddress.CONTENT_ITEM_TYPE) + .put(SipAddress.TYPE, SipAddress.TYPE_HOME) + .put(SipAddress.SIP_ADDRESS, "example@example.com"); + } + + public void testCustomPropertyV21_Parse() { + mVerifier.initForImportTest(V21, R.raw.v21_x_param); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "Ando;Roid;", Arrays.asList("Ando", "Roid", "")) + .addExpectedNodeWithOrder("ADR", "pobox;street", Arrays.asList("pobox", "street"), + new TypeSet("X-custom")) + .addExpectedNodeWithOrder("TEL", "1", new TypeSet("X-CuStoMpRop")) + .addExpectedNodeWithOrder("TEL", "2", new TypeSet("custompropertywithoutx")) + .addExpectedNodeWithOrder("EMAIL", "email@example.com", + new TypeSet("X-cUstomPrOperty")) + .addExpectedNodeWithOrder("EMAIL", "email2@example.com", + new TypeSet("CUSTOMPROPERTYWITHOUTX")); + } + + public void testCustomPropertyV21() { + mVerifier.initForImportTest(V21, R.raw.v21_x_param); + final ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "Ando") + .put(StructuredName.GIVEN_NAME, "Roid") + .put(StructuredName.DISPLAY_NAME, "Roid Ando"); + elem.addExpected(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_CUSTOM) + .put(StructuredPostal.LABEL, "custom") + .put(StructuredPostal.POBOX, "pobox") + .put(StructuredPostal.STREET, "street") + .put(StructuredPostal.FORMATTED_ADDRESS, "pobox street"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "CuStoMpRop") + .put(Phone.NUMBER, "1"); + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_CUSTOM) + .put(Phone.LABEL, "custompropertywithoutx") + .put(Phone.NUMBER, "2"); + elem.addExpected(Email.CONTENT_ITEM_TYPE) + .put(Email.TYPE, Email.TYPE_CUSTOM) + .put(Email.LABEL, "cUstomPrOperty") + .put(Email.ADDRESS, "email@example.com"); + elem.addExpected(Email.CONTENT_ITEM_TYPE) + .put(Email.TYPE, Email.TYPE_CUSTOM) + .put(Email.LABEL, "CUSTOMPROPERTYWITHOUTX") + .put(Email.ADDRESS, "email2@example.com"); + } + + public void testBase64Without2CrLf_Parse() { + mVerifier.initForImportTest(V21, R.raw.v21_base64_no_2_crlf); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "name") + .addExpectedNodeWithOrder("FN", "fullname") + .addExpectedNodeWithOrder("PHOTO", null, + null, sPhotoByteArrayForComplicatedCase, mContentValuesForBase64V21, + new TypeSet("JPEG"), null); + } + + public void testBase64Without2CrLfForBlackBerry_Parse() { + mVerifier.initForImportTest(V21, R.raw.v21_blackberry_photo); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("FN", "boogie") + .addExpectedNodeWithOrder("N", "boogie") + .addExpectedNodeWithOrder("PHOTO", null, + null, sPhotoByteArrayForComplicatedCase, mContentValuesForBase64V21, + null, null) + .addExpectedNodeWithOrder("TEL", "+5555555", new TypeSet("WORK")) + .addExpectedNodeWithOrder("TEL", "+5555556", new TypeSet("CELL")) + .addExpectedNodeWithOrder("EMAIL", "forrestgump@walladalla.com", + new TypeSet("INTERNET")); + } + + public void testMalformedBase64PhotoThrowsVCardException() { + mVerifier.initForImportTest(V21, R.raw.v21_malformed_photo); + + String expectedMsgContent = "qgEPAAIAAAAHAAAAugEQAAIAAAAG:ASDF=="; + mVerifier.addVCardExceptionVerifier(expectedMsgContent); + } + + public void testAndroidCustomPropertyV21() { + mVerifier.initForImportTest(V21, R.raw.v21_android_custom_prop); + final ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected("custom_mime1") + .put("data1", "1").put("data2", "2").put("data3", "3").put("data4", "4") + .put("data5", "5").put("data6", "6").put("data7", "7").put("data8", "8") + .put("data9", "9").put("data10", "10").put("data11", "11").put("data12", "12") + .put("data13", "13").put("data14", "14").put("data15", "15"); + // 16'th elemnt ('p') should be ignored + elem.addExpected("custom_mime2") + .put("data1", "a").put("data2", "b").put("data3", "c").put("data4", "d") + .put("data5", "e").put("data6", "f").put("data7", "g").put("data8", "h") + .put("data9", "i").put("data10", "j").put("data11", "k").put("data12", "l") + .put("data13", "m").put("data14", "n").put("data15", "o"); + + // custom_mime3 shouldn't be here, as there's no data + + // Smoke test. + elem.addExpected("custom_mime4").put("data1", "z"); + } + + public void testPauseWaitV30_Parse() { + mVerifier.initForImportTest(V30, R.raw.v30_pause_wait); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("FN", "Pause Wait") + .addExpectedNodeWithOrder("N", "Pause;Wait;;;", + Arrays.asList("Pause", "Wait", "", "", "")) + .addExpectedNodeWithOrder("TEL", "p1234p5678w9"); + } + + public void testPauseWaitV30() { + mVerifier.initForImportTest(V30, R.raw.v30_pause_wait); + final ContentValuesVerifierElem elem = mVerifier.addContentValuesVerifierElem(); + elem.addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "Pause") + .put(StructuredName.GIVEN_NAME, "Wait") + .put(StructuredName.DISPLAY_NAME, "Pause Wait"); + // See PhoneNumberUtils in Android SDK. + elem.addExpected(Phone.CONTENT_ITEM_TYPE) + .put(Phone.TYPE, Phone.TYPE_OTHER) + .put(Phone.NUMBER, ",1234,5678;9"); + } + + // We ran into a case where a 2.1 vcard was sent with a 3.0+ property, + // X-ANDROID-CUSTOM. If that property followed photo data without a blank line, + // getBase64() would append the X-ANDROID-CUSTOM data line to the photo data. + // When Base64.decode() was called with this data, it would throw an exception. + // Besides looking for the normal 2.1 properties, we need to look for + // X-ANDROID-CUSTOM as well. Here's an example of a bad vcard that would cause + // this problem (This vcard was generated by a KLP MR2 Nexus 5 or a KLP Moto X): + // ... + // LHnTW/l/wB9f+PV2UcFyfGeVis3qVPch7pWjs/s+m/2Zpdp5f8Ayy/df6uPZWNcR9dRzWoZPb + // /l4/xpotvtH2vj/l39f8/99f8AxFdvKjwD/9k= + // X-ANDROID-CUSTOM:vnd.android.cursor.item/contact_event;1999-07-10;1;;;;;;;;;;;;; + // BDAY:1975-08-20 + // END:VCARD + // + // Here's how to build and run the tests in this file: + // mmm frameworks/opt/vcard + // adb install -r -d out/target/product/hammerhead/data/app/AndroidVCardTests.apk + // adb shell am instrument -w com.android.vcard.tests/android.test.InstrumentationTestRunner + // + public void testV21XAndroidCustomAfterPhoto() { + mVerifier.initForImportTest(V21, R.raw.v21_x_android_custom_after_photo); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", "Ando;Roid;", Arrays.asList("Ando", "Roid", "")) + .addExpectedNodeWithOrder("PHOTO", null, + null, sPhotoByteArrayForComplicatedCase, mContentValuesForBase64V21, + null, null) + .addExpectedNodeWithOrder("X-ANDROID-CUSTOM", + "vnd.android.cursor.item/contact_event;1999-07-10;1;;;;;;;;;;;;;") + .addExpectedNodeWithOrder("BDAY", "1975-08-20"); + } + +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/VCardJapanizationTests.java b/modules/vcard/tests/src/com/android/vcard/tests/VCardJapanizationTests.java new file mode 100644 index 0000000..5921e31 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/VCardJapanizationTests.java @@ -0,0 +1,440 @@ +/* + * 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.vcard.tests; + +import android.content.ContentValues; +import android.provider.ContactsContract.CommonDataKinds.Nickname; +import android.provider.ContactsContract.CommonDataKinds.Note; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; + +import com.android.vcard.VCardConfig; +import com.android.vcard.VCardConstants; +import com.android.vcard.tests.testutils.ContactEntry; +import com.android.vcard.tests.testutils.ContentValuesBuilder; +import com.android.vcard.tests.testutils.PropertyNodesVerifierElem; +import com.android.vcard.tests.testutils.VCardTestsBase; +import com.android.vcard.tests.testutils.PropertyNodesVerifierElem.TypeSet; + +import java.util.Arrays; + +public class VCardJapanizationTests extends VCardTestsBase { + private void testNameUtf8Common(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\u3075\u308B\u3069") + .put(StructuredName.GIVEN_NAME, "\u3091\u308A\u304B") + .put(StructuredName.MIDDLE_NAME, "B") + .put(StructuredName.PREFIX, "Dr.") + .put(StructuredName.SUFFIX, "Ph.D"); + ContentValues contentValues = + (VCardConfig.isVersion21(vcardType) ? mContentValuesForQPAndUtf8 : null); + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNode("FN", "Dr. \u3075\u308B\u3069 B \u3091\u308A\u304B Ph.D", + contentValues) + .addExpectedNode("N", "\u3075\u308B\u3069;\u3091\u308A\u304B;B;Dr.;Ph.D", + Arrays.asList( + "\u3075\u308B\u3069", "\u3091\u308A\u304B", "B", "Dr.", "Ph.D"), + null, contentValues, null, null); + } + + public void testNameUtf8V21() { + testNameUtf8Common(VCardConfig.VCARD_TYPE_V21_JAPANESE); + } + + public void testNameUtf8V30() { + testNameUtf8Common(VCardConfig.VCARD_TYPE_V30_JAPANESE); + } + + public void testNameShiftJis() { + mVerifier.initForExportTest(VCardConfig.VCARD_TYPE_V30_JAPANESE, "Shift_JIS"); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\u3075\u308B\u3069") + .put(StructuredName.GIVEN_NAME, "\u3091\u308A\u304B") + .put(StructuredName.MIDDLE_NAME, "B") + .put(StructuredName.PREFIX, "Dr.") + .put(StructuredName.SUFFIX, "Ph.D"); + + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNode("FN", "Dr. \u3075\u308B\u3069 B \u3091\u308A\u304B Ph.D", + mContentValuesForSJis) + .addExpectedNode("N", "\u3075\u308B\u3069;\u3091\u308A\u304B;B;Dr.;Ph.D", + Arrays.asList( + "\u3075\u308B\u3069", "\u3091\u308A\u304B", "B", "Dr.", "Ph.D"), + null, mContentValuesForSJis, null, null); + } + + /** + * DoCoMo phones require all name elements should be in "family name" field. + */ + public void testNameDoCoMo() { + mVerifier.initForExportTest(VCardConfig.VCARD_TYPE_DOCOMO, "Shift_JIS"); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\u3075\u308B\u3069") + .put(StructuredName.GIVEN_NAME, "\u3091\u308A\u304B") + .put(StructuredName.MIDDLE_NAME, "B") + .put(StructuredName.PREFIX, "Dr.") + .put(StructuredName.SUFFIX, "Ph.D"); + + final String fullName = "Dr. \u3075\u308B\u3069 B \u3091\u308A\u304B Ph.D"; + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNode("N", fullName + ";;;;", + Arrays.asList(fullName, "", "", "", ""), + null, mContentValuesForSJis, null, null) + .addExpectedNode("FN", fullName, mContentValuesForSJis) + .addExpectedNode("SOUND", ";;;;", new TypeSet("X-IRMC-N")) + .addExpectedNode("TEL", "", new TypeSet("HOME")) + .addExpectedNode("EMAIL", "", new TypeSet("HOME")) + .addExpectedNode("ADR", "", new TypeSet("HOME")) + .addExpectedNode("X-CLASS", "PUBLIC") + .addExpectedNode("X-REDUCTION", "") + .addExpectedNode("X-NO", "") + .addExpectedNode("X-DCM-HMN-MODE", ""); + } + + private void testPhoneticNameCommon(int vcardType, String charset) { + mVerifier.initForExportTest(vcardType, charset); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.PHONETIC_FAMILY_NAME, "\u3084\u307E\u3060") + .put(StructuredName.PHONETIC_MIDDLE_NAME, "\u30DF\u30C9\u30EB\u30CD\u30FC\u30E0") + .put(StructuredName.PHONETIC_GIVEN_NAME, "\u305F\u308D\u3046"); + + final ContentValues contentValues = + ("SHIFT_JIS".equalsIgnoreCase(charset) ? + (VCardConfig.isVersion21(vcardType) ? mContentValuesForQPAndSJis : + mContentValuesForSJis) : + (VCardConfig.isVersion21(vcardType) ? mContentValuesForQPAndUtf8 : null)); + PropertyNodesVerifierElem elem = mVerifier.addPropertyNodesVerifierElemWithEmptyName(); + elem.addExpectedNode("X-PHONETIC-LAST-NAME", "\u3084\u307E\u3060", + contentValues) + .addExpectedNode("X-PHONETIC-MIDDLE-NAME", + "\u30DF\u30C9\u30EB\u30CD\u30FC\u30E0", + contentValues) + .addExpectedNode("X-PHONETIC-FIRST-NAME", "\u305F\u308D\u3046", + contentValues); + if (!VCardConfig.isVersion21(vcardType)) { + elem.addExpectedNode("SORT-STRING", + "\u3084\u307E\u3060 \u30DF\u30C9\u30EB\u30CD\u30FC\u30E0 \u305F\u308D\u3046", + contentValues); + } + ContentValuesBuilder builder = mVerifier.addContentValuesVerifierElem() + .addExpected(StructuredName.CONTENT_ITEM_TYPE); + builder.put(StructuredName.PHONETIC_FAMILY_NAME, "\u3084\u307E\u3060") + .put(StructuredName.PHONETIC_MIDDLE_NAME, "\u30DF\u30C9\u30EB\u30CD\u30FC\u30E0") + .put(StructuredName.PHONETIC_GIVEN_NAME, "\u305F\u308D\u3046") + .put(StructuredName.DISPLAY_NAME, + "\u3084\u307E\u3060 \u30DF\u30C9\u30EB\u30CD\u30FC\u30E0 " + + "\u305F\u308D\u3046"); + } + + public void testPhoneticNameForJapaneseV21Utf8() { + testPhoneticNameCommon(VCardConfig.VCARD_TYPE_V21_JAPANESE, null); + } + + public void testPhoneticNameForJapaneseV21Sjis() { + testPhoneticNameCommon(VCardConfig.VCARD_TYPE_V21_JAPANESE, "Shift_JIS"); + } + + public void testPhoneticNameForJapaneseV30Utf8() { + testPhoneticNameCommon(VCardConfig.VCARD_TYPE_V30_JAPANESE, null); + } + + public void testPhoneticNameForJapaneseV30SJis() { + testPhoneticNameCommon(VCardConfig.VCARD_TYPE_V30_JAPANESE, "Shift_JIS"); + } + + public void testPhoneticNameForMobileV21_1() { + mVerifier.initForExportTest(VCardConfig.VCARD_TYPE_V21_JAPANESE_MOBILE, "Shift_JIS"); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.PHONETIC_FAMILY_NAME, "\u3084\u307E\u3060") + .put(StructuredName.PHONETIC_MIDDLE_NAME, "\u30DF\u30C9\u30EB\u30CD\u30FC\u30E0") + .put(StructuredName.PHONETIC_GIVEN_NAME, "\u305F\u308D\u3046"); + + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNode("SOUND", + "\uFF94\uFF8F\uFF80\uFF9E \uFF90\uFF84\uFF9E\uFF99\uFF88\uFF70\uFF91 " + + "\uFF80\uFF9B\uFF73;;;;", + mContentValuesForSJis, new TypeSet("X-IRMC-N")); + ContentValuesBuilder builder = mVerifier.addContentValuesVerifierElem() + .addExpected(StructuredName.CONTENT_ITEM_TYPE); + builder.put(StructuredName.PHONETIC_FAMILY_NAME, "\uFF94\uFF8F\uFF80\uFF9E") + .put(StructuredName.PHONETIC_MIDDLE_NAME, + "\uFF90\uFF84\uFF9E\uFF99\uFF88\uFF70\uFF91") + .put(StructuredName.PHONETIC_GIVEN_NAME, "\uFF80\uFF9B\uFF73") + .put(StructuredName.DISPLAY_NAME, + "\uFF94\uFF8F\uFF80\uFF9E \uFF90\uFF84\uFF9E\uFF99\uFF88\uFF70\uFF91 " + + "\uFF80\uFF9B\uFF73"); + } + + public void testPhoneticNameForMobileV21_2() { + mVerifier.initForExportTest(VCardConfig.VCARD_TYPE_V21_JAPANESE_MOBILE, "Shift_JIS"); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.PHONETIC_FAMILY_NAME, "\u3084\u307E\u3060") + .put(StructuredName.PHONETIC_GIVEN_NAME, "\u305F\u308D\u3046"); + + mVerifier.addPropertyNodesVerifierElem() + .addExpectedNode("SOUND", "\uFF94\uFF8F\uFF80\uFF9E \uFF80\uFF9B\uFF73;;;;", + mContentValuesForSJis, new TypeSet("X-IRMC-N")); + ContentValuesBuilder builder = mVerifier.addContentValuesVerifierElem() + .addExpected(StructuredName.CONTENT_ITEM_TYPE); + builder.put(StructuredName.PHONETIC_FAMILY_NAME, "\uFF94\uFF8F\uFF80\uFF9E") + .put(StructuredName.PHONETIC_GIVEN_NAME, "\uFF80\uFF9B\uFF73") + .put(StructuredName.DISPLAY_NAME, "\uFF94\uFF8F\uFF80\uFF9E \uFF80\uFF9B\uFF73"); + } + + private void testPostalAddressWithJapaneseCommon(int vcardType, String charset) { + mVerifier.initForExportTest(vcardType, charset); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.POBOX, "\u79C1\u66F8\u7BB107") + .put(StructuredPostal.STREET, "\u96DB\u898B\u6CA2\u6751") + .put(StructuredPostal.CITY, "\u9E7F\u9AA8\u5E02") + .put(StructuredPostal.REGION, "\u00D7\u00D7\u770C") + .put(StructuredPostal.POSTCODE, "494-1313") + .put(StructuredPostal.COUNTRY, "\u65E5\u672C") + .put(StructuredPostal.FORMATTED_ADDRESS, + "\u3053\u3093\u306A\u3068\u3053\u308D\u3092\u898B" + + "\u308B\u306A\u3093\u3066\u6687\u4EBA\u3067\u3059\u304B\uFF1F") + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_CUSTOM) + .put(StructuredPostal.LABEL, "\u304A\u3082\u3061\u304B\u3048\u308A"); + + ContentValues contentValues = ("UTF-8".equalsIgnoreCase(charset) ? + (VCardConfig.isVersion21(vcardType) ? mContentValuesForQPAndSJis : + mContentValuesForSJis) : + (VCardConfig.isVersion21(vcardType) ? mContentValuesForQPAndUtf8 : + mContentValuesForUtf8)); + + PropertyNodesVerifierElem elem = mVerifier.addPropertyNodesVerifierElemWithEmptyName(); + // LABEL must be ignored in vCard 2.1. As for vCard 3.0, the current behavior is + // same as that in vCard 3.0, which can be changed in the future. + elem.addExpectedNode("ADR", Arrays.asList("\u79C1\u66F8\u7BB107", + "", "\u96DB\u898B\u6CA2\u6751", "\u9E7F\u9AA8\u5E02", "\u00D7\u00D7\u770C", + "494-1313", "\u65E5\u672C"), + contentValues); + mVerifier.addContentValuesVerifierElem().addExpected(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.POBOX, "\u79C1\u66F8\u7BB107") + .put(StructuredPostal.STREET, "\u96DB\u898B\u6CA2\u6751") + .put(StructuredPostal.CITY, "\u9E7F\u9AA8\u5E02") + .put(StructuredPostal.REGION, "\u00D7\u00D7\u770C") + .put(StructuredPostal.POSTCODE, "494-1313") + .put(StructuredPostal.COUNTRY, "\u65E5\u672C") + .put(StructuredPostal.FORMATTED_ADDRESS, + "\u65E5\u672C 494-1313 \u00D7\u00D7\u770C \u9E7F\u9AA8\u5E02 " + + "\u96DB\u898B\u6CA2\u6751 " + "\u79C1\u66F8\u7BB107") + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_HOME); + } + public void testPostalAddresswithJapaneseV21() { + testPostalAddressWithJapaneseCommon(VCardConfig.VCARD_TYPE_V21_JAPANESE, "Shift_JIS"); + } + + /** + * Verifies that only one address field is emitted toward DoCoMo phones. + * Prefered type must (should?) be: HOME > WORK > OTHER > CUSTOM + */ + public void testPostalAdrressForDoCoMo_1() { + mVerifier.initForExportTest(VCardConfig.VCARD_TYPE_DOCOMO, "Shift_JIS"); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_WORK) + .put(StructuredPostal.POBOX, "1"); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_OTHER) + .put(StructuredPostal.POBOX, "2"); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_HOME) + .put(StructuredPostal.POBOX, "3"); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_CUSTOM) + .put(StructuredPostal.LABEL, "custom") + .put(StructuredPostal.POBOX, "4"); + + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "", new TypeSet("HOME")) + .addExpectedNode("EMAIL", "", new TypeSet("HOME")) + .addExpectedNode("X-CLASS", "PUBLIC") + .addExpectedNode("X-REDUCTION", "") + .addExpectedNode("X-NO", "") + .addExpectedNode("X-DCM-HMN-MODE", "") + .addExpectedNode("ADR", + Arrays.asList("3", "", "", "", "", "", ""), new TypeSet("HOME")); + } + + public void testPostalAdrressForDoCoMo_2() { + mVerifier.initForExportTest(VCardConfig.VCARD_TYPE_DOCOMO, "Shift_JIS"); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_OTHER) + .put(StructuredPostal.POBOX, "1"); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_WORK) + .put(StructuredPostal.POBOX, "2"); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_CUSTOM) + .put(StructuredPostal.LABEL, "custom") + .put(StructuredPostal.POBOX, "3"); + + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "", new TypeSet("HOME")) + .addExpectedNode("EMAIL", "", new TypeSet("HOME")) + .addExpectedNode("X-CLASS", "PUBLIC") + .addExpectedNode("X-REDUCTION", "") + .addExpectedNode("X-NO", "") + .addExpectedNode("X-DCM-HMN-MODE", "") + .addExpectedNode("ADR", + Arrays.asList("2", "", "", "", "", "", ""), new TypeSet("WORK")); + } + + public void testPostalAdrressForDoCoMo_3() { + mVerifier.initForExportTest(VCardConfig.VCARD_TYPE_DOCOMO, "Shift_JIS"); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_CUSTOM) + .put(StructuredPostal.LABEL, "custom1") + .put(StructuredPostal.POBOX, "1"); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_OTHER) + .put(StructuredPostal.POBOX, "2"); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_CUSTOM) + .put(StructuredPostal.LABEL, "custom2") + .put(StructuredPostal.POBOX, "3"); + + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "", new TypeSet("HOME")) + .addExpectedNode("EMAIL", "", new TypeSet("HOME")) + .addExpectedNode("X-CLASS", "PUBLIC") + .addExpectedNode("X-REDUCTION", "") + .addExpectedNode("X-NO", "") + .addExpectedNode("X-DCM-HMN-MODE", "") + .addExpectedNode("ADR", + Arrays.asList("2", "", "", "", "", "", ""), + new TypeSet("X-" + VCardConstants.PARAM_ADR_EXTRA_TYPE_OTHER)); + } + + /** + * Verifies the vCard exporter tolerates null TYPE. + */ + public void testPostalAdrressForDoCoMo_4() { + mVerifier.initForExportTest(VCardConfig.VCARD_TYPE_DOCOMO, "Shift_JIS"); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.POBOX, "1"); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_OTHER) + .put(StructuredPostal.POBOX, "2"); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_HOME) + .put(StructuredPostal.POBOX, "3"); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.TYPE, StructuredPostal.TYPE_WORK) + .put(StructuredPostal.POBOX, "4"); + entry.addContentValues(StructuredPostal.CONTENT_ITEM_TYPE) + .put(StructuredPostal.POBOX, "5"); + + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "", new TypeSet("HOME")) + .addExpectedNode("EMAIL", "", new TypeSet("HOME")) + .addExpectedNode("X-CLASS", "PUBLIC") + .addExpectedNode("X-REDUCTION", "") + .addExpectedNode("X-NO", "") + .addExpectedNode("X-DCM-HMN-MODE", "") + .addExpectedNode("ADR", + Arrays.asList("3", "", "", "", "", "", ""), new TypeSet("HOME")); + } + + private void testJapanesePhoneNumberCommon(int vcardType) { + mVerifier.initForExportTest(vcardType); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "0312341234") + .put(Phone.TYPE, Phone.TYPE_HOME); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "09012341234") + .put(Phone.TYPE, Phone.TYPE_MOBILE); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "03-1234-1234", new TypeSet("HOME")) + .addExpectedNode("TEL", "090-1234-1234", new TypeSet("CELL")); + } + + public void testJapanesePhoneNumberV21_1() { + testJapanesePhoneNumberCommon(VCardConfig.VCARD_TYPE_V21_JAPANESE); + } + + public void testJapanesePhoneNumberV30() { + testJapanesePhoneNumberCommon(VCardConfig.VCARD_TYPE_V30_JAPANESE); + } + + public void testJapanesePhoneNumberDoCoMo() { + mVerifier.initForExportTest(VCardConfig.VCARD_TYPE_DOCOMO, "Shift_JIS"); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "0312341234") + .put(Phone.TYPE, Phone.TYPE_HOME); + entry.addContentValues(Phone.CONTENT_ITEM_TYPE) + .put(Phone.NUMBER, "09012341234") + .put(Phone.TYPE, Phone.TYPE_MOBILE); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("EMAIL", "", new TypeSet("HOME")) + .addExpectedNode("X-CLASS", "PUBLIC") + .addExpectedNode("X-REDUCTION", "") + .addExpectedNode("X-NO", "") + .addExpectedNode("X-DCM-HMN-MODE", "") + .addExpectedNode("ADR", "", new TypeSet("HOME")) + .addExpectedNode("TEL", "03-1234-1234", new TypeSet("HOME")) + .addExpectedNode("TEL", "090-1234-1234", new TypeSet("CELL")); + } + + public void testNoteDoCoMo() { + mVerifier.initForExportTest(VCardConfig.VCARD_TYPE_DOCOMO, "Shift_JIS"); + ContactEntry entry = mVerifier.addInputEntry(); + entry.addContentValues(Note.CONTENT_ITEM_TYPE) + .put(Note.NOTE, "note1"); + entry.addContentValues(Note.CONTENT_ITEM_TYPE) + .put(Note.NOTE, "note2"); + entry.addContentValues(Note.CONTENT_ITEM_TYPE) + .put(Note.NOTE, "note3"); + + // More than one note fields must be aggregated into one note. + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("TEL", "", new TypeSet("HOME")) + .addExpectedNode("EMAIL", "", new TypeSet("HOME")) + .addExpectedNode("X-CLASS", "PUBLIC") + .addExpectedNode("X-REDUCTION", "") + .addExpectedNode("X-NO", "") + .addExpectedNode("X-DCM-HMN-MODE", "") + .addExpectedNode("ADR", "", new TypeSet("HOME")) + .addExpectedNode("NOTE", "note1\nnote2\nnote3", mContentValuesForQP); + } + + public void testAndroidCustomV21() { + mVerifier.initForExportTest(VCardConfig.VCARD_TYPE_V21_GENERIC); + mVerifier.addInputEntry().addContentValues(Nickname.CONTENT_ITEM_TYPE) + .put(Nickname.NAME, "\u304D\u3083\u30FC\u30A8\u30C3\u30C1\u30FC"); + mVerifier.addPropertyNodesVerifierElemWithEmptyName() + .addExpectedNode("X-ANDROID-CUSTOM", + Arrays.asList(Nickname.CONTENT_ITEM_TYPE, + "\u304D\u3083\u30FC\u30A8\u30C3\u30C1\u30FC", + "", "", "", "", "", "", "", "", "", "", "", "", "", ""), + mContentValuesForQPAndUtf8); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/VCardParserTests.java b/modules/vcard/tests/src/com/android/vcard/tests/VCardParserTests.java new file mode 100644 index 0000000..2b63103 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/VCardParserTests.java @@ -0,0 +1,242 @@ +/* + * 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.vcard.tests; + +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.VCardProperty; +import com.android.vcard.exception.VCardException; + +import android.test.AndroidTestCase; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Tests for basic {@link VCardParser} capability (e.g. if it calls callbacks + * in {@link VCardInterpreter}}. + */ +public class VCardParserTests extends AndroidTestCase { + private enum Order { + START, + END, + START_ENTRY, + END_ENTRY, + PROPERTY_CREATED, + } + + private class MockVCardInterpreter implements VCardInterpreter { + private final List mHistory = new ArrayList(); + private final List mExpectedOrder = new ArrayList(); + + public MockVCardInterpreter addExpectedOrder(Order order) { + mExpectedOrder.add(order); + return this; + } + + private void inspectOrder(Order order) { + mHistory.add(order); + final Order top = mExpectedOrder.get(0); + assertEquals(top, order); + mExpectedOrder.remove(0); + } + + public void verify() { + assertTrue(String.format("Remaining: " + Arrays.toString(mExpectedOrder.toArray())), + mExpectedOrder.isEmpty()); + } + + @Override + public void onVCardStarted() { + inspectOrder(Order.START); + } + + @Override + public void onVCardEnded() { + inspectOrder(Order.END); + } + + @Override + public void onEntryStarted() { + inspectOrder(Order.START_ENTRY); + } + + @Override + public void onEntryEnded() { + inspectOrder(Order.END_ENTRY); + } + + @Override + public void onPropertyCreated(VCardProperty property) { + inspectOrder(Order.PROPERTY_CREATED); + } + } + + public void testSimple() throws IOException, VCardException { + InputStream inputStream = getContext().getResources().openRawResource(R.raw.v21_simple_1); + try { + VCardParser parser = new VCardParser_V21(); + MockVCardInterpreter interpreter = new MockVCardInterpreter(); + interpreter.addExpectedOrder(Order.START) + .addExpectedOrder(Order.START_ENTRY) + .addExpectedOrder(Order.PROPERTY_CREATED) + .addExpectedOrder(Order.END_ENTRY) + .addExpectedOrder(Order.END); + parser.addInterpreter(interpreter); + parser.parse(inputStream); + interpreter.verify(); + } finally { + inputStream.close(); + } + } + + public void testNest() throws IOException, VCardException { + InputStream inputStream = getContext().getResources().openRawResource(R.raw.v21_nest); + try { + VCardParser parser = new VCardParser_V21(); + MockVCardInterpreter interpreter = new MockVCardInterpreter(); + interpreter.addExpectedOrder(Order.START) + .addExpectedOrder(Order.START_ENTRY) + .addExpectedOrder(Order.PROPERTY_CREATED) // For VERSION + .addExpectedOrder(Order.PROPERTY_CREATED) // For N + .addExpectedOrder(Order.START_ENTRY) // First nested vCard begins + .addExpectedOrder(Order.PROPERTY_CREATED) // For VERSION + .addExpectedOrder(Order.PROPERTY_CREATED) // For N + .addExpectedOrder(Order.END_ENTRY) // First nested vCard ends + .addExpectedOrder(Order.START_ENTRY) // Second nested vCard begins + .addExpectedOrder(Order.PROPERTY_CREATED) // For VERSION + .addExpectedOrder(Order.PROPERTY_CREATED) // For N + .addExpectedOrder(Order.END_ENTRY) // Second nested vCard ends + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.END_ENTRY) + .addExpectedOrder(Order.END); + parser.addInterpreter(interpreter); + parser.parse(inputStream); + interpreter.verify(); + } finally { + inputStream.close(); + } + } + + /** + * Test vCard containing v3.0 line continuations and with non-standard + * line terminators \r\r\n coming from iOS 6.1.3. Tests to make sure the + * parser correctly skips the extra line terminators and still + * successfully concatenates the multi-line block. + */ + public void testIosMultiline() throws IOException, VCardException { + InputStream inputStream = getContext().getResources().openRawResource(R.raw.v30_ios_613_multiline); + try { + VCardParser parser = new VCardParser_V30(); + MockVCardInterpreter interpreter = new MockVCardInterpreter(); + interpreter.addExpectedOrder(Order.START) + .addExpectedOrder(Order.START_ENTRY) + .addExpectedOrder(Order.PROPERTY_CREATED) // For VERSION + .addExpectedOrder(Order.PROPERTY_CREATED) // For N + .addExpectedOrder(Order.PROPERTY_CREATED) // For FN + .addExpectedOrder(Order.PROPERTY_CREATED) // For ORG + .addExpectedOrder(Order.PROPERTY_CREATED) // For item1 + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For item2 + .addExpectedOrder(Order.PROPERTY_CREATED) // For item3 + .addExpectedOrder(Order.PROPERTY_CREATED) // For PHOTO + .addExpectedOrder(Order.END_ENTRY) + .addExpectedOrder(Order.END); + parser.addInterpreter(interpreter); + parser.parse(inputStream); + interpreter.verify(); + } finally { + inputStream.close(); + } + } + + /** + * Tests if {@link VCardParser#parse(InputStream)} parses the whole vCard file and + * {@link VCardParser#parseOne(InputStream)} parses just first entry of a vCard file + * with multiple vCard entries. + */ + public void testParseOne() throws IOException, VCardException { + InputStream inputStream = + getContext().getResources().openRawResource(R.raw.v21_multiple_entry); + try { + VCardParser parser = new VCardParser_V21(); + MockVCardInterpreter interpreter = new MockVCardInterpreter(); + interpreter.addExpectedOrder(Order.START) + .addExpectedOrder(Order.START_ENTRY) + .addExpectedOrder(Order.PROPERTY_CREATED) // For VERSION + .addExpectedOrder(Order.PROPERTY_CREATED) // For N + .addExpectedOrder(Order.PROPERTY_CREATED) // For SOUND + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.END_ENTRY) + .addExpectedOrder(Order.START_ENTRY) + .addExpectedOrder(Order.PROPERTY_CREATED) // For VERSION + .addExpectedOrder(Order.PROPERTY_CREATED) // For N + .addExpectedOrder(Order.PROPERTY_CREATED) // For SOUND + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.END_ENTRY) + .addExpectedOrder(Order.START_ENTRY) + .addExpectedOrder(Order.PROPERTY_CREATED) // For VERSION + .addExpectedOrder(Order.PROPERTY_CREATED) // For N + .addExpectedOrder(Order.PROPERTY_CREATED) // For SOUND + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.END_ENTRY) + .addExpectedOrder(Order.END); + parser.addInterpreter(interpreter); + // Use parse() to check we + parser.parse(inputStream); + interpreter.verify(); + } finally { + inputStream.close(); + } + + inputStream = getContext().getResources().openRawResource(R.raw.v21_multiple_entry); + try { + VCardParser parser = new VCardParser_V21(); + MockVCardInterpreter interpreter = new MockVCardInterpreter(); + interpreter.addExpectedOrder(Order.START) + .addExpectedOrder(Order.START_ENTRY) + .addExpectedOrder(Order.PROPERTY_CREATED) // For VERSION + .addExpectedOrder(Order.PROPERTY_CREATED) // For N + .addExpectedOrder(Order.PROPERTY_CREATED) // For SOUND + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.PROPERTY_CREATED) // For TEL + .addExpectedOrder(Order.END_ENTRY) + .addExpectedOrder(Order.END); + parser.addInterpreter(interpreter); + parser.parseOne(inputStream); + interpreter.verify(); + } finally { + inputStream.close(); + } + + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/VCardTestUtilsTests.java b/modules/vcard/tests/src/com/android/vcard/tests/VCardTestUtilsTests.java new file mode 100644 index 0000000..d747e52 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/VCardTestUtilsTests.java @@ -0,0 +1,98 @@ +/* + * 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.vcard.tests; + +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.test.AndroidTestCase; + +import com.android.vcard.VCardConfig; +import com.android.vcard.tests.testutils.VCardVerifier; + +import junit.framework.AssertionFailedError; +import junit.framework.TestCase; + +import java.util.Arrays; + +/** + * Tests confirming utilities for vCard tests work fine. + * + * Now that the foundation classes for vCard test cases became too complicated to + * rely on without testing itself. + */ +public class VCardTestUtilsTests extends AndroidTestCase { + public void testShouldFailAtPropertyNodeVerification() { + boolean failureDetected = false; + try { + final VCardVerifier verifier = new VCardVerifier(this); + verifier.initForImportTest(VCardConfig.VCARD_TYPE_V21_GENERIC, R.raw.v21_backslash); + verifier.addPropertyNodesVerifierElem() + .addExpectedNodeWithOrder("N", ";A;B\\;C\\;;D;:E;\\\\;--", // wrong + Arrays.asList("", "A;B\\", "C\\;", "D", ":E", "\\\\", "")) + .addExpectedNodeWithOrder("FN", "A;B\\C\\;D:E\\\\"); + verifier.verify(); + } catch (AssertionFailedError e) { + failureDetected = true; + } + if (!failureDetected) { + TestCase.fail("Test case that should fail actually succeeded."); + } + } + + public void testShouldFailAtContentValueVerification() { + boolean failureDetected = false; + try { + final VCardVerifier verifier = new VCardVerifier(this); + verifier.initForImportTest(VCardConfig.VCARD_TYPE_V21_GENERIC, R.raw.v21_backslash); + verifier.addContentValuesVerifierElem() + .addExpected(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.GIVEN_NAME, "A;B\\") + .put(StructuredName.MIDDLE_NAME, "C\\;") + .put(StructuredName.PREFIX, "D") + .put(StructuredName.SUFFIX, ":E"); + // DISPLAY_NAME is missing. + verifier.verify(); + } catch (AssertionFailedError e) { + failureDetected = true; + } + if (!failureDetected) { + TestCase.fail("Test case that should fail actually succeeded."); + } + } + + public void testShouldFailAtLineVerification() { + boolean failureDetected = false; + try { + final VCardVerifier verifier = new VCardVerifier(this); + verifier.initForExportTest(VCardConfig.VCARD_TYPE_V30_GENERIC); + verifier.addInputEntry().addContentValues(StructuredName.CONTENT_ITEM_TYPE) + .put(StructuredName.FAMILY_NAME, "\\") + .put(StructuredName.GIVEN_NAME, ";") + .put(StructuredName.MIDDLE_NAME, ",") + .put(StructuredName.PREFIX, "\n") + .put(StructuredName.DISPLAY_NAME, "[<{Unescaped:Asciis}>]"); + verifier.addLineVerifierElem() + .addExpected("TEL:1") // wrong + .addExpected("FN:[<{Unescaped:Asciis}>]"); + verifier.verify(); + } catch (AssertionFailedError e) { + failureDetected = true; + } + if (!failureDetected) { + TestCase.fail("Test case that should fail actually succeeded."); + } + } + +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/VCardUtilsTests.java b/modules/vcard/tests/src/com/android/vcard/tests/VCardUtilsTests.java new file mode 100644 index 0000000..d6365fb --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/VCardUtilsTests.java @@ -0,0 +1,144 @@ +/* + * 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.vcard.tests; + +import android.text.TextUtils; + +import com.android.vcard.VCardUtils; + +import junit.framework.TestCase; + +import java.util.List; + +public class VCardUtilsTests extends TestCase { + public void testContainsOnlyPrintableAscii() { + assertTrue(VCardUtils.containsOnlyPrintableAscii((String)null)); + assertTrue(VCardUtils.containsOnlyPrintableAscii((String[])null)); + assertTrue(VCardUtils.containsOnlyPrintableAscii((List)null)); + assertTrue(VCardUtils.containsOnlyPrintableAscii("")); + assertTrue(VCardUtils.containsOnlyPrintableAscii("abcdefghijklmnopqrstuvwxyz")); + assertTrue(VCardUtils.containsOnlyPrintableAscii("ABCDEFGHIJKLMNOPQRSTUVWXYZ")); + StringBuilder builder = new StringBuilder(); + for (int i = 0x20; i < 0x7F; i++) { + builder.append((char)i); + } + assertTrue(VCardUtils.containsOnlyPrintableAscii(builder.toString())); + assertTrue(VCardUtils.containsOnlyPrintableAscii("\r\n")); + assertFalse(VCardUtils.containsOnlyPrintableAscii("\u0019")); + assertFalse(VCardUtils.containsOnlyPrintableAscii("\u007F")); + } + + public void testContainsOnlyNonCrLfPrintableAscii() { + assertTrue(VCardUtils.containsOnlyNonCrLfPrintableAscii((String)null)); + assertTrue(VCardUtils.containsOnlyNonCrLfPrintableAscii((String[])null)); + assertTrue(VCardUtils.containsOnlyNonCrLfPrintableAscii((List)null)); + assertTrue(VCardUtils.containsOnlyNonCrLfPrintableAscii("")); + assertTrue(VCardUtils.containsOnlyNonCrLfPrintableAscii("abcdefghijklmnopqrstuvwxyz")); + assertTrue(VCardUtils.containsOnlyNonCrLfPrintableAscii("ABCDEFGHIJKLMNOPQRSTUVWXYZ")); + StringBuilder builder = new StringBuilder(); + for (int i = 0x20; i < 0x7F; i++) { + builder.append((char)i); + } + assertTrue(VCardUtils.containsOnlyNonCrLfPrintableAscii(builder.toString())); + assertFalse(VCardUtils.containsOnlyNonCrLfPrintableAscii("\u0019")); + assertFalse(VCardUtils.containsOnlyNonCrLfPrintableAscii("\u007F")); + assertFalse(VCardUtils.containsOnlyNonCrLfPrintableAscii("\r")); + assertFalse(VCardUtils.containsOnlyNonCrLfPrintableAscii("\n")); + } + + public void testContainsOnlyAlphaDigitHyphen() { + assertTrue(VCardUtils.containsOnlyAlphaDigitHyphen((String)null)); + assertTrue(VCardUtils.containsOnlyAlphaDigitHyphen((String[])null)); + assertTrue(VCardUtils.containsOnlyAlphaDigitHyphen((List)null)); + assertTrue(VCardUtils.containsOnlyAlphaDigitHyphen("")); + assertTrue(VCardUtils.containsOnlyNonCrLfPrintableAscii("abcdefghijklmnopqrstuvwxyz")); + assertTrue(VCardUtils.containsOnlyNonCrLfPrintableAscii("ABCDEFGHIJKLMNOPQRSTUVWXYZ")); + assertTrue(VCardUtils.containsOnlyNonCrLfPrintableAscii("0123456789-")); + for (int i = 0; i < 0x30; i++) { + if (i == 0x2D) { // - + continue; + } + assertFalse(VCardUtils.containsOnlyAlphaDigitHyphen(String.valueOf((char)i))); + } + for (int i = 0x3A; i < 0x41; i++) { + assertFalse(VCardUtils.containsOnlyAlphaDigitHyphen(String.valueOf((char)i))); + } + for (int i = 0x5B; i < 0x61; i++) { + assertFalse(VCardUtils.containsOnlyAlphaDigitHyphen(String.valueOf((char)i))); + } + for (int i = 0x7B; i < 0x100; i++) { + assertFalse(VCardUtils.containsOnlyAlphaDigitHyphen(String.valueOf((char)i))); + } + } + + public void testToStringAvailableAsV30ParamValue() { + // Smoke tests. + assertEquals("HOME", VCardUtils.toStringAsV30ParamValue("HOME")); + assertEquals("TEL", VCardUtils.toStringAsV30ParamValue("TEL")); + assertEquals("PAGER", VCardUtils.toStringAsV30ParamValue("PAGER")); + + assertTrue(TextUtils.isEmpty(VCardUtils.toStringAsV30ParamValue(""))); + assertTrue(TextUtils.isEmpty(VCardUtils.toStringAsV30ParamValue(null))); + assertTrue(TextUtils.isEmpty(VCardUtils.toStringAsV30ParamValue(" \t"))); + + // non-Ascii must be allowed + assertEquals("\u4E8B\u52D9\u6240", + VCardUtils.toStringAsV30ParamValue("\u4E8B\u52D9\u6240")); + // Reported as bug report. + assertEquals("\u8D39", VCardUtils.toStringAsV30ParamValue("\u8D39")); + assertEquals("\"comma,separated\"", + VCardUtils.toStringAsV30ParamValue("comma,separated")); + assertEquals("\"colon:aware\"", + VCardUtils.toStringAsV30ParamValue("colon:aware")); + // CTL characters. + assertEquals("CTLExample", + VCardUtils.toStringAsV30ParamValue("CTL\u0001Example")); + assertTrue(TextUtils.isEmpty( + VCardUtils.toStringAsV30ParamValue("\u0001\u0002\u0003"))); + // DQUOTE must be removed. + assertEquals("quoted", + VCardUtils.toStringAsV30ParamValue("\"quoted\"")); + // DQUOTE must be removed basically, but we should detect a space, which + // require us to use DQUOTE again. + // Right-side has one more illegal dquote to test quote-handle code thoroughly. + assertEquals("\"Already quoted\"", + VCardUtils.toStringAsV30ParamValue("\"Already quoted\"\"")); + } + + public void testAppearsLikeAndroidVCardQuotedPrintableByLength() { + assertFalse(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=")); + assertFalse(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=1")); + assertTrue(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=12")); + assertTrue(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=12=")); + assertFalse(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=12=1")); + assertTrue(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=12=12")); + assertTrue(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=12=12=")); + } + + public void testAppearsLikeAndroidVCardQuotedPrintableByContent() { + assertTrue(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("===")); + assertTrue(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("====")); + assertTrue(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=======")); + assertTrue(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=12=12=")); + assertTrue(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=12=34=")); + + assertFalse(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=123")); + assertFalse(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=123=")); + assertFalse(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("_12=")); + assertFalse(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=12_")); + assertFalse(VCardUtils.appearsLikeAndroidVCardQuotedPrintable("=12=34_56=")); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardAgentNotSupportedExceptionTest.java b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardAgentNotSupportedExceptionTest.java new file mode 100644 index 0000000..c370ec0 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardAgentNotSupportedExceptionTest.java @@ -0,0 +1,36 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.vcard.tests; + +import com.android.vcard.exception.VCardAgentNotSupportedException; + +import junit.framework.TestCase; + +public class VCardAgentNotSupportedExceptionTest extends TestCase { + static final String TEST_MESSAGE = "message"; + + public void testExceptionWithoutMessage() { + VCardAgentNotSupportedException exception = new VCardAgentNotSupportedException(); + assertNull(exception.getMessage()); + } + + public void testExceptionWithMessage() { + VCardAgentNotSupportedException exception = new VCardAgentNotSupportedException( + TEST_MESSAGE); + assertEquals(exception.getMessage(), TEST_MESSAGE); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardExceptionTest.java b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardExceptionTest.java new file mode 100644 index 0000000..6f09fad --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardExceptionTest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.vcard.tests; + +import com.android.vcard.exception.VCardException; + +import junit.framework.TestCase; + +public class VCardExceptionTest extends TestCase { + static final String TEST_MESSAGE = "message"; + + public void testExceptionWithoutMessage() { + VCardException exception = new VCardException(); + assertNull(exception.getMessage()); + } + + public void testExceptionWithMessage() { + VCardException exception = new VCardException(TEST_MESSAGE); + assertEquals(exception.getMessage(), TEST_MESSAGE); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardInvalidCommentLineExceptionTest.java b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardInvalidCommentLineExceptionTest.java new file mode 100644 index 0000000..5a1429f --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardInvalidCommentLineExceptionTest.java @@ -0,0 +1,36 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.vcard.tests; + +import com.android.vcard.exception.VCardInvalidCommentLineException; + +import junit.framework.TestCase; + +public class VCardInvalidCommentLineExceptionTest extends TestCase { + static final String TEST_MESSAGE = "message"; + + public void testExceptionWithoutMessage() { + VCardInvalidCommentLineException exception = new VCardInvalidCommentLineException(); + assertNull(exception.getMessage()); + } + + public void testExceptionWithMessage() { + VCardInvalidCommentLineException exception = new VCardInvalidCommentLineException( + TEST_MESSAGE); + assertEquals(exception.getMessage(), TEST_MESSAGE); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardInvalidLineExceptionTest.java b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardInvalidLineExceptionTest.java new file mode 100644 index 0000000..6568de8 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardInvalidLineExceptionTest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.vcard.tests; + +import com.android.vcard.exception.VCardInvalidLineException; + +import junit.framework.TestCase; + +public class VCardInvalidLineExceptionTest extends TestCase { + static final String TEST_MESSAGE = "message"; + + public void testExceptionWithoutMessage() { + VCardInvalidLineException exception = new VCardInvalidLineException(); + assertNull(exception.getMessage()); + } + + public void testExceptionWithMessage() { + VCardInvalidLineException exception = new VCardInvalidLineException(TEST_MESSAGE); + assertEquals(exception.getMessage(), TEST_MESSAGE); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardNestedExceptionTest.java b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardNestedExceptionTest.java new file mode 100644 index 0000000..ee87dc2 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardNestedExceptionTest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.vcard.tests; + +import com.android.vcard.exception.VCardNestedException; + +import junit.framework.TestCase; + +public class VCardNestedExceptionTest extends TestCase { + static final String TEST_MESSAGE = "message"; + + public void testExceptionWithoutMessage() { + VCardNestedException exception = new VCardNestedException(); + assertNull(exception.getMessage()); + } + + public void testExceptionWithMessage() { + VCardNestedException exception = new VCardNestedException(TEST_MESSAGE); + assertEquals(exception.getMessage(), TEST_MESSAGE); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardNotSupportedExceptionTest.java b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardNotSupportedExceptionTest.java new file mode 100644 index 0000000..0d393be --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardNotSupportedExceptionTest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.vcard.tests; + +import com.android.vcard.exception.VCardNotSupportedException; + +import junit.framework.TestCase; + +public class VCardNotSupportedExceptionTest extends TestCase { + static final String TEST_MESSAGE = "message"; + + public void testExceptionWithoutMessage() { + VCardNotSupportedException exception = new VCardNotSupportedException(); + assertNull(exception.getMessage()); + } + + public void testExceptionWithMessage() { + VCardNotSupportedException exception = new VCardNotSupportedException(TEST_MESSAGE); + assertEquals(exception.getMessage(), TEST_MESSAGE); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardVersionExceptionTest.java b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardVersionExceptionTest.java new file mode 100644 index 0000000..1e9ed67 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/exception/VCardVersionExceptionTest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.vcard.tests; + +import com.android.vcard.exception.VCardVersionException; + +import junit.framework.TestCase; + +public class VCardVersionExceptionTest extends TestCase { + static final String TEST_MESSAGE = "message"; + + public void testExceptionWithoutMessage() { + VCardVersionException exception = new VCardVersionException(); + assertNull(exception.getMessage()); + } + + public void testExceptionWithMessage() { + VCardVersionException exception = new VCardVersionException(TEST_MESSAGE); + assertEquals(exception.getMessage(), TEST_MESSAGE); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContactEntry.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContactEntry.java new file mode 100644 index 0000000..1f3406a --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContactEntry.java @@ -0,0 +1,43 @@ +/* + * 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.vcard.tests.testutils; + +import android.content.ContentValues; +import android.provider.ContactsContract.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + *

+ * The class representing one contact, which should contain multiple ContentValues like + * StructuredName, Email, etc. + *

+ */ +public final class ContactEntry { + private final List mContentValuesList = new ArrayList(); + + public ContentValuesBuilder addContentValues(String mimeType) { + ContentValues contentValues = new ContentValues(); + contentValues.put(Data.MIMETYPE, mimeType); + mContentValuesList.add(contentValues); + return new ContentValuesBuilder(contentValues); + } + + public List getList() { + return mContentValuesList; + } +} \ No newline at end of file diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContentValuesBuilder.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContentValuesBuilder.java new file mode 100644 index 0000000..67585e4 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContentValuesBuilder.java @@ -0,0 +1,82 @@ +/* + * 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.vcard.tests.testutils; + +import android.content.ContentValues; + +/** + * ContentValues-like class which enables users to chain put() methods and restricts + * the other methods. + */ +public class ContentValuesBuilder { + private final ContentValues mContentValues; + + public ContentValuesBuilder(final ContentValues contentValues) { + mContentValues = contentValues; + } + + public ContentValuesBuilder put(String key, String value) { + mContentValues.put(key, value); + return this; + } + + /* + public ContentValuesBuilder put(String key, Byte value) { + mContentValues.put(key, value); + return this; + } + + public ContentValuesBuilder put(String key, Short value) { + mContentValues.put(key, value); + return this; + }*/ + + public ContentValuesBuilder put(String key, Integer value) { + mContentValues.put(key, value); + return this; + } + + /* + public ContentValuesBuilder put(String key, Long value) { + mContentValues.put(key, value); + return this; + } + + public ContentValuesBuilder put(String key, Float value) { + mContentValues.put(key, value); + return this; + } + + public ContentValuesBuilder put(String key, Double value) { + mContentValues.put(key, value); + return this; + } + + public ContentValuesBuilder put(String key, Boolean value) { + mContentValues.put(key, value); + return this; + }*/ + + public ContentValuesBuilder put(String key, byte[] value) { + mContentValues.put(key, value); + return this; + } + + public ContentValuesBuilder putNull(String key) { + mContentValues.putNull(key); + return this; + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContentValuesVerifier.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContentValuesVerifier.java new file mode 100644 index 0000000..40f07fb --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContentValuesVerifier.java @@ -0,0 +1,58 @@ +/* + * 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.vcard.tests.testutils; + +import android.test.AndroidTestCase; + +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardEntryHandler; + +import java.util.ArrayList; +import java.util.List; + +public class ContentValuesVerifier implements VCardEntryHandler { + private List mContentValuesVerifierElemList = + new ArrayList(); + private int mIndex; + + public ContentValuesVerifierElem addElem(AndroidTestCase androidTestCase) { + ContentValuesVerifierElem elem = new ContentValuesVerifierElem(androidTestCase); + mContentValuesVerifierElemList.add(elem); + return elem; + } + + @Override + public void onStart() { + for (ContentValuesVerifierElem elem : mContentValuesVerifierElemList) { + elem.onParsingStart(); + } + } + + @Override + public void onEntryCreated(VCardEntry entry) { + AndroidTestCase.assertTrue(mIndex < mContentValuesVerifierElemList.size()); + mContentValuesVerifierElemList.get(mIndex).onEntryCreated(entry); + mIndex++; + } + + @Override + public void onEnd() { + for (ContentValuesVerifierElem elem : mContentValuesVerifierElemList) { + elem.onParsingEnd(); + elem.verifyResolver(); + } + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContentValuesVerifierElem.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContentValuesVerifierElem.java new file mode 100644 index 0000000..38bc14f --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ContentValuesVerifierElem.java @@ -0,0 +1,57 @@ +/* + * 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.vcard.tests.testutils; + +import android.content.ContentValues; +import android.provider.ContactsContract.Data; +import android.test.AndroidTestCase; + +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardEntryCommitter; +import com.android.vcard.VCardEntryHandler; + +public class ContentValuesVerifierElem { + private final ImportTestResolver mResolver; + private final VCardEntryHandler mHandler; + + public ContentValuesVerifierElem(AndroidTestCase androidTestCase) { + mResolver = new ImportTestResolver(androidTestCase); + mHandler = new VCardEntryCommitter(mResolver); + } + + public ContentValuesBuilder addExpected(String mimeType) { + ContentValues contentValues = new ContentValues(); + contentValues.put(Data.MIMETYPE, mimeType); + mResolver.addExpectedContentValues(contentValues); + return new ContentValuesBuilder(contentValues); + } + + public void verifyResolver() { + mResolver.verify(); + } + + public void onParsingStart() { + mHandler.onStart(); + } + + public void onEntryCreated(VCardEntry entry) { + mHandler.onEntryCreated(entry); + } + + public void onParsingEnd() { + mHandler.onEnd(); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/ExportTestProvider.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ExportTestProvider.java new file mode 100644 index 0000000..0b2bc7f --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ExportTestProvider.java @@ -0,0 +1,196 @@ +/* + * 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.vcard.tests.testutils; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Entity; +import android.content.EntityIterator; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.RawContacts; +import android.test.AndroidTestCase; +import android.test.mock.MockContentProvider; +import android.test.mock.MockCursor; +import android.util.Log; + +import junit.framework.TestCase; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +public class ExportTestProvider extends MockContentProvider { + final private ArrayList mContactEntryList = new ArrayList(); + + private static class MockEntityIterator implements EntityIterator { + private final List mEntityList; + private Iterator mIterator; + + public MockEntityIterator() { + mEntityList = new ArrayList(); + mIterator = mEntityList.iterator(); + } + + public void add(List contentValuesList) { + Entity entity = new Entity(new ContentValues()); + for (ContentValues contentValues : contentValuesList) { + entity.addSubValue(Data.CONTENT_URI, contentValues); + } + mEntityList.add(entity); + } + + @Override + public boolean hasNext() { + return mIterator.hasNext(); + } + + @Override + public Entity next() { + return mIterator.next(); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove not supported"); + } + + @Override + public void reset() { + mIterator = mEntityList.iterator(); + } + + @Override + public void close() { + } + } + + public ExportTestProvider(AndroidTestCase androidTestCase) { + } + + public ContactEntry buildInputEntry() { + ContactEntry contactEntry = new ContactEntry(); + mContactEntryList.add(contactEntry); + return contactEntry; + } + + /** + *

+ * An old method which had existed but was removed from ContentResolver. + *

+ *

+ * We still keep using this method since we don't have a propeer way to know + * which value in the ContentValue corresponds to the entry in Contacts database. + *

+ */ + public EntityIterator queryEntities(Uri uri, + String selection, String[] selectionArgs, String sortOrder) { + TestCase.assertTrue(uri != null); + TestCase.assertTrue(ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())); + final String authority = uri.getAuthority(); + TestCase.assertTrue(RawContacts.CONTENT_URI.getAuthority().equals(authority)); + TestCase.assertTrue(selection != null); + TestCase.assertTrue((selection.contains(Data.CONTACT_ID + " IN "))); + TestCase.assertNull(selectionArgs); + + MockEntityIterator iterator = new MockEntityIterator(); + for (ContactEntry contactEntry : mContactEntryList) { + iterator.add(contactEntry.getList()); + } + iterator.reset(); + return iterator; + } + + @Override + public Cursor query(Uri uri,String[] projection, + String selection, String[] selectionArgs, String sortOrder) { + TestCase.assertTrue(VCardVerifier.CONTACTS_TEST_CONTENT_URI.equals(uri)); + // In this test, following arguments are not supported. + TestCase.assertNull(selection); + TestCase.assertNull(selectionArgs); + TestCase.assertNull(sortOrder); + + return new MockCursor() { + int mCurrentPosition = -1; + + @Override + public int getCount() { + return mContactEntryList.size(); + } + + @Override + public boolean moveToFirst() { + mCurrentPosition = 0; + return true; + } + + @Override + public boolean moveToNext() { + if (mCurrentPosition < mContactEntryList.size() - 1) { + mCurrentPosition++; + return true; + } else { + return false; + } + } + + @Override + public boolean isBeforeFirst() { + return mCurrentPosition < 0; + } + + @Override + public boolean isLast() { + return mCurrentPosition == mContactEntryList.size() - 1; + } + + @Override + public boolean isAfterLast() { + return mCurrentPosition >= mContactEntryList.size(); + } + + @Override + public int getColumnIndex(String columnName) { + TestCase.assertEquals(Data.CONTACT_ID, columnName); + return 0; + } + + @Override + public int getInt(int columnIndex) { + TestCase.assertEquals(0, columnIndex); + TestCase.assertTrue(mCurrentPosition >= 0 + && mCurrentPosition < mContactEntryList.size()); + return mCurrentPosition; + } + + @Override + public long getLong(int columnIndex) { + return getInt(columnIndex); + } + + @Override + public String getString(int columnIndex) { + return String.valueOf(getInt(columnIndex)); + } + + @Override + public void close() { + } + }; + } +} \ No newline at end of file diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/ExportTestResolver.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ExportTestResolver.java new file mode 100644 index 0000000..9454de2 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ExportTestResolver.java @@ -0,0 +1,37 @@ +/* + * 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.vcard.tests.testutils; + +import android.provider.ContactsContract.RawContacts; +import android.test.AndroidTestCase; +import android.test.mock.MockContentResolver; + +public class ExportTestResolver extends MockContentResolver { + private final ExportTestProvider mProvider; + public ExportTestResolver(AndroidTestCase androidTestCase) { + mProvider = new ExportTestProvider(androidTestCase); + addProvider(VCardVerifier.VCARD_TEST_AUTHORITY, mProvider); + addProvider(RawContacts.CONTENT_URI.getAuthority(), mProvider); + } + + public ContactEntry addInputContactEntry() { + return mProvider.buildInputEntry(); + } + + public ExportTestProvider getProvider() { + return mProvider; + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/ImportTestProvider.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ImportTestProvider.java new file mode 100644 index 0000000..916f84d --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ImportTestProvider.java @@ -0,0 +1,241 @@ +/* + * 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.vcard.tests.testutils; + +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentValues; +import android.net.Uri; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.RawContacts; +import android.test.mock.MockContentProvider; +import android.text.TextUtils; + +import junit.framework.TestCase; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; + +public class ImportTestProvider extends MockContentProvider { + private final Map> + mMimeTypeToExpectedContentValues = new HashMap>(); + + public void addExpectedContentValues(ContentValues expectedContentValues) { + final String mimeType = expectedContentValues.getAsString(Data.MIMETYPE); + + final Collection contentValuesCollection; + if (mMimeTypeToExpectedContentValues.containsKey(mimeType)) { + contentValuesCollection = mMimeTypeToExpectedContentValues.get(mimeType); + } else { + contentValuesCollection = new ArrayList(); + mMimeTypeToExpectedContentValues.put(mimeType, contentValuesCollection); + } + + contentValuesCollection.add(expectedContentValues); + } + + @Override + public ContentProviderResult[] applyBatch( + ArrayList operations) { + if (operations == null) { + TestCase.fail("There is no operation."); + } + + final int size = operations.size(); + ContentProviderResult[] fakeResultArray = new ContentProviderResult[size]; + for (int i = 0; i < size; i++) { + Uri uri = Uri.withAppendedPath(RawContacts.CONTENT_URI, String.valueOf(i)); + fakeResultArray[i] = new ContentProviderResult(uri); + } + + for (int i = 0; i < size; i++) { + ContentProviderOperation operation = operations.get(i); + ContentValues contentValues = operation.resolveValueBackReferences( + fakeResultArray, i); + } + for (int i = 0; i < size; i++) { + ContentProviderOperation operation = operations.get(i); + ContentValues actualContentValues = operation.resolveValueBackReferences( + fakeResultArray, i); + final Uri uri = operation.getUri(); + if (uri.equals(RawContacts.CONTENT_URI)) { + TestCase.assertNull(actualContentValues.get(RawContacts.ACCOUNT_NAME)); + TestCase.assertNull(actualContentValues.get(RawContacts.ACCOUNT_TYPE)); + } else if (uri.equals(Data.CONTENT_URI)) { + final String mimeType = actualContentValues.getAsString(Data.MIMETYPE); + if (!mMimeTypeToExpectedContentValues.containsKey(mimeType)) { + TestCase.fail("Unregistered MimeType " + mimeType); + } + // Remove data meaningless in this unit tests. + // Specifically, Data.DATA1 - DATA7 are set to null or empty String + // regardless of the input, but it may change depending on how + // resolver-related code handles it. + // Here, we ignore these implementation-dependent specs and + // just check whether vCard importer correctly inserts rellevent data. + Set keyToBeRemoved = new HashSet(); + for (Entry entry : actualContentValues.valueSet()) { + Object value = entry.getValue(); + if (value == null || TextUtils.isEmpty(value.toString())) { + keyToBeRemoved.add(entry.getKey()); + } + } + for (String key: keyToBeRemoved) { + actualContentValues.remove(key); + } + /* for testing + Log.d("@@@", + String.format("MimeType: %s, data: %s", + mimeType, actualContentValues.toString())); */ + // Remove RAW_CONTACT_ID entry just for safety, since we do not care + // how resolver-related code handles the entry in this unit test, + if (actualContentValues.containsKey(Data.RAW_CONTACT_ID)) { + actualContentValues.remove(Data.RAW_CONTACT_ID); + } + final Collection contentValuesCollection = + mMimeTypeToExpectedContentValues.get(mimeType); + if (contentValuesCollection.isEmpty()) { + TestCase.fail("ContentValues for MimeType " + mimeType + + " is not expected at all (" + actualContentValues + ")"); + } + boolean checked = false; + for (ContentValues expectedContentValues : contentValuesCollection) { + /*for testing + Log.d("@@@", "expected: " + + convertToEasilyReadableString(expectedContentValues)); + Log.d("@@@", "actual : " + + convertToEasilyReadableString(actualContentValues));*/ + if (equalsForContentValues(expectedContentValues, + actualContentValues)) { + TestCase.assertTrue(contentValuesCollection.remove(expectedContentValues)); + checked = true; + break; + } + } + if (!checked) { + final StringBuilder builder = new StringBuilder(); + builder.append("\n"); + builder.append("Unexpected: "); + builder.append(convertToEasilyReadableString(actualContentValues)); + builder.append("\n"); + builder.append("Expected : "); + for (ContentValues expectedContentValues : contentValuesCollection) { + builder.append(convertToEasilyReadableString(expectedContentValues)); + } + TestCase.fail(builder.toString()); + } + } else { + TestCase.fail("Unexpected Uri has come: " + uri); + } + } // for (int i = 0; i < size; i++) { + return fakeResultArray; + } + + /** + * Checks all expected ContentValues are consumed during import. + */ + public void verify() { + StringBuilder builder = new StringBuilder(); + for (Collection contentValuesCollection : + mMimeTypeToExpectedContentValues.values()) { + for (ContentValues expectedContentValues: contentValuesCollection) { + builder.append(convertToEasilyReadableString(expectedContentValues)); + builder.append("\n"); + } + } + if (builder.length() > 0) { + final String failMsg = + "There is(are) remaining expected ContentValues instance(s): \n" + + builder.toString(); + TestCase.fail(failMsg); + } + } + + /** + * Utility method to print ContentValues whose content is printed with sorted keys. + */ + private String convertToEasilyReadableString(ContentValues contentValues) { + if (contentValues == null) { + return "null"; + } + String mimeTypeValue = ""; + SortedMap sortedMap = new TreeMap(); + for (Entry entry : contentValues.valueSet()) { + final String key = entry.getKey(); + final Object value = entry.getValue(); + final String valueString = (value != null ? value.toString() : null); + if (Data.MIMETYPE.equals(key)) { + mimeTypeValue = valueString; + } else { + TestCase.assertNotNull(key); + sortedMap.put(key, valueString); + } + } + StringBuilder builder = new StringBuilder(); + builder.append(Data.MIMETYPE); + builder.append('='); + builder.append(mimeTypeValue); + for (Entry entry : sortedMap.entrySet()) { + final String key = entry.getKey(); + final String value = entry.getValue(); + builder.append(' '); + builder.append(key); + builder.append("=\""); + builder.append(value); + builder.append('"'); + } + return builder.toString(); + } + + private static boolean equalsForContentValues( + final ContentValues expected, final ContentValues actual) { + if (expected == actual) { + return true; + } else if (expected == null || actual == null || expected.size() != actual.size()) { + return false; + } + + for (Entry entry : expected.valueSet()) { + final String key = entry.getKey(); + final Object value = entry.getValue(); + if (!actual.containsKey(key)) { + return false; + } + // Type mismatch usuall happens as importer doesn't care the type of each value. + // For example, variable type might be Integer when importing the type of TEL, + // while variable type would be String when importing the type of RELATION. + final Object actualValue = actual.get(key); + if (value instanceof byte[]) { + if (!Arrays.equals((byte[])value, (byte[])actualValue)) { + byte[] e = (byte[])value; + byte[] a = (byte[])actualValue; + return false; + } + } else if (!value.equals(actualValue) && + !value.toString().equals(actualValue.toString())) { + return false; + } + } + return true; + } +} \ No newline at end of file diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/ImportTestResolver.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ImportTestResolver.java new file mode 100644 index 0000000..535a62a --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/ImportTestResolver.java @@ -0,0 +1,56 @@ +/* + * 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.vcard.tests.testutils; + +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentValues; +import android.provider.ContactsContract.RawContacts; +import android.test.AndroidTestCase; +import android.test.mock.MockContentResolver; + +import java.util.ArrayList; + +public class ImportTestResolver extends MockContentResolver { + private final ImportTestProvider mProvider; + + public ImportTestResolver(AndroidTestCase androidTestCase) { + mProvider = new ImportTestProvider(); + } + + @Override + public ContentProviderResult[] applyBatch(String authority, + ArrayList operations) { + equalsString(authority, RawContacts.CONTENT_URI.toString()); + return mProvider.applyBatch(operations); + } + + public void addExpectedContentValues(ContentValues expectedContentValues) { + mProvider.addExpectedContentValues(expectedContentValues); + } + + public void verify() { + mProvider.verify(); + } + + private static boolean equalsString(String a, String b) { + if (a == null || a.length() == 0) { + return b == null || b.length() == 0; + } else { + return a.equals(b); + } + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/LineVerifier.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/LineVerifier.java new file mode 100644 index 0000000..88526b3 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/LineVerifier.java @@ -0,0 +1,52 @@ +/* + * 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.vcard.tests.testutils; + +import android.test.AndroidTestCase; + +import junit.framework.TestCase; + +import java.util.ArrayList; + +public class LineVerifier { + private final AndroidTestCase mAndroidTestCase; + private final ArrayList mLineVerifierElemList; + private int mVCardType; + private int index; + + public LineVerifier(AndroidTestCase androidTestCase, int vcardType) { + mAndroidTestCase = androidTestCase; + mLineVerifierElemList = new ArrayList(); + mVCardType = vcardType; + } + + public LineVerifierElem addLineVerifierElem() { + LineVerifierElem lineVerifier = new LineVerifierElem(mAndroidTestCase, mVCardType); + mLineVerifierElemList.add(lineVerifier); + return lineVerifier; + } + + public void verify(String vcard) { + if (index >= mLineVerifierElemList.size()) { + TestCase.fail("Insufficient number of LineVerifier (" + index + ")"); + } + + final LineVerifierElem lineVerifier = mLineVerifierElemList.get(index); + lineVerifier.verify(vcard); + + index++; + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/LineVerifierElem.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/LineVerifierElem.java new file mode 100644 index 0000000..8a3c633 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/LineVerifierElem.java @@ -0,0 +1,105 @@ +/* + * 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.vcard.tests.testutils; + +import android.test.AndroidTestCase; +import android.text.TextUtils; + +import com.android.vcard.VCardConfig; + +import junit.framework.TestCase; + +import java.util.ArrayList; +import java.util.List; + +public class LineVerifierElem { + private final List mExpectedLineList = new ArrayList(); + private final int mVCardType; + + public LineVerifierElem(AndroidTestCase androidTestCase, int vcardType) { + mVCardType = vcardType; + } + + public LineVerifierElem addExpected(final String line) { + if (!TextUtils.isEmpty(line)) { + mExpectedLineList.add(line); + } + return this; + } + + public void verify(final String vcard) { + final String[] lineArray = vcard.split("\\r?\\n"); + final int length = lineArray.length; + boolean beginExists = false; + boolean endExists = false; + boolean versionExists = false; + + for (int i = 0; i < length; i++) { + final String line = lineArray[i]; + if (TextUtils.isEmpty(line)) { + continue; + } + + if ("BEGIN:VCARD".equalsIgnoreCase(line)) { + if (beginExists) { + TestCase.fail("Multiple \"BEGIN:VCARD\" line found"); + } else { + beginExists = true; + continue; + } + } else if ("END:VCARD".equalsIgnoreCase(line)) { + if (endExists) { + TestCase.fail("Multiple \"END:VCARD\" line found"); + } else { + endExists = true; + continue; + } + } else if ((VCardConfig.isVersion21(mVCardType) ? "VERSION:2.1" : + (VCardConfig.isVersion30(mVCardType) ? "VERSION:3.0" : + "VERSION:4.0")).equalsIgnoreCase(line)) { + if (versionExists) { + TestCase.fail("Multiple VERSION line + found"); + } else { + versionExists = true; + continue; + } + } + + if (!beginExists) { + TestCase.fail("Property other than BEGIN came before BEGIN property: " + line); + } else if (endExists) { + TestCase.fail("Property other than END came after END property: " + line); + } + + final int index = mExpectedLineList.indexOf(line); + if (index >= 0) { + mExpectedLineList.remove(index); + } else { + TestCase.fail("Unexpected line: " + line); + } + } + + if (!mExpectedLineList.isEmpty()) { + StringBuffer buffer = new StringBuffer(); + for (String expectedLine : mExpectedLineList) { + buffer.append(expectedLine); + buffer.append("\n"); + } + + TestCase.fail("Expected line(s) not found:" + buffer.toString()); + } + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/PropertyNode.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/PropertyNode.java new file mode 100644 index 0000000..5f5833b --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/PropertyNode.java @@ -0,0 +1,205 @@ +/* + * 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.vcard.tests.testutils; + +import android.content.ContentValues; + +import com.android.vcard.VCardEntry; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + *

+ * The class representing one property (e.g. "N;ENCODING=UTF-8:family:given:middle:prefix:suffix"). + *

+ *

+ * Previously used in main vCard handling code but now exists only for testing. + *

+ *

+ * Especially useful for testing parser code (VCardParser), since all properties can be + * checked via this class unlike {@link VCardEntry}, which only emits the result of + * interpretation of the content of each vCard. We cannot know whether vCard parser or + * {@link VCardEntry} is wrong without this class. + *

+ */ +public class PropertyNode { + public String propName; + public String propValue; + public List propValue_vector; + + /** Store value as byte[],after decode. + * Used when propValue is encoded by something like BASE64, QUOTED-PRINTABLE, etc. + */ + public byte[] propValue_bytes; + + /** + * param store: key=paramType, value=paramValue + * Note that currently PropertyNode class does not support multiple param-values + * defined in vCard 3.0 (See also RFC 2426). multiple-values are stored as + * one String value like "A,B", not ["A", "B"]... + * TODO: fix this. + */ + public ContentValues paramMap; + + /** Only for TYPE=??? param store. */ + public Set paramMap_TYPE; + + /** Store group values. Used only in VCard. */ + public Set propGroupSet; + + public PropertyNode() { + propName = ""; + propValue = ""; + propValue_vector = new ArrayList(); + paramMap = new ContentValues(); + paramMap_TYPE = new HashSet(); + propGroupSet = new HashSet(); + } + + public PropertyNode( + String propName, String propValue, List propValue_vector, + byte[] propValue_bytes, ContentValues paramMap, Set paramMap_TYPE, + Set propGroupSet) { + if (propName != null) { + this.propName = propName; + } else { + this.propName = ""; + } + if (propValue != null) { + this.propValue = propValue; + } else { + this.propValue = ""; + } + if (propValue_vector != null) { + this.propValue_vector = propValue_vector; + } else { + this.propValue_vector = new ArrayList(); + } + this.propValue_bytes = propValue_bytes; + if (paramMap != null) { + this.paramMap = paramMap; + } else { + this.paramMap = new ContentValues(); + } + if (paramMap_TYPE != null) { + this.paramMap_TYPE = paramMap_TYPE; + } else { + this.paramMap_TYPE = new HashSet(); + } + if (propGroupSet != null) { + this.propGroupSet = propGroupSet; + } else { + this.propGroupSet = new HashSet(); + } + } + + @Override + public int hashCode() { + // vCard may contain more than one same line in one entry, while HashSet or any other + // library which utilize hashCode() does not honor that, so intentionally throw an + // Exception. + throw new UnsupportedOperationException( + "PropertyNode does not provide hashCode() implementation intentionally."); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof PropertyNode)) { + return false; + } + + PropertyNode node = (PropertyNode)obj; + + if (propName == null || !propName.equals(node.propName)) { + return false; + } else if (!paramMap_TYPE.equals(node.paramMap_TYPE)) { + return false; + } else if (!paramMap_TYPE.equals(node.paramMap_TYPE)) { + return false; + } else if (!propGroupSet.equals(node.propGroupSet)) { + return false; + } + + if (propValue_bytes != null && Arrays.equals(propValue_bytes, node.propValue_bytes)) { + return true; + } else { + if (!propValue.equals(node.propValue)) { + return false; + } + + // The value in propValue_vector is not decoded even if it should be + // decoded by BASE64 or QUOTED-PRINTABLE. When the size of propValue_vector + // is 1, the encoded value is stored in propValue, so we do not have to + // check it. + return (propValue_vector.equals(node.propValue_vector) || + propValue_vector.size() == 1 || + node.propValue_vector.size() == 1); + } + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("propName: "); + builder.append(propName); + builder.append(", paramMap: "); + builder.append(paramMap.toString()); + builder.append(", paramMap_TYPE: ["); + boolean first = true; + for (String elem : paramMap_TYPE) { + if (first) { + first = false; + } else { + builder.append(", "); + } + builder.append('"'); + builder.append(elem); + builder.append('"'); + } + builder.append("]"); + if (!propGroupSet.isEmpty()) { + builder.append(", propGroupSet: ["); + first = true; + for (String elem : propGroupSet) { + if (first) { + first = false; + } else { + builder.append(", "); + } + builder.append('"'); + builder.append(elem); + builder.append('"'); + } + builder.append("]"); + } + if (propValue_vector != null && propValue_vector.size() > 1) { + builder.append(", propValue_vector size: "); + builder.append(propValue_vector.size()); + } + if (propValue_bytes != null) { + builder.append(", propValue_bytes size: "); + builder.append(propValue_bytes.length); + } + builder.append(", propValue: \""); + builder.append(propValue); + builder.append("\""); + return builder.toString(); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/PropertyNodesVerifier.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/PropertyNodesVerifier.java new file mode 100644 index 0000000..1fdf2b1 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/PropertyNodesVerifier.java @@ -0,0 +1,88 @@ +/* + * 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.vcard.tests.testutils; + +import android.test.AndroidTestCase; + +import com.android.vcard.VCardParser; +import com.android.vcard.VCardUtils; +import com.android.vcard.exception.VCardException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +public class PropertyNodesVerifier extends VNodeBuilder { + private final List mPropertyNodesVerifierElemList; + private final AndroidTestCase mAndroidTestCase; + private int mIndex; + + public PropertyNodesVerifier(AndroidTestCase testCase) { + super(); + mPropertyNodesVerifierElemList = new ArrayList(); + mAndroidTestCase = testCase; + } + + public PropertyNodesVerifierElem addPropertyNodesVerifierElem() { + PropertyNodesVerifierElem elem = new PropertyNodesVerifierElem(mAndroidTestCase); + mPropertyNodesVerifierElemList.add(elem); + return elem; + } + + public void verify(int resId, int vcardType) throws IOException, VCardException { + verify(mAndroidTestCase.getContext().getResources().openRawResource(resId), vcardType); + } + + public void verify(int resId, int vcardType, final VCardParser parser) + throws IOException, VCardException { + verify(mAndroidTestCase.getContext().getResources().openRawResource(resId), + vcardType, parser); + } + + public void verify(InputStream is, int vcardType) throws IOException, VCardException { + final VCardParser parser = VCardUtils.getAppropriateParser(vcardType); + verify(is, vcardType, parser); + } + + public void verify(InputStream is, int vcardType, final VCardParser parser) + throws IOException, VCardException { + parser.addInterpreter(this); + try { + parser.parse(is); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + } + } + } + } + + @Override + public void onEntryStarted() { + super.onEntryStarted(); + AndroidTestCase.assertTrue(mIndex < mPropertyNodesVerifierElemList.size()); + } + + @Override + public void onEntryEnded() { + mPropertyNodesVerifierElemList.get(mIndex).verify(getCurrentVNode()); + super.onEntryEnded(); + mIndex++; + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/PropertyNodesVerifierElem.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/PropertyNodesVerifierElem.java new file mode 100644 index 0000000..036aa96 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/PropertyNodesVerifierElem.java @@ -0,0 +1,330 @@ +/* + * 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.vcard.tests.testutils; + +import android.content.ContentValues; +import android.test.AndroidTestCase; + +import junit.framework.TestCase; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +/** + * Utility class which verifies input VNode. + * + * This class first checks whether each propertyNode in the VNode is in the + * "ordered expected property list". + * If the node does not exist in the "ordered list", the class refers to + * "unorderd expected property set" and checks the node is expected somewhere. + */ +public class PropertyNodesVerifierElem { + public static class TypeSet extends HashSet { + public TypeSet(String ... array) { + super(Arrays.asList(array)); + } + } + + public static class GroupSet extends HashSet { + public GroupSet(String ... array) { + super(Arrays.asList(array)); + } + } + + private final HashMap> mOrderedNodeMap; + // Intentionally use ArrayList instead of Set, assuming there may be more than one + // exactly same objects. + private final ArrayList mUnorderedNodeList; + + public PropertyNodesVerifierElem(AndroidTestCase androidTestCase) { + mOrderedNodeMap = new HashMap>(); + mUnorderedNodeList = new ArrayList(); + } + + // WithOrder + + public PropertyNodesVerifierElem addExpectedNodeWithOrder(String propName, String propValue) { + return addExpectedNodeWithOrder(propName, propValue, null, null, null, null, null); + } + + public PropertyNodesVerifierElem addExpectedNodeWithOrder( + String propName, String propValue, ContentValues contentValues) { + return addExpectedNodeWithOrder(propName, propValue, null, + null, contentValues, null, null); + } + + public PropertyNodesVerifierElem addExpectedNodeWithOrder( + String propName, List propValueList, ContentValues contentValues) { + return addExpectedNodeWithOrder(propName, null, propValueList, + null, contentValues, null, null); + } + + public PropertyNodesVerifierElem addExpectedNodeWithOrder( + String propName, String propValue, List propValueList) { + return addExpectedNodeWithOrder(propName, propValue, propValueList, null, + null, null, null); + } + + public PropertyNodesVerifierElem addExpectedNodeWithOrder( + String propName, List propValueList) { + final String propValue = concatinateListWithSemiColon(propValueList); + return addExpectedNodeWithOrder(propName, propValue.toString(), propValueList, + null, null, null, null); + } + + public PropertyNodesVerifierElem addExpectedNodeWithOrder(String propName, String propValue, + TypeSet paramMap_TYPE) { + return addExpectedNodeWithOrder(propName, propValue, null, + null, null, paramMap_TYPE, null); + } + + public PropertyNodesVerifierElem addExpectedNodeWithOrder(String propName, + List propValueList, TypeSet paramMap_TYPE) { + return addExpectedNodeWithOrder(propName, null, propValueList, null, null, + paramMap_TYPE, null); + } + + public PropertyNodesVerifierElem addExpectedNodeWithOrder(String propName, + List propValueList, ContentValues paramMap, TypeSet paramMap_TYPE) { + return addExpectedNodeWithOrder(propName, null, propValueList, null, paramMap, + paramMap_TYPE, null); + } + + public PropertyNodesVerifierElem addExpectedNodeWithOrder(String propName, String propValue, + ContentValues paramMap, TypeSet paramMap_TYPE) { + return addExpectedNodeWithOrder(propName, propValue, null, null, + paramMap, paramMap_TYPE, null); + } + + public PropertyNodesVerifierElem addExpectedNodeWithOrder(String propName, String propValue, + List propValueList, TypeSet paramMap_TYPE) { + return addExpectedNodeWithOrder(propName, propValue, propValueList, null, null, + paramMap_TYPE, null); + } + + public PropertyNodesVerifierElem addExpectedNodeWithOrder(String propName, String propValue, + List propValueList, ContentValues paramMap) { + return addExpectedNodeWithOrder(propName, propValue, propValueList, null, paramMap, + null, null); + } + + public PropertyNodesVerifierElem addExpectedNodeWithOrder(String propName, String propValue, + List propValueList, byte[] propValue_bytes, + ContentValues paramMap, TypeSet paramMap_TYPE, GroupSet propGroupSet) { + if (propValue == null && propValueList != null) { + propValue = concatinateListWithSemiColon(propValueList); + } + final PropertyNode propertyNode = new PropertyNode(propName, + propValue, propValueList, propValue_bytes, + paramMap, paramMap_TYPE, propGroupSet); + List expectedNodeList = mOrderedNodeMap.get(propName); + if (expectedNodeList == null) { + expectedNodeList = new ArrayList(); + mOrderedNodeMap.put(propName, expectedNodeList); + } + expectedNodeList.add(propertyNode); + return this; + } + + // WithoutOrder + + public PropertyNodesVerifierElem addExpectedNode(String propName, String propValue) { + return addExpectedNode(propName, propValue, null, null, null, null, null); + } + + public PropertyNodesVerifierElem addExpectedNode(String propName, String propValue, + ContentValues contentValues) { + return addExpectedNode(propName, propValue, null, null, contentValues, null, null); + } + + public PropertyNodesVerifierElem addExpectedNode(String propName, + List propValueList, ContentValues contentValues) { + return addExpectedNode(propName, null, + propValueList, null, contentValues, null, null); + } + + public PropertyNodesVerifierElem addExpectedNode(String propName, String propValue, + List propValueList) { + return addExpectedNode(propName, propValue, propValueList, null, null, null, null); + } + + public PropertyNodesVerifierElem addExpectedNode(String propName, + List propValueList) { + return addExpectedNode(propName, null, propValueList, + null, null, null, null); + } + + public PropertyNodesVerifierElem addExpectedNode(String propName, String propValue, + TypeSet paramMap_TYPE) { + return addExpectedNode(propName, propValue, null, null, null, paramMap_TYPE, null); + } + + public PropertyNodesVerifierElem addExpectedNode(String propName, + List propValueList, TypeSet paramMap_TYPE) { + final String propValue = concatinateListWithSemiColon(propValueList); + return addExpectedNode(propName, propValue, propValueList, null, null, + paramMap_TYPE, null); + } + + public PropertyNodesVerifierElem addExpectedNode(String propName, String propValue, + List propValueList, TypeSet paramMap_TYPE) { + return addExpectedNode(propName, propValue, propValueList, null, null, + paramMap_TYPE, null); + } + + public PropertyNodesVerifierElem addExpectedNode(String propName, String propValue, + ContentValues paramMap, TypeSet paramMap_TYPE) { + return addExpectedNode(propName, propValue, null, null, + paramMap, paramMap_TYPE, null); + } + + public PropertyNodesVerifierElem addExpectedNode(String propName, String propValue, + List propValueList, byte[] propValue_bytes, + ContentValues paramMap, TypeSet paramMap_TYPE, GroupSet propGroupSet) { + if (propValue == null && propValueList != null) { + propValue = concatinateListWithSemiColon(propValueList); + } + mUnorderedNodeList.add(new PropertyNode(propName, propValue, + propValueList, propValue_bytes, paramMap, paramMap_TYPE, propGroupSet)); + return this; + } + + public void verify(VNode vnode) { + for (PropertyNode actualNode : vnode.propList) { + verifyNode(actualNode.propName, actualNode); + } + + if (!mOrderedNodeMap.isEmpty() || !mUnorderedNodeList.isEmpty()) { + final List expectedProps = new ArrayList(); + for (List nodes : mOrderedNodeMap.values()) { + for (PropertyNode node : nodes) { + if (!expectedProps.contains(node.propName)) { + expectedProps.add(node.propName); + } + } + } + for (PropertyNode node : mUnorderedNodeList) { + if (!expectedProps.contains(node.propName)) { + expectedProps.add(node.propName); + } + } + TestCase.fail("Expected property " + Arrays.toString(expectedProps.toArray()) + + " was not found."); + } + } + + private void verifyNode(final String propName, final PropertyNode actualNode) { + final List expectedNodeList = mOrderedNodeMap.get(propName); + final int size = (expectedNodeList != null ? expectedNodeList.size() : 0); + if (size > 0) { + for (int i = 0; i < size; i++) { + final PropertyNode expectedNode = expectedNodeList.get(i); + final List expectedButDifferentValueList = + new ArrayList(); + if (expectedNode.propName.equals(propName)) { + if (expectedNode.equals(actualNode)) { + expectedNodeList.remove(i); + if (expectedNodeList.size() == 0) { + mOrderedNodeMap.remove(propName); + } + return; + } else { + expectedButDifferentValueList.add(expectedNode); + } + } + + // "actualNode" is not in ordered expected list. + // Try looking over unordered expected list. + if (tryFoundExpectedNodeFromUnorderedList(actualNode, + expectedButDifferentValueList)) { + return; + } + + if (!expectedButDifferentValueList.isEmpty()) { + // Same propName exists but with different value(s). + failWithExpectedNodeList(propName, actualNode, + expectedButDifferentValueList); + } else { + // There's no expected node with same propName. + TestCase.fail("Unexpected property \"" + propName + "\" exists."); + } + } + } else { + List expectedButDifferentValueList = + new ArrayList(); + if (tryFoundExpectedNodeFromUnorderedList(actualNode, expectedButDifferentValueList)) { + return; + } else { + if (!expectedButDifferentValueList.isEmpty()) { + // Same propName exists but with different value(s). + failWithExpectedNodeList(propName, actualNode, + expectedButDifferentValueList); + } else { + // There's no expected node with same propName. + TestCase.fail("Unexpected property \"" + propName + "\" exists."); + } + } + } + } + + private String concatinateListWithSemiColon(List array) { + StringBuffer buffer = new StringBuffer(); + boolean first = true; + for (String propValueElem : array) { + if (first) { + first = false; + } else { + buffer.append(';'); + } + buffer.append(propValueElem); + } + + return buffer.toString(); + } + + private boolean tryFoundExpectedNodeFromUnorderedList(PropertyNode actualNode, + List expectedButDifferentValueList) { + final String propName = actualNode.propName; + int unorderedListSize = mUnorderedNodeList.size(); + for (int i = 0; i < unorderedListSize; i++) { + PropertyNode unorderedExpectedNode = mUnorderedNodeList.get(i); + if (unorderedExpectedNode.propName.equals(propName)) { + if (unorderedExpectedNode.equals(actualNode)) { + mUnorderedNodeList.remove(i); + return true; + } + expectedButDifferentValueList.add(unorderedExpectedNode); + } + } + return false; + } + + private void failWithExpectedNodeList(String propName, PropertyNode actualNode, + List expectedNodeList) { + StringBuilder builder = new StringBuilder(); + for (PropertyNode expectedNode : expectedNodeList) { + builder.append("expected: "); + builder.append(expectedNode.toString()); + builder.append("\n"); + } + TestCase.fail("Property \"" + propName + "\" has wrong value.\n" + + builder.toString() + + " actual: " + actualNode.toString()); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/VCardTestsBase.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/VCardTestsBase.java new file mode 100644 index 0000000..ee2a966 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/VCardTestsBase.java @@ -0,0 +1,99 @@ +/* + * 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.vcard.tests.testutils; + +import android.content.ContentValues; +import android.test.AndroidTestCase; + +import com.android.vcard.VCardConfig; +import com.android.vcard.tests.testutils.VCardVerifier; + +/** + * BaseClass for vCard unit tests with utility classes. + * Please do not add each unit test here. + */ +public abstract class VCardTestsBase extends AndroidTestCase { + public static final int V21 = VCardConfig.VCARD_TYPE_V21_GENERIC; + public static final int V30 = VCardConfig.VCARD_TYPE_V30_GENERIC; + public static final int V40 = VCardConfig.VCARD_TYPE_V40_GENERIC; + + // Do not modify these during tests. + protected final ContentValues mContentValuesForQP; + protected final ContentValues mContentValuesForSJis; + protected final ContentValues mContentValuesForUtf8; + protected final ContentValues mContentValuesForQPAndSJis; + protected final ContentValues mContentValuesForQPAndUtf8; + protected final ContentValues mContentValuesForBase64V21; + protected final ContentValues mContentValuesForBase64V30; + + protected VCardVerifier mVerifier; + /** + * true when we shouldn't call {@link VCardVerifier#verify()}. + */ + private boolean mSkipVerification; + + public VCardTestsBase() { + super(); + // Not using constants in vCard code since it may be wrong. + mContentValuesForQP = new ContentValues(); + mContentValuesForQP.put("ENCODING", "QUOTED-PRINTABLE"); + mContentValuesForSJis = new ContentValues(); + mContentValuesForSJis.put("CHARSET", "SHIFT_JIS"); + mContentValuesForUtf8 = new ContentValues(); + mContentValuesForUtf8.put("CHARSET", "UTF-8"); + mContentValuesForQPAndSJis = new ContentValues(); + mContentValuesForQPAndSJis.put("ENCODING", "QUOTED-PRINTABLE"); + mContentValuesForQPAndSJis.put("CHARSET", "SHIFT_JIS"); + mContentValuesForQPAndUtf8 = new ContentValues(); + mContentValuesForQPAndUtf8.put("ENCODING", "QUOTED-PRINTABLE"); + mContentValuesForQPAndUtf8.put("CHARSET", "UTF-8"); + mContentValuesForBase64V21 = new ContentValues(); + mContentValuesForBase64V21.put("ENCODING", "BASE64"); + mContentValuesForBase64V30 = new ContentValues(); + mContentValuesForBase64V30.put("ENCODING", "b"); + } + + @Override + public void testAndroidTestCaseSetupProperly() { + super.testAndroidTestCaseSetupProperly(); + mSkipVerification = true; + } + + /** + * Calls super's {@link #setUp()} and prepares {@link VCardVerifier}. We call + * {@link VCardVerifier#verify()} on {@link #tearDown()}. + */ + @Override + public final void setUp() throws Exception{ + super.setUp(); + mVerifier = new VCardVerifier(this); + mSkipVerification = false; + } + + /** + * Calls super's {@link #tearDown()} and {@link VCardVerifier#verify()}. + */ + @Override + public final void tearDown() throws Exception { + // We don't want to forget to call verify() as it makes unit test successful silently even + // when it shouldn't be, while each test case tends become so large to manage and sometimes + // we had forgotten to call the method. That is why we override setUp()/tearDown() here. + if (!mSkipVerification) { + mVerifier.verify(); + } + super.tearDown(); + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/VCardVerifier.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/VCardVerifier.java new file mode 100644 index 0000000..9c4f629 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/VCardVerifier.java @@ -0,0 +1,400 @@ +/* + * 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.vcard.tests.testutils; + +import android.content.ContentResolver; +import android.content.EntityIterator; +import android.database.Cursor; +import android.net.Uri; +import android.test.AndroidTestCase; +import android.test.mock.MockContext; +import android.text.TextUtils; +import android.util.Log; + +import com.android.vcard.VCardComposer; +import com.android.vcard.VCardConfig; +import com.android.vcard.VCardEntryConstructor; +import com.android.vcard.VCardParser; +import com.android.vcard.VCardUtils; +import com.android.vcard.exception.VCardException; + +import junit.framework.TestCase; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; + +/** + *

+ * The class lets users checks that given expected vCard data are same as given actual vCard data. + * Able to verify both vCard importer/exporter. + *

+ *

+ * First a user has to initialize the object by calling either + * {@link #initForImportTest(int, int)} or {@link #initForExportTest(int)}. + * "Round trip test" (import -> export -> import, or export -> import -> export) is not supported. + *

+ */ +public class VCardVerifier { + private static final String LOG_TAG = "VCardVerifier"; + private static final boolean DEBUG = true; + + /** + * Special URI for testing. + */ + /* package */ static final String VCARD_TEST_AUTHORITY = "com.android.unit_tests.vcard"; + private static final Uri VCARD_TEST_AUTHORITY_URI = + Uri.parse("content://" + VCARD_TEST_AUTHORITY); + /* package */ static final Uri CONTACTS_TEST_CONTENT_URI = + Uri.withAppendedPath(VCARD_TEST_AUTHORITY_URI, "contacts"); + + private static class CustomMockContext extends MockContext { + final ContentResolver mResolver; + public CustomMockContext(ContentResolver resolver) { + mResolver = resolver; + } + + @Override + public ContentResolver getContentResolver() { + return mResolver; + } + } + + private final AndroidTestCase mAndroidTestCase; + private int mVCardType; + private boolean mIsDoCoMo; + + // Only one of them must be non-empty. + private ExportTestResolver mExportTestResolver; + private InputStream mInputStream; + + // To allow duplication, use list instead of set. + // When null, we don't need to do the verification. + private PropertyNodesVerifier mPropertyNodesVerifier; + private LineVerifier mLineVerifier; + private ContentValuesVerifier mContentValuesVerifier; + private boolean mInitialized; + private boolean mVerified = false; + private String mCharset; + + private String mExceptionContents; + + // Called by VCardTestsBase + public VCardVerifier(AndroidTestCase androidTestCase) { + mAndroidTestCase = androidTestCase; + mExportTestResolver = null; + mInputStream = null; + mInitialized = false; + mVerified = false; + } + + // Should be called at the beginning of each import test. + public void initForImportTest(int vcardType, int resId) { + if (mInitialized) { + AndroidTestCase.fail("Already initialized"); + } + mVCardType = vcardType; + mIsDoCoMo = VCardConfig.isDoCoMo(vcardType); + setInputResourceId(resId); + mInitialized = true; + } + + // Should be called at the beginning of each export test. + public void initForExportTest(int vcardType) { + initForExportTest(vcardType, "UTF-8"); + } + + public void initForExportTest(int vcardType, String charset) { + if (mInitialized) { + AndroidTestCase.fail("Already initialized"); + } + mExportTestResolver = new ExportTestResolver(mAndroidTestCase); + mVCardType = vcardType; + mIsDoCoMo = VCardConfig.isDoCoMo(vcardType); + mInitialized = true; + if (TextUtils.isEmpty(charset)) { + mCharset = "UTF-8"; + } else { + mCharset = charset; + } + } + + private void setInputResourceId(int resId) { + final InputStream inputStream = + mAndroidTestCase.getContext().getResources().openRawResource(resId); + if (inputStream == null) { + AndroidTestCase.fail("Wrong resId: " + resId); + } + setInputStream(inputStream); + } + + private void setInputStream(InputStream inputStream) { + if (mExportTestResolver != null) { + AndroidTestCase.fail("addInputEntry() is called."); + } else if (mInputStream != null) { + AndroidTestCase.fail("InputStream is already set"); + } + mInputStream = inputStream; + } + + public ContactEntry addInputEntry() { + if (!mInitialized) { + AndroidTestCase.fail("Not initialized"); + } + if (mInputStream != null) { + AndroidTestCase.fail("setInputStream is called"); + } + return mExportTestResolver.addInputContactEntry(); + } + + public PropertyNodesVerifierElem addPropertyNodesVerifierElemWithoutVersion() { + if (!mInitialized) { + AndroidTestCase.fail("Not initialized"); + } + if (mPropertyNodesVerifier == null) { + mPropertyNodesVerifier = new PropertyNodesVerifier(mAndroidTestCase); + } + return mPropertyNodesVerifier.addPropertyNodesVerifierElem(); + } + + public PropertyNodesVerifierElem addPropertyNodesVerifierElem() { + final PropertyNodesVerifierElem elem = addPropertyNodesVerifierElemWithoutVersion(); + final String versionString; + if (VCardConfig.isVersion21(mVCardType)) { + versionString = "2.1"; + } else if (VCardConfig.isVersion30(mVCardType)) { + versionString = "3.0"; + } else if (VCardConfig.isVersion40(mVCardType)) { + versionString = "4.0"; + } else { + throw new RuntimeException("Unexpected vcard type during a unit test"); + } + elem.addExpectedNodeWithOrder("VERSION", versionString); + + return elem; + } + + public PropertyNodesVerifierElem addPropertyNodesVerifierElemWithEmptyName() { + if (!mInitialized) { + AndroidTestCase.fail("Not initialized"); + } + final PropertyNodesVerifierElem elem = addPropertyNodesVerifierElem(); + if (VCardConfig.isVersion40(mVCardType)) { + elem.addExpectedNodeWithOrder("FN", ""); + } else if (VCardConfig.isVersion30(mVCardType)) { + elem.addExpectedNodeWithOrder("N", ""); + elem.addExpectedNodeWithOrder("FN", ""); + } else if (mIsDoCoMo) { + elem.addExpectedNodeWithOrder("N", ""); + } + return elem; + } + + public LineVerifierElem addLineVerifierElem() { + if (!mInitialized) { + AndroidTestCase.fail("Not initialized"); + } + if (mLineVerifier == null) { + mLineVerifier = new LineVerifier(mAndroidTestCase, mVCardType); + } + return mLineVerifier.addLineVerifierElem(); + } + + public ContentValuesVerifierElem addContentValuesVerifierElem() { + if (!mInitialized) { + AndroidTestCase.fail("Not initialized"); + } + if (mContentValuesVerifier == null) { + mContentValuesVerifier = new ContentValuesVerifier(); + } + + return mContentValuesVerifier.addElem(mAndroidTestCase); + } + + public void addVCardExceptionVerifier(String contents) { + mExceptionContents = contents; + } + + /** + * Sets up sub-verifiers correctly and tries to parse vCard as {@link InputStream}. + * Errors around InputStream must be handled outside this method. + * + * Used both from {@link #verifyForImportTest()} and from {@link #verifyForExportTest()}. + */ + private void verifyWithInputStream(InputStream is) throws IOException { + try { + // Note: we must not specify charset toward vCard parsers. This code checks whether + // those parsers are able to encode given binary without any extra information for + // charset. + final VCardParser parser = VCardUtils.getAppropriateParser(mVCardType); + if (mContentValuesVerifier != null) { + final VCardEntryConstructor constructor = new VCardEntryConstructor(mVCardType); + constructor.addEntryHandler(mContentValuesVerifier); + parser.addInterpreter(constructor); + } + if (mPropertyNodesVerifier != null) { + parser.addInterpreter(mPropertyNodesVerifier); + } + parser.parse(is); + if (mExceptionContents != null) { + // exception contents exists, we expect an exception to occur. + AndroidTestCase.fail(); + } + } catch (VCardException e) { + if (mExceptionContents != null) { + AndroidTestCase.assertTrue(e.getMessage().contains(mExceptionContents)); + } else { + Log.e(LOG_TAG, "VCardException", e); + AndroidTestCase.fail("Unexpected VCardException: " + e.getMessage()); + } + } + } + + private void verifyOneVCardForExport(final String vcard) { + if (DEBUG) Log.d(LOG_TAG, vcard); + InputStream is = null; + try { + is = new ByteArrayInputStream(vcard.getBytes(mCharset)); + verifyWithInputStream(is); + } catch (IOException e) { + AndroidTestCase.fail("Unexpected IOException: " + e.getMessage()); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + AndroidTestCase.fail("Unexpected IOException: " + e.getMessage()); + } + } + } + } + + public void verify() { + if (!mInitialized) { + TestCase.fail("Not initialized."); + } + if (mVerified) { + TestCase.fail("verify() was called twice."); + } + + if (mInputStream != null) { + if (mExportTestResolver != null){ + TestCase.fail("There are two input sources."); + } + verifyForImportTest(); + } else if (mExportTestResolver != null){ + verifyForExportTest(); + } else { + TestCase.fail("No input is determined"); + } + mVerified = true; + } + + private void verifyForImportTest() { + if (mLineVerifier != null) { + AndroidTestCase.fail("Not supported now."); + } + + try { + verifyWithInputStream(mInputStream); + } catch (IOException e) { + AndroidTestCase.fail("IOException was thrown: " + e.getMessage()); + } finally { + if (mInputStream != null) { + try { + mInputStream.close(); + } catch (IOException e) { + } + } + } + } + + public static EntityIterator mockGetEntityIteratorMethod( + final ContentResolver resolver, + final Uri uri, final String selection, + final String[] selectionArgs, final String sortOrder) { + if (ExportTestResolver.class.equals(resolver.getClass())) { + return ((ExportTestResolver)resolver).getProvider().queryEntities( + uri, selection, selectionArgs, sortOrder); + } + + Log.e(LOG_TAG, "Unexpected provider given."); + return null; + } + + private Method getMockGetEntityIteratorMethod() + throws SecurityException, NoSuchMethodException { + return this.getClass().getMethod("mockGetEntityIteratorMethod", + ContentResolver.class, Uri.class, String.class, String[].class, String.class); + } + + private void verifyForExportTest() { + final CustomMockContext context = new CustomMockContext(mExportTestResolver); + final ContentResolver resolver = context.getContentResolver(); + final VCardComposer composer = new VCardComposer(context, mVCardType, mCharset); + // projection is ignored. + final Cursor cursor = resolver.query(CONTACTS_TEST_CONTENT_URI, null, null, null, null); + Method mockGetEntityIteratorMethod = null; + try { + mockGetEntityIteratorMethod = getMockGetEntityIteratorMethod(); + } catch (Exception e) { + AndroidTestCase.fail("Exception thrown: " + e); + } + AndroidTestCase.assertNotNull(mockGetEntityIteratorMethod); + composer.setGetEntityIteratorMethod(mockGetEntityIteratorMethod); + if (!composer.init(cursor)) { + AndroidTestCase.fail("init() failed. Reason: " + composer.getErrorReason()); + } + AndroidTestCase.assertFalse(composer.isAfterLast()); + try { + while (!composer.isAfterLast()) { + final String vcard = composer.createOneEntry(); + AndroidTestCase.assertNotNull(vcard); + if (mLineVerifier != null) { + mLineVerifier.verify(vcard); + } + verifyOneVCardForExport(vcard); + } + } finally { + composer.terminate(); + } + } + + public String buildVCardForExportTest() { + final CustomMockContext context = new CustomMockContext(mExportTestResolver); + final ContentResolver resolver = context.getContentResolver(); + final VCardComposer composer = new VCardComposer(context, mVCardType, mCharset); + // projection is ignored. + final Cursor cursor = resolver.query(CONTACTS_TEST_CONTENT_URI, null, null, null, null); + Method mockGetEntityIteratorMethod = null; + try { + mockGetEntityIteratorMethod = getMockGetEntityIteratorMethod(); + } catch (Exception e) { + AndroidTestCase.fail("Exception thrown: " + e); + } + AndroidTestCase.assertNotNull(mockGetEntityIteratorMethod); + composer.setGetEntityIteratorMethod(mockGetEntityIteratorMethod); + if (!composer.init(cursor)) { + AndroidTestCase.fail("init() failed. Reason: " + composer.getErrorReason()); + } + AndroidTestCase.assertFalse(composer.isAfterLast()); + final String vcard = composer.createOneEntry(); + AndroidTestCase.assertNotNull(vcard); + composer.terminate(); + return vcard; + } +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/VNode.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/VNode.java new file mode 100644 index 0000000..1147715 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/VNode.java @@ -0,0 +1,28 @@ +/* + * 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.vcard.tests.testutils; + +import java.util.ArrayList; + +/** + * Previously used in main vCard handling code but now exists only for testing. + * + * TODO: remove this and relevant classes. Given we can have appropriate test cases for + * VCardEntry and VCardProperty, we won't need redundancy here. + */ +public class VNode { + public ArrayList propList = new ArrayList(); +} diff --git a/modules/vcard/tests/src/com/android/vcard/tests/testutils/VNodeBuilder.java b/modules/vcard/tests/src/com/android/vcard/tests/testutils/VNodeBuilder.java new file mode 100644 index 0000000..560f352 --- /dev/null +++ b/modules/vcard/tests/src/com/android/vcard/tests/testutils/VNodeBuilder.java @@ -0,0 +1,173 @@ +/* + * 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.vcard.tests.testutils; + +import com.android.vcard.VCardConfig; +import com.android.vcard.VCardInterpreter; +import com.android.vcard.VCardProperty; +import com.android.vcard.VCardUtils; + +import android.content.ContentValues; +import android.util.Base64; +import android.util.Log; + +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + *

+ * The class storing the parse result to custom datastruct: + * {@link VNode}, and {@link PropertyNode}. + * Maybe several vcard instance, so use vNodeList to store. + *

+ *

+ * This is called VNode, not VCardNode, since it was used for expressing vCalendar (iCal). + *

+ */ +public class VNodeBuilder implements VCardInterpreter { + private static String LOG_TAG = "VNodeBuilder"; + + private List mVNodeList = new ArrayList(); + private VNode mCurrentVNode; + + /** + * The charset using which VParser parses the text. + */ + private String mSourceCharset; + + /** + * The charset with which byte array is encoded to String. + */ + private String mTargetCharset; + + private boolean mStrictLineBreakParsing; + + public VNodeBuilder() { + this(VCardConfig.DEFAULT_IMPORT_CHARSET, false); + } + + public VNodeBuilder(String targetCharset, boolean strictLineBreakParsing) { + mSourceCharset = VCardConfig.DEFAULT_INTERMEDIATE_CHARSET; + if (targetCharset != null) { + mTargetCharset = targetCharset; + } else { + mTargetCharset = VCardConfig.DEFAULT_IMPORT_CHARSET; + } + mStrictLineBreakParsing = strictLineBreakParsing; + } + + @Override + public void onVCardStarted() { + } + + @Override + public void onVCardEnded() { + } + + @Override + public void onEntryStarted() { + mCurrentVNode = new VNode(); + mVNodeList.add(mCurrentVNode); + } + + @Override + public void onEntryEnded() { + int lastIndex = mVNodeList.size() - 1; + mVNodeList.remove(lastIndex--); + mCurrentVNode = lastIndex >= 0 ? mVNodeList.get(lastIndex) : null; + } + + @Override + public void onPropertyCreated(VCardProperty property) { + // TODO: remove PropertyNode. + PropertyNode propNode = new PropertyNode(); + propNode.propName = property.getName(); + List groupList = property.getGroupList(); + if (groupList != null) { + propNode.propGroupSet.addAll(groupList); + } + Map> propertyParameterMap = property.getParameterMap(); + for (String paramType : propertyParameterMap.keySet()) { + Collection paramValueList = propertyParameterMap.get(paramType); + if (paramType.equalsIgnoreCase("TYPE")) { + propNode.paramMap_TYPE.addAll(paramValueList); + } else { + for (String paramValue : paramValueList) { + propNode.paramMap.put(paramType, paramValue); + } + } + } + + // TODO: just redundant + + if (property.getRawValue() == null) { + propNode.propValue_bytes = null; + propNode.propValue_vector.clear(); + propNode.propValue_vector.add(""); + propNode.propValue = ""; + return; + } + + final List values = property.getValueList(); + if (values == null || values.size() == 0) { + propNode.propValue_vector.clear(); + propNode.propValue_vector.add(""); + propNode.propValue = ""; + } else { + propNode.propValue_vector.addAll(values); + propNode.propValue = listToString(propNode.propValue_vector); + } + propNode.propValue_bytes = property.getByteValue(); + + mCurrentVNode.propList.add(propNode); + } + + private String listToString(List list){ + int size = list.size(); + if (size > 1) { + StringBuilder typeListB = new StringBuilder(); + for (String type : list) { + typeListB.append(type).append(";"); + } + int len = typeListB.length(); + if (len > 0 && typeListB.charAt(len - 1) == ';') { + return typeListB.substring(0, len - 1); + } + return typeListB.toString(); + } else if (size == 1) { + return list.get(0); + } else { + return ""; + } + } + + public String getResult(){ + throw new RuntimeException("Not supported"); + } + + public List getVNodeList() { + return mVNodeList; + } + + public VNode getCurrentVNode() { + return mCurrentVNode; + } +}