EmailSyncAdapter.java revision d62e26b2ce5a09de6a43c1d2d4f4692eb5aac81a
1/* 2 * Copyright (C) 2008-2009 Marc Blank 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.exchange.adapter; 19 20import com.android.email.LegacyConversions; 21import com.android.email.Utility; 22import com.android.email.mail.Address; 23import com.android.email.mail.MeetingInfo; 24import com.android.email.mail.MessagingException; 25import com.android.email.mail.PackedString; 26import com.android.email.mail.Part; 27import com.android.email.mail.internet.MimeMessage; 28import com.android.email.mail.internet.MimeUtility; 29import com.android.email.provider.AttachmentProvider; 30import com.android.email.provider.EmailContent; 31import com.android.email.provider.EmailContent.Account; 32import com.android.email.provider.EmailContent.AccountColumns; 33import com.android.email.provider.EmailContent.Attachment; 34import com.android.email.provider.EmailContent.Body; 35import com.android.email.provider.EmailContent.Mailbox; 36import com.android.email.provider.EmailContent.Message; 37import com.android.email.provider.EmailContent.MessageColumns; 38import com.android.email.provider.EmailContent.SyncColumns; 39import com.android.email.provider.EmailProvider; 40import com.android.email.service.MailService; 41import com.android.exchange.Eas; 42import com.android.exchange.EasSyncService; 43import com.android.exchange.MessageMoveRequest; 44import com.android.exchange.utility.CalendarUtilities; 45 46import android.content.ContentProviderOperation; 47import android.content.ContentResolver; 48import android.content.ContentUris; 49import android.content.ContentValues; 50import android.content.OperationApplicationException; 51import android.database.Cursor; 52import android.net.Uri; 53import android.os.RemoteException; 54import android.webkit.MimeTypeMap; 55 56import java.io.ByteArrayInputStream; 57import java.io.IOException; 58import java.io.InputStream; 59import java.util.ArrayList; 60import java.util.Calendar; 61import java.util.GregorianCalendar; 62import java.util.TimeZone; 63 64/** 65 * Sync adapter for EAS email 66 * 67 */ 68public class EmailSyncAdapter extends AbstractSyncAdapter { 69 70 private static final int UPDATES_READ_COLUMN = 0; 71 private static final int UPDATES_MAILBOX_KEY_COLUMN = 1; 72 private static final int UPDATES_SERVER_ID_COLUMN = 2; 73 private static final int UPDATES_FLAG_COLUMN = 3; 74 private static final String[] UPDATES_PROJECTION = 75 {MessageColumns.FLAG_READ, MessageColumns.MAILBOX_KEY, SyncColumns.SERVER_ID, 76 MessageColumns.FLAG_FAVORITE}; 77 78 private static final int MESSAGE_ID_SUBJECT_ID_COLUMN = 0; 79 private static final int MESSAGE_ID_SUBJECT_SUBJECT_COLUMN = 1; 80 private static final String[] MESSAGE_ID_SUBJECT_PROJECTION = 81 new String[] { Message.RECORD_ID, MessageColumns.SUBJECT }; 82 83 private static final String WHERE_BODY_SOURCE_MESSAGE_KEY = Body.SOURCE_MESSAGE_KEY + "=?"; 84 private static final String WHERE_MAILBOX_KEY_AND_MOVED = 85 MessageColumns.MAILBOX_KEY + "=? AND (" + MessageColumns.FLAGS + "&" + 86 EasSyncService.MESSAGE_FLAG_MOVED_MESSAGE + ")!=0"; 87 private static final String[] FETCH_REQUEST_PROJECTION = 88 new String[] {EmailContent.RECORD_ID, SyncColumns.SERVER_ID}; 89 private static final int FETCH_REQUEST_RECORD_ID = 0; 90 private static final int FETCH_REQUEST_SERVER_ID = 1; 91 92 private static final String EMAIL_WINDOW_SIZE = "5"; 93 94 String[] mBindArguments = new String[2]; 95 String[] mBindArgument = new String[1]; 96 97 /*package*/ ArrayList<Long> mDeletedIdList = new ArrayList<Long>(); 98 /*package*/ ArrayList<Long> mUpdatedIdList = new ArrayList<Long>(); 99 /*package*/ ArrayList<FetchRequest> mFetchRequestList = new ArrayList<FetchRequest>(); 100 private boolean mFetchNeeded = false; 101 102 // Holds the parser's value for isLooping() 103 boolean mIsLooping = false; 104 105 public EmailSyncAdapter(EasSyncService service) { 106 super(service); 107 } 108 109 @Override 110 public void wipe() { 111 mContentResolver.delete(Message.CONTENT_URI, 112 Message.MAILBOX_KEY + "=" + mMailbox.mId, null); 113 mContentResolver.delete(Message.DELETED_CONTENT_URI, 114 Message.MAILBOX_KEY + "=" + mMailbox.mId, null); 115 mContentResolver.delete(Message.UPDATED_CONTENT_URI, 116 Message.MAILBOX_KEY + "=" + mMailbox.mId, null); 117 mService.clearRequests(); 118 mFetchRequestList.clear(); 119 // Delete attachments... 120 AttachmentProvider.deleteAllMailboxAttachmentFiles(mContext, mAccount.mId, mMailbox.mId); 121 } 122 123 private String getEmailFilter() { 124 switch (mAccount.mSyncLookback) { 125 case com.android.email.Account.SYNC_WINDOW_1_DAY: 126 return Eas.FILTER_1_DAY; 127 case com.android.email.Account.SYNC_WINDOW_3_DAYS: 128 return Eas.FILTER_3_DAYS; 129 case com.android.email.Account.SYNC_WINDOW_1_WEEK: 130 return Eas.FILTER_1_WEEK; 131 case com.android.email.Account.SYNC_WINDOW_2_WEEKS: 132 return Eas.FILTER_2_WEEKS; 133 case com.android.email.Account.SYNC_WINDOW_1_MONTH: 134 return Eas.FILTER_1_MONTH; 135 case com.android.email.Account.SYNC_WINDOW_ALL: 136 return Eas.FILTER_ALL; 137 default: 138 return Eas.FILTER_1_WEEK; 139 } 140 } 141 142 /** 143 * Holder for fetch request information (record id and server id) 144 */ 145 static class FetchRequest { 146 final long messageId; 147 final String serverId; 148 149 FetchRequest(long _messageId, String _serverId) { 150 messageId = _messageId; 151 serverId = _serverId; 152 } 153 } 154 155 @Override 156 public void sendSyncOptions(Double protocolVersion, Serializer s) 157 throws IOException { 158 mFetchRequestList.clear(); 159 // Find partially loaded messages; this should typically be a rare occurrence 160 Cursor c = mContext.getContentResolver().query(Message.CONTENT_URI, 161 FETCH_REQUEST_PROJECTION, 162 MessageColumns.FLAG_LOADED + "=" + Message.FLAG_LOADED_PARTIAL + " AND " + 163 MessageColumns.MAILBOX_KEY + "=?", 164 new String[] {Long.toString(mMailbox.mId)}, null); 165 try { 166 // Put all of these messages into a list; we'll need both id and server id 167 while (c.moveToNext()) { 168 mFetchRequestList.add(new FetchRequest(c.getLong(FETCH_REQUEST_RECORD_ID), 169 c.getString(FETCH_REQUEST_SERVER_ID))); 170 } 171 } finally { 172 c.close(); 173 } 174 175 // The "empty" case is typical; we send a request for changes, and also specify a sync 176 // window, body preference type (HTML for EAS 12.0 and later; MIME for EAS 2.5), and 177 // truncation 178 // If there are fetch requests, we only want the fetches (i.e. no changes from the server) 179 // so we turn MIME support off. Note that we are always using EAS 2.5 if there are fetch 180 // requests 181 if (mFetchRequestList.isEmpty()) { 182 s.tag(Tags.SYNC_DELETES_AS_MOVES); 183 s.tag(Tags.SYNC_GET_CHANGES); 184 s.data(Tags.SYNC_WINDOW_SIZE, EMAIL_WINDOW_SIZE); 185 s.start(Tags.SYNC_OPTIONS); 186 // Set the lookback appropriately (EAS calls this a "filter") 187 s.data(Tags.SYNC_FILTER_TYPE, getEmailFilter()); 188 // Set the truncation amount for all classes 189 if (protocolVersion >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 190 s.start(Tags.BASE_BODY_PREFERENCE); 191 // HTML for email 192 s.data(Tags.BASE_TYPE, Eas.BODY_PREFERENCE_HTML); 193 s.data(Tags.BASE_TRUNCATION_SIZE, Eas.EAS12_TRUNCATION_SIZE); 194 s.end(); 195 } else { 196 // Use MIME data for EAS 2.5 197 s.data(Tags.SYNC_MIME_SUPPORT, Eas.MIME_BODY_PREFERENCE_MIME); 198 s.data(Tags.SYNC_MIME_TRUNCATION, Eas.EAS2_5_TRUNCATION_SIZE); 199 } 200 s.end(); 201 } else { 202 s.start(Tags.SYNC_OPTIONS); 203 // Ask for plain text, rather than MIME data. This guarantees that we'll get a usable 204 // text body 205 s.data(Tags.SYNC_MIME_SUPPORT, Eas.MIME_BODY_PREFERENCE_TEXT); 206 s.data(Tags.SYNC_TRUNCATION, Eas.EAS2_5_TRUNCATION_SIZE); 207 s.end(); 208 } 209 } 210 211 @Override 212 public boolean parse(InputStream is) throws IOException { 213 EasEmailSyncParser p = new EasEmailSyncParser(is, this); 214 mFetchNeeded = false; 215 boolean res = p.parse(); 216 // Hold on to the parser's value for isLooping() to pass back to the service 217 mIsLooping = p.isLooping(); 218 // If we've need a body fetch, or we've just finished one, return true in order to continue 219 if (mFetchNeeded || !mFetchRequestList.isEmpty()) { 220 return true; 221 } 222 return res; 223 } 224 225 /** 226 * Return the value of isLooping() as returned from the parser 227 */ 228 @Override 229 public boolean isLooping() { 230 return mIsLooping; 231 } 232 233 @Override 234 public boolean isSyncable() { 235 return true; 236 } 237 238 public class EasEmailSyncParser extends AbstractSyncParser { 239 240 private static final String WHERE_SERVER_ID_AND_MAILBOX_KEY = 241 SyncColumns.SERVER_ID + "=? and " + MessageColumns.MAILBOX_KEY + "=?"; 242 243 private String mMailboxIdAsString; 244 245 ArrayList<Message> newEmails = new ArrayList<Message>(); 246 ArrayList<Message> fetchedEmails = new ArrayList<Message>(); 247 ArrayList<Long> deletedEmails = new ArrayList<Long>(); 248 ArrayList<ServerChange> changedEmails = new ArrayList<ServerChange>(); 249 250 public EasEmailSyncParser(InputStream in, EmailSyncAdapter adapter) throws IOException { 251 super(in, adapter); 252 mMailboxIdAsString = Long.toString(mMailbox.mId); 253 } 254 255 public void addData (Message msg) throws IOException { 256 ArrayList<Attachment> atts = new ArrayList<Attachment>(); 257 boolean truncated = false; 258 259 while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) { 260 switch (tag) { 261 case Tags.EMAIL_ATTACHMENTS: 262 case Tags.BASE_ATTACHMENTS: // BASE_ATTACHMENTS is used in EAS 12.0 and up 263 attachmentsParser(atts, msg); 264 break; 265 case Tags.EMAIL_TO: 266 msg.mTo = Address.pack(Address.parse(getValue())); 267 break; 268 case Tags.EMAIL_FROM: 269 Address[] froms = Address.parse(getValue()); 270 if (froms != null && froms.length > 0) { 271 msg.mDisplayName = froms[0].toFriendly(); 272 } 273 msg.mFrom = Address.pack(froms); 274 break; 275 case Tags.EMAIL_CC: 276 msg.mCc = Address.pack(Address.parse(getValue())); 277 break; 278 case Tags.EMAIL_REPLY_TO: 279 msg.mReplyTo = Address.pack(Address.parse(getValue())); 280 break; 281 case Tags.EMAIL_DATE_RECEIVED: 282 msg.mTimeStamp = Utility.parseEmailDateTimeToMillis(getValue()); 283 break; 284 case Tags.EMAIL_SUBJECT: 285 msg.mSubject = getValue(); 286 break; 287 case Tags.EMAIL_READ: 288 msg.mFlagRead = getValueInt() == 1; 289 break; 290 case Tags.BASE_BODY: 291 bodyParser(msg); 292 break; 293 case Tags.EMAIL_FLAG: 294 msg.mFlagFavorite = flagParser(); 295 break; 296 case Tags.EMAIL_MIME_TRUNCATED: 297 truncated = getValueInt() == 1; 298 break; 299 case Tags.EMAIL_MIME_DATA: 300 // We get MIME data for EAS 2.5. First we parse it, then we take the 301 // html and/or plain text data and store it in the message 302 if (truncated) { 303 // If the MIME data is truncated, don't bother parsing it, because 304 // it will take time and throw an exception anyway when EOF is reached 305 // In this case, we will load the body separately by tagging the message 306 // "partially loaded". 307 userLog("Partially loaded: ", msg.mServerId); 308 msg.mFlagLoaded = Message.FLAG_LOADED_PARTIAL; 309 mFetchNeeded = true; 310 } else { 311 mimeBodyParser(msg, getValue()); 312 } 313 break; 314 case Tags.EMAIL_BODY: 315 String text = getValue(); 316 msg.mText = text; 317 break; 318 case Tags.EMAIL_MESSAGE_CLASS: 319 String messageClass = getValue(); 320 if (messageClass.equals("IPM.Schedule.Meeting.Request")) { 321 msg.mFlags |= Message.FLAG_INCOMING_MEETING_INVITE; 322 } else if (messageClass.equals("IPM.Schedule.Meeting.Canceled")) { 323 msg.mFlags |= Message.FLAG_INCOMING_MEETING_CANCEL; 324 } 325 break; 326 case Tags.EMAIL_MEETING_REQUEST: 327 meetingRequestParser(msg); 328 break; 329 default: 330 skipTag(); 331 } 332 } 333 334 if (atts.size() > 0) { 335 msg.mAttachments = atts; 336 } 337 } 338 339 /** 340 * Set up the meetingInfo field in the message with various pieces of information gleaned 341 * from MeetingRequest tags. This information will be used later to generate an appropriate 342 * reply email if the user chooses to respond 343 * @param msg the Message being built 344 * @throws IOException 345 */ 346 private void meetingRequestParser(Message msg) throws IOException { 347 PackedString.Builder packedString = new PackedString.Builder(); 348 while (nextTag(Tags.EMAIL_MEETING_REQUEST) != END) { 349 switch (tag) { 350 case Tags.EMAIL_DTSTAMP: 351 packedString.put(MeetingInfo.MEETING_DTSTAMP, getValue()); 352 break; 353 case Tags.EMAIL_START_TIME: 354 packedString.put(MeetingInfo.MEETING_DTSTART, getValue()); 355 break; 356 case Tags.EMAIL_END_TIME: 357 packedString.put(MeetingInfo.MEETING_DTEND, getValue()); 358 break; 359 case Tags.EMAIL_ORGANIZER: 360 packedString.put(MeetingInfo.MEETING_ORGANIZER_EMAIL, getValue()); 361 break; 362 case Tags.EMAIL_LOCATION: 363 packedString.put(MeetingInfo.MEETING_LOCATION, getValue()); 364 break; 365 case Tags.EMAIL_GLOBAL_OBJID: 366 packedString.put(MeetingInfo.MEETING_UID, 367 CalendarUtilities.getUidFromGlobalObjId(getValue())); 368 break; 369 case Tags.EMAIL_CATEGORIES: 370 nullParser(); 371 break; 372 case Tags.EMAIL_RECURRENCES: 373 recurrencesParser(); 374 break; 375 case Tags.EMAIL_RESPONSE_REQUESTED: 376 packedString.put(MeetingInfo.MEETING_RESPONSE_REQUESTED, getValue()); 377 break; 378 default: 379 skipTag(); 380 } 381 } 382 if (msg.mSubject != null) { 383 packedString.put(MeetingInfo.MEETING_TITLE, msg.mSubject); 384 } 385 msg.mMeetingInfo = packedString.toString(); 386 } 387 388 private void nullParser() throws IOException { 389 while (nextTag(Tags.EMAIL_CATEGORIES) != END) { 390 skipTag(); 391 } 392 } 393 394 private void recurrencesParser() throws IOException { 395 while (nextTag(Tags.EMAIL_RECURRENCES) != END) { 396 switch (tag) { 397 case Tags.EMAIL_RECURRENCE: 398 nullParser(); 399 break; 400 default: 401 skipTag(); 402 } 403 } 404 } 405 406 /** 407 * Parse a message from the server stream. 408 * @return the parsed Message 409 * @throws IOException 410 */ 411 private Message addParser() throws IOException { 412 Message msg = new Message(); 413 msg.mAccountKey = mAccount.mId; 414 msg.mMailboxKey = mMailbox.mId; 415 msg.mFlagLoaded = Message.FLAG_LOADED_COMPLETE; 416 int status = -1; 417 418 while (nextTag(Tags.SYNC_ADD) != END) { 419 switch (tag) { 420 case Tags.SYNC_SERVER_ID: 421 msg.mServerId = getValue(); 422 break; 423 case Tags.SYNC_STATUS: 424 status = getValueInt(); 425 break; 426 case Tags.SYNC_APPLICATION_DATA: 427 addData(msg); 428 break; 429 default: 430 skipTag(); 431 } 432 } 433 // For sync, status 1 = success 434 if (status != 1) { 435 throw new SyncStatusException(msg.mServerId, status); 436 } 437 return msg; 438 } 439 440 // For now, we only care about the "active" state 441 private Boolean flagParser() throws IOException { 442 Boolean state = false; 443 while (nextTag(Tags.EMAIL_FLAG) != END) { 444 switch (tag) { 445 case Tags.EMAIL_FLAG_STATUS: 446 state = getValueInt() == 2; 447 break; 448 default: 449 skipTag(); 450 } 451 } 452 return state; 453 } 454 455 private void bodyParser(Message msg) throws IOException { 456 String bodyType = Eas.BODY_PREFERENCE_TEXT; 457 String body = ""; 458 while (nextTag(Tags.EMAIL_BODY) != END) { 459 switch (tag) { 460 case Tags.BASE_TYPE: 461 bodyType = getValue(); 462 break; 463 case Tags.BASE_DATA: 464 body = getValue(); 465 break; 466 default: 467 skipTag(); 468 } 469 } 470 // We always ask for TEXT or HTML; there's no third option 471 if (bodyType.equals(Eas.BODY_PREFERENCE_HTML)) { 472 msg.mHtml = body; 473 } else { 474 msg.mText = body; 475 } 476 } 477 478 /** 479 * Parses untruncated MIME data, saving away the text parts 480 * @param msg the message we're building 481 * @param mimeData the MIME data we've received from the server 482 * @throws IOException 483 */ 484 private void mimeBodyParser(Message msg, String mimeData) throws IOException { 485 try { 486 ByteArrayInputStream in = new ByteArrayInputStream(mimeData.getBytes()); 487 // The constructor parses the message 488 MimeMessage mimeMessage = new MimeMessage(in); 489 // Now process body parts & attachments 490 ArrayList<Part> viewables = new ArrayList<Part>(); 491 // We'll ignore the attachments, as we'll get them directly from EAS 492 ArrayList<Part> attachments = new ArrayList<Part>(); 493 MimeUtility.collectParts(mimeMessage, viewables, attachments); 494 Body tempBody = new Body(); 495 // updateBodyFields fills in the content fields of the Body 496 LegacyConversions.updateBodyFields(tempBody, msg, viewables); 497 // But we need them in the message itself for handling during commit() 498 msg.mHtml = tempBody.mHtmlContent; 499 msg.mText = tempBody.mTextContent; 500 } catch (MessagingException e) { 501 // This would most likely indicate a broken stream 502 throw new IOException(e); 503 } 504 } 505 506 private void attachmentsParser(ArrayList<Attachment> atts, Message msg) throws IOException { 507 while (nextTag(Tags.EMAIL_ATTACHMENTS) != END) { 508 switch (tag) { 509 case Tags.EMAIL_ATTACHMENT: 510 case Tags.BASE_ATTACHMENT: // BASE_ATTACHMENT is used in EAS 12.0 and up 511 attachmentParser(atts, msg); 512 break; 513 default: 514 skipTag(); 515 } 516 } 517 } 518 519 private void attachmentParser(ArrayList<Attachment> atts, Message msg) throws IOException { 520 String fileName = null; 521 String length = null; 522 String location = null; 523 524 while (nextTag(Tags.EMAIL_ATTACHMENT) != END) { 525 switch (tag) { 526 // We handle both EAS 2.5 and 12.0+ attachments here 527 case Tags.EMAIL_DISPLAY_NAME: 528 case Tags.BASE_DISPLAY_NAME: 529 fileName = getValue(); 530 break; 531 case Tags.EMAIL_ATT_NAME: 532 case Tags.BASE_FILE_REFERENCE: 533 location = getValue(); 534 break; 535 case Tags.EMAIL_ATT_SIZE: 536 case Tags.BASE_ESTIMATED_DATA_SIZE: 537 length = getValue(); 538 break; 539 default: 540 skipTag(); 541 } 542 } 543 544 if ((fileName != null) && (length != null) && (location != null)) { 545 Attachment att = new Attachment(); 546 att.mEncoding = "base64"; 547 att.mSize = Long.parseLong(length); 548 att.mFileName = fileName; 549 att.mLocation = location; 550 att.mMimeType = getMimeTypeFromFileName(fileName); 551 att.mAccountKey = mService.mAccount.mId; 552 atts.add(att); 553 msg.mFlagAttachment = true; 554 } 555 } 556 557 /** 558 * Try to determine a mime type from a file name, defaulting to application/x, where x 559 * is either the extension or (if none) octet-stream 560 * At the moment, this is somewhat lame, since many file types aren't recognized 561 * @param fileName the file name to ponder 562 * @return 563 */ 564 // Note: The MimeTypeMap method currently uses a very limited set of mime types 565 // A bug has been filed against this issue. 566 public String getMimeTypeFromFileName(String fileName) { 567 String mimeType; 568 int lastDot = fileName.lastIndexOf('.'); 569 String extension = null; 570 if ((lastDot > 0) && (lastDot < fileName.length() - 1)) { 571 extension = fileName.substring(lastDot + 1).toLowerCase(); 572 } 573 if (extension == null) { 574 // A reasonable default for now. 575 mimeType = "application/octet-stream"; 576 } else { 577 mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); 578 if (mimeType == null) { 579 mimeType = "application/" + extension; 580 } 581 } 582 return mimeType; 583 } 584 585 private Cursor getServerIdCursor(String serverId, String[] projection) { 586 mBindArguments[0] = serverId; 587 mBindArguments[1] = mMailboxIdAsString; 588 return mContentResolver.query(Message.CONTENT_URI, projection, 589 WHERE_SERVER_ID_AND_MAILBOX_KEY, mBindArguments, null); 590 } 591 592 /*package*/ void deleteParser(ArrayList<Long> deletes, int entryTag) throws IOException { 593 while (nextTag(entryTag) != END) { 594 switch (tag) { 595 case Tags.SYNC_SERVER_ID: 596 String serverId = getValue(); 597 // Find the message in this mailbox with the given serverId 598 Cursor c = getServerIdCursor(serverId, MESSAGE_ID_SUBJECT_PROJECTION); 599 try { 600 if (c.moveToFirst()) { 601 deletes.add(c.getLong(MESSAGE_ID_SUBJECT_ID_COLUMN)); 602 if (Eas.USER_LOG) { 603 userLog("Deleting ", serverId + ", " 604 + c.getString(MESSAGE_ID_SUBJECT_SUBJECT_COLUMN)); 605 } 606 } 607 } finally { 608 c.close(); 609 } 610 break; 611 default: 612 skipTag(); 613 } 614 } 615 } 616 617 class ServerChange { 618 long id; 619 Boolean read; 620 Boolean flag; 621 622 ServerChange(long _id, Boolean _read, Boolean _flag) { 623 id = _id; 624 read = _read; 625 flag = _flag; 626 } 627 } 628 629 /*package*/ void changeParser(ArrayList<ServerChange> changes) throws IOException { 630 String serverId = null; 631 Boolean oldRead = false; 632 Boolean oldFlag = false; 633 long id = 0; 634 while (nextTag(Tags.SYNC_CHANGE) != END) { 635 switch (tag) { 636 case Tags.SYNC_SERVER_ID: 637 serverId = getValue(); 638 Cursor c = getServerIdCursor(serverId, Message.LIST_PROJECTION); 639 try { 640 if (c.moveToFirst()) { 641 userLog("Changing ", serverId); 642 oldRead = c.getInt(Message.LIST_READ_COLUMN) == Message.READ; 643 oldFlag = c.getInt(Message.LIST_FAVORITE_COLUMN) == 1; 644 id = c.getLong(Message.LIST_ID_COLUMN); 645 } 646 } finally { 647 c.close(); 648 } 649 break; 650 case Tags.SYNC_APPLICATION_DATA: 651 changeApplicationDataParser(changes, oldRead, oldFlag, id); 652 break; 653 default: 654 skipTag(); 655 } 656 } 657 } 658 659 private void changeApplicationDataParser(ArrayList<ServerChange> changes, Boolean oldRead, 660 Boolean oldFlag, long id) throws IOException { 661 Boolean read = null; 662 Boolean flag = null; 663 while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) { 664 switch (tag) { 665 case Tags.EMAIL_READ: 666 read = getValueInt() == 1; 667 break; 668 case Tags.EMAIL_FLAG: 669 flag = flagParser(); 670 break; 671 default: 672 skipTag(); 673 } 674 } 675 if (((read != null) && !oldRead.equals(read)) || 676 ((flag != null) && !oldFlag.equals(flag))) { 677 changes.add(new ServerChange(id, read, flag)); 678 } 679 } 680 681 /* (non-Javadoc) 682 * @see com.android.exchange.adapter.EasContentParser#commandsParser() 683 */ 684 @Override 685 public void commandsParser() throws IOException { 686 while (nextTag(Tags.SYNC_COMMANDS) != END) { 687 if (tag == Tags.SYNC_ADD) { 688 newEmails.add(addParser()); 689 incrementChangeCount(); 690 } else if (tag == Tags.SYNC_DELETE || tag == Tags.SYNC_SOFT_DELETE) { 691 deleteParser(deletedEmails, tag); 692 incrementChangeCount(); 693 } else if (tag == Tags.SYNC_CHANGE) { 694 changeParser(changedEmails); 695 incrementChangeCount(); 696 } else 697 skipTag(); 698 } 699 } 700 701 @Override 702 public void responsesParser() throws IOException { 703 while (nextTag(Tags.SYNC_RESPONSES) != END) { 704 if (tag == Tags.SYNC_ADD || tag == Tags.SYNC_CHANGE || tag == Tags.SYNC_DELETE) { 705 // We can ignore all of these 706 } else if (tag == Tags.SYNC_FETCH) { 707 try { 708 fetchedEmails.add(addParser()); 709 } catch (SyncStatusException sse) { 710 if (sse.mStatus == 8) { 711 // 8 = object not found; delete the message from EmailProvider 712 // No other status should be seen in a fetch response, except, perhaps, 713 // for some temporary server failure 714 mBindArguments[0] = sse.mServerId; 715 mBindArguments[1] = mMailboxIdAsString; 716 mContentResolver.delete(Message.CONTENT_URI, 717 WHERE_SERVER_ID_AND_MAILBOX_KEY, mBindArguments); 718 } 719 } 720 } 721 } 722 } 723 724 @Override 725 public void commit() { 726 int notifyCount = 0; 727 728 // Use a batch operation to handle the changes 729 // TODO New mail notifications? Who looks for these? 730 ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>(); 731 732 for (Message msg: fetchedEmails) { 733 // Find the original message's id (by serverId and mailbox) 734 Cursor c = getServerIdCursor(msg.mServerId, EmailContent.ID_PROJECTION); 735 String id = null; 736 try { 737 if (c.moveToFirst()) { 738 id = c.getString(EmailContent.ID_PROJECTION_COLUMN); 739 } 740 } finally { 741 c.close(); 742 } 743 744 // If we find one, we do two things atomically: 1) set the body text for the 745 // message, and 2) mark the message loaded (i.e. completely loaded) 746 if (id != null) { 747 userLog("Fetched body successfully for ", id); 748 mBindArgument[0] = id; 749 ops.add(ContentProviderOperation.newUpdate(Body.CONTENT_URI) 750 .withSelection(Body.MESSAGE_KEY + "=?", mBindArgument) 751 .withValue(Body.TEXT_CONTENT, msg.mText) 752 .build()); 753 ops.add(ContentProviderOperation.newUpdate(Message.CONTENT_URI) 754 .withSelection(EmailContent.RECORD_ID + "=?", mBindArgument) 755 .withValue(Message.FLAG_LOADED, Message.FLAG_LOADED_COMPLETE) 756 .build()); 757 } 758 } 759 760 for (Message msg: newEmails) { 761 if (!msg.mFlagRead) { 762 notifyCount++; 763 } 764 msg.addSaveOps(ops); 765 } 766 767 for (Long id : deletedEmails) { 768 ops.add(ContentProviderOperation.newDelete( 769 ContentUris.withAppendedId(Message.CONTENT_URI, id)).build()); 770 AttachmentProvider.deleteAllAttachmentFiles(mContext, mAccount.mId, id); 771 } 772 773 if (!changedEmails.isEmpty()) { 774 // Server wins in a conflict... 775 for (ServerChange change : changedEmails) { 776 ContentValues cv = new ContentValues(); 777 if (change.read != null) { 778 cv.put(MessageColumns.FLAG_READ, change.read); 779 } 780 if (change.flag != null) { 781 cv.put(MessageColumns.FLAG_FAVORITE, change.flag); 782 } 783 ops.add(ContentProviderOperation.newUpdate( 784 ContentUris.withAppendedId(Message.CONTENT_URI, change.id)) 785 .withValues(cv) 786 .build()); 787 } 788 } 789 790 // We only want to update the sync key here 791 ContentValues mailboxValues = new ContentValues(); 792 mailboxValues.put(Mailbox.SYNC_KEY, mMailbox.mSyncKey); 793 ops.add(ContentProviderOperation.newUpdate( 794 ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailbox.mId)) 795 .withValues(mailboxValues).build()); 796 797 addCleanupOps(ops); 798 799 // No commits if we're stopped 800 synchronized (mService.getSynchronizer()) { 801 if (mService.isStopped()) return; 802 try { 803 mContentResolver.applyBatch(EmailProvider.EMAIL_AUTHORITY, ops); 804 userLog(mMailbox.mDisplayName, " SyncKey saved as: ", mMailbox.mSyncKey); 805 } catch (RemoteException e) { 806 // There is nothing to be done here; fail by returning null 807 } catch (OperationApplicationException e) { 808 // There is nothing to be done here; fail by returning null 809 } 810 } 811 812 if (notifyCount > 0) { 813 // Use the new atomic add URI in EmailProvider 814 // We could add this to the operations being done, but it's not strictly 815 // speaking necessary, as the previous batch preserves the integrity of the 816 // database, whereas this is purely for notification purposes, and is itself atomic 817 ContentValues cv = new ContentValues(); 818 cv.put(EmailContent.FIELD_COLUMN_NAME, AccountColumns.NEW_MESSAGE_COUNT); 819 cv.put(EmailContent.ADD_COLUMN_NAME, notifyCount); 820 Uri uri = ContentUris.withAppendedId(Account.ADD_TO_FIELD_URI, mAccount.mId); 821 mContentResolver.update(uri, cv, null, null); 822 MailService.actionNotifyNewMessages(mContext, mAccount.mId); 823 } 824 } 825 } 826 827 @Override 828 public String getCollectionName() { 829 return "Email"; 830 } 831 832 private void addCleanupOps(ArrayList<ContentProviderOperation> ops) { 833 // If we've sent local deletions, clear out the deleted table 834 for (Long id: mDeletedIdList) { 835 ops.add(ContentProviderOperation.newDelete( 836 ContentUris.withAppendedId(Message.DELETED_CONTENT_URI, id)).build()); 837 } 838 // And same with the updates 839 for (Long id: mUpdatedIdList) { 840 ops.add(ContentProviderOperation.newDelete( 841 ContentUris.withAppendedId(Message.UPDATED_CONTENT_URI, id)).build()); 842 } 843 // Delete any moved messages (since we've just synced the mailbox, and no longer need the 844 // placeholder message); this prevents duplicates from appearing in the mailbox. 845 mBindArgument[0] = Long.toString(mMailbox.mId); 846 ops.add(ContentProviderOperation.newDelete(Message.CONTENT_URI) 847 .withSelection(WHERE_MAILBOX_KEY_AND_MOVED, mBindArgument).build()); 848 } 849 850 @Override 851 public void cleanup() { 852 if (!mDeletedIdList.isEmpty() || !mUpdatedIdList.isEmpty()) { 853 ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>(); 854 addCleanupOps(ops); 855 try { 856 mContext.getContentResolver() 857 .applyBatch(EmailProvider.EMAIL_AUTHORITY, ops); 858 } catch (RemoteException e) { 859 // There is nothing to be done here; fail by returning null 860 } catch (OperationApplicationException e) { 861 // There is nothing to be done here; fail by returning null 862 } 863 } 864 } 865 866 private String formatTwo(int num) { 867 if (num < 10) { 868 return "0" + (char)('0' + num); 869 } else 870 return Integer.toString(num); 871 } 872 873 /** 874 * Create date/time in RFC8601 format. Oddly enough, for calendar date/time, Microsoft uses 875 * a different format that excludes the punctuation (this is why I'm not putting this in a 876 * parent class) 877 */ 878 public String formatDateTime(Calendar calendar) { 879 StringBuilder sb = new StringBuilder(); 880 //YYYY-MM-DDTHH:MM:SS.MSSZ 881 sb.append(calendar.get(Calendar.YEAR)); 882 sb.append('-'); 883 sb.append(formatTwo(calendar.get(Calendar.MONTH) + 1)); 884 sb.append('-'); 885 sb.append(formatTwo(calendar.get(Calendar.DAY_OF_MONTH))); 886 sb.append('T'); 887 sb.append(formatTwo(calendar.get(Calendar.HOUR_OF_DAY))); 888 sb.append(':'); 889 sb.append(formatTwo(calendar.get(Calendar.MINUTE))); 890 sb.append(':'); 891 sb.append(formatTwo(calendar.get(Calendar.SECOND))); 892 sb.append(".000Z"); 893 return sb.toString(); 894 } 895 896 /** 897 * Note that messages in the deleted database preserve the message's unique id; therefore, we 898 * can utilize this id to find references to the message. The only reference situation at this 899 * point is in the Body table; it is when sending messages via SmartForward and SmartReply 900 */ 901 private boolean messageReferenced(ContentResolver cr, long id) { 902 mBindArgument[0] = Long.toString(id); 903 // See if this id is referenced in a body 904 Cursor c = cr.query(Body.CONTENT_URI, Body.ID_PROJECTION, WHERE_BODY_SOURCE_MESSAGE_KEY, 905 mBindArgument, null); 906 try { 907 return c.moveToFirst(); 908 } finally { 909 c.close(); 910 } 911 } 912 913 /*private*/ /** 914 * Serialize commands to delete items from the server; as we find items to delete, add their 915 * id's to the deletedId's array 916 * 917 * @param s the Serializer we're using to create post data 918 * @param deletedIds ids whose deletions are being sent to the server 919 * @param first whether or not this is the first command being sent 920 * @return true if SYNC_COMMANDS hasn't been sent (false otherwise) 921 * @throws IOException 922 */ 923 boolean sendDeletedItems(Serializer s, ArrayList<Long> deletedIds, boolean first) 924 throws IOException { 925 ContentResolver cr = mContext.getContentResolver(); 926 927 // Find any of our deleted items 928 Cursor c = cr.query(Message.DELETED_CONTENT_URI, Message.LIST_PROJECTION, 929 MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null); 930 // We keep track of the list of deleted item id's so that we can remove them from the 931 // deleted table after the server receives our command 932 deletedIds.clear(); 933 try { 934 while (c.moveToNext()) { 935 String serverId = c.getString(Message.LIST_SERVER_ID_COLUMN); 936 // Keep going if there's no serverId 937 if (serverId == null) { 938 continue; 939 // Also check if this message is referenced elsewhere 940 } else if (messageReferenced(cr, c.getLong(Message.CONTENT_ID_COLUMN))) { 941 userLog("Postponing deletion of referenced message: ", serverId); 942 continue; 943 } else if (first) { 944 s.start(Tags.SYNC_COMMANDS); 945 first = false; 946 } 947 // Send the command to delete this message 948 s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end(); 949 deletedIds.add(c.getLong(Message.LIST_ID_COLUMN)); 950 } 951 } finally { 952 c.close(); 953 } 954 955 return first; 956 } 957 958 @Override 959 public boolean sendLocalChanges(Serializer s) throws IOException { 960 ContentResolver cr = mContext.getContentResolver(); 961 962 if (getSyncKey().equals("0")) { 963 return false; 964 } 965 966 // Never upsync from these folders 967 if (mMailbox.mType == Mailbox.TYPE_DRAFTS || mMailbox.mType == Mailbox.TYPE_OUTBOX) { 968 return false; 969 } 970 971 // This code is split out for unit testing purposes 972 boolean firstCommand = sendDeletedItems(s, mDeletedIdList, true); 973 974 if (!mFetchRequestList.isEmpty()) { 975 // Add FETCH commands for messages that need a body (i.e. we didn't find it during 976 // our earlier sync; this happens only in EAS 2.5 where the body couldn't be found 977 // after parsing the message's MIME data) 978 if (firstCommand) { 979 s.start(Tags.SYNC_COMMANDS); 980 firstCommand = false; 981 } 982 for (FetchRequest req: mFetchRequestList) { 983 s.start(Tags.SYNC_FETCH).data(Tags.SYNC_SERVER_ID, req.serverId).end(); 984 } 985 } 986 987 // Find our trash mailbox, since deletions will have been moved there... 988 long trashMailboxId = 989 Mailbox.findMailboxOfType(mContext, mMailbox.mAccountKey, Mailbox.TYPE_TRASH); 990 991 // Do the same now for updated items 992 Cursor c = cr.query(Message.UPDATED_CONTENT_URI, Message.LIST_PROJECTION, 993 MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null); 994 995 // We keep track of the list of updated item id's as we did above with deleted items 996 mUpdatedIdList.clear(); 997 try { 998 while (c.moveToNext()) { 999 long id = c.getLong(Message.LIST_ID_COLUMN); 1000 // Say we've handled this update 1001 mUpdatedIdList.add(id); 1002 // We have the id of the changed item. But first, we have to find out its current 1003 // state, since the updated table saves the opriginal state 1004 Cursor currentCursor = cr.query(ContentUris.withAppendedId(Message.CONTENT_URI, id), 1005 UPDATES_PROJECTION, null, null, null); 1006 try { 1007 // If this item no longer exists (shouldn't be possible), just move along 1008 if (!currentCursor.moveToFirst()) { 1009 continue; 1010 } 1011 // Keep going if there's no serverId 1012 String serverId = currentCursor.getString(UPDATES_SERVER_ID_COLUMN); 1013 if (serverId == null) { 1014 continue; 1015 } 1016 // If the message is now in the trash folder, it has been deleted by the user 1017 if (currentCursor.getLong(UPDATES_MAILBOX_KEY_COLUMN) == trashMailboxId) { 1018 if (firstCommand) { 1019 s.start(Tags.SYNC_COMMANDS); 1020 firstCommand = false; 1021 } 1022 // Send the command to delete this message 1023 s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end(); 1024 continue; 1025 } 1026 1027 boolean flagChange = false; 1028 boolean readChange = false; 1029 1030 long mailbox = currentCursor.getLong(UPDATES_MAILBOX_KEY_COLUMN); 1031 if (mailbox != c.getLong(Message.LIST_MAILBOX_KEY_COLUMN)) { 1032 // The message has moved to another mailbox; add a request for this 1033 // Note: The Sync command doesn't handle moving messages, so we need 1034 // to handle this as a "request" (similar to meeting response and 1035 // attachment load) 1036 mService.addRequest(new MessageMoveRequest(id, mailbox)); 1037 // Regardless of other changes that might be made, we don't want to indicate 1038 // that this message has been updated until the move request has been 1039 // handled (without this, a crash between the flag upsync and the move 1040 // would cause the move to be lost) 1041 mUpdatedIdList.remove(id); 1042 } 1043 1044 // We can only send flag changes to the server in 12.0 or later 1045 int flag = 0; 1046 if (mService.mProtocolVersionDouble >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 1047 flag = currentCursor.getInt(UPDATES_FLAG_COLUMN); 1048 if (flag != c.getInt(Message.LIST_FAVORITE_COLUMN)) { 1049 flagChange = true; 1050 } 1051 } 1052 1053 int read = currentCursor.getInt(UPDATES_READ_COLUMN); 1054 if (read != c.getInt(Message.LIST_READ_COLUMN)) { 1055 readChange = true; 1056 } 1057 1058 if (!flagChange && !readChange) { 1059 // In this case, we've got nothing to send to the server 1060 continue; 1061 } 1062 1063 if (firstCommand) { 1064 s.start(Tags.SYNC_COMMANDS); 1065 firstCommand = false; 1066 } 1067 // Send the change to "read" and "favorite" (flagged) 1068 s.start(Tags.SYNC_CHANGE) 1069 .data(Tags.SYNC_SERVER_ID, c.getString(Message.LIST_SERVER_ID_COLUMN)) 1070 .start(Tags.SYNC_APPLICATION_DATA); 1071 if (readChange) { 1072 s.data(Tags.EMAIL_READ, Integer.toString(read)); 1073 } 1074 // "Flag" is a relatively complex concept in EAS 12.0 and above. It is not only 1075 // the boolean "favorite" that we think of in Gmail, but it also represents a 1076 // follow up action, which can include a subject, start and due dates, and even 1077 // recurrences. We don't support any of this as yet, but EAS 12.0 and higher 1078 // require that a flag contain a status, a type, and four date fields, two each 1079 // for start date and end (due) date. 1080 if (flagChange) { 1081 if (flag != 0) { 1082 // Status 2 = set flag 1083 s.start(Tags.EMAIL_FLAG).data(Tags.EMAIL_FLAG_STATUS, "2"); 1084 // "FollowUp" is the standard type 1085 s.data(Tags.EMAIL_FLAG_TYPE, "FollowUp"); 1086 long now = System.currentTimeMillis(); 1087 Calendar calendar = 1088 GregorianCalendar.getInstance(TimeZone.getTimeZone("GMT")); 1089 calendar.setTimeInMillis(now); 1090 // Flags are required to have a start date and end date (duplicated) 1091 // First, we'll set the current date/time in GMT as the start time 1092 String utc = formatDateTime(calendar); 1093 s.data(Tags.TASK_START_DATE, utc).data(Tags.TASK_UTC_START_DATE, utc); 1094 // And then we'll use one week from today for completion date 1095 calendar.setTimeInMillis(now + 1*WEEKS); 1096 utc = formatDateTime(calendar); 1097 s.data(Tags.TASK_DUE_DATE, utc).data(Tags.TASK_UTC_DUE_DATE, utc); 1098 s.end(); 1099 } else { 1100 s.tag(Tags.EMAIL_FLAG); 1101 } 1102 } 1103 s.end().end(); // SYNC_APPLICATION_DATA, SYNC_CHANGE 1104 } finally { 1105 currentCursor.close(); 1106 } 1107 } 1108 } finally { 1109 c.close(); 1110 } 1111 1112 if (!firstCommand) { 1113 s.end(); // SYNC_COMMANDS 1114 } 1115 return false; 1116 } 1117} 1118