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