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