• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2008-2009 Marc Blank
3  * Licensed to The Android Open Source Project.
4  *
5  * Licensed under the Apache License, Version 2.0 (the "License");
6  * you may not use this file except in compliance with the License.
7  * You may obtain a copy of the License at
8  *
9  *      http://www.apache.org/licenses/LICENSE-2.0
10  *
11  * Unless required by applicable law or agreed to in writing, software
12  * distributed under the License is distributed on an "AS IS" BASIS,
13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14  * See the License for the specific language governing permissions and
15  * limitations under the License.
16  */
17 
18 package com.android.exchange.adapter;
19 
20 import android.content.ContentProviderOperation;
21 import android.content.ContentResolver;
22 import android.content.ContentUris;
23 import android.content.ContentValues;
24 import android.content.OperationApplicationException;
25 import android.database.Cursor;
26 import android.net.Uri;
27 import android.os.RemoteException;
28 import android.os.TransactionTooLargeException;
29 import android.provider.CalendarContract.Events;
30 import android.text.Html;
31 import android.text.SpannedString;
32 import android.text.TextUtils;
33 import android.util.Base64;
34 import android.util.Log;
35 import android.webkit.MimeTypeMap;
36 
37 import com.android.emailcommon.internet.MimeMessage;
38 import com.android.emailcommon.internet.MimeUtility;
39 import com.android.emailcommon.mail.Address;
40 import com.android.emailcommon.mail.MeetingInfo;
41 import com.android.emailcommon.mail.MessagingException;
42 import com.android.emailcommon.mail.PackedString;
43 import com.android.emailcommon.mail.Part;
44 import com.android.emailcommon.provider.Account;
45 import com.android.emailcommon.provider.EmailContent;
46 import com.android.emailcommon.provider.EmailContent.AccountColumns;
47 import com.android.emailcommon.provider.EmailContent.Attachment;
48 import com.android.emailcommon.provider.EmailContent.Body;
49 import com.android.emailcommon.provider.EmailContent.MailboxColumns;
50 import com.android.emailcommon.provider.EmailContent.Message;
51 import com.android.emailcommon.provider.EmailContent.MessageColumns;
52 import com.android.emailcommon.provider.EmailContent.SyncColumns;
53 import com.android.emailcommon.provider.Mailbox;
54 import com.android.emailcommon.provider.Policy;
55 import com.android.emailcommon.provider.ProviderUnavailableException;
56 import com.android.emailcommon.service.SyncWindow;
57 import com.android.emailcommon.utility.AttachmentUtilities;
58 import com.android.emailcommon.utility.ConversionUtilities;
59 import com.android.emailcommon.utility.TextUtilities;
60 import com.android.emailcommon.utility.Utility;
61 import com.android.exchange.CommandStatusException;
62 import com.android.exchange.Eas;
63 import com.android.exchange.EasResponse;
64 import com.android.exchange.EasSyncService;
65 import com.android.exchange.MessageMoveRequest;
66 import com.android.exchange.R;
67 import com.android.exchange.utility.CalendarUtilities;
68 import com.google.common.annotations.VisibleForTesting;
69 
70 import org.apache.http.HttpStatus;
71 import org.apache.http.entity.ByteArrayEntity;
72 
73 import java.io.ByteArrayInputStream;
74 import java.io.IOException;
75 import java.io.InputStream;
76 import java.util.ArrayList;
77 import java.util.Calendar;
78 import java.util.GregorianCalendar;
79 import java.util.List;
80 import java.util.TimeZone;
81 
82 /**
83  * Sync adapter for EAS email
84  *
85  */
86 public 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 
EmailSyncAdapter(EasSyncService service)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
wipe()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 
getEmailFilter()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 
FetchRequest(long _messageId, String _serverId)197         FetchRequest(long _messageId, String _serverId) {
198             messageId = _messageId;
199             serverId = _serverId;
200         }
201     }
202 
203     @Override
sendSyncOptions(Double protocolVersion, Serializer s, boolean initialSync)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
parse(InputStream is)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 
getAutomaticLookback()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 
GetItemEstimateParser(InputStream in)363         public GetItemEstimateParser(InputStream in) throws IOException {
364             super(in);
365         }
366 
367         @Override
parse()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 
parseGetItemEstimate()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 
parseResponse()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 
parseCollection()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      */
getEstimate(String filter)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();
putFromMeeting(ps, MeetingInfo.MEETING_LOCATION, values, Events.EVENT_LOCATION)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);
values.put(Events.DTSTART, startTime)633                             values.put(Events.DTSTART, startTime);
634                         }
putFromMeeting(ps, MeetingInfo.MEETING_ALL_DAY, values, Events.ALL_DAY)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