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