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