/* * Copyright (C) 2012 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.service; import android.app.Service; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.net.TrafficStats; import android.net.Uri; import android.os.IBinder; import android.os.RemoteException; import com.android.email.NotificationController; import com.android.email.mail.Store; import com.android.email.mail.store.Pop3Store; import com.android.email.mail.store.Pop3Store.Pop3Folder; import com.android.email.mail.store.Pop3Store.Pop3Message; import com.android.email.provider.Utilities; import com.android.email2.ui.MailActivityEmail; import com.android.emailcommon.Logging; import com.android.emailcommon.TrafficFlags; import com.android.emailcommon.mail.AuthenticationFailedException; import com.android.emailcommon.mail.Folder.OpenMode; import com.android.emailcommon.mail.MessagingException; 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.Message; 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.EmailServiceStatus; import com.android.emailcommon.service.IEmailServiceCallback; import com.android.emailcommon.utility.AttachmentUtilities; import com.android.mail.providers.UIProvider; import com.android.mail.providers.UIProvider.AttachmentState; import com.android.mail.utils.LogUtils; import org.apache.james.mime4j.EOLConvertingInputStream; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; public class Pop3Service extends Service { private static final String TAG = "Pop3Service"; private static final int DEFAULT_SYNC_COUNT = 100; @Override public int onStartCommand(Intent intent, int flags, int startId) { return Service.START_STICKY; } /** * Create our EmailService implementation here. */ private final EmailServiceStub mBinder = new EmailServiceStub() { @Override public void loadAttachment(final IEmailServiceCallback callback, final long attachmentId, final boolean background) throws RemoteException { Attachment att = Attachment.restoreAttachmentWithId(mContext, attachmentId); if (att == null || att.mUiState != AttachmentState.DOWNLOADING) return; long inboxId = Mailbox.findMailboxOfType(mContext, att.mAccountKey, Mailbox.TYPE_INBOX); if (inboxId == Mailbox.NO_MAILBOX) return; // We load attachments during a sync requestSync(inboxId, true, 0); } }; @Override public IBinder onBind(Intent intent) { mBinder.init(this); return mBinder; } /** * 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 * @param deltaMessageCount the requested change in number of messages to sync. * @return The status code for whether this operation succeeded. * @throws MessagingException */ public static int synchronizeMailboxSynchronous(Context context, final Account account, final Mailbox folder, final int deltaMessageCount) throws MessagingException { TrafficStats.setThreadStatsTag(TrafficFlags.getSyncFlags(context, account)); NotificationController nc = NotificationController.getInstance(context); try { synchronizePop3Mailbox(context, account, folder, deltaMessageCount); // Clear authentication notification for this account nc.cancelLoginFailedNotification(account.mId); } catch (MessagingException e) { if (Logging.LOGD) { LogUtils.v(Logging.LOG_TAG, "synchronizeMailbox", e); } if (e instanceof AuthenticationFailedException) { // Generate authentication notification nc.showLoginFailedNotification(account.mId); } throw e; } // TODO: Rather than use exceptions as logic aobve, return the status and handle it // correctly in caller. return EmailServiceStatus.SUCCESS; } /** * 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_LOADED = 1; private static final int COLUMN_SERVER_ID = 2; private static final String[] PROJECTION = new String[] { EmailContent.RECORD_ID, MessageColumns.FLAG_LOADED, SyncColumns.SERVER_ID }; final long mId; final int mFlagLoaded; final String mServerId; public LocalMessageInfo(Cursor c) { mId = c.getLong(COLUMN_ID); mFlagLoaded = c.getInt(COLUMN_FLAG_LOADED); mServerId = c.getString(COLUMN_SERVER_ID); // Note: mailbox key and account key not needed - they are projected // for the SELECT } } /** * 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 */ static void loadUnsyncedMessages(final Context context, final Account account, Pop3Folder remoteFolder, ArrayList unsyncedMessages, final Mailbox toMailbox) throws MessagingException { if (MailActivityEmail.DEBUG) { LogUtils.d(TAG, "Loading " + unsyncedMessages.size() + " unsynced messages"); } try { int cnt = unsyncedMessages.size(); // They are in most recent to least recent order, process them that way. for (int i = 0; i < cnt; i++) { final Pop3Message message = unsyncedMessages.get(i); remoteFolder.fetchBody(message, Pop3Store.FETCH_BODY_SANE_SUGGESTED_SIZE / 76, null); int flag = EmailContent.Message.FLAG_LOADED_COMPLETE; if (!message.isComplete()) { // TODO: when the message is not complete, this should mark the message as // partial. When that change is made, we need to make sure that: // 1) Partial messages are shown in the conversation list // 2) We are able to download the rest of the message/attachment when the // user requests it. flag = EmailContent.Message.FLAG_LOADED_PARTIAL; } if (MailActivityEmail.DEBUG) { LogUtils.d(TAG, "Message is " + (message.isComplete() ? "" : "NOT ") + "complete"); } // If message is incomplete, create a "fake" attachment Utilities.copyOneMessageToProvider(context, message, account, toMailbox, flag); } } catch (IOException e) { throw new MessagingException(MessagingException.IOERROR); } } private static class FetchCallback implements EOLConvertingInputStream.Callback { private final ContentResolver mResolver; private final Uri mAttachmentUri; private final ContentValues mContentValues = new ContentValues(); FetchCallback(ContentResolver resolver, Uri attachmentUri) { mResolver = resolver; mAttachmentUri = attachmentUri; } @Override public void report(int bytesRead) { mContentValues.put(AttachmentColumns.UI_DOWNLOADED_SIZE, bytesRead); mResolver.update(mAttachmentUri, mContentValues, null, null); } } /** * Synchronizer * * @param account the account to sync * @param mailbox the mailbox to sync * @param deltaMessageCount the requested change to number of messages to sync * @throws MessagingException */ private synchronized static void synchronizePop3Mailbox(final Context context, final Account account, final Mailbox mailbox, final int deltaMessageCount) throws MessagingException { // TODO Break this into smaller pieces ContentResolver resolver = context.getContentResolver(); // We only sync Inbox if (mailbox.mType != Mailbox.TYPE_INBOX) { return; } // Get the message list from EmailProvider and create an index of the uids Cursor localUidCursor = null; HashMap localMessageMap = new HashMap(); try { localUidCursor = resolver.query( EmailContent.Message.CONTENT_URI, LocalMessageInfo.PROJECTION, MessageColumns.MAILBOX_KEY + "=?", new String[] { String.valueOf(mailbox.mId) }, null); while (localUidCursor.moveToNext()) { LocalMessageInfo info = new LocalMessageInfo(localUidCursor); localMessageMap.put(info.mServerId, info); } } finally { if (localUidCursor != null) { localUidCursor.close(); } } // Open the remote folder and create the remote folder if necessary Pop3Store remoteStore = (Pop3Store)Store.getInstance(account, context); // The account might have been deleted if (remoteStore == null) return; Pop3Folder remoteFolder = (Pop3Folder)remoteStore.getFolder(mailbox.mServerId); // Open the remote folder. This pre-loads certain metadata like message // count. remoteFolder.open(OpenMode.READ_WRITE); String[] accountIdArgs = new String[] { Long.toString(account.mId) }; long trashMailboxId = Mailbox.findMailboxOfType(context, account.mId, Mailbox.TYPE_TRASH); Cursor updates = resolver.query( EmailContent.Message.UPDATED_CONTENT_URI, EmailContent.Message.ID_COLUMN_PROJECTION, EmailContent.MessageColumns.ACCOUNT_KEY + "=?", accountIdArgs, null); try { // loop through messages marked as deleted while (updates.moveToNext()) { long id = updates.getLong(Message.ID_COLUMNS_ID_COLUMN); EmailContent.Message currentMsg = EmailContent.Message.restoreMessageWithId(context, id); if (currentMsg.mMailboxKey == trashMailboxId) { // Delete this on the server Pop3Message popMessage = (Pop3Message)remoteFolder.getMessage(currentMsg.mServerId); if (popMessage != null) { remoteFolder.deleteMessage(popMessage); } } // Finally, delete the update Uri uri = ContentUris.withAppendedId(EmailContent.Message.UPDATED_CONTENT_URI, id); context.getContentResolver().delete(uri, null, null); } } finally { updates.close(); } // Get the remote message count. final int remoteMessageCount = remoteFolder.getMessageCount(); // Save the folder message count. mailbox.updateMessageCount(context, remoteMessageCount); // Create a list of messages to download Pop3Message[] remoteMessages = new Pop3Message[0]; final ArrayList unsyncedMessages = new ArrayList(); HashMap remoteUidMap = new HashMap(); if (remoteMessageCount > 0) { /* * Get all messageIds in the mailbox. * We don't necessarily need to sync all of them. */ remoteMessages = remoteFolder.getMessages(remoteMessageCount, remoteMessageCount); LogUtils.d(Logging.LOG_TAG, "remoteMessageCount " + remoteMessageCount); /* * TODO: It would be nicer if the default sync window were time based rather than * count based, but POP3 does not support time based queries, and the UIDL command * does not report timestamps. To handle this, we would need to load a block of * Ids, sync those messages to get the timestamps, and then load more Ids until we * have filled out our window. */ int count = 0; int countNeeded = DEFAULT_SYNC_COUNT; for (final Pop3Message message : remoteMessages) { final String uid = message.getUid(); remoteUidMap.put(uid, message); } /* * Figure out which messages we need to sync. Start at the most recent ones, and keep * going until we hit one of four end conditions: * 1. We currently have zero local messages. In this case, we will sync the most recent * DEFAULT_SYNC_COUNT, then stop. * 2. We have some local messages, and after encountering them, we find some older * messages that do not yet exist locally. In this case, we will load whichever came * before the ones we already had locally, and also deltaMessageCount additional * older messages. * 3. We have some local messages, but after examining the most recent * DEFAULT_SYNC_COUNT remote messages, we still have not encountered any that exist * locally. In this case, we'll stop adding new messages to sync, leaving a gap between * the ones we've just loaded and the ones we already had. * 4. We examine all of the remote messages before running into any of our count * limitations. */ for (final Pop3Message message : remoteMessages) { final String uid = message.getUid(); final LocalMessageInfo localMessage = localMessageMap.get(uid); if (localMessage == null) { count++; } else { // We have found a message that already exists locally. We may or may not // need to keep looking, depending on what deltaMessageCount is. LogUtils.d(Logging.LOG_TAG, "found a local message, need " + deltaMessageCount + " more remote messages"); countNeeded = deltaMessageCount; count = 0; } // localMessage == null -> message has never been created (not even headers) // mFlagLoaded != FLAG_LOADED_COMPLETE -> message failed to sync completely if (localMessage == null || (localMessage.mFlagLoaded != EmailContent.Message.FLAG_LOADED_COMPLETE && localMessage.mFlagLoaded != Message.FLAG_LOADED_PARTIAL)) { LogUtils.d(Logging.LOG_TAG, "need to sync " + uid); unsyncedMessages.add(message); } else { LogUtils.d(Logging.LOG_TAG, "don't need to sync " + uid); } if (count >= countNeeded) { LogUtils.d(Logging.LOG_TAG, "loaded " + count + " messages, stopping"); break; } } } else { if (MailActivityEmail.DEBUG) { LogUtils.d(TAG, "*** Message count is zero??"); } remoteFolder.close(false); return; } // Get "attachments" to be loaded Cursor c = resolver.query(Attachment.CONTENT_URI, Attachment.CONTENT_PROJECTION, AttachmentColumns.ACCOUNT_KEY + "=? AND " + AttachmentColumns.UI_STATE + "=" + AttachmentState.DOWNLOADING, new String[] {Long.toString(account.mId)}, null); try { final ContentValues values = new ContentValues(); while (c.moveToNext()) { values.put(AttachmentColumns.UI_STATE, UIProvider.AttachmentState.SAVED); Attachment att = new Attachment(); att.restore(c); Message msg = Message.restoreMessageWithId(context, att.mMessageKey); if (msg == null || (msg.mFlagLoaded == Message.FLAG_LOADED_COMPLETE)) { values.put(AttachmentColumns.UI_DOWNLOADED_SIZE, att.mSize); resolver.update(ContentUris.withAppendedId(Attachment.CONTENT_URI, att.mId), values, null, null); continue; } else { String uid = msg.mServerId; Pop3Message popMessage = remoteUidMap.get(uid); if (popMessage != null) { Uri attUri = ContentUris.withAppendedId(Attachment.CONTENT_URI, att.mId); try { remoteFolder.fetchBody(popMessage, -1, new FetchCallback(resolver, attUri)); } catch (IOException e) { throw new MessagingException(MessagingException.IOERROR); } // Say we've downloaded the attachment values.put(AttachmentColumns.UI_STATE, AttachmentState.SAVED); resolver.update(attUri, values, null, null); int flag = EmailContent.Message.FLAG_LOADED_COMPLETE; if (!popMessage.isComplete()) { LogUtils.e(TAG, "How is this possible?"); } Utilities.copyOneMessageToProvider( context, popMessage, account, mailbox, flag); // Get rid of the temporary attachment resolver.delete(attUri, null, null); } else { // TODO: Should we mark this attachment as failed so we don't // keep trying to download? LogUtils.e(TAG, "Could not find message for attachment " + uid); } } } } finally { c.close(); } // Remove any messages that are in the local store but no longer on the remote store. HashSet localUidsToDelete = new HashSet(localMessageMap.keySet()); localUidsToDelete.removeAll(remoteUidMap.keySet()); for (String uidToDelete : localUidsToDelete) { LogUtils.d(Logging.LOG_TAG, "need to delete " + uidToDelete); LocalMessageInfo infoToDelete = localMessageMap.get(uidToDelete); // Delete associated data (attachment files) // Attachment & Body records are auto-deleted when we delete the // Message record AttachmentUtilities.deleteAllAttachmentFiles(context, 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 updateRowToDelete = ContentUris.withAppendedId( EmailContent.Message.UPDATED_CONTENT_URI, infoToDelete.mId); resolver.delete(updateRowToDelete, null, null); Uri deleteRowToDelete = ContentUris.withAppendedId( EmailContent.Message.DELETED_CONTENT_URI, infoToDelete.mId); resolver.delete(deleteRowToDelete, null, null); } LogUtils.d(TAG, "loadUnsynchedMessages " + unsyncedMessages.size()); // Load messages we need to sync loadUnsyncedMessages(context, account, remoteFolder, unsyncedMessages, mailbox); // Clean up and report results remoteFolder.close(false); } }