EmailSyncAdapter.java revision d5fadc87ea52aee033afd476369b29b29bfd434f
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                            Utility.byteToHex(sb, b);
226                        }
227                        packedString.put(MeetingInfo.MEETING_UID, sb.toString());
228                        break;
229                    case Tags.EMAIL_CATEGORIES:
230                        nullParser();
231                        break;
232                    case Tags.EMAIL_RECURRENCES:
233                        recurrencesParser();
234                        break;
235                    default:
236                        skipTag();
237                }
238            }
239            if (msg.mSubject != null) {
240                packedString.put(MeetingInfo.MEETING_TITLE, msg.mSubject);
241            }
242            msg.mMeetingInfo = packedString.toString();
243        }
244
245        private void nullParser() throws IOException {
246            while (nextTag(Tags.EMAIL_CATEGORIES) != END) {
247                skipTag();
248            }
249        }
250
251        private void recurrencesParser() throws IOException {
252            while (nextTag(Tags.EMAIL_RECURRENCES) != END) {
253                switch (tag) {
254                    case Tags.EMAIL_RECURRENCE:
255                        nullParser();
256                        break;
257                    default:
258                        skipTag();
259                }
260            }
261        }
262
263        private void addParser(ArrayList<Message> emails) throws IOException {
264            Message msg = new Message();
265            msg.mAccountKey = mAccount.mId;
266            msg.mMailboxKey = mMailbox.mId;
267            msg.mFlagLoaded = Message.FLAG_LOADED_COMPLETE;
268
269            while (nextTag(Tags.SYNC_ADD) != END) {
270                switch (tag) {
271                    case Tags.SYNC_SERVER_ID:
272                        msg.mServerId = getValue();
273                        break;
274                    case Tags.SYNC_APPLICATION_DATA:
275                        addData(msg);
276                        break;
277                    default:
278                        skipTag();
279                }
280            }
281            emails.add(msg);
282        }
283
284        // For now, we only care about the "active" state
285        private Boolean flagParser() throws IOException {
286            Boolean state = false;
287            while (nextTag(Tags.EMAIL_FLAG) != END) {
288                switch (tag) {
289                    case Tags.EMAIL_FLAG_STATUS:
290                        state = getValueInt() == 2;
291                        break;
292                    default:
293                        skipTag();
294                }
295            }
296            return state;
297        }
298
299        private void bodyParser(Message msg) throws IOException {
300            String bodyType = Eas.BODY_PREFERENCE_TEXT;
301            String body = "";
302            while (nextTag(Tags.EMAIL_BODY) != END) {
303                switch (tag) {
304                    case Tags.BASE_TYPE:
305                        bodyType = getValue();
306                        break;
307                    case Tags.BASE_DATA:
308                        body = getValue();
309                        break;
310                    default:
311                        skipTag();
312                }
313            }
314            // We always ask for TEXT or HTML; there's no third option
315            if (bodyType.equals(Eas.BODY_PREFERENCE_HTML)) {
316                msg.mHtml = body;
317            } else {
318                msg.mText = body;
319            }
320        }
321
322        private void attachmentsParser(ArrayList<Attachment> atts, Message msg) throws IOException {
323            while (nextTag(Tags.EMAIL_ATTACHMENTS) != END) {
324                switch (tag) {
325                    case Tags.EMAIL_ATTACHMENT:
326                    case Tags.BASE_ATTACHMENT:  // BASE_ATTACHMENT is used in EAS 12.0 and up
327                        attachmentParser(atts, msg);
328                        break;
329                    default:
330                        skipTag();
331                }
332            }
333        }
334
335        private void attachmentParser(ArrayList<Attachment> atts, Message msg) throws IOException {
336            String fileName = null;
337            String length = null;
338            String location = null;
339
340            while (nextTag(Tags.EMAIL_ATTACHMENT) != END) {
341                switch (tag) {
342                    // We handle both EAS 2.5 and 12.0+ attachments here
343                    case Tags.EMAIL_DISPLAY_NAME:
344                    case Tags.BASE_DISPLAY_NAME:
345                        fileName = getValue();
346                        break;
347                    case Tags.EMAIL_ATT_NAME:
348                    case Tags.BASE_FILE_REFERENCE:
349                        location = getValue();
350                        break;
351                    case Tags.EMAIL_ATT_SIZE:
352                    case Tags.BASE_ESTIMATED_DATA_SIZE:
353                        length = getValue();
354                        break;
355                    default:
356                        skipTag();
357                }
358            }
359
360            if ((fileName != null) && (length != null) && (location != null)) {
361                Attachment att = new Attachment();
362                att.mEncoding = "base64";
363                att.mSize = Long.parseLong(length);
364                att.mFileName = fileName;
365                att.mLocation = location;
366                att.mMimeType = getMimeTypeFromFileName(fileName);
367                atts.add(att);
368                msg.mFlagAttachment = true;
369            }
370        }
371
372        /**
373         * Try to determine a mime type from a file name, defaulting to application/x, where x
374         * is either the extension or (if none) octet-stream
375         * At the moment, this is somewhat lame, since many file types aren't recognized
376         * @param fileName the file name to ponder
377         * @return
378         */
379        // Note: The MimeTypeMap method currently uses a very limited set of mime types
380        // A bug has been filed against this issue.
381        public String getMimeTypeFromFileName(String fileName) {
382            String mimeType;
383            int lastDot = fileName.lastIndexOf('.');
384            String extension = null;
385            if ((lastDot > 0) && (lastDot < fileName.length() - 1)) {
386                extension = fileName.substring(lastDot + 1).toLowerCase();
387            }
388            if (extension == null) {
389                // A reasonable default for now.
390                mimeType = "application/octet-stream";
391            } else {
392                mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
393                if (mimeType == null) {
394                    mimeType = "application/" + extension;
395                }
396            }
397            return mimeType;
398        }
399
400        private Cursor getServerIdCursor(String serverId, String[] projection) {
401            mBindArguments[0] = serverId;
402            mBindArguments[1] = mMailboxIdAsString;
403            return mContentResolver.query(Message.CONTENT_URI, projection,
404                    WHERE_SERVER_ID_AND_MAILBOX_KEY, mBindArguments, null);
405        }
406
407        /*package*/ void deleteParser(ArrayList<Long> deletes, int entryTag) throws IOException {
408            while (nextTag(entryTag) != END) {
409                switch (tag) {
410                    case Tags.SYNC_SERVER_ID:
411                        String serverId = getValue();
412                        // Find the message in this mailbox with the given serverId
413                        Cursor c = getServerIdCursor(serverId, MESSAGE_ID_SUBJECT_PROJECTION);
414                        try {
415                            if (c.moveToFirst()) {
416                                deletes.add(c.getLong(MESSAGE_ID_SUBJECT_ID_COLUMN));
417                                if (Eas.USER_LOG) {
418                                    userLog("Deleting ", serverId + ", "
419                                            + c.getString(MESSAGE_ID_SUBJECT_SUBJECT_COLUMN));
420                                }
421                            }
422                        } finally {
423                            c.close();
424                        }
425                        break;
426                    default:
427                        skipTag();
428                }
429            }
430        }
431
432        class ServerChange {
433            long id;
434            Boolean read;
435            Boolean flag;
436
437            ServerChange(long _id, Boolean _read, Boolean _flag) {
438                id = _id;
439                read = _read;
440                flag = _flag;
441            }
442        }
443
444        /*package*/ void changeParser(ArrayList<ServerChange> changes) throws IOException {
445            String serverId = null;
446            Boolean oldRead = false;
447            Boolean oldFlag = false;
448            long id = 0;
449            while (nextTag(Tags.SYNC_CHANGE) != END) {
450                switch (tag) {
451                    case Tags.SYNC_SERVER_ID:
452                        serverId = getValue();
453                        Cursor c = getServerIdCursor(serverId, Message.LIST_PROJECTION);
454                        try {
455                            if (c.moveToFirst()) {
456                                userLog("Changing ", serverId);
457                                oldRead = c.getInt(Message.LIST_READ_COLUMN) == Message.READ;
458                                oldFlag = c.getInt(Message.LIST_FAVORITE_COLUMN) == 1;
459                                id = c.getLong(Message.LIST_ID_COLUMN);
460                            }
461                        } finally {
462                            c.close();
463                        }
464                        break;
465                    case Tags.SYNC_APPLICATION_DATA:
466                        changeApplicationDataParser(changes, oldRead, oldFlag, id);
467                        break;
468                    default:
469                        skipTag();
470                }
471            }
472        }
473
474        private void changeApplicationDataParser(ArrayList<ServerChange> changes, Boolean oldRead,
475                Boolean oldFlag, long id) throws IOException {
476            Boolean read = null;
477            Boolean flag = null;
478            while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) {
479                switch (tag) {
480                    case Tags.EMAIL_READ:
481                        read = getValueInt() == 1;
482                        break;
483                    case Tags.EMAIL_FLAG:
484                        flag = flagParser();
485                        break;
486                    default:
487                        skipTag();
488                }
489            }
490            if (((read != null) && !oldRead.equals(read)) ||
491                    ((flag != null) && !oldFlag.equals(flag))) {
492                changes.add(new ServerChange(id, read, flag));
493            }
494        }
495
496        /* (non-Javadoc)
497         * @see com.android.exchange.adapter.EasContentParser#commandsParser()
498         */
499        @Override
500        public void commandsParser() throws IOException {
501            while (nextTag(Tags.SYNC_COMMANDS) != END) {
502                if (tag == Tags.SYNC_ADD) {
503                    addParser(newEmails);
504                    incrementChangeCount();
505                } else if (tag == Tags.SYNC_DELETE || tag == Tags.SYNC_SOFT_DELETE) {
506                    deleteParser(deletedEmails, tag);
507                    incrementChangeCount();
508                } else if (tag == Tags.SYNC_CHANGE) {
509                    changeParser(changedEmails);
510                    incrementChangeCount();
511                } else
512                    skipTag();
513            }
514        }
515
516        @Override
517        public void responsesParser() {
518        }
519
520        @Override
521        public void commit() {
522            int notifyCount = 0;
523
524            // Use a batch operation to handle the changes
525            // TODO New mail notifications?  Who looks for these?
526            ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
527            for (Message msg: newEmails) {
528                if (!msg.mFlagRead) {
529                    notifyCount++;
530                }
531                msg.addSaveOps(ops);
532            }
533            for (Long id : deletedEmails) {
534                ops.add(ContentProviderOperation.newDelete(
535                        ContentUris.withAppendedId(Message.CONTENT_URI, id)).build());
536                AttachmentProvider.deleteAllAttachmentFiles(mContext, mAccount.mId, id);
537            }
538            if (!changedEmails.isEmpty()) {
539                // Server wins in a conflict...
540                for (ServerChange change : changedEmails) {
541                     ContentValues cv = new ContentValues();
542                    if (change.read != null) {
543                        cv.put(MessageColumns.FLAG_READ, change.read);
544                    }
545                    if (change.flag != null) {
546                        cv.put(MessageColumns.FLAG_FAVORITE, change.flag);
547                    }
548                    ops.add(ContentProviderOperation.newUpdate(
549                            ContentUris.withAppendedId(Message.CONTENT_URI, change.id))
550                                .withValues(cv)
551                                .build());
552                }
553            }
554
555            // We only want to update the sync key here
556            ContentValues mailboxValues = new ContentValues();
557            mailboxValues.put(Mailbox.SYNC_KEY, mMailbox.mSyncKey);
558            ops.add(ContentProviderOperation.newUpdate(
559                    ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailbox.mId))
560                        .withValues(mailboxValues).build());
561
562            addCleanupOps(ops);
563
564            // No commits if we're stopped
565            synchronized (mService.getSynchronizer()) {
566                if (mService.isStopped()) return;
567                try {
568                    mContentResolver.applyBatch(EmailProvider.EMAIL_AUTHORITY, ops);
569                    userLog(mMailbox.mDisplayName, " SyncKey saved as: ", mMailbox.mSyncKey);
570                } catch (RemoteException e) {
571                    // There is nothing to be done here; fail by returning null
572                } catch (OperationApplicationException e) {
573                    // There is nothing to be done here; fail by returning null
574                }
575            }
576
577            if (notifyCount > 0) {
578                // Use the new atomic add URI in EmailProvider
579                // We could add this to the operations being done, but it's not strictly
580                // speaking necessary, as the previous batch preserves the integrity of the
581                // database, whereas this is purely for notification purposes, and is itself atomic
582                ContentValues cv = new ContentValues();
583                cv.put(EmailContent.FIELD_COLUMN_NAME, AccountColumns.NEW_MESSAGE_COUNT);
584                cv.put(EmailContent.ADD_COLUMN_NAME, notifyCount);
585                Uri uri = ContentUris.withAppendedId(Account.ADD_TO_FIELD_URI, mAccount.mId);
586                mContentResolver.update(uri, cv, null, null);
587                MailService.actionNotifyNewMessages(mContext, mAccount.mId);
588            }
589        }
590    }
591
592    @Override
593    public String getCollectionName() {
594        return "Email";
595    }
596
597    private void addCleanupOps(ArrayList<ContentProviderOperation> ops) {
598        // If we've sent local deletions, clear out the deleted table
599        for (Long id: mDeletedIdList) {
600            ops.add(ContentProviderOperation.newDelete(
601                    ContentUris.withAppendedId(Message.DELETED_CONTENT_URI, id)).build());
602        }
603        // And same with the updates
604        for (Long id: mUpdatedIdList) {
605            ops.add(ContentProviderOperation.newDelete(
606                    ContentUris.withAppendedId(Message.UPDATED_CONTENT_URI, id)).build());
607        }
608    }
609
610    @Override
611    public void cleanup() {
612        if (!mDeletedIdList.isEmpty() || !mUpdatedIdList.isEmpty()) {
613            ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
614            addCleanupOps(ops);
615            try {
616                mContext.getContentResolver()
617                    .applyBatch(EmailProvider.EMAIL_AUTHORITY, ops);
618            } catch (RemoteException e) {
619                // There is nothing to be done here; fail by returning null
620            } catch (OperationApplicationException e) {
621                // There is nothing to be done here; fail by returning null
622            }
623        }
624    }
625
626    private String formatTwo(int num) {
627        if (num < 10) {
628            return "0" + (char)('0' + num);
629        } else
630            return Integer.toString(num);
631    }
632
633    /**
634     * Create date/time in RFC8601 format.  Oddly enough, for calendar date/time, Microsoft uses
635     * a different format that excludes the punctuation (this is why I'm not putting this in a
636     * parent class)
637     */
638    public String formatDateTime(Calendar calendar) {
639        StringBuilder sb = new StringBuilder();
640        //YYYY-MM-DDTHH:MM:SS.MSSZ
641        sb.append(calendar.get(Calendar.YEAR));
642        sb.append('-');
643        sb.append(formatTwo(calendar.get(Calendar.MONTH) + 1));
644        sb.append('-');
645        sb.append(formatTwo(calendar.get(Calendar.DAY_OF_MONTH)));
646        sb.append('T');
647        sb.append(formatTwo(calendar.get(Calendar.HOUR_OF_DAY)));
648        sb.append(':');
649        sb.append(formatTwo(calendar.get(Calendar.MINUTE)));
650        sb.append(':');
651        sb.append(formatTwo(calendar.get(Calendar.SECOND)));
652        sb.append(".000Z");
653        return sb.toString();
654    }
655
656    /**
657     * Note that messages in the deleted database preserve the message's unique id; therefore, we
658     * can utilize this id to find references to the message.  The only reference situation at this
659     * point is in the Body table; it is when sending messages via SmartForward and SmartReply
660     */
661    private boolean messageReferenced(ContentResolver cr, long id) {
662        mBindArgument[0] = Long.toString(id);
663        // See if this id is referenced in a body
664        Cursor c = cr.query(Body.CONTENT_URI, Body.ID_PROJECTION, WHERE_BODY_SOURCE_MESSAGE_KEY,
665                mBindArgument, null);
666        try {
667            return c.moveToFirst();
668        } finally {
669            c.close();
670        }
671    }
672
673    /*private*/ /**
674     * Serialize commands to delete items from the server; as we find items to delete, add their
675     * id's to the deletedId's array
676     *
677     * @param s the Serializer we're using to create post data
678     * @param deletedIds ids whose deletions are being sent to the server
679     * @param first whether or not this is the first command being sent
680     * @return true if SYNC_COMMANDS hasn't been sent (false otherwise)
681     * @throws IOException
682     */
683    boolean sendDeletedItems(Serializer s, ArrayList<Long> deletedIds, boolean first)
684            throws IOException {
685        ContentResolver cr = mContext.getContentResolver();
686
687        // Find any of our deleted items
688        Cursor c = cr.query(Message.DELETED_CONTENT_URI, Message.LIST_PROJECTION,
689                MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null);
690        // We keep track of the list of deleted item id's so that we can remove them from the
691        // deleted table after the server receives our command
692        deletedIds.clear();
693        try {
694            while (c.moveToNext()) {
695                String serverId = c.getString(Message.LIST_SERVER_ID_COLUMN);
696                // Keep going if there's no serverId
697                if (serverId == null) {
698                    continue;
699                // Also check if this message is referenced elsewhere
700                } else if (messageReferenced(cr, c.getLong(Message.CONTENT_ID_COLUMN))) {
701                    userLog("Postponing deletion of referenced message: ", serverId);
702                    continue;
703                } else if (first) {
704                    s.start(Tags.SYNC_COMMANDS);
705                    first = false;
706                }
707                // Send the command to delete this message
708                s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end();
709                deletedIds.add(c.getLong(Message.LIST_ID_COLUMN));
710            }
711        } finally {
712            c.close();
713        }
714
715       return first;
716    }
717
718    @Override
719    public boolean sendLocalChanges(Serializer s) throws IOException {
720        ContentResolver cr = mContext.getContentResolver();
721
722        // Never upsync from these folders
723        if (mMailbox.mType == Mailbox.TYPE_DRAFTS || mMailbox.mType == Mailbox.TYPE_OUTBOX) {
724            return false;
725        }
726
727        // This code is split out for unit testing purposes
728        boolean firstCommand = sendDeletedItems(s, mDeletedIdList, true);
729
730        // Find our trash mailbox, since deletions will have been moved there...
731        long trashMailboxId =
732            Mailbox.findMailboxOfType(mContext, mMailbox.mAccountKey, Mailbox.TYPE_TRASH);
733
734        // Do the same now for updated items
735        Cursor c = cr.query(Message.UPDATED_CONTENT_URI, Message.LIST_PROJECTION,
736                MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null);
737
738        // We keep track of the list of updated item id's as we did above with deleted items
739        mUpdatedIdList.clear();
740        try {
741            while (c.moveToNext()) {
742                long id = c.getLong(Message.LIST_ID_COLUMN);
743                // Say we've handled this update
744                mUpdatedIdList.add(id);
745                // We have the id of the changed item.  But first, we have to find out its current
746                // state, since the updated table saves the opriginal state
747                Cursor currentCursor = cr.query(ContentUris.withAppendedId(Message.CONTENT_URI, id),
748                        UPDATES_PROJECTION, null, null, null);
749                try {
750                    // If this item no longer exists (shouldn't be possible), just move along
751                    if (!currentCursor.moveToFirst()) {
752                         continue;
753                    }
754                    // Keep going if there's no serverId
755                    String serverId = currentCursor.getString(UPDATES_SERVER_ID_COLUMN);
756                    if (serverId == null) {
757                        continue;
758                    }
759                    // If the message is now in the trash folder, it has been deleted by the user
760                    if (currentCursor.getLong(UPDATES_MAILBOX_KEY_COLUMN) == trashMailboxId) {
761                         if (firstCommand) {
762                            s.start(Tags.SYNC_COMMANDS);
763                            firstCommand = false;
764                        }
765                        // Send the command to delete this message
766                        s.start(Tags.SYNC_DELETE).data(Tags.SYNC_SERVER_ID, serverId).end();
767                        continue;
768                    }
769
770                    boolean flagChange = false;
771                    boolean readChange = false;
772
773                    int flag = 0;
774
775                    // We can only send flag changes to the server in 12.0 or later
776                    if (mService.mProtocolVersionDouble >= Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) {
777                        flag = currentCursor.getInt(UPDATES_FLAG_COLUMN);
778                        if (flag != c.getInt(Message.LIST_FAVORITE_COLUMN)) {
779                            flagChange = true;
780                        }
781                    }
782
783                    int read = currentCursor.getInt(UPDATES_READ_COLUMN);
784                    if (read != c.getInt(Message.LIST_READ_COLUMN)) {
785                        readChange = true;
786                    }
787
788                    if (!flagChange && !readChange) {
789                        // In this case, we've got nothing to send to the server
790                        continue;
791                    }
792
793                    if (firstCommand) {
794                        s.start(Tags.SYNC_COMMANDS);
795                        firstCommand = false;
796                    }
797                    // Send the change to "read" and "favorite" (flagged)
798                    s.start(Tags.SYNC_CHANGE)
799                        .data(Tags.SYNC_SERVER_ID, c.getString(Message.LIST_SERVER_ID_COLUMN))
800                        .start(Tags.SYNC_APPLICATION_DATA);
801                    if (readChange) {
802                        s.data(Tags.EMAIL_READ, Integer.toString(read));
803                    }
804                    // "Flag" is a relatively complex concept in EAS 12.0 and above.  It is not only
805                    // the boolean "favorite" that we think of in Gmail, but it also represents a
806                    // follow up action, which can include a subject, start and due dates, and even
807                    // recurrences.  We don't support any of this as yet, but EAS 12.0 and higher
808                    // require that a flag contain a status, a type, and four date fields, two each
809                    // for start date and end (due) date.
810                    if (flagChange) {
811                        if (flag != 0) {
812                            // Status 2 = set flag
813                            s.start(Tags.EMAIL_FLAG).data(Tags.EMAIL_FLAG_STATUS, "2");
814                            // "FollowUp" is the standard type
815                            s.data(Tags.EMAIL_FLAG_TYPE, "FollowUp");
816                            long now = System.currentTimeMillis();
817                            Calendar calendar =
818                                GregorianCalendar.getInstance(TimeZone.getTimeZone("GMT"));
819                            calendar.setTimeInMillis(now);
820                            // Flags are required to have a start date and end date (duplicated)
821                            // First, we'll set the current date/time in GMT as the start time
822                            String utc = formatDateTime(calendar);
823                            s.data(Tags.TASK_START_DATE, utc).data(Tags.TASK_UTC_START_DATE, utc);
824                            // And then we'll use one week from today for completion date
825                            calendar.setTimeInMillis(now + 1*WEEKS);
826                            utc = formatDateTime(calendar);
827                            s.data(Tags.TASK_DUE_DATE, utc).data(Tags.TASK_UTC_DUE_DATE, utc);
828                            s.end();
829                        } else {
830                            s.tag(Tags.EMAIL_FLAG);
831                        }
832                    }
833                    s.end().end(); // SYNC_APPLICATION_DATA, SYNC_CHANGE
834                } finally {
835                    currentCursor.close();
836                }
837            }
838        } finally {
839            c.close();
840        }
841
842        if (!firstCommand) {
843            s.end(); // SYNC_COMMANDS
844        }
845        return false;
846    }
847}
848