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