/* * Copyright (C) 2008-2009 Marc Blank * Licensed to The Android Open Source Project. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.exchange.adapter; import com.android.email.mail.Address; import com.android.email.provider.EmailContent; import com.android.email.provider.EmailProvider; import com.android.email.provider.EmailContent.Account; import com.android.email.provider.EmailContent.AccountColumns; import com.android.email.provider.EmailContent.Attachment; import com.android.email.provider.EmailContent.Mailbox; import com.android.email.provider.EmailContent.Message; import com.android.email.provider.EmailContent.MessageColumns; import com.android.email.provider.EmailContent.SyncColumns; import com.android.email.service.MailService; import com.android.exchange.Eas; import com.android.exchange.EasSyncService; import android.content.ContentProviderOperation; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.OperationApplicationException; import android.database.Cursor; import android.net.Uri; import android.os.RemoteException; import android.webkit.MimeTypeMap; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Calendar; import java.util.GregorianCalendar; import java.util.TimeZone; /** * Sync adapter for EAS email * */ public class EmailSyncAdapter extends AbstractSyncAdapter { private static final int UPDATES_READ_COLUMN = 0; private static final int UPDATES_MAILBOX_KEY_COLUMN = 1; private static final int UPDATES_SERVER_ID_COLUMN = 2; private static final int UPDATES_FLAG_COLUMN = 3; private static final String[] UPDATES_PROJECTION = {MessageColumns.FLAG_READ, MessageColumns.MAILBOX_KEY, SyncColumns.SERVER_ID, MessageColumns.FLAG_FAVORITE}; String[] bindArguments = new String[2]; ArrayList mDeletedIdList = new ArrayList(); ArrayList mUpdatedIdList = new ArrayList(); public EmailSyncAdapter(Mailbox mailbox, EasSyncService service) { super(mailbox, service); } @Override public boolean parse(InputStream is, EasSyncService service) throws IOException { EasEmailSyncParser p = new EasEmailSyncParser(is, service); return p.parse(); } public class EasEmailSyncParser extends AbstractSyncParser { private static final String WHERE_SERVER_ID_AND_MAILBOX_KEY = SyncColumns.SERVER_ID + "=? and " + MessageColumns.MAILBOX_KEY + "=?"; private String mMailboxIdAsString; public EasEmailSyncParser(InputStream in, EasSyncService service) throws IOException { super(in, service); mMailboxIdAsString = Long.toString(mMailbox.mId); } @Override public void wipe() { mContentResolver.delete(Message.CONTENT_URI, Message.MAILBOX_KEY + "=" + mMailbox.mId, null); mContentResolver.delete(Message.DELETED_CONTENT_URI, Message.MAILBOX_KEY + "=" + mMailbox.mId, null); mContentResolver.delete(Message.UPDATED_CONTENT_URI, Message.MAILBOX_KEY + "=" + mMailbox.mId, null); } public void addData (Message msg) throws IOException { ArrayList atts = new ArrayList(); while (nextTag(Tags.SYNC_APPLICATION_DATA) != END) { switch (tag) { case Tags.EMAIL_ATTACHMENTS: case Tags.BASE_ATTACHMENTS: // BASE_ATTACHMENTS is used in EAS 12.0 and up attachmentsParser(atts, msg); break; case Tags.EMAIL_TO: msg.mTo = Address.pack(Address.parse(getValue())); break; case Tags.EMAIL_FROM: String from = getValue(); String sender = from; int q = from.indexOf('\"'); if (q >= 0) { int qq = from.indexOf('\"', q + 1); if (qq > 0) { sender = from.substring(q + 1, qq); } } msg.mDisplayName = sender; msg.mFrom = Address.pack(Address.parse(from)); break; case Tags.EMAIL_CC: msg.mCc = Address.pack(Address.parse(getValue())); break; case Tags.EMAIL_REPLY_TO: msg.mReplyTo = Address.pack(Address.parse(getValue())); break; case Tags.EMAIL_DATE_RECEIVED: String date = getValue(); // 2009-02-11T18:03:03.627Z GregorianCalendar cal = new GregorianCalendar(); cal.set(Integer.parseInt(date.substring(0, 4)), Integer.parseInt(date .substring(5, 7)) - 1, Integer.parseInt(date.substring(8, 10)), Integer.parseInt(date.substring(11, 13)), Integer.parseInt(date .substring(14, 16)), Integer.parseInt(date .substring(17, 19))); cal.setTimeZone(TimeZone.getTimeZone("GMT")); msg.mTimeStamp = cal.getTimeInMillis(); break; case Tags.EMAIL_SUBJECT: msg.mSubject = getValue(); break; case Tags.EMAIL_READ: msg.mFlagRead = getValueInt() == 1; break; case Tags.BASE_BODY: bodyParser(msg); break; case Tags.EMAIL_FLAG: msg.mFlagFavorite = flagParser(); break; case Tags.EMAIL_BODY: String text = getValue(); msg.mText = text; msg.mTextInfo = "X;X;8;" + text.length(); // location;encoding;charset;size break; default: skipTag(); } } if (atts.size() > 0) { msg.mAttachments = atts; } } private void addParser(ArrayList emails) throws IOException { Message msg = new Message(); msg.mAccountKey = mAccount.mId; msg.mMailboxKey = mMailbox.mId; msg.mFlagLoaded = Message.LOADED; while (nextTag(Tags.SYNC_ADD) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: msg.mServerId = getValue(); break; case Tags.SYNC_APPLICATION_DATA: addData(msg); break; default: skipTag(); } } // Tell the provider that this is synced back msg.mServerVersion = mMailbox.mSyncKey; emails.add(msg); } // For now, we only care about the "active" state private Boolean flagParser() throws IOException { Boolean state = false; while (nextTag(Tags.EMAIL_FLAG) != END) { switch (tag) { case Tags.EMAIL_FLAG_STATUS: state = true; break; default: skipTag(); } } return state; } private void bodyParser(Message msg) throws IOException { String bodyType = Eas.BODY_PREFERENCE_TEXT; String body = ""; while (nextTag(Tags.EMAIL_BODY) != END) { switch (tag) { case Tags.BASE_TYPE: bodyType = getValue(); break; case Tags.BASE_DATA: body = getValue(); break; default: skipTag(); } } // We always ask for TEXT or HTML; there's no third option String info = "X;X;8;" + body.length(); if (bodyType.equals(Eas.BODY_PREFERENCE_HTML)) { msg.mHtmlInfo = info; msg.mHtml = body; } else { msg.mTextInfo = info; msg.mText = body; } } private void attachmentsParser(ArrayList atts, Message msg) throws IOException { while (nextTag(Tags.EMAIL_ATTACHMENTS) != END) { switch (tag) { case Tags.EMAIL_ATTACHMENT: case Tags.BASE_ATTACHMENT: // BASE_ATTACHMENT is used in EAS 12.0 and up attachmentParser(atts, msg); break; default: skipTag(); } } } private void attachmentParser(ArrayList atts, Message msg) throws IOException { String fileName = null; String length = null; String location = null; while (nextTag(Tags.EMAIL_ATTACHMENT) != END) { switch (tag) { // We handle both EAS 2.5 and 12.0+ attachments here case Tags.EMAIL_DISPLAY_NAME: case Tags.BASE_DISPLAY_NAME: fileName = getValue(); break; case Tags.EMAIL_ATT_NAME: case Tags.BASE_FILE_REFERENCE: location = getValue(); break; case Tags.EMAIL_ATT_SIZE: case Tags.BASE_ESTIMATED_DATA_SIZE: length = getValue(); break; default: skipTag(); } } if ((fileName != null) && (length != null) && (location != null)) { Attachment att = new Attachment(); att.mEncoding = "base64"; att.mSize = Long.parseLong(length); att.mFileName = fileName; att.mLocation = location; att.mMimeType = getMimeTypeFromFileName(fileName); atts.add(att); msg.mFlagAttachment = true; } } /** * Try to determine a mime type from a file name, defaulting to application/x, where x * is either the extension or (if none) octet-stream * At the moment, this is somewhat lame, since many file types aren't recognized * @param fileName the file name to ponder * @return */ // Note: The MimeTypeMap method currently uses a very limited set of mime types // A bug has been filed against this issue. public String getMimeTypeFromFileName(String fileName) { String mimeType; int lastDot = fileName.lastIndexOf('.'); String extension = null; if ((lastDot > 0) && (lastDot < fileName.length() - 1)) { extension = fileName.substring(lastDot + 1); } if (extension == null) { // A reasonable default for now. mimeType = "application/octet-stream"; } else { mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); if (mimeType == null) { mimeType = "application/" + extension; } } return mimeType; } private Cursor getServerIdCursor(String serverId, String[] projection) { bindArguments[0] = serverId; bindArguments[1] = mMailboxIdAsString; return mContentResolver.query(Message.CONTENT_URI, projection, WHERE_SERVER_ID_AND_MAILBOX_KEY, bindArguments, null); } private void deleteParser(ArrayList deletes) throws IOException { while (nextTag(Tags.SYNC_DELETE) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: String serverId = getValue(); // Find the message in this mailbox with the given serverId Cursor c = getServerIdCursor(serverId, Message.ID_COLUMN_PROJECTION); try { if (c.moveToFirst()) { userLog("Deleting ", serverId); deletes.add(c.getLong(Message.ID_COLUMNS_ID_COLUMN)); } } finally { c.close(); } break; default: skipTag(); } } } class ServerChange { long id; Boolean read; Boolean flag; ServerChange(long _id, Boolean _read, Boolean _flag) { id = _id; read = _read; flag = _flag; } } private void changeParser(ArrayList changes) throws IOException { String serverId = null; Boolean oldRead = false; Boolean read = null; Boolean oldFlag = false; Boolean flag = null; long id = 0; while (nextTag(Tags.SYNC_CHANGE) != END) { switch (tag) { case Tags.SYNC_SERVER_ID: serverId = getValue(); Cursor c = getServerIdCursor(serverId, Message.LIST_PROJECTION); try { if (c.moveToFirst()) { userLog("Changing ", serverId); oldRead = c.getInt(Message.LIST_READ_COLUMN) == Message.READ; oldFlag = c.getInt(Message.LIST_FAVORITE_COLUMN) == 1; id = c.getLong(Message.LIST_ID_COLUMN); } } finally { c.close(); } break; case Tags.EMAIL_READ: read = getValueInt() == 1; break; case Tags.EMAIL_FLAG: flag = flagParser(); break; case Tags.SYNC_APPLICATION_DATA: break; default: skipTag(); } } if (((read != null) && !oldRead.equals(read)) || ((flag != null) && !oldFlag.equals(flag))) { changes.add(new ServerChange(id, read, flag)); } } /* (non-Javadoc) * @see com.android.exchange.adapter.EasContentParser#commandsParser() */ @Override public void commandsParser() throws IOException { ArrayList newEmails = new ArrayList(); ArrayList deletedEmails = new ArrayList(); ArrayList changedEmails = new ArrayList(); int notifyCount = 0; while (nextTag(Tags.SYNC_COMMANDS) != END) { if (tag == Tags.SYNC_ADD) { addParser(newEmails); incrementChangeCount(); } else if (tag == Tags.SYNC_DELETE) { deleteParser(deletedEmails); incrementChangeCount(); } else if (tag == Tags.SYNC_CHANGE) { changeParser(changedEmails); incrementChangeCount(); } else skipTag(); } // Use a batch operation to handle the changes // TODO New mail notifications? Who looks for these? ArrayList ops = new ArrayList(); for (Message msg: newEmails) { if (!msg.mFlagRead) { notifyCount++; } msg.addSaveOps(ops); } for (Long id : deletedEmails) { ops.add(ContentProviderOperation.newDelete( ContentUris.withAppendedId(Message.CONTENT_URI, id)).build()); } if (!changedEmails.isEmpty()) { // Server wins in a conflict... for (ServerChange change : changedEmails) { ContentValues cv = new ContentValues(); if (change.read != null) { cv.put(MessageColumns.FLAG_READ, change.read); } if (change.flag != null) { cv.put(MessageColumns.FLAG_FAVORITE, change.flag); } ops.add(ContentProviderOperation.newUpdate( ContentUris.withAppendedId(Message.CONTENT_URI, change.id)) .withValues(cv) .build()); } } ops.add(ContentProviderOperation.newUpdate( ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailbox.mId)).withValues( mMailbox.toContentValues()).build()); addCleanupOps(ops); // No commits if we're stopped synchronized (mService.getSynchronizer()) { if (mService.isStopped()) return; try { mContentResolver.applyBatch(EmailProvider.EMAIL_AUTHORITY, ops); userLog(mMailbox.mDisplayName, " SyncKey saved as: ", mMailbox.mSyncKey); } catch (RemoteException e) { // There is nothing to be done here; fail by returning null } catch (OperationApplicationException e) { // There is nothing to be done here; fail by returning null } } if (notifyCount > 0) { // Use the new atomic add URI in EmailProvider // We could add this to the operations being done, but it's not strictly // speaking necessary, as the previous batch preserves the integrity of the // database, whereas this is purely for notification purposes, and is itself atomic ContentValues cv = new ContentValues(); cv.put(EmailContent.FIELD_COLUMN_NAME, AccountColumns.NEW_MESSAGE_COUNT); cv.put(EmailContent.ADD_COLUMN_NAME, notifyCount); Uri uri = ContentUris.withAppendedId(Account.ADD_TO_FIELD_URI, mAccount.mId); mContentResolver.update(uri, cv, null, null); MailService.actionNotifyNewMessages(mContext, mAccount.mId); } } } @Override public String getCollectionName() { return "Email"; } private void addCleanupOps(ArrayList ops) { // If we've sent local deletions, clear out the deleted table for (Long id: mDeletedIdList) { ops.add(ContentProviderOperation.newDelete( ContentUris.withAppendedId(Message.DELETED_CONTENT_URI, id)).build()); } // And same with the updates for (Long id: mUpdatedIdList) { ops.add(ContentProviderOperation.newDelete( ContentUris.withAppendedId(Message.UPDATED_CONTENT_URI, id)).build()); } } @Override public void cleanup(EasSyncService service) { if (!mDeletedIdList.isEmpty() || !mUpdatedIdList.isEmpty()) { ArrayList ops = new ArrayList(); addCleanupOps(ops); try { service.mContext.getContentResolver() .applyBatch(EmailProvider.EMAIL_AUTHORITY, ops); } catch (RemoteException e) { // There is nothing to be done here; fail by returning null } catch (OperationApplicationException e) { // There is nothing to be done here; fail by returning null } } } private String formatTwo(int num) { if (num < 10) { return "0" + (char)('0' + num); } else return Integer.toString(num); } /** * Create date/time in RFC8601 format. Oddly enough, for calendar date/time, Microsoft uses * a different format that excludes the punctuation (this is why I'm not putting this in a * parent class) */ public String formatDateTime(Calendar calendar) { StringBuilder sb = new StringBuilder(); //YYYY-MM-DDTHH:MM:SS.MSSZ sb.append(calendar.get(Calendar.YEAR)); sb.append('-'); sb.append(formatTwo(calendar.get(Calendar.MONTH) + 1)); sb.append('-'); sb.append(formatTwo(calendar.get(Calendar.DAY_OF_MONTH))); sb.append('T'); sb.append(formatTwo(calendar.get(Calendar.HOUR_OF_DAY))); sb.append(':'); sb.append(formatTwo(calendar.get(Calendar.MINUTE))); sb.append(':'); sb.append(formatTwo(calendar.get(Calendar.SECOND))); sb.append(".000Z"); return sb.toString(); } @Override public boolean sendLocalChanges(Serializer s, EasSyncService service) throws IOException { ContentResolver cr = mContext.getContentResolver(); // Find any of our deleted items Cursor c = cr.query(Message.DELETED_CONTENT_URI, Message.LIST_PROJECTION, MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null); boolean first = true; // We keep track of the list of deleted item id's so that we can remove them from the // deleted table after the server receives our command mDeletedIdList.clear(); try { while (c.moveToNext()) { if (first) { s.start(Tags.SYNC_COMMANDS); first = false; } // Send the command to delete this message s.start(Tags.SYNC_DELETE) .data(Tags.SYNC_SERVER_ID, c.getString(Message.LIST_SERVER_ID_COLUMN)) .end(); // SYNC_DELETE mDeletedIdList.add(c.getLong(Message.LIST_ID_COLUMN)); } } finally { c.close(); } // Find our trash mailbox, since deletions will have been moved there... long trashMailboxId = Mailbox.findMailboxOfType(mContext, mMailbox.mAccountKey, Mailbox.TYPE_TRASH); // Do the same now for updated items c = cr.query(Message.UPDATED_CONTENT_URI, Message.LIST_PROJECTION, MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null); // We keep track of the list of updated item id's as we did above with deleted items mUpdatedIdList.clear(); try { while (c.moveToNext()) { long id = c.getLong(Message.LIST_ID_COLUMN); // Say we've handled this update mUpdatedIdList.add(id); // We have the id of the changed item. But first, we have to find out its current // state, since the updated table saves the opriginal state Cursor currentCursor = cr.query(ContentUris.withAppendedId(Message.CONTENT_URI, id), UPDATES_PROJECTION, null, null, null); try { // If this item no longer exists (shouldn't be possible), just move along if (!currentCursor.moveToFirst()) { continue; } // If the message is now in the trash folder, it has been deleted by the user if (currentCursor.getLong(UPDATES_MAILBOX_KEY_COLUMN) == trashMailboxId) { if (first) { s.start(Tags.SYNC_COMMANDS); first = false; } // Send the command to delete this message s.start(Tags.SYNC_DELETE) .data(Tags.SYNC_SERVER_ID, currentCursor.getString(UPDATES_SERVER_ID_COLUMN)) .end(); // SYNC_DELETE continue; } boolean flagChange = false; boolean readChange = false; int flag = 0; // We can only send flag changes to the server in 12.0 or later if (mService.mProtocolVersionDouble >= 12.0) { flag = currentCursor.getInt(UPDATES_FLAG_COLUMN); if (flag != c.getInt(Message.LIST_FAVORITE_COLUMN)) { flagChange = true; } } int read = currentCursor.getInt(UPDATES_READ_COLUMN); if (read == c.getInt(Message.LIST_READ_COLUMN)) { readChange = true; } if (!flagChange && !readChange) { // In this case, we've got nothing to send to the server continue; } if (first) { s.start(Tags.SYNC_COMMANDS); first = false; } // Send the change to "read" and "favorite" (flagged) s.start(Tags.SYNC_CHANGE) .data(Tags.SYNC_SERVER_ID, c.getString(Message.LIST_SERVER_ID_COLUMN)) .start(Tags.SYNC_APPLICATION_DATA); if (readChange) { s.data(Tags.EMAIL_READ, Integer.toString(read)); } // "Flag" is a relatively complex concept in EAS 12.0 and above. It is not only // the boolean "favorite" that we think of in Gmail, but it also represents a // follow up action, which can include a subject, start and due dates, and even // recurrences. We don't support any of this as yet, but EAS 12.0 and higher // require that a flag contain a status, a type, and four date fields, two each // for start date and end (due) date. if (flagChange) { if (flag != 0) { // Status 2 = set flag s.start(Tags.EMAIL_FLAG).data(Tags.EMAIL_FLAG_STATUS, "2"); // "FollowUp" is the standard type s.data(Tags.EMAIL_FLAG_TYPE, "FollowUp"); long now = System.currentTimeMillis(); Calendar calendar = GregorianCalendar.getInstance(TimeZone.getTimeZone("GMT")); calendar.setTimeInMillis(now); // Flags are required to have a start date and end date (duplicated) // First, we'll set the current date/time in GMT as the start time String utc = formatDateTime(calendar); s.data(Tags.TASK_START_DATE, utc).data(Tags.TASK_UTC_START_DATE, utc); // And then we'll use one week from today for completion date calendar.setTimeInMillis(now + 1*WEEKS); utc = formatDateTime(calendar); s.data(Tags.TASK_DUE_DATE, utc).data(Tags.TASK_UTC_DUE_DATE, utc); s.end(); } else { s.tag(Tags.EMAIL_FLAG); } } s.end().end(); // SYNC_APPLICATION_DATA, SYNC_CHANGE } finally { currentCursor.close(); } } } finally { c.close(); } if (!first) { s.end(); // SYNC_COMMANDS } return false; } }