1 package com.android.exchange.service; 2 3 import android.content.ContentProviderOperation; 4 import android.content.ContentResolver; 5 import android.content.ContentUris; 6 import android.content.ContentValues; 7 import android.content.Context; 8 import android.content.Entity; 9 import android.content.EntityIterator; 10 import android.content.SyncResult; 11 import android.database.Cursor; 12 import android.net.Uri; 13 import android.os.Bundle; 14 import android.provider.ContactsContract; 15 import android.provider.ContactsContract.CommonDataKinds.Email; 16 import android.provider.ContactsContract.CommonDataKinds.Event; 17 import android.provider.ContactsContract.CommonDataKinds.GroupMembership; 18 import android.provider.ContactsContract.CommonDataKinds.Im; 19 import android.provider.ContactsContract.CommonDataKinds.Nickname; 20 import android.provider.ContactsContract.CommonDataKinds.Note; 21 import android.provider.ContactsContract.CommonDataKinds.Organization; 22 import android.provider.ContactsContract.CommonDataKinds.Phone; 23 import android.provider.ContactsContract.CommonDataKinds.Photo; 24 import android.provider.ContactsContract.CommonDataKinds.Relation; 25 import android.provider.ContactsContract.CommonDataKinds.StructuredName; 26 import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; 27 import android.provider.ContactsContract.CommonDataKinds.Website; 28 import android.provider.ContactsContract.Groups; 29 import android.text.TextUtils; 30 import android.util.Base64; 31 32 import com.android.emailcommon.TrafficFlags; 33 import com.android.emailcommon.provider.Account; 34 import com.android.emailcommon.provider.Mailbox; 35 import com.android.exchange.Eas; 36 import com.android.exchange.adapter.AbstractSyncParser; 37 import com.android.exchange.adapter.ContactsSyncParser; 38 import com.android.exchange.adapter.Serializer; 39 import com.android.exchange.adapter.Tags; 40 import com.android.mail.utils.LogUtils; 41 42 import java.io.IOException; 43 import java.io.InputStream; 44 import java.text.DateFormat; 45 import java.text.ParseException; 46 import java.text.SimpleDateFormat; 47 import java.util.ArrayList; 48 import java.util.Date; 49 import java.util.Locale; 50 import java.util.TimeZone; 51 52 /** 53 * Performs an Exchange sync for contacts. 54 * Contact state is in the contacts provider, not in our DB (and therefore not in e.g. mMailbox). 55 * The Mailbox in the Email DB is only useful for serverId and syncInterval. 56 */ 57 public class EasContactsSyncHandler extends EasSyncHandler { 58 private static final String TAG = Eas.LOG_TAG; 59 60 private static final String MIMETYPE_GROUP_MEMBERSHIP_AND_ID_EQUALS = 61 ContactsContract.Data.MIMETYPE + "='" + GroupMembership.CONTENT_ITEM_TYPE + "' AND " + 62 GroupMembership.GROUP_ROW_ID + "=?"; 63 64 private static final String[] GROUP_TITLE_PROJECTION = 65 new String[] {Groups.TITLE}; 66 private static final String[] GROUPS_ID_PROJECTION = new String[] {Groups._ID}; 67 68 /** The maximum number of IMs we can send for one contact. */ 69 private static final int MAX_IM_ROWS = 3; 70 /** The tags to use for IMs in an upsync. */ 71 private static final int[] IM_TAGS = new int[] {Tags.CONTACTS2_IM_ADDRESS, 72 Tags.CONTACTS2_IM_ADDRESS_2, Tags.CONTACTS2_IM_ADDRESS_3}; 73 74 /** The maximum number of email addresses we can send for one contact. */ 75 private static final int MAX_EMAIL_ROWS = 3; 76 /** The tags to use for the emails in an upsync. */ 77 private static final int[] EMAIL_TAGS = new int[] {Tags.CONTACTS_EMAIL1_ADDRESS, 78 Tags.CONTACTS_EMAIL2_ADDRESS, Tags.CONTACTS_EMAIL3_ADDRESS}; 79 80 /** The maximum number of phone numbers of each type we can send for one contact. */ 81 private static final int MAX_PHONE_ROWS = 2; 82 /** The tags to use for work phone numbers. */ 83 private static final int[] WORK_PHONE_TAGS = new int[] {Tags.CONTACTS_BUSINESS_TELEPHONE_NUMBER, 84 Tags.CONTACTS_BUSINESS2_TELEPHONE_NUMBER}; 85 /** The tags to use for home phone numbers. */ 86 private static final int[] HOME_PHONE_TAGS = new int[] {Tags.CONTACTS_HOME_TELEPHONE_NUMBER, 87 Tags.CONTACTS_HOME2_TELEPHONE_NUMBER}; 88 89 /** The tags to use for different parts of a home address. */ 90 private static final int[] HOME_ADDRESS_TAGS = new int[] {Tags.CONTACTS_HOME_ADDRESS_CITY, 91 Tags.CONTACTS_HOME_ADDRESS_COUNTRY, 92 Tags.CONTACTS_HOME_ADDRESS_POSTAL_CODE, 93 Tags.CONTACTS_HOME_ADDRESS_STATE, 94 Tags.CONTACTS_HOME_ADDRESS_STREET}; 95 96 /** The tags to use for different parts of a work address. */ 97 private static final int[] WORK_ADDRESS_TAGS = new int[] {Tags.CONTACTS_BUSINESS_ADDRESS_CITY, 98 Tags.CONTACTS_BUSINESS_ADDRESS_COUNTRY, 99 Tags.CONTACTS_BUSINESS_ADDRESS_POSTAL_CODE, 100 Tags.CONTACTS_BUSINESS_ADDRESS_STATE, 101 Tags.CONTACTS_BUSINESS_ADDRESS_STREET}; 102 103 /** The tags to use for different parts of an "other" address. */ 104 private static final int[] OTHER_ADDRESS_TAGS = new int[] {Tags.CONTACTS_HOME_ADDRESS_CITY, 105 Tags.CONTACTS_OTHER_ADDRESS_COUNTRY, 106 Tags.CONTACTS_OTHER_ADDRESS_POSTAL_CODE, 107 Tags.CONTACTS_OTHER_ADDRESS_STATE, 108 Tags.CONTACTS_OTHER_ADDRESS_STREET}; 109 110 private final android.accounts.Account mAccountManagerAccount; 111 112 private final ArrayList<Long> mDeletedContacts = new ArrayList<Long>(); 113 private final ArrayList<Long> mUpdatedContacts = new ArrayList<Long>(); 114 115 // We store the parser so that we can ask it later isGroupsUsed. 116 // TODO: Can we do this more cleanly? 117 private ContactsSyncParser mParser = null; 118 119 private static final class EasChildren { 120 private EasChildren() {} 121 122 /** MIME type used when storing this in data table. */ 123 public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/eas_children"; 124 public static final int MAX_CHILDREN = 8; 125 public static final String[] ROWS = 126 new String[] {"data2", "data3", "data4", "data5", "data6", "data7", "data8", "data9"}; 127 } 128 129 // Classes for each type of contact. 130 // These are copied from ContactSyncAdapter, with unused fields and methods removed, but the 131 // parser hasn't been moved over yet. When that happens, the variables and functions may also 132 // need to be copied over. 133 134 /** 135 * Data and constants for a Personal contact. 136 */ 137 private static final class EasPersonal { 138 /** MIME type used when storing this in data table. */ 139 public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/eas_personal"; 140 public static final String ANNIVERSARY = "data2"; 141 public static final String FILE_AS = "data4"; 142 } 143 144 /** 145 * Data and constants for a Business contact. 146 */ 147 private static final class EasBusiness { 148 /** MIME type used when storing this in data table. */ 149 public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/eas_business"; 150 public static final String CUSTOMER_ID = "data6"; 151 public static final String GOVERNMENT_ID = "data7"; 152 public static final String ACCOUNT_NAME = "data8"; 153 } 154 155 public EasContactsSyncHandler(final Context context, final ContentResolver contentResolver, 156 final android.accounts.Account accountManagerAccount, final Account account, 157 final Mailbox mailbox, final Bundle syncExtras, final SyncResult syncResult) { 158 super(context, contentResolver, account, mailbox, syncExtras, syncResult); 159 mAccountManagerAccount = accountManagerAccount; 160 } 161 162 @Override 163 protected int getTrafficFlag() { 164 return TrafficFlags.DATA_CONTACTS; 165 } 166 167 @Override 168 protected String getFolderClassName() { 169 return "Contacts"; 170 } 171 172 @Override 173 protected AbstractSyncParser getParser(final InputStream is) throws IOException { 174 // Store the parser because we'll want to ask it about whether groups are used later. 175 // TODO: It'd be nice to find a cleaner way to get this result back from the parser. 176 mParser = new ContactsSyncParser(mContext, mContentResolver, is, 177 mMailbox, mAccount, mAccountManagerAccount); 178 return mParser; 179 } 180 181 @Override 182 protected void setInitialSyncOptions(final Serializer s) throws IOException { 183 // These are the tags we support for upload; whenever we add/remove support 184 // (in addData), we need to update this list 185 s.start(Tags.SYNC_SUPPORTED); 186 s.tag(Tags.CONTACTS_FIRST_NAME); 187 s.tag(Tags.CONTACTS_LAST_NAME); 188 s.tag(Tags.CONTACTS_MIDDLE_NAME); 189 s.tag(Tags.CONTACTS_SUFFIX); 190 s.tag(Tags.CONTACTS_COMPANY_NAME); 191 s.tag(Tags.CONTACTS_JOB_TITLE); 192 s.tag(Tags.CONTACTS_EMAIL1_ADDRESS); 193 s.tag(Tags.CONTACTS_EMAIL2_ADDRESS); 194 s.tag(Tags.CONTACTS_EMAIL3_ADDRESS); 195 s.tag(Tags.CONTACTS_BUSINESS2_TELEPHONE_NUMBER); 196 s.tag(Tags.CONTACTS_BUSINESS_TELEPHONE_NUMBER); 197 s.tag(Tags.CONTACTS2_MMS); 198 s.tag(Tags.CONTACTS_BUSINESS_FAX_NUMBER); 199 s.tag(Tags.CONTACTS2_COMPANY_MAIN_PHONE); 200 s.tag(Tags.CONTACTS_HOME_FAX_NUMBER); 201 s.tag(Tags.CONTACTS_HOME_TELEPHONE_NUMBER); 202 s.tag(Tags.CONTACTS_HOME2_TELEPHONE_NUMBER); 203 s.tag(Tags.CONTACTS_MOBILE_TELEPHONE_NUMBER); 204 s.tag(Tags.CONTACTS_CAR_TELEPHONE_NUMBER); 205 s.tag(Tags.CONTACTS_RADIO_TELEPHONE_NUMBER); 206 s.tag(Tags.CONTACTS_PAGER_NUMBER); 207 s.tag(Tags.CONTACTS_ASSISTANT_TELEPHONE_NUMBER); 208 s.tag(Tags.CONTACTS2_IM_ADDRESS); 209 s.tag(Tags.CONTACTS2_IM_ADDRESS_2); 210 s.tag(Tags.CONTACTS2_IM_ADDRESS_3); 211 s.tag(Tags.CONTACTS_BUSINESS_ADDRESS_CITY); 212 s.tag(Tags.CONTACTS_BUSINESS_ADDRESS_COUNTRY); 213 s.tag(Tags.CONTACTS_BUSINESS_ADDRESS_POSTAL_CODE); 214 s.tag(Tags.CONTACTS_BUSINESS_ADDRESS_STATE); 215 s.tag(Tags.CONTACTS_BUSINESS_ADDRESS_STREET); 216 s.tag(Tags.CONTACTS_HOME_ADDRESS_CITY); 217 s.tag(Tags.CONTACTS_HOME_ADDRESS_COUNTRY); 218 s.tag(Tags.CONTACTS_HOME_ADDRESS_POSTAL_CODE); 219 s.tag(Tags.CONTACTS_HOME_ADDRESS_STATE); 220 s.tag(Tags.CONTACTS_HOME_ADDRESS_STREET); 221 s.tag(Tags.CONTACTS_OTHER_ADDRESS_CITY); 222 s.tag(Tags.CONTACTS_OTHER_ADDRESS_COUNTRY); 223 s.tag(Tags.CONTACTS_OTHER_ADDRESS_POSTAL_CODE); 224 s.tag(Tags.CONTACTS_OTHER_ADDRESS_STATE); 225 s.tag(Tags.CONTACTS_OTHER_ADDRESS_STREET); 226 s.tag(Tags.CONTACTS_YOMI_COMPANY_NAME); 227 s.tag(Tags.CONTACTS_YOMI_FIRST_NAME); 228 s.tag(Tags.CONTACTS_YOMI_LAST_NAME); 229 s.tag(Tags.CONTACTS2_NICKNAME); 230 s.tag(Tags.CONTACTS_ASSISTANT_NAME); 231 s.tag(Tags.CONTACTS2_MANAGER_NAME); 232 s.tag(Tags.CONTACTS_SPOUSE); 233 s.tag(Tags.CONTACTS_DEPARTMENT); 234 s.tag(Tags.CONTACTS_TITLE); 235 s.tag(Tags.CONTACTS_OFFICE_LOCATION); 236 s.tag(Tags.CONTACTS2_CUSTOMER_ID); 237 s.tag(Tags.CONTACTS2_GOVERNMENT_ID); 238 s.tag(Tags.CONTACTS2_ACCOUNT_NAME); 239 s.tag(Tags.CONTACTS_ANNIVERSARY); 240 s.tag(Tags.CONTACTS_BIRTHDAY); 241 s.tag(Tags.CONTACTS_WEBPAGE); 242 s.tag(Tags.CONTACTS_PICTURE); 243 s.end(); // SYNC_SUPPORTED 244 } 245 246 @Override 247 protected void setNonInitialSyncOptions(final Serializer s, int numWindows) throws IOException { 248 final int windowSize = numWindows * PIM_WINDOW_SIZE_CONTACTS; 249 if (windowSize > MAX_WINDOW_SIZE + PIM_WINDOW_SIZE_CONTACTS) { 250 throw new IOException("Max window size reached and still no data"); 251 } 252 setPimSyncOptions(s, null, windowSize < MAX_WINDOW_SIZE ? windowSize : MAX_WINDOW_SIZE); 253 } 254 255 /** 256 * Add account info and the "caller is syncadapter" param to a URI. 257 * @param uri The {@link Uri} to add to. 258 * @param emailAddress The email address to add to uri. 259 * @return 260 */ 261 private static Uri uriWithAccountAndIsSyncAdapter(final Uri uri, final String emailAddress) { 262 return uri.buildUpon() 263 .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, emailAddress) 264 .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, 265 Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE) 266 .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") 267 .build(); 268 } 269 270 /** 271 * Add the "caller is syncadapter" param to a URI. 272 * @param uri The {@link Uri} to add to. 273 * @return 274 */ 275 private static Uri addCallerIsSyncAdapterParameter(final Uri uri) { 276 return uri.buildUpon() 277 .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") 278 .build(); 279 } 280 281 /** 282 * Mark contacts in dirty groups as dirty. 283 */ 284 private void dirtyContactsWithinDirtyGroups() { 285 final String emailAddress = mAccount.mEmailAddress; 286 final Cursor c = mContentResolver.query( 287 uriWithAccountAndIsSyncAdapter(Groups.CONTENT_URI, emailAddress), 288 GROUPS_ID_PROJECTION, Groups.DIRTY + "=1", null, null); 289 if (c == null) { 290 return; 291 } 292 try { 293 if (c.getCount() > 0) { 294 final String[] updateArgs = new String[1]; 295 final ContentValues updateValues = new ContentValues(); 296 while (c.moveToNext()) { 297 // For each, "touch" all data rows with this group id; this will mark contacts 298 // in this group as dirty (per ContactsContract). We will then know to upload 299 // them to the server with the modified group information 300 final long id = c.getLong(0); 301 updateValues.put(GroupMembership.GROUP_ROW_ID, id); 302 updateArgs[0] = Long.toString(id); 303 mContentResolver.update(ContactsContract.Data.CONTENT_URI, updateValues, 304 MIMETYPE_GROUP_MEMBERSHIP_AND_ID_EQUALS, updateArgs); 305 } 306 // Really delete groups that are marked deleted 307 mContentResolver.delete(uriWithAccountAndIsSyncAdapter( 308 Groups.CONTENT_URI, emailAddress), 309 Groups.DELETED + "=1", null); 310 // Clear the dirty flag for all of our groups 311 updateValues.clear(); 312 updateValues.put(Groups.DIRTY, 0); 313 mContentResolver.update(uriWithAccountAndIsSyncAdapter( 314 Groups.CONTENT_URI, emailAddress), updateValues, null, 315 null); 316 } 317 } finally { 318 c.close(); 319 } 320 } 321 322 /** 323 * Helper to add a string to the upsync. 324 * @param s The {@link Serializer} for this sync request 325 * @param cv The {@link ContentValues} with the data for this string. 326 * @param column The column name in cv to find the string. 327 * @param tag The tag to use when adding to s. 328 * @throws IOException 329 */ 330 private static void sendStringData(final Serializer s, final ContentValues cv, 331 final String column, final int tag) throws IOException { 332 if (cv.containsKey(column)) { 333 final String value = cv.getAsString(column); 334 if (!TextUtils.isEmpty(value)) { 335 s.data(tag, value); 336 } 337 } 338 } 339 340 341 // This is to catch when the contacts provider has a date in this particular wrong format. 342 private static final SimpleDateFormat SHORT_DATE_FORMAT; 343 // Array of formats we check when parsing dates from the contacts provider. 344 private static final DateFormat[] DATE_FORMATS; 345 static { 346 SHORT_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.US); 347 SHORT_DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC")); 348 //TODO: We only handle two formatting types. The default contacts app will work with this 349 // but any other contacts apps might not. We can try harder to handle those guys too. 350 DATE_FORMATS = new DateFormat[] { Eas.DATE_FORMAT, SHORT_DATE_FORMAT }; 351 } 352 353 /** 354 * Helper to add a date to the upsync. It reads the date as a string from the 355 * {@link ContentValues} that we got from the provider, tries to parse it using various formats, 356 * and formats it correctly to send to the server. If it can't parse it, it will omit the date 357 * in the upsync; since Birthdays (the only date currently supported by this class) can be 358 * ghosted, this means that any date changes on the client will NOT be reflected on the server. 359 * @param s The {@link Serializer} for this sync request 360 * @param cv The {@link ContentValues} with the data for this string. 361 * @param column The column name in cv to find the string. 362 * @param tag The tag to use when adding to s. 363 * @throws IOException 364 */ 365 private static void sendDateData(final Serializer s, final ContentValues cv, 366 final String column, final int tag) throws IOException { 367 if (cv.containsKey(column)) { 368 final String value = cv.getAsString(column); 369 if (!TextUtils.isEmpty(value)) { 370 Date date; 371 // Check all the formats we know about to see if one of them works. 372 for (final DateFormat format : DATE_FORMATS) { 373 try { 374 date = format.parse(value); 375 if (date != null) { 376 // We got a legit date for this format, so send it up. 377 s.data(tag, Eas.DATE_FORMAT.format(date)); 378 return; 379 } 380 } catch (final ParseException e) { 381 // The date didn't match this particular format; keep looping. 382 } 383 } 384 } 385 } 386 } 387 388 389 /** 390 * Add a nickname to the upsync. 391 * @param s The {@link Serializer} for this sync request. 392 * @param cv The {@link ContentValues} with the data for this nickname. 393 * @throws IOException 394 */ 395 private static void sendNickname(final Serializer s, final ContentValues cv) 396 throws IOException { 397 sendStringData(s, cv, Nickname.NAME, Tags.CONTACTS2_NICKNAME); 398 } 399 400 /** 401 * Add children data to the upsync. 402 * @param s The {@link Serializer} for this sync request. 403 * @param cv The {@link ContentValues} with the data for a set of children. 404 * @throws IOException 405 */ 406 private static void sendChildren(final Serializer s, final ContentValues cv) 407 throws IOException { 408 boolean first = true; 409 for (int i = 0; i < EasChildren.MAX_CHILDREN; i++) { 410 final String row = EasChildren.ROWS[i]; 411 if (cv.containsKey(row)) { 412 if (first) { 413 s.start(Tags.CONTACTS_CHILDREN); 414 first = false; 415 } 416 s.data(Tags.CONTACTS_CHILD, cv.getAsString(row)); 417 } 418 } 419 if (!first) { 420 s.end(); 421 } 422 } 423 424 /** 425 * Add business contact info to the upsync. 426 * @param s The {@link Serializer} for this sync request. 427 * @param cv The {@link ContentValues} with the data for this business contact. 428 * @throws IOException 429 */ 430 private static void sendBusiness(final Serializer s, final ContentValues cv) 431 throws IOException { 432 sendStringData(s, cv, EasBusiness.ACCOUNT_NAME, Tags.CONTACTS2_ACCOUNT_NAME); 433 sendStringData(s, cv, EasBusiness.CUSTOMER_ID, Tags.CONTACTS2_CUSTOMER_ID); 434 sendStringData(s, cv, EasBusiness.GOVERNMENT_ID, Tags.CONTACTS2_GOVERNMENT_ID); 435 } 436 437 /** 438 * Add a webpage info to the upsync. 439 * @param s The {@link Serializer} for this sync request. 440 * @param cv The {@link ContentValues} with the data for this webpage. 441 * @throws IOException 442 */ 443 private static void sendWebpage(final Serializer s, final ContentValues cv) throws IOException { 444 sendStringData(s, cv, Website.URL, Tags.CONTACTS_WEBPAGE); 445 } 446 447 /** 448 * Add personal contact info to the upsync. 449 * @param s The {@link Serializer} for this sync request. 450 * @param cv The {@link ContentValues} with the data for this personal contact. 451 * @throws IOException 452 */ 453 private static void sendPersonal(final Serializer s, final ContentValues cv) 454 throws IOException { 455 sendStringData(s, cv, EasPersonal.ANNIVERSARY, Tags.CONTACTS_ANNIVERSARY); 456 sendStringData(s, cv, EasPersonal.FILE_AS, Tags.CONTACTS_FILE_AS); 457 } 458 459 /** 460 * Add a phone number to the upsync. 461 * @param s The {@link Serializer} for this sync request. 462 * @param cv The {@link ContentValues} with the data for this phone number. 463 * @param workCount The number of work phone numbers already added. 464 * @param homeCount The number of home phone numbers already added. 465 * @throws IOException 466 */ 467 private static void sendPhone(final Serializer s, final ContentValues cv, final int workCount, 468 final int homeCount) throws IOException { 469 final String value = cv.getAsString(Phone.NUMBER); 470 if (value == null) return; 471 switch (cv.getAsInteger(Phone.TYPE)) { 472 case Phone.TYPE_WORK: 473 if (workCount < MAX_PHONE_ROWS) { 474 s.data(WORK_PHONE_TAGS[workCount], value); 475 } 476 break; 477 case Phone.TYPE_MMS: 478 s.data(Tags.CONTACTS2_MMS, value); 479 break; 480 case Phone.TYPE_ASSISTANT: 481 s.data(Tags.CONTACTS_ASSISTANT_TELEPHONE_NUMBER, value); 482 break; 483 case Phone.TYPE_FAX_WORK: 484 s.data(Tags.CONTACTS_BUSINESS_FAX_NUMBER, value); 485 break; 486 case Phone.TYPE_COMPANY_MAIN: 487 s.data(Tags.CONTACTS2_COMPANY_MAIN_PHONE, value); 488 break; 489 case Phone.TYPE_HOME: 490 if (homeCount < MAX_PHONE_ROWS) { 491 s.data(HOME_PHONE_TAGS[homeCount], value); 492 } 493 break; 494 case Phone.TYPE_MOBILE: 495 s.data(Tags.CONTACTS_MOBILE_TELEPHONE_NUMBER, value); 496 break; 497 case Phone.TYPE_CAR: 498 s.data(Tags.CONTACTS_CAR_TELEPHONE_NUMBER, value); 499 break; 500 case Phone.TYPE_PAGER: 501 s.data(Tags.CONTACTS_PAGER_NUMBER, value); 502 break; 503 case Phone.TYPE_RADIO: 504 s.data(Tags.CONTACTS_RADIO_TELEPHONE_NUMBER, value); 505 break; 506 case Phone.TYPE_FAX_HOME: 507 s.data(Tags.CONTACTS_HOME_FAX_NUMBER, value); 508 break; 509 default: 510 break; 511 } 512 } 513 514 /** 515 * Add a relation to the upsync. 516 * @param s The {@link Serializer} for this sync request. 517 * @param cv The {@link ContentValues} with the data for this relation. 518 * @throws IOException 519 */ 520 private static void sendRelation(final Serializer s, final ContentValues cv) 521 throws IOException { 522 final String value = cv.getAsString(Relation.DATA); 523 if (value == null) return; 524 switch (cv.getAsInteger(Relation.TYPE)) { 525 case Relation.TYPE_ASSISTANT: 526 s.data(Tags.CONTACTS_ASSISTANT_NAME, value); 527 break; 528 case Relation.TYPE_MANAGER: 529 s.data(Tags.CONTACTS2_MANAGER_NAME, value); 530 break; 531 case Relation.TYPE_SPOUSE: 532 s.data(Tags.CONTACTS_SPOUSE, value); 533 break; 534 default: 535 break; 536 } 537 } 538 539 /** 540 * Add a name to the upsync. 541 * @param s The {@link Serializer} for this sync request. 542 * @param cv The {@link ContentValues} with the data for this name. 543 * @throws IOException 544 */ 545 // TODO: This used to return a displayName, but it was always null. Figure out what it really 546 // wanted to return. 547 private static void sendStructuredName(final Serializer s, final ContentValues cv) 548 throws IOException { 549 sendStringData(s, cv, StructuredName.FAMILY_NAME, Tags.CONTACTS_LAST_NAME); 550 sendStringData(s, cv, StructuredName.GIVEN_NAME, Tags.CONTACTS_FIRST_NAME); 551 sendStringData(s, cv, StructuredName.MIDDLE_NAME, Tags.CONTACTS_MIDDLE_NAME); 552 sendStringData(s, cv, StructuredName.SUFFIX, Tags.CONTACTS_SUFFIX); 553 sendStringData(s, cv, StructuredName.PHONETIC_GIVEN_NAME, Tags.CONTACTS_YOMI_FIRST_NAME); 554 sendStringData(s, cv, StructuredName.PHONETIC_FAMILY_NAME, Tags.CONTACTS_YOMI_LAST_NAME); 555 sendStringData(s, cv, StructuredName.PREFIX, Tags.CONTACTS_TITLE); 556 } 557 558 /** 559 * Add an address of a particular type to the upsync. 560 * @param s The {@link Serializer} for this sync request. 561 * @param cv The {@link ContentValues} with the data for this address. 562 * @param fieldNames The field names for this address type. 563 * @throws IOException 564 */ 565 private static void sendOnePostal(final Serializer s, final ContentValues cv, 566 final int[] fieldNames) throws IOException{ 567 sendStringData(s, cv, StructuredPostal.CITY, fieldNames[0]); 568 sendStringData(s, cv, StructuredPostal.COUNTRY, fieldNames[1]); 569 sendStringData(s, cv, StructuredPostal.POSTCODE, fieldNames[2]); 570 sendStringData(s, cv, StructuredPostal.REGION, fieldNames[3]); 571 sendStringData(s, cv, StructuredPostal.STREET, fieldNames[4]); 572 } 573 574 /** 575 * Add an address to the upsync. 576 * @param s The {@link Serializer} for this sync request. 577 * @param cv The {@link ContentValues} with the data for this address. 578 * @throws IOException 579 */ 580 private static void sendStructuredPostal(final Serializer s, final ContentValues cv) 581 throws IOException { 582 switch (cv.getAsInteger(StructuredPostal.TYPE)) { 583 case StructuredPostal.TYPE_HOME: 584 sendOnePostal(s, cv, HOME_ADDRESS_TAGS); 585 break; 586 case StructuredPostal.TYPE_WORK: 587 sendOnePostal(s, cv, WORK_ADDRESS_TAGS); 588 break; 589 case StructuredPostal.TYPE_OTHER: 590 sendOnePostal(s, cv, OTHER_ADDRESS_TAGS); 591 break; 592 default: 593 break; 594 } 595 } 596 597 /** 598 * Add an organization to the upsync. 599 * @param s The {@link Serializer} for this sync request. 600 * @param cv The {@link ContentValues} with the data for this organization. 601 * @throws IOException 602 */ 603 private static void sendOrganization(final Serializer s, final ContentValues cv) 604 throws IOException { 605 sendStringData(s, cv, Organization.TITLE, Tags.CONTACTS_JOB_TITLE); 606 sendStringData(s, cv, Organization.COMPANY, Tags.CONTACTS_COMPANY_NAME); 607 sendStringData(s, cv, Organization.DEPARTMENT, Tags.CONTACTS_DEPARTMENT); 608 sendStringData(s, cv, Organization.OFFICE_LOCATION, Tags.CONTACTS_OFFICE_LOCATION); 609 } 610 611 /** 612 * Add an IM to the upsync. 613 * @param s The {@link Serializer} for this sync request. 614 * @param cv The {@link ContentValues} with the data for this IM. 615 * @throws IOException 616 */ 617 private static void sendIm(final Serializer s, final ContentValues cv, final int count) 618 throws IOException { 619 final String value = cv.getAsString(Im.DATA); 620 if (value == null) return; 621 if (count < MAX_IM_ROWS) { 622 s.data(IM_TAGS[count], value); 623 } 624 } 625 626 /** 627 * Add a birthday to the upsync. 628 * @param s The {@link Serializer} for this sync request. 629 * @param cv The {@link ContentValues} with the data for this birthday. 630 * @throws IOException 631 */ 632 private static void sendBirthday(final Serializer s, final ContentValues cv) 633 throws IOException { 634 sendDateData(s, cv, Event.START_DATE, Tags.CONTACTS_BIRTHDAY); 635 } 636 637 /** 638 * Add a note to the upsync. 639 * @param s The {@link Serializer} for this sync request. 640 * @param cv The {@link ContentValues} with the data for this note. 641 * @throws IOException 642 */ 643 private void sendNote(final Serializer s, final ContentValues cv) throws IOException { 644 // Even when there is no local note, we must explicitly upsync an empty note, 645 // which is the only way to force the server to delete any pre-existing note. 646 String note = ""; 647 if (cv.containsKey(Note.NOTE)) { 648 // EAS won't accept note data with raw newline characters 649 note = cv.getAsString(Note.NOTE).replaceAll("\n", "\r\n"); 650 } 651 // Format of upsync data depends on protocol version 652 if (getProtocolVersion() >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 653 s.start(Tags.BASE_BODY); 654 s.data(Tags.BASE_TYPE, Eas.BODY_PREFERENCE_TEXT).data(Tags.BASE_DATA, note); 655 s.end(); 656 } else { 657 s.data(Tags.CONTACTS_BODY, note); 658 } 659 } 660 661 /** 662 * Add a photo to the upsync. 663 * @param s The {@link Serializer} for this sync request. 664 * @param cv The {@link ContentValues} with the data for this photo. 665 * @throws IOException 666 */ 667 private static void sendPhoto(final Serializer s, final ContentValues cv) throws IOException { 668 if (cv.containsKey(Photo.PHOTO)) { 669 final byte[] bytes = cv.getAsByteArray(Photo.PHOTO); 670 final String pic = Base64.encodeToString(bytes, Base64.NO_WRAP); 671 s.data(Tags.CONTACTS_PICTURE, pic); 672 } else { 673 // Send an empty tag, which signals the server to delete any pre-existing photo 674 s.tag(Tags.CONTACTS_PICTURE); 675 } 676 } 677 678 /** 679 * Add an email address to the upsync. 680 * @param s The {@link Serializer} for this sync request. 681 * @param cv The {@link ContentValues} with the data for this email address. 682 * @param count The number of email addresses that have already been added. 683 * @param displayName The display name for this contact. 684 * @throws IOException 685 */ 686 private void sendEmail(final Serializer s, final ContentValues cv, final int count, 687 final String displayName) throws IOException { 688 // Get both parts of the email address (a newly created one in the UI won't have a name) 689 final String addr = cv.getAsString(Email.DATA); 690 String name = cv.getAsString(Email.DISPLAY_NAME); 691 if (name == null) { 692 if (displayName != null) { 693 name = displayName; 694 } else { 695 name = addr; 696 } 697 } 698 // Compose address from name and addr 699 if (addr != null) { 700 final String value; 701 // Only send the raw email address for EAS 2.5 (Hotmail, in particular, chokes on 702 // an RFC822 address) 703 if (getProtocolVersion() < Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 704 value = addr; 705 } else { 706 value = '\"' + name + "\" <" + addr + '>'; 707 } 708 if (count < MAX_EMAIL_ROWS) { 709 s.data(EMAIL_TAGS[count], value); 710 } 711 } 712 } 713 714 @Override 715 protected void setUpsyncCommands(final Serializer s) throws IOException { 716 // Find any groups of ours that are dirty and dirty those groups' members 717 dirtyContactsWithinDirtyGroups(); 718 719 // First, let's find Contacts that have changed. 720 final Uri uri = uriWithAccountAndIsSyncAdapter( 721 ContactsContract.RawContactsEntity.CONTENT_URI, mAccount.mEmailAddress); 722 723 // Get them all atomically 724 final EntityIterator ei = ContactsContract.RawContacts.newEntityIterator( 725 mContentResolver.query(uri, null, ContactsContract.RawContacts.DIRTY + "=1", null, 726 null)); 727 final ContentValues cidValues = new ContentValues(); 728 try { 729 boolean first = true; 730 final Uri rawContactUri = addCallerIsSyncAdapterParameter( 731 ContactsContract.RawContacts.CONTENT_URI); 732 while (ei.hasNext()) { 733 final Entity entity = ei.next(); 734 // For each of these entities, create the change commands 735 final ContentValues entityValues = entity.getEntityValues(); 736 final String serverId = 737 entityValues.getAsString(ContactsContract.RawContacts.SOURCE_ID); 738 final ArrayList<Integer> groupIds = new ArrayList<Integer>(); 739 if (first) { 740 s.start(Tags.SYNC_COMMANDS); 741 LogUtils.d(TAG, "Sending Contacts changes to the server"); 742 first = false; 743 } 744 if (serverId == null) { 745 // This is a new contact; create a clientId 746 final String clientId = 747 "new_" + mMailbox.mId + '_' + System.currentTimeMillis(); 748 LogUtils.d(TAG, "Creating new contact with clientId: %s", clientId); 749 s.start(Tags.SYNC_ADD).data(Tags.SYNC_CLIENT_ID, clientId); 750 // And save it in the raw contact 751 cidValues.put(ContactsContract.RawContacts.SYNC1, clientId); 752 mContentResolver.update(ContentUris.withAppendedId(rawContactUri, 753 entityValues.getAsLong(ContactsContract.RawContacts._ID)), 754 cidValues, null, null); 755 } else { 756 if (entityValues.getAsInteger(ContactsContract.RawContacts.DELETED) == 1) { 757 LogUtils.d(TAG, "Deleting contact with serverId: %s", serverId); 758 s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end(); 759 mDeletedContacts.add( 760 entityValues.getAsLong(ContactsContract.RawContacts._ID)); 761 continue; 762 } 763 LogUtils.d(TAG, "Upsync change to contact with serverId: %s", serverId); 764 s.start(Tags.SYNC_CHANGE).data(Tags.SYNC_SERVER_ID, serverId); 765 } 766 s.start(Tags.SYNC_APPLICATION_DATA); 767 // Write out the data here 768 int imCount = 0; 769 int emailCount = 0; 770 int homePhoneCount = 0; 771 int workPhoneCount = 0; 772 // TODO: How is this name supposed to be formed? 773 String displayName = null; 774 final ArrayList<ContentValues> emailValues = new ArrayList<ContentValues>(); 775 for (final Entity.NamedContentValues ncv: entity.getSubValues()) { 776 final ContentValues cv = ncv.values; 777 final String mimeType = cv.getAsString(ContactsContract.Data.MIMETYPE); 778 if (mimeType.equals(Email.CONTENT_ITEM_TYPE)) { 779 emailValues.add(cv); 780 } else if (mimeType.equals(Nickname.CONTENT_ITEM_TYPE)) { 781 sendNickname(s, cv); 782 } else if (mimeType.equals(EasChildren.CONTENT_ITEM_TYPE)) { 783 sendChildren(s, cv); 784 } else if (mimeType.equals(EasBusiness.CONTENT_ITEM_TYPE)) { 785 sendBusiness(s, cv); 786 } else if (mimeType.equals(Website.CONTENT_ITEM_TYPE)) { 787 sendWebpage(s, cv); 788 } else if (mimeType.equals(EasPersonal.CONTENT_ITEM_TYPE)) { 789 sendPersonal(s, cv); 790 } else if (mimeType.equals(Phone.CONTENT_ITEM_TYPE)) { 791 sendPhone(s, cv, workPhoneCount, homePhoneCount); 792 int type = cv.getAsInteger(Phone.TYPE); 793 if (type == Phone.TYPE_HOME) homePhoneCount++; 794 if (type == Phone.TYPE_WORK) workPhoneCount++; 795 } else if (mimeType.equals(Relation.CONTENT_ITEM_TYPE)) { 796 sendRelation(s, cv); 797 } else if (mimeType.equals(StructuredName.CONTENT_ITEM_TYPE)) { 798 sendStructuredName(s, cv); 799 } else if (mimeType.equals(StructuredPostal.CONTENT_ITEM_TYPE)) { 800 sendStructuredPostal(s, cv); 801 } else if (mimeType.equals(Organization.CONTENT_ITEM_TYPE)) { 802 sendOrganization(s, cv); 803 } else if (mimeType.equals(Im.CONTENT_ITEM_TYPE)) { 804 sendIm(s, cv, imCount++); 805 } else if (mimeType.equals(Event.CONTENT_ITEM_TYPE)) { 806 Integer eventType = cv.getAsInteger(Event.TYPE); 807 if (eventType != null && eventType.equals(Event.TYPE_BIRTHDAY)) { 808 sendBirthday(s, cv); 809 } 810 } else if (mimeType.equals(GroupMembership.CONTENT_ITEM_TYPE)) { 811 // We must gather these, and send them together (below) 812 groupIds.add(cv.getAsInteger(GroupMembership.GROUP_ROW_ID)); 813 } else if (mimeType.equals(Note.CONTENT_ITEM_TYPE)) { 814 sendNote(s, cv); 815 } else if (mimeType.equals(Photo.CONTENT_ITEM_TYPE)) { 816 sendPhoto(s, cv); 817 } else { 818 LogUtils.i(TAG, "Contacts upsync, unknown data: %s", mimeType); 819 } 820 } 821 822 // We do the email rows last, because we need to make sure we've found the 823 // displayName (if one exists); this would be in a StructuredName rnow 824 for (final ContentValues cv: emailValues) { 825 sendEmail(s, cv, emailCount++, displayName); 826 } 827 828 // Now, we'll send up groups, if any 829 if (!groupIds.isEmpty()) { 830 boolean groupFirst = true; 831 for (final int id: groupIds) { 832 // Since we get id's from the provider, we need to find their names 833 final Cursor c = mContentResolver.query(ContentUris.withAppendedId( 834 Groups.CONTENT_URI, id), 835 GROUP_TITLE_PROJECTION, null, null, null); 836 try { 837 // Presumably, this should always succeed, but ... 838 if (c.moveToFirst()) { 839 if (groupFirst) { 840 s.start(Tags.CONTACTS_CATEGORIES); 841 groupFirst = false; 842 } 843 s.data(Tags.CONTACTS_CATEGORY, c.getString(0)); 844 } 845 } finally { 846 c.close(); 847 } 848 } 849 if (!groupFirst) { 850 s.end(); 851 } 852 } 853 s.end().end(); // ApplicationData & Change 854 mUpdatedContacts.add(entityValues.getAsLong(ContactsContract.RawContacts._ID)); 855 } 856 if (!first) { 857 s.end(); // Commands 858 } 859 } finally { 860 ei.close(); 861 } 862 863 } 864 865 @Override 866 protected void cleanup(final int syncResult) { 867 if (syncResult == SYNC_RESULT_FAILED) { 868 return; 869 } 870 871 // Mark the changed contacts dirty = 0 872 // Permanently delete the user deletions 873 ContactsSyncParser.ContactOperations ops = new ContactsSyncParser.ContactOperations(); 874 for (final Long id: mUpdatedContacts) { 875 ops.add(ContentProviderOperation 876 .newUpdate(ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, 877 id).buildUpon() 878 .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") 879 .build()) 880 .withValue(ContactsContract.RawContacts.DIRTY, 0).build()); 881 } 882 for (final Long id: mDeletedContacts) { 883 ops.add(ContentProviderOperation.newDelete(ContentUris.withAppendedId( 884 ContactsContract.RawContacts.CONTENT_URI, id).buildUpon() 885 .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build()) 886 .build()); 887 } 888 ops.execute(mContext); 889 if (mParser != null && mParser.isGroupsUsed()) { 890 // Make sure the title column is set for all of our groups 891 // And that all of our groups are visible 892 // TODO Perhaps the visible part should only happen when the group is created, but 893 // this is fine for now. 894 final Uri groupsUri = uriWithAccountAndIsSyncAdapter(Groups.CONTENT_URI, 895 mAccount.mEmailAddress); 896 final Cursor c = mContentResolver.query(groupsUri, 897 new String[] {Groups.SOURCE_ID, Groups.TITLE}, 898 Groups.TITLE + " IS NULL", null, null); 899 final ContentValues values = new ContentValues(); 900 values.put(Groups.GROUP_VISIBLE, 1); 901 try { 902 while (c.moveToNext()) { 903 final String sourceId = c.getString(0); 904 values.put(Groups.TITLE, sourceId); 905 mContentResolver.update(uriWithAccountAndIsSyncAdapter(groupsUri, 906 mAccount.mEmailAddress), values, Groups.SOURCE_ID + "=?", 907 new String[] {sourceId}); 908 } 909 } finally { 910 c.close(); 911 } 912 } 913 } 914 915 /** 916 * Delete an account from the Contacts provider. 917 * @param context Our {@link Context} 918 * @param emailAddress The email address of the account we wish to delete 919 */ 920 public static void wipeAccountFromContentProvider(final Context context, 921 final String emailAddress) { 922 context.getContentResolver().delete(uriWithAccountAndIsSyncAdapter( 923 ContactsContract.RawContacts.CONTENT_URI, emailAddress), null, null); 924 } 925 } 926