/* * Copyright (C) 2008 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.email; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.TrafficStats; import android.net.Uri; import android.os.Process; import android.text.TextUtils; import android.util.Log; import com.android.email.mail.Sender; import com.android.email.mail.Store; import com.android.emailcommon.Logging; import com.android.emailcommon.TrafficFlags; import com.android.emailcommon.internet.MimeBodyPart; import com.android.emailcommon.internet.MimeHeader; import com.android.emailcommon.internet.MimeMultipart; import com.android.emailcommon.internet.MimeUtility; import com.android.emailcommon.mail.AuthenticationFailedException; import com.android.emailcommon.mail.FetchProfile; import com.android.emailcommon.mail.Flag; import com.android.emailcommon.mail.Folder; import com.android.emailcommon.mail.Folder.FolderType; import com.android.emailcommon.mail.Folder.MessageRetrievalListener; import com.android.emailcommon.mail.Folder.MessageUpdateCallbacks; import com.android.emailcommon.mail.Folder.OpenMode; import com.android.emailcommon.mail.Message; import com.android.emailcommon.mail.MessagingException; import com.android.emailcommon.mail.Part; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.EmailContent; import com.android.emailcommon.provider.EmailContent.Attachment; import com.android.emailcommon.provider.EmailContent.AttachmentColumns; import com.android.emailcommon.provider.EmailContent.MailboxColumns; import com.android.emailcommon.provider.EmailContent.MessageColumns; import com.android.emailcommon.provider.EmailContent.SyncColumns; import com.android.emailcommon.provider.Mailbox; import com.android.emailcommon.service.SearchParams; import com.android.emailcommon.utility.AttachmentUtilities; import com.android.emailcommon.utility.ConversionUtilities; import com.android.emailcommon.utility.Utility; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; /** * Starts a long running (application) Thread that will run through commands * that require remote mailbox access. This class is used to serialize and * prioritize these commands. Each method that will submit a command requires a * MessagingListener instance to be provided. It is expected that that listener * has also been added as a registered listener using addListener(). When a * command is to be executed, if the listener that was provided with the command * is no longer registered the command is skipped. The design idea for the above * is that when an Activity starts it registers as a listener. When it is paused * it removes itself. Thus, any commands that that activity submitted are * removed from the queue once the activity is no longer active. */ public class MessagingController implements Runnable { /** * The maximum message size that we'll consider to be "small". A small message is downloaded * in full immediately instead of in pieces. Anything over this size will be downloaded in * pieces with attachments being left off completely and downloaded on demand. * * * 25k for a "small" message was picked by educated trial and error. * http://answers.google.com/answers/threadview?id=312463 claims that the * average size of an email is 59k, which I feel is too large for our * blind download. The following tests were performed on a download of * 25 random messages. * <pre> * 5k - 61 seconds, * 25k - 51 seconds, * 55k - 53 seconds, * </pre> * So 25k gives good performance and a reasonable data footprint. Sounds good to me. */ private static final int MAX_SMALL_MESSAGE_SIZE = (25 * 1024); private static final Flag[] FLAG_LIST_SEEN = new Flag[] { Flag.SEEN }; private static final Flag[] FLAG_LIST_FLAGGED = new Flag[] { Flag.FLAGGED }; private static final Flag[] FLAG_LIST_ANSWERED = new Flag[] { Flag.ANSWERED }; /** * We write this into the serverId field of messages that will never be upsynced. */ private static final String LOCAL_SERVERID_PREFIX = "Local-"; /** * Cache search results by account; this allows for "load more" support without having to * redo the search (which can be quite slow). SortableMessage is a smallish class, so memory * shouldn't be an issue */ private static final HashMap<Long, SortableMessage[]> sSearchResults = new HashMap<Long, SortableMessage[]>(); private static final ContentValues PRUNE_ATTACHMENT_CV = new ContentValues(); static { PRUNE_ATTACHMENT_CV.putNull(AttachmentColumns.CONTENT_URI); } private static MessagingController sInstance = null; private final BlockingQueue<Command> mCommands = new LinkedBlockingQueue<Command>(); private final Thread mThread; /** * All access to mListeners *must* be synchronized */ private final GroupMessagingListener mListeners = new GroupMessagingListener(); private boolean mBusy; private final Context mContext; private final Controller mController; /** * Simple cache for last search result mailbox by account and serverId, since the most common * case will be repeated use of the same mailbox */ private long mLastSearchAccountKey = Account.NO_ACCOUNT; private String mLastSearchServerId = null; private Mailbox mLastSearchRemoteMailbox = null; protected MessagingController(Context _context, Controller _controller) { mContext = _context.getApplicationContext(); mController = _controller; mThread = new Thread(this); mThread.start(); } /** * Gets or creates the singleton instance of MessagingController. Application is used to * provide a Context to classes that need it. */ public synchronized static MessagingController getInstance(Context _context, Controller _controller) { if (sInstance == null) { sInstance = new MessagingController(_context, _controller); } return sInstance; } /** * Inject a mock controller. Used only for testing. Affects future calls to getInstance(). */ public static void injectMockController(MessagingController mockController) { sInstance = mockController; } // TODO: seems that this reading of mBusy isn't thread-safe public boolean isBusy() { return mBusy; } public void run() { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); // TODO: add an end test to this infinite loop while (true) { Command command; try { command = mCommands.take(); } catch (InterruptedException e) { continue; //re-test the condition on the eclosing while } if (command.listener == null || isActiveListener(command.listener)) { mBusy = true; command.runnable.run(); mListeners.controllerCommandCompleted(mCommands.size() > 0); } mBusy = false; } } private void put(String description, MessagingListener listener, Runnable runnable) { try { Command command = new Command(); command.listener = listener; command.runnable = runnable; command.description = description; mCommands.add(command); } catch (IllegalStateException ie) { throw new Error(ie); } } public void addListener(MessagingListener listener) { mListeners.addListener(listener); } public void removeListener(MessagingListener listener) { mListeners.removeListener(listener); } private boolean isActiveListener(MessagingListener listener) { return mListeners.isActiveListener(listener); } private static final int MAILBOX_COLUMN_ID = 0; private static final int MAILBOX_COLUMN_SERVER_ID = 1; private static final int MAILBOX_COLUMN_TYPE = 2; /** Small projection for just the columns required for a sync. */ private static final String[] MAILBOX_PROJECTION = new String[] { MailboxColumns.ID, MailboxColumns.SERVER_ID, MailboxColumns.TYPE, }; /** * Synchronize the folder list with the remote server. Synchronization occurs in the * background and results are passed through the {@link MessagingListener}. If the * given listener is not {@code null}, it must have been previously added to the set * of listeners using the {@link #addListener(MessagingListener)}. Otherwise, no * actions will be performed. * * TODO this needs to cache the remote folder list * TODO break out an inner listFoldersSynchronized which could simplify checkMail * * @param accountId ID of the account for which to list the folders * @param listener A listener to notify */ void listFolders(final long accountId, MessagingListener listener) { final Account account = Account.restoreAccountWithId(mContext, accountId); if (account == null) { Log.i(Logging.LOG_TAG, "Could not load account id " + accountId + ". Has it been removed?"); return; } mListeners.listFoldersStarted(accountId); put("listFolders", listener, new Runnable() { // TODO For now, mailbox addition occurs in the server-dependent store implementation, // but, mailbox removal occurs here. Instead, each store should be responsible for // content synchronization (addition AND removal) since each store will likely need // to implement it's own, unique synchronization methodology. public void run() { TrafficStats.setThreadStatsTag(TrafficFlags.getSyncFlags(mContext, account)); Cursor localFolderCursor = null; try { // Step 1: Get remote mailboxes Store store = Store.getInstance(account, mContext); Folder[] remoteFolders = store.updateFolders(); HashSet<String> remoteFolderNames = new HashSet<String>(); for (int i = 0, count = remoteFolders.length; i < count; i++) { remoteFolderNames.add(remoteFolders[i].getName()); } // Step 2: Get local mailboxes localFolderCursor = mContext.getContentResolver().query( Mailbox.CONTENT_URI, MAILBOX_PROJECTION, EmailContent.MailboxColumns.ACCOUNT_KEY + "=?", new String[] { String.valueOf(account.mId) }, null); // Step 3: Remove any local mailbox not on the remote list while (localFolderCursor.moveToNext()) { String mailboxPath = localFolderCursor.getString(MAILBOX_COLUMN_SERVER_ID); // Short circuit if we have a remote mailbox with the same name if (remoteFolderNames.contains(mailboxPath)) { continue; } int mailboxType = localFolderCursor.getInt(MAILBOX_COLUMN_TYPE); long mailboxId = localFolderCursor.getLong(MAILBOX_COLUMN_ID); switch (mailboxType) { case Mailbox.TYPE_INBOX: case Mailbox.TYPE_DRAFTS: case Mailbox.TYPE_OUTBOX: case Mailbox.TYPE_SENT: case Mailbox.TYPE_TRASH: case Mailbox.TYPE_SEARCH: // Never, ever delete special mailboxes break; default: // Drop all attachment files related to this mailbox AttachmentUtilities.deleteAllMailboxAttachmentFiles( mContext, accountId, mailboxId); // Delete the mailbox; database triggers take care of related // Message, Body and Attachment records Uri uri = ContentUris.withAppendedId( Mailbox.CONTENT_URI, mailboxId); mContext.getContentResolver().delete(uri, null, null); break; } } mListeners.listFoldersFinished(accountId); } catch (Exception e) { mListeners.listFoldersFailed(accountId, e.toString()); } finally { if (localFolderCursor != null) { localFolderCursor.close(); } } } }); } /** * Start background synchronization of the specified folder. * @param account * @param folder * @param listener */ public void synchronizeMailbox(final Account account, final Mailbox folder, MessagingListener listener) { /* * We don't ever sync the Outbox. */ if (folder.mType == Mailbox.TYPE_OUTBOX) { return; } mListeners.synchronizeMailboxStarted(account.mId, folder.mId); put("synchronizeMailbox", listener, new Runnable() { public void run() { synchronizeMailboxSynchronous(account, folder); } }); } /** * Start foreground synchronization of the specified folder. This is called by * synchronizeMailbox or checkMail. * TODO this should use ID's instead of fully-restored objects * @param account * @param folder */ private void synchronizeMailboxSynchronous(final Account account, final Mailbox folder) { TrafficStats.setThreadStatsTag(TrafficFlags.getSyncFlags(mContext, account)); mListeners.synchronizeMailboxStarted(account.mId, folder.mId); if ((folder.mFlags & Mailbox.FLAG_HOLDS_MAIL) == 0) { // We don't hold messages, so, nothing to synchronize mListeners.synchronizeMailboxFinished(account.mId, folder.mId, 0, 0, null); return; } NotificationController nc = NotificationController.getInstance(mContext); try { processPendingActionsSynchronous(account); // Select generic sync or store-specific sync SyncResults results = synchronizeMailboxGeneric(account, folder); // The account might have been deleted if (results == null) return; mListeners.synchronizeMailboxFinished(account.mId, folder.mId, results.mTotalMessages, results.mAddedMessages.size(), results.mAddedMessages); // Clear authentication notification for this account nc.cancelLoginFailedNotification(account.mId); } catch (MessagingException e) { if (Logging.LOGD) { Log.v(Logging.LOG_TAG, "synchronizeMailbox", e); } if (e instanceof AuthenticationFailedException) { // Generate authentication notification nc.showLoginFailedNotification(account.mId); } mListeners.synchronizeMailboxFailed(account.mId, folder.mId, e); } } /** * Lightweight record for the first pass of message sync, where I'm just seeing if * the local message requires sync. Later (for messages that need syncing) we'll do a full * readout from the DB. */ private static class LocalMessageInfo { private static final int COLUMN_ID = 0; private static final int COLUMN_FLAG_READ = 1; private static final int COLUMN_FLAG_FAVORITE = 2; private static final int COLUMN_FLAG_LOADED = 3; private static final int COLUMN_SERVER_ID = 4; private static final int COLUMN_FLAGS = 7; private static final String[] PROJECTION = new String[] { EmailContent.RECORD_ID, MessageColumns.FLAG_READ, MessageColumns.FLAG_FAVORITE, MessageColumns.FLAG_LOADED, SyncColumns.SERVER_ID, MessageColumns.MAILBOX_KEY, MessageColumns.ACCOUNT_KEY, MessageColumns.FLAGS }; final long mId; final boolean mFlagRead; final boolean mFlagFavorite; final int mFlagLoaded; final String mServerId; final int mFlags; public LocalMessageInfo(Cursor c) { mId = c.getLong(COLUMN_ID); mFlagRead = c.getInt(COLUMN_FLAG_READ) != 0; mFlagFavorite = c.getInt(COLUMN_FLAG_FAVORITE) != 0; mFlagLoaded = c.getInt(COLUMN_FLAG_LOADED); mServerId = c.getString(COLUMN_SERVER_ID); mFlags = c.getInt(COLUMN_FLAGS); // Note: mailbox key and account key not needed - they are projected for the SELECT } } private void saveOrUpdate(EmailContent content, Context context) { if (content.isSaved()) { content.update(context, content.toContentValues()); } else { content.save(context); } } /** * Load the structure and body of messages not yet synced * @param account the account we're syncing * @param remoteFolder the (open) Folder we're working on * @param unsyncedMessages an array of Message's we've got headers for * @param toMailbox the destination mailbox we're syncing * @throws MessagingException */ void loadUnsyncedMessages(final Account account, Folder remoteFolder, ArrayList<Message> unsyncedMessages, final Mailbox toMailbox) throws MessagingException { // 1. Divide the unsynced messages into small & large (by size) // TODO doing this work here (synchronously) is problematic because it prevents the UI // from affecting the order (e.g. download a message because the user requested it.) Much // of this logic should move out to a different sync loop that attempts to update small // groups of messages at a time, as a background task. However, we can't just return // (yet) because POP messages don't have an envelope yet.... ArrayList<Message> largeMessages = new ArrayList<Message>(); ArrayList<Message> smallMessages = new ArrayList<Message>(); for (Message message : unsyncedMessages) { if (message.getSize() > (MAX_SMALL_MESSAGE_SIZE)) { largeMessages.add(message); } else { smallMessages.add(message); } } // 2. Download small messages // TODO Problems with this implementation. 1. For IMAP, where we get a real envelope, // this is going to be inefficient and duplicate work we've already done. 2. It's going // back to the DB for a local message that we already had (and discarded). // For small messages, we specify "body", which returns everything (incl. attachments) FetchProfile fp = new FetchProfile(); fp.add(FetchProfile.Item.BODY); remoteFolder.fetch(smallMessages.toArray(new Message[smallMessages.size()]), fp, new MessageRetrievalListener() { public void messageRetrieved(Message message) { // Store the updated message locally and mark it fully loaded copyOneMessageToProvider(message, account, toMailbox, EmailContent.Message.FLAG_LOADED_COMPLETE); } @Override public void loadAttachmentProgress(int progress) { } }); // 3. Download large messages. We ask the server to give us the message structure, // but not all of the attachments. fp.clear(); fp.add(FetchProfile.Item.STRUCTURE); remoteFolder.fetch(largeMessages.toArray(new Message[largeMessages.size()]), fp, null); for (Message message : largeMessages) { if (message.getBody() == null) { // POP doesn't support STRUCTURE mode, so we'll just do a partial download // (hopefully enough to see some/all of the body) and mark the message for // further download. fp.clear(); fp.add(FetchProfile.Item.BODY_SANE); // TODO a good optimization here would be to make sure that all Stores set // the proper size after this fetch and compare the before and after size. If // they equal we can mark this SYNCHRONIZED instead of PARTIALLY_SYNCHRONIZED remoteFolder.fetch(new Message[] { message }, fp, null); // Store the partially-loaded message and mark it partially loaded copyOneMessageToProvider(message, account, toMailbox, EmailContent.Message.FLAG_LOADED_PARTIAL); } else { // We have a structure to deal with, from which // we can pull down the parts we want to actually store. // Build a list of parts we are interested in. Text parts will be downloaded // right now, attachments will be left for later. ArrayList<Part> viewables = new ArrayList<Part>(); ArrayList<Part> attachments = new ArrayList<Part>(); MimeUtility.collectParts(message, viewables, attachments); // Download the viewables immediately for (Part part : viewables) { fp.clear(); fp.add(part); // TODO what happens if the network connection dies? We've got partial // messages with incorrect status stored. remoteFolder.fetch(new Message[] { message }, fp, null); } // Store the updated message locally and mark it fully loaded copyOneMessageToProvider(message, account, toMailbox, EmailContent.Message.FLAG_LOADED_COMPLETE); } } } public void downloadFlagAndEnvelope(final Account account, final Mailbox mailbox, Folder remoteFolder, ArrayList<Message> unsyncedMessages, HashMap<String, LocalMessageInfo> localMessageMap, final ArrayList<Long> unseenMessages) throws MessagingException { FetchProfile fp = new FetchProfile(); fp.add(FetchProfile.Item.FLAGS); fp.add(FetchProfile.Item.ENVELOPE); final HashMap<String, LocalMessageInfo> localMapCopy; if (localMessageMap != null) localMapCopy = new HashMap<String, LocalMessageInfo>(localMessageMap); else { localMapCopy = new HashMap<String, LocalMessageInfo>(); } remoteFolder.fetch(unsyncedMessages.toArray(new Message[0]), fp, new MessageRetrievalListener() { @Override public void messageRetrieved(Message message) { try { // Determine if the new message was already known (e.g. partial) // And create or reload the full message info LocalMessageInfo localMessageInfo = localMapCopy.get(message.getUid()); EmailContent.Message localMessage = null; if (localMessageInfo == null) { localMessage = new EmailContent.Message(); } else { localMessage = EmailContent.Message.restoreMessageWithId( mContext, localMessageInfo.mId); } if (localMessage != null) { try { // Copy the fields that are available into the message LegacyConversions.updateMessageFields(localMessage, message, account.mId, mailbox.mId); // Commit the message to the local store saveOrUpdate(localMessage, mContext); // Track the "new" ness of the downloaded message if (!message.isSet(Flag.SEEN) && unseenMessages != null) { unseenMessages.add(localMessage.mId); } } catch (MessagingException me) { Log.e(Logging.LOG_TAG, "Error while copying downloaded message." + me); } } } catch (Exception e) { Log.e(Logging.LOG_TAG, "Error while storing downloaded message." + e.toString()); } } @Override public void loadAttachmentProgress(int progress) { } }); } /** * A message and numeric uid that's easily sortable */ private static class SortableMessage { private final Message mMessage; private final long mUid; SortableMessage(Message message, long uid) { mMessage = message; mUid = uid; } } public int searchMailbox(long accountId, SearchParams searchParams, long destMailboxId) throws MessagingException { try { return searchMailboxImpl(accountId, searchParams, destMailboxId); } finally { // Tell UI that we're done loading any search results (no harm calling this even if we // encountered an error or never sent a "started" message) mListeners.synchronizeMailboxFinished(accountId, destMailboxId, 0, 0, null); } } private int searchMailboxImpl(long accountId, SearchParams searchParams, final long destMailboxId) throws MessagingException { final Account account = Account.restoreAccountWithId(mContext, accountId); final Mailbox mailbox = Mailbox.restoreMailboxWithId(mContext, searchParams.mMailboxId); final Mailbox destMailbox = Mailbox.restoreMailboxWithId(mContext, destMailboxId); if (account == null || mailbox == null || destMailbox == null) { Log.d(Logging.LOG_TAG, "Attempted search for " + searchParams + " but account or mailbox information was missing"); return 0; } // Tell UI that we're loading messages mListeners.synchronizeMailboxStarted(accountId, destMailbox.mId); Store remoteStore = Store.getInstance(account, mContext); Folder remoteFolder = remoteStore.getFolder(mailbox.mServerId); remoteFolder.open(OpenMode.READ_WRITE); SortableMessage[] sortableMessages = new SortableMessage[0]; if (searchParams.mOffset == 0) { // Get the "bare" messages (basically uid) Message[] remoteMessages = remoteFolder.getMessages(searchParams, null); int remoteCount = remoteMessages.length; if (remoteCount > 0) { sortableMessages = new SortableMessage[remoteCount]; int i = 0; for (Message msg : remoteMessages) { sortableMessages[i++] = new SortableMessage(msg, Long.parseLong(msg.getUid())); } // Sort the uid's, most recent first // Note: Not all servers will be nice and return results in the order of request; // those that do will see messages arrive from newest to oldest Arrays.sort(sortableMessages, new Comparator<SortableMessage>() { @Override public int compare(SortableMessage lhs, SortableMessage rhs) { return lhs.mUid > rhs.mUid ? -1 : lhs.mUid < rhs.mUid ? 1 : 0; } }); sSearchResults.put(accountId, sortableMessages); } } else { sortableMessages = sSearchResults.get(accountId); } final int numSearchResults = sortableMessages.length; final int numToLoad = Math.min(numSearchResults - searchParams.mOffset, searchParams.mLimit); if (numToLoad <= 0) { return 0; } final ArrayList<Message> messageList = new ArrayList<Message>(); for (int i = searchParams.mOffset; i < numToLoad + searchParams.mOffset; i++) { messageList.add(sortableMessages[i].mMessage); } // Get everything in one pass, rather than two (as in sync); this starts getting us // usable results quickly. FetchProfile fp = new FetchProfile(); fp.add(FetchProfile.Item.FLAGS); fp.add(FetchProfile.Item.ENVELOPE); fp.add(FetchProfile.Item.STRUCTURE); fp.add(FetchProfile.Item.BODY_SANE); remoteFolder.fetch(messageList.toArray(new Message[0]), fp, new MessageRetrievalListener() { public void messageRetrieved(Message message) { try { // Determine if the new message was already known (e.g. partial) // And create or reload the full message info EmailContent.Message localMessage = new EmailContent.Message(); try { // Copy the fields that are available into the message LegacyConversions.updateMessageFields(localMessage, message, account.mId, mailbox.mId); // Commit the message to the local store saveOrUpdate(localMessage, mContext); localMessage.mMailboxKey = destMailboxId; // We load 50k or so; maybe it's complete, maybe not... int flag = EmailContent.Message.FLAG_LOADED_COMPLETE; // We store the serverId of the source mailbox into protocolSearchInfo // This will be used by loadMessageForView, etc. to use the proper remote // folder localMessage.mProtocolSearchInfo = mailbox.mServerId; if (message.getSize() > Store.FETCH_BODY_SANE_SUGGESTED_SIZE) { flag = EmailContent.Message.FLAG_LOADED_PARTIAL; } copyOneMessageToProvider(message, localMessage, flag, mContext); } catch (MessagingException me) { Log.e(Logging.LOG_TAG, "Error while copying downloaded message." + me); } } catch (Exception e) { Log.e(Logging.LOG_TAG, "Error while storing downloaded message." + e.toString()); } } @Override public void loadAttachmentProgress(int progress) { } }); return numSearchResults; } /** * Generic synchronizer - used for POP3 and IMAP. * * TODO Break this method up into smaller chunks. * * @param account the account to sync * @param mailbox the mailbox to sync * @return results of the sync pass * @throws MessagingException */ private SyncResults synchronizeMailboxGeneric(final Account account, final Mailbox mailbox) throws MessagingException { /* * A list of IDs for messages that were downloaded and did not have the seen flag set. * This serves as the "true" new message count reported to the user via notification. */ final ArrayList<Long> unseenMessages = new ArrayList<Long>(); Log.d(Logging.LOG_TAG, "*** synchronizeMailboxGeneric ***"); ContentResolver resolver = mContext.getContentResolver(); // 0. We do not ever sync DRAFTS or OUTBOX (down or up) if (mailbox.mType == Mailbox.TYPE_DRAFTS || mailbox.mType == Mailbox.TYPE_OUTBOX) { int totalMessages = EmailContent.count(mContext, mailbox.getUri(), null, null); return new SyncResults(totalMessages, unseenMessages); } // 1. Get the message list from the local store and create an index of the uids Cursor localUidCursor = null; HashMap<String, LocalMessageInfo> localMessageMap = new HashMap<String, LocalMessageInfo>(); try { localUidCursor = resolver.query( EmailContent.Message.CONTENT_URI, LocalMessageInfo.PROJECTION, EmailContent.MessageColumns.ACCOUNT_KEY + "=?" + " AND " + MessageColumns.MAILBOX_KEY + "=?", new String[] { String.valueOf(account.mId), String.valueOf(mailbox.mId) }, null); while (localUidCursor.moveToNext()) { LocalMessageInfo info = new LocalMessageInfo(localUidCursor); localMessageMap.put(info.mServerId, info); } } finally { if (localUidCursor != null) { localUidCursor.close(); } } // 2. Open the remote folder and create the remote folder if necessary Store remoteStore = Store.getInstance(account, mContext); // The account might have been deleted if (remoteStore == null) return null; Folder remoteFolder = remoteStore.getFolder(mailbox.mServerId); /* * If the folder is a "special" folder we need to see if it exists * on the remote server. It if does not exist we'll try to create it. If we * can't create we'll abort. This will happen on every single Pop3 folder as * designed and on Imap folders during error conditions. This allows us * to treat Pop3 and Imap the same in this code. */ if (mailbox.mType == Mailbox.TYPE_TRASH || mailbox.mType == Mailbox.TYPE_SENT || mailbox.mType == Mailbox.TYPE_DRAFTS) { if (!remoteFolder.exists()) { if (!remoteFolder.create(FolderType.HOLDS_MESSAGES)) { return new SyncResults(0, unseenMessages); } } } // 3, Open the remote folder. This pre-loads certain metadata like message count. remoteFolder.open(OpenMode.READ_WRITE); // 4. Trash any remote messages that are marked as trashed locally. // TODO - this comment was here, but no code was here. // 5. Get the remote message count. int remoteMessageCount = remoteFolder.getMessageCount(); // 6. Determine the limit # of messages to download int visibleLimit = mailbox.mVisibleLimit; if (visibleLimit <= 0) { visibleLimit = Email.VISIBLE_LIMIT_DEFAULT; } // 7. Create a list of messages to download Message[] remoteMessages = new Message[0]; final ArrayList<Message> unsyncedMessages = new ArrayList<Message>(); HashMap<String, Message> remoteUidMap = new HashMap<String, Message>(); int newMessageCount = 0; if (remoteMessageCount > 0) { /* * Message numbers start at 1. */ int remoteStart = Math.max(0, remoteMessageCount - visibleLimit) + 1; int remoteEnd = remoteMessageCount; remoteMessages = remoteFolder.getMessages(remoteStart, remoteEnd, null); // TODO Why are we running through the list twice? Combine w/ for loop below for (Message message : remoteMessages) { remoteUidMap.put(message.getUid(), message); } /* * Get a list of the messages that are in the remote list but not on the * local store, or messages that are in the local store but failed to download * on the last sync. These are the new messages that we will download. * Note, we also skip syncing messages which are flagged as "deleted message" sentinels, * because they are locally deleted and we don't need or want the old message from * the server. */ for (Message message : remoteMessages) { LocalMessageInfo localMessage = localMessageMap.get(message.getUid()); if (localMessage == null) { newMessageCount++; } // localMessage == null -> message has never been created (not even headers) // mFlagLoaded = UNLOADED -> message created, but none of body loaded // mFlagLoaded = PARTIAL -> message created, a "sane" amt of body has been loaded // mFlagLoaded = COMPLETE -> message body has been completely loaded // mFlagLoaded = DELETED -> message has been deleted // Only the first two of these are "unsynced", so let's retrieve them if (localMessage == null || (localMessage.mFlagLoaded == EmailContent.Message.FLAG_LOADED_UNLOADED)) { unsyncedMessages.add(message); } } } // 8. Download basic info about the new/unloaded messages (if any) /* * Fetch the flags and envelope only of the new messages. This is intended to get us * critical data as fast as possible, and then we'll fill in the details. */ if (unsyncedMessages.size() > 0) { downloadFlagAndEnvelope(account, mailbox, remoteFolder, unsyncedMessages, localMessageMap, unseenMessages); } // 9. Refresh the flags for any messages in the local store that we didn't just download. FetchProfile fp = new FetchProfile(); fp.add(FetchProfile.Item.FLAGS); remoteFolder.fetch(remoteMessages, fp, null); boolean remoteSupportsSeen = false; boolean remoteSupportsFlagged = false; boolean remoteSupportsAnswered = false; for (Flag flag : remoteFolder.getPermanentFlags()) { if (flag == Flag.SEEN) { remoteSupportsSeen = true; } if (flag == Flag.FLAGGED) { remoteSupportsFlagged = true; } if (flag == Flag.ANSWERED) { remoteSupportsAnswered = true; } } // Update SEEN/FLAGGED/ANSWERED (star) flags (if supported remotely - e.g. not for POP3) if (remoteSupportsSeen || remoteSupportsFlagged || remoteSupportsAnswered) { for (Message remoteMessage : remoteMessages) { LocalMessageInfo localMessageInfo = localMessageMap.get(remoteMessage.getUid()); if (localMessageInfo == null) { continue; } boolean localSeen = localMessageInfo.mFlagRead; boolean remoteSeen = remoteMessage.isSet(Flag.SEEN); boolean newSeen = (remoteSupportsSeen && (remoteSeen != localSeen)); boolean localFlagged = localMessageInfo.mFlagFavorite; boolean remoteFlagged = remoteMessage.isSet(Flag.FLAGGED); boolean newFlagged = (remoteSupportsFlagged && (localFlagged != remoteFlagged)); int localFlags = localMessageInfo.mFlags; boolean localAnswered = (localFlags & EmailContent.Message.FLAG_REPLIED_TO) != 0; boolean remoteAnswered = remoteMessage.isSet(Flag.ANSWERED); boolean newAnswered = (remoteSupportsAnswered && (localAnswered != remoteAnswered)); if (newSeen || newFlagged || newAnswered) { Uri uri = ContentUris.withAppendedId( EmailContent.Message.CONTENT_URI, localMessageInfo.mId); ContentValues updateValues = new ContentValues(); updateValues.put(MessageColumns.FLAG_READ, remoteSeen); updateValues.put(MessageColumns.FLAG_FAVORITE, remoteFlagged); if (remoteAnswered) { localFlags |= EmailContent.Message.FLAG_REPLIED_TO; } else { localFlags &= ~EmailContent.Message.FLAG_REPLIED_TO; } updateValues.put(MessageColumns.FLAGS, localFlags); resolver.update(uri, updateValues, null, null); } } } // 10. Remove any messages that are in the local store but no longer on the remote store. HashSet<String> localUidsToDelete = new HashSet<String>(localMessageMap.keySet()); localUidsToDelete.removeAll(remoteUidMap.keySet()); for (String uidToDelete : localUidsToDelete) { LocalMessageInfo infoToDelete = localMessageMap.get(uidToDelete); // Delete associated data (attachment files) // Attachment & Body records are auto-deleted when we delete the Message record AttachmentUtilities.deleteAllAttachmentFiles(mContext, account.mId, infoToDelete.mId); // Delete the message itself Uri uriToDelete = ContentUris.withAppendedId( EmailContent.Message.CONTENT_URI, infoToDelete.mId); resolver.delete(uriToDelete, null, null); // Delete extra rows (e.g. synced or deleted) Uri syncRowToDelete = ContentUris.withAppendedId( EmailContent.Message.UPDATED_CONTENT_URI, infoToDelete.mId); resolver.delete(syncRowToDelete, null, null); Uri deletERowToDelete = ContentUris.withAppendedId( EmailContent.Message.UPDATED_CONTENT_URI, infoToDelete.mId); resolver.delete(deletERowToDelete, null, null); } loadUnsyncedMessages(account, remoteFolder, unsyncedMessages, mailbox); // 14. Clean up and report results remoteFolder.close(false); return new SyncResults(remoteMessageCount, unseenMessages); } /** * Copy one downloaded message (which may have partially-loaded sections) * into a newly created EmailProvider Message, given the account and mailbox * * @param message the remote message we've just downloaded * @param account the account it will be stored into * @param folder the mailbox it will be stored into * @param loadStatus when complete, the message will be marked with this status (e.g. * EmailContent.Message.LOADED) */ public void copyOneMessageToProvider(Message message, Account account, Mailbox folder, int loadStatus) { EmailContent.Message localMessage = null; Cursor c = null; try { c = mContext.getContentResolver().query( EmailContent.Message.CONTENT_URI, EmailContent.Message.CONTENT_PROJECTION, EmailContent.MessageColumns.ACCOUNT_KEY + "=?" + " AND " + MessageColumns.MAILBOX_KEY + "=?" + " AND " + SyncColumns.SERVER_ID + "=?", new String[] { String.valueOf(account.mId), String.valueOf(folder.mId), String.valueOf(message.getUid()) }, null); if (c.moveToNext()) { localMessage = EmailContent.getContent(c, EmailContent.Message.class); localMessage.mMailboxKey = folder.mId; localMessage.mAccountKey = account.mId; copyOneMessageToProvider(message, localMessage, loadStatus, mContext); } } finally { if (c != null) { c.close(); } } } /** * Copy one downloaded message (which may have partially-loaded sections) * into an already-created EmailProvider Message * * @param message the remote message we've just downloaded * @param localMessage the EmailProvider Message, already created * @param loadStatus when complete, the message will be marked with this status (e.g. * EmailContent.Message.LOADED) * @param context the context to be used for EmailProvider */ public void copyOneMessageToProvider(Message message, EmailContent.Message localMessage, int loadStatus, Context context) { try { EmailContent.Body body = EmailContent.Body.restoreBodyWithMessageId(context, localMessage.mId); if (body == null) { body = new EmailContent.Body(); } try { // Copy the fields that are available into the message object LegacyConversions.updateMessageFields(localMessage, message, localMessage.mAccountKey, localMessage.mMailboxKey); // Now process body parts & attachments ArrayList<Part> viewables = new ArrayList<Part>(); ArrayList<Part> attachments = new ArrayList<Part>(); MimeUtility.collectParts(message, viewables, attachments); ConversionUtilities.updateBodyFields(body, localMessage, viewables); // Commit the message & body to the local store immediately saveOrUpdate(localMessage, context); saveOrUpdate(body, context); // process (and save) attachments LegacyConversions.updateAttachments(context, localMessage, attachments); // One last update of message with two updated flags localMessage.mFlagLoaded = loadStatus; ContentValues cv = new ContentValues(); cv.put(EmailContent.MessageColumns.FLAG_ATTACHMENT, localMessage.mFlagAttachment); cv.put(EmailContent.MessageColumns.FLAG_LOADED, localMessage.mFlagLoaded); Uri uri = ContentUris.withAppendedId(EmailContent.Message.CONTENT_URI, localMessage.mId); context.getContentResolver().update(uri, cv, null, null); } catch (MessagingException me) { Log.e(Logging.LOG_TAG, "Error while copying downloaded message." + me); } } catch (RuntimeException rte) { Log.e(Logging.LOG_TAG, "Error while storing downloaded message." + rte.toString()); } catch (IOException ioe) { Log.e(Logging.LOG_TAG, "Error while storing attachment." + ioe.toString()); } } public void processPendingActions(final long accountId) { put("processPendingActions", null, new Runnable() { public void run() { try { Account account = Account.restoreAccountWithId(mContext, accountId); if (account == null) { return; } processPendingActionsSynchronous(account); } catch (MessagingException me) { if (Logging.LOGD) { Log.v(Logging.LOG_TAG, "processPendingActions", me); } /* * Ignore any exceptions from the commands. Commands will be processed * on the next round. */ } } }); } /** * Find messages in the updated table that need to be written back to server. * * Handles: * Read/Unread * Flagged * Append (upload) * Move To Trash * Empty trash * TODO: * Move * * @param account the account to scan for pending actions * @throws MessagingException */ private void processPendingActionsSynchronous(Account account) throws MessagingException { TrafficStats.setThreadStatsTag(TrafficFlags.getSyncFlags(mContext, account)); ContentResolver resolver = mContext.getContentResolver(); String[] accountIdArgs = new String[] { Long.toString(account.mId) }; // Handle deletes first, it's always better to get rid of things first processPendingDeletesSynchronous(account, resolver, accountIdArgs); // Handle uploads (currently, only to sent messages) processPendingUploadsSynchronous(account, resolver, accountIdArgs); // Now handle updates / upsyncs processPendingUpdatesSynchronous(account, resolver, accountIdArgs); } /** * Get the mailbox corresponding to the remote location of a message; this will normally be * the mailbox whose _id is mailboxKey, except for search results, where we must look it up * by serverId * @param message the message in question * @return the mailbox in which the message resides on the server */ private Mailbox getRemoteMailboxForMessage(EmailContent.Message message) { // If this is a search result, use the protocolSearchInfo field to get the server info if (!TextUtils.isEmpty(message.mProtocolSearchInfo)) { long accountKey = message.mAccountKey; String protocolSearchInfo = message.mProtocolSearchInfo; if (accountKey == mLastSearchAccountKey && protocolSearchInfo.equals(mLastSearchServerId)) { return mLastSearchRemoteMailbox; } Cursor c = mContext.getContentResolver().query(Mailbox.CONTENT_URI, Mailbox.CONTENT_PROJECTION, Mailbox.PATH_AND_ACCOUNT_SELECTION, new String[] {protocolSearchInfo, Long.toString(accountKey)}, null); try { if (c.moveToNext()) { Mailbox mailbox = new Mailbox(); mailbox.restore(c); mLastSearchAccountKey = accountKey; mLastSearchServerId = protocolSearchInfo; mLastSearchRemoteMailbox = mailbox; return mailbox; } else { return null; } } finally { c.close(); } } else { return Mailbox.restoreMailboxWithId(mContext, message.mMailboxKey); } } /** * Scan for messages that are in the Message_Deletes table, look for differences that * we can deal with, and do the work. * * @param account * @param resolver * @param accountIdArgs */ private void processPendingDeletesSynchronous(Account account, ContentResolver resolver, String[] accountIdArgs) { Cursor deletes = resolver.query(EmailContent.Message.DELETED_CONTENT_URI, EmailContent.Message.CONTENT_PROJECTION, EmailContent.MessageColumns.ACCOUNT_KEY + "=?", accountIdArgs, EmailContent.MessageColumns.MAILBOX_KEY); long lastMessageId = -1; try { // Defer setting up the store until we know we need to access it Store remoteStore = null; // loop through messages marked as deleted while (deletes.moveToNext()) { boolean deleteFromTrash = false; EmailContent.Message oldMessage = EmailContent.getContent(deletes, EmailContent.Message.class); if (oldMessage != null) { lastMessageId = oldMessage.mId; Mailbox mailbox = getRemoteMailboxForMessage(oldMessage); if (mailbox == null) { continue; // Mailbox removed. Move to the next message. } deleteFromTrash = mailbox.mType == Mailbox.TYPE_TRASH; // Load the remote store if it will be needed if (remoteStore == null && deleteFromTrash) { remoteStore = Store.getInstance(account, mContext); } // Dispatch here for specific change types if (deleteFromTrash) { // Move message to trash processPendingDeleteFromTrash(remoteStore, account, mailbox, oldMessage); } } // Finally, delete the update Uri uri = ContentUris.withAppendedId(EmailContent.Message.DELETED_CONTENT_URI, oldMessage.mId); resolver.delete(uri, null, null); } } catch (MessagingException me) { // Presumably an error here is an account connection failure, so there is // no point in continuing through the rest of the pending updates. if (Email.DEBUG) { Log.d(Logging.LOG_TAG, "Unable to process pending delete for id=" + lastMessageId + ": " + me); } } finally { deletes.close(); } } /** * Scan for messages that are in Sent, and are in need of upload, * and send them to the server. "In need of upload" is defined as: * serverId == null (no UID has been assigned) * or * message is in the updated list * * Note we also look for messages that are moving from drafts->outbox->sent. They never * go through "drafts" or "outbox" on the server, so we hang onto these until they can be * uploaded directly to the Sent folder. * * @param account * @param resolver * @param accountIdArgs */ private void processPendingUploadsSynchronous(Account account, ContentResolver resolver, String[] accountIdArgs) { // Find the Sent folder (since that's all we're uploading for now Cursor mailboxes = resolver.query(Mailbox.CONTENT_URI, Mailbox.ID_PROJECTION, MailboxColumns.ACCOUNT_KEY + "=?" + " and " + MailboxColumns.TYPE + "=" + Mailbox.TYPE_SENT, accountIdArgs, null); long lastMessageId = -1; try { // Defer setting up the store until we know we need to access it Store remoteStore = null; while (mailboxes.moveToNext()) { long mailboxId = mailboxes.getLong(Mailbox.ID_PROJECTION_COLUMN); String[] mailboxKeyArgs = new String[] { Long.toString(mailboxId) }; // Demand load mailbox Mailbox mailbox = null; // First handle the "new" messages (serverId == null) Cursor upsyncs1 = resolver.query(EmailContent.Message.CONTENT_URI, EmailContent.Message.ID_PROJECTION, EmailContent.Message.MAILBOX_KEY + "=?" + " and (" + EmailContent.Message.SERVER_ID + " is null" + " or " + EmailContent.Message.SERVER_ID + "=''" + ")", mailboxKeyArgs, null); try { while (upsyncs1.moveToNext()) { // Load the remote store if it will be needed if (remoteStore == null) { remoteStore = Store.getInstance(account, mContext); } // Load the mailbox if it will be needed if (mailbox == null) { mailbox = Mailbox.restoreMailboxWithId(mContext, mailboxId); if (mailbox == null) { continue; // Mailbox removed. Move to the next message. } } // upsync the message long id = upsyncs1.getLong(EmailContent.Message.ID_PROJECTION_COLUMN); lastMessageId = id; processUploadMessage(resolver, remoteStore, account, mailbox, id); } } finally { if (upsyncs1 != null) { upsyncs1.close(); } } // Next, handle any updates (e.g. edited in place, although this shouldn't happen) Cursor upsyncs2 = resolver.query(EmailContent.Message.UPDATED_CONTENT_URI, EmailContent.Message.ID_PROJECTION, EmailContent.MessageColumns.MAILBOX_KEY + "=?", mailboxKeyArgs, null); try { while (upsyncs2.moveToNext()) { // Load the remote store if it will be needed if (remoteStore == null) { remoteStore = Store.getInstance(account, mContext); } // Load the mailbox if it will be needed if (mailbox == null) { mailbox = Mailbox.restoreMailboxWithId(mContext, mailboxId); if (mailbox == null) { continue; // Mailbox removed. Move to the next message. } } // upsync the message long id = upsyncs2.getLong(EmailContent.Message.ID_PROJECTION_COLUMN); lastMessageId = id; processUploadMessage(resolver, remoteStore, account, mailbox, id); } } finally { if (upsyncs2 != null) { upsyncs2.close(); } } } } catch (MessagingException me) { // Presumably an error here is an account connection failure, so there is // no point in continuing through the rest of the pending updates. if (Email.DEBUG) { Log.d(Logging.LOG_TAG, "Unable to process pending upsync for id=" + lastMessageId + ": " + me); } } finally { if (mailboxes != null) { mailboxes.close(); } } } /** * Scan for messages that are in the Message_Updates table, look for differences that * we can deal with, and do the work. * * @param account * @param resolver * @param accountIdArgs */ private void processPendingUpdatesSynchronous(Account account, ContentResolver resolver, String[] accountIdArgs) { Cursor updates = resolver.query(EmailContent.Message.UPDATED_CONTENT_URI, EmailContent.Message.CONTENT_PROJECTION, EmailContent.MessageColumns.ACCOUNT_KEY + "=?", accountIdArgs, EmailContent.MessageColumns.MAILBOX_KEY); long lastMessageId = -1; try { // Defer setting up the store until we know we need to access it Store remoteStore = null; // Demand load mailbox (note order-by to reduce thrashing here) Mailbox mailbox = null; // loop through messages marked as needing updates while (updates.moveToNext()) { boolean changeMoveToTrash = false; boolean changeRead = false; boolean changeFlagged = false; boolean changeMailbox = false; boolean changeAnswered = false; EmailContent.Message oldMessage = EmailContent.getContent(updates, EmailContent.Message.class); lastMessageId = oldMessage.mId; EmailContent.Message newMessage = EmailContent.Message.restoreMessageWithId(mContext, oldMessage.mId); if (newMessage != null) { mailbox = Mailbox.restoreMailboxWithId(mContext, newMessage.mMailboxKey); if (mailbox == null) { continue; // Mailbox removed. Move to the next message. } if (oldMessage.mMailboxKey != newMessage.mMailboxKey) { if (mailbox.mType == Mailbox.TYPE_TRASH) { changeMoveToTrash = true; } else { changeMailbox = true; } } changeRead = oldMessage.mFlagRead != newMessage.mFlagRead; changeFlagged = oldMessage.mFlagFavorite != newMessage.mFlagFavorite; changeAnswered = (oldMessage.mFlags & EmailContent.Message.FLAG_REPLIED_TO) != (newMessage.mFlags & EmailContent.Message.FLAG_REPLIED_TO); } // Load the remote store if it will be needed if (remoteStore == null && (changeMoveToTrash || changeRead || changeFlagged || changeMailbox || changeAnswered)) { remoteStore = Store.getInstance(account, mContext); } // Dispatch here for specific change types if (changeMoveToTrash) { // Move message to trash processPendingMoveToTrash(remoteStore, account, mailbox, oldMessage, newMessage); } else if (changeRead || changeFlagged || changeMailbox || changeAnswered) { processPendingDataChange(remoteStore, mailbox, changeRead, changeFlagged, changeMailbox, changeAnswered, oldMessage, newMessage); } // Finally, delete the update Uri uri = ContentUris.withAppendedId(EmailContent.Message.UPDATED_CONTENT_URI, oldMessage.mId); resolver.delete(uri, null, null); } } catch (MessagingException me) { // Presumably an error here is an account connection failure, so there is // no point in continuing through the rest of the pending updates. if (Email.DEBUG) { Log.d(Logging.LOG_TAG, "Unable to process pending update for id=" + lastMessageId + ": " + me); } } finally { updates.close(); } } /** * Upsync an entire message. This must also unwind whatever triggered it (either by * updating the serverId, or by deleting the update record, or it's going to keep happening * over and over again. * * Note: If the message is being uploaded into an unexpected mailbox, we *do not* upload. * This is to avoid unnecessary uploads into the trash. Although the caller attempts to select * only the Drafts and Sent folders, this can happen when the update record and the current * record mismatch. In this case, we let the update record remain, because the filters * in processPendingUpdatesSynchronous() will pick it up as a move and handle it (or drop it) * appropriately. * * @param resolver * @param remoteStore * @param account * @param mailbox the actual mailbox * @param messageId */ private void processUploadMessage(ContentResolver resolver, Store remoteStore, Account account, Mailbox mailbox, long messageId) throws MessagingException { EmailContent.Message newMessage = EmailContent.Message.restoreMessageWithId(mContext, messageId); boolean deleteUpdate = false; if (newMessage == null) { deleteUpdate = true; Log.d(Logging.LOG_TAG, "Upsync failed for null message, id=" + messageId); } else if (mailbox.mType == Mailbox.TYPE_DRAFTS) { deleteUpdate = false; Log.d(Logging.LOG_TAG, "Upsync skipped for mailbox=drafts, id=" + messageId); } else if (mailbox.mType == Mailbox.TYPE_OUTBOX) { deleteUpdate = false; Log.d(Logging.LOG_TAG, "Upsync skipped for mailbox=outbox, id=" + messageId); } else if (mailbox.mType == Mailbox.TYPE_TRASH) { deleteUpdate = false; Log.d(Logging.LOG_TAG, "Upsync skipped for mailbox=trash, id=" + messageId); } else if (newMessage != null && newMessage.mMailboxKey != mailbox.mId) { deleteUpdate = false; Log.d(Logging.LOG_TAG, "Upsync skipped; mailbox changed, id=" + messageId); } else { Log.d(Logging.LOG_TAG, "Upsyc triggered for message id=" + messageId); deleteUpdate = processPendingAppend(remoteStore, account, mailbox, newMessage); } if (deleteUpdate) { // Finally, delete the update (if any) Uri uri = ContentUris.withAppendedId( EmailContent.Message.UPDATED_CONTENT_URI, messageId); resolver.delete(uri, null, null); } } /** * Upsync changes to read, flagged, or mailbox * * @param remoteStore the remote store for this mailbox * @param mailbox the mailbox the message is stored in * @param changeRead whether the message's read state has changed * @param changeFlagged whether the message's flagged state has changed * @param changeMailbox whether the message's mailbox has changed * @param oldMessage the message in it's pre-change state * @param newMessage the current version of the message */ private void processPendingDataChange(Store remoteStore, Mailbox mailbox, boolean changeRead, boolean changeFlagged, boolean changeMailbox, boolean changeAnswered, EmailContent.Message oldMessage, final EmailContent.Message newMessage) throws MessagingException { // New mailbox is the mailbox this message WILL be in (same as the one it WAS in if it isn't // being moved Mailbox newMailbox = mailbox; // Mailbox is the original remote mailbox (the one we're acting on) mailbox = getRemoteMailboxForMessage(oldMessage); // 0. No remote update if the message is local-only if (newMessage.mServerId == null || newMessage.mServerId.equals("") || newMessage.mServerId.startsWith(LOCAL_SERVERID_PREFIX) || (mailbox == null)) { return; } // 1. No remote update for DRAFTS or OUTBOX if (mailbox.mType == Mailbox.TYPE_DRAFTS || mailbox.mType == Mailbox.TYPE_OUTBOX) { return; } // 2. Open the remote store & folder Folder remoteFolder = remoteStore.getFolder(mailbox.mServerId); if (!remoteFolder.exists()) { return; } remoteFolder.open(OpenMode.READ_WRITE); if (remoteFolder.getMode() != OpenMode.READ_WRITE) { return; } // 3. Finally, apply the changes to the message Message remoteMessage = remoteFolder.getMessage(newMessage.mServerId); if (remoteMessage == null) { return; } if (Email.DEBUG) { Log.d(Logging.LOG_TAG, "Update for msg id=" + newMessage.mId + " read=" + newMessage.mFlagRead + " flagged=" + newMessage.mFlagFavorite + " answered=" + ((newMessage.mFlags & EmailContent.Message.FLAG_REPLIED_TO) != 0) + " new mailbox=" + newMessage.mMailboxKey); } Message[] messages = new Message[] { remoteMessage }; if (changeRead) { remoteFolder.setFlags(messages, FLAG_LIST_SEEN, newMessage.mFlagRead); } if (changeFlagged) { remoteFolder.setFlags(messages, FLAG_LIST_FLAGGED, newMessage.mFlagFavorite); } if (changeAnswered) { remoteFolder.setFlags(messages, FLAG_LIST_ANSWERED, (newMessage.mFlags & EmailContent.Message.FLAG_REPLIED_TO) != 0); } if (changeMailbox) { Folder toFolder = remoteStore.getFolder(newMailbox.mServerId); if (!remoteFolder.exists()) { return; } // We may need the message id to search for the message in the destination folder remoteMessage.setMessageId(newMessage.mMessageId); // Copy the message to its new folder remoteFolder.copyMessages(messages, toFolder, new MessageUpdateCallbacks() { @Override public void onMessageUidChange(Message message, String newUid) { ContentValues cv = new ContentValues(); cv.put(EmailContent.Message.SERVER_ID, newUid); // We only have one message, so, any updates _must_ be for it. Otherwise, // we'd have to cycle through to find the one with the same server ID. mContext.getContentResolver().update(ContentUris.withAppendedId( EmailContent.Message.CONTENT_URI, newMessage.mId), cv, null, null); } @Override public void onMessageNotFound(Message message) { } }); // Delete the message from the remote source folder remoteMessage.setFlag(Flag.DELETED, true); remoteFolder.expunge(); } remoteFolder.close(false); } /** * Process a pending trash message command. * * @param remoteStore the remote store we're working in * @param account The account in which we are working * @param newMailbox The local trash mailbox * @param oldMessage The message copy that was saved in the updates shadow table * @param newMessage The message that was moved to the mailbox */ private void processPendingMoveToTrash(Store remoteStore, Account account, Mailbox newMailbox, EmailContent.Message oldMessage, final EmailContent.Message newMessage) throws MessagingException { // 0. No remote move if the message is local-only if (newMessage.mServerId == null || newMessage.mServerId.equals("") || newMessage.mServerId.startsWith(LOCAL_SERVERID_PREFIX)) { return; } // 1. Escape early if we can't find the local mailbox // TODO smaller projection here Mailbox oldMailbox = getRemoteMailboxForMessage(oldMessage); if (oldMailbox == null) { // can't find old mailbox, it may have been deleted. just return. return; } // 2. We don't support delete-from-trash here if (oldMailbox.mType == Mailbox.TYPE_TRASH) { return; } // 3. If DELETE_POLICY_NEVER, simply write back the deleted sentinel and return // // This sentinel takes the place of the server-side message, and locally "deletes" it // by inhibiting future sync or display of the message. It will eventually go out of // scope when it becomes old, or is deleted on the server, and the regular sync code // will clean it up for us. if (account.getDeletePolicy() == Account.DELETE_POLICY_NEVER) { EmailContent.Message sentinel = new EmailContent.Message(); sentinel.mAccountKey = oldMessage.mAccountKey; sentinel.mMailboxKey = oldMessage.mMailboxKey; sentinel.mFlagLoaded = EmailContent.Message.FLAG_LOADED_DELETED; sentinel.mFlagRead = true; sentinel.mServerId = oldMessage.mServerId; sentinel.save(mContext); return; } // The rest of this method handles server-side deletion // 4. Find the remote mailbox (that we deleted from), and open it Folder remoteFolder = remoteStore.getFolder(oldMailbox.mServerId); if (!remoteFolder.exists()) { return; } remoteFolder.open(OpenMode.READ_WRITE); if (remoteFolder.getMode() != OpenMode.READ_WRITE) { remoteFolder.close(false); return; } // 5. Find the remote original message Message remoteMessage = remoteFolder.getMessage(oldMessage.mServerId); if (remoteMessage == null) { remoteFolder.close(false); return; } // 6. Find the remote trash folder, and create it if not found Folder remoteTrashFolder = remoteStore.getFolder(newMailbox.mServerId); if (!remoteTrashFolder.exists()) { /* * If the remote trash folder doesn't exist we try to create it. */ remoteTrashFolder.create(FolderType.HOLDS_MESSAGES); } // 7. Try to copy the message into the remote trash folder // Note, this entire section will be skipped for POP3 because there's no remote trash if (remoteTrashFolder.exists()) { /* * Because remoteTrashFolder may be new, we need to explicitly open it */ remoteTrashFolder.open(OpenMode.READ_WRITE); if (remoteTrashFolder.getMode() != OpenMode.READ_WRITE) { remoteFolder.close(false); remoteTrashFolder.close(false); return; } remoteFolder.copyMessages(new Message[] { remoteMessage }, remoteTrashFolder, new Folder.MessageUpdateCallbacks() { public void onMessageUidChange(Message message, String newUid) { // update the UID in the local trash folder, because some stores will // have to change it when copying to remoteTrashFolder ContentValues cv = new ContentValues(); cv.put(EmailContent.Message.SERVER_ID, newUid); mContext.getContentResolver().update(newMessage.getUri(), cv, null, null); } /** * This will be called if the deleted message doesn't exist and can't be * deleted (e.g. it was already deleted from the server.) In this case, * attempt to delete the local copy as well. */ public void onMessageNotFound(Message message) { mContext.getContentResolver().delete(newMessage.getUri(), null, null); } }); remoteTrashFolder.close(false); } // 8. Delete the message from the remote source folder remoteMessage.setFlag(Flag.DELETED, true); remoteFolder.expunge(); remoteFolder.close(false); } /** * Process a pending trash message command. * * @param remoteStore the remote store we're working in * @param account The account in which we are working * @param oldMailbox The local trash mailbox * @param oldMessage The message that was deleted from the trash */ private void processPendingDeleteFromTrash(Store remoteStore, Account account, Mailbox oldMailbox, EmailContent.Message oldMessage) throws MessagingException { // 1. We only support delete-from-trash here if (oldMailbox.mType != Mailbox.TYPE_TRASH) { return; } // 2. Find the remote trash folder (that we are deleting from), and open it Folder remoteTrashFolder = remoteStore.getFolder(oldMailbox.mServerId); if (!remoteTrashFolder.exists()) { return; } remoteTrashFolder.open(OpenMode.READ_WRITE); if (remoteTrashFolder.getMode() != OpenMode.READ_WRITE) { remoteTrashFolder.close(false); return; } // 3. Find the remote original message Message remoteMessage = remoteTrashFolder.getMessage(oldMessage.mServerId); if (remoteMessage == null) { remoteTrashFolder.close(false); return; } // 4. Delete the message from the remote trash folder remoteMessage.setFlag(Flag.DELETED, true); remoteTrashFolder.expunge(); remoteTrashFolder.close(false); } /** * Process a pending append message command. This command uploads a local message to the * server, first checking to be sure that the server message is not newer than * the local message. * * @param remoteStore the remote store we're working in * @param account The account in which we are working * @param newMailbox The mailbox we're appending to * @param message The message we're appending * @return true if successfully uploaded */ private boolean processPendingAppend(Store remoteStore, Account account, Mailbox newMailbox, EmailContent.Message message) throws MessagingException { boolean updateInternalDate = false; boolean updateMessage = false; boolean deleteMessage = false; // 1. Find the remote folder that we're appending to and create and/or open it Folder remoteFolder = remoteStore.getFolder(newMailbox.mServerId); if (!remoteFolder.exists()) { if (!remoteFolder.canCreate(FolderType.HOLDS_MESSAGES)) { // This is POP3, we cannot actually upload. Instead, we'll update the message // locally with a fake serverId (so we don't keep trying here) and return. if (message.mServerId == null || message.mServerId.length() == 0) { message.mServerId = LOCAL_SERVERID_PREFIX + message.mId; Uri uri = ContentUris.withAppendedId(EmailContent.Message.CONTENT_URI, message.mId); ContentValues cv = new ContentValues(); cv.put(EmailContent.Message.SERVER_ID, message.mServerId); mContext.getContentResolver().update(uri, cv, null, null); } return true; } if (!remoteFolder.create(FolderType.HOLDS_MESSAGES)) { // This is a (hopefully) transient error and we return false to try again later return false; } } remoteFolder.open(OpenMode.READ_WRITE); if (remoteFolder.getMode() != OpenMode.READ_WRITE) { return false; } // 2. If possible, load a remote message with the matching UID Message remoteMessage = null; if (message.mServerId != null && message.mServerId.length() > 0) { remoteMessage = remoteFolder.getMessage(message.mServerId); } // 3. If a remote message could not be found, upload our local message if (remoteMessage == null) { // 3a. Create a legacy message to upload Message localMessage = LegacyConversions.makeMessage(mContext, message); // 3b. Upload it FetchProfile fp = new FetchProfile(); fp.add(FetchProfile.Item.BODY); remoteFolder.appendMessages(new Message[] { localMessage }); // 3b. And record the UID from the server message.mServerId = localMessage.getUid(); updateInternalDate = true; updateMessage = true; } else { // 4. If the remote message exists we need to determine which copy to keep. FetchProfile fp = new FetchProfile(); fp.add(FetchProfile.Item.ENVELOPE); remoteFolder.fetch(new Message[] { remoteMessage }, fp, null); Date localDate = new Date(message.mServerTimeStamp); Date remoteDate = remoteMessage.getInternalDate(); if (remoteDate != null && remoteDate.compareTo(localDate) > 0) { // 4a. If the remote message is newer than ours we'll just // delete ours and move on. A sync will get the server message // if we need to be able to see it. deleteMessage = true; } else { // 4b. Otherwise we'll upload our message and then delete the remote message. // Create a legacy message to upload Message localMessage = LegacyConversions.makeMessage(mContext, message); // 4c. Upload it fp.clear(); fp = new FetchProfile(); fp.add(FetchProfile.Item.BODY); remoteFolder.appendMessages(new Message[] { localMessage }); // 4d. Record the UID and new internalDate from the server message.mServerId = localMessage.getUid(); updateInternalDate = true; updateMessage = true; // 4e. And delete the old copy of the message from the server remoteMessage.setFlag(Flag.DELETED, true); } } // 5. If requested, Best-effort to capture new "internaldate" from the server if (updateInternalDate && message.mServerId != null) { try { Message remoteMessage2 = remoteFolder.getMessage(message.mServerId); if (remoteMessage2 != null) { FetchProfile fp2 = new FetchProfile(); fp2.add(FetchProfile.Item.ENVELOPE); remoteFolder.fetch(new Message[] { remoteMessage2 }, fp2, null); message.mServerTimeStamp = remoteMessage2.getInternalDate().getTime(); updateMessage = true; } } catch (MessagingException me) { // skip it - we can live without this } } // 6. Perform required edits to local copy of message if (deleteMessage || updateMessage) { Uri uri = ContentUris.withAppendedId(EmailContent.Message.CONTENT_URI, message.mId); ContentResolver resolver = mContext.getContentResolver(); if (deleteMessage) { resolver.delete(uri, null, null); } else if (updateMessage) { ContentValues cv = new ContentValues(); cv.put(EmailContent.Message.SERVER_ID, message.mServerId); cv.put(EmailContent.Message.SERVER_TIMESTAMP, message.mServerTimeStamp); resolver.update(uri, cv, null, null); } } return true; } /** * Finish loading a message that have been partially downloaded. * * @param messageId the message to load * @param listener the callback by which results will be reported */ public void loadMessageForView(final long messageId, MessagingListener listener) { mListeners.loadMessageForViewStarted(messageId); put("loadMessageForViewRemote", listener, new Runnable() { public void run() { try { // 1. Resample the message, in case it disappeared or synced while // this command was in queue EmailContent.Message message = EmailContent.Message.restoreMessageWithId(mContext, messageId); if (message == null) { mListeners.loadMessageForViewFailed(messageId, "Unknown message"); return; } if (message.mFlagLoaded == EmailContent.Message.FLAG_LOADED_COMPLETE) { mListeners.loadMessageForViewFinished(messageId); return; } // 2. Open the remote folder. // TODO combine with common code in loadAttachment Account account = Account.restoreAccountWithId(mContext, message.mAccountKey); Mailbox mailbox = Mailbox.restoreMailboxWithId(mContext, message.mMailboxKey); if (account == null || mailbox == null) { mListeners.loadMessageForViewFailed(messageId, "null account or mailbox"); return; } TrafficStats.setThreadStatsTag(TrafficFlags.getSyncFlags(mContext, account)); Store remoteStore = Store.getInstance(account, mContext); String remoteServerId = mailbox.mServerId; // If this is a search result, use the protocolSearchInfo field to get the // correct remote location if (!TextUtils.isEmpty(message.mProtocolSearchInfo)) { remoteServerId = message.mProtocolSearchInfo; } Folder remoteFolder = remoteStore.getFolder(remoteServerId); remoteFolder.open(OpenMode.READ_WRITE); // 3. Set up to download the entire message Message remoteMessage = remoteFolder.getMessage(message.mServerId); FetchProfile fp = new FetchProfile(); fp.add(FetchProfile.Item.BODY); remoteFolder.fetch(new Message[] { remoteMessage }, fp, null); // 4. Write to provider copyOneMessageToProvider(remoteMessage, account, mailbox, EmailContent.Message.FLAG_LOADED_COMPLETE); // 5. Notify UI mListeners.loadMessageForViewFinished(messageId); } catch (MessagingException me) { if (Logging.LOGD) Log.v(Logging.LOG_TAG, "", me); mListeners.loadMessageForViewFailed(messageId, me.getMessage()); } catch (RuntimeException rte) { mListeners.loadMessageForViewFailed(messageId, rte.getMessage()); } } }); } /** * Attempts to load the attachment specified by id from the given account and message. */ public void loadAttachment(final long accountId, final long messageId, final long mailboxId, final long attachmentId, MessagingListener listener, final boolean background) { mListeners.loadAttachmentStarted(accountId, messageId, attachmentId, true); put("loadAttachment", listener, new Runnable() { public void run() { try { //1. Check if the attachment is already here and return early in that case Attachment attachment = Attachment.restoreAttachmentWithId(mContext, attachmentId); if (attachment == null) { mListeners.loadAttachmentFailed(accountId, messageId, attachmentId, new MessagingException("The attachment is null"), background); return; } if (Utility.attachmentExists(mContext, attachment)) { mListeners.loadAttachmentFinished(accountId, messageId, attachmentId); return; } // 2. Open the remote folder. // TODO all of these could be narrower projections Account account = Account.restoreAccountWithId(mContext, accountId); Mailbox mailbox = Mailbox.restoreMailboxWithId(mContext, mailboxId); EmailContent.Message message = EmailContent.Message.restoreMessageWithId(mContext, messageId); if (account == null || mailbox == null || message == null) { mListeners.loadAttachmentFailed(accountId, messageId, attachmentId, new MessagingException( "Account, mailbox, message or attachment are null"), background); return; } TrafficStats.setThreadStatsTag( TrafficFlags.getAttachmentFlags(mContext, account)); Store remoteStore = Store.getInstance(account, mContext); Folder remoteFolder = remoteStore.getFolder(mailbox.mServerId); remoteFolder.open(OpenMode.READ_WRITE); // 3. Generate a shell message in which to retrieve the attachment, // and a shell BodyPart for the attachment. Then glue them together. Message storeMessage = remoteFolder.createMessage(message.mServerId); MimeBodyPart storePart = new MimeBodyPart(); storePart.setSize((int)attachment.mSize); storePart.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, attachment.mLocation); storePart.setHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\n name=\"%s\"", attachment.mMimeType, attachment.mFileName)); // TODO is this always true for attachments? I think we dropped the // true encoding along the way storePart.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64"); MimeMultipart multipart = new MimeMultipart(); multipart.setSubType("mixed"); multipart.addBodyPart(storePart); storeMessage.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "multipart/mixed"); storeMessage.setBody(multipart); // 4. Now ask for the attachment to be fetched FetchProfile fp = new FetchProfile(); fp.add(storePart); remoteFolder.fetch(new Message[] { storeMessage }, fp, mController.new MessageRetrievalListenerBridge( messageId, attachmentId)); // If we failed to load the attachment, throw an Exception here, so that // AttachmentDownloadService knows that we failed if (storePart.getBody() == null) { throw new MessagingException("Attachment not loaded."); } // 5. Save the downloaded file and update the attachment as necessary LegacyConversions.saveAttachmentBody(mContext, storePart, attachment, accountId); // 6. Report success mListeners.loadAttachmentFinished(accountId, messageId, attachmentId); } catch (MessagingException me) { if (Logging.LOGD) Log.v(Logging.LOG_TAG, "", me); mListeners.loadAttachmentFailed( accountId, messageId, attachmentId, me, background); } catch (IOException ioe) { Log.e(Logging.LOG_TAG, "Error while storing attachment." + ioe.toString()); } }}); } /** * Attempt to send any messages that are sitting in the Outbox. * @param account * @param listener */ public void sendPendingMessages(final Account account, final long sentFolderId, MessagingListener listener) { put("sendPendingMessages", listener, new Runnable() { public void run() { sendPendingMessagesSynchronous(account, sentFolderId); } }); } /** * Attempt to send all messages sitting in the given account's outbox. Optionally, * if the server requires it, the message will be moved to the given sent folder. */ public void sendPendingMessagesSynchronous(final Account account, long sentFolderId) { TrafficStats.setThreadStatsTag(TrafficFlags.getSmtpFlags(mContext, account)); NotificationController nc = NotificationController.getInstance(mContext); // 1. Loop through all messages in the account's outbox long outboxId = Mailbox.findMailboxOfType(mContext, account.mId, Mailbox.TYPE_OUTBOX); if (outboxId == Mailbox.NO_MAILBOX) { return; } ContentResolver resolver = mContext.getContentResolver(); Cursor c = resolver.query(EmailContent.Message.CONTENT_URI, EmailContent.Message.ID_COLUMN_PROJECTION, EmailContent.Message.MAILBOX_KEY + "=?", new String[] { Long.toString(outboxId) }, null); try { // 2. exit early if (c.getCount() <= 0) { return; } // 3. do one-time setup of the Sender & other stuff mListeners.sendPendingMessagesStarted(account.mId, -1); Sender sender = Sender.getInstance(mContext, account); Store remoteStore = Store.getInstance(account, mContext); boolean requireMoveMessageToSentFolder = remoteStore.requireCopyMessageToSentFolder(); ContentValues moveToSentValues = null; if (requireMoveMessageToSentFolder) { moveToSentValues = new ContentValues(); moveToSentValues.put(MessageColumns.MAILBOX_KEY, sentFolderId); } // 4. loop through the available messages and send them while (c.moveToNext()) { long messageId = -1; try { messageId = c.getLong(0); mListeners.sendPendingMessagesStarted(account.mId, messageId); // Don't send messages with unloaded attachments if (Utility.hasUnloadedAttachments(mContext, messageId)) { if (Email.DEBUG) { Log.d(Logging.LOG_TAG, "Can't send #" + messageId + "; unloaded attachments"); } continue; } sender.sendMessage(messageId); } catch (MessagingException me) { // report error for this message, but keep trying others if (me instanceof AuthenticationFailedException) { nc.showLoginFailedNotification(account.mId); } mListeners.sendPendingMessagesFailed(account.mId, messageId, me); continue; } // 5. move to sent, or delete Uri syncedUri = ContentUris.withAppendedId(EmailContent.Message.SYNCED_CONTENT_URI, messageId); if (requireMoveMessageToSentFolder) { // If this is a forwarded message and it has attachments, delete them, as they // duplicate information found elsewhere (on the server). This saves storage. EmailContent.Message msg = EmailContent.Message.restoreMessageWithId(mContext, messageId); if (msg != null && ((msg.mFlags & EmailContent.Message.FLAG_TYPE_FORWARD) != 0)) { AttachmentUtilities.deleteAllAttachmentFiles(mContext, account.mId, messageId); } resolver.update(syncedUri, moveToSentValues, null, null); } else { AttachmentUtilities.deleteAllAttachmentFiles(mContext, account.mId, messageId); Uri uri = ContentUris.withAppendedId(EmailContent.Message.CONTENT_URI, messageId); resolver.delete(uri, null, null); resolver.delete(syncedUri, null, null); } } // 6. report completion/success mListeners.sendPendingMessagesCompleted(account.mId); nc.cancelLoginFailedNotification(account.mId); } catch (MessagingException me) { if (me instanceof AuthenticationFailedException) { nc.showLoginFailedNotification(account.mId); } mListeners.sendPendingMessagesFailed(account.mId, -1, me); } finally { c.close(); } } /** * Checks mail for an account. * This entry point is for use by the mail checking service only, because it * gives slightly different callbacks (so the service doesn't get confused by callbacks * triggered by/for the foreground UI. * * TODO clean up the execution model which is unnecessarily threaded due to legacy code * * @param accountId the account to check * @param listener */ public void checkMail(final long accountId, final long tag, final MessagingListener listener) { mListeners.checkMailStarted(mContext, accountId, tag); // This puts the command on the queue (not synchronous) listFolders(accountId, null); // Put this on the queue as well so it follows listFolders put("checkMail", listener, new Runnable() { public void run() { // send any pending outbound messages. note, there is a slight race condition // here if we somehow don't have a sent folder, but this should never happen // because the call to sendMessage() would have built one previously. long inboxId = -1; Account account = Account.restoreAccountWithId(mContext, accountId); if (account != null) { long sentboxId = Mailbox.findMailboxOfType(mContext, accountId, Mailbox.TYPE_SENT); if (sentboxId != Mailbox.NO_MAILBOX) { sendPendingMessagesSynchronous(account, sentboxId); } // find mailbox # for inbox and sync it. // TODO we already know this in Controller, can we pass it in? inboxId = Mailbox.findMailboxOfType(mContext, accountId, Mailbox.TYPE_INBOX); if (inboxId != Mailbox.NO_MAILBOX) { Mailbox mailbox = Mailbox.restoreMailboxWithId(mContext, inboxId); if (mailbox != null) { synchronizeMailboxSynchronous(account, mailbox); } } } mListeners.checkMailFinished(mContext, accountId, inboxId, tag); } }); } private static class Command { public Runnable runnable; public MessagingListener listener; public String description; @Override public String toString() { return description; } } /** Results of the latest synchronization. */ private static class SyncResults { /** The total # of messages in the folder */ public final int mTotalMessages; /** A list of new message IDs; must not be {@code null} */ public final ArrayList<Long> mAddedMessages; public SyncResults(int totalMessages, ArrayList<Long> addedMessages) { if (addedMessages == null) { throw new IllegalArgumentException("addedMessages must not be null"); } mTotalMessages = totalMessages; mAddedMessages = addedMessages; } } }