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