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 18 package com.android.exchange.adapter; 19 20 import android.content.ContentProviderOperation; 21 import android.content.ContentResolver; 22 import android.content.ContentUris; 23 import android.content.ContentValues; 24 import android.content.OperationApplicationException; 25 import android.database.Cursor; 26 import android.net.Uri; 27 import android.os.RemoteException; 28 import android.text.TextUtils; 29 import android.util.Base64; 30 import android.util.Log; 31 import android.webkit.MimeTypeMap; 32 33 import com.android.emailcommon.internet.MimeMessage; 34 import com.android.emailcommon.internet.MimeUtility; 35 import com.android.emailcommon.mail.Address; 36 import com.android.emailcommon.mail.MeetingInfo; 37 import com.android.emailcommon.mail.MessagingException; 38 import com.android.emailcommon.mail.PackedString; 39 import com.android.emailcommon.mail.Part; 40 import com.android.emailcommon.provider.Account; 41 import com.android.emailcommon.provider.EmailContent; 42 import com.android.emailcommon.provider.EmailContent.AccountColumns; 43 import com.android.emailcommon.provider.EmailContent.Attachment; 44 import com.android.emailcommon.provider.EmailContent.Body; 45 import com.android.emailcommon.provider.EmailContent.MailboxColumns; 46 import com.android.emailcommon.provider.EmailContent.Message; 47 import com.android.emailcommon.provider.EmailContent.MessageColumns; 48 import com.android.emailcommon.provider.EmailContent.SyncColumns; 49 import com.android.emailcommon.provider.Mailbox; 50 import com.android.emailcommon.provider.Policy; 51 import com.android.emailcommon.provider.ProviderUnavailableException; 52 import com.android.emailcommon.service.SyncWindow; 53 import com.android.emailcommon.utility.AttachmentUtilities; 54 import com.android.emailcommon.utility.ConversionUtilities; 55 import com.android.emailcommon.utility.Utility; 56 import com.android.exchange.CommandStatusException; 57 import com.android.exchange.Eas; 58 import com.android.exchange.EasResponse; 59 import com.android.exchange.EasSyncService; 60 import com.android.exchange.MessageMoveRequest; 61 import com.android.exchange.R; 62 import com.android.exchange.utility.CalendarUtilities; 63 64 import com.google.common.annotations.VisibleForTesting; 65 66 import org.apache.http.HttpStatus; 67 import org.apache.http.entity.ByteArrayEntity; 68 69 import java.io.ByteArrayInputStream; 70 import java.io.IOException; 71 import java.io.InputStream; 72 import java.util.ArrayList; 73 import java.util.Calendar; 74 import java.util.GregorianCalendar; 75 import java.util.TimeZone; 76 77 /** 78 * Sync adapter for EAS email 79 * 80 */ 81 public class EmailSyncAdapter extends AbstractSyncAdapter { 82 83 private static final String TAG = "EmailSyncAdapter"; 84 85 private static final int UPDATES_READ_COLUMN = 0; 86 private static final int UPDATES_MAILBOX_KEY_COLUMN = 1; 87 private static final int UPDATES_SERVER_ID_COLUMN = 2; 88 private static final int UPDATES_FLAG_COLUMN = 3; 89 private static final String[] UPDATES_PROJECTION = 90 {MessageColumns.FLAG_READ, MessageColumns.MAILBOX_KEY, SyncColumns.SERVER_ID, 91 MessageColumns.FLAG_FAVORITE}; 92 93 private static final int MESSAGE_ID_SUBJECT_ID_COLUMN = 0; 94 private static final int MESSAGE_ID_SUBJECT_SUBJECT_COLUMN = 1; 95 private static final String[] MESSAGE_ID_SUBJECT_PROJECTION = 96 new String[] { Message.RECORD_ID, MessageColumns.SUBJECT }; 97 98 private static final String WHERE_BODY_SOURCE_MESSAGE_KEY = Body.SOURCE_MESSAGE_KEY + "=?"; 99 private static final String WHERE_MAILBOX_KEY_AND_MOVED = 100 MessageColumns.MAILBOX_KEY + "=? AND (" + MessageColumns.FLAGS + "&" + 101 EasSyncService.MESSAGE_FLAG_MOVED_MESSAGE + ")!=0"; 102 private static final String[] FETCH_REQUEST_PROJECTION = 103 new String[] {EmailContent.RECORD_ID, SyncColumns.SERVER_ID}; 104 private static final int FETCH_REQUEST_RECORD_ID = 0; 105 private static final int FETCH_REQUEST_SERVER_ID = 1; 106 107 private static final String EMAIL_WINDOW_SIZE = "5"; 108 109 @VisibleForTesting 110 static final int LAST_VERB_REPLY = 1; 111 @VisibleForTesting 112 static final int LAST_VERB_REPLY_ALL = 2; 113 @VisibleForTesting 114 static final int LAST_VERB_FORWARD = 3; 115 116 private final String[] mBindArguments = new String[2]; 117 private final String[] mBindArgument = new String[1]; 118 119 @VisibleForTesting 120 ArrayList<Long> mDeletedIdList = new ArrayList<Long>(); 121 @VisibleForTesting 122 ArrayList<Long> mUpdatedIdList = new ArrayList<Long>(); 123 private final ArrayList<FetchRequest> mFetchRequestList = new ArrayList<FetchRequest>(); 124 private boolean mFetchNeeded = false; 125 126 // Holds the parser's value for isLooping() 127 private boolean mIsLooping = false; 128 129 // The policy (if any) for this adapter's Account 130 private final Policy mPolicy; 131 132 public EmailSyncAdapter(EasSyncService service) { 133 super(service); 134 // If we've got an account with a policy, cache it now 135 if (mAccount.mPolicyKey != 0) { 136 mPolicy = Policy.restorePolicyWithId(mContext, mAccount.mPolicyKey); 137 } else { 138 mPolicy = null; 139 } 140 } 141 142 @Override 143 public void wipe() { 144 mContentResolver.delete(Message.CONTENT_URI, 145 Message.MAILBOX_KEY + "=" + mMailbox.mId, null); 146 mContentResolver.delete(Message.DELETED_CONTENT_URI, 147 Message.MAILBOX_KEY + "=" + mMailbox.mId, null); 148 mContentResolver.delete(Message.UPDATED_CONTENT_URI, 149 Message.MAILBOX_KEY + "=" + mMailbox.mId, null); 150 mService.clearRequests(); 151 mFetchRequestList.clear(); 152 // Delete attachments... 153 AttachmentUtilities.deleteAllMailboxAttachmentFiles(mContext, mAccount.mId, mMailbox.mId); 154 } 155 156 private String getEmailFilter() { 157 int syncLookback = mMailbox.mSyncLookback; 158 if (syncLookback == SyncWindow.SYNC_WINDOW_UNKNOWN 159 || mMailbox.mType == Mailbox.TYPE_INBOX) { 160 syncLookback = mAccount.mSyncLookback; 161 } 162 switch (syncLookback) { 163 case SyncWindow.SYNC_WINDOW_AUTO: 164 return Eas.FILTER_AUTO; 165 case SyncWindow.SYNC_WINDOW_1_DAY: 166 return Eas.FILTER_1_DAY; 167 case SyncWindow.SYNC_WINDOW_3_DAYS: 168 return Eas.FILTER_3_DAYS; 169 case SyncWindow.SYNC_WINDOW_1_WEEK: 170 return Eas.FILTER_1_WEEK; 171 case SyncWindow.SYNC_WINDOW_2_WEEKS: 172 return Eas.FILTER_2_WEEKS; 173 case SyncWindow.SYNC_WINDOW_1_MONTH: 174 return Eas.FILTER_1_MONTH; 175 case SyncWindow.SYNC_WINDOW_ALL: 176 return Eas.FILTER_ALL; 177 default: 178 return Eas.FILTER_1_WEEK; 179 } 180 } 181 182 /** 183 * Holder for fetch request information (record id and server id) 184 */ 185 private static class FetchRequest { 186 @SuppressWarnings("unused") 187 final long messageId; 188 final String serverId; 189 190 FetchRequest(long _messageId, String _serverId) { 191 messageId = _messageId; 192 serverId = _serverId; 193 } 194 } 195 196 @Override 197 public void sendSyncOptions(Double protocolVersion, Serializer s) 198 throws IOException { 199 mFetchRequestList.clear(); 200 // Find partially loaded messages; this should typically be a rare occurrence 201 Cursor c = mContext.getContentResolver().query(Message.CONTENT_URI, 202 FETCH_REQUEST_PROJECTION, 203 MessageColumns.FLAG_LOADED + "=" + Message.FLAG_LOADED_PARTIAL + " AND " + 204 MessageColumns.MAILBOX_KEY + "=?", 205 new String[] {Long.toString(mMailbox.mId)}, null); 206 try { 207 // Put all of these messages into a list; we'll need both id and server id 208 while (c.moveToNext()) { 209 mFetchRequestList.add(new FetchRequest(c.getLong(FETCH_REQUEST_RECORD_ID), 210 c.getString(FETCH_REQUEST_SERVER_ID))); 211 } 212 } finally { 213 c.close(); 214 } 215 216 // The "empty" case is typical; we send a request for changes, and also specify a sync 217 // window, body preference type (HTML for EAS 12.0 and later; MIME for EAS 2.5), and 218 // truncation 219 // If there are fetch requests, we only want the fetches (i.e. no changes from the server) 220 // so we turn MIME support off. Note that we are always using EAS 2.5 if there are fetch 221 // requests 222 if (mFetchRequestList.isEmpty()) { 223 // Permanently delete if in trash mailbox 224 // In Exchange 2003, deletes-as-moves tag = true; no tag = false 225 // In Exchange 2007 and up, deletes-as-moves tag is "0" (false) or "1" (true) 226 boolean isTrashMailbox = mMailbox.mType == Mailbox.TYPE_TRASH; 227 if (protocolVersion < Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 228 if (!isTrashMailbox) { 229 s.tag(Tags.SYNC_DELETES_AS_MOVES); 230 } 231 } else { 232 s.data(Tags.SYNC_DELETES_AS_MOVES, isTrashMailbox ? "0" : "1"); 233 } 234 s.tag(Tags.SYNC_GET_CHANGES); 235 s.data(Tags.SYNC_WINDOW_SIZE, EMAIL_WINDOW_SIZE); 236 s.start(Tags.SYNC_OPTIONS); 237 // Set the lookback appropriately (EAS calls this a "filter") 238 String filter = getEmailFilter(); 239 // We shouldn't get FILTER_AUTO here, but if we do, make it something legal... 240 if (filter.equals(Eas.FILTER_AUTO)) { 241 filter = Eas.FILTER_3_DAYS; 242 } 243 s.data(Tags.SYNC_FILTER_TYPE, filter); 244 // Set the truncation amount for all classes 245 if (protocolVersion >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 246 s.start(Tags.BASE_BODY_PREFERENCE); 247 // HTML for email 248 s.data(Tags.BASE_TYPE, Eas.BODY_PREFERENCE_HTML); 249 s.data(Tags.BASE_TRUNCATION_SIZE, Eas.EAS12_TRUNCATION_SIZE); 250 s.end(); 251 } else { 252 // Use MIME data for EAS 2.5 253 s.data(Tags.SYNC_MIME_SUPPORT, Eas.MIME_BODY_PREFERENCE_MIME); 254 s.data(Tags.SYNC_MIME_TRUNCATION, Eas.EAS2_5_TRUNCATION_SIZE); 255 } 256 s.end(); 257 } else { 258 s.start(Tags.SYNC_OPTIONS); 259 // Ask for plain text, rather than MIME data. This guarantees that we'll get a usable 260 // text body 261 s.data(Tags.SYNC_MIME_SUPPORT, Eas.MIME_BODY_PREFERENCE_TEXT); 262 s.data(Tags.SYNC_TRUNCATION, Eas.EAS2_5_TRUNCATION_SIZE); 263 s.end(); 264 } 265 } 266 267 @Override 268 public boolean parse(InputStream is) throws IOException, CommandStatusException { 269 EasEmailSyncParser p = new EasEmailSyncParser(is, this); 270 mFetchNeeded = false; 271 boolean res = p.parse(); 272 // Hold on to the parser's value for isLooping() to pass back to the service 273 mIsLooping = p.isLooping(); 274 // If we've need a body fetch, or we've just finished one, return true in order to continue 275 if (mFetchNeeded || !mFetchRequestList.isEmpty()) { 276 return true; 277 } 278 279 // Don't check for "auto" on the initial sync 280 if (!("0".equals(mMailbox.mSyncKey))) { 281 // We've completed the first successful sync 282 if (getEmailFilter().equals(Eas.FILTER_AUTO)) { 283 getAutomaticLookback(); 284 } 285 } 286 287 return res; 288 } 289 290 private void getAutomaticLookback() throws IOException { 291 // If we're using an auto lookback, check how many items in the past week 292 // TODO Make the literal ints below constants once we twiddle them a bit 293 int items = getEstimate(Eas.FILTER_1_WEEK); 294 int lookback; 295 if (items > 1050) { 296 // Over 150/day, just use one day (smallest) 297 lookback = SyncWindow.SYNC_WINDOW_1_DAY; 298 } else if (items > 350 || (items == -1)) { 299 // 50-150/day, use 3 days (150 to 450 messages synced) 300 lookback = SyncWindow.SYNC_WINDOW_3_DAYS; 301 } else if (items > 150) { 302 // 20-50/day, use 1 week (140 to 350 messages synced) 303 lookback = SyncWindow.SYNC_WINDOW_1_WEEK; 304 } else if (items > 75) { 305 // 10-25/day, use 1 week (140 to 350 messages synced) 306 lookback = SyncWindow.SYNC_WINDOW_2_WEEKS; 307 } else if (items < 5) { 308 // If there are only a couple, see if it makes sense to get everything 309 items = getEstimate(Eas.FILTER_ALL); 310 if (items >= 0 && items < 100) { 311 lookback = SyncWindow.SYNC_WINDOW_ALL; 312 } else { 313 lookback = SyncWindow.SYNC_WINDOW_1_MONTH; 314 } 315 } else { 316 lookback = SyncWindow.SYNC_WINDOW_1_MONTH; 317 } 318 319 // Store the new lookback and persist it 320 // TODO Code similar to this is used elsewhere (e.g. MailboxSettings); try to clean this up 321 ContentValues cv = new ContentValues(); 322 Uri uri; 323 if (mMailbox.mType == Mailbox.TYPE_INBOX) { 324 mAccount.mSyncLookback = lookback; 325 cv.put(AccountColumns.SYNC_LOOKBACK, lookback); 326 uri = ContentUris.withAppendedId(Account.CONTENT_URI, mAccount.mId); 327 } else { 328 mMailbox.mSyncLookback = lookback; 329 cv.put(MailboxColumns.SYNC_LOOKBACK, lookback); 330 uri = ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailbox.mId); 331 } 332 mContentResolver.update(uri, cv, null, null); 333 334 CharSequence[] windowEntries = mContext.getResources().getTextArray( 335 R.array.account_settings_mail_window_entries); 336 Log.d(TAG, "Auto lookback: " + windowEntries[lookback]); 337 } 338 339 private static class GetItemEstimateParser extends Parser { 340 @SuppressWarnings("hiding") 341 private static final String TAG = "GetItemEstimateParser"; 342 private int mEstimate = -1; 343 344 public GetItemEstimateParser(InputStream in) throws IOException { 345 super(in); 346 } 347 348 @Override 349 public boolean parse() throws IOException { 350 // Loop here through the remaining xml 351 while (nextTag(START_DOCUMENT) != END_DOCUMENT) { 352 if (tag == Tags.GIE_GET_ITEM_ESTIMATE) { 353 parseGetItemEstimate(); 354 } else { 355 skipTag(); 356 } 357 } 358 return true; 359 } 360 361 public void parseGetItemEstimate() throws IOException { 362 while (nextTag(Tags.GIE_GET_ITEM_ESTIMATE) != END) { 363 if (tag == Tags.GIE_RESPONSE) { 364 parseResponse(); 365 } else { 366 skipTag(); 367 } 368 } 369 } 370 371 public void parseResponse() throws IOException { 372 while (nextTag(Tags.GIE_RESPONSE) != END) { 373 if (tag == Tags.GIE_STATUS) { 374 Log.d(TAG, "GIE status: " + getValue()); 375 } else if (tag == Tags.GIE_COLLECTION) { 376 parseCollection(); 377 } else { 378 skipTag(); 379 } 380 } 381 } 382 383 public void parseCollection() throws IOException { 384 while (nextTag(Tags.GIE_COLLECTION) != END) { 385 if (tag == Tags.GIE_CLASS) { 386 Log.d(TAG, "GIE class: " + getValue()); 387 } else if (tag == Tags.GIE_COLLECTION_ID) { 388 Log.d(TAG, "GIE collectionId: " + getValue()); 389 } else if (tag == Tags.GIE_ESTIMATE) { 390 mEstimate = getValueInt(); 391 Log.d(TAG, "GIE estimate: " + mEstimate); 392 } else { 393 skipTag(); 394 } 395 } 396 } 397 } 398 399 /** 400 * Return the estimated number of items to be synced in the current mailbox, based on the 401 * passed in filter argument 402 * @param filter an EAS "window" filter 403 * @return the estimated number of items to be synced, or -1 if unknown 404 * @throws IOException 405 */ 406 private int getEstimate(String filter) throws IOException { 407 Serializer s = new Serializer(); 408 boolean ex10 = mService.mProtocolVersionDouble >= Eas.SUPPORTED_PROTOCOL_EX2010_DOUBLE; 409 boolean ex03 = mService.mProtocolVersionDouble < Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE; 410 boolean ex07 = !ex10 && !ex03; 411 412 String className = getCollectionName(); 413 String syncKey = getSyncKey(); 414 userLog("gie, sending ", className, " syncKey: ", syncKey); 415 416 s.start(Tags.GIE_GET_ITEM_ESTIMATE).start(Tags.GIE_COLLECTIONS); 417 s.start(Tags.GIE_COLLECTION); 418 if (ex07) { 419 // Exchange 2007 likes collection id first 420 s.data(Tags.GIE_COLLECTION_ID, mMailbox.mServerId); 421 s.data(Tags.SYNC_FILTER_TYPE, filter); 422 s.data(Tags.SYNC_SYNC_KEY, syncKey); 423 } else if (ex03) { 424 // Exchange 2003 needs the "class" element 425 s.data(Tags.GIE_CLASS, className); 426 s.data(Tags.SYNC_SYNC_KEY, syncKey); 427 s.data(Tags.GIE_COLLECTION_ID, mMailbox.mServerId); 428 s.data(Tags.SYNC_FILTER_TYPE, filter); 429 } else { 430 // Exchange 2010 requires the filter inside an OPTIONS container and sync key first 431 s.data(Tags.SYNC_SYNC_KEY, syncKey); 432 s.data(Tags.GIE_COLLECTION_ID, mMailbox.mServerId); 433 s.start(Tags.SYNC_OPTIONS).data(Tags.SYNC_FILTER_TYPE, filter).end(); 434 } 435 s.end().end().end().done(); // GIE_COLLECTION, GIE_COLLECTIONS, GIE_GET_ITEM_ESTIMATE 436 437 EasResponse resp = mService.sendHttpClientPost("GetItemEstimate", 438 new ByteArrayEntity(s.toByteArray()), EasSyncService.COMMAND_TIMEOUT); 439 try { 440 int code = resp.getStatus(); 441 if (code == HttpStatus.SC_OK) { 442 if (!resp.isEmpty()) { 443 InputStream is = resp.getInputStream(); 444 GetItemEstimateParser gieParser = new GetItemEstimateParser(is); 445 gieParser.parse(); 446 // Return the estimated number of items 447 return gieParser.mEstimate; 448 } 449 } 450 } finally { 451 resp.close(); 452 } 453 // If we can't get an estimate, indicate this... 454 return -1; 455 } 456 457 /** 458 * Return the value of isLooping() as returned from the parser 459 */ 460 @Override 461 public boolean isLooping() { 462 return mIsLooping; 463 } 464 465 @Override 466 public boolean isSyncable() { 467 return true; 468 } 469 470 public class EasEmailSyncParser extends AbstractSyncParser { 471 472 private static final String WHERE_SERVER_ID_AND_MAILBOX_KEY = 473 SyncColumns.SERVER_ID + "=? and " + MessageColumns.MAILBOX_KEY + "=?"; 474 475 private final String mMailboxIdAsString; 476 477 private final ArrayList<Message> newEmails = new ArrayList<Message>(); 478 private final ArrayList<Message> fetchedEmails = new ArrayList<Message>(); 479 private final ArrayList<Long> deletedEmails = new ArrayList<Long>(); 480 private final ArrayList<ServerChange> changedEmails = new ArrayList<ServerChange>(); 481 482 public EasEmailSyncParser(InputStream in, EmailSyncAdapter adapter) throws IOException { 483 super(in, adapter); 484 mMailboxIdAsString = Long.toString(mMailbox.mId); 485 } 486 487 public EasEmailSyncParser(Parser parser, EmailSyncAdapter adapter) throws IOException { 488 super(parser, adapter); 489 mMailboxIdAsString = Long.toString(mMailbox.mId); 490 } 491 492 public void addData (Message msg, int endingTag) throws IOException { 493 ArrayList<Attachment> atts = new ArrayList<Attachment>(); 494 boolean truncated = false; 495 496 while (nextTag(endingTag) != END) { 497 switch (tag) { 498 case Tags.EMAIL_ATTACHMENTS: 499 case Tags.BASE_ATTACHMENTS: // BASE_ATTACHMENTS is used in EAS 12.0 and up 500 attachmentsParser(atts, msg); 501 break; 502 case Tags.EMAIL_TO: 503 msg.mTo = Address.pack(Address.parse(getValue())); 504 break; 505 case Tags.EMAIL_FROM: 506 Address[] froms = Address.parse(getValue()); 507 if (froms != null && froms.length > 0) { 508 msg.mDisplayName = froms[0].toFriendly(); 509 } 510 msg.mFrom = Address.pack(froms); 511 break; 512 case Tags.EMAIL_CC: 513 msg.mCc = Address.pack(Address.parse(getValue())); 514 break; 515 case Tags.EMAIL_REPLY_TO: 516 msg.mReplyTo = Address.pack(Address.parse(getValue())); 517 break; 518 case Tags.EMAIL_DATE_RECEIVED: 519 msg.mTimeStamp = Utility.parseEmailDateTimeToMillis(getValue()); 520 break; 521 case Tags.EMAIL_SUBJECT: 522 msg.mSubject = getValue(); 523 break; 524 case Tags.EMAIL_READ: 525 msg.mFlagRead = getValueInt() == 1; 526 break; 527 case Tags.BASE_BODY: 528 bodyParser(msg); 529 break; 530 case Tags.EMAIL_FLAG: 531 msg.mFlagFavorite = flagParser(); 532 break; 533 case Tags.EMAIL_MIME_TRUNCATED: 534 truncated = getValueInt() == 1; 535 break; 536 case Tags.EMAIL_MIME_DATA: 537 // We get MIME data for EAS 2.5. First we parse it, then we take the 538 // html and/or plain text data and store it in the message 539 if (truncated) { 540 // If the MIME data is truncated, don't bother parsing it, because 541 // it will take time and throw an exception anyway when EOF is reached 542 // In this case, we will load the body separately by tagging the message 543 // "partially loaded". 544 // Get the data (and ignore it) 545 getValue(); 546 userLog("Partially loaded: ", msg.mServerId); 547 msg.mFlagLoaded = Message.FLAG_LOADED_PARTIAL; 548 mFetchNeeded = true; 549 } else { 550 mimeBodyParser(msg, getValue()); 551 } 552 break; 553 case Tags.EMAIL_BODY: 554 String text = getValue(); 555 msg.mText = text; 556 break; 557 case Tags.EMAIL_MESSAGE_CLASS: 558 String messageClass = getValue(); 559 if (messageClass.equals("IPM.Schedule.Meeting.Request")) { 560 msg.mFlags |= Message.FLAG_INCOMING_MEETING_INVITE; 561 } else if (messageClass.equals("IPM.Schedule.Meeting.Canceled")) { 562 msg.mFlags |= Message.FLAG_INCOMING_MEETING_CANCEL; 563 } 564 break; 565 case Tags.EMAIL_MEETING_REQUEST: 566 meetingRequestParser(msg); 567 break; 568 case Tags.RIGHTS_LICENSE: 569 skipParser(tag); 570 break; 571 case Tags.EMAIL2_CONVERSATION_ID: 572 msg.mServerConversationId = 573 Base64.encodeToString(getValueBytes(), Base64.URL_SAFE); 574 break; 575 case Tags.EMAIL2_CONVERSATION_INDEX: 576 // Ignore this byte array since we're not constructing a tree. 577 getValueBytes(); 578 break; 579 case Tags.EMAIL2_LAST_VERB_EXECUTED: 580 int val = getValueInt(); 581 if (val == LAST_VERB_REPLY || val == LAST_VERB_REPLY_ALL) { 582 // We aren't required to distinguish between reply and reply all here 583 msg.mFlags |= Message.FLAG_REPLIED_TO; 584 } else if (val == LAST_VERB_FORWARD) { 585 msg.mFlags |= Message.FLAG_FORWARDED; 586 } 587 break; 588 default: 589 skipTag(); 590 } 591 } 592 593 if (atts.size() > 0) { 594 msg.mAttachments = atts; 595 } 596 } 597 598 /** 599 * Set up the meetingInfo field in the message with various pieces of information gleaned 600 * from MeetingRequest tags. This information will be used later to generate an appropriate 601 * reply email if the user chooses to respond 602 * @param msg the Message being built 603 * @throws IOException 604 */ 605 private void meetingRequestParser(Message msg) throws IOException { 606 PackedString.Builder packedString = new PackedString.Builder(); 607 while (nextTag(Tags.EMAIL_MEETING_REQUEST) != END) { 608 switch (tag) { 609 case Tags.EMAIL_DTSTAMP: 610 packedString.put(MeetingInfo.MEETING_DTSTAMP, getValue()); 611 break; 612 case Tags.EMAIL_START_TIME: 613 packedString.put(MeetingInfo.MEETING_DTSTART, getValue()); 614 break; 615 case Tags.EMAIL_END_TIME: 616 packedString.put(MeetingInfo.MEETING_DTEND, getValue()); 617 break; 618 case Tags.EMAIL_ORGANIZER: 619 packedString.put(MeetingInfo.MEETING_ORGANIZER_EMAIL, getValue()); 620 break; 621 case Tags.EMAIL_LOCATION: 622 packedString.put(MeetingInfo.MEETING_LOCATION, getValue()); 623 break; 624 case Tags.EMAIL_GLOBAL_OBJID: 625 packedString.put(MeetingInfo.MEETING_UID, 626 CalendarUtilities.getUidFromGlobalObjId(getValue())); 627 break; 628 case Tags.EMAIL_CATEGORIES: 629 skipParser(tag); 630 break; 631 case Tags.EMAIL_RECURRENCES: 632 recurrencesParser(); 633 break; 634 case Tags.EMAIL_RESPONSE_REQUESTED: 635 packedString.put(MeetingInfo.MEETING_RESPONSE_REQUESTED, getValue()); 636 break; 637 default: 638 skipTag(); 639 } 640 } 641 if (msg.mSubject != null) { 642 packedString.put(MeetingInfo.MEETING_TITLE, msg.mSubject); 643 } 644 msg.mMeetingInfo = packedString.toString(); 645 } 646 647 private void recurrencesParser() throws IOException { 648 while (nextTag(Tags.EMAIL_RECURRENCES) != END) { 649 switch (tag) { 650 case Tags.EMAIL_RECURRENCE: 651 skipParser(tag); 652 break; 653 default: 654 skipTag(); 655 } 656 } 657 } 658 659 /** 660 * Parse a message from the server stream. 661 * @return the parsed Message 662 * @throws IOException 663 */ 664 private Message addParser() throws IOException, CommandStatusException { 665 Message msg = new Message(); 666 msg.mAccountKey = mAccount.mId; 667 msg.mMailboxKey = mMailbox.mId; 668 msg.mFlagLoaded = Message.FLAG_LOADED_COMPLETE; 669 // Default to 1 (success) in case we don't get this tag 670 int status = 1; 671 672 while (nextTag(Tags.SYNC_ADD) != END) { 673 switch (tag) { 674 case Tags.SYNC_SERVER_ID: 675 msg.mServerId = getValue(); 676 break; 677 case Tags.SYNC_STATUS: 678 status = getValueInt(); 679 break; 680 case Tags.SYNC_APPLICATION_DATA: 681 addData(msg, tag); 682 break; 683 default: 684 skipTag(); 685 } 686 } 687 // For sync, status 1 = success 688 if (status != 1) { 689 throw new CommandStatusException(status, msg.mServerId); 690 } 691 return msg; 692 } 693 694 // For now, we only care about the "active" state 695 private Boolean flagParser() throws IOException { 696 Boolean state = false; 697 while (nextTag(Tags.EMAIL_FLAG) != END) { 698 switch (tag) { 699 case Tags.EMAIL_FLAG_STATUS: 700 state = getValueInt() == 2; 701 break; 702 default: 703 skipTag(); 704 } 705 } 706 return state; 707 } 708 709 private void bodyParser(Message msg) throws IOException { 710 String bodyType = Eas.BODY_PREFERENCE_TEXT; 711 String body = ""; 712 while (nextTag(Tags.EMAIL_BODY) != END) { 713 switch (tag) { 714 case Tags.BASE_TYPE: 715 bodyType = getValue(); 716 break; 717 case Tags.BASE_DATA: 718 body = getValue(); 719 break; 720 default: 721 skipTag(); 722 } 723 } 724 // We always ask for TEXT or HTML; there's no third option 725 if (bodyType.equals(Eas.BODY_PREFERENCE_HTML)) { 726 msg.mHtml = body; 727 } else { 728 msg.mText = body; 729 } 730 } 731 732 /** 733 * Parses untruncated MIME data, saving away the text parts 734 * @param msg the message we're building 735 * @param mimeData the MIME data we've received from the server 736 * @throws IOException 737 */ 738 private void mimeBodyParser(Message msg, String mimeData) throws IOException { 739 try { 740 ByteArrayInputStream in = new ByteArrayInputStream(mimeData.getBytes()); 741 // The constructor parses the message 742 MimeMessage mimeMessage = new MimeMessage(in); 743 // Now process body parts & attachments 744 ArrayList<Part> viewables = new ArrayList<Part>(); 745 // We'll ignore the attachments, as we'll get them directly from EAS 746 ArrayList<Part> attachments = new ArrayList<Part>(); 747 MimeUtility.collectParts(mimeMessage, viewables, attachments); 748 Body tempBody = new Body(); 749 // updateBodyFields fills in the content fields of the Body 750 ConversionUtilities.updateBodyFields(tempBody, msg, viewables); 751 // But we need them in the message itself for handling during commit() 752 msg.mHtml = tempBody.mHtmlContent; 753 msg.mText = tempBody.mTextContent; 754 } catch (MessagingException e) { 755 // This would most likely indicate a broken stream 756 throw new IOException(e); 757 } 758 } 759 760 private void attachmentsParser(ArrayList<Attachment> atts, Message msg) throws IOException { 761 while (nextTag(Tags.EMAIL_ATTACHMENTS) != END) { 762 switch (tag) { 763 case Tags.EMAIL_ATTACHMENT: 764 case Tags.BASE_ATTACHMENT: // BASE_ATTACHMENT is used in EAS 12.0 and up 765 attachmentParser(atts, msg); 766 break; 767 default: 768 skipTag(); 769 } 770 } 771 } 772 773 private void attachmentParser(ArrayList<Attachment> atts, Message msg) throws IOException { 774 String fileName = null; 775 String length = null; 776 String location = null; 777 boolean isInline = false; 778 String contentId = null; 779 780 while (nextTag(Tags.EMAIL_ATTACHMENT) != END) { 781 switch (tag) { 782 // We handle both EAS 2.5 and 12.0+ attachments here 783 case Tags.EMAIL_DISPLAY_NAME: 784 case Tags.BASE_DISPLAY_NAME: 785 fileName = getValue(); 786 break; 787 case Tags.EMAIL_ATT_NAME: 788 case Tags.BASE_FILE_REFERENCE: 789 location = getValue(); 790 break; 791 case Tags.EMAIL_ATT_SIZE: 792 case Tags.BASE_ESTIMATED_DATA_SIZE: 793 length = getValue(); 794 break; 795 case Tags.BASE_IS_INLINE: 796 isInline = getValueInt() == 1; 797 break; 798 case Tags.BASE_CONTENT_ID: 799 contentId = getValue(); 800 break; 801 default: 802 skipTag(); 803 } 804 } 805 806 if ((fileName != null) && (length != null) && (location != null)) { 807 Attachment att = new Attachment(); 808 att.mEncoding = "base64"; 809 att.mSize = Long.parseLong(length); 810 att.mFileName = fileName; 811 att.mLocation = location; 812 att.mMimeType = getMimeTypeFromFileName(fileName); 813 att.mAccountKey = mService.mAccount.mId; 814 // Save away the contentId, if we've got one (for inline images); note that the 815 // EAS docs appear to be wrong about the tags used; inline images come with 816 // contentId rather than contentLocation, when sent from Ex03, Ex07, and Ex10 817 if (isInline && !TextUtils.isEmpty(contentId)) { 818 att.mContentId = contentId; 819 } 820 // Check if this attachment can't be downloaded due to an account policy 821 if (mPolicy != null) { 822 if (mPolicy.mDontAllowAttachments || 823 (mPolicy.mMaxAttachmentSize > 0 && 824 (att.mSize > mPolicy.mMaxAttachmentSize))) { 825 att.mFlags = Attachment.FLAG_POLICY_DISALLOWS_DOWNLOAD; 826 } 827 } 828 atts.add(att); 829 msg.mFlagAttachment = true; 830 } 831 } 832 833 /** 834 * Returns an appropriate mimetype for the given file name's extension. If a mimetype 835 * cannot be determined, {@code application/<<x>>} [where @{code <<x>> is the extension, 836 * if it exists or {@code application/octet-stream}]. 837 * At the moment, this is somewhat lame, since many file types aren't recognized 838 * @param fileName the file name to ponder 839 */ 840 // Note: The MimeTypeMap method currently uses a very limited set of mime types 841 // A bug has been filed against this issue. 842 public String getMimeTypeFromFileName(String fileName) { 843 String mimeType; 844 int lastDot = fileName.lastIndexOf('.'); 845 String extension = null; 846 if ((lastDot > 0) && (lastDot < fileName.length() - 1)) { 847 extension = fileName.substring(lastDot + 1).toLowerCase(); 848 } 849 if (extension == null) { 850 // A reasonable default for now. 851 mimeType = "application/octet-stream"; 852 } else { 853 mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); 854 if (mimeType == null) { 855 mimeType = "application/" + extension; 856 } 857 } 858 return mimeType; 859 } 860 861 private Cursor getServerIdCursor(String serverId, String[] projection) { 862 mBindArguments[0] = serverId; 863 mBindArguments[1] = mMailboxIdAsString; 864 Cursor c = mContentResolver.query(Message.CONTENT_URI, projection, 865 WHERE_SERVER_ID_AND_MAILBOX_KEY, mBindArguments, null); 866 if (c == null) throw new ProviderUnavailableException(); 867 return c; 868 } 869 870 @VisibleForTesting 871 void deleteParser(ArrayList<Long> deletes, int entryTag) throws IOException { 872 while (nextTag(entryTag) != END) { 873 switch (tag) { 874 case Tags.SYNC_SERVER_ID: 875 String serverId = getValue(); 876 // Find the message in this mailbox with the given serverId 877 Cursor c = getServerIdCursor(serverId, MESSAGE_ID_SUBJECT_PROJECTION); 878 try { 879 if (c.moveToFirst()) { 880 deletes.add(c.getLong(MESSAGE_ID_SUBJECT_ID_COLUMN)); 881 if (Eas.USER_LOG) { 882 userLog("Deleting ", serverId + ", " 883 + c.getString(MESSAGE_ID_SUBJECT_SUBJECT_COLUMN)); 884 } 885 } 886 } finally { 887 c.close(); 888 } 889 break; 890 default: 891 skipTag(); 892 } 893 } 894 } 895 896 @VisibleForTesting 897 class ServerChange { 898 final long id; 899 final Boolean read; 900 final Boolean flag; 901 final Integer flags; 902 903 ServerChange(long _id, Boolean _read, Boolean _flag, Integer _flags) { 904 id = _id; 905 read = _read; 906 flag = _flag; 907 flags = _flags; 908 } 909 } 910 911 @VisibleForTesting 912 void changeParser(ArrayList<ServerChange> changes) throws IOException { 913 String serverId = null; 914 Boolean oldRead = false; 915 Boolean oldFlag = false; 916 int flags = 0; 917 long id = 0; 918 while (nextTag(Tags.SYNC_CHANGE) != END) { 919 switch (tag) { 920 case Tags.SYNC_SERVER_ID: 921 serverId = getValue(); 922 Cursor c = getServerIdCursor(serverId, Message.LIST_PROJECTION); 923 try { 924 if (c.moveToFirst()) { 925 userLog("Changing ", serverId); 926 oldRead = c.getInt(Message.LIST_READ_COLUMN) == Message.READ; 927 oldFlag = c.getInt(Message.LIST_FAVORITE_COLUMN) == 1; 928 flags = c.getInt(Message.LIST_FLAGS_COLUMN); 929 id = c.getLong(Message.LIST_ID_COLUMN); 930 } 931 } finally { 932 c.close(); 933 } 934 break; 935 case Tags.SYNC_APPLICATION_DATA: 936 changeApplicationDataParser(changes, oldRead, oldFlag, flags, id); 937 break; 938 default: 939 skipTag(); 940 } 941 } 942 } 943 944 private void changeApplicationDataParser(ArrayList<ServerChange> changes, Boolean oldRead, 945 Boolean oldFlag, int oldFlags, long id) throws IOException { 946 Boolean read = null; 947 Boolean flag = null; 948 Integer flags = null; 949 while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) { 950 switch (tag) { 951 case Tags.EMAIL_READ: 952 read = getValueInt() == 1; 953 break; 954 case Tags.EMAIL_FLAG: 955 flag = flagParser(); 956 break; 957 case Tags.EMAIL2_LAST_VERB_EXECUTED: 958 int val = getValueInt(); 959 // Clear out the old replied/forward flags and add in the new flag 960 flags = oldFlags & ~(Message.FLAG_REPLIED_TO | Message.FLAG_FORWARDED); 961 if (val == LAST_VERB_REPLY || val == LAST_VERB_REPLY_ALL) { 962 // We aren't required to distinguish between reply and reply all here 963 flags |= Message.FLAG_REPLIED_TO; 964 } else if (val == LAST_VERB_FORWARD) { 965 flags |= Message.FLAG_FORWARDED; 966 } 967 break; 968 default: 969 skipTag(); 970 } 971 } 972 // See if there are flag changes re: read, flag (favorite) or replied/forwarded 973 if (((read != null) && !oldRead.equals(read)) || 974 ((flag != null) && !oldFlag.equals(flag)) || (flags != null)) { 975 changes.add(new ServerChange(id, read, flag, flags)); 976 } 977 } 978 979 /* (non-Javadoc) 980 * @see com.android.exchange.adapter.EasContentParser#commandsParser() 981 */ 982 @Override 983 public void commandsParser() throws IOException, CommandStatusException { 984 while (nextTag(Tags.SYNC_COMMANDS) != END) { 985 if (tag == Tags.SYNC_ADD) { 986 newEmails.add(addParser()); 987 incrementChangeCount(); 988 } else if (tag == Tags.SYNC_DELETE || tag == Tags.SYNC_SOFT_DELETE) { 989 deleteParser(deletedEmails, tag); 990 incrementChangeCount(); 991 } else if (tag == Tags.SYNC_CHANGE) { 992 changeParser(changedEmails); 993 incrementChangeCount(); 994 } else 995 skipTag(); 996 } 997 } 998 999 /** 1000 * Removed any messages with status 7 (mismatch) from the updatedIdList 1001 * @param endTag the tag we end with 1002 * @throws IOException 1003 */ 1004 public void failedUpdateParser(int endTag) throws IOException { 1005 // We get serverId and status in the responses 1006 String serverId = null; 1007 while (nextTag(endTag) != END) { 1008 if (tag == Tags.SYNC_STATUS) { 1009 int status = getValueInt(); 1010 if (status == 7 && serverId != null) { 1011 Cursor c = getServerIdCursor(serverId, Message.ID_COLUMN_PROJECTION); 1012 try { 1013 if (c.moveToFirst()) { 1014 Long id = c.getLong(Message.ID_PROJECTION_COLUMN); 1015 mService.userLog("Update of " + serverId + " failed; will retry"); 1016 mUpdatedIdList.remove(id); 1017 mService.mUpsyncFailed = true; 1018 } 1019 } finally { 1020 c.close(); 1021 } 1022 } 1023 } else if (tag == Tags.SYNC_SERVER_ID) { 1024 serverId = getValue(); 1025 } else { 1026 skipTag(); 1027 } 1028 } 1029 } 1030 1031 @Override 1032 public void responsesParser() throws IOException { 1033 while (nextTag(Tags.SYNC_RESPONSES) != END) { 1034 if (tag == Tags.SYNC_ADD || tag == Tags.SYNC_CHANGE || tag == Tags.SYNC_DELETE) { 1035 failedUpdateParser(tag); 1036 } else if (tag == Tags.SYNC_FETCH) { 1037 try { 1038 fetchedEmails.add(addParser()); 1039 } catch (CommandStatusException sse) { 1040 if (sse.mStatus == 8) { 1041 // 8 = object not found; delete the message from EmailProvider 1042 // No other status should be seen in a fetch response, except, perhaps, 1043 // for some temporary server failure 1044 mBindArguments[0] = sse.mItemId; 1045 mBindArguments[1] = mMailboxIdAsString; 1046 mContentResolver.delete(Message.CONTENT_URI, 1047 WHERE_SERVER_ID_AND_MAILBOX_KEY, mBindArguments); 1048 } 1049 } 1050 } 1051 } 1052 } 1053 1054 @Override 1055 public void commit() { 1056 // Use a batch operation to handle the changes 1057 ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>(); 1058 1059 for (Message msg: fetchedEmails) { 1060 // Find the original message's id (by serverId and mailbox) 1061 Cursor c = getServerIdCursor(msg.mServerId, EmailContent.ID_PROJECTION); 1062 String id = null; 1063 try { 1064 if (c.moveToFirst()) { 1065 id = c.getString(EmailContent.ID_PROJECTION_COLUMN); 1066 } 1067 } finally { 1068 c.close(); 1069 } 1070 1071 // If we find one, we do two things atomically: 1) set the body text for the 1072 // message, and 2) mark the message loaded (i.e. completely loaded) 1073 if (id != null) { 1074 userLog("Fetched body successfully for ", id); 1075 mBindArgument[0] = id; 1076 ops.add(ContentProviderOperation.newUpdate(Body.CONTENT_URI) 1077 .withSelection(Body.MESSAGE_KEY + "=?", mBindArgument) 1078 .withValue(Body.TEXT_CONTENT, msg.mText) 1079 .build()); 1080 ops.add(ContentProviderOperation.newUpdate(Message.CONTENT_URI) 1081 .withSelection(EmailContent.RECORD_ID + "=?", mBindArgument) 1082 .withValue(Message.FLAG_LOADED, Message.FLAG_LOADED_COMPLETE) 1083 .build()); 1084 } 1085 } 1086 1087 for (Message msg: newEmails) { 1088 msg.addSaveOps(ops); 1089 } 1090 1091 for (Long id : deletedEmails) { 1092 ops.add(ContentProviderOperation.newDelete( 1093 ContentUris.withAppendedId(Message.CONTENT_URI, id)).build()); 1094 AttachmentUtilities.deleteAllAttachmentFiles(mContext, mAccount.mId, id); 1095 } 1096 1097 if (!changedEmails.isEmpty()) { 1098 // Server wins in a conflict... 1099 for (ServerChange change : changedEmails) { 1100 ContentValues cv = new ContentValues(); 1101 if (change.read != null) { 1102 cv.put(MessageColumns.FLAG_READ, change.read); 1103 } 1104 if (change.flag != null) { 1105 cv.put(MessageColumns.FLAG_FAVORITE, change.flag); 1106 } 1107 if (change.flags != null) { 1108 cv.put(MessageColumns.FLAGS, change.flags); 1109 } 1110 ops.add(ContentProviderOperation.newUpdate( 1111 ContentUris.withAppendedId(Message.CONTENT_URI, change.id)) 1112 .withValues(cv) 1113 .build()); 1114 } 1115 } 1116 1117 // We only want to update the sync key here 1118 ContentValues mailboxValues = new ContentValues(); 1119 mailboxValues.put(Mailbox.SYNC_KEY, mMailbox.mSyncKey); 1120 ops.add(ContentProviderOperation.newUpdate( 1121 ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailbox.mId)) 1122 .withValues(mailboxValues).build()); 1123 1124 // No commits if we're stopped 1125 synchronized (mService.getSynchronizer()) { 1126 if (mService.isStopped()) return; 1127 try { 1128 mContentResolver.applyBatch(EmailContent.AUTHORITY, ops); 1129 userLog(mMailbox.mDisplayName, " SyncKey saved as: ", mMailbox.mSyncKey); 1130 } catch (RemoteException e) { 1131 // There is nothing to be done here; fail by returning null 1132 } catch (OperationApplicationException e) { 1133 // There is nothing to be done here; fail by returning null 1134 } 1135 } 1136 } 1137 } 1138 1139 @Override 1140 public String getCollectionName() { 1141 return "Email"; 1142 } 1143 1144 private void addCleanupOps(ArrayList<ContentProviderOperation> ops) { 1145 // If we've sent local deletions, clear out the deleted table 1146 for (Long id: mDeletedIdList) { 1147 ops.add(ContentProviderOperation.newDelete( 1148 ContentUris.withAppendedId(Message.DELETED_CONTENT_URI, id)).build()); 1149 } 1150 // And same with the updates 1151 for (Long id: mUpdatedIdList) { 1152 ops.add(ContentProviderOperation.newDelete( 1153 ContentUris.withAppendedId(Message.UPDATED_CONTENT_URI, id)).build()); 1154 } 1155 } 1156 1157 @Override 1158 public void cleanup() { 1159 ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>(); 1160 // Delete any moved messages (since we've just synced the mailbox, and no longer need the 1161 // placeholder message); this prevents duplicates from appearing in the mailbox. 1162 mBindArgument[0] = Long.toString(mMailbox.mId); 1163 ops.add(ContentProviderOperation.newDelete(Message.CONTENT_URI) 1164 .withSelection(WHERE_MAILBOX_KEY_AND_MOVED, mBindArgument).build()); 1165 // If we've done deletions/updates, clean up the deleted/updated tables 1166 if (!mDeletedIdList.isEmpty() || !mUpdatedIdList.isEmpty()) { 1167 addCleanupOps(ops); 1168 } 1169 try { 1170 mContext.getContentResolver() 1171 .applyBatch(EmailContent.AUTHORITY, ops); 1172 } catch (RemoteException e) { 1173 // There is nothing to be done here; fail by returning null 1174 } catch (OperationApplicationException e) { 1175 // There is nothing to be done here; fail by returning null 1176 } 1177 } 1178 1179 private String formatTwo(int num) { 1180 if (num < 10) { 1181 return "0" + (char)('0' + num); 1182 } else 1183 return Integer.toString(num); 1184 } 1185 1186 /** 1187 * Create date/time in RFC8601 format. Oddly enough, for calendar date/time, Microsoft uses 1188 * a different format that excludes the punctuation (this is why I'm not putting this in a 1189 * parent class) 1190 */ 1191 public String formatDateTime(Calendar calendar) { 1192 StringBuilder sb = new StringBuilder(); 1193 //YYYY-MM-DDTHH:MM:SS.MSSZ 1194 sb.append(calendar.get(Calendar.YEAR)); 1195 sb.append('-'); 1196 sb.append(formatTwo(calendar.get(Calendar.MONTH) + 1)); 1197 sb.append('-'); 1198 sb.append(formatTwo(calendar.get(Calendar.DAY_OF_MONTH))); 1199 sb.append('T'); 1200 sb.append(formatTwo(calendar.get(Calendar.HOUR_OF_DAY))); 1201 sb.append(':'); 1202 sb.append(formatTwo(calendar.get(Calendar.MINUTE))); 1203 sb.append(':'); 1204 sb.append(formatTwo(calendar.get(Calendar.SECOND))); 1205 sb.append(".000Z"); 1206 return sb.toString(); 1207 } 1208 1209 /** 1210 * Note that messages in the deleted database preserve the message's unique id; therefore, we 1211 * can utilize this id to find references to the message. The only reference situation at this 1212 * point is in the Body table; it is when sending messages via SmartForward and SmartReply 1213 */ 1214 private boolean messageReferenced(ContentResolver cr, long id) { 1215 mBindArgument[0] = Long.toString(id); 1216 // See if this id is referenced in a body 1217 Cursor c = cr.query(Body.CONTENT_URI, Body.ID_PROJECTION, WHERE_BODY_SOURCE_MESSAGE_KEY, 1218 mBindArgument, null); 1219 try { 1220 return c.moveToFirst(); 1221 } finally { 1222 c.close(); 1223 } 1224 } 1225 1226 /*private*/ /** 1227 * Serialize commands to delete items from the server; as we find items to delete, add their 1228 * id's to the deletedId's array 1229 * 1230 * @param s the Serializer we're using to create post data 1231 * @param deletedIds ids whose deletions are being sent to the server 1232 * @param first whether or not this is the first command being sent 1233 * @return true if SYNC_COMMANDS hasn't been sent (false otherwise) 1234 * @throws IOException 1235 */ 1236 @VisibleForTesting 1237 boolean sendDeletedItems(Serializer s, ArrayList<Long> deletedIds, boolean first) 1238 throws IOException { 1239 ContentResolver cr = mContext.getContentResolver(); 1240 1241 // Find any of our deleted items 1242 Cursor c = cr.query(Message.DELETED_CONTENT_URI, Message.LIST_PROJECTION, 1243 MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null); 1244 // We keep track of the list of deleted item id's so that we can remove them from the 1245 // deleted table after the server receives our command 1246 deletedIds.clear(); 1247 try { 1248 while (c.moveToNext()) { 1249 String serverId = c.getString(Message.LIST_SERVER_ID_COLUMN); 1250 // Keep going if there's no serverId 1251 if (serverId == null) { 1252 continue; 1253 // Also check if this message is referenced elsewhere 1254 } else if (messageReferenced(cr, c.getLong(Message.CONTENT_ID_COLUMN))) { 1255 userLog("Postponing deletion of referenced message: ", serverId); 1256 continue; 1257 } else if (first) { 1258 s.start(Tags.SYNC_COMMANDS); 1259 first = false; 1260 } 1261 // Send the command to delete this message 1262 s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end(); 1263 deletedIds.add(c.getLong(Message.LIST_ID_COLUMN)); 1264 } 1265 } finally { 1266 c.close(); 1267 } 1268 1269 return first; 1270 } 1271 1272 @Override 1273 public boolean sendLocalChanges(Serializer s) throws IOException { 1274 ContentResolver cr = mContext.getContentResolver(); 1275 1276 if (getSyncKey().equals("0")) { 1277 return false; 1278 } 1279 1280 // Never upsync from these folders 1281 if (mMailbox.mType == Mailbox.TYPE_DRAFTS || mMailbox.mType == Mailbox.TYPE_OUTBOX) { 1282 return false; 1283 } 1284 1285 // This code is split out for unit testing purposes 1286 boolean firstCommand = sendDeletedItems(s, mDeletedIdList, true); 1287 1288 if (!mFetchRequestList.isEmpty()) { 1289 // Add FETCH commands for messages that need a body (i.e. we didn't find it during 1290 // our earlier sync; this happens only in EAS 2.5 where the body couldn't be found 1291 // after parsing the message's MIME data) 1292 if (firstCommand) { 1293 s.start(Tags.SYNC_COMMANDS); 1294 firstCommand = false; 1295 } 1296 for (FetchRequest req: mFetchRequestList) { 1297 s.start(Tags.SYNC_FETCH).data(Tags.SYNC_SERVER_ID, req.serverId).end(); 1298 } 1299 } 1300 1301 // Find our trash mailbox, since deletions will have been moved there... 1302 long trashMailboxId = 1303 Mailbox.findMailboxOfType(mContext, mMailbox.mAccountKey, Mailbox.TYPE_TRASH); 1304 1305 // Do the same now for updated items 1306 Cursor c = cr.query(Message.UPDATED_CONTENT_URI, Message.LIST_PROJECTION, 1307 MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null); 1308 1309 // We keep track of the list of updated item id's as we did above with deleted items 1310 mUpdatedIdList.clear(); 1311 try { 1312 ContentValues cv = new ContentValues(); 1313 while (c.moveToNext()) { 1314 long id = c.getLong(Message.LIST_ID_COLUMN); 1315 // Say we've handled this update 1316 mUpdatedIdList.add(id); 1317 // We have the id of the changed item. But first, we have to find out its current 1318 // state, since the updated table saves the opriginal state 1319 Cursor currentCursor = cr.query(ContentUris.withAppendedId(Message.CONTENT_URI, id), 1320 UPDATES_PROJECTION, null, null, null); 1321 try { 1322 // If this item no longer exists (shouldn't be possible), just move along 1323 if (!currentCursor.moveToFirst()) { 1324 continue; 1325 } 1326 // Keep going if there's no serverId 1327 String serverId = currentCursor.getString(UPDATES_SERVER_ID_COLUMN); 1328 if (serverId == null) { 1329 continue; 1330 } 1331 1332 boolean flagChange = false; 1333 boolean readChange = false; 1334 1335 long mailbox = currentCursor.getLong(UPDATES_MAILBOX_KEY_COLUMN); 1336 if (mailbox != c.getLong(Message.LIST_MAILBOX_KEY_COLUMN)) { 1337 // The message has moved to another mailbox; add a request for this 1338 // Note: The Sync command doesn't handle moving messages, so we need 1339 // to handle this as a "request" (similar to meeting response and 1340 // attachment load) 1341 mService.addRequest(new MessageMoveRequest(id, mailbox)); 1342 // Regardless of other changes that might be made, we don't want to indicate 1343 // that this message has been updated until the move request has been 1344 // handled (without this, a crash between the flag upsync and the move 1345 // would cause the move to be lost) 1346 mUpdatedIdList.remove(id); 1347 } 1348 1349 // We can only send flag changes to the server in 12.0 or later 1350 int flag = 0; 1351 if (mService.mProtocolVersionDouble >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) { 1352 flag = currentCursor.getInt(UPDATES_FLAG_COLUMN); 1353 if (flag != c.getInt(Message.LIST_FAVORITE_COLUMN)) { 1354 flagChange = true; 1355 } 1356 } 1357 1358 int read = currentCursor.getInt(UPDATES_READ_COLUMN); 1359 if (read != c.getInt(Message.LIST_READ_COLUMN)) { 1360 readChange = true; 1361 } 1362 1363 if (!flagChange && !readChange) { 1364 // In this case, we've got nothing to send to the server 1365 continue; 1366 } 1367 1368 if (firstCommand) { 1369 s.start(Tags.SYNC_COMMANDS); 1370 firstCommand = false; 1371 } 1372 // Send the change to "read" and "favorite" (flagged) 1373 s.start(Tags.SYNC_CHANGE) 1374 .data(Tags.SYNC_SERVER_ID, c.getString(Message.LIST_SERVER_ID_COLUMN)) 1375 .start(Tags.SYNC_APPLICATION_DATA); 1376 if (readChange) { 1377 s.data(Tags.EMAIL_READ, Integer.toString(read)); 1378 } 1379 // "Flag" is a relatively complex concept in EAS 12.0 and above. It is not only 1380 // the boolean "favorite" that we think of in Gmail, but it also represents a 1381 // follow up action, which can include a subject, start and due dates, and even 1382 // recurrences. We don't support any of this as yet, but EAS 12.0 and higher 1383 // require that a flag contain a status, a type, and four date fields, two each 1384 // for start date and end (due) date. 1385 if (flagChange) { 1386 if (flag != 0) { 1387 // Status 2 = set flag 1388 s.start(Tags.EMAIL_FLAG).data(Tags.EMAIL_FLAG_STATUS, "2"); 1389 // "FollowUp" is the standard type 1390 s.data(Tags.EMAIL_FLAG_TYPE, "FollowUp"); 1391 long now = System.currentTimeMillis(); 1392 Calendar calendar = 1393 GregorianCalendar.getInstance(TimeZone.getTimeZone("GMT")); 1394 calendar.setTimeInMillis(now); 1395 // Flags are required to have a start date and end date (duplicated) 1396 // First, we'll set the current date/time in GMT as the start time 1397 String utc = formatDateTime(calendar); 1398 s.data(Tags.TASK_START_DATE, utc).data(Tags.TASK_UTC_START_DATE, utc); 1399 // And then we'll use one week from today for completion date 1400 calendar.setTimeInMillis(now + 1*WEEKS); 1401 utc = formatDateTime(calendar); 1402 s.data(Tags.TASK_DUE_DATE, utc).data(Tags.TASK_UTC_DUE_DATE, utc); 1403 s.end(); 1404 } else { 1405 s.tag(Tags.EMAIL_FLAG); 1406 } 1407 } 1408 s.end().end(); // SYNC_APPLICATION_DATA, SYNC_CHANGE 1409 1410 // If the message is now in the trash folder, it has been deleted by the user 1411 if (currentCursor.getLong(UPDATES_MAILBOX_KEY_COLUMN) == trashMailboxId) { 1412 if (firstCommand) { 1413 s.start(Tags.SYNC_COMMANDS); 1414 firstCommand = false; 1415 } 1416 // Send the command to delete this message 1417 s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end(); 1418 // Mark the message as moved (so the copy will be deleted if/when the server 1419 // version is synced) 1420 int flags = c.getInt(Message.LIST_FLAGS_COLUMN); 1421 cv.put(MessageColumns.FLAGS, 1422 flags | EasSyncService.MESSAGE_FLAG_MOVED_MESSAGE); 1423 cr.update(ContentUris.withAppendedId(Message.CONTENT_URI, id), cv, 1424 null, null); 1425 continue; 1426 } 1427 } finally { 1428 currentCursor.close(); 1429 } 1430 } 1431 } finally { 1432 c.close(); 1433 } 1434 1435 if (!firstCommand) { 1436 s.end(); // SYNC_COMMANDS 1437 } 1438 return false; 1439 } 1440 } 1441