/* * Copyright (C) 2008-2009 Marc Blank * Licensed to The Android Open Source Project. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.exchange.adapter; import com.android.exchange.CommandStatusException; import com.android.exchange.Eas; import com.android.exchange.EasSyncService; import android.content.ContentProviderClient; import android.content.ContentProviderOperation; import android.content.ContentProviderOperation.Builder; import android.content.ContentProviderResult; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Entity; import android.content.Entity.NamedContentValues; import android.content.EntityIterator; import android.content.OperationApplicationException; import android.database.Cursor; import android.net.Uri; import android.os.RemoteException; import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds.Email; import android.provider.ContactsContract.CommonDataKinds.Event; import android.provider.ContactsContract.CommonDataKinds.GroupMembership; import android.provider.ContactsContract.CommonDataKinds.Im; import android.provider.ContactsContract.CommonDataKinds.Nickname; import android.provider.ContactsContract.CommonDataKinds.Note; import android.provider.ContactsContract.CommonDataKinds.Organization; import android.provider.ContactsContract.CommonDataKinds.Phone; import android.provider.ContactsContract.CommonDataKinds.Photo; import android.provider.ContactsContract.CommonDataKinds.Relation; 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.Groups; import android.provider.ContactsContract.RawContacts; import android.provider.ContactsContract.RawContactsEntity; import android.provider.ContactsContract.Settings; import android.provider.ContactsContract.SyncState; import android.provider.SyncStateContract; import android.text.TextUtils; import android.text.util.Rfc822Token; import android.text.util.Rfc822Tokenizer; import android.util.Base64; import android.util.Log; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; /** * Sync adapter for EAS Contacts * */ public class ContactsSyncAdapter extends AbstractSyncAdapter { private static final String TAG = "EasContactsSyncAdapter"; private static final String SERVER_ID_SELECTION = RawContacts.SOURCE_ID + "=?"; private static final String CLIENT_ID_SELECTION = RawContacts.SYNC1 + "=?"; private static final String[] ID_PROJECTION = new String[] {RawContacts._ID}; private static final String[] GROUP_TITLE_PROJECTION = new String[] {Groups.TITLE}; private static final String MIMETYPE_GROUP_MEMBERSHIP_AND_ID_EQUALS = Data.MIMETYPE + "='" + GroupMembership.CONTENT_ITEM_TYPE + "' AND " + GroupMembership.GROUP_ROW_ID + "=?"; private static final String[] GROUPS_ID_PROJECTION = new String[] {Groups._ID}; private static final ArrayList<NamedContentValues> EMPTY_ARRAY_NAMEDCONTENTVALUES = new ArrayList<NamedContentValues>(); private static final String FOUND_DATA_ROW = "com.android.exchange.FOUND_ROW"; private static final int[] HOME_ADDRESS_TAGS = new int[] {Tags.CONTACTS_HOME_ADDRESS_CITY, Tags.CONTACTS_HOME_ADDRESS_COUNTRY, Tags.CONTACTS_HOME_ADDRESS_POSTAL_CODE, Tags.CONTACTS_HOME_ADDRESS_STATE, Tags.CONTACTS_HOME_ADDRESS_STREET}; private static final int[] WORK_ADDRESS_TAGS = new int[] {Tags.CONTACTS_BUSINESS_ADDRESS_CITY, Tags.CONTACTS_BUSINESS_ADDRESS_COUNTRY, Tags.CONTACTS_BUSINESS_ADDRESS_POSTAL_CODE, Tags.CONTACTS_BUSINESS_ADDRESS_STATE, Tags.CONTACTS_BUSINESS_ADDRESS_STREET}; private static final int[] OTHER_ADDRESS_TAGS = new int[] {Tags.CONTACTS_HOME_ADDRESS_CITY, Tags.CONTACTS_OTHER_ADDRESS_COUNTRY, Tags.CONTACTS_OTHER_ADDRESS_POSTAL_CODE, Tags.CONTACTS_OTHER_ADDRESS_STATE, Tags.CONTACTS_OTHER_ADDRESS_STREET}; private static final int MAX_IM_ROWS = 3; private static final int MAX_EMAIL_ROWS = 3; private static final int MAX_PHONE_ROWS = 2; private static final String COMMON_DATA_ROW = Im.DATA; // Could have been Email.DATA, etc. private static final String COMMON_TYPE_ROW = Phone.TYPE; // Could have been any typed row private static final int[] IM_TAGS = new int[] {Tags.CONTACTS2_IM_ADDRESS, Tags.CONTACTS2_IM_ADDRESS_2, Tags.CONTACTS2_IM_ADDRESS_3}; private static final int[] EMAIL_TAGS = new int[] {Tags.CONTACTS_EMAIL1_ADDRESS, Tags.CONTACTS_EMAIL2_ADDRESS, Tags.CONTACTS_EMAIL3_ADDRESS}; private static final int[] WORK_PHONE_TAGS = new int[] {Tags.CONTACTS_BUSINESS_TELEPHONE_NUMBER, Tags.CONTACTS_BUSINESS2_TELEPHONE_NUMBER}; private static final int[] HOME_PHONE_TAGS = new int[] {Tags.CONTACTS_HOME_TELEPHONE_NUMBER, Tags.CONTACTS_HOME2_TELEPHONE_NUMBER}; private static final Object sSyncKeyLock = new Object(); ArrayList<Long> mDeletedIdList = new ArrayList<Long>(); ArrayList<Long> mUpdatedIdList = new ArrayList<Long>(); private final Uri mAccountUri; private final ContentResolver mContentResolver; private boolean mGroupsUsed = false; public ContactsSyncAdapter(EasSyncService service) { super(service); mAccountUri = uriWithAccountAndIsSyncAdapter(RawContacts.CONTENT_URI); mContentResolver = mContext.getContentResolver(); } static Uri addCallerIsSyncAdapterParameter(Uri uri) { return uri.buildUpon() .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") .build(); } @Override public void sendSyncOptions(Double protocolVersion, Serializer s) throws IOException { setPimSyncOptions(protocolVersion, null, s); } @Override public boolean isSyncable() { return ContentResolver.getSyncAutomatically( mAccountManagerAccount, ContactsContract.AUTHORITY); } @Override public boolean parse(InputStream is) throws IOException, CommandStatusException { EasContactsSyncParser p = new EasContactsSyncParser(is, this); return p.parse(); } @Override public void wipe() { mContentResolver.delete(mAccountUri, null, null); } interface UntypedRow { public void addValues(RowBuilder builder); public boolean isSameAs(int type, String value); } /** * We get our SyncKey from ContactsProvider. If there's not one, we set it to "0" (the reset * state) and save that away. */ @Override public String getSyncKey() throws IOException { synchronized (sSyncKeyLock) { ContentProviderClient client = mService.mContentResolver .acquireContentProviderClient(ContactsContract.AUTHORITY_URI); try { byte[] data = SyncStateContract.Helpers.get(client, ContactsContract.SyncState.CONTENT_URI, mAccountManagerAccount); if (data == null || data.length == 0) { // Initialize the SyncKey setSyncKey("0", false); // Make sure ungrouped contacts for Exchange are defaultly visible ContentValues cv = new ContentValues(); cv.put(Groups.ACCOUNT_NAME, mAccount.mEmailAddress); cv.put(Groups.ACCOUNT_TYPE, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE); cv.put(Settings.UNGROUPED_VISIBLE, true); client.insert(addCallerIsSyncAdapterParameter(Settings.CONTENT_URI), cv); return "0"; } else { return new String(data); } } catch (RemoteException e) { throw new IOException("Can't get SyncKey from ContactsProvider"); } } } /** * We only need to set this when we're forced to make the SyncKey "0" (a reset). In all other * cases, the SyncKey is set within ContactOperations */ @Override public void setSyncKey(String syncKey, boolean inCommands) throws IOException { synchronized (sSyncKeyLock) { if ("0".equals(syncKey) || !inCommands) { ContentProviderClient client = mService.mContentResolver .acquireContentProviderClient(ContactsContract.AUTHORITY_URI); try { SyncStateContract.Helpers.set(client, ContactsContract.SyncState.CONTENT_URI, mAccountManagerAccount, syncKey.getBytes()); userLog("SyncKey set to ", syncKey, " in ContactsProvider"); } catch (RemoteException e) { throw new IOException("Can't set SyncKey in ContactsProvider"); } } mMailbox.mSyncKey = syncKey; } } public static final class EasChildren { private EasChildren() {} /** MIME type used when storing this in data table. */ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/eas_children"; public static final int MAX_CHILDREN = 8; public static final String[] ROWS = new String[] {"data2", "data3", "data4", "data5", "data6", "data7", "data8", "data9"}; } public static final class EasPersonal { String anniversary; String fileAs; /** MIME type used when storing this in data table. */ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/eas_personal"; public static final String ANNIVERSARY = "data2"; public static final String FILE_AS = "data4"; boolean hasData() { return anniversary != null || fileAs != null; } } public static final class EasBusiness { String customerId; String governmentId; String accountName; /** MIME type used when storing this in data table. */ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/eas_business"; public static final String CUSTOMER_ID = "data6"; public static final String GOVERNMENT_ID = "data7"; public static final String ACCOUNT_NAME = "data8"; boolean hasData() { return customerId != null || governmentId != null || accountName != null; } } public static final class Address { String city; String country; String code; String street; String state; boolean hasData() { return city != null || country != null || code != null || state != null || street != null; } } class EmailRow implements UntypedRow { String email; String displayName; public EmailRow(String _email) { Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(_email); // Can't happen, but belt & suspenders if (tokens.length == 0) { email = ""; displayName = ""; } else { Rfc822Token token = tokens[0]; email = token.getAddress(); displayName = token.getName(); } } public void addValues(RowBuilder builder) { builder.withValue(Email.DATA, email); builder.withValue(Email.DISPLAY_NAME, displayName); } public boolean isSameAs(int type, String value) { return email.equalsIgnoreCase(value); } } class ImRow implements UntypedRow { String im; public ImRow(String _im) { im = _im; } public void addValues(RowBuilder builder) { builder.withValue(Im.DATA, im); } public boolean isSameAs(int type, String value) { return im.equalsIgnoreCase(value); } } class PhoneRow implements UntypedRow { String phone; int type; public PhoneRow(String _phone, int _type) { phone = _phone; type = _type; } public void addValues(RowBuilder builder) { builder.withValue(Im.DATA, phone); builder.withValue(Phone.TYPE, type); } public boolean isSameAs(int _type, String value) { return type == _type && phone.equalsIgnoreCase(value); } } class EasContactsSyncParser extends AbstractSyncParser { String[] mBindArgument = new String[1]; String mMailboxIdAsString; ContactOperations ops = new ContactOperations(); public EasContactsSyncParser(InputStream in, ContactsSyncAdapter adapter) throws IOException { super(in, adapter); } public void addData(String serverId, ContactOperations ops, Entity entity) throws IOException { String fileAs = null; String prefix = null; String firstName = null; String lastName = null; String middleName = null; String suffix = null; String companyName = null; String yomiFirstName = null; String yomiLastName = null; String yomiCompanyName = null; String title = null; String department = null; String officeLocation = null; Address home = new Address(); Address work = new Address(); Address other = new Address(); EasBusiness business = new EasBusiness(); EasPersonal personal = new EasPersonal(); ArrayList<String> children = new ArrayList<String>(); ArrayList<UntypedRow> emails = new ArrayList<UntypedRow>(); ArrayList<UntypedRow> ims = new ArrayList<UntypedRow>(); ArrayList<UntypedRow> homePhones = new ArrayList<UntypedRow>(); ArrayList<UntypedRow> workPhones = new ArrayList<UntypedRow>(); if (entity == null) { ops.newContact(serverId); } while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) { switch (tag) { case Tags.CONTACTS_FIRST_NAME: firstName = getValue(); break; case Tags.CONTACTS_LAST_NAME: lastName = getValue(); break; case Tags.CONTACTS_MIDDLE_NAME: middleName = getValue(); break; case Tags.CONTACTS_FILE_AS: fileAs = getValue(); break; case Tags.CONTACTS_SUFFIX: suffix = getValue(); break; case Tags.CONTACTS_COMPANY_NAME: companyName = getValue(); break; case Tags.CONTACTS_JOB_TITLE: title = getValue(); break; case Tags.CONTACTS_EMAIL1_ADDRESS: case Tags.CONTACTS_EMAIL2_ADDRESS: case Tags.CONTACTS_EMAIL3_ADDRESS: emails.add(new EmailRow(getValue())); break; case Tags.CONTACTS_BUSINESS2_TELEPHONE_NUMBER: case Tags.CONTACTS_BUSINESS_TELEPHONE_NUMBER: workPhones.add(new PhoneRow(getValue(), Phone.TYPE_WORK)); break; case Tags.CONTACTS2_MMS: ops.addPhone(entity, Phone.TYPE_MMS, getValue()); break; case Tags.CONTACTS_BUSINESS_FAX_NUMBER: ops.addPhone(entity, Phone.TYPE_FAX_WORK, getValue()); break; case Tags.CONTACTS2_COMPANY_MAIN_PHONE: ops.addPhone(entity, Phone.TYPE_COMPANY_MAIN, getValue()); break; case Tags.CONTACTS_HOME_FAX_NUMBER: ops.addPhone(entity, Phone.TYPE_FAX_HOME, getValue()); break; case Tags.CONTACTS_HOME_TELEPHONE_NUMBER: case Tags.CONTACTS_HOME2_TELEPHONE_NUMBER: homePhones.add(new PhoneRow(getValue(), Phone.TYPE_HOME)); break; case Tags.CONTACTS_MOBILE_TELEPHONE_NUMBER: ops.addPhone(entity, Phone.TYPE_MOBILE, getValue()); break; case Tags.CONTACTS_CAR_TELEPHONE_NUMBER: ops.addPhone(entity, Phone.TYPE_CAR, getValue()); break; case Tags.CONTACTS_RADIO_TELEPHONE_NUMBER: ops.addPhone(entity, Phone.TYPE_RADIO, getValue()); break; case Tags.CONTACTS_PAGER_NUMBER: ops.addPhone(entity, Phone.TYPE_PAGER, getValue()); break; case Tags.CONTACTS_ASSISTANT_TELEPHONE_NUMBER: ops.addPhone(entity, Phone.TYPE_ASSISTANT, getValue()); break; case Tags.CONTACTS2_IM_ADDRESS: case Tags.CONTACTS2_IM_ADDRESS_2: case Tags.CONTACTS2_IM_ADDRESS_3: ims.add(new ImRow(getValue())); break; case Tags.CONTACTS_BUSINESS_ADDRESS_CITY: work.city = getValue(); break; case Tags.CONTACTS_BUSINESS_ADDRESS_COUNTRY: work.country = getValue(); break; case Tags.CONTACTS_BUSINESS_ADDRESS_POSTAL_CODE: work.code = getValue(); break; case Tags.CONTACTS_BUSINESS_ADDRESS_STATE: work.state = getValue(); break; case Tags.CONTACTS_BUSINESS_ADDRESS_STREET: work.street = getValue(); break; case Tags.CONTACTS_HOME_ADDRESS_CITY: home.city = getValue(); break; case Tags.CONTACTS_HOME_ADDRESS_COUNTRY: home.country = getValue(); break; case Tags.CONTACTS_HOME_ADDRESS_POSTAL_CODE: home.code = getValue(); break; case Tags.CONTACTS_HOME_ADDRESS_STATE: home.state = getValue(); break; case Tags.CONTACTS_HOME_ADDRESS_STREET: home.street = getValue(); break; case Tags.CONTACTS_OTHER_ADDRESS_CITY: other.city = getValue(); break; case Tags.CONTACTS_OTHER_ADDRESS_COUNTRY: other.country = getValue(); break; case Tags.CONTACTS_OTHER_ADDRESS_POSTAL_CODE: other.code = getValue(); break; case Tags.CONTACTS_OTHER_ADDRESS_STATE: other.state = getValue(); break; case Tags.CONTACTS_OTHER_ADDRESS_STREET: other.street = getValue(); break; case Tags.CONTACTS_CHILDREN: childrenParser(children); break; case Tags.CONTACTS_YOMI_COMPANY_NAME: yomiCompanyName = getValue(); break; case Tags.CONTACTS_YOMI_FIRST_NAME: yomiFirstName = getValue(); break; case Tags.CONTACTS_YOMI_LAST_NAME: yomiLastName = getValue(); break; case Tags.CONTACTS2_NICKNAME: ops.addNickname(entity, getValue()); break; case Tags.CONTACTS_ASSISTANT_NAME: ops.addRelation(entity, Relation.TYPE_ASSISTANT, getValue()); break; case Tags.CONTACTS2_MANAGER_NAME: ops.addRelation(entity, Relation.TYPE_MANAGER, getValue()); break; case Tags.CONTACTS_SPOUSE: ops.addRelation(entity, Relation.TYPE_SPOUSE, getValue()); break; case Tags.CONTACTS_DEPARTMENT: department = getValue(); break; case Tags.CONTACTS_TITLE: prefix = getValue(); break; // EAS Business case Tags.CONTACTS_OFFICE_LOCATION: officeLocation = getValue(); break; case Tags.CONTACTS2_CUSTOMER_ID: business.customerId = getValue(); break; case Tags.CONTACTS2_GOVERNMENT_ID: business.governmentId = getValue(); break; case Tags.CONTACTS2_ACCOUNT_NAME: business.accountName = getValue(); break; // EAS Personal case Tags.CONTACTS_ANNIVERSARY: personal.anniversary = getValue(); break; case Tags.CONTACTS_BIRTHDAY: ops.addBirthday(entity, getValue()); break; case Tags.CONTACTS_WEBPAGE: ops.addWebpage(entity, getValue()); break; case Tags.CONTACTS_PICTURE: ops.addPhoto(entity, getValue()); break; case Tags.BASE_BODY: ops.addNote(entity, bodyParser()); break; case Tags.CONTACTS_BODY: ops.addNote(entity, getValue()); break; case Tags.CONTACTS_CATEGORIES: mGroupsUsed = true; categoriesParser(ops, entity); break; case Tags.CONTACTS_COMPRESSED_RTF: // We don't use this, and it isn't necessary to upload, so we'll ignore it skipTag(); break; default: skipTag(); } } // We must have first name, last name, or company name String name = null; if (firstName != null || lastName != null) { if (firstName == null) { name = lastName; } else if (lastName == null) { name = firstName; } else { name = firstName + ' ' + lastName; } } else if (companyName != null) { name = companyName; } ops.addName(entity, prefix, firstName, lastName, middleName, suffix, name, yomiFirstName, yomiLastName, fileAs); ops.addBusiness(entity, business); ops.addPersonal(entity, personal); ops.addUntyped(entity, emails, Email.CONTENT_ITEM_TYPE, -1, MAX_EMAIL_ROWS); ops.addUntyped(entity, ims, Im.CONTENT_ITEM_TYPE, -1, MAX_IM_ROWS); ops.addUntyped(entity, homePhones, Phone.CONTENT_ITEM_TYPE, Phone.TYPE_HOME, MAX_PHONE_ROWS); ops.addUntyped(entity, workPhones, Phone.CONTENT_ITEM_TYPE, Phone.TYPE_WORK, MAX_PHONE_ROWS); if (!children.isEmpty()) { ops.addChildren(entity, children); } if (work.hasData()) { ops.addPostal(entity, StructuredPostal.TYPE_WORK, work.street, work.city, work.state, work.country, work.code); } if (home.hasData()) { ops.addPostal(entity, StructuredPostal.TYPE_HOME, home.street, home.city, home.state, home.country, home.code); } if (other.hasData()) { ops.addPostal(entity, StructuredPostal.TYPE_OTHER, other.street, other.city, other.state, other.country, other.code); } if (companyName != null) { ops.addOrganization(entity, Organization.TYPE_WORK, companyName, title, department, yomiCompanyName, officeLocation); } if (entity != null) { // We've been removing rows from the list as they've been found in the xml // Any that are left must have been deleted on the server ArrayList<NamedContentValues> ncvList = entity.getSubValues(); for (NamedContentValues ncv: ncvList) { // These rows need to be deleted... Uri u = dataUriFromNamedContentValues(ncv); ops.add(ContentProviderOperation.newDelete(addCallerIsSyncAdapterParameter(u)) .build()); } } } private void categoriesParser(ContactOperations ops, Entity entity) throws IOException { while (nextTag(Tags.CONTACTS_CATEGORIES) != END) { switch (tag) { case Tags.CONTACTS_CATEGORY: ops.addGroup(entity, getValue()); break; default: skipTag(); } } } private void childrenParser(ArrayList<String> children) throws IOException { while (nextTag(Tags.CONTACTS_CHILDREN) != END) { switch (tag) { case Tags.CONTACTS_CHILD: if (children.size() < EasChildren.MAX_CHILDREN) { children.add(getValue()); } break; default: skipTag(); } } } private String bodyParser() throws IOException { String body = null; while (nextTag(Tags.BASE_BODY) != END) { switch (tag) { case Tags.BASE_DATA: body = getValue(); break; default: skipTag(); } } return body; } public void addParser(ContactOperations ops) throws IOException { String serverId = null; while (nextTag(Tags.SYNC_ADD) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: // same as serverId = getValue(); break; case Tags.SYNC_APPLICATION_DATA: addData(serverId, ops, null); break; default: skipTag(); } } } private Cursor getServerIdCursor(String serverId) { mBindArgument[0] = serverId; return mContentResolver.query(mAccountUri, ID_PROJECTION, SERVER_ID_SELECTION, mBindArgument, null); } private Cursor getClientIdCursor(String clientId) { mBindArgument[0] = clientId; return mContentResolver.query(mAccountUri, ID_PROJECTION, CLIENT_ID_SELECTION, mBindArgument, null); } public void deleteParser(ContactOperations ops) throws IOException { while (nextTag(Tags.SYNC_DELETE) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: String serverId = getValue(); // Find the message in this mailbox with the given serverId Cursor c = getServerIdCursor(serverId); try { if (c.moveToFirst()) { userLog("Deleting ", serverId); ops.delete(c.getLong(0)); } } finally { c.close(); } break; default: skipTag(); } } } class ServerChange { long id; boolean read; ServerChange(long _id, boolean _read) { id = _id; read = _read; } } /** * Changes are handled row by row, and only changed/new rows are acted upon * @param ops the array of pending ContactProviderOperations. * @throws IOException */ public void changeParser(ContactOperations ops) throws IOException { String serverId = null; Entity entity = null; while (nextTag(Tags.SYNC_CHANGE) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: serverId = getValue(); Cursor c = getServerIdCursor(serverId); try { if (c.moveToFirst()) { // TODO Handle deleted individual rows... Uri uri = ContentUris.withAppendedId( RawContacts.CONTENT_URI, c.getLong(0)); uri = Uri.withAppendedPath( uri, RawContacts.Entity.CONTENT_DIRECTORY); EntityIterator entityIterator = RawContacts.newEntityIterator( mContentResolver.query(uri, null, null, null, null)); if (entityIterator.hasNext()) { entity = entityIterator.next(); } userLog("Changing contact ", serverId); } } finally { c.close(); } break; case Tags.SYNC_APPLICATION_DATA: addData(serverId, ops, entity); break; default: skipTag(); } } } @Override public void commandsParser() throws IOException { while (nextTag(Tags.SYNC_COMMANDS) != END) { if (tag == Tags.SYNC_ADD) { addParser(ops); incrementChangeCount(); } else if (tag == Tags.SYNC_DELETE) { deleteParser(ops); incrementChangeCount(); } else if (tag == Tags.SYNC_CHANGE) { changeParser(ops); incrementChangeCount(); } else skipTag(); } } @Override public void commit() throws IOException { // Save the syncKey here, using the Helper provider by Contacts provider userLog("Contacts SyncKey saved as: ", mMailbox.mSyncKey); ops.add(SyncStateContract.Helpers.newSetOperation(SyncState.CONTENT_URI, mAccountManagerAccount, mMailbox.mSyncKey.getBytes())); // Execute these all at once... ops.execute(); if (ops.mResults != null) { ContentValues cv = new ContentValues(); cv.put(RawContacts.DIRTY, 0); for (int i = 0; i < ops.mContactIndexCount; i++) { int index = ops.mContactIndexArray[i]; Uri u = ops.mResults[index].uri; if (u != null) { String idString = u.getLastPathSegment(); mContentResolver.update( addCallerIsSyncAdapterParameter(RawContacts.CONTENT_URI), cv, RawContacts._ID + "=" + idString, null); } } } } public void addResponsesParser() throws IOException { String serverId = null; String clientId = null; ContentValues cv = new ContentValues(); while (nextTag(Tags.SYNC_ADD) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: serverId = getValue(); break; case Tags.SYNC_CLIENT_ID: clientId = getValue(); break; case Tags.SYNC_STATUS: getValue(); break; default: skipTag(); } } // This is theoretically impossible, but... if (clientId == null || serverId == null) return; Cursor c = getClientIdCursor(clientId); try { if (c.moveToFirst()) { cv.put(RawContacts.SOURCE_ID, serverId); cv.put(RawContacts.DIRTY, 0); ops.add(ContentProviderOperation.newUpdate( ContentUris.withAppendedId( addCallerIsSyncAdapterParameter(RawContacts.CONTENT_URI), c.getLong(0))) .withValues(cv) .build()); userLog("New contact " + clientId + " was given serverId: " + serverId); } } finally { c.close(); } } public void changeResponsesParser() throws IOException { String serverId = null; String status = null; while (nextTag(Tags.SYNC_CHANGE) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: serverId = getValue(); break; case Tags.SYNC_STATUS: status = getValue(); break; default: skipTag(); } } if (serverId != null && status != null) { userLog("Changed contact " + serverId + " failed with status: " + status); } } @Override public void responsesParser() throws IOException { // Handle server responses here (for Add and Change) while (nextTag(Tags.SYNC_RESPONSES) != END) { if (tag == Tags.SYNC_ADD) { addResponsesParser(); } else if (tag == Tags.SYNC_CHANGE) { changeResponsesParser(); } else skipTag(); } } } private Uri uriWithAccountAndIsSyncAdapter(Uri uri) { return uri.buildUpon() .appendQueryParameter(RawContacts.ACCOUNT_NAME, mAccount.mEmailAddress) .appendQueryParameter(RawContacts.ACCOUNT_TYPE, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE) .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") .build(); } /** * SmartBuilder is a wrapper for the Builder class that is used to create/update rows for a * ContentProvider. It has, in addition to the Builder, ContentValues which, if present, * represent the current values of that row, that can be compared against current values to * see whether an update is even necessary. The methods on SmartBuilder are delegated to * the Builder. */ private class RowBuilder { Builder builder; ContentValues cv; public RowBuilder(Builder _builder) { builder = _builder; } public RowBuilder(Builder _builder, NamedContentValues _ncv) { builder = _builder; cv = _ncv.values; } RowBuilder withValues(ContentValues values) { builder.withValues(values); return this; } RowBuilder withValueBackReference(String key, int previousResult) { builder.withValueBackReference(key, previousResult); return this; } ContentProviderOperation build() { return builder.build(); } RowBuilder withValue(String key, Object value) { builder.withValue(key, value); return this; } } private class ContactOperations extends ArrayList<ContentProviderOperation> { private static final long serialVersionUID = 1L; private int mCount = 0; private int mContactBackValue = mCount; // Make an array big enough for the PIM window (max items we can get) private int[] mContactIndexArray = new int[Integer.parseInt(AbstractSyncAdapter.PIM_WINDOW_SIZE)]; private int mContactIndexCount = 0; private ContentProviderResult[] mResults = null; @Override public boolean add(ContentProviderOperation op) { super.add(op); mCount++; return true; } public void newContact(String serverId) { Builder builder = ContentProviderOperation .newInsert(uriWithAccountAndIsSyncAdapter(RawContacts.CONTENT_URI)); ContentValues values = new ContentValues(); values.put(RawContacts.SOURCE_ID, serverId); builder.withValues(values); mContactBackValue = mCount; mContactIndexArray[mContactIndexCount++] = mCount; add(builder.build()); } public void delete(long id) { add(ContentProviderOperation .newDelete(ContentUris.withAppendedId(RawContacts.CONTENT_URI, id) .buildUpon() .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") .build()) .build()); } public void execute() { synchronized (mService.getSynchronizer()) { if (!mService.isStopped()) { try { if (!isEmpty()) { mService.userLog("Executing ", size(), " CPO's"); mResults = mContext.getContentResolver().applyBatch( ContactsContract.AUTHORITY, this); } } catch (RemoteException e) { // There is nothing sensible to be done here Log.e(TAG, "problem inserting contact during server update", e); } catch (OperationApplicationException e) { // There is nothing sensible to be done here Log.e(TAG, "problem inserting contact during server update", e); } } } } /** * Given the list of NamedContentValues for an entity, a mime type, and a subtype, * tries to find a match, returning it * @param list the list of NCV's from the contact entity * @param contentItemType the mime type we're looking for * @param type the subtype (e.g. HOME, WORK, etc.) * @return the matching NCV or null if not found */ private NamedContentValues findTypedData(ArrayList<NamedContentValues> list, String contentItemType, int type, String stringType) { NamedContentValues result = null; // Loop through the ncv's, looking for an existing row for (NamedContentValues namedContentValues: list) { Uri uri = namedContentValues.uri; ContentValues cv = namedContentValues.values; if (Data.CONTENT_URI.equals(uri)) { String mimeType = cv.getAsString(Data.MIMETYPE); if (mimeType.equals(contentItemType)) { if (stringType != null) { if (cv.getAsString(GroupMembership.GROUP_ROW_ID).equals(stringType)) { result = namedContentValues; } // Note Email.TYPE could be ANY type column; they are all defined in // the private CommonColumns class in ContactsContract // We'll accept either type < 0 (don't care), cv doesn't have a type, // or the types are equal } else if (type < 0 || !cv.containsKey(Email.TYPE) || cv.getAsInteger(Email.TYPE) == type) { result = namedContentValues; } } } } // If we've found an existing data row, we'll delete it. Any rows left at the // end should be deleted... if (result != null) { list.remove(result); } // Return the row found (or null) return result; } /** * Given the list of NamedContentValues for an entity and a mime type * gather all of the matching NCV's, returning them * @param list the list of NCV's from the contact entity * @param contentItemType the mime type we're looking for * @param type the subtype (e.g. HOME, WORK, etc.) * @return the matching NCVs */ private ArrayList<NamedContentValues> findUntypedData(ArrayList<NamedContentValues> list, int type, String contentItemType) { ArrayList<NamedContentValues> result = new ArrayList<NamedContentValues>(); // Loop through the ncv's, looking for an existing row for (NamedContentValues namedContentValues: list) { Uri uri = namedContentValues.uri; ContentValues cv = namedContentValues.values; if (Data.CONTENT_URI.equals(uri)) { String mimeType = cv.getAsString(Data.MIMETYPE); if (mimeType.equals(contentItemType)) { if (type != -1) { int subtype = cv.getAsInteger(Phone.TYPE); if (type != subtype) { continue; } } result.add(namedContentValues); } } } // If we've found an existing data row, we'll delete it. Any rows left at the // end should be deleted... for (NamedContentValues values : result) { list.remove(values); } // Return the row found (or null) return result; } /** * Create a wrapper for a builder (insert or update) that also includes the NCV for * an existing row of this type. If the SmartBuilder's cv field is not null, then * it represents the current (old) values of this field. The caller can then check * whether the field is now different and needs to be updated; if it's not different, * the caller will simply return and not generate a new CPO. Otherwise, the builder * should have its content values set, and the built CPO should be added to the * ContactOperations list. * * @param entity the contact entity (or null if this is a new contact) * @param mimeType the mime type of this row * @param type the subtype of this row * @param stringType for groups, the name of the group (type will be ignored), or null * @return the created SmartBuilder */ public RowBuilder createBuilder(Entity entity, String mimeType, int type, String stringType) { RowBuilder builder = null; if (entity != null) { NamedContentValues ncv = findTypedData(entity.getSubValues(), mimeType, type, stringType); if (ncv != null) { builder = new RowBuilder( ContentProviderOperation .newUpdate(addCallerIsSyncAdapterParameter( dataUriFromNamedContentValues(ncv))), ncv); } } if (builder == null) { builder = newRowBuilder(entity, mimeType); } // Return the appropriate builder (insert or update) // Caller will fill in the appropriate values; 4 MIMETYPE is already set return builder; } private RowBuilder typedRowBuilder(Entity entity, String mimeType, int type) { return createBuilder(entity, mimeType, type, null); } private RowBuilder untypedRowBuilder(Entity entity, String mimeType) { return createBuilder(entity, mimeType, -1, null); } private RowBuilder newRowBuilder(Entity entity, String mimeType) { // This is a new row; first get the contactId // If the Contact is new, use the saved back value; otherwise the value in the entity int contactId = mContactBackValue; if (entity != null) { contactId = entity.getEntityValues().getAsInteger(RawContacts._ID); } // Create an insert operation with the proper contactId reference RowBuilder builder = new RowBuilder(ContentProviderOperation.newInsert( addCallerIsSyncAdapterParameter(Data.CONTENT_URI))); if (entity == null) { builder.withValueBackReference(Data.RAW_CONTACT_ID, contactId); } else { builder.withValue(Data.RAW_CONTACT_ID, contactId); } // Set the mime type of the row builder.withValue(Data.MIMETYPE, mimeType); return builder; } /** * Compare a column in a ContentValues with an (old) value, and see if they are the * same. For this purpose, null and an empty string are considered the same. * @param cv a ContentValues object, from a NamedContentValues * @param column a column that might be in the ContentValues * @param oldValue an old value (or null) to check against * @return whether the column's value in the ContentValues matches oldValue */ private boolean cvCompareString(ContentValues cv, String column, String oldValue) { if (cv.containsKey(column)) { if (oldValue != null && cv.getAsString(column).equals(oldValue)) { return true; } } else if (oldValue == null || oldValue.length() == 0) { return true; } return false; } public void addChildren(Entity entity, ArrayList<String> children) { RowBuilder builder = untypedRowBuilder(entity, EasChildren.CONTENT_ITEM_TYPE); int i = 0; for (String child: children) { builder.withValue(EasChildren.ROWS[i++], child); } add(builder.build()); } public void addGroup(Entity entity, String group) { RowBuilder builder = createBuilder(entity, GroupMembership.CONTENT_ITEM_TYPE, -1, group); builder.withValue(GroupMembership.GROUP_SOURCE_ID, group); add(builder.build()); } public void addBirthday(Entity entity, String birthday) { RowBuilder builder = typedRowBuilder(entity, Event.CONTENT_ITEM_TYPE, Event.TYPE_BIRTHDAY); ContentValues cv = builder.cv; if (cv != null && cvCompareString(cv, Event.START_DATE, birthday)) { return; } builder.withValue(Event.START_DATE, birthday); builder.withValue(Event.TYPE, Event.TYPE_BIRTHDAY); add(builder.build()); } public void addName(Entity entity, String prefix, String givenName, String familyName, String middleName, String suffix, String displayName, String yomiFirstName, String yomiLastName, String fileAs) { RowBuilder builder = untypedRowBuilder(entity, StructuredName.CONTENT_ITEM_TYPE); ContentValues cv = builder.cv; if (cv != null && cvCompareString(cv, StructuredName.GIVEN_NAME, givenName) && cvCompareString(cv, StructuredName.FAMILY_NAME, familyName) && cvCompareString(cv, StructuredName.MIDDLE_NAME, middleName) && cvCompareString(cv, StructuredName.PREFIX, prefix) && cvCompareString(cv, StructuredName.PHONETIC_GIVEN_NAME, yomiFirstName) && cvCompareString(cv, StructuredName.PHONETIC_FAMILY_NAME, yomiLastName) && //cvCompareString(cv, StructuredName.DISPLAY_NAME, fileAs) && cvCompareString(cv, StructuredName.SUFFIX, suffix)) { return; } builder.withValue(StructuredName.GIVEN_NAME, givenName); builder.withValue(StructuredName.FAMILY_NAME, familyName); builder.withValue(StructuredName.MIDDLE_NAME, middleName); builder.withValue(StructuredName.SUFFIX, suffix); builder.withValue(StructuredName.PHONETIC_GIVEN_NAME, yomiFirstName); builder.withValue(StructuredName.PHONETIC_FAMILY_NAME, yomiLastName); builder.withValue(StructuredName.PREFIX, prefix); //builder.withValue(StructuredName.DISPLAY_NAME, fileAs); add(builder.build()); } public void addPersonal(Entity entity, EasPersonal personal) { RowBuilder builder = untypedRowBuilder(entity, EasPersonal.CONTENT_ITEM_TYPE); ContentValues cv = builder.cv; if (cv != null && cvCompareString(cv, EasPersonal.ANNIVERSARY, personal.anniversary) && cvCompareString(cv, EasPersonal.FILE_AS , personal.fileAs)) { return; } if (!personal.hasData()) { return; } builder.withValue(EasPersonal.FILE_AS, personal.fileAs); builder.withValue(EasPersonal.ANNIVERSARY, personal.anniversary); add(builder.build()); } public void addBusiness(Entity entity, EasBusiness business) { RowBuilder builder = untypedRowBuilder(entity, EasBusiness.CONTENT_ITEM_TYPE); ContentValues cv = builder.cv; if (cv != null && cvCompareString(cv, EasBusiness.ACCOUNT_NAME, business.accountName) && cvCompareString(cv, EasBusiness.CUSTOMER_ID, business.customerId) && cvCompareString(cv, EasBusiness.GOVERNMENT_ID, business.governmentId)) { return; } if (!business.hasData()) { return; } builder.withValue(EasBusiness.ACCOUNT_NAME, business.accountName); builder.withValue(EasBusiness.CUSTOMER_ID, business.customerId); builder.withValue(EasBusiness.GOVERNMENT_ID, business.governmentId); add(builder.build()); } public void addPhoto(Entity entity, String photo) { RowBuilder builder = untypedRowBuilder(entity, Photo.CONTENT_ITEM_TYPE); // We're always going to add this; it's not worth trying to figure out whether the // picture is the same as the one stored. byte[] pic = Base64.decode(photo, Base64.DEFAULT); builder.withValue(Photo.PHOTO, pic); add(builder.build()); } public void addPhone(Entity entity, int type, String phone) { RowBuilder builder = typedRowBuilder(entity, Phone.CONTENT_ITEM_TYPE, type); ContentValues cv = builder.cv; if (cv != null && cvCompareString(cv, Phone.NUMBER, phone)) { return; } builder.withValue(Phone.TYPE, type); builder.withValue(Phone.NUMBER, phone); add(builder.build()); } public void addWebpage(Entity entity, String url) { RowBuilder builder = untypedRowBuilder(entity, Website.CONTENT_ITEM_TYPE); ContentValues cv = builder.cv; if (cv != null && cvCompareString(cv, Website.URL, url)) { return; } builder.withValue(Website.TYPE, Website.TYPE_WORK); builder.withValue(Website.URL, url); add(builder.build()); } public void addRelation(Entity entity, int type, String value) { RowBuilder builder = typedRowBuilder(entity, Relation.CONTENT_ITEM_TYPE, type); ContentValues cv = builder.cv; if (cv != null && cvCompareString(cv, Relation.DATA, value)) { return; } builder.withValue(Relation.TYPE, type); builder.withValue(Relation.DATA, value); add(builder.build()); } public void addNickname(Entity entity, String name) { RowBuilder builder = typedRowBuilder(entity, Nickname.CONTENT_ITEM_TYPE, Nickname.TYPE_DEFAULT); ContentValues cv = builder.cv; if (cv != null && cvCompareString(cv, Nickname.NAME, name)) { return; } builder.withValue(Nickname.TYPE, Nickname.TYPE_DEFAULT); builder.withValue(Nickname.NAME, name); add(builder.build()); } public void addPostal(Entity entity, int type, String street, String city, String state, String country, String code) { RowBuilder builder = typedRowBuilder(entity, StructuredPostal.CONTENT_ITEM_TYPE, type); ContentValues cv = builder.cv; if (cv != null && cvCompareString(cv, StructuredPostal.CITY, city) && cvCompareString(cv, StructuredPostal.STREET, street) && cvCompareString(cv, StructuredPostal.COUNTRY, country) && cvCompareString(cv, StructuredPostal.POSTCODE, code) && cvCompareString(cv, StructuredPostal.REGION, state)) { return; } builder.withValue(StructuredPostal.TYPE, type); builder.withValue(StructuredPostal.CITY, city); builder.withValue(StructuredPostal.STREET, street); builder.withValue(StructuredPostal.COUNTRY, country); builder.withValue(StructuredPostal.POSTCODE, code); builder.withValue(StructuredPostal.REGION, state); add(builder.build()); } /** * We now are dealing with up to maxRows typeless rows of mimeType data. We need to try to * match them with existing rows; if there's a match, everything's great. Otherwise, we * either need to add a new row for the data, or we have to replace an existing one * that no longer matches. This is similar to the way Emails are handled. */ public void addUntyped(Entity entity, ArrayList<UntypedRow> rows, String mimeType, int type, int maxRows) { // Make a list of all same type rows in the existing entity ArrayList<NamedContentValues> oldValues = EMPTY_ARRAY_NAMEDCONTENTVALUES; ArrayList<NamedContentValues> entityValues = EMPTY_ARRAY_NAMEDCONTENTVALUES; if (entity != null) { oldValues = findUntypedData(entityValues, type, mimeType); entityValues = entity.getSubValues(); } // These will be rows needing replacement with new values ArrayList<UntypedRow> rowsToReplace = new ArrayList<UntypedRow>(); // The count of existing rows int numRows = oldValues.size(); for (UntypedRow row: rows) { boolean found = false; // If we already have this row, mark it for (NamedContentValues ncv: oldValues) { ContentValues cv = ncv.values; String data = cv.getAsString(COMMON_DATA_ROW); int rowType = -1; if (cv.containsKey(COMMON_TYPE_ROW)) { rowType = cv.getAsInteger(COMMON_TYPE_ROW); } if (row.isSameAs(rowType, data)) { cv.put(FOUND_DATA_ROW, true); // Remove this to indicate it's still being used entityValues.remove(ncv); found = true; break; } } if (!found) { // If we don't, there are two possibilities if (numRows < maxRows) { // If there are available rows, add a new one RowBuilder builder = newRowBuilder(entity, mimeType); row.addValues(builder); add(builder.build()); numRows++; } else { // Otherwise, say we need to replace a row with this rowsToReplace.add(row); } } } // Go through rows needing replacement for (UntypedRow row: rowsToReplace) { for (NamedContentValues ncv: oldValues) { ContentValues cv = ncv.values; // Find a row that hasn't been used (i.e. doesn't match current rows) if (!cv.containsKey(FOUND_DATA_ROW)) { // And update it RowBuilder builder = new RowBuilder( ContentProviderOperation .newUpdate(addCallerIsSyncAdapterParameter( dataUriFromNamedContentValues(ncv))), ncv); row.addValues(builder); add(builder.build()); } } } } public void addOrganization(Entity entity, int type, String company, String title, String department, String yomiCompanyName, String officeLocation) { RowBuilder builder = typedRowBuilder(entity, Organization.CONTENT_ITEM_TYPE, type); ContentValues cv = builder.cv; if (cv != null && cvCompareString(cv, Organization.COMPANY, company) && cvCompareString(cv, Organization.PHONETIC_NAME, yomiCompanyName) && cvCompareString(cv, Organization.DEPARTMENT, department) && cvCompareString(cv, Organization.TITLE, title) && cvCompareString(cv, Organization.OFFICE_LOCATION, officeLocation)) { return; } builder.withValue(Organization.TYPE, type); builder.withValue(Organization.COMPANY, company); builder.withValue(Organization.TITLE, title); builder.withValue(Organization.DEPARTMENT, department); builder.withValue(Organization.PHONETIC_NAME, yomiCompanyName); builder.withValue(Organization.OFFICE_LOCATION, officeLocation); add(builder.build()); } public void addNote(Entity entity, String note) { RowBuilder builder = typedRowBuilder(entity, Note.CONTENT_ITEM_TYPE, -1); ContentValues cv = builder.cv; if (note == null) return; note = note.replaceAll("\r\n", "\n"); if (cv != null && cvCompareString(cv, Note.NOTE, note)) { return; } // Reject notes with nothing in them. Often, we get something from Outlook when // nothing was ever entered. Sigh. int len = note.length(); int i = 0; for (; i < len; i++) { char c = note.charAt(i); if (!Character.isWhitespace(c)) { break; } } if (i == len) return; builder.withValue(Note.NOTE, note); add(builder.build()); } } /** * Generate the uri for the data row associated with this NamedContentValues object * @param ncv the NamedContentValues object * @return a uri that can be used to refer to this row */ public Uri dataUriFromNamedContentValues(NamedContentValues ncv) { long id = ncv.values.getAsLong(RawContacts._ID); Uri dataUri = ContentUris.withAppendedId(ncv.uri, id); return dataUri; } @Override public void cleanup() { // Mark the changed contacts dirty = 0 // Permanently delete the user deletions ContactOperations ops = new ContactOperations(); for (Long id: mUpdatedIdList) { ops.add(ContentProviderOperation .newUpdate(ContentUris.withAppendedId(RawContacts.CONTENT_URI, id) .buildUpon() .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") .build()) .withValue(RawContacts.DIRTY, 0).build()); } for (Long id: mDeletedIdList) { ops.add(ContentProviderOperation .newDelete(ContentUris.withAppendedId(RawContacts.CONTENT_URI, id) .buildUpon() .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") .build()) .build()); } ops.execute(); ContentResolver cr = mContext.getContentResolver(); if (mGroupsUsed) { // Make sure the title column is set for all of our groups // And that all of our groups are visible // TODO Perhaps the visible part should only happen when the group is created, but // this is fine for now. Uri groupsUri = uriWithAccountAndIsSyncAdapter(Groups.CONTENT_URI); Cursor c = cr.query(groupsUri, new String[] {Groups.SOURCE_ID, Groups.TITLE}, Groups.TITLE + " IS NULL", null, null); ContentValues values = new ContentValues(); values.put(Groups.GROUP_VISIBLE, 1); try { while (c.moveToNext()) { String sourceId = c.getString(0); values.put(Groups.TITLE, sourceId); cr.update(uriWithAccountAndIsSyncAdapter(groupsUri), values, Groups.SOURCE_ID + "=?", new String[] {sourceId}); } } finally { c.close(); } } } @Override public String getCollectionName() { return "Contacts"; } private void sendEmail(Serializer s, ContentValues cv, int count, String displayName) throws IOException { // Get both parts of the email address (a newly created one in the UI won't have a name) String addr = cv.getAsString(Email.DATA); String name = cv.getAsString(Email.DISPLAY_NAME); if (name == null) { if (displayName != null) { name = displayName; } else { name = addr; } } // Compose address from name and addr if (addr != null) { String value = '\"' + name + "\" <" + addr + '>'; if (count < MAX_EMAIL_ROWS) { s.data(EMAIL_TAGS[count], value); } } } private void sendIm(Serializer s, ContentValues cv, int count) throws IOException { String value = cv.getAsString(Im.DATA); if (value == null) return; if (count < MAX_IM_ROWS) { s.data(IM_TAGS[count], value); } } private void sendOnePostal(Serializer s, ContentValues cv, int[] fieldNames) throws IOException{ sendStringData(s, cv, StructuredPostal.CITY, fieldNames[0]); sendStringData(s, cv, StructuredPostal.COUNTRY, fieldNames[1]); sendStringData(s, cv, StructuredPostal.POSTCODE, fieldNames[2]); sendStringData(s, cv, StructuredPostal.REGION, fieldNames[3]); sendStringData(s, cv, StructuredPostal.STREET, fieldNames[4]); } private void sendStructuredPostal(Serializer s, ContentValues cv) throws IOException { switch (cv.getAsInteger(StructuredPostal.TYPE)) { case StructuredPostal.TYPE_HOME: sendOnePostal(s, cv, HOME_ADDRESS_TAGS); break; case StructuredPostal.TYPE_WORK: sendOnePostal(s, cv, WORK_ADDRESS_TAGS); break; case StructuredPostal.TYPE_OTHER: sendOnePostal(s, cv, OTHER_ADDRESS_TAGS); break; default: break; } } private void sendStringData(Serializer s, ContentValues cv, String column, int tag) throws IOException { if (cv.containsKey(column)) { String value = cv.getAsString(column); if (!TextUtils.isEmpty(value)) { s.data(tag, value); } } } private String sendStructuredName(Serializer s, ContentValues cv) throws IOException { String displayName = null; sendStringData(s, cv, StructuredName.FAMILY_NAME, Tags.CONTACTS_LAST_NAME); sendStringData(s, cv, StructuredName.GIVEN_NAME, Tags.CONTACTS_FIRST_NAME); sendStringData(s, cv, StructuredName.MIDDLE_NAME, Tags.CONTACTS_MIDDLE_NAME); sendStringData(s, cv, StructuredName.SUFFIX, Tags.CONTACTS_SUFFIX); sendStringData(s, cv, StructuredName.PHONETIC_GIVEN_NAME, Tags.CONTACTS_YOMI_FIRST_NAME); sendStringData(s, cv, StructuredName.PHONETIC_FAMILY_NAME, Tags.CONTACTS_YOMI_LAST_NAME); sendStringData(s, cv, StructuredName.PREFIX, Tags.CONTACTS_TITLE); if (cv.containsKey(StructuredName.DISPLAY_NAME)) { displayName = cv.getAsString(StructuredName.DISPLAY_NAME); if (!TextUtils.isEmpty(displayName)) { s.data(Tags.CONTACTS_FILE_AS, displayName); } } return displayName; } private void sendBusiness(Serializer s, ContentValues cv) throws IOException { sendStringData(s, cv, EasBusiness.ACCOUNT_NAME, Tags.CONTACTS2_ACCOUNT_NAME); sendStringData(s, cv, EasBusiness.CUSTOMER_ID, Tags.CONTACTS2_CUSTOMER_ID); sendStringData(s, cv, EasBusiness.GOVERNMENT_ID, Tags.CONTACTS2_GOVERNMENT_ID); } private void sendPersonal(Serializer s, ContentValues cv) throws IOException { sendStringData(s, cv, EasPersonal.ANNIVERSARY, Tags.CONTACTS_ANNIVERSARY); sendStringData(s, cv, EasPersonal.FILE_AS, Tags.CONTACTS_FILE_AS); } private void sendBirthday(Serializer s, ContentValues cv) throws IOException { sendStringData(s, cv, Event.START_DATE, Tags.CONTACTS_BIRTHDAY); } private void sendPhoto(Serializer s, ContentValues cv) throws IOException { if (cv.containsKey(Photo.PHOTO)) { byte[] bytes = cv.getAsByteArray(Photo.PHOTO); String pic = Base64.encodeToString(bytes, Base64.NO_WRAP); s.data(Tags.CONTACTS_PICTURE, pic); } else { // Send an empty tag, which signals the server to delete any pre-existing photo s.tag(Tags.CONTACTS_PICTURE); } } private void sendOrganization(Serializer s, ContentValues cv) throws IOException { sendStringData(s, cv, Organization.TITLE, Tags.CONTACTS_JOB_TITLE); sendStringData(s, cv, Organization.COMPANY, Tags.CONTACTS_COMPANY_NAME); sendStringData(s, cv, Organization.DEPARTMENT, Tags.CONTACTS_DEPARTMENT); sendStringData(s, cv, Organization.OFFICE_LOCATION, Tags.CONTACTS_OFFICE_LOCATION); } private void sendNickname(Serializer s, ContentValues cv) throws IOException { sendStringData(s, cv, Nickname.NAME, Tags.CONTACTS2_NICKNAME); } private void sendWebpage(Serializer s, ContentValues cv) throws IOException { sendStringData(s, cv, Website.URL, Tags.CONTACTS_WEBPAGE); } private void sendNote(Serializer s, ContentValues cv) throws IOException { // Even when there is no local note, we must explicitly upsync an empty note, // which is the only way to force the server to delete any pre-existing note. String note = ""; if (cv.containsKey(Note.NOTE)) { // EAS won't accept note data with raw newline characters note = cv.getAsString(Note.NOTE).replaceAll("\n", "\r\n"); } // Format of upsync data depends on protocol version if (mService.mProtocolVersionDouble >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { s.start(Tags.BASE_BODY); s.data(Tags.BASE_TYPE, Eas.BODY_PREFERENCE_TEXT).data(Tags.BASE_DATA, note); s.end(); } else { s.data(Tags.CONTACTS_BODY, note); } } private void sendChildren(Serializer s, ContentValues cv) throws IOException { boolean first = true; for (int i = 0; i < EasChildren.MAX_CHILDREN; i++) { String row = EasChildren.ROWS[i]; if (cv.containsKey(row)) { if (first) { s.start(Tags.CONTACTS_CHILDREN); first = false; } s.data(Tags.CONTACTS_CHILD, cv.getAsString(row)); } } if (!first) { s.end(); } } private void sendPhone(Serializer s, ContentValues cv, int workCount, int homeCount) throws IOException { String value = cv.getAsString(Phone.NUMBER); if (value == null) return; switch (cv.getAsInteger(Phone.TYPE)) { case Phone.TYPE_WORK: if (workCount < MAX_PHONE_ROWS) { s.data(WORK_PHONE_TAGS[workCount], value); } break; case Phone.TYPE_MMS: s.data(Tags.CONTACTS2_MMS, value); break; case Phone.TYPE_ASSISTANT: s.data(Tags.CONTACTS_ASSISTANT_TELEPHONE_NUMBER, value); break; case Phone.TYPE_FAX_WORK: s.data(Tags.CONTACTS_BUSINESS_FAX_NUMBER, value); break; case Phone.TYPE_COMPANY_MAIN: s.data(Tags.CONTACTS2_COMPANY_MAIN_PHONE, value); break; case Phone.TYPE_HOME: if (homeCount < MAX_PHONE_ROWS) { s.data(HOME_PHONE_TAGS[homeCount], value); } break; case Phone.TYPE_MOBILE: s.data(Tags.CONTACTS_MOBILE_TELEPHONE_NUMBER, value); break; case Phone.TYPE_CAR: s.data(Tags.CONTACTS_CAR_TELEPHONE_NUMBER, value); break; case Phone.TYPE_PAGER: s.data(Tags.CONTACTS_PAGER_NUMBER, value); break; case Phone.TYPE_RADIO: s.data(Tags.CONTACTS_RADIO_TELEPHONE_NUMBER, value); break; case Phone.TYPE_FAX_HOME: s.data(Tags.CONTACTS_HOME_FAX_NUMBER, value); break; default: break; } } private void sendRelation(Serializer s, ContentValues cv) throws IOException { String value = cv.getAsString(Relation.DATA); if (value == null) return; switch (cv.getAsInteger(Relation.TYPE)) { case Relation.TYPE_ASSISTANT: s.data(Tags.CONTACTS_ASSISTANT_NAME, value); break; case Relation.TYPE_MANAGER: s.data(Tags.CONTACTS2_MANAGER_NAME, value); break; case Relation.TYPE_SPOUSE: s.data(Tags.CONTACTS_SPOUSE, value); break; default: break; } } private void dirtyContactsWithinDirtyGroups() { ContentResolver cr = mService.mContentResolver; Cursor c = cr.query(uriWithAccountAndIsSyncAdapter(Groups.CONTENT_URI), GROUPS_ID_PROJECTION, Groups.DIRTY + "=1", null, null); try { if (c.getCount() > 0) { String[] updateArgs = new String[1]; ContentValues updateValues = new ContentValues(); while (c.moveToNext()) { // For each, "touch" all data rows with this group id; this will mark contacts // in this group as dirty (per ContactsContract). We will then know to upload // them to the server with the modified group information long id = c.getLong(0); updateValues.put(GroupMembership.GROUP_ROW_ID, id); updateArgs[0] = Long.toString(id); cr.update(Data.CONTENT_URI, updateValues, MIMETYPE_GROUP_MEMBERSHIP_AND_ID_EQUALS, updateArgs); } // Really delete groups that are marked deleted cr.delete(uriWithAccountAndIsSyncAdapter(Groups.CONTENT_URI), Groups.DELETED + "=1", null); // Clear the dirty flag for all of our groups updateValues.clear(); updateValues.put(Groups.DIRTY, 0); cr.update(uriWithAccountAndIsSyncAdapter(Groups.CONTENT_URI), updateValues, null, null); } } finally { c.close(); } } @Override public boolean sendLocalChanges(Serializer s) throws IOException { ContentResolver cr = mService.mContentResolver; // Find any groups of ours that are dirty and dirty those groups' members dirtyContactsWithinDirtyGroups(); // First, let's find Contacts that have changed. Uri uri = uriWithAccountAndIsSyncAdapter(RawContactsEntity.CONTENT_URI); if (getSyncKey().equals("0")) { return false; } // Get them all atomically EntityIterator ei = RawContacts.newEntityIterator( cr.query(uri, null, RawContacts.DIRTY + "=1", null, null)); ContentValues cidValues = new ContentValues(); try { boolean first = true; final Uri rawContactUri = addCallerIsSyncAdapterParameter(RawContacts.CONTENT_URI); while (ei.hasNext()) { Entity entity = ei.next(); // For each of these entities, create the change commands ContentValues entityValues = entity.getEntityValues(); String serverId = entityValues.getAsString(RawContacts.SOURCE_ID); ArrayList<Integer> groupIds = new ArrayList<Integer>(); if (first) { s.start(Tags.SYNC_COMMANDS); userLog("Sending Contacts changes to the server"); first = false; } if (serverId == null) { // This is a new contact; create a clientId String clientId = "new_" + mMailbox.mId + '_' + System.currentTimeMillis(); userLog("Creating new contact with clientId: ", clientId); s.start(Tags.SYNC_ADD).data(Tags.SYNC_CLIENT_ID, clientId); // And save it in the raw contact cidValues.put(RawContacts.SYNC1, clientId); cr.update(ContentUris. withAppendedId(rawContactUri, entityValues.getAsLong(RawContacts._ID)), cidValues, null, null); } else { if (entityValues.getAsInteger(RawContacts.DELETED) == 1) { userLog("Deleting contact with serverId: ", serverId); s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end(); mDeletedIdList.add(entityValues.getAsLong(RawContacts._ID)); continue; } userLog("Upsync change to contact with serverId: " + serverId); s.start(Tags.SYNC_CHANGE).data(Tags.SYNC_SERVER_ID, serverId); } s.start(Tags.SYNC_APPLICATION_DATA); // Write out the data here int imCount = 0; int emailCount = 0; int homePhoneCount = 0; int workPhoneCount = 0; String displayName = null; ArrayList<ContentValues> emailValues = new ArrayList<ContentValues>(); for (NamedContentValues ncv: entity.getSubValues()) { ContentValues cv = ncv.values; String mimeType = cv.getAsString(Data.MIMETYPE); if (mimeType.equals(Email.CONTENT_ITEM_TYPE)) { emailValues.add(cv); } else if (mimeType.equals(Nickname.CONTENT_ITEM_TYPE)) { sendNickname(s, cv); } else if (mimeType.equals(EasChildren.CONTENT_ITEM_TYPE)) { sendChildren(s, cv); } else if (mimeType.equals(EasBusiness.CONTENT_ITEM_TYPE)) { sendBusiness(s, cv); } else if (mimeType.equals(Website.CONTENT_ITEM_TYPE)) { sendWebpage(s, cv); } else if (mimeType.equals(EasPersonal.CONTENT_ITEM_TYPE)) { sendPersonal(s, cv); } else if (mimeType.equals(Phone.CONTENT_ITEM_TYPE)) { sendPhone(s, cv, workPhoneCount, homePhoneCount); int type = cv.getAsInteger(Phone.TYPE); if (type == Phone.TYPE_HOME) homePhoneCount++; if (type == Phone.TYPE_WORK) workPhoneCount++; } else if (mimeType.equals(Relation.CONTENT_ITEM_TYPE)) { sendRelation(s, cv); } else if (mimeType.equals(StructuredName.CONTENT_ITEM_TYPE)) { displayName = sendStructuredName(s, cv); } else if (mimeType.equals(StructuredPostal.CONTENT_ITEM_TYPE)) { sendStructuredPostal(s, cv); } else if (mimeType.equals(Organization.CONTENT_ITEM_TYPE)) { sendOrganization(s, cv); } else if (mimeType.equals(Im.CONTENT_ITEM_TYPE)) { sendIm(s, cv, imCount++); } else if (mimeType.equals(Event.CONTENT_ITEM_TYPE)) { Integer eventType = cv.getAsInteger(Event.TYPE); if (eventType != null && eventType.equals(Event.TYPE_BIRTHDAY)) { sendBirthday(s, cv); } } else if (mimeType.equals(GroupMembership.CONTENT_ITEM_TYPE)) { // We must gather these, and send them together (below) groupIds.add(cv.getAsInteger(GroupMembership.GROUP_ROW_ID)); } else if (mimeType.equals(Note.CONTENT_ITEM_TYPE)) { sendNote(s, cv); } else if (mimeType.equals(Photo.CONTENT_ITEM_TYPE)) { sendPhoto(s, cv); } else { userLog("Contacts upsync, unknown data: ", mimeType); } } // We do the email rows last, because we need to make sure we've found the // displayName (if one exists); this would be in a StructuredName rnow for (ContentValues cv: emailValues) { sendEmail(s, cv, emailCount++, displayName); } // Now, we'll send up groups, if any if (!groupIds.isEmpty()) { boolean groupFirst = true; for (int id: groupIds) { // Since we get id's from the provider, we need to find their names Cursor c = cr.query(ContentUris.withAppendedId(Groups.CONTENT_URI, id), GROUP_TITLE_PROJECTION, null, null, null); try { // Presumably, this should always succeed, but ... if (c.moveToFirst()) { if (groupFirst) { s.start(Tags.CONTACTS_CATEGORIES); groupFirst = false; } s.data(Tags.CONTACTS_CATEGORY, c.getString(0)); } } finally { c.close(); } } if (!groupFirst) { s.end(); } } s.end().end(); // ApplicationData & Change mUpdatedIdList.add(entityValues.getAsLong(RawContacts._ID)); } if (!first) { s.end(); // Commands } } finally { ei.close(); } return false; } }