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