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