EmailSyncAdapter.java revision 8e26c42accbaf72eff6694173496aba0e6aa37f6
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.Utility;
21import com.android.email.mail.Address;
22import com.android.email.mail.MeetingInfo;
23import com.android.email.mail.PackedString;
24import com.android.email.provider.AttachmentProvider;
25import com.android.email.provider.EmailContent;
26import com.android.email.provider.EmailProvider;
27import com.android.email.provider.EmailContent.Account;
28import com.android.email.provider.EmailContent.AccountColumns;
29import com.android.email.provider.EmailContent.Attachment;
30import com.android.email.provider.EmailContent.Body;
31import com.android.email.provider.EmailContent.Mailbox;
32import com.android.email.provider.EmailContent.Message;
33import com.android.email.provider.EmailContent.MessageColumns;
34import com.android.email.provider.EmailContent.SyncColumns;
35import com.android.email.service.MailService;
36import com.android.exchange.Eas;
37import com.android.exchange.EasSyncService;
38import com.android.exchange.utility.CalendarUtilities;
39
40import android.content.ContentProviderOperation;
41import android.content.ContentResolver;
42import android.content.ContentUris;
43import android.content.ContentValues;
44import android.content.OperationApplicationException;
45import android.database.Cursor;
46import android.net.Uri;
47import android.os.RemoteException;
48import android.util.base64.Base64;
49import android.webkit.MimeTypeMap;
50
51import java.io.IOException;
52import java.io.InputStream;
53import java.util.ArrayList;
54import java.util.Calendar;
55import java.util.GregorianCalendar;
56import java.util.TimeZone;
57
58/**
59 * Sync adapter for EAS email
60 *
61 */
62public class EmailSyncAdapter extends AbstractSyncAdapter {
63
64    private static final int UPDATES_READ_COLUMN = 0;
65    private static final int UPDATES_MAILBOX_KEY_COLUMN = 1;
66    private static final int UPDATES_SERVER_ID_COLUMN = 2;
67    private static final int UPDATES_FLAG_COLUMN = 3;
68    private static final String[] UPDATES_PROJECTION =
69        {MessageColumns.FLAG_READ, MessageColumns.MAILBOX_KEY, SyncColumns.SERVER_ID,
70            MessageColumns.FLAG_FAVORITE};
71
72    private static final int MESSAGE_ID_SUBJECT_ID_COLUMN = 0;
73    private static final int MESSAGE_ID_SUBJECT_SUBJECT_COLUMN = 1;
74    private static final String[] MESSAGE_ID_SUBJECT_PROJECTION =
75        new String[] { Message.RECORD_ID, MessageColumns.SUBJECT };
76
77    private static final String WHERE_BODY_SOURCE_MESSAGE_KEY = Body.SOURCE_MESSAGE_KEY + "=?";
78
79    String[] mBindArguments = new String[2];
80    String[] mBindArgument = new String[1];
81
82    ArrayList<Long> mDeletedIdList = new ArrayList<Long>();
83    ArrayList<Long> mUpdatedIdList = new ArrayList<Long>();
84
85    public EmailSyncAdapter(Mailbox mailbox, EasSyncService service) {
86        super(mailbox, service);
87    }
88
89    @Override
90    public boolean parse(InputStream is) throws IOException {
91        EasEmailSyncParser p = new EasEmailSyncParser(is, this);
92        return p.parse();
93    }
94
95    @Override
96    public boolean isSyncable() {
97        return true;
98    }
99
100    public class EasEmailSyncParser extends AbstractSyncParser {
101
102        private static final String WHERE_SERVER_ID_AND_MAILBOX_KEY =
103            SyncColumns.SERVER_ID + "=? and " + MessageColumns.MAILBOX_KEY + "=?";
104
105        private String mMailboxIdAsString;
106
107        ArrayList<Message> newEmails = new ArrayList<Message>();
108        ArrayList<Long> deletedEmails = new ArrayList<Long>();
109        ArrayList<ServerChange> changedEmails = new ArrayList<ServerChange>();
110
111        public EasEmailSyncParser(InputStream in, EmailSyncAdapter adapter) throws IOException {
112            super(in, adapter);
113            mMailboxIdAsString = Long.toString(mMailbox.mId);
114        }
115
116        @Override
117        public void wipe() {
118            mContentResolver.delete(Message.CONTENT_URI,
119                    Message.MAILBOX_KEY + "=" + mMailbox.mId, null);
120            mContentResolver.delete(Message.DELETED_CONTENT_URI,
121                    Message.MAILBOX_KEY + "=" + mMailbox.mId, null);
122            mContentResolver.delete(Message.UPDATED_CONTENT_URI,
123                    Message.MAILBOX_KEY + "=" + mMailbox.mId, null);
124        }
125
126        public void addData (Message msg) throws IOException {
127            ArrayList<Attachment> atts = new ArrayList<Attachment>();
128
129            while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) {
130                switch (tag) {
131                    case Tags.EMAIL_ATTACHMENTS:
132                    case Tags.BASE_ATTACHMENTS: // BASE_ATTACHMENTS is used in EAS 12.0 and up
133                        attachmentsParser(atts, msg);
134                        break;
135                    case Tags.EMAIL_TO:
136                        msg.mTo = Address.pack(Address.parse(getValue()));
137                        break;
138                    case Tags.EMAIL_FROM:
139                        Address[] froms = Address.parse(getValue());
140                        if (froms != null && froms.length > 0) {
141                          msg.mDisplayName = froms[0].toFriendly();
142                        }
143                        msg.mFrom = Address.pack(froms);
144                        break;
145                    case Tags.EMAIL_CC:
146                        msg.mCc = Address.pack(Address.parse(getValue()));
147                        break;
148                    case Tags.EMAIL_REPLY_TO:
149                        msg.mReplyTo = Address.pack(Address.parse(getValue()));
150                        break;
151                    case Tags.EMAIL_DATE_RECEIVED:
152                        msg.mTimeStamp = Utility.parseEmailDateTimeToMillis(getValue());
153                        break;
154                    case Tags.EMAIL_SUBJECT:
155                        msg.mSubject = getValue();
156                        break;
157                    case Tags.EMAIL_READ:
158                        msg.mFlagRead = getValueInt() == 1;
159                        break;
160                    case Tags.BASE_BODY:
161                        bodyParser(msg);
162                        break;
163                    case Tags.EMAIL_FLAG:
164                        msg.mFlagFavorite = flagParser();
165                        break;
166                    case Tags.EMAIL_BODY:
167                        String text = getValue();
168                        msg.mText = text;
169                        break;
170                    case Tags.EMAIL_MESSAGE_CLASS:
171                        String messageClass = getValue();
172                        if (messageClass.equals("IPM.Schedule.Meeting.Request")) {
173                            msg.mFlags |= Message.FLAG_INCOMING_MEETING_INVITE;
174                        } else if (messageClass.equals("IPM.Schedule.Meeting.Canceled")) {
175                            msg.mFlags |= Message.FLAG_INCOMING_MEETING_CANCEL;
176                        }
177                        break;
178                    case Tags.EMAIL_MEETING_REQUEST:
179                        meetingRequestParser(msg);
180                        break;
181                    default:
182                        skipTag();
183                }
184            }
185
186            if (atts.size() > 0) {
187                msg.mAttachments = atts;
188            }
189        }
190
191        /**
192         * Set up the meetingInfo field in the message with various pieces of information gleaned
193         * from MeetingRequest tags.  This information will be used later to generate an appropriate
194         * reply email if the user chooses to respond
195         * @param msg the Message being built
196         * @throws IOException
197         */
198        private void meetingRequestParser(Message msg) throws IOException {
199            PackedString.Builder packedString = new PackedString.Builder();
200            while (nextTag(Tags.EMAIL_MEETING_REQUEST) != END) {
201                switch (tag) {
202                    case Tags.EMAIL_DTSTAMP:
203                        packedString.put(MeetingInfo.MEETING_DTSTAMP, getValue());
204                        break;
205                    case Tags.EMAIL_START_TIME:
206                        packedString.put(MeetingInfo.MEETING_DTSTART, getValue());
207                        break;
208                    case Tags.EMAIL_END_TIME:
209                        packedString.put(MeetingInfo.MEETING_DTEND, getValue());
210                        break;
211                    case Tags.EMAIL_ORGANIZER:
212                        packedString.put(MeetingInfo.MEETING_ORGANIZER_EMAIL, getValue());
213                        break;
214                    case Tags.EMAIL_LOCATION:
215                        packedString.put(MeetingInfo.MEETING_LOCATION, getValue());
216                        break;
217                    case Tags.EMAIL_GLOBAL_OBJID:
218                        // This is lovely; the unique id is a base64 encoded hex string
219                        String guid = getValue();
220                        StringBuilder sb = new StringBuilder();
221                        // First get the decoded base64
222                        byte[] bytes = Base64.decode(guid, Base64.DEFAULT);
223                        // Then go through the bytes and write out the hex values as characters
224                        for (byte b: bytes) {
225                            int unsignedByte = (b < 0) ? b + 256 : b;
226                            sb.append("0123456789ABCDEF".charAt(unsignedByte >> 4));
227                            sb.append("0123456789ABCDEF".charAt(unsignedByte & 0xF));
228                        }
229                        packedString.put(MeetingInfo.MEETING_UID, sb.toString());
230                        break;
231                    case Tags.EMAIL_CATEGORIES:
232                        nullParser();
233                        break;
234                    case Tags.EMAIL_RECURRENCES:
235                        recurrencesParser();
236                        break;
237                    default:
238                        skipTag();
239                }
240            }
241            if (msg.mSubject != null) {
242                packedString.put(MeetingInfo.MEETING_TITLE, msg.mSubject);
243            }
244            msg.mMeetingInfo = packedString.toString();
245        }
246
247        private void nullParser() throws IOException {
248            while (nextTag(Tags.EMAIL_CATEGORIES) != END) {
249                skipTag();
250            }
251        }
252
253        private void recurrencesParser() throws IOException {
254            while (nextTag(Tags.EMAIL_RECURRENCES) != END) {
255                switch (tag) {
256                    case Tags.EMAIL_RECURRENCE:
257                        nullParser();
258                        break;
259                    default:
260                        skipTag();
261                }
262            }
263        }
264
265        private void addParser(ArrayList<Message> emails) throws IOException {
266            Message msg = new Message();
267            msg.mAccountKey = mAccount.mId;
268            msg.mMailboxKey = mMailbox.mId;
269            msg.mFlagLoaded = Message.FLAG_LOADED_COMPLETE;
270
271            while (nextTag(Tags.SYNC_ADD) != END) {
272                switch (tag) {
273                    case Tags.SYNC_SERVER_ID:
274                        msg.mServerId = getValue();
275                        break;
276                    case Tags.SYNC_APPLICATION_DATA:
277                        addData(msg);
278                        break;
279                    default:
280                        skipTag();
281                }
282            }
283            emails.add(msg);
284        }
285
286        // For now, we only care about the "active" state
287        private Boolean flagParser() throws IOException {
288            Boolean state = false;
289            while (nextTag(Tags.EMAIL_FLAG) != END) {
290                switch (tag) {
291                    case Tags.EMAIL_FLAG_STATUS:
292                        state = getValueInt() == 2;
293                        break;
294                    default:
295                        skipTag();
296                }
297            }
298            return state;
299        }
300
301        private void bodyParser(Message msg) throws IOException {
302            String bodyType = Eas.BODY_PREFERENCE_TEXT;
303            String body = "";
304            while (nextTag(Tags.EMAIL_BODY) != END) {
305                switch (tag) {
306                    case Tags.BASE_TYPE:
307                        bodyType = getValue();
308                        break;
309                    case Tags.BASE_DATA:
310                        body = getValue();
311                        break;
312                    default:
313                        skipTag();
314                }
315            }
316            // We always ask for TEXT or HTML; there's no third option
317            if (bodyType.equals(Eas.BODY_PREFERENCE_HTML)) {
318                msg.mHtml = body;
319            } else {
320                msg.mText = body;
321            }
322        }
323
324        private void attachmentsParser(ArrayList<Attachment> atts, Message msg) throws IOException {
325            while (nextTag(Tags.EMAIL_ATTACHMENTS) != END) {
326                switch (tag) {
327                    case Tags.EMAIL_ATTACHMENT:
328                    case Tags.BASE_ATTACHMENT:  // BASE_ATTACHMENT is used in EAS 12.0 and up
329                        attachmentParser(atts, msg);
330                        break;
331                    default:
332                        skipTag();
333                }
334            }
335        }
336
337        private void attachmentParser(ArrayList<Attachment> atts, Message msg) throws IOException {
338            String fileName = null;
339            String length = null;
340            String location = null;
341
342            while (nextTag(Tags.EMAIL_ATTACHMENT) != END) {
343                switch (tag) {
344                    // We handle both EAS 2.5 and 12.0+ attachments here
345                    case Tags.EMAIL_DISPLAY_NAME:
346                    case Tags.BASE_DISPLAY_NAME:
347                        fileName = getValue();
348                        break;
349                    case Tags.EMAIL_ATT_NAME:
350                    case Tags.BASE_FILE_REFERENCE:
351                        location = getValue();
352                        break;
353                    case Tags.EMAIL_ATT_SIZE:
354                    case Tags.BASE_ESTIMATED_DATA_SIZE:
355                        length = getValue();
356                        break;
357                    default:
358                        skipTag();
359                }
360            }
361
362            if ((fileName != null) && (length != null) && (location != null)) {
363                Attachment att = new Attachment();
364                att.mEncoding = "base64";
365                att.mSize = Long.parseLong(length);
366                att.mFileName = fileName;
367                att.mLocation = location;
368                att.mMimeType = getMimeTypeFromFileName(fileName);
369                atts.add(att);
370                msg.mFlagAttachment = true;
371            }
372        }
373
374        /**
375         * Try to determine a mime type from a file name, defaulting to application/x, where x
376         * is either the extension or (if none) octet-stream
377         * At the moment, this is somewhat lame, since many file types aren't recognized
378         * @param fileName the file name to ponder
379         * @return
380         */
381        // Note: The MimeTypeMap method currently uses a very limited set of mime types
382        // A bug has been filed against this issue.
383        public String getMimeTypeFromFileName(String fileName) {
384            String mimeType;
385            int lastDot = fileName.lastIndexOf('.');
386            String extension = null;
387            if ((lastDot > 0) && (lastDot < fileName.length() - 1)) {
388                extension = fileName.substring(lastDot + 1).toLowerCase();
389            }
390            if (extension == null) {
391                // A reasonable default for now.
392                mimeType = "application/octet-stream";
393            } else {
394                mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
395                if (mimeType == null) {
396                    mimeType = "application/" + extension;
397                }
398            }
399            return mimeType;
400        }
401
402        private Cursor getServerIdCursor(String serverId, String[] projection) {
403            mBindArguments[0] = serverId;
404            mBindArguments[1] = mMailboxIdAsString;
405            return mContentResolver.query(Message.CONTENT_URI, projection,
406                    WHERE_SERVER_ID_AND_MAILBOX_KEY, mBindArguments, null);
407        }
408
409        /*package*/ void deleteParser(ArrayList<Long> deletes, int entryTag) throws IOException {
410            while (nextTag(entryTag) != END) {
411                switch (tag) {
412                    case Tags.SYNC_SERVER_ID:
413                        String serverId = getValue();
414                        // Find the message in this mailbox with the given serverId
415                        Cursor c = getServerIdCursor(serverId, MESSAGE_ID_SUBJECT_PROJECTION);
416                        try {
417                            if (c.moveToFirst()) {
418                                deletes.add(c.getLong(MESSAGE_ID_SUBJECT_ID_COLUMN));
419                                if (Eas.USER_LOG) {
420                                    userLog("Deleting ", serverId + ", "
421                                            + c.getString(MESSAGE_ID_SUBJECT_SUBJECT_COLUMN));
422                                }
423                            }
424                        } finally {
425                            c.close();
426                        }
427                        break;
428                    default:
429                        skipTag();
430                }
431            }
432        }
433
434        class ServerChange {
435            long id;
436            Boolean read;
437            Boolean flag;
438
439            ServerChange(long _id, Boolean _read, Boolean _flag) {
440                id = _id;
441                read = _read;
442                flag = _flag;
443            }
444        }
445
446        /*package*/ void changeParser(ArrayList<ServerChange> changes) throws IOException {
447            String serverId = null;
448            Boolean oldRead = false;
449            Boolean oldFlag = false;
450            long id = 0;
451            while (nextTag(Tags.SYNC_CHANGE) != END) {
452                switch (tag) {
453                    case Tags.SYNC_SERVER_ID:
454                        serverId = getValue();
455                        Cursor c = getServerIdCursor(serverId, Message.LIST_PROJECTION);
456                        try {
457                            if (c.moveToFirst()) {
458                                userLog("Changing ", serverId);
459                                oldRead = c.getInt(Message.LIST_READ_COLUMN) == Message.READ;
460                                oldFlag = c.getInt(Message.LIST_FAVORITE_COLUMN) == 1;
461                                id = c.getLong(Message.LIST_ID_COLUMN);
462                            }
463                        } finally {
464                            c.close();
465                        }
466                        break;
467                    case Tags.SYNC_APPLICATION_DATA:
468                        changeApplicationDataParser(changes, oldRead, oldFlag, id);
469                        break;
470                    default:
471                        skipTag();
472                }
473            }
474        }
475
476        private void changeApplicationDataParser(ArrayList<ServerChange> changes, Boolean oldRead,
477                Boolean oldFlag, long id) throws IOException {
478            Boolean read = null;
479            Boolean flag = null;
480            while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) {
481                switch (tag) {
482                    case Tags.EMAIL_READ:
483                        read = getValueInt() == 1;
484                        break;
485                    case Tags.EMAIL_FLAG:
486                        flag = flagParser();
487                        break;
488                    default:
489                        skipTag();
490                }
491            }
492            if (((read != null) && !oldRead.equals(read)) ||
493                    ((flag != null) && !oldFlag.equals(flag))) {
494                changes.add(new ServerChange(id, read, flag));
495            }
496        }
497
498        /* (non-Javadoc)
499         * @see com.android.exchange.adapter.EasContentParser#commandsParser()
500         */
501        @Override
502        public void commandsParser() throws IOException {
503            while (nextTag(Tags.SYNC_COMMANDS) != END) {
504                if (tag == Tags.SYNC_ADD) {
505                    addParser(newEmails);
506                    incrementChangeCount();
507                } else if (tag == Tags.SYNC_DELETE || tag == Tags.SYNC_SOFT_DELETE) {
508                    deleteParser(deletedEmails, tag);
509                    incrementChangeCount();
510                } else if (tag == Tags.SYNC_CHANGE) {
511                    changeParser(changedEmails);
512                    incrementChangeCount();
513                } else
514                    skipTag();
515            }
516        }
517
518        @Override
519        public void responsesParser() {
520        }
521
522        @Override
523        public void commit() {
524            int notifyCount = 0;
525
526            // Use a batch operation to handle the changes
527            // TODO New mail notifications?  Who looks for these?
528            ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
529            for (Message msg: newEmails) {
530                if (!msg.mFlagRead) {
531                    notifyCount++;
532                }
533                msg.addSaveOps(ops);
534            }
535            for (Long id : deletedEmails) {
536                ops.add(ContentProviderOperation.newDelete(
537                        ContentUris.withAppendedId(Message.CONTENT_URI, id)).build());
538                AttachmentProvider.deleteAllAttachmentFiles(mContext, mAccount.mId, id);
539            }
540            if (!changedEmails.isEmpty()) {
541                // Server wins in a conflict...
542                for (ServerChange change : changedEmails) {
543                     ContentValues cv = new ContentValues();
544                    if (change.read != null) {
545                        cv.put(MessageColumns.FLAG_READ, change.read);
546                    }
547                    if (change.flag != null) {
548                        cv.put(MessageColumns.FLAG_FAVORITE, change.flag);
549                    }
550                    ops.add(ContentProviderOperation.newUpdate(
551                            ContentUris.withAppendedId(Message.CONTENT_URI, change.id))
552                                .withValues(cv)
553                                .build());
554                }
555            }
556
557            // We only want to update the sync key here
558            ContentValues mailboxValues = new ContentValues();
559            mailboxValues.put(Mailbox.SYNC_KEY, mMailbox.mSyncKey);
560            ops.add(ContentProviderOperation.newUpdate(
561                    ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailbox.mId))
562                        .withValues(mailboxValues).build());
563
564            addCleanupOps(ops);
565
566            // No commits if we're stopped
567            synchronized (mService.getSynchronizer()) {
568                if (mService.isStopped()) return;
569                try {
570                    mContentResolver.applyBatch(EmailProvider.EMAIL_AUTHORITY, ops);
571                    userLog(mMailbox.mDisplayName, " SyncKey saved as: ", mMailbox.mSyncKey);
572                } catch (RemoteException e) {
573                    // There is nothing to be done here; fail by returning null
574                } catch (OperationApplicationException e) {
575                    // There is nothing to be done here; fail by returning null
576                }
577            }
578
579            if (notifyCount > 0) {
580                // Use the new atomic add URI in EmailProvider
581                // We could add this to the operations being done, but it's not strictly
582                // speaking necessary, as the previous batch preserves the integrity of the
583                // database, whereas this is purely for notification purposes, and is itself atomic
584                ContentValues cv = new ContentValues();
585                cv.put(EmailContent.FIELD_COLUMN_NAME, AccountColumns.NEW_MESSAGE_COUNT);
586                cv.put(EmailContent.ADD_COLUMN_NAME, notifyCount);
587                Uri uri = ContentUris.withAppendedId(Account.ADD_TO_FIELD_URI, mAccount.mId);
588                mContentResolver.update(uri, cv, null, null);
589                MailService.actionNotifyNewMessages(mContext, mAccount.mId);
590            }
591        }
592    }
593
594    @Override
595    public String getCollectionName() {
596        return "Email";
597    }
598
599    private void addCleanupOps(ArrayList<ContentProviderOperation> ops) {
600        // If we've sent local deletions, clear out the deleted table
601        for (Long id: mDeletedIdList) {
602            ops.add(ContentProviderOperation.newDelete(
603                    ContentUris.withAppendedId(Message.DELETED_CONTENT_URI, id)).build());
604        }
605        // And same with the updates
606        for (Long id: mUpdatedIdList) {
607            ops.add(ContentProviderOperation.newDelete(
608                    ContentUris.withAppendedId(Message.UPDATED_CONTENT_URI, id)).build());
609        }
610    }
611
612    @Override
613    public void cleanup() {
614        if (!mDeletedIdList.isEmpty() || !mUpdatedIdList.isEmpty()) {
615            ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
616            addCleanupOps(ops);
617            try {
618                mContext.getContentResolver()
619                    .applyBatch(EmailProvider.EMAIL_AUTHORITY, ops);
620            } catch (RemoteException e) {
621                // There is nothing to be done here; fail by returning null
622            } catch (OperationApplicationException e) {
623                // There is nothing to be done here; fail by returning null
624            }
625        }
626    }
627
628    private String formatTwo(int num) {
629        if (num < 10) {
630            return "0" + (char)('0' + num);
631        } else
632            return Integer.toString(num);
633    }
634
635    /**
636     * Create date/time in RFC8601 format.  Oddly enough, for calendar date/time, Microsoft uses
637     * a different format that excludes the punctuation (this is why I'm not putting this in a
638     * parent class)
639     */
640    public String formatDateTime(Calendar calendar) {
641        StringBuilder sb = new StringBuilder();
642        //YYYY-MM-DDTHH:MM:SS.MSSZ
643        sb.append(calendar.get(Calendar.YEAR));
644        sb.append('-');
645        sb.append(formatTwo(calendar.get(Calendar.MONTH) + 1));
646        sb.append('-');
647        sb.append(formatTwo(calendar.get(Calendar.DAY_OF_MONTH)));
648        sb.append('T');
649        sb.append(formatTwo(calendar.get(Calendar.HOUR_OF_DAY)));
650        sb.append(':');
651        sb.append(formatTwo(calendar.get(Calendar.MINUTE)));
652        sb.append(':');
653        sb.append(formatTwo(calendar.get(Calendar.SECOND)));
654        sb.append(".000Z");
655        return sb.toString();
656    }
657
658    /**
659     * Note that messages in the deleted database preserve the message's unique id; therefore, we
660     * can utilize this id to find references to the message.  The only reference situation at this
661     * point is in the Body table; it is when sending messages via SmartForward and SmartReply
662     */
663    private boolean messageReferenced(ContentResolver cr, long id) {
664        mBindArgument[0] = Long.toString(id);
665        // See if this id is referenced in a body
666        Cursor c = cr.query(Body.CONTENT_URI, Body.ID_PROJECTION, WHERE_BODY_SOURCE_MESSAGE_KEY,
667                mBindArgument, null);
668        try {
669            return c.moveToFirst();
670        } finally {
671            c.close();
672        }
673    }
674
675    /*private*/ /**
676     * Serialize commands to delete items from the server; as we find items to delete, add their
677     * id's to the deletedId's array
678     *
679     * @param s the Serializer we're using to create post data
680     * @param deletedIds ids whose deletions are being sent to the server
681     * @param first whether or not this is the first command being sent
682     * @return true if SYNC_COMMANDS hasn't been sent (false otherwise)
683     * @throws IOException
684     */
685    boolean sendDeletedItems(Serializer s, ArrayList<Long> deletedIds, boolean first)
686            throws IOException {
687        ContentResolver cr = mContext.getContentResolver();
688
689        // Find any of our deleted items
690        Cursor c = cr.query(Message.DELETED_CONTENT_URI, Message.LIST_PROJECTION,
691                MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null);
692        // We keep track of the list of deleted item id's so that we can remove them from the
693        // deleted table after the server receives our command
694        deletedIds.clear();
695        try {
696            while (c.moveToNext()) {
697                String serverId = c.getString(Message.LIST_SERVER_ID_COLUMN);
698                // Keep going if there's no serverId
699                if (serverId == null) {
700                    continue;
701                // Also check if this message is referenced elsewhere
702                } else if (messageReferenced(cr, c.getLong(Message.CONTENT_ID_COLUMN))) {
703                    userLog("Postponing deletion of referenced message: ", serverId);
704                    continue;
705                } else if (first) {
706                    s.start(Tags.SYNC_COMMANDS);
707                    first = false;
708                }
709                // Send the command to delete this message
710                s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end();
711                deletedIds.add(c.getLong(Message.LIST_ID_COLUMN));
712            }
713        } finally {
714            c.close();
715        }
716
717       return first;
718    }
719
720    @Override
721    public boolean sendLocalChanges(Serializer s) throws IOException {
722        ContentResolver cr = mContext.getContentResolver();
723
724        // Never upsync from these folders
725        if (mMailbox.mType == Mailbox.TYPE_DRAFTS || mMailbox.mType == Mailbox.TYPE_OUTBOX) {
726            return false;
727        }
728
729        // This code is split out for unit testing purposes
730        boolean firstCommand = sendDeletedItems(s, mDeletedIdList, true);
731
732        // Find our trash mailbox, since deletions will have been moved there...
733        long trashMailboxId =
734            Mailbox.findMailboxOfType(mContext, mMailbox.mAccountKey, Mailbox.TYPE_TRASH);
735
736        // Do the same now for updated items
737        Cursor c = cr.query(Message.UPDATED_CONTENT_URI, Message.LIST_PROJECTION,
738                MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null);
739
740        // We keep track of the list of updated item id's as we did above with deleted items
741        mUpdatedIdList.clear();
742        try {
743            while (c.moveToNext()) {
744                long id = c.getLong(Message.LIST_ID_COLUMN);
745                // Say we've handled this update
746                mUpdatedIdList.add(id);
747                // We have the id of the changed item.  But first, we have to find out its current
748                // state, since the updated table saves the opriginal state
749                Cursor currentCursor = cr.query(ContentUris.withAppendedId(Message.CONTENT_URI, id),
750                        UPDATES_PROJECTION, null, null, null);
751                try {
752                    // If this item no longer exists (shouldn't be possible), just move along
753                    if (!currentCursor.moveToFirst()) {
754                         continue;
755                    }
756                    // Keep going if there's no serverId
757                    String serverId = currentCursor.getString(UPDATES_SERVER_ID_COLUMN);
758                    if (serverId == null) {
759                        continue;
760                    }
761                    // If the message is now in the trash folder, it has been deleted by the user
762                    if (currentCursor.getLong(UPDATES_MAILBOX_KEY_COLUMN) == trashMailboxId) {
763                         if (firstCommand) {
764                            s.start(Tags.SYNC_COMMANDS);
765                            firstCommand = false;
766                        }
767                        // Send the command to delete this message
768                        s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end();
769                        continue;
770                    }
771
772                    boolean flagChange = false;
773                    boolean readChange = false;
774
775                    int flag = 0;
776
777                    // We can only send flag changes to the server in 12.0 or later
778                    if (mService.mProtocolVersionDouble >= 12.0) {
779                        flag = currentCursor.getInt(UPDATES_FLAG_COLUMN);
780                        if (flag != c.getInt(Message.LIST_FAVORITE_COLUMN)) {
781                            flagChange = true;
782                        }
783                    }
784
785                    int read = currentCursor.getInt(UPDATES_READ_COLUMN);
786                    if (read != c.getInt(Message.LIST_READ_COLUMN)) {
787                        readChange = true;
788                    }
789
790                    if (!flagChange && !readChange) {
791                        // In this case, we've got nothing to send to the server
792                        continue;
793                    }
794
795                    if (firstCommand) {
796                        s.start(Tags.SYNC_COMMANDS);
797                        firstCommand = false;
798                    }
799                    // Send the change to "read" and "favorite" (flagged)
800                    s.start(Tags.SYNC_CHANGE)
801                        .data(Tags.SYNC_SERVER_ID, c.getString(Message.LIST_SERVER_ID_COLUMN))
802                        .start(Tags.SYNC_APPLICATION_DATA);
803                    if (readChange) {
804                        s.data(Tags.EMAIL_READ, Integer.toString(read));
805                    }
806                    // "Flag" is a relatively complex concept in EAS 12.0 and above.  It is not only
807                    // the boolean "favorite" that we think of in Gmail, but it also represents a
808                    // follow up action, which can include a subject, start and due dates, and even
809                    // recurrences.  We don't support any of this as yet, but EAS 12.0 and higher
810                    // require that a flag contain a status, a type, and four date fields, two each
811                    // for start date and end (due) date.
812                    if (flagChange) {
813                        if (flag != 0) {
814                            // Status 2 = set flag
815                            s.start(Tags.EMAIL_FLAG).data(Tags.EMAIL_FLAG_STATUS, "2");
816                            // "FollowUp" is the standard type
817                            s.data(Tags.EMAIL_FLAG_TYPE, "FollowUp");
818                            long now = System.currentTimeMillis();
819                            Calendar calendar =
820                                GregorianCalendar.getInstance(TimeZone.getTimeZone("GMT"));
821                            calendar.setTimeInMillis(now);
822                            // Flags are required to have a start date and end date (duplicated)
823                            // First, we'll set the current date/time in GMT as the start time
824                            String utc = formatDateTime(calendar);
825                            s.data(Tags.TASK_START_DATE, utc).data(Tags.TASK_UTC_START_DATE, utc);
826                            // And then we'll use one week from today for completion date
827                            calendar.setTimeInMillis(now + 1*WEEKS);
828                            utc = formatDateTime(calendar);
829                            s.data(Tags.TASK_DUE_DATE, utc).data(Tags.TASK_UTC_DUE_DATE, utc);
830                            s.end();
831                        } else {
832                            s.tag(Tags.EMAIL_FLAG);
833                        }
834                    }
835                    s.end().end(); // SYNC_APPLICATION_DATA, SYNC_CHANGE
836                } finally {
837                    currentCursor.close();
838                }
839            }
840        } finally {
841            c.close();
842        }
843
844        if (!firstCommand) {
845            s.end(); // SYNC_COMMANDS
846        }
847        return false;
848    }
849}
850