diff options
Diffstat (limited to 'src')
37 files changed, 6286 insertions, 16 deletions
diff --git a/src/com/android/contacts/common/ContactPhotoManager.java b/src/com/android/contacts/common/ContactPhotoManager.java index 995201d6..3cde3197 100644 --- a/src/com/android/contacts/common/ContactPhotoManager.java +++ b/src/com/android/contacts/common/ContactPhotoManager.java @@ -573,6 +573,7 @@ class ContactPhotoManagerImpl extends ContactPhotoManager implements Callback { layers[1] = new BitmapDrawable(mContext.getResources(), cachedBitmap); TransitionDrawable drawable = new TransitionDrawable(layers); view.setImageDrawable(drawable); + drawable.setCrossFadeEnabled(true); drawable.startTransition(FADE_TRANSITION_DURATION); } else { view.setImageBitmap(cachedBitmap); diff --git a/src/com/android/contacts/common/ContactsUtils.java b/src/com/android/contacts/common/ContactsUtils.java new file mode 100644 index 00000000..038ec260 --- /dev/null +++ b/src/com/android/contacts/common/ContactsUtils.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.contacts.common; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.DisplayPhoto; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; + +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.contacts.common.test.NeededForTesting; +import com.android.contacts.common.model.AccountTypeManager; + +import java.util.List; + +public class ContactsUtils { + private static final String TAG = "ContactsUtils"; + + private static int sThumbnailSize = -1; + + // TODO find a proper place for the canonical version of these + public interface ProviderNames { + String YAHOO = "Yahoo"; + String GTALK = "GTalk"; + String MSN = "MSN"; + String ICQ = "ICQ"; + String AIM = "AIM"; + String XMPP = "XMPP"; + String JABBER = "JABBER"; + String SKYPE = "SKYPE"; + String QQ = "QQ"; + } + + /** + * This looks up the provider name defined in + * ProviderNames from the predefined IM protocol id. + * This is used for interacting with the IM application. + * + * @param protocol the protocol ID + * @return the provider name the IM app uses for the given protocol, or null if no + * provider is defined for the given protocol + * @hide + */ + public static String lookupProviderNameFromId(int protocol) { + switch (protocol) { + case Im.PROTOCOL_GOOGLE_TALK: + return ProviderNames.GTALK; + case Im.PROTOCOL_AIM: + return ProviderNames.AIM; + case Im.PROTOCOL_MSN: + return ProviderNames.MSN; + case Im.PROTOCOL_YAHOO: + return ProviderNames.YAHOO; + case Im.PROTOCOL_ICQ: + return ProviderNames.ICQ; + case Im.PROTOCOL_JABBER: + return ProviderNames.JABBER; + case Im.PROTOCOL_SKYPE: + return ProviderNames.SKYPE; + case Im.PROTOCOL_QQ: + return ProviderNames.QQ; + } + return null; + } + + /** + * Test if the given {@link CharSequence} contains any graphic characters, + * first checking {@link TextUtils#isEmpty(CharSequence)} to handle null. + */ + public static boolean isGraphic(CharSequence str) { + return !TextUtils.isEmpty(str) && TextUtils.isGraphic(str); + } + + /** + * Returns true if two objects are considered equal. Two null references are equal here. + */ + @NeededForTesting + public static boolean areObjectsEqual(Object a, Object b) { + return a == b || (a != null && a.equals(b)); + } + + /** + * Returns true if two {@link Intent}s are both null, or have the same action. + */ + public static final boolean areIntentActionEqual(Intent a, Intent b) { + if (a == b) { + return true; + } + if (a == null || b == null) { + return false; + } + return TextUtils.equals(a.getAction(), b.getAction()); + } + + public static boolean areContactWritableAccountsAvailable(Context context) { + final List<AccountWithDataSet> accounts = + AccountTypeManager.getInstance(context).getAccounts(true /* writeable */); + return !accounts.isEmpty(); + } + + public static boolean areGroupWritableAccountsAvailable(Context context) { + final List<AccountWithDataSet> accounts = + AccountTypeManager.getInstance(context).getGroupWritableAccounts(); + return !accounts.isEmpty(); + } + + /** + * Returns the size (width and height) of thumbnail pictures as configured in the provider. This + * can safely be called from the UI thread, as the provider can serve this without performing + * a database access + */ + public static int getThumbnailSize(Context context) { + if (sThumbnailSize == -1) { + final Cursor c = context.getContentResolver().query( + DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI, + new String[] { DisplayPhoto.THUMBNAIL_MAX_DIM }, null, null, null); + try { + c.moveToFirst(); + sThumbnailSize = c.getInt(0); + } finally { + c.close(); + } + } + return sThumbnailSize; + } + +} diff --git a/src/com/android/contacts/common/GroupMetaData.java b/src/com/android/contacts/common/GroupMetaData.java new file mode 100644 index 00000000..fa86ae20 --- /dev/null +++ b/src/com/android/contacts/common/GroupMetaData.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.contacts.common; + +/** + * Meta-data for a contact group. We load all groups associated with the contact's + * constituent accounts. + */ +public final class GroupMetaData { + private String mAccountName; + private String mAccountType; + private String mDataSet; + private long mGroupId; + private String mTitle; + private boolean mDefaultGroup; + private boolean mFavorites; + + public GroupMetaData(String accountName, String accountType, String dataSet, long groupId, + String title, boolean defaultGroup, boolean favorites) { + this.mAccountName = accountName; + this.mAccountType = accountType; + this.mDataSet = dataSet; + this.mGroupId = groupId; + this.mTitle = title; + this.mDefaultGroup = defaultGroup; + this.mFavorites = favorites; + } + + public String getAccountName() { + return mAccountName; + } + + public String getAccountType() { + return mAccountType; + } + + public String getDataSet() { + return mDataSet; + } + + public long getGroupId() { + return mGroupId; + } + + public String getTitle() { + return mTitle; + } + + public boolean isDefaultGroup() { + return mDefaultGroup; + } + + public boolean isFavorites() { + return mFavorites; + } +}
\ No newline at end of file diff --git a/src/com/android/contacts/common/list/ContactEntryListAdapter.java b/src/com/android/contacts/common/list/ContactEntryListAdapter.java index bf9be457..0d13ec25 100644 --- a/src/com/android/contacts/common/list/ContactEntryListAdapter.java +++ b/src/com/android/contacts/common/list/ContactEntryListAdapter.java @@ -667,8 +667,13 @@ public abstract class ContactEntryListAdapter extends IndexerListAdapter { int contactIdColumn, int lookUpKeyColumn) { long contactId = cursor.getLong(contactIdColumn); String lookupKey = cursor.getString(lookUpKeyColumn); - Uri uri = Contacts.getLookupUri(contactId, lookupKey); long directoryId = ((DirectoryPartition)getPartition(partitionIndex)).getDirectoryId(); + // Remote directories must have a lookup key or we don't have + // a working contact URI + if (TextUtils.isEmpty(lookupKey) && isRemoteDirectory(directoryId)) { + return null; + } + Uri uri = Contacts.getLookupUri(contactId, lookupKey); if (directoryId != Directory.DEFAULT) { uri = uri.buildUpon().appendQueryParameter( ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)).build(); diff --git a/src/com/android/contacts/common/list/ContactListItemView.java b/src/com/android/contacts/common/list/ContactListItemView.java index 95ac3920..6b9492bf 100644 --- a/src/com/android/contacts/common/list/ContactListItemView.java +++ b/src/com/android/contacts/common/list/ContactListItemView.java @@ -916,7 +916,7 @@ public class ContactListItemView extends ViewGroup mNameTextView = new TextView(mContext); mNameTextView.setSingleLine(true); mNameTextView.setEllipsize(getTextEllipsis()); - mNameTextView.setTextAppearance(mContext, android.R.style.TextAppearance_Medium); + mNameTextView.setTextAppearance(mContext, R.style.TextAppearanceMedium); // Manually call setActivated() since this view may be added after the first // setActivated() call toward this whole item view. mNameTextView.setActivated(isActivated()); @@ -983,9 +983,9 @@ public class ContactListItemView extends ViewGroup mLabelView = new TextView(mContext); mLabelView.setSingleLine(true); mLabelView.setEllipsize(getTextEllipsis()); - mLabelView.setTextAppearance(mContext, android.R.style.TextAppearance_Small); + mLabelView.setTextAppearance(mContext, R.style.TextAppearanceSmall); if (mPhotoPosition == PhotoPosition.LEFT) { - mLabelView.setTextSize(TypedValue.COMPLEX_UNIT_SP, mCountViewTextSize); + //mLabelView.setTextSize(TypedValue.COMPLEX_UNIT_SP, mCountViewTextSize); mLabelView.setAllCaps(true); mLabelView.setGravity(Gravity.END); } else { @@ -1072,7 +1072,7 @@ public class ContactListItemView extends ViewGroup mDataView = new TextView(mContext); mDataView.setSingleLine(true); mDataView.setEllipsize(getTextEllipsis()); - mDataView.setTextAppearance(mContext, android.R.style.TextAppearance_Small); + mDataView.setTextAppearance(mContext, R.style.TextAppearanceSmall); mDataView.setActivated(isActivated()); mDataView.setId(R.id.cliv_data_view); addView(mDataView); @@ -1103,7 +1103,6 @@ public class ContactListItemView extends ViewGroup mSnippetView.setSingleLine(true); mSnippetView.setEllipsize(getTextEllipsis()); mSnippetView.setTextAppearance(mContext, android.R.style.TextAppearance_Small); - mSnippetView.setTypeface(mSnippetView.getTypeface(), Typeface.BOLD); mSnippetView.setActivated(isActivated()); addView(mSnippetView); } @@ -1535,7 +1534,10 @@ public class ContactListItemView extends ViewGroup public boolean onTouchEvent(MotionEvent event) { final float x = event.getX(); final float y = event.getY(); - if (mBoundsWithoutHeader.contains((int) x, (int) y)) { + // If the touch event's coordinates are not within the view's header, then delegate + // to super.onTouchEvent so that regular view behavior is preserved. Otherwise, consume + // and ignore the touch event. + if (mBoundsWithoutHeader.contains((int) x, (int) y) || !pointInView(x, y, 0)) { return super.onTouchEvent(event); } else { return true; diff --git a/src/com/android/contacts/common/list/PhoneNumberPickerFragment.java b/src/com/android/contacts/common/list/PhoneNumberPickerFragment.java index 9091fc51..47cce8b1 100644 --- a/src/com/android/contacts/common/list/PhoneNumberPickerFragment.java +++ b/src/com/android/contacts/common/list/PhoneNumberPickerFragment.java @@ -21,6 +21,7 @@ import android.content.Loader; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; +import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.MenuItem; @@ -189,7 +190,7 @@ public class PhoneNumberPickerFragment extends ContactEntryListFragment<ContactE pickPhoneNumber(phoneUri); } else { final String number = getPhoneNumber(position); - if (number != null) { + if (!TextUtils.isEmpty(number)) { cacheContactInfo(position); mListener.onCallNumberDirectly(number); } else { diff --git a/src/com/android/contacts/common/list/PinnedHeaderListView.java b/src/com/android/contacts/common/list/PinnedHeaderListView.java index 034a3dc8..db247b34 100644 --- a/src/com/android/contacts/common/list/PinnedHeaderListView.java +++ b/src/com/android/contacts/common/list/PinnedHeaderListView.java @@ -112,11 +112,11 @@ public class PinnedHeaderListView extends AutoScrollListView private int mHeaderWidth; public PinnedHeaderListView(Context context) { - this(context, null, com.android.internal.R.attr.listViewStyle); + this(context, null, android.R.attr.listViewStyle); } public PinnedHeaderListView(Context context, AttributeSet attrs) { - this(context, attrs, com.android.internal.R.attr.listViewStyle); + this(context, attrs, android.R.attr.listViewStyle); } public PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle) { @@ -414,9 +414,14 @@ public class PinnedHeaderListView extends AutoScrollListView if (mScrollState == SCROLL_STATE_IDLE) { final int y = (int)ev.getY(); + final int x = (int)ev.getX(); for (int i = mSize; --i >= 0;) { PinnedHeader header = mHeaders[i]; - if (header.visible && header.y <= y && header.y + header.height > y) { + // For RTL layouts, this also takes into account that the scrollbar is on the left + // side. + final int padding = getPaddingLeft(); + if (header.visible && header.y <= y && header.y + header.height > y && + x >= padding && padding + mHeaderWidth >= x) { mHeaderTouched = true; if (mScrollToSectionOnHeaderTouch && ev.getAction() == MotionEvent.ACTION_DOWN) { diff --git a/src/com/android/contacts/common/model/Contact.java b/src/com/android/contacts/common/model/Contact.java new file mode 100644 index 00000000..d5ff0a32 --- /dev/null +++ b/src/com/android/contacts/common/model/Contact.java @@ -0,0 +1,475 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model; + +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds.Photo; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.Directory; +import android.provider.ContactsContract.DisplayNameSources; + +import com.android.contacts.common.GroupMetaData; +import com.android.contacts.common.model.account.AccountType; +import com.android.contacts.common.util.DataStatus; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import java.util.ArrayList; +import java.util.Collections; + +/** + * A Contact represents a single person or logical entity as perceived by the user. The information + * about a contact can come from multiple data sources, which are each represented by a RawContact + * object. Thus, a Contact is associated with a collection of RawContact objects. + * + * The aggregation of raw contacts into a single contact is performed automatically, and it is + * also possible for users to manually split and join raw contacts into various contacts. + * + * Only the {@link ContactLoader} class can create a Contact object with various flags to allow + * partial loading of contact data. Thus, an instance of this class should be treated as + * a read-only object. + */ +public class Contact { + private enum Status { + /** Contact is successfully loaded */ + LOADED, + /** There was an error loading the contact */ + ERROR, + /** Contact is not found */ + NOT_FOUND, + } + + private final Uri mRequestedUri; + private final Uri mLookupUri; + private final Uri mUri; + private final long mDirectoryId; + private final String mLookupKey; + private final long mId; + private final long mNameRawContactId; + private final int mDisplayNameSource; + private final long mPhotoId; + private final String mPhotoUri; + private final String mDisplayName; + private final String mAltDisplayName; + private final String mPhoneticName; + private final boolean mStarred; + private final Integer mPresence; + private ImmutableList<RawContact> mRawContacts; + private ImmutableMap<Long,DataStatus> mStatuses; + private ImmutableList<AccountType> mInvitableAccountTypes; + + private String mDirectoryDisplayName; + private String mDirectoryType; + private String mDirectoryAccountType; + private String mDirectoryAccountName; + private int mDirectoryExportSupport; + + private ImmutableList<GroupMetaData> mGroups; + + private byte[] mPhotoBinaryData; + private final boolean mSendToVoicemail; + private final String mCustomRingtone; + private final boolean mIsUserProfile; + + private final Contact.Status mStatus; + private final Exception mException; + + /** + * Constructor for special results, namely "no contact found" and "error". + */ + private Contact(Uri requestedUri, Contact.Status status, Exception exception) { + if (status == Status.ERROR && exception == null) { + throw new IllegalArgumentException("ERROR result must have exception"); + } + mStatus = status; + mException = exception; + mRequestedUri = requestedUri; + mLookupUri = null; + mUri = null; + mDirectoryId = -1; + mLookupKey = null; + mId = -1; + mRawContacts = null; + mStatuses = null; + mNameRawContactId = -1; + mDisplayNameSource = DisplayNameSources.UNDEFINED; + mPhotoId = -1; + mPhotoUri = null; + mDisplayName = null; + mAltDisplayName = null; + mPhoneticName = null; + mStarred = false; + mPresence = null; + mInvitableAccountTypes = null; + mSendToVoicemail = false; + mCustomRingtone = null; + mIsUserProfile = false; + } + + public static Contact forError(Uri requestedUri, Exception exception) { + return new Contact(requestedUri, Status.ERROR, exception); + } + + public static Contact forNotFound(Uri requestedUri) { + return new Contact(requestedUri, Status.NOT_FOUND, null); + } + + /** + * Constructor to call when contact was found + */ + public Contact(Uri requestedUri, Uri uri, Uri lookupUri, long directoryId, String lookupKey, + long id, long nameRawContactId, int displayNameSource, long photoId, + String photoUri, String displayName, String altDisplayName, String phoneticName, + boolean starred, Integer presence, boolean sendToVoicemail, String customRingtone, + boolean isUserProfile) { + mStatus = Status.LOADED; + mException = null; + mRequestedUri = requestedUri; + mLookupUri = lookupUri; + mUri = uri; + mDirectoryId = directoryId; + mLookupKey = lookupKey; + mId = id; + mRawContacts = null; + mStatuses = null; + mNameRawContactId = nameRawContactId; + mDisplayNameSource = displayNameSource; + mPhotoId = photoId; + mPhotoUri = photoUri; + mDisplayName = displayName; + mAltDisplayName = altDisplayName; + mPhoneticName = phoneticName; + mStarred = starred; + mPresence = presence; + mInvitableAccountTypes = null; + mSendToVoicemail = sendToVoicemail; + mCustomRingtone = customRingtone; + mIsUserProfile = isUserProfile; + } + + public Contact(Uri requestedUri, Contact from) { + mRequestedUri = requestedUri; + + mStatus = from.mStatus; + mException = from.mException; + mLookupUri = from.mLookupUri; + mUri = from.mUri; + mDirectoryId = from.mDirectoryId; + mLookupKey = from.mLookupKey; + mId = from.mId; + mNameRawContactId = from.mNameRawContactId; + mDisplayNameSource = from.mDisplayNameSource; + mPhotoId = from.mPhotoId; + mPhotoUri = from.mPhotoUri; + mDisplayName = from.mDisplayName; + mAltDisplayName = from.mAltDisplayName; + mPhoneticName = from.mPhoneticName; + mStarred = from.mStarred; + mPresence = from.mPresence; + mRawContacts = from.mRawContacts; + mStatuses = from.mStatuses; + mInvitableAccountTypes = from.mInvitableAccountTypes; + + mDirectoryDisplayName = from.mDirectoryDisplayName; + mDirectoryType = from.mDirectoryType; + mDirectoryAccountType = from.mDirectoryAccountType; + mDirectoryAccountName = from.mDirectoryAccountName; + mDirectoryExportSupport = from.mDirectoryExportSupport; + + mGroups = from.mGroups; + + mPhotoBinaryData = from.mPhotoBinaryData; + mSendToVoicemail = from.mSendToVoicemail; + mCustomRingtone = from.mCustomRingtone; + mIsUserProfile = from.mIsUserProfile; + } + + /** + * @param exportSupport See {@link Directory#EXPORT_SUPPORT}. + */ + public void setDirectoryMetaData(String displayName, String directoryType, + String accountType, String accountName, int exportSupport) { + mDirectoryDisplayName = displayName; + mDirectoryType = directoryType; + mDirectoryAccountType = accountType; + mDirectoryAccountName = accountName; + mDirectoryExportSupport = exportSupport; + } + + /* package */ void setPhotoBinaryData(byte[] photoBinaryData) { + mPhotoBinaryData = photoBinaryData; + } + + /** + * Returns the URI for the contact that contains both the lookup key and the ID. This is + * the best URI to reference a contact. + * For directory contacts, this is the same a the URI as returned by {@link #getUri()} + */ + public Uri getLookupUri() { + return mLookupUri; + } + + public String getLookupKey() { + return mLookupKey; + } + + /** + * Returns the contact Uri that was passed to the provider to make the query. This is + * the same as the requested Uri, unless the requested Uri doesn't specify a Contact: + * If it either references a Raw-Contact or a Person (a pre-Eclair style Uri), this Uri will + * always reference the full aggregate contact. + */ + public Uri getUri() { + return mUri; + } + + /** + * Returns the URI for which this {@link ContactLoader) was initially requested. + */ + public Uri getRequestedUri() { + return mRequestedUri; + } + + /** + * Instantiate a new RawContactDeltaList for this contact. + */ + public RawContactDeltaList createRawContactDeltaList() { + return RawContactDeltaList.fromIterator(getRawContacts().iterator()); + } + + /** + * Returns the contact ID. + */ + @VisibleForTesting + /* package */ long getId() { + return mId; + } + + /** + * @return true when an exception happened during loading, in which case + * {@link #getException} returns the actual exception object. + * Note {@link #isNotFound()} and {@link #isError()} are mutually exclusive; If + * {@link #isError()} is {@code true}, {@link #isNotFound()} is always {@code false}, + * and vice versa. + */ + public boolean isError() { + return mStatus == Status.ERROR; + } + + public Exception getException() { + return mException; + } + + /** + * @return true when the specified contact is not found. + * Note {@link #isNotFound()} and {@link #isError()} are mutually exclusive; If + * {@link #isError()} is {@code true}, {@link #isNotFound()} is always {@code false}, + * and vice versa. + */ + public boolean isNotFound() { + return mStatus == Status.NOT_FOUND; + } + + /** + * @return true if the specified contact is successfully loaded. + * i.e. neither {@link #isError()} nor {@link #isNotFound()}. + */ + public boolean isLoaded() { + return mStatus == Status.LOADED; + } + + public long getNameRawContactId() { + return mNameRawContactId; + } + + public int getDisplayNameSource() { + return mDisplayNameSource; + } + + public long getPhotoId() { + return mPhotoId; + } + + public String getPhotoUri() { + return mPhotoUri; + } + + public String getDisplayName() { + return mDisplayName; + } + + public String getAltDisplayName() { + return mAltDisplayName; + } + + public String getPhoneticName() { + return mPhoneticName; + } + + public boolean getStarred() { + return mStarred; + } + + public Integer getPresence() { + return mPresence; + } + + /** + * This can return non-null invitable account types only if the {@link ContactLoader} was + * configured to load invitable account types in its constructor. + * @return + */ + public ImmutableList<AccountType> getInvitableAccountTypes() { + return mInvitableAccountTypes; + } + + public ImmutableList<RawContact> getRawContacts() { + return mRawContacts; + } + + public ImmutableMap<Long, DataStatus> getStatuses() { + return mStatuses; + } + + public long getDirectoryId() { + return mDirectoryId; + } + + public boolean isDirectoryEntry() { + return mDirectoryId != -1 && mDirectoryId != Directory.DEFAULT + && mDirectoryId != Directory.LOCAL_INVISIBLE; + } + + /** + * @return true if this is a contact (not group, etc.) with at least one + * writable raw-contact, and false otherwise. + */ + public boolean isWritableContact(final Context context) { + return getFirstWritableRawContactId(context) != -1; + } + + /** + * Return the ID of the first raw-contact in the contact data that belongs to a + * contact-writable account, or -1 if no such entity exists. + */ + public long getFirstWritableRawContactId(final Context context) { + // Directory entries are non-writable + if (isDirectoryEntry()) return -1; + + // Iterate through raw-contacts; if we find a writable on, return its ID. + for (RawContact rawContact : getRawContacts()) { + AccountType accountType = rawContact.getAccountType(context); + if (accountType != null && accountType.areContactsWritable()) { + return rawContact.getId(); + } + } + // No writable raw-contact was found. + return -1; + } + + public int getDirectoryExportSupport() { + return mDirectoryExportSupport; + } + + public String getDirectoryDisplayName() { + return mDirectoryDisplayName; + } + + public String getDirectoryType() { + return mDirectoryType; + } + + public String getDirectoryAccountType() { + return mDirectoryAccountType; + } + + public String getDirectoryAccountName() { + return mDirectoryAccountName; + } + + public byte[] getPhotoBinaryData() { + return mPhotoBinaryData; + } + + public ArrayList<ContentValues> getContentValues() { + if (mRawContacts.size() != 1) { + throw new IllegalStateException( + "Cannot extract content values from an aggregated contact"); + } + + RawContact rawContact = mRawContacts.get(0); + ArrayList<ContentValues> result = rawContact.getContentValues(); + + // If the photo was loaded using the URI, create an entry for the photo + // binary data. + if (mPhotoId == 0 && mPhotoBinaryData != null) { + ContentValues photo = new ContentValues(); + photo.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE); + photo.put(Photo.PHOTO, mPhotoBinaryData); + result.add(photo); + } + + return result; + } + + /** + * This can return non-null group meta-data only if the {@link ContactLoader} was configured to + * load group metadata in its constructor. + * @return + */ + public ImmutableList<GroupMetaData> getGroupMetaData() { + return mGroups; + } + + public boolean isSendToVoicemail() { + return mSendToVoicemail; + } + + public String getCustomRingtone() { + return mCustomRingtone; + } + + public boolean isUserProfile() { + return mIsUserProfile; + } + + @Override + public String toString() { + return "{requested=" + mRequestedUri + ",lookupkey=" + mLookupKey + + ",uri=" + mUri + ",status=" + mStatus + "}"; + } + + /* package */ void setRawContacts(ImmutableList<RawContact> rawContacts) { + mRawContacts = rawContacts; + } + + /* package */ void setStatuses(ImmutableMap<Long, DataStatus> statuses) { + mStatuses = statuses; + } + + /* package */ void setInvitableAccountTypes(ImmutableList<AccountType> accountTypes) { + mInvitableAccountTypes = accountTypes; + } + + /* package */ void setGroupMetaData(ImmutableList<GroupMetaData> groups) { + mGroups = groups; + } +} diff --git a/src/com/android/contacts/common/model/ContactLoader.java b/src/com/android/contacts/common/model/ContactLoader.java new file mode 100644 index 00000000..ce177b04 --- /dev/null +++ b/src/com/android/contacts/common/model/ContactLoader.java @@ -0,0 +1,970 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.contacts.common.model; + +import android.content.AsyncTaskLoader; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.AssetFileDescriptor; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.GroupMembership; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.Directory; +import android.provider.ContactsContract.Groups; +import android.provider.ContactsContract.RawContacts; +import android.text.TextUtils; +import android.util.Log; + +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.GroupMetaData; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.account.AccountType; +import com.android.contacts.common.model.account.AccountTypeWithDataSet; +import com.android.contacts.common.util.Constants; +import com.android.contacts.common.util.ContactLoaderUtils; +import com.android.contacts.common.util.DataStatus; +import com.android.contacts.common.util.UriUtils; +import com.android.contacts.common.model.dataitem.DataItem; +import com.android.contacts.common.model.dataitem.PhoneDataItem; +import com.android.contacts.common.model.dataitem.PhotoDataItem; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Loads a single Contact and all it constituent RawContacts. + */ +public class ContactLoader extends AsyncTaskLoader<Contact> { + + private static final String TAG = ContactLoader.class.getSimpleName(); + + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + + /** A short-lived cache that can be set by {@link #cacheResult()} */ + private static Contact sCachedResult = null; + + private final Uri mRequestedUri; + private Uri mLookupUri; + private boolean mLoadGroupMetaData; + private boolean mLoadInvitableAccountTypes; + private boolean mPostViewNotification; + private boolean mComputeFormattedPhoneNumber; + private Contact mContact; + private ForceLoadContentObserver mObserver; + private final Set<Long> mNotifiedRawContactIds = Sets.newHashSet(); + + public ContactLoader(Context context, Uri lookupUri, boolean postViewNotification) { + this(context, lookupUri, false, false, postViewNotification, false); + } + + public ContactLoader(Context context, Uri lookupUri, boolean loadGroupMetaData, + boolean loadInvitableAccountTypes, + boolean postViewNotification, boolean computeFormattedPhoneNumber) { + super(context); + mLookupUri = lookupUri; + mRequestedUri = lookupUri; + mLoadGroupMetaData = loadGroupMetaData; + mLoadInvitableAccountTypes = loadInvitableAccountTypes; + mPostViewNotification = postViewNotification; + mComputeFormattedPhoneNumber = computeFormattedPhoneNumber; + } + + /** + * Projection used for the query that loads all data for the entire contact (except for + * social stream items). + */ + private static class ContactQuery { + static final String[] COLUMNS = new String[] { + Contacts.NAME_RAW_CONTACT_ID, + Contacts.DISPLAY_NAME_SOURCE, + Contacts.LOOKUP_KEY, + Contacts.DISPLAY_NAME, + Contacts.DISPLAY_NAME_ALTERNATIVE, + Contacts.PHONETIC_NAME, + Contacts.PHOTO_ID, + Contacts.STARRED, + Contacts.CONTACT_PRESENCE, + Contacts.CONTACT_STATUS, + Contacts.CONTACT_STATUS_TIMESTAMP, + Contacts.CONTACT_STATUS_RES_PACKAGE, + Contacts.CONTACT_STATUS_LABEL, + Contacts.Entity.CONTACT_ID, + Contacts.Entity.RAW_CONTACT_ID, + + RawContacts.ACCOUNT_NAME, + RawContacts.ACCOUNT_TYPE, + RawContacts.DATA_SET, + RawContacts.ACCOUNT_TYPE_AND_DATA_SET, + RawContacts.DIRTY, + RawContacts.VERSION, + RawContacts.SOURCE_ID, + RawContacts.SYNC1, + RawContacts.SYNC2, + RawContacts.SYNC3, + RawContacts.SYNC4, + RawContacts.DELETED, + RawContacts.NAME_VERIFIED, + + Contacts.Entity.DATA_ID, + Data.DATA1, + Data.DATA2, + Data.DATA3, + Data.DATA4, + Data.DATA5, + Data.DATA6, + Data.DATA7, + Data.DATA8, + Data.DATA9, + Data.DATA10, + Data.DATA11, + Data.DATA12, + Data.DATA13, + Data.DATA14, + Data.DATA15, + Data.SYNC1, + Data.SYNC2, + Data.SYNC3, + Data.SYNC4, + Data.DATA_VERSION, + Data.IS_PRIMARY, + Data.IS_SUPER_PRIMARY, + Data.MIMETYPE, + Data.RES_PACKAGE, + + GroupMembership.GROUP_SOURCE_ID, + + Data.PRESENCE, + Data.CHAT_CAPABILITY, + Data.STATUS, + Data.STATUS_RES_PACKAGE, + Data.STATUS_ICON, + Data.STATUS_LABEL, + Data.STATUS_TIMESTAMP, + + Contacts.PHOTO_URI, + Contacts.SEND_TO_VOICEMAIL, + Contacts.CUSTOM_RINGTONE, + Contacts.IS_USER_PROFILE, + }; + + public static final int NAME_RAW_CONTACT_ID = 0; + public static final int DISPLAY_NAME_SOURCE = 1; + public static final int LOOKUP_KEY = 2; + public static final int DISPLAY_NAME = 3; + public static final int ALT_DISPLAY_NAME = 4; + public static final int PHONETIC_NAME = 5; + public static final int PHOTO_ID = 6; + public static final int STARRED = 7; + public static final int CONTACT_PRESENCE = 8; + public static final int CONTACT_STATUS = 9; + public static final int CONTACT_STATUS_TIMESTAMP = 10; + public static final int CONTACT_STATUS_RES_PACKAGE = 11; + public static final int CONTACT_STATUS_LABEL = 12; + public static final int CONTACT_ID = 13; + public static final int RAW_CONTACT_ID = 14; + + public static final int ACCOUNT_NAME = 15; + public static final int ACCOUNT_TYPE = 16; + public static final int DATA_SET = 17; + public static final int ACCOUNT_TYPE_AND_DATA_SET = 18; + public static final int DIRTY = 19; + public static final int VERSION = 20; + public static final int SOURCE_ID = 21; + public static final int SYNC1 = 22; + public static final int SYNC2 = 23; + public static final int SYNC3 = 24; + public static final int SYNC4 = 25; + public static final int DELETED = 26; + public static final int NAME_VERIFIED = 27; + + public static final int DATA_ID = 28; + public static final int DATA1 = 29; + public static final int DATA2 = 30; + public static final int DATA3 = 31; + public static final int DATA4 = 32; + public static final int DATA5 = 33; + public static final int DATA6 = 34; + public static final int DATA7 = 35; + public static final int DATA8 = 36; + public static final int DATA9 = 37; + public static final int DATA10 = 38; + public static final int DATA11 = 39; + public static final int DATA12 = 40; + public static final int DATA13 = 41; + public static final int DATA14 = 42; + public static final int DATA15 = 43; + public static final int DATA_SYNC1 = 44; + public static final int DATA_SYNC2 = 45; + public static final int DATA_SYNC3 = 46; + public static final int DATA_SYNC4 = 47; + public static final int DATA_VERSION = 48; + public static final int IS_PRIMARY = 49; + public static final int IS_SUPERPRIMARY = 50; + public static final int MIMETYPE = 51; + public static final int RES_PACKAGE = 52; + + public static final int GROUP_SOURCE_ID = 53; + + public static final int PRESENCE = 54; + public static final int CHAT_CAPABILITY = 55; + public static final int STATUS = 56; + public static final int STATUS_RES_PACKAGE = 57; + public static final int STATUS_ICON = 58; + public static final int STATUS_LABEL = 59; + public static final int STATUS_TIMESTAMP = 60; + + public static final int PHOTO_URI = 61; + public static final int SEND_TO_VOICEMAIL = 62; + public static final int CUSTOM_RINGTONE = 63; + public static final int IS_USER_PROFILE = 64; + } + + /** + * Projection used for the query that loads all data for the entire contact. + */ + private static class DirectoryQuery { + static final String[] COLUMNS = new String[] { + Directory.DISPLAY_NAME, + Directory.PACKAGE_NAME, + Directory.TYPE_RESOURCE_ID, + Directory.ACCOUNT_TYPE, + Directory.ACCOUNT_NAME, + Directory.EXPORT_SUPPORT, + }; + + public static final int DISPLAY_NAME = 0; + public static final int PACKAGE_NAME = 1; + public static final int TYPE_RESOURCE_ID = 2; + public static final int ACCOUNT_TYPE = 3; + public static final int ACCOUNT_NAME = 4; + public static final int EXPORT_SUPPORT = 5; + } + + private static class GroupQuery { + static final String[] COLUMNS = new String[] { + Groups.ACCOUNT_NAME, + Groups.ACCOUNT_TYPE, + Groups.DATA_SET, + Groups.ACCOUNT_TYPE_AND_DATA_SET, + Groups._ID, + Groups.TITLE, + Groups.AUTO_ADD, + Groups.FAVORITES, + }; + + public static final int ACCOUNT_NAME = 0; + public static final int ACCOUNT_TYPE = 1; + public static final int DATA_SET = 2; + public static final int ACCOUNT_TYPE_AND_DATA_SET = 3; + public static final int ID = 4; + public static final int TITLE = 5; + public static final int AUTO_ADD = 6; + public static final int FAVORITES = 7; + } + + @Override + public Contact loadInBackground() { + try { + final ContentResolver resolver = getContext().getContentResolver(); + final Uri uriCurrentFormat = ContactLoaderUtils.ensureIsContactUri( + resolver, mLookupUri); + final Contact cachedResult = sCachedResult; + sCachedResult = null; + // Is this the same Uri as what we had before already? In that case, reuse that result + final Contact result; + final boolean resultIsCached; + if (cachedResult != null && + UriUtils.areEqual(cachedResult.getLookupUri(), mLookupUri)) { + // We are using a cached result from earlier. Below, we should make sure + // we are not doing any more network or disc accesses + result = new Contact(mRequestedUri, cachedResult); + resultIsCached = true; + } else { + if (uriCurrentFormat.getLastPathSegment().equals(Constants.LOOKUP_URI_ENCODED)) { + result = loadEncodedContactEntity(uriCurrentFormat); + } else { + result = loadContactEntity(resolver, uriCurrentFormat); + } + resultIsCached = false; + } + if (result.isLoaded()) { + if (result.isDirectoryEntry()) { + if (!resultIsCached) { + loadDirectoryMetaData(result); + } + } else if (mLoadGroupMetaData) { + if (result.getGroupMetaData() == null) { + loadGroupMetaData(result); + } + } + if (mComputeFormattedPhoneNumber) { + computeFormattedPhoneNumbers(result); + } + if (!resultIsCached) loadPhotoBinaryData(result); + + // Note ME profile should never have "Add connection" + if (mLoadInvitableAccountTypes && result.getInvitableAccountTypes() == null) { + loadInvitableAccountTypes(result); + } + } + return result; + } catch (Exception e) { + Log.e(TAG, "Error loading the contact: " + mLookupUri, e); + return Contact.forError(mRequestedUri, e); + } + } + + private Contact loadEncodedContactEntity(Uri uri) throws JSONException { + final String jsonString = uri.getEncodedFragment(); + final JSONObject json = new JSONObject(jsonString); + + final long directoryId = + Long.valueOf(uri.getQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY)); + + final String displayName = json.getString(Contacts.DISPLAY_NAME); + final String altDisplayName = json.optString( + Contacts.DISPLAY_NAME_ALTERNATIVE, displayName); + final int displayNameSource = json.getInt(Contacts.DISPLAY_NAME_SOURCE); + final String photoUri = json.optString(Contacts.PHOTO_URI, null); + final Contact contact = new Contact( + uri, uri, + mLookupUri, + directoryId, + null /* lookupKey */, + -1 /* id */, + -1 /* nameRawContactId */, + displayNameSource, + 0 /* photoId */, + photoUri, + displayName, + altDisplayName, + null /* phoneticName */, + false /* starred */, + null /* presence */, + false /* sendToVoicemail */, + null /* customRingtone */, + false /* isUserProfile */); + + contact.setStatuses(new ImmutableMap.Builder<Long, DataStatus>().build()); + + final String accountName = json.optString(RawContacts.ACCOUNT_NAME, null); + final String directoryName = uri.getQueryParameter(Directory.DISPLAY_NAME); + if (accountName != null) { + final String accountType = json.getString(RawContacts.ACCOUNT_TYPE); + contact.setDirectoryMetaData(directoryName, null, accountName, accountType, + json.optInt(Directory.EXPORT_SUPPORT, + Directory.EXPORT_SUPPORT_SAME_ACCOUNT_ONLY)); + } else { + contact.setDirectoryMetaData(directoryName, null, null, null, + json.optInt(Directory.EXPORT_SUPPORT, Directory.EXPORT_SUPPORT_ANY_ACCOUNT)); + } + + final ContentValues values = new ContentValues(); + values.put(Data._ID, -1); + values.put(Data.CONTACT_ID, -1); + final RawContact rawContact = new RawContact(values); + + final JSONObject items = json.getJSONObject(Contacts.CONTENT_ITEM_TYPE); + final Iterator keys = items.keys(); + while (keys.hasNext()) { + final String mimetype = (String) keys.next(); + + // Could be single object or array. + final JSONObject obj = items.optJSONObject(mimetype); + if (obj == null) { + final JSONArray array = items.getJSONArray(mimetype); + for (int i = 0; i < array.length(); i++) { + final JSONObject item = array.getJSONObject(i); + processOneRecord(rawContact, item, mimetype); + } + } else { + processOneRecord(rawContact, obj, mimetype); + } + } + + contact.setRawContacts(new ImmutableList.Builder<RawContact>() + .add(rawContact) + .build()); + return contact; + } + + private void processOneRecord(RawContact rawContact, JSONObject item, String mimetype) + throws JSONException { + final ContentValues itemValues = new ContentValues(); + itemValues.put(Data.MIMETYPE, mimetype); + itemValues.put(Data._ID, -1); + + final Iterator iterator = item.keys(); + while (iterator.hasNext()) { + String name = (String) iterator.next(); + final Object o = item.get(name); + if (o instanceof String) { + itemValues.put(name, (String) o); + } else if (o instanceof Integer) { + itemValues.put(name, (Integer) o); + } + } + rawContact.addDataItemValues(itemValues); + } + + private Contact loadContactEntity(ContentResolver resolver, Uri contactUri) { + Uri entityUri = Uri.withAppendedPath(contactUri, Contacts.Entity.CONTENT_DIRECTORY); + Cursor cursor = resolver.query(entityUri, ContactQuery.COLUMNS, null, null, + Contacts.Entity.RAW_CONTACT_ID); + if (cursor == null) { + Log.e(TAG, "No cursor returned in loadContactEntity"); + return Contact.forNotFound(mRequestedUri); + } + + try { + if (!cursor.moveToFirst()) { + cursor.close(); + return Contact.forNotFound(mRequestedUri); + } + + // Create the loaded contact starting with the header data. + Contact contact = loadContactHeaderData(cursor, contactUri); + + // Fill in the raw contacts, which is wrapped in an Entity and any + // status data. Initially, result has empty entities and statuses. + long currentRawContactId = -1; + RawContact rawContact = null; + ImmutableList.Builder<RawContact> rawContactsBuilder = + new ImmutableList.Builder<RawContact>(); + ImmutableMap.Builder<Long, DataStatus> statusesBuilder = + new ImmutableMap.Builder<Long, DataStatus>(); + do { + long rawContactId = cursor.getLong(ContactQuery.RAW_CONTACT_ID); + if (rawContactId != currentRawContactId) { + // First time to see this raw contact id, so create a new entity, and + // add it to the result's entities. + currentRawContactId = rawContactId; + rawContact = new RawContact(loadRawContactValues(cursor)); + rawContactsBuilder.add(rawContact); + } + if (!cursor.isNull(ContactQuery.DATA_ID)) { + ContentValues data = loadDataValues(cursor); + rawContact.addDataItemValues(data); + + if (!cursor.isNull(ContactQuery.PRESENCE) + || !cursor.isNull(ContactQuery.STATUS)) { + final DataStatus status = new DataStatus(cursor); + final long dataId = cursor.getLong(ContactQuery.DATA_ID); + statusesBuilder.put(dataId, status); + } + } + } while (cursor.moveToNext()); + + contact.setRawContacts(rawContactsBuilder.build()); + contact.setStatuses(statusesBuilder.build()); + + return contact; + } finally { + cursor.close(); + } + } + + /** + * Looks for the photo data item in entities. If found, creates a new Bitmap instance. If + * not found, returns null + */ + private void loadPhotoBinaryData(Contact contactData) { + // If we have a photo URI, try loading that first. + String photoUri = contactData.getPhotoUri(); + if (photoUri != null) { + try { + final InputStream inputStream; + final AssetFileDescriptor fd; + final Uri uri = Uri.parse(photoUri); + final String scheme = uri.getScheme(); + if ("http".equals(scheme) || "https".equals(scheme)) { + // Support HTTP urls that might come from extended directories + inputStream = new URL(photoUri).openStream(); + fd = null; + } else { + fd = getContext().getContentResolver().openAssetFileDescriptor(uri, "r"); + inputStream = fd.createInputStream(); + } + byte[] buffer = new byte[16 * 1024]; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + int size; + while ((size = inputStream.read(buffer)) != -1) { + baos.write(buffer, 0, size); + } + contactData.setPhotoBinaryData(baos.toByteArray()); + } finally { + inputStream.close(); + if (fd != null) { + fd.close(); + } + } + return; + } catch (IOException ioe) { + // Just fall back to the case below. + } + } + + // If we couldn't load from a file, fall back to the data blob. + final long photoId = contactData.getPhotoId(); + if (photoId <= 0) { + // No photo ID + return; + } + + for (RawContact rawContact : contactData.getRawContacts()) { + for (DataItem dataItem : rawContact.getDataItems()) { + if (dataItem.getId() == photoId) { + if (!(dataItem instanceof PhotoDataItem)) { + break; + } + + final PhotoDataItem photo = (PhotoDataItem) dataItem; + contactData.setPhotoBinaryData(photo.getPhoto()); + break; + } + } + } + } + + /** + * Sets the "invitable" account types to {@link Contact#mInvitableAccountTypes}. + */ + private void loadInvitableAccountTypes(Contact contactData) { + final ImmutableList.Builder<AccountType> resultListBuilder = + new ImmutableList.Builder<AccountType>(); + if (!contactData.isUserProfile()) { + Map<AccountTypeWithDataSet, AccountType> invitables = + AccountTypeManager.getInstance(getContext()).getUsableInvitableAccountTypes(); + if (!invitables.isEmpty()) { + final Map<AccountTypeWithDataSet, AccountType> resultMap = + Maps.newHashMap(invitables); + + // Remove the ones that already have a raw contact in the current contact + for (RawContact rawContact : contactData.getRawContacts()) { + final AccountTypeWithDataSet type = AccountTypeWithDataSet.get( + rawContact.getAccountTypeString(), + rawContact.getDataSet()); + resultMap.remove(type); + } + + resultListBuilder.addAll(resultMap.values()); + } + } + + // Set to mInvitableAccountTypes + contactData.setInvitableAccountTypes(resultListBuilder.build()); + } + + /** + * Extracts Contact level columns from the cursor. + */ + private Contact loadContactHeaderData(final Cursor cursor, Uri contactUri) { + final String directoryParameter = + contactUri.getQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY); + final long directoryId = directoryParameter == null + ? Directory.DEFAULT + : Long.parseLong(directoryParameter); + final long contactId = cursor.getLong(ContactQuery.CONTACT_ID); + final String lookupKey = cursor.getString(ContactQuery.LOOKUP_KEY); + final long nameRawContactId = cursor.getLong(ContactQuery.NAME_RAW_CONTACT_ID); + final int displayNameSource = cursor.getInt(ContactQuery.DISPLAY_NAME_SOURCE); + final String displayName = cursor.getString(ContactQuery.DISPLAY_NAME); + final String altDisplayName = cursor.getString(ContactQuery.ALT_DISPLAY_NAME); + final String phoneticName = cursor.getString(ContactQuery.PHONETIC_NAME); + final long photoId = cursor.getLong(ContactQuery.PHOTO_ID); + final String photoUri = cursor.getString(ContactQuery.PHOTO_URI); + final boolean starred = cursor.getInt(ContactQuery.STARRED) != 0; + final Integer presence = cursor.isNull(ContactQuery.CONTACT_PRESENCE) + ? null + : cursor.getInt(ContactQuery.CONTACT_PRESENCE); + final boolean sendToVoicemail = cursor.getInt(ContactQuery.SEND_TO_VOICEMAIL) == 1; + final String customRingtone = cursor.getString(ContactQuery.CUSTOM_RINGTONE); + final boolean isUserProfile = cursor.getInt(ContactQuery.IS_USER_PROFILE) == 1; + + Uri lookupUri; + if (directoryId == Directory.DEFAULT || directoryId == Directory.LOCAL_INVISIBLE) { + lookupUri = ContentUris.withAppendedId( + Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey), contactId); + } else { + lookupUri = contactUri; + } + + return new Contact(mRequestedUri, contactUri, lookupUri, directoryId, lookupKey, + contactId, nameRawContactId, displayNameSource, photoId, photoUri, displayName, + altDisplayName, phoneticName, starred, presence, sendToVoicemail, + customRingtone, isUserProfile); + } + + /** + * Extracts RawContact level columns from the cursor. + */ + private ContentValues loadRawContactValues(Cursor cursor) { + ContentValues cv = new ContentValues(); + + cv.put(RawContacts._ID, cursor.getLong(ContactQuery.RAW_CONTACT_ID)); + + cursorColumnToContentValues(cursor, cv, ContactQuery.ACCOUNT_NAME); + cursorColumnToContentValues(cursor, cv, ContactQuery.ACCOUNT_TYPE); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SET); + cursorColumnToContentValues(cursor, cv, ContactQuery.ACCOUNT_TYPE_AND_DATA_SET); + cursorColumnToContentValues(cursor, cv, ContactQuery.DIRTY); + cursorColumnToContentValues(cursor, cv, ContactQuery.VERSION); + cursorColumnToContentValues(cursor, cv, ContactQuery.SOURCE_ID); + cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC1); + cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC2); + cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC3); + cursorColumnToContentValues(cursor, cv, ContactQuery.SYNC4); + cursorColumnToContentValues(cursor, cv, ContactQuery.DELETED); + cursorColumnToContentValues(cursor, cv, ContactQuery.CONTACT_ID); + cursorColumnToContentValues(cursor, cv, ContactQuery.STARRED); + cursorColumnToContentValues(cursor, cv, ContactQuery.NAME_VERIFIED); + + return cv; + } + + /** + * Extracts Data level columns from the cursor. + */ + private ContentValues loadDataValues(Cursor cursor) { + ContentValues cv = new ContentValues(); + + cv.put(Data._ID, cursor.getLong(ContactQuery.DATA_ID)); + + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA1); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA2); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA3); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA4); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA5); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA6); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA7); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA8); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA9); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA10); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA11); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA12); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA13); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA14); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA15); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC1); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC2); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC3); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_SYNC4); + cursorColumnToContentValues(cursor, cv, ContactQuery.DATA_VERSION); + cursorColumnToContentValues(cursor, cv, ContactQuery.IS_PRIMARY); + cursorColumnToContentValues(cursor, cv, ContactQuery.IS_SUPERPRIMARY); + cursorColumnToContentValues(cursor, cv, ContactQuery.MIMETYPE); + cursorColumnToContentValues(cursor, cv, ContactQuery.RES_PACKAGE); + cursorColumnToContentValues(cursor, cv, ContactQuery.GROUP_SOURCE_ID); + cursorColumnToContentValues(cursor, cv, ContactQuery.CHAT_CAPABILITY); + + return cv; + } + + private void cursorColumnToContentValues( + Cursor cursor, ContentValues values, int index) { + switch (cursor.getType(index)) { + case Cursor.FIELD_TYPE_NULL: + // don't put anything in the content values + break; + case Cursor.FIELD_TYPE_INTEGER: + values.put(ContactQuery.COLUMNS[index], cursor.getLong(index)); + break; + case Cursor.FIELD_TYPE_STRING: + values.put(ContactQuery.COLUMNS[index], cursor.getString(index)); + break; + case Cursor.FIELD_TYPE_BLOB: + values.put(ContactQuery.COLUMNS[index], cursor.getBlob(index)); + break; + default: + throw new IllegalStateException("Invalid or unhandled data type"); + } + } + + private void loadDirectoryMetaData(Contact result) { + long directoryId = result.getDirectoryId(); + + Cursor cursor = getContext().getContentResolver().query( + ContentUris.withAppendedId(Directory.CONTENT_URI, directoryId), + DirectoryQuery.COLUMNS, null, null, null); + if (cursor == null) { + return; + } + try { + if (cursor.moveToFirst()) { + final String displayName = cursor.getString(DirectoryQuery.DISPLAY_NAME); + final String packageName = cursor.getString(DirectoryQuery.PACKAGE_NAME); + final int typeResourceId = cursor.getInt(DirectoryQuery.TYPE_RESOURCE_ID); + final String accountType = cursor.getString(DirectoryQuery.ACCOUNT_TYPE); + final String accountName = cursor.getString(DirectoryQuery.ACCOUNT_NAME); + final int exportSupport = cursor.getInt(DirectoryQuery.EXPORT_SUPPORT); + String directoryType = null; + if (!TextUtils.isEmpty(packageName)) { + PackageManager pm = getContext().getPackageManager(); + try { + Resources resources = pm.getResourcesForApplication(packageName); + directoryType = resources.getString(typeResourceId); + } catch (NameNotFoundException e) { + Log.w(TAG, "Contact directory resource not found: " + + packageName + "." + typeResourceId); + } + } + + result.setDirectoryMetaData( + displayName, directoryType, accountType, accountName, exportSupport); + } + } finally { + cursor.close(); + } + } + + /** + * Loads groups meta-data for all groups associated with all constituent raw contacts' + * accounts. + */ + private void loadGroupMetaData(Contact result) { + StringBuilder selection = new StringBuilder(); + ArrayList<String> selectionArgs = new ArrayList<String>(); + for (RawContact rawContact : result.getRawContacts()) { + final String accountName = rawContact.getAccountName(); + final String accountType = rawContact.getAccountTypeString(); + final String dataSet = rawContact.getDataSet(); + if (accountName != null && accountType != null) { + if (selection.length() != 0) { + selection.append(" OR "); + } + selection.append( + "(" + Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?"); + selectionArgs.add(accountName); + selectionArgs.add(accountType); + + if (dataSet != null) { + selection.append(" AND " + Groups.DATA_SET + "=?"); + selectionArgs.add(dataSet); + } else { + selection.append(" AND " + Groups.DATA_SET + " IS NULL"); + } + selection.append(")"); + } + } + final ImmutableList.Builder<GroupMetaData> groupListBuilder = + new ImmutableList.Builder<GroupMetaData>(); + final Cursor cursor = getContext().getContentResolver().query(Groups.CONTENT_URI, + GroupQuery.COLUMNS, selection.toString(), selectionArgs.toArray(new String[0]), + null); + try { + while (cursor.moveToNext()) { + final String accountName = cursor.getString(GroupQuery.ACCOUNT_NAME); + final String accountType = cursor.getString(GroupQuery.ACCOUNT_TYPE); + final String dataSet = cursor.getString(GroupQuery.DATA_SET); + final long groupId = cursor.getLong(GroupQuery.ID); + final String title = cursor.getString(GroupQuery.TITLE); + final boolean defaultGroup = cursor.isNull(GroupQuery.AUTO_ADD) + ? false + : cursor.getInt(GroupQuery.AUTO_ADD) != 0; + final boolean favorites = cursor.isNull(GroupQuery.FAVORITES) + ? false + : cursor.getInt(GroupQuery.FAVORITES) != 0; + + groupListBuilder.add(new GroupMetaData( + accountName, accountType, dataSet, groupId, title, defaultGroup, + favorites)); + } + } finally { + cursor.close(); + } + result.setGroupMetaData(groupListBuilder.build()); + } + + /** + * Iterates over all data items that represent phone numbers are tries to calculate a formatted + * number. This function can safely be called several times as no unformatted data is + * overwritten + */ + private void computeFormattedPhoneNumbers(Contact contactData) { + final String countryIso = GeoUtil.getCurrentCountryIso(getContext()); + final ImmutableList<RawContact> rawContacts = contactData.getRawContacts(); + final int rawContactCount = rawContacts.size(); + for (int rawContactIndex = 0; rawContactIndex < rawContactCount; rawContactIndex++) { + final RawContact rawContact = rawContacts.get(rawContactIndex); + final List<DataItem> dataItems = rawContact.getDataItems(); + final int dataCount = dataItems.size(); + for (int dataIndex = 0; dataIndex < dataCount; dataIndex++) { + final DataItem dataItem = dataItems.get(dataIndex); + if (dataItem instanceof PhoneDataItem) { + final PhoneDataItem phoneDataItem = (PhoneDataItem) dataItem; + phoneDataItem.computeFormattedPhoneNumber(countryIso); + } + } + } + } + + @Override + public void deliverResult(Contact result) { + unregisterObserver(); + + // The creator isn't interested in any further updates + if (isReset() || result == null) { + return; + } + + mContact = result; + + if (result.isLoaded()) { + mLookupUri = result.getLookupUri(); + + if (!result.isDirectoryEntry()) { + Log.i(TAG, "Registering content observer for " + mLookupUri); + if (mObserver == null) { + mObserver = new ForceLoadContentObserver(); + } + getContext().getContentResolver().registerContentObserver( + mLookupUri, true, mObserver); + } + + if (mPostViewNotification) { + // inform the source of the data that this contact is being looked at + postViewNotificationToSyncAdapter(); + } + } + + super.deliverResult(mContact); + } + + /** + * Posts a message to the contributing sync adapters that have opted-in, notifying them + * that the contact has just been loaded + */ + private void postViewNotificationToSyncAdapter() { + Context context = getContext(); + for (RawContact rawContact : mContact.getRawContacts()) { + final long rawContactId = rawContact.getId(); + if (mNotifiedRawContactIds.contains(rawContactId)) { + continue; // Already notified for this raw contact. + } + mNotifiedRawContactIds.add(rawContactId); + final AccountType accountType = rawContact.getAccountType(context); + final String serviceName = accountType.getViewContactNotifyServiceClassName(); + final String servicePackageName = accountType.getViewContactNotifyServicePackageName(); + if (!TextUtils.isEmpty(serviceName) && !TextUtils.isEmpty(servicePackageName)) { + final Uri uri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId); + final Intent intent = new Intent(); + intent.setClassName(servicePackageName, serviceName); + intent.setAction(Intent.ACTION_VIEW); + intent.setDataAndType(uri, RawContacts.CONTENT_ITEM_TYPE); + try { + context.startService(intent); + } catch (Exception e) { + Log.e(TAG, "Error sending message to source-app", e); + } + } + } + } + + private void unregisterObserver() { + if (mObserver != null) { + getContext().getContentResolver().unregisterContentObserver(mObserver); + mObserver = null; + } + } + + /** + * Fully upgrades this ContactLoader to one with all lists fully loaded. When done, the + * new result will be delivered + */ + public void upgradeToFullContact() { + // Everything requested already? Nothing to do, so let's bail out + if (mLoadGroupMetaData && mLoadInvitableAccountTypes + && mPostViewNotification && mComputeFormattedPhoneNumber) return; + + mLoadGroupMetaData = true; + mLoadInvitableAccountTypes = true; + mPostViewNotification = true; + mComputeFormattedPhoneNumber = true; + + // Cache the current result, so that we only load the "missing" parts of the contact. + cacheResult(); + + // Our load parameters have changed, so let's pretend the data has changed. Its the same + // thing, essentially. + onContentChanged(); + } + + public Uri getLookupUri() { + return mLookupUri; + } + + @Override + protected void onStartLoading() { + if (mContact != null) { + deliverResult(mContact); + } + + if (takeContentChanged() || mContact == null) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + protected void onReset() { + super.onReset(); + cancelLoad(); + unregisterObserver(); + mContact = null; + } + + /** + * Caches the result, which is useful when we switch from activity to activity, using the same + * contact. If the next load is for a different contact, the cached result will be dropped + */ + public void cacheResult() { + if (mContact == null || !mContact.isLoaded()) { + sCachedResult = null; + } else { + sCachedResult = mContact; + } + } +} diff --git a/src/com/android/contacts/common/model/RawContact.java b/src/com/android/contacts/common/model/RawContact.java new file mode 100644 index 00000000..e5fd06a2 --- /dev/null +++ b/src/com/android/contacts/common/model/RawContact.java @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model; + +import android.content.ContentValues; +import android.content.Context; +import android.content.Entity; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.RawContacts; + +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.account.AccountType; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.contacts.common.model.dataitem.DataItem; +import com.google.common.base.Objects; +import com.google.common.collect.Lists; + +import java.util.ArrayList; +import java.util.List; + +/** + * RawContact represents a single raw contact in the raw contacts database. + * It has specialized getters/setters for raw contact + * items, and also contains a collection of DataItem objects. A RawContact contains the information + * from a single account. + * + * This allows RawContact objects to be thought of as a class with raw contact + * fields (like account type, name, data set, sync state, etc.) and a list of + * DataItem objects that represent contact information elements (like phone + * numbers, email, address, etc.). + */ +final public class RawContact implements Parcelable { + + private AccountTypeManager mAccountTypeManager; + private final ContentValues mValues; + private final ArrayList<NamedDataItem> mDataItems; + + final public static class NamedDataItem implements Parcelable { + public final Uri mUri; + + // This use to be a DataItem. DataItem creation is now delayed until the point of request + // since there is no benefit to storing them here due to the multiple inheritance. + // Eventually instanceof still has to be used anyways to determine which sub-class of + // DataItem it is. And having parent DataItem's here makes it very difficult to serialize or + // parcelable. + // + // Instead of having a common DataItem super class, we should refactor this to be a generic + // Object where the object is a concrete class that no longer relies on ContentValues. + // (this will also make the classes easier to use). + // Since instanceof is used later anyways, having a list of Objects won't hurt and is no + // worse than having a DataItem. + public final ContentValues mContentValues; + + public NamedDataItem(Uri uri, ContentValues values) { + this.mUri = uri; + this.mContentValues = values; + } + + public NamedDataItem(Parcel parcel) { + this.mUri = parcel.readParcelable(Uri.class.getClassLoader()); + this.mContentValues = parcel.readParcelable(ContentValues.class.getClassLoader()); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeParcelable(mUri, i); + parcel.writeParcelable(mContentValues, i); + } + + public static final Parcelable.Creator<NamedDataItem> CREATOR + = new Parcelable.Creator<NamedDataItem>() { + + @Override + public NamedDataItem createFromParcel(Parcel parcel) { + return new NamedDataItem(parcel); + } + + @Override + public NamedDataItem[] newArray(int i) { + return new NamedDataItem[i]; + } + }; + + @Override + public int hashCode() { + return Objects.hashCode(mUri, mContentValues); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + final NamedDataItem other = (NamedDataItem) obj; + return Objects.equal(mUri, other.mUri) && + Objects.equal(mContentValues, other.mContentValues); + } + } + + public static RawContact createFrom(Entity entity) { + final ContentValues values = entity.getEntityValues(); + final ArrayList<Entity.NamedContentValues> subValues = entity.getSubValues(); + + RawContact rawContact = new RawContact(values); + for (Entity.NamedContentValues subValue : subValues) { + rawContact.addNamedDataItemValues(subValue.uri, subValue.values); + } + return rawContact; + } + + /** + * A RawContact object can be created with or without a context. + */ + public RawContact() { + this(new ContentValues()); + } + + public RawContact(ContentValues values) { + mValues = values; + mDataItems = new ArrayList<NamedDataItem>(); + } + + /** + * Constructor for the parcelable. + * + * @param parcel The parcel to de-serialize from. + */ + private RawContact(Parcel parcel) { + mValues = parcel.readParcelable(ContentValues.class.getClassLoader()); + mDataItems = Lists.newArrayList(); + parcel.readTypedList(mDataItems, NamedDataItem.CREATOR); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int i) { + parcel.writeParcelable(mValues, i); + parcel.writeTypedList(mDataItems); + } + + /** + * Create for building the parcelable. + */ + public static final Parcelable.Creator<RawContact> CREATOR + = new Parcelable.Creator<RawContact>() { + + @Override + public RawContact createFromParcel(Parcel parcel) { + return new RawContact(parcel); + } + + @Override + public RawContact[] newArray(int i) { + return new RawContact[i]; + } + }; + + public AccountTypeManager getAccountTypeManager(Context context) { + if (mAccountTypeManager == null) { + mAccountTypeManager = AccountTypeManager.getInstance(context); + } + return mAccountTypeManager; + } + + public ContentValues getValues() { + return mValues; + } + + /** + * Returns the id of the raw contact. + */ + public Long getId() { + return getValues().getAsLong(RawContacts._ID); + } + + /** + * Returns the account name of the raw contact. + */ + public String getAccountName() { + return getValues().getAsString(RawContacts.ACCOUNT_NAME); + } + + /** + * Returns the account type of the raw contact. + */ + public String getAccountTypeString() { + return getValues().getAsString(RawContacts.ACCOUNT_TYPE); + } + + /** + * Returns the data set of the raw contact. + */ + public String getDataSet() { + return getValues().getAsString(RawContacts.DATA_SET); + } + + /** + * Returns the account type and data set of the raw contact. + */ + public String getAccountTypeAndDataSetString() { + return getValues().getAsString(RawContacts.ACCOUNT_TYPE_AND_DATA_SET); + } + + public boolean isDirty() { + return getValues().getAsBoolean(RawContacts.DIRTY); + } + + public String getSourceId() { + return getValues().getAsString(RawContacts.SOURCE_ID); + } + + public String getSync1() { + return getValues().getAsString(RawContacts.SYNC1); + } + + public String getSync2() { + return getValues().getAsString(RawContacts.SYNC2); + } + + public String getSync3() { + return getValues().getAsString(RawContacts.SYNC3); + } + + public String getSync4() { + return getValues().getAsString(RawContacts.SYNC4); + } + + public boolean isDeleted() { + return getValues().getAsBoolean(RawContacts.DELETED); + } + + public boolean isNameVerified() { + return getValues().getAsBoolean(RawContacts.NAME_VERIFIED); + } + + public long getContactId() { + return getValues().getAsLong(Contacts.Entity.CONTACT_ID); + } + + public boolean isStarred() { + return getValues().getAsBoolean(Contacts.STARRED); + } + + public AccountType getAccountType(Context context) { + return getAccountTypeManager(context).getAccountType(getAccountTypeString(), getDataSet()); + } + + /** + * Sets the account name, account type, and data set strings. + * Valid combinations for account-name, account-type, data-set + * 1) null, null, null (local account) + * 2) non-null, non-null, null (valid account without data-set) + * 3) non-null, non-null, non-null (valid account with data-set) + */ + private void setAccount(String accountName, String accountType, String dataSet) { + final ContentValues values = getValues(); + if (accountName == null) { + if (accountType == null && dataSet == null) { + // This is a local account + values.putNull(RawContacts.ACCOUNT_NAME); + values.putNull(RawContacts.ACCOUNT_TYPE); + values.putNull(RawContacts.DATA_SET); + return; + } + } else { + if (accountType != null) { + // This is a valid account, either with or without a dataSet. + values.put(RawContacts.ACCOUNT_NAME, accountName); + values.put(RawContacts.ACCOUNT_TYPE, accountType); + if (dataSet == null) { + values.putNull(RawContacts.DATA_SET); + } else { + values.put(RawContacts.DATA_SET, dataSet); + } + return; + } + } + throw new IllegalArgumentException( + "Not a valid combination of account name, type, and data set."); + } + + public void setAccount(AccountWithDataSet accountWithDataSet) { + setAccount(accountWithDataSet.name, accountWithDataSet.type, accountWithDataSet.dataSet); + } + + public void setAccountToLocal() { + setAccount(null, null, null); + } + + /** + * Creates and inserts a DataItem object that wraps the content values, and returns it. + */ + public void addDataItemValues(ContentValues values) { + addNamedDataItemValues(Data.CONTENT_URI, values); + } + + public NamedDataItem addNamedDataItemValues(Uri uri, ContentValues values) { + final NamedDataItem namedItem = new NamedDataItem(uri, values); + mDataItems.add(namedItem); + return namedItem; + } + + public ArrayList<ContentValues> getContentValues() { + final ArrayList<ContentValues> list = Lists.newArrayListWithCapacity(mDataItems.size()); + for (NamedDataItem dataItem : mDataItems) { + if (Data.CONTENT_URI.equals(dataItem.mUri)) { + list.add(dataItem.mContentValues); + } + } + return list; + } + + public List<DataItem> getDataItems() { + final ArrayList<DataItem> list = Lists.newArrayListWithCapacity(mDataItems.size()); + for (NamedDataItem dataItem : mDataItems) { + if (Data.CONTENT_URI.equals(dataItem.mUri)) { + list.add(DataItem.createFrom(dataItem.mContentValues)); + } + } + return list; + } + + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append("RawContact: ").append(mValues); + for (RawContact.NamedDataItem namedDataItem : mDataItems) { + sb.append("\n ").append(namedDataItem.mUri); + sb.append("\n -> ").append(namedDataItem.mContentValues); + } + return sb.toString(); + } + + @Override + public int hashCode() { + return Objects.hashCode(mValues, mDataItems); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + RawContact other = (RawContact) obj; + return Objects.equal(mValues, other.mValues) && + Objects.equal(mDataItems, other.mDataItems); + } +} diff --git a/src/com/android/contacts/common/model/RawContactDelta.java b/src/com/android/contacts/common/model/RawContactDelta.java new file mode 100644 index 00000000..7a200418 --- /dev/null +++ b/src/com/android/contacts/common/model/RawContactDelta.java @@ -0,0 +1,556 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model; + +import android.content.ContentProviderOperation; +import android.content.ContentProviderOperation.Builder; +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.BaseColumns; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.Profile; +import android.provider.ContactsContract.RawContacts; +import android.util.Log; + +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.ValuesDelta; +import com.android.contacts.common.model.account.AccountType; +import com.android.contacts.common.test.NeededForTesting; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; + +import java.util.ArrayList; +import java.util.HashMap; + +/** + * Contains a {@link RawContact} and records any modifications separately so the + * original {@link RawContact} can be swapped out with a newer version and the + * changes still cleanly applied. + * <p> + * One benefit of this approach is that we can build changes entirely on an + * empty {@link RawContact}, which then becomes an insert {@link RawContacts} case. + * <p> + * When applying modifications over an {@link RawContact}, we try finding the + * original {@link Data#_ID} rows where the modifications took place. If those + * rows are missing from the new {@link RawContact}, we know the original data must + * be deleted, but to preserve the user modifications we treat as an insert. + */ +public class RawContactDelta implements Parcelable { + // TODO: optimize by using contentvalues pool, since we allocate so many of them + + private static final String TAG = "EntityDelta"; + private static final boolean LOGV = false; + + /** + * Direct values from {@link Entity#getEntityValues()}. + */ + private ValuesDelta mValues; + + /** + * URI used for contacts queries, by default it is set to query raw contacts. + * It can be set to query the profile's raw contact(s). + */ + private Uri mContactsQueryUri = RawContacts.CONTENT_URI; + + /** + * Internal map of children values from {@link Entity#getSubValues()}, which + * we store here sorted into {@link Data#MIMETYPE} bins. + */ + private final HashMap<String, ArrayList<ValuesDelta>> mEntries = Maps.newHashMap(); + + public RawContactDelta() { + } + + public RawContactDelta(ValuesDelta values) { + mValues = values; + } + + /** + * Build an {@link RawContactDelta} using the given {@link RawContact} as a + * starting point; the "before" snapshot. + */ + public static RawContactDelta fromBefore(RawContact before) { + final RawContactDelta rawContactDelta = new RawContactDelta(); + rawContactDelta.mValues = ValuesDelta.fromBefore(before.getValues()); + rawContactDelta.mValues.setIdColumn(RawContacts._ID); + for (final ContentValues values : before.getContentValues()) { + rawContactDelta.addEntry(ValuesDelta.fromBefore(values)); + } + return rawContactDelta; + } + + /** + * Merge the "after" values from the given {@link RawContactDelta} onto the + * "before" state represented by this {@link RawContactDelta}, discarding any + * existing "after" states. This is typically used when re-parenting changes + * onto an updated {@link Entity}. + */ + public static RawContactDelta mergeAfter(RawContactDelta local, RawContactDelta remote) { + // Bail early if trying to merge delete with missing local + final ValuesDelta remoteValues = remote.mValues; + if (local == null && (remoteValues.isDelete() || remoteValues.isTransient())) return null; + + // Create local version if none exists yet + if (local == null) local = new RawContactDelta(); + + if (LOGV) { + final Long localVersion = (local.mValues == null) ? null : local.mValues + .getAsLong(RawContacts.VERSION); + final Long remoteVersion = remote.mValues.getAsLong(RawContacts.VERSION); + Log.d(TAG, "Re-parenting from original version " + remoteVersion + " to " + + localVersion); + } + + // Create values if needed, and merge "after" changes + local.mValues = ValuesDelta.mergeAfter(local.mValues, remote.mValues); + + // Find matching local entry for each remote values, or create + for (ArrayList<ValuesDelta> mimeEntries : remote.mEntries.values()) { + for (ValuesDelta remoteEntry : mimeEntries) { + final Long childId = remoteEntry.getId(); + + // Find or create local match and merge + final ValuesDelta localEntry = local.getEntry(childId); + final ValuesDelta merged = ValuesDelta.mergeAfter(localEntry, remoteEntry); + + if (localEntry == null && merged != null) { + // No local entry before, so insert + local.addEntry(merged); + } + } + } + + return local; + } + + public ValuesDelta getValues() { + return mValues; + } + + public boolean isContactInsert() { + return mValues.isInsert(); + } + + /** + * Get the {@link ValuesDelta} child marked as {@link Data#IS_PRIMARY}, + * which may return null when no entry exists. + */ + public ValuesDelta getPrimaryEntry(String mimeType) { + final ArrayList<ValuesDelta> mimeEntries = getMimeEntries(mimeType, false); + if (mimeEntries == null) return null; + + for (ValuesDelta entry : mimeEntries) { + if (entry.isPrimary()) { + return entry; + } + } + + // When no direct primary, return something + return mimeEntries.size() > 0 ? mimeEntries.get(0) : null; + } + + /** + * calls {@link #getSuperPrimaryEntry(String, boolean)} with true + * @see #getSuperPrimaryEntry(String, boolean) + */ + public ValuesDelta getSuperPrimaryEntry(String mimeType) { + return getSuperPrimaryEntry(mimeType, true); + } + + /** + * Returns the super-primary entry for the given mime type + * @param forceSelection if true, will try to return some value even if a super-primary + * doesn't exist (may be a primary, or just a random item + * @return + */ + @NeededForTesting + public ValuesDelta getSuperPrimaryEntry(String mimeType, boolean forceSelection) { + final ArrayList<ValuesDelta> mimeEntries = getMimeEntries(mimeType, false); + if (mimeEntries == null) return null; + + ValuesDelta primary = null; + for (ValuesDelta entry : mimeEntries) { + if (entry.isSuperPrimary()) { + return entry; + } else if (entry.isPrimary()) { + primary = entry; + } + } + + if (!forceSelection) { + return null; + } + + // When no direct super primary, return something + if (primary != null) { + return primary; + } + return mimeEntries.size() > 0 ? mimeEntries.get(0) : null; + } + + /** + * Return the AccountType that this raw-contact belongs to. + */ + public AccountType getRawContactAccountType(Context context) { + ContentValues entityValues = getValues().getCompleteValues(); + String type = entityValues.getAsString(RawContacts.ACCOUNT_TYPE); + String dataSet = entityValues.getAsString(RawContacts.DATA_SET); + return AccountTypeManager.getInstance(context).getAccountType(type, dataSet); + } + + public Long getRawContactId() { + return getValues().getAsLong(RawContacts._ID); + } + + public String getAccountName() { + return getValues().getAsString(RawContacts.ACCOUNT_NAME); + } + + public String getAccountType() { + return getValues().getAsString(RawContacts.ACCOUNT_TYPE); + } + + public String getDataSet() { + return getValues().getAsString(RawContacts.DATA_SET); + } + + public AccountType getAccountType(AccountTypeManager manager) { + return manager.getAccountType(getAccountType(), getDataSet()); + } + + public boolean isVisible() { + return getValues().isVisible(); + } + + /** + * Return the list of child {@link ValuesDelta} from our optimized map, + * creating the list if requested. + */ + private ArrayList<ValuesDelta> getMimeEntries(String mimeType, boolean lazyCreate) { + ArrayList<ValuesDelta> mimeEntries = mEntries.get(mimeType); + if (mimeEntries == null && lazyCreate) { + mimeEntries = Lists.newArrayList(); + mEntries.put(mimeType, mimeEntries); + } + return mimeEntries; + } + + public ArrayList<ValuesDelta> getMimeEntries(String mimeType) { + return getMimeEntries(mimeType, false); + } + + public int getMimeEntriesCount(String mimeType, boolean onlyVisible) { + final ArrayList<ValuesDelta> mimeEntries = getMimeEntries(mimeType); + if (mimeEntries == null) return 0; + + int count = 0; + for (ValuesDelta child : mimeEntries) { + // Skip deleted items when requesting only visible + if (onlyVisible && !child.isVisible()) continue; + count++; + } + return count; + } + + public boolean hasMimeEntries(String mimeType) { + return mEntries.containsKey(mimeType); + } + + public ValuesDelta addEntry(ValuesDelta entry) { + final String mimeType = entry.getMimetype(); + getMimeEntries(mimeType, true).add(entry); + return entry; + } + + public ArrayList<ContentValues> getContentValues() { + ArrayList<ContentValues> values = Lists.newArrayList(); + for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) { + for (ValuesDelta entry : mimeEntries) { + if (!entry.isDelete()) { + values.add(entry.getCompleteValues()); + } + } + } + return values; + } + + /** + * Find entry with the given {@link BaseColumns#_ID} value. + */ + public ValuesDelta getEntry(Long childId) { + if (childId == null) { + // Requesting an "insert" entry, which has no "before" + return null; + } + + // Search all children for requested entry + for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) { + for (ValuesDelta entry : mimeEntries) { + if (childId.equals(entry.getId())) { + return entry; + } + } + } + return null; + } + + /** + * Return the total number of {@link ValuesDelta} contained. + */ + public int getEntryCount(boolean onlyVisible) { + int count = 0; + for (String mimeType : mEntries.keySet()) { + count += getMimeEntriesCount(mimeType, onlyVisible); + } + return count; + } + + @Override + public boolean equals(Object object) { + if (object instanceof RawContactDelta) { + final RawContactDelta other = (RawContactDelta)object; + + // Equality failed if parent values different + if (!other.mValues.equals(mValues)) return false; + + for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) { + for (ValuesDelta child : mimeEntries) { + // Equality failed if any children unmatched + if (!other.containsEntry(child)) return false; + } + } + + // Passed all tests, so equal + return true; + } + return false; + } + + private boolean containsEntry(ValuesDelta entry) { + for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) { + for (ValuesDelta child : mimeEntries) { + // Contained if we find any child that matches + if (child.equals(entry)) return true; + } + } + return false; + } + + /** + * Mark this entire object deleted, including any {@link ValuesDelta}. + */ + public void markDeleted() { + this.mValues.markDeleted(); + for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) { + for (ValuesDelta child : mimeEntries) { + child.markDeleted(); + } + } + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder(); + builder.append("\n("); + builder.append("Uri="); + builder.append(mContactsQueryUri); + builder.append(", Values="); + builder.append(mValues != null ? mValues.toString() : "null"); + builder.append(", Entries={"); + for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) { + for (ValuesDelta child : mimeEntries) { + builder.append("\n\t"); + child.toString(builder); + } + } + builder.append("\n})\n"); + return builder.toString(); + } + + /** + * Consider building the given {@link ContentProviderOperation.Builder} and + * appending it to the given list, which only happens if builder is valid. + */ + private void possibleAdd(ArrayList<ContentProviderOperation> diff, + ContentProviderOperation.Builder builder) { + if (builder != null) { + diff.add(builder.build()); + } + } + + /** + * Build a list of {@link ContentProviderOperation} that will assert any + * "before" state hasn't changed. This is maintained separately so that all + * asserts can take place before any updates occur. + */ + public void buildAssert(ArrayList<ContentProviderOperation> buildInto) { + final boolean isContactInsert = mValues.isInsert(); + if (!isContactInsert) { + // Assert version is consistent while persisting changes + final Long beforeId = mValues.getId(); + final Long beforeVersion = mValues.getAsLong(RawContacts.VERSION); + if (beforeId == null || beforeVersion == null) return; + + final ContentProviderOperation.Builder builder = ContentProviderOperation + .newAssertQuery(mContactsQueryUri); + builder.withSelection(RawContacts._ID + "=" + beforeId, null); + builder.withValue(RawContacts.VERSION, beforeVersion); + buildInto.add(builder.build()); + } + } + + /** + * Build a list of {@link ContentProviderOperation} that will transform the + * current "before" {@link Entity} state into the modified state which this + * {@link RawContactDelta} represents. + */ + public void buildDiff(ArrayList<ContentProviderOperation> buildInto) { + final int firstIndex = buildInto.size(); + + final boolean isContactInsert = mValues.isInsert(); + final boolean isContactDelete = mValues.isDelete(); + final boolean isContactUpdate = !isContactInsert && !isContactDelete; + + final Long beforeId = mValues.getId(); + + Builder builder; + + if (isContactInsert) { + // TODO: for now simply disabling aggregation when a new contact is + // created on the phone. In the future, will show aggregation suggestions + // after saving the contact. + mValues.put(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_SUSPENDED); + } + + // Build possible operation at Contact level + builder = mValues.buildDiff(mContactsQueryUri); + possibleAdd(buildInto, builder); + + // Build operations for all children + for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) { + for (ValuesDelta child : mimeEntries) { + // Ignore children if parent was deleted + if (isContactDelete) continue; + + // Use the profile data URI if the contact is the profile. + if (mContactsQueryUri.equals(Profile.CONTENT_RAW_CONTACTS_URI)) { + builder = child.buildDiff(Uri.withAppendedPath(Profile.CONTENT_URI, + RawContacts.Data.CONTENT_DIRECTORY)); + } else { + builder = child.buildDiff(Data.CONTENT_URI); + } + + if (child.isInsert()) { + if (isContactInsert) { + // Parent is brand new insert, so back-reference _id + builder.withValueBackReference(Data.RAW_CONTACT_ID, firstIndex); + } else { + // Inserting under existing, so fill with known _id + builder.withValue(Data.RAW_CONTACT_ID, beforeId); + } + } else if (isContactInsert && builder != null) { + // Child must be insert when Contact insert + throw new IllegalArgumentException("When parent insert, child must be also"); + } + possibleAdd(buildInto, builder); + } + } + + final boolean addedOperations = buildInto.size() > firstIndex; + if (addedOperations && isContactUpdate) { + // Suspend aggregation while persisting updates + builder = buildSetAggregationMode(beforeId, RawContacts.AGGREGATION_MODE_SUSPENDED); + buildInto.add(firstIndex, builder.build()); + + // Restore aggregation mode as last operation + builder = buildSetAggregationMode(beforeId, RawContacts.AGGREGATION_MODE_DEFAULT); + buildInto.add(builder.build()); + } else if (isContactInsert) { + // Restore aggregation mode as last operation + builder = ContentProviderOperation.newUpdate(mContactsQueryUri); + builder.withValue(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_DEFAULT); + builder.withSelection(RawContacts._ID + "=?", new String[1]); + builder.withSelectionBackReference(0, firstIndex); + buildInto.add(builder.build()); + } + } + + /** + * Build a {@link ContentProviderOperation} that changes + * {@link RawContacts#AGGREGATION_MODE} to the given value. + */ + protected Builder buildSetAggregationMode(Long beforeId, int mode) { + Builder builder = ContentProviderOperation.newUpdate(mContactsQueryUri); + builder.withValue(RawContacts.AGGREGATION_MODE, mode); + builder.withSelection(RawContacts._ID + "=" + beforeId, null); + return builder; + } + + /** {@inheritDoc} */ + public int describeContents() { + // Nothing special about this parcel + return 0; + } + + /** {@inheritDoc} */ + public void writeToParcel(Parcel dest, int flags) { + final int size = this.getEntryCount(false); + dest.writeInt(size); + dest.writeParcelable(mValues, flags); + dest.writeParcelable(mContactsQueryUri, flags); + for (ArrayList<ValuesDelta> mimeEntries : mEntries.values()) { + for (ValuesDelta child : mimeEntries) { + dest.writeParcelable(child, flags); + } + } + } + + public void readFromParcel(Parcel source) { + final ClassLoader loader = getClass().getClassLoader(); + final int size = source.readInt(); + mValues = source.<ValuesDelta> readParcelable(loader); + mContactsQueryUri = source.<Uri> readParcelable(loader); + for (int i = 0; i < size; i++) { + final ValuesDelta child = source.<ValuesDelta> readParcelable(loader); + this.addEntry(child); + } + } + + /** + * Used to set the query URI to the profile URI to store profiles. + */ + public void setProfileQueryUri() { + mContactsQueryUri = Profile.CONTENT_RAW_CONTACTS_URI; + } + + public static final Parcelable.Creator<RawContactDelta> CREATOR = + new Parcelable.Creator<RawContactDelta>() { + public RawContactDelta createFromParcel(Parcel in) { + final RawContactDelta state = new RawContactDelta(); + state.readFromParcel(in); + return state; + } + + public RawContactDelta[] newArray(int size) { + return new RawContactDelta[size]; + } + }; + +} diff --git a/src/com/android/contacts/common/model/RawContactDeltaList.java b/src/com/android/contacts/common/model/RawContactDeltaList.java new file mode 100644 index 00000000..f3070c41 --- /dev/null +++ b/src/com/android/contacts/common/model/RawContactDeltaList.java @@ -0,0 +1,453 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model; + +import android.content.ContentProviderOperation; +import android.content.ContentProviderOperation.Builder; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Entity; +import android.content.EntityIterator; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.ContactsContract.AggregationExceptions; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.RawContacts; +import android.util.Log; + +import com.android.contacts.common.model.ValuesDelta; +import com.google.common.collect.Lists; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; + +/** + * Container for multiple {@link RawContactDelta} objects, usually when editing + * together as an entire aggregate. Provides convenience methods for parceling + * and applying another {@link RawContactDeltaList} over it. + */ +public class RawContactDeltaList extends ArrayList<RawContactDelta> implements Parcelable { + private static final String TAG = RawContactDeltaList.class.getSimpleName(); + private static final boolean VERBOSE_LOGGING = Log.isLoggable(TAG, Log.VERBOSE); + + private boolean mSplitRawContacts; + private long[] mJoinWithRawContactIds; + + public RawContactDeltaList() { + } + + /** + * Create an {@link RawContactDeltaList} based on {@link Contacts} specified by the + * given query parameters. This closes the {@link EntityIterator} when + * finished, so it doesn't subscribe to updates. + */ + public static RawContactDeltaList fromQuery(Uri entityUri, ContentResolver resolver, + String selection, String[] selectionArgs, String sortOrder) { + final EntityIterator iterator = RawContacts.newEntityIterator( + resolver.query(entityUri, null, selection, selectionArgs, sortOrder)); + try { + return fromIterator(iterator); + } finally { + iterator.close(); + } + } + + /** + * Create an {@link RawContactDeltaList} that contains the entities of the Iterator as before + * values. This function can be passed an iterator of Entity objects or an iterator of + * RawContact objects. + */ + public static RawContactDeltaList fromIterator(Iterator<?> iterator) { + final RawContactDeltaList state = new RawContactDeltaList(); + state.addAll(iterator); + return state; + } + + public void addAll(Iterator<?> iterator) { + // Perform background query to pull contact details + while (iterator.hasNext()) { + // Read all contacts into local deltas to prepare for edits + Object nextObject = iterator.next(); + final RawContact before = nextObject instanceof Entity + ? RawContact.createFrom((Entity) nextObject) + : (RawContact) nextObject; + final RawContactDelta rawContactDelta = RawContactDelta.fromBefore(before); + add(rawContactDelta); + } + } + + /** + * Merge the "after" values from the given {@link RawContactDeltaList}, discarding any + * previous "after" states. This is typically used when re-parenting user + * edits onto an updated {@link RawContactDeltaList}. + */ + public static RawContactDeltaList mergeAfter(RawContactDeltaList local, + RawContactDeltaList remote) { + if (local == null) local = new RawContactDeltaList(); + + // For each entity in the remote set, try matching over existing + for (RawContactDelta remoteEntity : remote) { + final Long rawContactId = remoteEntity.getValues().getId(); + + // Find or create local match and merge + final RawContactDelta localEntity = local.getByRawContactId(rawContactId); + final RawContactDelta merged = RawContactDelta.mergeAfter(localEntity, remoteEntity); + + if (localEntity == null && merged != null) { + // No local entry before, so insert + local.add(merged); + } + } + + return local; + } + + /** + * Build a list of {@link ContentProviderOperation} that will transform all + * the "before" {@link Entity} states into the modified state which all + * {@link RawContactDelta} objects represent. This method specifically creates + * any {@link AggregationExceptions} rules needed to groups edits together. + */ + public ArrayList<ContentProviderOperation> buildDiff() { + if (VERBOSE_LOGGING) { + Log.v(TAG, "buildDiff: list=" + toString()); + } + final ArrayList<ContentProviderOperation> diff = Lists.newArrayList(); + + final long rawContactId = this.findRawContactId(); + int firstInsertRow = -1; + + // First pass enforces versions remain consistent + for (RawContactDelta delta : this) { + delta.buildAssert(diff); + } + + final int assertMark = diff.size(); + int backRefs[] = new int[size()]; + + int rawContactIndex = 0; + + // Second pass builds actual operations + for (RawContactDelta delta : this) { + final int firstBatch = diff.size(); + final boolean isInsert = delta.isContactInsert(); + backRefs[rawContactIndex++] = isInsert ? firstBatch : -1; + + delta.buildDiff(diff); + + // If the user chose to join with some other existing raw contact(s) at save time, + // add aggregation exceptions for all those raw contacts. + if (mJoinWithRawContactIds != null) { + for (Long joinedRawContactId : mJoinWithRawContactIds) { + final Builder builder = beginKeepTogether(); + builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, joinedRawContactId); + if (rawContactId != -1) { + builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId); + } else { + builder.withValueBackReference( + AggregationExceptions.RAW_CONTACT_ID2, firstBatch); + } + diff.add(builder.build()); + } + } + + // Only create rules for inserts + if (!isInsert) continue; + + // If we are going to split all contacts, there is no point in first combining them + if (mSplitRawContacts) continue; + + if (rawContactId != -1) { + // Has existing contact, so bind to it strongly + final Builder builder = beginKeepTogether(); + builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId); + builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID2, firstBatch); + diff.add(builder.build()); + + } else if (firstInsertRow == -1) { + // First insert case, so record row + firstInsertRow = firstBatch; + + } else { + // Additional insert case, so point at first insert + final Builder builder = beginKeepTogether(); + builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID1, + firstInsertRow); + builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID2, firstBatch); + diff.add(builder.build()); + } + } + + if (mSplitRawContacts) { + buildSplitContactDiff(diff, backRefs); + } + + // No real changes if only left with asserts + if (diff.size() == assertMark) { + diff.clear(); + } + if (VERBOSE_LOGGING) { + Log.v(TAG, "buildDiff: ops=" + diffToString(diff)); + } + return diff; + } + + private static String diffToString(ArrayList<ContentProviderOperation> ops) { + StringBuilder sb = new StringBuilder(); + sb.append("[\n"); + for (ContentProviderOperation op : ops) { + sb.append(op.toString()); + sb.append(",\n"); + } + sb.append("]\n"); + return sb.toString(); + } + + /** + * Start building a {@link ContentProviderOperation} that will keep two + * {@link RawContacts} together. + */ + protected Builder beginKeepTogether() { + final Builder builder = ContentProviderOperation + .newUpdate(AggregationExceptions.CONTENT_URI); + builder.withValue(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_TOGETHER); + return builder; + } + + /** + * Builds {@link AggregationExceptions} to split all constituent raw contacts into + * separate contacts. + */ + private void buildSplitContactDiff(final ArrayList<ContentProviderOperation> diff, + int[] backRefs) { + int count = size(); + for (int i = 0; i < count; i++) { + for (int j = 0; j < count; j++) { + if (i != j) { + buildSplitContactDiff(diff, i, j, backRefs); + } + } + } + } + + /** + * Construct a {@link AggregationExceptions#TYPE_KEEP_SEPARATE}. + */ + private void buildSplitContactDiff(ArrayList<ContentProviderOperation> diff, int index1, + int index2, int[] backRefs) { + Builder builder = + ContentProviderOperation.newUpdate(AggregationExceptions.CONTENT_URI); + builder.withValue(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_SEPARATE); + + Long rawContactId1 = get(index1).getValues().getAsLong(RawContacts._ID); + int backRef1 = backRefs[index1]; + if (rawContactId1 != null && rawContactId1 >= 0) { + builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1); + } else if (backRef1 >= 0) { + builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID1, backRef1); + } else { + return; + } + + Long rawContactId2 = get(index2).getValues().getAsLong(RawContacts._ID); + int backRef2 = backRefs[index2]; + if (rawContactId2 != null && rawContactId2 >= 0) { + builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2); + } else if (backRef2 >= 0) { + builder.withValueBackReference(AggregationExceptions.RAW_CONTACT_ID2, backRef2); + } else { + return; + } + + diff.add(builder.build()); + } + + /** + * Search all contained {@link RawContactDelta} for the first one with an + * existing {@link RawContacts#_ID} value. Usually used when creating + * {@link AggregationExceptions} during an update. + */ + public long findRawContactId() { + for (RawContactDelta delta : this) { + final Long rawContactId = delta.getValues().getAsLong(RawContacts._ID); + if (rawContactId != null && rawContactId >= 0) { + return rawContactId; + } + } + return -1; + } + + /** + * Find {@link RawContacts#_ID} of the requested {@link RawContactDelta}. + */ + public Long getRawContactId(int index) { + if (index >= 0 && index < this.size()) { + final RawContactDelta delta = this.get(index); + final ValuesDelta values = delta.getValues(); + if (values.isVisible()) { + return values.getAsLong(RawContacts._ID); + } + } + return null; + } + + /** + * Find the raw-contact (an {@link RawContactDelta}) with the specified ID. + */ + public RawContactDelta getByRawContactId(Long rawContactId) { + final int index = this.indexOfRawContactId(rawContactId); + return (index == -1) ? null : this.get(index); + } + + /** + * Find index of given {@link RawContacts#_ID} when present. + */ + public int indexOfRawContactId(Long rawContactId) { + if (rawContactId == null) return -1; + final int size = this.size(); + for (int i = 0; i < size; i++) { + final Long currentId = getRawContactId(i); + if (rawContactId.equals(currentId)) { + return i; + } + } + return -1; + } + + /** + * Return the index of the first RawContactDelta corresponding to a writable raw-contact, or -1. + * */ + public int indexOfFirstWritableRawContact(Context context) { + // Find the first writable entity. + int entityIndex = 0; + for (RawContactDelta delta : this) { + if (delta.getRawContactAccountType(context).areContactsWritable()) return entityIndex; + entityIndex++; + } + return -1; + } + + /** Return the first RawContactDelta corresponding to a writable raw-contact, or null. */ + public RawContactDelta getFirstWritableRawContact(Context context) { + final int index = indexOfFirstWritableRawContact(context); + return (index == -1) ? null : get(index); + } + + public ValuesDelta getSuperPrimaryEntry(final String mimeType) { + ValuesDelta primary = null; + ValuesDelta randomEntry = null; + for (RawContactDelta delta : this) { + final ArrayList<ValuesDelta> mimeEntries = delta.getMimeEntries(mimeType); + if (mimeEntries == null) return null; + + for (ValuesDelta entry : mimeEntries) { + if (entry.isSuperPrimary()) { + return entry; + } else if (primary == null && entry.isPrimary()) { + primary = entry; + } else if (randomEntry == null) { + randomEntry = entry; + } + } + } + // When no direct super primary, return something + if (primary != null) { + return primary; + } + return randomEntry; + } + + /** + * Sets a flag that will split ("explode") the raw_contacts into seperate contacts + */ + public void markRawContactsForSplitting() { + mSplitRawContacts = true; + } + + public boolean isMarkedForSplitting() { + return mSplitRawContacts; + } + + public void setJoinWithRawContacts(long[] rawContactIds) { + mJoinWithRawContactIds = rawContactIds; + } + + public boolean isMarkedForJoining() { + return mJoinWithRawContactIds != null && mJoinWithRawContactIds.length > 0; + } + + /** {@inheritDoc} */ + @Override + public int describeContents() { + // Nothing special about this parcel + return 0; + } + + /** {@inheritDoc} */ + @Override + public void writeToParcel(Parcel dest, int flags) { + final int size = this.size(); + dest.writeInt(size); + for (RawContactDelta delta : this) { + dest.writeParcelable(delta, flags); + } + dest.writeLongArray(mJoinWithRawContactIds); + dest.writeInt(mSplitRawContacts ? 1 : 0); + } + + @SuppressWarnings("unchecked") + public void readFromParcel(Parcel source) { + final ClassLoader loader = getClass().getClassLoader(); + final int size = source.readInt(); + for (int i = 0; i < size; i++) { + this.add(source.<RawContactDelta> readParcelable(loader)); + } + mJoinWithRawContactIds = source.createLongArray(); + mSplitRawContacts = source.readInt() != 0; + } + + public static final Parcelable.Creator<RawContactDeltaList> CREATOR = + new Parcelable.Creator<RawContactDeltaList>() { + @Override + public RawContactDeltaList createFromParcel(Parcel in) { + final RawContactDeltaList state = new RawContactDeltaList(); + state.readFromParcel(in); + return state; + } + + @Override + public RawContactDeltaList[] newArray(int size) { + return new RawContactDeltaList[size]; + } + }; + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("("); + sb.append("Split="); + sb.append(mSplitRawContacts); + sb.append(", Join=["); + sb.append(Arrays.toString(mJoinWithRawContactIds)); + sb.append("], Values="); + sb.append(super.toString()); + sb.append(")"); + return sb.toString(); + } +} diff --git a/src/com/android/contacts/common/model/RawContactModifier.java b/src/com/android/contacts/common/model/RawContactModifier.java new file mode 100644 index 00000000..0cd243c5 --- /dev/null +++ b/src/com/android/contacts/common/model/RawContactModifier.java @@ -0,0 +1,1427 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.BaseTypes; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Event; +import android.provider.ContactsContract.CommonDataKinds.GroupMembership; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.CommonDataKinds.Nickname; +import android.provider.ContactsContract.CommonDataKinds.Note; +import android.provider.ContactsContract.CommonDataKinds.Organization; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Photo; +import android.provider.ContactsContract.CommonDataKinds.Relation; +import android.provider.ContactsContract.CommonDataKinds.SipAddress; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; +import android.provider.ContactsContract.CommonDataKinds.Website; +import android.provider.ContactsContract.Data; +import android.provider.ContactsContract.Intents; +import android.provider.ContactsContract.Intents.Insert; +import android.provider.ContactsContract.RawContacts; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; +import android.util.SparseIntArray; + +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.ValuesDelta; +import com.android.contacts.common.util.CommonDateUtils; +import com.android.contacts.common.util.DateUtils; +import com.android.contacts.common.util.NameConverter; +import com.android.contacts.common.model.account.AccountType; +import com.android.contacts.common.model.account.AccountType.EditField; +import com.android.contacts.common.model.account.AccountType.EditType; +import com.android.contacts.common.model.account.AccountType.EventEditType; +import com.android.contacts.common.model.account.GoogleAccountType; +import com.android.contacts.common.model.dataitem.DataKind; +import com.android.contacts.common.model.dataitem.PhoneDataItem; +import com.android.contacts.common.model.dataitem.StructuredNameDataItem; + +import java.text.ParsePosition; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Helper methods for modifying an {@link RawContactDelta}, such as inserting + * new rows, or enforcing {@link AccountType}. + */ +public class RawContactModifier { + private static final String TAG = RawContactModifier.class.getSimpleName(); + + /** Set to true in order to view logs on entity operations */ + private static final boolean DEBUG = false; + + /** + * For the given {@link RawContactDelta}, determine if the given + * {@link DataKind} could be inserted under specific + * {@link AccountType}. + */ + public static boolean canInsert(RawContactDelta state, DataKind kind) { + // Insert possible when have valid types and under overall maximum + final int visibleCount = state.getMimeEntriesCount(kind.mimeType, true); + final boolean validTypes = hasValidTypes(state, kind); + final boolean validOverall = (kind.typeOverallMax == -1) + || (visibleCount < kind.typeOverallMax); + return (validTypes && validOverall); + } + + public static boolean hasValidTypes(RawContactDelta state, DataKind kind) { + if (RawContactModifier.hasEditTypes(kind)) { + return (getValidTypes(state, kind).size() > 0); + } else { + return true; + } + } + + /** + * Ensure that at least one of the given {@link DataKind} exists in the + * given {@link RawContactDelta} state, and try creating one if none exist. + * @return The child (either newly created or the first existing one), or null if the + * account doesn't support this {@link DataKind}. + */ + public static ValuesDelta ensureKindExists( + RawContactDelta state, AccountType accountType, String mimeType) { + final DataKind kind = accountType.getKindForMimetype(mimeType); + final boolean hasChild = state.getMimeEntriesCount(mimeType, true) > 0; + + if (kind != null) { + if (hasChild) { + // Return the first entry. + return state.getMimeEntries(mimeType).get(0); + } else { + // Create child when none exists and valid kind + final ValuesDelta child = insertChild(state, kind); + if (kind.mimeType.equals(Photo.CONTENT_ITEM_TYPE)) { + child.setFromTemplate(true); + } + return child; + } + } + return null; + } + + /** + * For the given {@link RawContactDelta} and {@link DataKind}, return the + * list possible {@link EditType} options available based on + * {@link AccountType}. + */ + public static ArrayList<EditType> getValidTypes(RawContactDelta state, DataKind kind) { + return getValidTypes(state, kind, null, true, null); + } + + /** + * For the given {@link RawContactDelta} and {@link DataKind}, return the + * list possible {@link EditType} options available based on + * {@link AccountType}. + * + * @param forceInclude Always include this {@link EditType} in the returned + * list, even when an otherwise-invalid choice. This is useful + * when showing a dialog that includes the current type. + */ + public static ArrayList<EditType> getValidTypes(RawContactDelta state, DataKind kind, + EditType forceInclude) { + return getValidTypes(state, kind, forceInclude, true, null); + } + + /** + * For the given {@link RawContactDelta} and {@link DataKind}, return the + * list possible {@link EditType} options available based on + * {@link AccountType}. + * + * @param forceInclude Always include this {@link EditType} in the returned + * list, even when an otherwise-invalid choice. This is useful + * when showing a dialog that includes the current type. + * @param includeSecondary If true, include any valid types marked as + * {@link EditType#secondary}. + * @param typeCount When provided, will be used for the frequency count of + * each {@link EditType}, otherwise built using + * {@link #getTypeFrequencies(RawContactDelta, DataKind)}. + */ + private static ArrayList<EditType> getValidTypes(RawContactDelta state, DataKind kind, + EditType forceInclude, boolean includeSecondary, SparseIntArray typeCount) { + final ArrayList<EditType> validTypes = new ArrayList<EditType>(); + + // Bail early if no types provided + if (!hasEditTypes(kind)) return validTypes; + + if (typeCount == null) { + // Build frequency counts if not provided + typeCount = getTypeFrequencies(state, kind); + } + + // Build list of valid types + final int overallCount = typeCount.get(FREQUENCY_TOTAL); + for (EditType type : kind.typeList) { + final boolean validOverall = (kind.typeOverallMax == -1 ? true + : overallCount < kind.typeOverallMax); + final boolean validSpecific = (type.specificMax == -1 ? true : typeCount + .get(type.rawValue) < type.specificMax); + final boolean validSecondary = (includeSecondary ? true : !type.secondary); + final boolean forcedInclude = type.equals(forceInclude); + if (forcedInclude || (validOverall && validSpecific && validSecondary)) { + // Type is valid when no limit, under limit, or forced include + validTypes.add(type); + } + } + + return validTypes; + } + + private static final int FREQUENCY_TOTAL = Integer.MIN_VALUE; + + /** + * Count up the frequency that each {@link EditType} appears in the given + * {@link RawContactDelta}. The returned {@link SparseIntArray} maps from + * {@link EditType#rawValue} to counts, with the total overall count stored + * as {@link #FREQUENCY_TOTAL}. + */ + private static SparseIntArray getTypeFrequencies(RawContactDelta state, DataKind kind) { + final SparseIntArray typeCount = new SparseIntArray(); + + // Find all entries for this kind, bailing early if none found + final List<ValuesDelta> mimeEntries = state.getMimeEntries(kind.mimeType); + if (mimeEntries == null) return typeCount; + + int totalCount = 0; + for (ValuesDelta entry : mimeEntries) { + // Only count visible entries + if (!entry.isVisible()) continue; + totalCount++; + + final EditType type = getCurrentType(entry, kind); + if (type != null) { + final int count = typeCount.get(type.rawValue); + typeCount.put(type.rawValue, count + 1); + } + } + typeCount.put(FREQUENCY_TOTAL, totalCount); + return typeCount; + } + + /** + * Check if the given {@link DataKind} has multiple types that should be + * displayed for users to pick. + */ + public static boolean hasEditTypes(DataKind kind) { + return kind.typeList != null && kind.typeList.size() > 0; + } + + /** + * Find the {@link EditType} that describes the given + * {@link ValuesDelta} row, assuming the given {@link DataKind} dictates + * the possible types. + */ + public static EditType getCurrentType(ValuesDelta entry, DataKind kind) { + final Long rawValue = entry.getAsLong(kind.typeColumn); + if (rawValue == null) return null; + return getType(kind, rawValue.intValue()); + } + + /** + * Find the {@link EditType} that describes the given {@link ContentValues} row, + * assuming the given {@link DataKind} dictates the possible types. + */ + public static EditType getCurrentType(ContentValues entry, DataKind kind) { + if (kind.typeColumn == null) return null; + final Integer rawValue = entry.getAsInteger(kind.typeColumn); + if (rawValue == null) return null; + return getType(kind, rawValue); + } + + /** + * Find the {@link EditType} that describes the given {@link Cursor} row, + * assuming the given {@link DataKind} dictates the possible types. + */ + public static EditType getCurrentType(Cursor cursor, DataKind kind) { + if (kind.typeColumn == null) return null; + final int index = cursor.getColumnIndex(kind.typeColumn); + if (index == -1) return null; + final int rawValue = cursor.getInt(index); + return getType(kind, rawValue); + } + + /** + * Find the {@link EditType} with the given {@link EditType#rawValue}. + */ + public static EditType getType(DataKind kind, int rawValue) { + for (EditType type : kind.typeList) { + if (type.rawValue == rawValue) { + return type; + } + } + return null; + } + + /** + * Return the precedence for the the given {@link EditType#rawValue}, where + * lower numbers are higher precedence. + */ + public static int getTypePrecedence(DataKind kind, int rawValue) { + for (int i = 0; i < kind.typeList.size(); i++) { + final EditType type = kind.typeList.get(i); + if (type.rawValue == rawValue) { + return i; + } + } + return Integer.MAX_VALUE; + } + + /** + * Find the best {@link EditType} for a potential insert. The "best" is the + * first primary type that doesn't already exist. When all valid types + * exist, we pick the last valid option. + */ + public static EditType getBestValidType(RawContactDelta state, DataKind kind, + boolean includeSecondary, int exactValue) { + // Shortcut when no types + if (kind.typeColumn == null) return null; + + // Find type counts and valid primary types, bail if none + final SparseIntArray typeCount = getTypeFrequencies(state, kind); + final ArrayList<EditType> validTypes = getValidTypes(state, kind, null, includeSecondary, + typeCount); + if (validTypes.size() == 0) return null; + + // Keep track of the last valid type + final EditType lastType = validTypes.get(validTypes.size() - 1); + + // Remove any types that already exist + Iterator<EditType> iterator = validTypes.iterator(); + while (iterator.hasNext()) { + final EditType type = iterator.next(); + final int count = typeCount.get(type.rawValue); + + if (exactValue == type.rawValue) { + // Found exact value match + return type; + } + + if (count > 0) { + // Type already appears, so don't consider + iterator.remove(); + } + } + + // Use the best remaining, otherwise the last valid + if (validTypes.size() > 0) { + return validTypes.get(0); + } else { + return lastType; + } + } + + /** + * Insert a new child of kind {@link DataKind} into the given + * {@link RawContactDelta}. Tries using the best {@link EditType} found using + * {@link #getBestValidType(RawContactDelta, DataKind, boolean, int)}. + */ + public static ValuesDelta insertChild(RawContactDelta state, DataKind kind) { + // First try finding a valid primary + EditType bestType = getBestValidType(state, kind, false, Integer.MIN_VALUE); + if (bestType == null) { + // No valid primary found, so expand search to secondary + bestType = getBestValidType(state, kind, true, Integer.MIN_VALUE); + } + return insertChild(state, kind, bestType); + } + + /** + * Insert a new child of kind {@link DataKind} into the given + * {@link RawContactDelta}, marked with the given {@link EditType}. + */ + public static ValuesDelta insertChild(RawContactDelta state, DataKind kind, EditType type) { + // Bail early if invalid kind + if (kind == null) return null; + final ContentValues after = new ContentValues(); + + // Our parent CONTACT_ID is provided later + after.put(Data.MIMETYPE, kind.mimeType); + + // Fill-in with any requested default values + if (kind.defaultValues != null) { + after.putAll(kind.defaultValues); + } + + if (kind.typeColumn != null && type != null) { + // Set type, if provided + after.put(kind.typeColumn, type.rawValue); + } + + final ValuesDelta child = ValuesDelta.fromAfter(after); + state.addEntry(child); + return child; + } + + /** + * Processing to trim any empty {@link ValuesDelta} and {@link RawContactDelta} + * from the given {@link RawContactDeltaList}, assuming the given {@link AccountTypeManager} + * dictates the structure for various fields. This method ignores rows not + * described by the {@link AccountType}. + */ + public static void trimEmpty(RawContactDeltaList set, AccountTypeManager accountTypes) { + for (RawContactDelta state : set) { + ValuesDelta values = state.getValues(); + final String accountType = values.getAsString(RawContacts.ACCOUNT_TYPE); + final String dataSet = values.getAsString(RawContacts.DATA_SET); + final AccountType type = accountTypes.getAccountType(accountType, dataSet); + trimEmpty(state, type); + } + } + + public static boolean hasChanges(RawContactDeltaList set, AccountTypeManager accountTypes) { + if (set.isMarkedForSplitting() || set.isMarkedForJoining()) { + return true; + } + + for (RawContactDelta state : set) { + ValuesDelta values = state.getValues(); + final String accountType = values.getAsString(RawContacts.ACCOUNT_TYPE); + final String dataSet = values.getAsString(RawContacts.DATA_SET); + final AccountType type = accountTypes.getAccountType(accountType, dataSet); + if (hasChanges(state, type)) { + return true; + } + } + return false; + } + + /** + * Processing to trim any empty {@link ValuesDelta} rows from the given + * {@link RawContactDelta}, assuming the given {@link AccountType} dictates + * the structure for various fields. This method ignores rows not described + * by the {@link AccountType}. + */ + public static void trimEmpty(RawContactDelta state, AccountType accountType) { + boolean hasValues = false; + + // Walk through entries for each well-known kind + for (DataKind kind : accountType.getSortedDataKinds()) { + final String mimeType = kind.mimeType; + final ArrayList<ValuesDelta> entries = state.getMimeEntries(mimeType); + if (entries == null) continue; + + for (ValuesDelta entry : entries) { + // Skip any values that haven't been touched + final boolean touched = entry.isInsert() || entry.isUpdate(); + if (!touched) { + hasValues = true; + continue; + } + + // Test and remove this row if empty and it isn't a photo from google + final boolean isGoogleAccount = TextUtils.equals(GoogleAccountType.ACCOUNT_TYPE, + state.getValues().getAsString(RawContacts.ACCOUNT_TYPE)); + final boolean isPhoto = TextUtils.equals(Photo.CONTENT_ITEM_TYPE, kind.mimeType); + final boolean isGooglePhoto = isPhoto && isGoogleAccount; + + if (RawContactModifier.isEmpty(entry, kind) && !isGooglePhoto) { + if (DEBUG) { + Log.v(TAG, "Trimming: " + entry.toString()); + } + entry.markDeleted(); + } else if (!entry.isFromTemplate()) { + hasValues = true; + } + } + } + if (!hasValues) { + // Trim overall entity if no children exist + state.markDeleted(); + } + } + + private static boolean hasChanges(RawContactDelta state, AccountType accountType) { + for (DataKind kind : accountType.getSortedDataKinds()) { + final String mimeType = kind.mimeType; + final ArrayList<ValuesDelta> entries = state.getMimeEntries(mimeType); + if (entries == null) continue; + + for (ValuesDelta entry : entries) { + // An empty Insert must be ignored, because it won't save anything (an example + // is an empty name that stays empty) + final boolean isRealInsert = entry.isInsert() && !isEmpty(entry, kind); + if (isRealInsert || entry.isUpdate() || entry.isDelete()) { + return true; + } + } + } + return false; + } + + /** + * Test if the given {@link ValuesDelta} would be considered "empty" in + * terms of {@link DataKind#fieldList}. + */ + public static boolean isEmpty(ValuesDelta values, DataKind kind) { + if (Photo.CONTENT_ITEM_TYPE.equals(kind.mimeType)) { + return values.isInsert() && values.getAsByteArray(Photo.PHOTO) == null; + } + + // No defined fields mean this row is always empty + if (kind.fieldList == null) return true; + + for (EditField field : kind.fieldList) { + // If any field has values, we're not empty + final String value = values.getAsString(field.column); + if (ContactsUtils.isGraphic(value)) { + return false; + } + } + + return true; + } + + /** + * Compares corresponding fields in values1 and values2. Only the fields + * declared by the DataKind are taken into consideration. + */ + protected static boolean areEqual(ValuesDelta values1, ContentValues values2, DataKind kind) { + if (kind.fieldList == null) return false; + + for (EditField field : kind.fieldList) { + final String value1 = values1.getAsString(field.column); + final String value2 = values2.getAsString(field.column); + if (!TextUtils.equals(value1, value2)) { + return false; + } + } + + return true; + } + + /** + * Parse the given {@link Bundle} into the given {@link RawContactDelta} state, + * assuming the extras defined through {@link Intents}. + */ + public static void parseExtras(Context context, AccountType accountType, RawContactDelta state, + Bundle extras) { + if (extras == null || extras.size() == 0) { + // Bail early if no useful data + return; + } + + parseStructuredNameExtra(context, accountType, state, extras); + parseStructuredPostalExtra(accountType, state, extras); + + { + // Phone + final DataKind kind = accountType.getKindForMimetype(Phone.CONTENT_ITEM_TYPE); + parseExtras(state, kind, extras, Insert.PHONE_TYPE, Insert.PHONE, Phone.NUMBER); + parseExtras(state, kind, extras, Insert.SECONDARY_PHONE_TYPE, Insert.SECONDARY_PHONE, + Phone.NUMBER); + parseExtras(state, kind, extras, Insert.TERTIARY_PHONE_TYPE, Insert.TERTIARY_PHONE, + Phone.NUMBER); + } + + { + // Email + final DataKind kind = accountType.getKindForMimetype(Email.CONTENT_ITEM_TYPE); + parseExtras(state, kind, extras, Insert.EMAIL_TYPE, Insert.EMAIL, Email.DATA); + parseExtras(state, kind, extras, Insert.SECONDARY_EMAIL_TYPE, Insert.SECONDARY_EMAIL, + Email.DATA); + parseExtras(state, kind, extras, Insert.TERTIARY_EMAIL_TYPE, Insert.TERTIARY_EMAIL, + Email.DATA); + } + + { + // Im + final DataKind kind = accountType.getKindForMimetype(Im.CONTENT_ITEM_TYPE); + fixupLegacyImType(extras); + parseExtras(state, kind, extras, Insert.IM_PROTOCOL, Insert.IM_HANDLE, Im.DATA); + } + + // Organization + final boolean hasOrg = extras.containsKey(Insert.COMPANY) + || extras.containsKey(Insert.JOB_TITLE); + final DataKind kindOrg = accountType.getKindForMimetype(Organization.CONTENT_ITEM_TYPE); + if (hasOrg && RawContactModifier.canInsert(state, kindOrg)) { + final ValuesDelta child = RawContactModifier.insertChild(state, kindOrg); + + final String company = extras.getString(Insert.COMPANY); + if (ContactsUtils.isGraphic(company)) { + child.put(Organization.COMPANY, company); + } + + final String title = extras.getString(Insert.JOB_TITLE); + if (ContactsUtils.isGraphic(title)) { + child.put(Organization.TITLE, title); + } + } + + // Notes + final boolean hasNotes = extras.containsKey(Insert.NOTES); + final DataKind kindNotes = accountType.getKindForMimetype(Note.CONTENT_ITEM_TYPE); + if (hasNotes && RawContactModifier.canInsert(state, kindNotes)) { + final ValuesDelta child = RawContactModifier.insertChild(state, kindNotes); + + final String notes = extras.getString(Insert.NOTES); + if (ContactsUtils.isGraphic(notes)) { + child.put(Note.NOTE, notes); + } + } + + // Arbitrary additional data + ArrayList<ContentValues> values = extras.getParcelableArrayList(Insert.DATA); + if (values != null) { + parseValues(state, accountType, values); + } + } + + private static void parseStructuredNameExtra( + Context context, AccountType accountType, RawContactDelta state, Bundle extras) { + // StructuredName + RawContactModifier.ensureKindExists(state, accountType, StructuredName.CONTENT_ITEM_TYPE); + final ValuesDelta child = state.getPrimaryEntry(StructuredName.CONTENT_ITEM_TYPE); + + final String name = extras.getString(Insert.NAME); + if (ContactsUtils.isGraphic(name)) { + final DataKind kind = accountType.getKindForMimetype(StructuredName.CONTENT_ITEM_TYPE); + boolean supportsDisplayName = false; + if (kind.fieldList != null) { + for (EditField field : kind.fieldList) { + if (StructuredName.DISPLAY_NAME.equals(field.column)) { + supportsDisplayName = true; + break; + } + } + } + + if (supportsDisplayName) { + child.put(StructuredName.DISPLAY_NAME, name); + } else { + Uri uri = ContactsContract.AUTHORITY_URI.buildUpon() + .appendPath("complete_name") + .appendQueryParameter(StructuredName.DISPLAY_NAME, name) + .build(); + Cursor cursor = context.getContentResolver().query(uri, + new String[]{ + StructuredName.PREFIX, + StructuredName.GIVEN_NAME, + StructuredName.MIDDLE_NAME, + StructuredName.FAMILY_NAME, + StructuredName.SUFFIX, + }, null, null, null); + + try { + if (cursor.moveToFirst()) { + child.put(StructuredName.PREFIX, cursor.getString(0)); + child.put(StructuredName.GIVEN_NAME, cursor.getString(1)); + child.put(StructuredName.MIDDLE_NAME, cursor.getString(2)); + child.put(StructuredName.FAMILY_NAME, cursor.getString(3)); + child.put(StructuredName.SUFFIX, cursor.getString(4)); + } + } finally { + cursor.close(); + } + } + } + + final String phoneticName = extras.getString(Insert.PHONETIC_NAME); + if (ContactsUtils.isGraphic(phoneticName)) { + child.put(StructuredName.PHONETIC_GIVEN_NAME, phoneticName); + } + } + + private static void parseStructuredPostalExtra( + AccountType accountType, RawContactDelta state, Bundle extras) { + // StructuredPostal + final DataKind kind = accountType.getKindForMimetype(StructuredPostal.CONTENT_ITEM_TYPE); + final ValuesDelta child = parseExtras(state, kind, extras, Insert.POSTAL_TYPE, + Insert.POSTAL, StructuredPostal.FORMATTED_ADDRESS); + String address = child == null ? null + : child.getAsString(StructuredPostal.FORMATTED_ADDRESS); + if (!TextUtils.isEmpty(address)) { + boolean supportsFormatted = false; + if (kind.fieldList != null) { + for (EditField field : kind.fieldList) { + if (StructuredPostal.FORMATTED_ADDRESS.equals(field.column)) { + supportsFormatted = true; + break; + } + } + } + + if (!supportsFormatted) { + child.put(StructuredPostal.STREET, address); + child.putNull(StructuredPostal.FORMATTED_ADDRESS); + } + } + } + + private static void parseValues( + RawContactDelta state, AccountType accountType, + ArrayList<ContentValues> dataValueList) { + for (ContentValues values : dataValueList) { + String mimeType = values.getAsString(Data.MIMETYPE); + if (TextUtils.isEmpty(mimeType)) { + Log.e(TAG, "Mimetype is required. Ignoring: " + values); + continue; + } + + // Won't override the contact name + if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) { + continue; + } else if (Phone.CONTENT_ITEM_TYPE.equals(mimeType)) { + values.remove(PhoneDataItem.KEY_FORMATTED_PHONE_NUMBER); + final Integer type = values.getAsInteger(Phone.TYPE); + // If the provided phone number provides a custom phone type but not a label, + // replace it with mobile (by default) to avoid the "Enter custom label" from + // popping up immediately upon entering the ContactEditorFragment + if (type != null && type == Phone.TYPE_CUSTOM && + TextUtils.isEmpty(values.getAsString(Phone.LABEL))) { + values.put(Phone.TYPE, Phone.TYPE_MOBILE); + } + } + + DataKind kind = accountType.getKindForMimetype(mimeType); + if (kind == null) { + Log.e(TAG, "Mimetype not supported for account type " + + accountType.getAccountTypeAndDataSet() + ". Ignoring: " + values); + continue; + } + + ValuesDelta entry = ValuesDelta.fromAfter(values); + if (isEmpty(entry, kind)) { + continue; + } + + ArrayList<ValuesDelta> entries = state.getMimeEntries(mimeType); + + if ((kind.typeOverallMax != 1) || GroupMembership.CONTENT_ITEM_TYPE.equals(mimeType)) { + // Check for duplicates + boolean addEntry = true; + int count = 0; + if (entries != null && entries.size() > 0) { + for (ValuesDelta delta : entries) { + if (!delta.isDelete()) { + if (areEqual(delta, values, kind)) { + addEntry = false; + break; + } + count++; + } + } + } + + if (kind.typeOverallMax != -1 && count >= kind.typeOverallMax) { + Log.e(TAG, "Mimetype allows at most " + kind.typeOverallMax + + " entries. Ignoring: " + values); + addEntry = false; + } + + if (addEntry) { + addEntry = adjustType(entry, entries, kind); + } + + if (addEntry) { + state.addEntry(entry); + } + } else { + // Non-list entries should not be overridden + boolean addEntry = true; + if (entries != null && entries.size() > 0) { + for (ValuesDelta delta : entries) { + if (!delta.isDelete() && !isEmpty(delta, kind)) { + addEntry = false; + break; + } + } + if (addEntry) { + for (ValuesDelta delta : entries) { + delta.markDeleted(); + } + } + } + + if (addEntry) { + addEntry = adjustType(entry, entries, kind); + } + + if (addEntry) { + state.addEntry(entry); + } else if (Note.CONTENT_ITEM_TYPE.equals(mimeType)){ + // Note is most likely to contain large amounts of text + // that we don't want to drop on the ground. + for (ValuesDelta delta : entries) { + if (!isEmpty(delta, kind)) { + delta.put(Note.NOTE, delta.getAsString(Note.NOTE) + "\n" + + values.getAsString(Note.NOTE)); + break; + } + } + } else { + Log.e(TAG, "Will not override mimetype " + mimeType + ". Ignoring: " + + values); + } + } + } + } + + /** + * Checks if the data kind allows addition of another entry (e.g. Exchange only + * supports two "work" phone numbers). If not, tries to switch to one of the + * unused types. If successful, returns true. + */ + private static boolean adjustType( + ValuesDelta entry, ArrayList<ValuesDelta> entries, DataKind kind) { + if (kind.typeColumn == null || kind.typeList == null || kind.typeList.size() == 0) { + return true; + } + + Integer typeInteger = entry.getAsInteger(kind.typeColumn); + int type = typeInteger != null ? typeInteger : kind.typeList.get(0).rawValue; + + if (isTypeAllowed(type, entries, kind)) { + entry.put(kind.typeColumn, type); + return true; + } + + // Specified type is not allowed - choose the first available type that is allowed + int size = kind.typeList.size(); + for (int i = 0; i < size; i++) { + EditType editType = kind.typeList.get(i); + if (isTypeAllowed(editType.rawValue, entries, kind)) { + entry.put(kind.typeColumn, editType.rawValue); + return true; + } + } + + return false; + } + + /** + * Checks if a new entry of the specified type can be added to the raw + * contact. For example, Exchange only supports two "work" phone numbers, so + * addition of a third would not be allowed. + */ + private static boolean isTypeAllowed(int type, ArrayList<ValuesDelta> entries, DataKind kind) { + int max = 0; + int size = kind.typeList.size(); + for (int i = 0; i < size; i++) { + EditType editType = kind.typeList.get(i); + if (editType.rawValue == type) { + max = editType.specificMax; + break; + } + } + + if (max == 0) { + // This type is not allowed at all + return false; + } + + if (max == -1) { + // Unlimited instances of this type are allowed + return true; + } + + return getEntryCountByType(entries, kind.typeColumn, type) < max; + } + + /** + * Counts occurrences of the specified type in the supplied entry list. + * + * @return The count of occurrences of the type in the entry list. 0 if entries is + * {@literal null} + */ + private static int getEntryCountByType(ArrayList<ValuesDelta> entries, String typeColumn, + int type) { + int count = 0; + if (entries != null) { + for (ValuesDelta entry : entries) { + Integer typeInteger = entry.getAsInteger(typeColumn); + if (typeInteger != null && typeInteger == type) { + count++; + } + } + } + return count; + } + + /** + * Attempt to parse legacy {@link Insert#IM_PROTOCOL} values, replacing them + * with updated values. + */ + @SuppressWarnings("deprecation") + private static void fixupLegacyImType(Bundle bundle) { + final String encodedString = bundle.getString(Insert.IM_PROTOCOL); + if (encodedString == null) return; + + try { + final Object protocol = android.provider.Contacts.ContactMethods + .decodeImProtocol(encodedString); + if (protocol instanceof Integer) { + bundle.putInt(Insert.IM_PROTOCOL, (Integer)protocol); + } else { + bundle.putString(Insert.IM_PROTOCOL, (String)protocol); + } + } catch (IllegalArgumentException e) { + // Ignore exception when legacy parser fails + } + } + + /** + * Parse a specific entry from the given {@link Bundle} and insert into the + * given {@link RawContactDelta}. Silently skips the insert when missing value + * or no valid {@link EditType} found. + * + * @param typeExtra {@link Bundle} key that holds the incoming + * {@link EditType#rawValue} value. + * @param valueExtra {@link Bundle} key that holds the incoming value. + * @param valueColumn Column to write value into {@link ValuesDelta}. + */ + public static ValuesDelta parseExtras(RawContactDelta state, DataKind kind, Bundle extras, + String typeExtra, String valueExtra, String valueColumn) { + final CharSequence value = extras.getCharSequence(valueExtra); + + // Bail early if account type doesn't handle this MIME type + if (kind == null) return null; + + // Bail when can't insert type, or value missing + final boolean canInsert = RawContactModifier.canInsert(state, kind); + final boolean validValue = (value != null && TextUtils.isGraphic(value)); + if (!validValue || !canInsert) return null; + + // Find exact type when requested, otherwise best available type + final boolean hasType = extras.containsKey(typeExtra); + final int typeValue = extras.getInt(typeExtra, hasType ? BaseTypes.TYPE_CUSTOM + : Integer.MIN_VALUE); + final EditType editType = RawContactModifier.getBestValidType(state, kind, true, typeValue); + + // Create data row and fill with value + final ValuesDelta child = RawContactModifier.insertChild(state, kind, editType); + child.put(valueColumn, value.toString()); + + if (editType != null && editType.customColumn != null) { + // Write down label when custom type picked + final String customType = extras.getString(typeExtra); + child.put(editType.customColumn, customType); + } + + return child; + } + + /** + * Generic mime types with type support (e.g. TYPE_HOME). + * Here, "type support" means if the data kind has CommonColumns#TYPE or not. Data kinds which + * have their own migrate methods aren't listed here. + */ + private static final Set<String> sGenericMimeTypesWithTypeSupport = new HashSet<String>( + Arrays.asList(Phone.CONTENT_ITEM_TYPE, + Email.CONTENT_ITEM_TYPE, + Im.CONTENT_ITEM_TYPE, + Nickname.CONTENT_ITEM_TYPE, + Website.CONTENT_ITEM_TYPE, + Relation.CONTENT_ITEM_TYPE, + SipAddress.CONTENT_ITEM_TYPE)); + private static final Set<String> sGenericMimeTypesWithoutTypeSupport = new HashSet<String>( + Arrays.asList(Organization.CONTENT_ITEM_TYPE, + Note.CONTENT_ITEM_TYPE, + Photo.CONTENT_ITEM_TYPE, + GroupMembership.CONTENT_ITEM_TYPE)); + // CommonColumns.TYPE cannot be accessed as it is protected interface, so use + // Phone.TYPE instead. + private static final String COLUMN_FOR_TYPE = Phone.TYPE; + private static final String COLUMN_FOR_LABEL = Phone.LABEL; + private static final int TYPE_CUSTOM = Phone.TYPE_CUSTOM; + + /** + * Migrates old RawContactDelta to newly created one with a new restriction supplied from + * newAccountType. + * + * This is only for account switch during account creation (which must be insert operation). + */ + public static void migrateStateForNewContact(Context context, + RawContactDelta oldState, RawContactDelta newState, + AccountType oldAccountType, AccountType newAccountType) { + if (newAccountType == oldAccountType) { + // Just copying all data in oldState isn't enough, but we can still rely on a lot of + // shortcuts. + for (DataKind kind : newAccountType.getSortedDataKinds()) { + final String mimeType = kind.mimeType; + // The fields with short/long form capability must be treated properly. + if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) { + migrateStructuredName(context, oldState, newState, kind); + } else { + List<ValuesDelta> entryList = oldState.getMimeEntries(mimeType); + if (entryList != null && !entryList.isEmpty()) { + for (ValuesDelta entry : entryList) { + ContentValues values = entry.getAfter(); + if (values != null) { + newState.addEntry(ValuesDelta.fromAfter(values)); + } + } + } + } + } + } else { + // Migrate data supported by the new account type. + // All the other data inside oldState are silently dropped. + for (DataKind kind : newAccountType.getSortedDataKinds()) { + if (!kind.editable) continue; + final String mimeType = kind.mimeType; + if (DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME.equals(mimeType) + || DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME.equals(mimeType)) { + // Ignore pseudo data. + continue; + } else if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) { + migrateStructuredName(context, oldState, newState, kind); + } else if (StructuredPostal.CONTENT_ITEM_TYPE.equals(mimeType)) { + migratePostal(oldState, newState, kind); + } else if (Event.CONTENT_ITEM_TYPE.equals(mimeType)) { + migrateEvent(oldState, newState, kind, null /* default Year */); + } else if (sGenericMimeTypesWithoutTypeSupport.contains(mimeType)) { + migrateGenericWithoutTypeColumn(oldState, newState, kind); + } else if (sGenericMimeTypesWithTypeSupport.contains(mimeType)) { + migrateGenericWithTypeColumn(oldState, newState, kind); + } else { + throw new IllegalStateException("Unexpected editable mime-type: " + mimeType); + } + } + } + } + + /** + * Checks {@link DataKind#isList} and {@link DataKind#typeOverallMax}, and restricts + * the number of entries (ValuesDelta) inside newState. + */ + private static ArrayList<ValuesDelta> ensureEntryMaxSize(RawContactDelta newState, + DataKind kind, ArrayList<ValuesDelta> mimeEntries) { + if (mimeEntries == null) { + return null; + } + + final int typeOverallMax = kind.typeOverallMax; + if (typeOverallMax >= 0 && (mimeEntries.size() > typeOverallMax)) { + ArrayList<ValuesDelta> newMimeEntries = new ArrayList<ValuesDelta>(typeOverallMax); + for (int i = 0; i < typeOverallMax; i++) { + newMimeEntries.add(mimeEntries.get(i)); + } + mimeEntries = newMimeEntries; + } + return mimeEntries; + } + + /** @hide Public only for testing. */ + public static void migrateStructuredName( + Context context, RawContactDelta oldState, RawContactDelta newState, + DataKind newDataKind) { + final ContentValues values = + oldState.getPrimaryEntry(StructuredName.CONTENT_ITEM_TYPE).getAfter(); + if (values == null) { + return; + } + + boolean supportDisplayName = false; + boolean supportPhoneticFullName = false; + boolean supportPhoneticFamilyName = false; + boolean supportPhoneticMiddleName = false; + boolean supportPhoneticGivenName = false; + for (EditField editField : newDataKind.fieldList) { + if (StructuredName.DISPLAY_NAME.equals(editField.column)) { + supportDisplayName = true; + } + if (DataKind.PSEUDO_COLUMN_PHONETIC_NAME.equals(editField.column)) { + supportPhoneticFullName = true; + } + if (StructuredName.PHONETIC_FAMILY_NAME.equals(editField.column)) { + supportPhoneticFamilyName = true; + } + if (StructuredName.PHONETIC_MIDDLE_NAME.equals(editField.column)) { + supportPhoneticMiddleName = true; + } + if (StructuredName.PHONETIC_GIVEN_NAME.equals(editField.column)) { + supportPhoneticGivenName = true; + } + } + + // DISPLAY_NAME <-> PREFIX, GIVEN_NAME, MIDDLE_NAME, FAMILY_NAME, SUFFIX + final String displayName = values.getAsString(StructuredName.DISPLAY_NAME); + if (!TextUtils.isEmpty(displayName)) { + if (!supportDisplayName) { + // Old data has a display name, while the new account doesn't allow it. + NameConverter.displayNameToStructuredName(context, displayName, values); + + // We don't want to migrate unseen data which may confuse users after the creation. + values.remove(StructuredName.DISPLAY_NAME); + } + } else { + if (supportDisplayName) { + // Old data does not have display name, while the new account requires it. + values.put(StructuredName.DISPLAY_NAME, + NameConverter.structuredNameToDisplayName(context, values)); + for (String field : NameConverter.STRUCTURED_NAME_FIELDS) { + values.remove(field); + } + } + } + + // Phonetic (full) name <-> PHONETIC_FAMILY_NAME, PHONETIC_MIDDLE_NAME, PHONETIC_GIVEN_NAME + final String phoneticFullName = values.getAsString(DataKind.PSEUDO_COLUMN_PHONETIC_NAME); + if (!TextUtils.isEmpty(phoneticFullName)) { + if (!supportPhoneticFullName) { + // Old data has a phonetic (full) name, while the new account doesn't allow it. + final StructuredNameDataItem tmpItem = + NameConverter.parsePhoneticName(phoneticFullName, null); + values.remove(DataKind.PSEUDO_COLUMN_PHONETIC_NAME); + if (supportPhoneticFamilyName) { + values.put(StructuredName.PHONETIC_FAMILY_NAME, + tmpItem.getPhoneticFamilyName()); + } else { + values.remove(StructuredName.PHONETIC_FAMILY_NAME); + } + if (supportPhoneticMiddleName) { + values.put(StructuredName.PHONETIC_MIDDLE_NAME, + tmpItem.getPhoneticMiddleName()); + } else { + values.remove(StructuredName.PHONETIC_MIDDLE_NAME); + } + if (supportPhoneticGivenName) { + values.put(StructuredName.PHONETIC_GIVEN_NAME, + tmpItem.getPhoneticGivenName()); + } else { + values.remove(StructuredName.PHONETIC_GIVEN_NAME); + } + } + } else { + if (supportPhoneticFullName) { + // Old data does not have a phonetic (full) name, while the new account requires it. + values.put(DataKind.PSEUDO_COLUMN_PHONETIC_NAME, + NameConverter.buildPhoneticName( + values.getAsString(StructuredName.PHONETIC_FAMILY_NAME), + values.getAsString(StructuredName.PHONETIC_MIDDLE_NAME), + values.getAsString(StructuredName.PHONETIC_GIVEN_NAME))); + } + if (!supportPhoneticFamilyName) { + values.remove(StructuredName.PHONETIC_FAMILY_NAME); + } + if (!supportPhoneticMiddleName) { + values.remove(StructuredName.PHONETIC_MIDDLE_NAME); + } + if (!supportPhoneticGivenName) { + values.remove(StructuredName.PHONETIC_GIVEN_NAME); + } + } + + newState.addEntry(ValuesDelta.fromAfter(values)); + } + + /** @hide Public only for testing. */ + public static void migratePostal(RawContactDelta oldState, RawContactDelta newState, + DataKind newDataKind) { + final ArrayList<ValuesDelta> mimeEntries = ensureEntryMaxSize(newState, newDataKind, + oldState.getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE)); + if (mimeEntries == null || mimeEntries.isEmpty()) { + return; + } + + boolean supportFormattedAddress = false; + boolean supportStreet = false; + final String firstColumn = newDataKind.fieldList.get(0).column; + for (EditField editField : newDataKind.fieldList) { + if (StructuredPostal.FORMATTED_ADDRESS.equals(editField.column)) { + supportFormattedAddress = true; + } + if (StructuredPostal.STREET.equals(editField.column)) { + supportStreet = true; + } + } + + final Set<Integer> supportedTypes = new HashSet<Integer>(); + if (newDataKind.typeList != null && !newDataKind.typeList.isEmpty()) { + for (EditType editType : newDataKind.typeList) { + supportedTypes.add(editType.rawValue); + } + } + + for (ValuesDelta entry : mimeEntries) { + final ContentValues values = entry.getAfter(); + if (values == null) { + continue; + } + final Integer oldType = values.getAsInteger(StructuredPostal.TYPE); + if (!supportedTypes.contains(oldType)) { + int defaultType; + if (newDataKind.defaultValues != null) { + defaultType = newDataKind.defaultValues.getAsInteger(StructuredPostal.TYPE); + } else { + defaultType = newDataKind.typeList.get(0).rawValue; + } + values.put(StructuredPostal.TYPE, defaultType); + if (oldType != null && oldType == StructuredPostal.TYPE_CUSTOM) { + values.remove(StructuredPostal.LABEL); + } + } + + final String formattedAddress = values.getAsString(StructuredPostal.FORMATTED_ADDRESS); + if (!TextUtils.isEmpty(formattedAddress)) { + if (!supportFormattedAddress) { + // Old data has a formatted address, while the new account doesn't allow it. + values.remove(StructuredPostal.FORMATTED_ADDRESS); + + // Unlike StructuredName we don't have logic to split it, so first + // try to use street field and. If the new account doesn't have one, + // then select first one anyway. + if (supportStreet) { + values.put(StructuredPostal.STREET, formattedAddress); + } else { + values.put(firstColumn, formattedAddress); + } + } + } else { + if (supportFormattedAddress) { + // Old data does not have formatted address, while the new account requires it. + // Unlike StructuredName we don't have logic to join multiple address values. + // Use poor join heuristics for now. + String[] structuredData; + final boolean useJapaneseOrder = + Locale.JAPANESE.getLanguage().equals(Locale.getDefault().getLanguage()); + if (useJapaneseOrder) { + structuredData = new String[] { + values.getAsString(StructuredPostal.COUNTRY), + values.getAsString(StructuredPostal.POSTCODE), + values.getAsString(StructuredPostal.REGION), + values.getAsString(StructuredPostal.CITY), + values.getAsString(StructuredPostal.NEIGHBORHOOD), + values.getAsString(StructuredPostal.STREET), + values.getAsString(StructuredPostal.POBOX) }; + } else { + structuredData = new String[] { + values.getAsString(StructuredPostal.POBOX), + values.getAsString(StructuredPostal.STREET), + values.getAsString(StructuredPostal.NEIGHBORHOOD), + values.getAsString(StructuredPostal.CITY), + values.getAsString(StructuredPostal.REGION), + values.getAsString(StructuredPostal.POSTCODE), + values.getAsString(StructuredPostal.COUNTRY) }; + } + final StringBuilder builder = new StringBuilder(); + for (String elem : structuredData) { + if (!TextUtils.isEmpty(elem)) { + builder.append(elem + "\n"); + } + } + values.put(StructuredPostal.FORMATTED_ADDRESS, builder.toString()); + + values.remove(StructuredPostal.POBOX); + values.remove(StructuredPostal.STREET); + values.remove(StructuredPostal.NEIGHBORHOOD); + values.remove(StructuredPostal.CITY); + values.remove(StructuredPostal.REGION); + values.remove(StructuredPostal.POSTCODE); + values.remove(StructuredPostal.COUNTRY); + } + } + + newState.addEntry(ValuesDelta.fromAfter(values)); + } + } + + /** @hide Public only for testing. */ + public static void migrateEvent(RawContactDelta oldState, RawContactDelta newState, + DataKind newDataKind, Integer defaultYear) { + final ArrayList<ValuesDelta> mimeEntries = ensureEntryMaxSize(newState, newDataKind, + oldState.getMimeEntries(Event.CONTENT_ITEM_TYPE)); + if (mimeEntries == null || mimeEntries.isEmpty()) { + return; + } + + final SparseArray<EventEditType> allowedTypes = new SparseArray<EventEditType>(); + for (EditType editType : newDataKind.typeList) { + allowedTypes.put(editType.rawValue, (EventEditType) editType); + } + for (ValuesDelta entry : mimeEntries) { + final ContentValues values = entry.getAfter(); + if (values == null) { + continue; + } + final String dateString = values.getAsString(Event.START_DATE); + final Integer type = values.getAsInteger(Event.TYPE); + if (type != null && (allowedTypes.indexOfKey(type) >= 0) + && !TextUtils.isEmpty(dateString)) { + EventEditType suitableType = allowedTypes.get(type); + + final ParsePosition position = new ParsePosition(0); + boolean yearOptional = false; + Date date = CommonDateUtils.DATE_AND_TIME_FORMAT.parse(dateString, position); + if (date == null) { + yearOptional = true; + date = CommonDateUtils.NO_YEAR_DATE_FORMAT.parse(dateString, position); + } + if (date != null) { + if (yearOptional && !suitableType.isYearOptional()) { + // The new EditType doesn't allow optional year. Supply default. + final Calendar calendar = Calendar.getInstance(DateUtils.UTC_TIMEZONE, + Locale.US); + if (defaultYear == null) { + defaultYear = calendar.get(Calendar.YEAR); + } + calendar.setTime(date); + final int month = calendar.get(Calendar.MONTH); + final int day = calendar.get(Calendar.DAY_OF_MONTH); + // Exchange requires 8:00 for birthdays + calendar.set(defaultYear, month, day, + CommonDateUtils.DEFAULT_HOUR, 0, 0); + values.put(Event.START_DATE, + CommonDateUtils.FULL_DATE_FORMAT.format(calendar.getTime())); + } + } + newState.addEntry(ValuesDelta.fromAfter(values)); + } else { + // Just drop it. + } + } + } + + /** @hide Public only for testing. */ + public static void migrateGenericWithoutTypeColumn( + RawContactDelta oldState, RawContactDelta newState, DataKind newDataKind) { + final ArrayList<ValuesDelta> mimeEntries = ensureEntryMaxSize(newState, newDataKind, + oldState.getMimeEntries(newDataKind.mimeType)); + if (mimeEntries == null || mimeEntries.isEmpty()) { + return; + } + + for (ValuesDelta entry : mimeEntries) { + ContentValues values = entry.getAfter(); + if (values != null) { + newState.addEntry(ValuesDelta.fromAfter(values)); + } + } + } + + /** @hide Public only for testing. */ + public static void migrateGenericWithTypeColumn( + RawContactDelta oldState, RawContactDelta newState, DataKind newDataKind) { + final ArrayList<ValuesDelta> mimeEntries = oldState.getMimeEntries(newDataKind.mimeType); + if (mimeEntries == null || mimeEntries.isEmpty()) { + return; + } + + // Note that type specified with the old account may be invalid with the new account, while + // we want to preserve its data as much as possible. e.g. if a user typed a phone number + // with a type which is valid with an old account but not with a new account, the user + // probably wants to have the number with default type, rather than seeing complete data + // loss. + // + // Specifically, this method works as follows: + // 1. detect defaultType + // 2. prepare constants & variables for iteration + // 3. iterate over mimeEntries: + // 3.1 stop iteration if total number of mimeEntries reached typeOverallMax specified in + // DataKind + // 3.2 replace unallowed types with defaultType + // 3.3 check if the number of entries is below specificMax specified in AccountType + + // Here, defaultType can be supplied in two ways + // - via kind.defaultValues + // - via kind.typeList.get(0).rawValue + Integer defaultType = null; + if (newDataKind.defaultValues != null) { + defaultType = newDataKind.defaultValues.getAsInteger(COLUMN_FOR_TYPE); + } + final Set<Integer> allowedTypes = new HashSet<Integer>(); + // key: type, value: the number of entries allowed for the type (specificMax) + final SparseIntArray typeSpecificMaxMap = new SparseIntArray(); + if (defaultType != null) { + allowedTypes.add(defaultType); + typeSpecificMaxMap.put(defaultType, -1); + } + // Note: typeList may be used in different purposes when defaultValues are specified. + // Especially in IM, typeList contains available protocols (e.g. PROTOCOL_GOOGLE_TALK) + // instead of "types" which we want to treate here (e.g. TYPE_HOME). So we don't add + // anything other than defaultType into allowedTypes and typeSpecificMapMax. + if (!Im.CONTENT_ITEM_TYPE.equals(newDataKind.mimeType) && + newDataKind.typeList != null && !newDataKind.typeList.isEmpty()) { + for (EditType editType : newDataKind.typeList) { + allowedTypes.add(editType.rawValue); + typeSpecificMaxMap.put(editType.rawValue, editType.specificMax); + } + if (defaultType == null) { + defaultType = newDataKind.typeList.get(0).rawValue; + } + } + + if (defaultType == null) { + Log.w(TAG, "Default type isn't available for mimetype " + newDataKind.mimeType); + } + + final int typeOverallMax = newDataKind.typeOverallMax; + + // key: type, value: the number of current entries. + final SparseIntArray currentEntryCount = new SparseIntArray(); + int totalCount = 0; + + for (ValuesDelta entry : mimeEntries) { + if (typeOverallMax != -1 && totalCount >= typeOverallMax) { + break; + } + + final ContentValues values = entry.getAfter(); + if (values == null) { + continue; + } + + final Integer oldType = entry.getAsInteger(COLUMN_FOR_TYPE); + final Integer typeForNewAccount; + if (!allowedTypes.contains(oldType)) { + // The new account doesn't support the type. + if (defaultType != null) { + typeForNewAccount = defaultType.intValue(); + values.put(COLUMN_FOR_TYPE, defaultType.intValue()); + if (oldType != null && oldType == TYPE_CUSTOM) { + values.remove(COLUMN_FOR_LABEL); + } + } else { + typeForNewAccount = null; + values.remove(COLUMN_FOR_TYPE); + } + } else { + typeForNewAccount = oldType; + } + if (typeForNewAccount != null) { + final int specificMax = typeSpecificMaxMap.get(typeForNewAccount, 0); + if (specificMax >= 0) { + final int currentCount = currentEntryCount.get(typeForNewAccount, 0); + if (currentCount >= specificMax) { + continue; + } + currentEntryCount.put(typeForNewAccount, currentCount + 1); + } + } + newState.addEntry(ValuesDelta.fromAfter(values)); + totalCount++; + } + } +} diff --git a/src/com/android/contacts/common/model/account/ExternalAccountType.java b/src/com/android/contacts/common/model/account/ExternalAccountType.java index 5697efe3..f6ab375d 100644 --- a/src/com/android/contacts/common/model/account/ExternalAccountType.java +++ b/src/com/android/contacts/common/model/account/ExternalAccountType.java @@ -366,17 +366,16 @@ public class ExternalAccountType extends BaseAccountType { final DataKind kind = new DataKind(); kind.mimeType = a - .getString(com.android.internal.R.styleable.ContactsDataKind_mimeType); - + .getString(android.R.styleable.ContactsDataKind_mimeType); final String summaryColumn = a.getString( - com.android.internal.R.styleable.ContactsDataKind_summaryColumn); + android.R.styleable.ContactsDataKind_summaryColumn); if (summaryColumn != null) { // Inflate a specific column as summary when requested kind.actionHeader = new SimpleInflater(summaryColumn); } final String detailColumn = a.getString( - com.android.internal.R.styleable.ContactsDataKind_detailColumn); + android.R.styleable.ContactsDataKind_detailColumn); if (detailColumn != null) { // Inflate specific column as summary diff --git a/src/com/android/contacts/common/model/dataitem/DataItem.java b/src/com/android/contacts/common/model/dataitem/DataItem.java new file mode 100644 index 00000000..60a006f8 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/DataItem.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Event; +import android.provider.ContactsContract.CommonDataKinds.GroupMembership; +import android.provider.ContactsContract.CommonDataKinds.Identity; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.CommonDataKinds.Nickname; +import android.provider.ContactsContract.CommonDataKinds.Note; +import android.provider.ContactsContract.CommonDataKinds.Organization; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.Photo; +import android.provider.ContactsContract.CommonDataKinds.Relation; +import android.provider.ContactsContract.CommonDataKinds.SipAddress; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; +import android.provider.ContactsContract.CommonDataKinds.Website; +import android.provider.ContactsContract.Contacts.Data; + +import com.android.contacts.common.model.dataitem.DataKind; + +/** + * This is the base class for data items, which represents a row from the Data table. + */ +public class DataItem { + + private final ContentValues mContentValues; + + protected DataItem(ContentValues values) { + mContentValues = values; + } + + /** + * Factory for creating subclasses of DataItem objects based on the mimetype in the + * content values. Raw contact is the raw contact that this data item is associated with. + */ + public static DataItem createFrom(ContentValues values) { + final String mimeType = values.getAsString(Data.MIMETYPE); + if (GroupMembership.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new GroupMembershipDataItem(values); + } else if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new StructuredNameDataItem(values); + } else if (Phone.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new PhoneDataItem(values); + } else if (Email.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new EmailDataItem(values); + } else if (StructuredPostal.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new StructuredPostalDataItem(values); + } else if (Im.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new ImDataItem(values); + } else if (Organization.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new OrganizationDataItem(values); + } else if (Nickname.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new NicknameDataItem(values); + } else if (Note.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new NoteDataItem(values); + } else if (Website.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new WebsiteDataItem(values); + } else if (SipAddress.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new SipAddressDataItem(values); + } else if (Event.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new EventDataItem(values); + } else if (Relation.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new RelationDataItem(values); + } else if (Identity.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new IdentityDataItem(values); + } else if (Photo.CONTENT_ITEM_TYPE.equals(mimeType)) { + return new PhotoDataItem(values); + } + + // generic + return new DataItem(values); + } + + public ContentValues getContentValues() { + return mContentValues; + } + + public void setRawContactId(long rawContactId) { + mContentValues.put(Data.RAW_CONTACT_ID, rawContactId); + } + + /** + * Returns the data id. + */ + public long getId() { + return mContentValues.getAsLong(Data._ID); + } + + /** + * Returns the mimetype of the data. + */ + public String getMimeType() { + return mContentValues.getAsString(Data.MIMETYPE); + } + + public void setMimeType(String mimeType) { + mContentValues.put(Data.MIMETYPE, mimeType); + } + + public boolean isPrimary() { + Integer primary = mContentValues.getAsInteger(Data.IS_PRIMARY); + return primary != null && primary != 0; + } + + public boolean isSuperPrimary() { + Integer superPrimary = mContentValues.getAsInteger(Data.IS_SUPER_PRIMARY); + return superPrimary != null && superPrimary != 0; + } + + public boolean hasKindTypeColumn(DataKind kind) { + final String key = kind.typeColumn; + return key != null && mContentValues.containsKey(key) && + mContentValues.getAsInteger(key) != null; + } + + public int getKindTypeColumn(DataKind kind) { + final String key = kind.typeColumn; + return mContentValues.getAsInteger(key); + } + + /** + * This builds the data string depending on the type of data item by using the generic + * DataKind object underneath. + */ + public String buildDataString(Context context, DataKind kind) { + if (kind.actionBody == null) { + return null; + } + CharSequence actionBody = kind.actionBody.inflateUsing(context, mContentValues); + return actionBody == null ? null : actionBody.toString(); + } + + /** + * This builds the data string(intended for display) depending on the type of data item. It + * returns the same value as {@link #buildDataString} by default, but certain data items can + * override it to provide their version of formatted data strings. + * + * @return Data string representing the data item, possibly formatted for display + */ + public String buildDataStringForDisplay(Context context, DataKind kind) { + return buildDataString(context, kind); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/EmailDataItem.java b/src/com/android/contacts/common/model/dataitem/EmailDataItem.java new file mode 100644 index 00000000..23efb015 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/EmailDataItem.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Email; + +/** + * Represents an email data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.Email}. + */ +public class EmailDataItem extends DataItem { + + /* package */ EmailDataItem(ContentValues values) { + super(values); + } + + public String getAddress() { + return getContentValues().getAsString(Email.ADDRESS); + } + + public String getDisplayName() { + return getContentValues().getAsString(Email.DISPLAY_NAME); + } + + public String getData() { + return getContentValues().getAsString(Email.DATA); + } + + public String getLabel() { + return getContentValues().getAsString(Email.LABEL); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/EventDataItem.java b/src/com/android/contacts/common/model/dataitem/EventDataItem.java new file mode 100644 index 00000000..e664db18 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/EventDataItem.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Event; + +/** + * Represents an event data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.Event}. + */ +public class EventDataItem extends DataItem { + + /* package */ EventDataItem(ContentValues values) { + super(values); + } + + public String getStartDate() { + return getContentValues().getAsString(Event.START_DATE); + } + + public String getLabel() { + return getContentValues().getAsString(Event.LABEL); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/GroupMembershipDataItem.java b/src/com/android/contacts/common/model/dataitem/GroupMembershipDataItem.java new file mode 100644 index 00000000..41f19e65 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/GroupMembershipDataItem.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.GroupMembership; + +/** + * Represents a group memebership data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.GroupMembership}. + */ +public class GroupMembershipDataItem extends DataItem { + + /* package */ GroupMembershipDataItem(ContentValues values) { + super(values); + } + + public Long getGroupRowId() { + return getContentValues().getAsLong(GroupMembership.GROUP_ROW_ID); + } + + public String getGroupSourceId() { + return getContentValues().getAsString(GroupMembership.GROUP_SOURCE_ID); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/IdentityDataItem.java b/src/com/android/contacts/common/model/dataitem/IdentityDataItem.java new file mode 100644 index 00000000..29e9a401 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/IdentityDataItem.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Identity; + +/** + * Represents an identity data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.Identity}. + */ +public class IdentityDataItem extends DataItem { + + /* package */ IdentityDataItem(ContentValues values) { + super(values); + } + + public String getIdentity() { + return getContentValues().getAsString(Identity.IDENTITY); + } + + public String getNamespace() { + return getContentValues().getAsString(Identity.NAMESPACE); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/ImDataItem.java b/src/com/android/contacts/common/model/dataitem/ImDataItem.java new file mode 100644 index 00000000..532b89f1 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/ImDataItem.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Im; + +/** + * Represents an IM data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.Im}. + */ +public class ImDataItem extends DataItem { + + private final boolean mCreatedFromEmail; + + /* package */ ImDataItem(ContentValues values) { + super(values); + mCreatedFromEmail = false; + } + + private ImDataItem(ContentValues values, boolean createdFromEmail) { + super(values); + mCreatedFromEmail = createdFromEmail; + } + + public static ImDataItem createFromEmail(EmailDataItem item) { + ImDataItem im = new ImDataItem(new ContentValues(item.getContentValues()), true); + im.setMimeType(Im.CONTENT_ITEM_TYPE); + return im; + } + + public String getData() { + if (mCreatedFromEmail) { + return getContentValues().getAsString(Email.DATA); + } else { + return getContentValues().getAsString(Im.DATA); + } + } + + public String getLabel() { + return getContentValues().getAsString(Im.LABEL); + } + + /** + * Values are one of Im.PROTOCOL_ + */ + public Integer getProtocol() { + return getContentValues().getAsInteger(Im.PROTOCOL); + } + + public boolean isProtocolValid() { + return getProtocol() != null; + } + + public String getCustomProtocol() { + return getContentValues().getAsString(Im.CUSTOM_PROTOCOL); + } + + public int getChatCapability() { + Integer result = getContentValues().getAsInteger(Im.CHAT_CAPABILITY); + return result == null ? 0 : result; + } + + public boolean isCreatedFromEmail() { + return mCreatedFromEmail; + } +} diff --git a/src/com/android/contacts/common/model/dataitem/NicknameDataItem.java b/src/com/android/contacts/common/model/dataitem/NicknameDataItem.java new file mode 100644 index 00000000..e7f9d4a5 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/NicknameDataItem.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Nickname; + +/** + * Represents a nickname data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.Nickname}. + */ +public class NicknameDataItem extends DataItem { + + public NicknameDataItem(ContentValues values) { + super(values); + } + + public String getName() { + return getContentValues().getAsString(Nickname.NAME); + } + + public String getLabel() { + return getContentValues().getAsString(Nickname.LABEL); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/NoteDataItem.java b/src/com/android/contacts/common/model/dataitem/NoteDataItem.java new file mode 100644 index 00000000..3d711673 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/NoteDataItem.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Note; + +/** + * Represents a note data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.Note}. + */ +public class NoteDataItem extends DataItem { + + /* package */ NoteDataItem(ContentValues values) { + super(values); + } + + public String getNote() { + return getContentValues().getAsString(Note.NOTE); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/OrganizationDataItem.java b/src/com/android/contacts/common/model/dataitem/OrganizationDataItem.java new file mode 100644 index 00000000..9f4b8d3e --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/OrganizationDataItem.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Organization; + +/** + * Represents an organization data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.Organization}. + */ +public class OrganizationDataItem extends DataItem { + + /* package */ OrganizationDataItem(ContentValues values) { + super(values); + } + + public String getCompany() { + return getContentValues().getAsString(Organization.COMPANY); + } + + public String getLabel() { + return getContentValues().getAsString(Organization.LABEL); + } + + public String getTitle() { + return getContentValues().getAsString(Organization.TITLE); + } + + public String getDepartment() { + return getContentValues().getAsString(Organization.DEPARTMENT); + } + + public String getJobDescription() { + return getContentValues().getAsString(Organization.JOB_DESCRIPTION); + } + + public String getSymbol() { + return getContentValues().getAsString(Organization.SYMBOL); + } + + public String getPhoneticName() { + return getContentValues().getAsString(Organization.PHONETIC_NAME); + } + + public String getOfficeLocation() { + return getContentValues().getAsString(Organization.OFFICE_LOCATION); + } + + public String getPhoneticNameStyle() { + return getContentValues().getAsString(Organization.PHONETIC_NAME_STYLE); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/PhoneDataItem.java b/src/com/android/contacts/common/model/dataitem/PhoneDataItem.java new file mode 100644 index 00000000..f45e025d --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/PhoneDataItem.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.content.Context; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.telephony.PhoneNumberUtils; + +import com.android.contacts.common.model.dataitem.DataKind; + +/** + * Represents a phone data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.Phone}. + */ +public class PhoneDataItem extends DataItem { + + public static final String KEY_FORMATTED_PHONE_NUMBER = "formattedPhoneNumber"; + + /* package */ PhoneDataItem(ContentValues values) { + super(values); + } + + public String getNumber() { + return getContentValues().getAsString(Phone.NUMBER); + } + + /** + * Returns the normalized phone number in E164 format. + */ + public String getNormalizedNumber() { + return getContentValues().getAsString(Phone.NORMALIZED_NUMBER); + } + + public String getFormattedPhoneNumber() { + return getContentValues().getAsString(KEY_FORMATTED_PHONE_NUMBER); + } + + public String getLabel() { + return getContentValues().getAsString(Phone.LABEL); + } + + public void computeFormattedPhoneNumber(String defaultCountryIso) { + final String phoneNumber = getNumber(); + if (phoneNumber != null) { + final String formattedPhoneNumber = PhoneNumberUtils.formatNumber(phoneNumber, + getNormalizedNumber(), defaultCountryIso); + getContentValues().put(KEY_FORMATTED_PHONE_NUMBER, formattedPhoneNumber); + } + } + + /** + * Returns the formatted phone number (if already computed using {@link + * #computeFormattedPhoneNumber}). Otherwise this method returns the unformatted phone number. + */ + @Override + public String buildDataStringForDisplay(Context context, DataKind kind) { + final String formatted = getFormattedPhoneNumber(); + if (formatted != null) { + return formatted; + } else { + return getNumber(); + } + } +} diff --git a/src/com/android/contacts/common/model/dataitem/PhotoDataItem.java b/src/com/android/contacts/common/model/dataitem/PhotoDataItem.java new file mode 100644 index 00000000..a61218b2 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/PhotoDataItem.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts.Photo; + +/** + * Represents a photo data item, wrapping the columns in + * {@link ContactsContract.Contacts.Photo}. + */ +public class PhotoDataItem extends DataItem { + + /* package */ PhotoDataItem(ContentValues values) { + super(values); + } + + public Long getPhotoFileId() { + return getContentValues().getAsLong(Photo.PHOTO_FILE_ID); + } + + public byte[] getPhoto() { + return getContentValues().getAsByteArray(Photo.PHOTO); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/RelationDataItem.java b/src/com/android/contacts/common/model/dataitem/RelationDataItem.java new file mode 100644 index 00000000..b6992978 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/RelationDataItem.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Relation; + +/** + * Represents a relation data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.Relation}. + */ +public class RelationDataItem extends DataItem { + + /* package */ RelationDataItem(ContentValues values) { + super(values); + } + + public String getName() { + return getContentValues().getAsString(Relation.NAME); + } + + public String getLabel() { + return getContentValues().getAsString(Relation.LABEL); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/SipAddressDataItem.java b/src/com/android/contacts/common/model/dataitem/SipAddressDataItem.java new file mode 100644 index 00000000..ec704fc3 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/SipAddressDataItem.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.SipAddress; + +/** + * Represents a sip address data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.SipAddress}. + */ +public class SipAddressDataItem extends DataItem { + + /* package */ SipAddressDataItem(ContentValues values) { + super(values); + } + + public String getSipAddress() { + return getContentValues().getAsString(SipAddress.SIP_ADDRESS); + } + + public String getLabel() { + return getContentValues().getAsString(SipAddress.LABEL); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/StructuredNameDataItem.java b/src/com/android/contacts/common/model/dataitem/StructuredNameDataItem.java new file mode 100644 index 00000000..ce2c84a9 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/StructuredNameDataItem.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.Contacts.Data; + +/** + * Represents a structured name data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.StructuredName}. + */ +public class StructuredNameDataItem extends DataItem { + + public StructuredNameDataItem() { + super(new ContentValues()); + getContentValues().put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE); + } + + /* package */ StructuredNameDataItem(ContentValues values) { + super(values); + } + + public String getDisplayName() { + return getContentValues().getAsString(StructuredName.DISPLAY_NAME); + } + + public void setDisplayName(String name) { + getContentValues().put(StructuredName.DISPLAY_NAME, name); + } + + public String getGivenName() { + return getContentValues().getAsString(StructuredName.GIVEN_NAME); + } + + public String getFamilyName() { + return getContentValues().getAsString(StructuredName.FAMILY_NAME); + } + + public String getPrefix() { + return getContentValues().getAsString(StructuredName.PREFIX); + } + + public String getMiddleName() { + return getContentValues().getAsString(StructuredName.MIDDLE_NAME); + } + + public String getSuffix() { + return getContentValues().getAsString(StructuredName.SUFFIX); + } + + public String getPhoneticGivenName() { + return getContentValues().getAsString(StructuredName.PHONETIC_GIVEN_NAME); + } + + public String getPhoneticMiddleName() { + return getContentValues().getAsString(StructuredName.PHONETIC_MIDDLE_NAME); + } + + public String getPhoneticFamilyName() { + return getContentValues().getAsString(StructuredName.PHONETIC_FAMILY_NAME); + } + + public String getFullNameStyle() { + return getContentValues().getAsString(StructuredName.FULL_NAME_STYLE); + } + + public String getPhoneticNameStyle() { + return getContentValues().getAsString(StructuredName.PHONETIC_NAME_STYLE); + } + + public void setPhoneticFamilyName(String name) { + getContentValues().put(StructuredName.PHONETIC_FAMILY_NAME, name); + } + + public void setPhoneticMiddleName(String name) { + getContentValues().put(StructuredName.PHONETIC_MIDDLE_NAME, name); + } + + public void setPhoneticGivenName(String name) { + getContentValues().put(StructuredName.PHONETIC_GIVEN_NAME, name); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/StructuredPostalDataItem.java b/src/com/android/contacts/common/model/dataitem/StructuredPostalDataItem.java new file mode 100644 index 00000000..6cfc0c16 --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/StructuredPostalDataItem.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; + +/** + * Represents a structured postal data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.StructuredPostal}. + */ +public class StructuredPostalDataItem extends DataItem { + + /* package */ StructuredPostalDataItem(ContentValues values) { + super(values); + } + + public String getFormattedAddress() { + return getContentValues().getAsString(StructuredPostal.FORMATTED_ADDRESS); + } + + public String getLabel() { + return getContentValues().getAsString(StructuredPostal.LABEL); + } + + public String getStreet() { + return getContentValues().getAsString(StructuredPostal.STREET); + } + + public String getPOBox() { + return getContentValues().getAsString(StructuredPostal.POBOX); + } + + public String getNeighborhood() { + return getContentValues().getAsString(StructuredPostal.NEIGHBORHOOD); + } + + public String getCity() { + return getContentValues().getAsString(StructuredPostal.CITY); + } + + public String getRegion() { + return getContentValues().getAsString(StructuredPostal.REGION); + } + + public String getPostcode() { + return getContentValues().getAsString(StructuredPostal.POSTCODE); + } + + public String getCountry() { + return getContentValues().getAsString(StructuredPostal.COUNTRY); + } +} diff --git a/src/com/android/contacts/common/model/dataitem/WebsiteDataItem.java b/src/com/android/contacts/common/model/dataitem/WebsiteDataItem.java new file mode 100644 index 00000000..0939421e --- /dev/null +++ b/src/com/android/contacts/common/model/dataitem/WebsiteDataItem.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.model.dataitem; + +import android.content.ContentValues; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Website; + +/** + * Represents a website data item, wrapping the columns in + * {@link ContactsContract.CommonDataKinds.Website}. + */ +public class WebsiteDataItem extends DataItem { + + /* package */ WebsiteDataItem(ContentValues values) { + super(values); + } + + public String getUrl() { + return getContentValues().getAsString(Website.URL); + } + + public String getLabel() { + return getContentValues().getAsString(Website.LABEL); + } +} diff --git a/src/com/android/contacts/common/test/InjectedServices.java b/src/com/android/contacts/common/test/InjectedServices.java new file mode 100644 index 00000000..75ad9380 --- /dev/null +++ b/src/com/android/contacts/common/test/InjectedServices.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.test; + +import android.content.ContentResolver; +import android.content.SharedPreferences; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Maps; + +import java.util.HashMap; + +/** + * A mechanism for providing alternative (mock) services to the application + * while running tests. Activities, Services and the Application should check + * with this class to see if a particular service has been overridden. + */ +public class InjectedServices { + + private ContentResolver mContentResolver; + private SharedPreferences mSharedPreferences; + private HashMap<String, Object> mSystemServices; + + @VisibleForTesting + public void setContentResolver(ContentResolver contentResolver) { + this.mContentResolver = contentResolver; + } + + public ContentResolver getContentResolver() { + return mContentResolver; + } + + @VisibleForTesting + public void setSharedPreferences(SharedPreferences sharedPreferences) { + this.mSharedPreferences = sharedPreferences; + } + + public SharedPreferences getSharedPreferences() { + return mSharedPreferences; + } + + @VisibleForTesting + public void setSystemService(String name, Object service) { + if (mSystemServices == null) { + mSystemServices = Maps.newHashMap(); + } + + mSystemServices.put(name, service); + } + + public Object getSystemService(String name) { + if (mSystemServices != null) { + return mSystemServices.get(name); + } + return null; + } +} diff --git a/src/com/android/contacts/common/util/CommonDateUtils.java b/src/com/android/contacts/common/util/CommonDateUtils.java index 5dfd149a..bba910ac 100644 --- a/src/com/android/contacts/common/util/CommonDateUtils.java +++ b/src/com/android/contacts/common/util/CommonDateUtils.java @@ -33,4 +33,9 @@ public class CommonDateUtils { new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); public static final SimpleDateFormat NO_YEAR_DATE_AND_TIME_FORMAT = new SimpleDateFormat("--MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + + /** + * Exchange requires 8:00 for birthdays + */ + public final static int DEFAULT_HOUR = 8; } diff --git a/src/com/android/contacts/common/util/ContactLoaderUtils.java b/src/com/android/contacts/common/util/ContactLoaderUtils.java new file mode 100644 index 00000000..0ec8887a --- /dev/null +++ b/src/com/android/contacts/common/util/ContactLoaderUtils.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.util; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.net.Uri; +import android.provider.Contacts; +import android.provider.ContactsContract; +import android.provider.ContactsContract.RawContacts; + +/** + * Utility methods for the {@link ContactLoader}. + */ +public final class ContactLoaderUtils { + + /** Static helper, not instantiable. */ + private ContactLoaderUtils() {} + + /** + * Transforms the given Uri and returns a Lookup-Uri that represents the contact. + * For legacy contacts, a raw-contact lookup is performed. An {@link IllegalArgumentException} + * can be thrown if the URI is null or the authority is not recognized. + * + * Do not call from the UI thread. + */ + @SuppressWarnings("deprecation") + public static Uri ensureIsContactUri(final ContentResolver resolver, final Uri uri) + throws IllegalArgumentException { + if (uri == null) throw new IllegalArgumentException("uri must not be null"); + + final String authority = uri.getAuthority(); + + // Current Style Uri? + if (ContactsContract.AUTHORITY.equals(authority)) { + final String type = resolver.getType(uri); + // Contact-Uri? Good, return it + if (ContactsContract.Contacts.CONTENT_ITEM_TYPE.equals(type)) { + return uri; + } + + // RawContact-Uri? Transform it to ContactUri + if (RawContacts.CONTENT_ITEM_TYPE.equals(type)) { + final long rawContactId = ContentUris.parseId(uri); + return RawContacts.getContactLookupUri(resolver, + ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId)); + } + + // Anything else? We don't know what this is + throw new IllegalArgumentException("uri format is unknown"); + } + + // Legacy Style? Convert to RawContact + final String OBSOLETE_AUTHORITY = Contacts.AUTHORITY; + if (OBSOLETE_AUTHORITY.equals(authority)) { + // Legacy Format. Convert to RawContact-Uri and then lookup the contact + final long rawContactId = ContentUris.parseId(uri); + return RawContacts.getContactLookupUri(resolver, + ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId)); + } + + throw new IllegalArgumentException("uri authority is unknown"); + } +} diff --git a/src/com/android/contacts/common/util/DataStatus.java b/src/com/android/contacts/common/util/DataStatus.java new file mode 100644 index 00000000..76f11b65 --- /dev/null +++ b/src/com/android/contacts/common/util/DataStatus.java @@ -0,0 +1,165 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.util; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.graphics.drawable.Drawable; +import android.provider.ContactsContract.Data; +import android.text.TextUtils; +import android.text.format.DateUtils; + +import com.android.contacts.common.R; + +/** + * Storage for a social status update. Holds a single update, but can use + * {@link #possibleUpdate(Cursor)} to consider updating when a better status + * exists. Statuses with timestamps, or with newer timestamps win. + */ +public class DataStatus { + private int mPresence = -1; + private String mStatus = null; + private long mTimestamp = -1; + + private String mResPackage = null; + private int mIconRes = -1; + private int mLabelRes = -1; + + public DataStatus() { + } + + public DataStatus(Cursor cursor) { + // When creating from cursor row, fill normally + fromCursor(cursor); + } + + /** + * Attempt updating this {@link DataStatus} based on values at the + * current row of the given {@link Cursor}. + */ + public void possibleUpdate(Cursor cursor) { + final boolean hasStatus = !isNull(cursor, Data.STATUS); + final boolean hasTimestamp = !isNull(cursor, Data.STATUS_TIMESTAMP); + + // Bail early when not valid status, or when previous status was + // found and we can't compare this one. + if (!hasStatus) return; + if (isValid() && !hasTimestamp) return; + + if (hasTimestamp) { + // Compare timestamps and bail if older status + final long newTimestamp = getLong(cursor, Data.STATUS_TIMESTAMP, -1); + if (newTimestamp < mTimestamp) return; + + mTimestamp = newTimestamp; + } + + // Fill in remaining details from cursor + fromCursor(cursor); + } + + private void fromCursor(Cursor cursor) { + mPresence = getInt(cursor, Data.PRESENCE, -1); + mStatus = getString(cursor, Data.STATUS); + mTimestamp = getLong(cursor, Data.STATUS_TIMESTAMP, -1); + mResPackage = getString(cursor, Data.STATUS_RES_PACKAGE); + mIconRes = getInt(cursor, Data.STATUS_ICON, -1); + mLabelRes = getInt(cursor, Data.STATUS_LABEL, -1); + } + + public boolean isValid() { + return !TextUtils.isEmpty(mStatus); + } + + public int getPresence() { + return mPresence; + } + + public CharSequence getStatus() { + return mStatus; + } + + public long getTimestamp() { + return mTimestamp; + } + + /** + * Build any timestamp and label into a single string. + */ + public CharSequence getTimestampLabel(Context context) { + final PackageManager pm = context.getPackageManager(); + + // Use local package for resources when none requested + if (mResPackage == null) mResPackage = context.getPackageName(); + + final boolean validTimestamp = mTimestamp > 0; + final boolean validLabel = mResPackage != null && mLabelRes != -1; + + final CharSequence timeClause = validTimestamp ? DateUtils.getRelativeTimeSpanString( + mTimestamp, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE) : null; + final CharSequence labelClause = validLabel ? pm.getText(mResPackage, mLabelRes, + null) : null; + + if (validTimestamp && validLabel) { + return context.getString( + R.string.contact_status_update_attribution_with_date, + timeClause, labelClause); + } else if (validLabel) { + return context.getString( + R.string.contact_status_update_attribution, + labelClause); + } else if (validTimestamp) { + return timeClause; + } else { + return null; + } + } + + public Drawable getIcon(Context context) { + final PackageManager pm = context.getPackageManager(); + + // Use local package for resources when none requested + if (mResPackage == null) mResPackage = context.getPackageName(); + + final boolean validIcon = mResPackage != null && mIconRes != -1; + return validIcon ? pm.getDrawable(mResPackage, mIconRes, null) : null; + } + + private static String getString(Cursor cursor, String columnName) { + return cursor.getString(cursor.getColumnIndex(columnName)); + } + + private static int getInt(Cursor cursor, String columnName) { + return cursor.getInt(cursor.getColumnIndex(columnName)); + } + + private static int getInt(Cursor cursor, String columnName, int missingValue) { + final int columnIndex = cursor.getColumnIndex(columnName); + return cursor.isNull(columnIndex) ? missingValue : cursor.getInt(columnIndex); + } + + private static long getLong(Cursor cursor, String columnName, long missingValue) { + final int columnIndex = cursor.getColumnIndex(columnName); + return cursor.isNull(columnIndex) ? missingValue : cursor.getLong(columnIndex); + } + + private static boolean isNull(Cursor cursor, String columnName) { + return cursor.isNull(cursor.getColumnIndex(columnName)); + } +} diff --git a/src/com/android/contacts/common/util/DateUtils.java b/src/com/android/contacts/common/util/DateUtils.java new file mode 100644 index 00000000..f527eb95 --- /dev/null +++ b/src/com/android/contacts/common/util/DateUtils.java @@ -0,0 +1,271 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.util; + +import android.content.Context; +import android.text.format.DateFormat; + + +import java.text.ParsePosition; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; + +/** + * Utility methods for processing dates. + */ +public class DateUtils { + public static final TimeZone UTC_TIMEZONE = TimeZone.getTimeZone("UTC"); + + /** + * When parsing a date without a year, the system assumes 1970, which wasn't a leap-year. + * Let's add a one-off hack for that day of the year + */ + public static final String NO_YEAR_DATE_FEB29TH = "--02-29"; + + // Variations of ISO 8601 date format. Do not change the order - it does affect the + // result in ambiguous cases. + private static final SimpleDateFormat[] DATE_FORMATS = { + CommonDateUtils.FULL_DATE_FORMAT, + CommonDateUtils.DATE_AND_TIME_FORMAT, + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'", Locale.US), + new SimpleDateFormat("yyyyMMdd", Locale.US), + new SimpleDateFormat("yyyyMMdd'T'HHmmssSSS'Z'", Locale.US), + new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US), + new SimpleDateFormat("yyyyMMdd'T'HHmm'Z'", Locale.US), + }; + + static { + for (SimpleDateFormat format : DATE_FORMATS) { + format.setLenient(true); + format.setTimeZone(UTC_TIMEZONE); + } + CommonDateUtils.NO_YEAR_DATE_FORMAT.setTimeZone(UTC_TIMEZONE); + } + + /** + * Parses the supplied string to see if it looks like a date. + * + * @param string The string representation of the provided date + * @param mustContainYear If true, the string is parsed as a date containing a year. If false, + * the string is parsed into a valid date even if the year field is missing. + * @return A Calendar object corresponding to the date if the string is successfully parsed. + * If not, null is returned. + */ + public static Calendar parseDate(String string, boolean mustContainYear) { + ParsePosition parsePosition = new ParsePosition(0); + Date date; + if (!mustContainYear) { + final boolean noYearParsed; + // Unfortunately, we can't parse Feb 29th correctly, so let's handle this day seperately + if (NO_YEAR_DATE_FEB29TH.equals(string)) { + return getUtcDate(0, Calendar.FEBRUARY, 29); + } else { + synchronized (CommonDateUtils.NO_YEAR_DATE_FORMAT) { + date = CommonDateUtils.NO_YEAR_DATE_FORMAT.parse(string, parsePosition); + } + noYearParsed = parsePosition.getIndex() == string.length(); + } + + if (noYearParsed) { + return getUtcDate(date, true); + } + } + for (int i = 0; i < DATE_FORMATS.length; i++) { + SimpleDateFormat f = DATE_FORMATS[i]; + synchronized (f) { + parsePosition.setIndex(0); + date = f.parse(string, parsePosition); + if (parsePosition.getIndex() == string.length()) { + return getUtcDate(date, false); + } + } + } + return null; + } + + private static final Calendar getUtcDate(Date date, boolean noYear) { + final Calendar calendar = Calendar.getInstance(UTC_TIMEZONE, Locale.US); + calendar.setTime(date); + if (noYear) { + calendar.set(Calendar.YEAR, 0); + } + return calendar; + } + + private static final Calendar getUtcDate(int year, int month, int dayOfMonth) { + final Calendar calendar = Calendar.getInstance(UTC_TIMEZONE, Locale.US); + calendar.clear(); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month); + calendar.set(Calendar.DAY_OF_MONTH, dayOfMonth); + return calendar; + } + + public static boolean isYearSet(Calendar cal) { + // use the Calendar.YEAR field to track whether or not the year is set instead of + // Calendar.isSet() because doing Calendar.get() causes Calendar.isSet() to become + // true irregardless of what the previous value was + return cal.get(Calendar.YEAR) > 1; + } + + /** + * Same as {@link #formatDate(Context context, String string, boolean longForm)}, with + * longForm set to {@code true} by default. + * + * @param context Valid context + * @param string String representation of a date to parse + * @return Returns the same date in a cleaned up format. If the supplied string does not look + * like a date, return it unchanged. + */ + + public static String formatDate(Context context, String string) { + return formatDate(context, string, true); + } + + /** + * Parses the supplied string to see if it looks like a date. + * + * @param context Valid context + * @param string String representation of a date to parse + * @param longForm If true, return the date formatted into its long string representation. + * If false, return the date formatted using its short form representation (i.e. 12/11/2012) + * @return Returns the same date in a cleaned up format. If the supplied string does not look + * like a date, return it unchanged. + */ + public static String formatDate(Context context, String string, boolean longForm) { + if (string == null) { + return null; + } + + string = string.trim(); + if (string.length() == 0) { + return string; + } + final Calendar cal = parseDate(string, false); + + // we weren't able to parse the string successfully so just return it unchanged + if (cal == null) { + return string; + } + + final boolean isYearSet = isYearSet(cal); + final java.text.DateFormat outFormat; + if (!isYearSet) { + outFormat = getLocalizedDateFormatWithoutYear(context); + } else { + outFormat = + longForm ? DateFormat.getLongDateFormat(context) : + DateFormat.getDateFormat(context); + } + synchronized (outFormat) { + outFormat.setTimeZone(UTC_TIMEZONE); + return outFormat.format(cal.getTime()); + } + } + + public static boolean isMonthBeforeDay(Context context) { + char[] dateFormatOrder = DateFormat.getDateFormatOrder(context); + for (int i = 0; i < dateFormatOrder.length; i++) { + if (dateFormatOrder[i] == DateFormat.DATE) { + return false; + } + if (dateFormatOrder[i] == DateFormat.MONTH) { + return true; + } + } + return false; + } + + /** + * Returns a SimpleDateFormat object without the year fields by using a regular expression + * to eliminate the year in the string pattern. In the rare occurence that the resulting + * pattern cannot be reconverted into a SimpleDateFormat, it uses the provided context to + * determine whether the month field should be displayed before the day field, and returns + * either "MMMM dd" or "dd MMMM" converted into a SimpleDateFormat. + */ + public static java.text.DateFormat getLocalizedDateFormatWithoutYear(Context context) { + final String pattern = ((SimpleDateFormat) SimpleDateFormat.getDateInstance( + java.text.DateFormat.LONG)).toPattern(); + // Determine the correct regex pattern for year. + // Special case handling for Spanish locale by checking for "de" + final String yearPattern = pattern.contains( + "de") ? "[^Mm]*[Yy]+[^Mm]*" : "[^DdMm]*[Yy]+[^DdMm]*"; + try { + // Eliminate the substring in pattern that matches the format for that of year + return new SimpleDateFormat(pattern.replaceAll(yearPattern, "")); + } catch (IllegalArgumentException e) { + return new SimpleDateFormat( + DateUtils.isMonthBeforeDay(context) ? "MMMM dd" : "dd MMMM"); + } + } + + /** + * Given a calendar (possibly containing only a day of the year), returns the earliest possible + * anniversary of the date that is equal to or after the current point in time if the date + * does not contain a year, or the date converted to the local time zone (if the date contains + * a year. + * + * @param target The date we wish to convert(in the UTC time zone). + * @return If date does not contain a year (year < 1900), returns the next earliest anniversary + * that is after the current point in time (in the local time zone). Otherwise, returns the + * adjusted Date in the local time zone. + */ + public static Date getNextAnnualDate(Calendar target) { + final Calendar today = Calendar.getInstance(); + today.setTime(new Date()); + + // Round the current time to the exact start of today so that when we compare + // today against the target date, both dates are set to exactly 0000H. + today.set(Calendar.HOUR_OF_DAY, 0); + today.set(Calendar.MINUTE, 0); + today.set(Calendar.SECOND, 0); + today.set(Calendar.MILLISECOND, 0); + + final boolean isYearSet = isYearSet(target); + final int targetYear = target.get(Calendar.YEAR); + final int targetMonth = target.get(Calendar.MONTH); + final int targetDay = target.get(Calendar.DAY_OF_MONTH); + final boolean isFeb29 = (targetMonth == Calendar.FEBRUARY && targetDay == 29); + final GregorianCalendar anniversary = new GregorianCalendar(); + // Convert from the UTC date to the local date. Set the year to today's year if the + // there is no provided year (targetYear < 1900) + anniversary.set(!isYearSet ? today.get(Calendar.YEAR) : targetYear, + targetMonth, targetDay); + // If the anniversary's date is before the start of today and there is no year set, + // increment the year by 1 so that the returned date is always equal to or greater than + // today. If the day is a leap year, keep going until we get the next leap year anniversary + // Otherwise if there is already a year set, simply return the exact date. + if (!isYearSet) { + int anniversaryYear = today.get(Calendar.YEAR); + if (anniversary.before(today) || + (isFeb29 && !anniversary.isLeapYear(anniversaryYear))) { + // If the target date is not Feb 29, then set the anniversary to the next year. + // Otherwise, keep going until we find the next leap year (this is not guaranteed + // to be in 4 years time). + do { + anniversaryYear +=1; + } while (isFeb29 && !anniversary.isLeapYear(anniversaryYear)); + anniversary.set(anniversaryYear, targetMonth, targetDay); + } + } + return anniversary.getTime(); + } +} diff --git a/src/com/android/contacts/common/util/NameConverter.java b/src/com/android/contacts/common/util/NameConverter.java new file mode 100644 index 00000000..56f31924 --- /dev/null +++ b/src/com/android/contacts/common/util/NameConverter.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.util; + + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.net.Uri.Builder; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.text.TextUtils; + +import com.android.contacts.common.model.dataitem.StructuredNameDataItem; + +import java.util.Map; +import java.util.TreeMap; + +/** + * Utility class for converting between a display name and structured name (and vice-versa), via + * calls to the contact provider. + */ +public class NameConverter { + + /** + * The array of fields that comprise a structured name. + */ + public static final String[] STRUCTURED_NAME_FIELDS = new String[] { + StructuredName.PREFIX, + StructuredName.GIVEN_NAME, + StructuredName.MIDDLE_NAME, + StructuredName.FAMILY_NAME, + StructuredName.SUFFIX + }; + + /** + * Converts the given structured name (provided as a map from {@link StructuredName} fields to + * corresponding values) into a display name string. + * <p> + * Note that this operates via a call back to the ContactProvider, but it does not access the + * database, so it should be safe to call from the UI thread. See + * ContactsProvider2.completeName() for the underlying method call. + * @param context Activity context. + * @param structuredName The structured name map to convert. + * @return The display name computed from the structured name map. + */ + public static String structuredNameToDisplayName(Context context, + Map<String, String> structuredName) { + Builder builder = ContactsContract.AUTHORITY_URI.buildUpon().appendPath("complete_name"); + for (String key : STRUCTURED_NAME_FIELDS) { + if (structuredName.containsKey(key)) { + appendQueryParameter(builder, key, structuredName.get(key)); + } + } + return fetchDisplayName(context, builder.build()); + } + + /** + * Converts the given structured name (provided as ContentValues) into a display name string. + * @param context Activity context. + * @param values The content values containing values comprising the structured name. + * @return + */ + public static String structuredNameToDisplayName(Context context, ContentValues values) { + Builder builder = ContactsContract.AUTHORITY_URI.buildUpon().appendPath("complete_name"); + for (String key : STRUCTURED_NAME_FIELDS) { + if (values.containsKey(key)) { + appendQueryParameter(builder, key, values.getAsString(key)); + } + } + return fetchDisplayName(context, builder.build()); + } + + /** + * Helper method for fetching the display name via the given URI. + */ + private static String fetchDisplayName(Context context, Uri uri) { + String displayName = null; + Cursor cursor = context.getContentResolver().query(uri, new String[]{ + StructuredName.DISPLAY_NAME, + }, null, null, null); + + try { + if (cursor.moveToFirst()) { + displayName = cursor.getString(0); + } + } finally { + cursor.close(); + } + return displayName; + } + + /** + * Converts the given display name string into a structured name (as a map from + * {@link StructuredName} fields to corresponding values). + * <p> + * Note that this operates via a call back to the ContactProvider, but it does not access the + * database, so it should be safe to call from the UI thread. + * @param context Activity context. + * @param displayName The display name to convert. + * @return The structured name map computed from the display name. + */ + public static Map<String, String> displayNameToStructuredName(Context context, + String displayName) { + Map<String, String> structuredName = new TreeMap<String, String>(); + Builder builder = ContactsContract.AUTHORITY_URI.buildUpon().appendPath("complete_name"); + + appendQueryParameter(builder, StructuredName.DISPLAY_NAME, displayName); + Cursor cursor = context.getContentResolver().query(builder.build(), STRUCTURED_NAME_FIELDS, + null, null, null); + + try { + if (cursor.moveToFirst()) { + for (int i = 0; i < STRUCTURED_NAME_FIELDS.length; i++) { + structuredName.put(STRUCTURED_NAME_FIELDS[i], cursor.getString(i)); + } + } + } finally { + cursor.close(); + } + return structuredName; + } + + /** + * Converts the given display name string into a structured name (inserting the structured + * values into a new or existing ContentValues object). + * <p> + * Note that this operates via a call back to the ContactProvider, but it does not access the + * database, so it should be safe to call from the UI thread. + * @param context Activity context. + * @param displayName The display name to convert. + * @param contentValues The content values object to place the structured name values into. If + * null, a new one will be created and returned. + * @return The ContentValues object containing the structured name fields derived from the + * display name. + */ + public static ContentValues displayNameToStructuredName(Context context, String displayName, + ContentValues contentValues) { + if (contentValues == null) { + contentValues = new ContentValues(); + } + Map<String, String> mapValues = displayNameToStructuredName(context, displayName); + for (String key : mapValues.keySet()) { + contentValues.put(key, mapValues.get(key)); + } + return contentValues; + } + + private static void appendQueryParameter(Builder builder, String field, String value) { + if (!TextUtils.isEmpty(value)) { + builder.appendQueryParameter(field, value); + } + } + + /** + * Parses phonetic name and returns parsed data (family, middle, given) as ContentValues. + * Parsed data should be {@link StructuredName#PHONETIC_FAMILY_NAME}, + * {@link StructuredName#PHONETIC_MIDDLE_NAME}, and + * {@link StructuredName#PHONETIC_GIVEN_NAME}. + * If this method cannot parse given phoneticName, null values will be stored. + * + * @param phoneticName Phonetic name to be parsed + * @param values ContentValues to be used for storing data. If null, new instance will be + * created. + * @return ContentValues with parsed data. Those data can be null. + */ + public static StructuredNameDataItem parsePhoneticName(String phoneticName, + StructuredNameDataItem item) { + String family = null; + String middle = null; + String given = null; + + if (!TextUtils.isEmpty(phoneticName)) { + String[] strings = phoneticName.split(" ", 3); + switch (strings.length) { + case 1: + family = strings[0]; + break; + case 2: + family = strings[0]; + given = strings[1]; + break; + case 3: + family = strings[0]; + middle = strings[1]; + given = strings[2]; + break; + } + } + + if (item == null) { + item = new StructuredNameDataItem(); + } + item.setPhoneticFamilyName(family); + item.setPhoneticMiddleName(middle); + item.setPhoneticGivenName(given); + return item; + } + + /** + * Constructs and returns a phonetic full name from given parts. + */ + public static String buildPhoneticName(String family, String middle, String given) { + if (!TextUtils.isEmpty(family) || !TextUtils.isEmpty(middle) + || !TextUtils.isEmpty(given)) { + StringBuilder sb = new StringBuilder(); + if (!TextUtils.isEmpty(family)) { + sb.append(family.trim()).append(' '); + } + if (!TextUtils.isEmpty(middle)) { + sb.append(middle.trim()).append(' '); + } + if (!TextUtils.isEmpty(given)) { + sb.append(given.trim()).append(' '); + } + sb.setLength(sb.length() - 1); // Yank the last space + return sb.toString(); + } else { + return null; + } + } +} diff --git a/src/com/android/contacts/common/util/UriUtils.java b/src/com/android/contacts/common/util/UriUtils.java index 352da489..dbe900b5 100644 --- a/src/com/android/contacts/common/util/UriUtils.java +++ b/src/com/android/contacts/common/util/UriUtils.java @@ -50,6 +50,6 @@ public class UriUtils { } public static boolean isEncodedContactUri(Uri uri) { - return uri.getLastPathSegment().equals(Constants.LOOKUP_URI_ENCODED); + return uri != null && uri.getLastPathSegment().equals(Constants.LOOKUP_URI_ENCODED); } } |