1/*
2 * Copyright (C) 2012 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email.service;
18
19import android.app.Service;
20import android.content.ContentResolver;
21import android.content.ContentUris;
22import android.content.ContentValues;
23import android.content.Context;
24import android.content.Intent;
25import android.database.Cursor;
26import android.net.TrafficStats;
27import android.net.Uri;
28import android.os.IBinder;
29import android.os.RemoteException;
30
31import com.android.email.NotificationController;
32import com.android.email.mail.Store;
33import com.android.email.mail.store.Pop3Store;
34import com.android.email.mail.store.Pop3Store.Pop3Folder;
35import com.android.email.mail.store.Pop3Store.Pop3Message;
36import com.android.email.provider.Utilities;
37import com.android.email2.ui.MailActivityEmail;
38import com.android.emailcommon.Logging;
39import com.android.emailcommon.TrafficFlags;
40import com.android.emailcommon.mail.AuthenticationFailedException;
41import com.android.emailcommon.mail.Folder.OpenMode;
42import com.android.emailcommon.mail.MessagingException;
43import com.android.emailcommon.provider.Account;
44import com.android.emailcommon.provider.EmailContent;
45import com.android.emailcommon.provider.EmailContent.Attachment;
46import com.android.emailcommon.provider.EmailContent.AttachmentColumns;
47import com.android.emailcommon.provider.EmailContent.Message;
48import com.android.emailcommon.provider.EmailContent.MessageColumns;
49import com.android.emailcommon.provider.EmailContent.SyncColumns;
50import com.android.emailcommon.provider.Mailbox;
51import com.android.emailcommon.service.EmailServiceStatus;
52import com.android.emailcommon.service.IEmailServiceCallback;
53import com.android.emailcommon.utility.AttachmentUtilities;
54import com.android.mail.providers.UIProvider;
55import com.android.mail.providers.UIProvider.AttachmentState;
56import com.android.mail.utils.LogUtils;
57
58import org.apache.james.mime4j.EOLConvertingInputStream;
59
60import java.io.IOException;
61import java.util.ArrayList;
62import java.util.HashMap;
63import java.util.HashSet;
64
65public class Pop3Service extends Service {
66    private static final String TAG = "Pop3Service";
67    private static final int DEFAULT_SYNC_COUNT = 100;
68
69    @Override
70    public int onStartCommand(Intent intent, int flags, int startId) {
71        return Service.START_STICKY;
72    }
73
74    /**
75     * Create our EmailService implementation here.
76     */
77    private final EmailServiceStub mBinder = new EmailServiceStub() {
78        @Override
79        public void loadAttachment(final IEmailServiceCallback callback, final long accountId,
80                final long attachmentId, final boolean background) throws RemoteException {
81            Attachment att = Attachment.restoreAttachmentWithId(mContext, attachmentId);
82            if (att == null || att.mUiState != AttachmentState.DOWNLOADING) return;
83            long inboxId = Mailbox.findMailboxOfType(mContext, att.mAccountKey, Mailbox.TYPE_INBOX);
84            if (inboxId == Mailbox.NO_MAILBOX) return;
85            // We load attachments during a sync
86            requestSync(inboxId, true, 0);
87        }
88    };
89
90    @Override
91    public IBinder onBind(Intent intent) {
92        mBinder.init(this);
93        return mBinder;
94    }
95
96    /**
97     * Start foreground synchronization of the specified folder. This is called
98     * by synchronizeMailbox or checkMail. TODO this should use ID's instead of
99     * fully-restored objects
100     *
101     * @param account
102     * @param folder
103     * @param deltaMessageCount the requested change in number of messages to sync.
104     * @return The status code for whether this operation succeeded.
105     * @throws MessagingException
106     */
107    public static int synchronizeMailboxSynchronous(Context context, final Account account,
108            final Mailbox folder, final int deltaMessageCount) throws MessagingException {
109        TrafficStats.setThreadStatsTag(TrafficFlags.getSyncFlags(context, account));
110        NotificationController nc = NotificationController.getInstance(context);
111        try {
112            synchronizePop3Mailbox(context, account, folder, deltaMessageCount);
113            // Clear authentication notification for this account
114            nc.cancelLoginFailedNotification(account.mId);
115        } catch (MessagingException e) {
116            if (Logging.LOGD) {
117                LogUtils.v(Logging.LOG_TAG, "synchronizeMailbox", e);
118            }
119            if (e instanceof AuthenticationFailedException) {
120                // Generate authentication notification
121                nc.showLoginFailedNotification(account.mId);
122            }
123            throw e;
124        }
125        // TODO: Rather than use exceptions as logic aobve, return the status and handle it
126        // correctly in caller.
127        return EmailServiceStatus.SUCCESS;
128    }
129
130    /**
131     * Lightweight record for the first pass of message sync, where I'm just
132     * seeing if the local message requires sync. Later (for messages that need
133     * syncing) we'll do a full readout from the DB.
134     */
135    private static class LocalMessageInfo {
136        private static final int COLUMN_ID = 0;
137        private static final int COLUMN_FLAG_LOADED = 1;
138        private static final int COLUMN_SERVER_ID = 2;
139        private static final String[] PROJECTION = new String[] {
140                EmailContent.RECORD_ID, MessageColumns.FLAG_LOADED, SyncColumns.SERVER_ID
141        };
142
143        final long mId;
144        final int mFlagLoaded;
145        final String mServerId;
146
147        public LocalMessageInfo(Cursor c) {
148            mId = c.getLong(COLUMN_ID);
149            mFlagLoaded = c.getInt(COLUMN_FLAG_LOADED);
150            mServerId = c.getString(COLUMN_SERVER_ID);
151            // Note: mailbox key and account key not needed - they are projected
152            // for the SELECT
153        }
154    }
155
156    /**
157     * Load the structure and body of messages not yet synced
158     *
159     * @param account the account we're syncing
160     * @param remoteFolder the (open) Folder we're working on
161     * @param unsyncedMessages an array of Message's we've got headers for
162     * @param toMailbox the destination mailbox we're syncing
163     * @throws MessagingException
164     */
165    static void loadUnsyncedMessages(final Context context, final Account account,
166            Pop3Folder remoteFolder, ArrayList<Pop3Message> unsyncedMessages,
167            final Mailbox toMailbox) throws MessagingException {
168
169        if (MailActivityEmail.DEBUG) {
170            LogUtils.d(TAG, "Loading " + unsyncedMessages.size() + " unsynced messages");
171        }
172
173        try {
174            int cnt = unsyncedMessages.size();
175            // They are in most recent to least recent order, process them that way.
176            for (int i = 0; i < cnt; i++) {
177                final Pop3Message message = unsyncedMessages.get(i);
178                remoteFolder.fetchBody(message, Pop3Store.FETCH_BODY_SANE_SUGGESTED_SIZE / 76,
179                        null);
180                int flag = EmailContent.Message.FLAG_LOADED_COMPLETE;
181                if (!message.isComplete()) {
182                    // TODO: when the message is not complete, this should mark the message as
183                    // partial.  When that change is made, we need to make sure that:
184                    // 1) Partial messages are shown in the conversation list
185                    // 2) We are able to download the rest of the message/attachment when the
186                    //    user requests it.
187                     flag = EmailContent.Message.FLAG_LOADED_PARTIAL;
188                }
189                if (MailActivityEmail.DEBUG) {
190                    LogUtils.d(TAG, "Message is " + (message.isComplete() ? "" : "NOT ")
191                            + "complete");
192                }
193                // If message is incomplete, create a "fake" attachment
194                Utilities.copyOneMessageToProvider(context, message, account, toMailbox, flag);
195            }
196        } catch (IOException e) {
197            throw new MessagingException(MessagingException.IOERROR);
198        }
199    }
200
201    private static class FetchCallback implements EOLConvertingInputStream.Callback {
202        private final ContentResolver mResolver;
203        private final Uri mAttachmentUri;
204        private final ContentValues mContentValues = new ContentValues();
205
206        FetchCallback(ContentResolver resolver, Uri attachmentUri) {
207            mResolver = resolver;
208            mAttachmentUri = attachmentUri;
209        }
210
211        @Override
212        public void report(int bytesRead) {
213            mContentValues.put(AttachmentColumns.UI_DOWNLOADED_SIZE, bytesRead);
214            mResolver.update(mAttachmentUri, mContentValues, null, null);
215        }
216    }
217
218    /**
219     * Synchronizer
220     *
221     * @param account the account to sync
222     * @param mailbox the mailbox to sync
223     * @param deltaMessageCount the requested change to number of messages to sync
224     * @throws MessagingException
225     */
226    private synchronized static void synchronizePop3Mailbox(final Context context, final Account account,
227            final Mailbox mailbox, final int deltaMessageCount) throws MessagingException {
228        // TODO Break this into smaller pieces
229        ContentResolver resolver = context.getContentResolver();
230
231        // We only sync Inbox
232        if (mailbox.mType != Mailbox.TYPE_INBOX) {
233            return;
234        }
235
236        // Get the message list from EmailProvider and create an index of the uids
237
238        Cursor localUidCursor = null;
239        HashMap<String, LocalMessageInfo> localMessageMap = new HashMap<String, LocalMessageInfo>();
240
241        try {
242            localUidCursor = resolver.query(
243                    EmailContent.Message.CONTENT_URI,
244                    LocalMessageInfo.PROJECTION,
245                    MessageColumns.MAILBOX_KEY + "=?",
246                    new String[] {
247                            String.valueOf(mailbox.mId)
248                    },
249                    null);
250            while (localUidCursor.moveToNext()) {
251                LocalMessageInfo info = new LocalMessageInfo(localUidCursor);
252                localMessageMap.put(info.mServerId, info);
253            }
254        } finally {
255            if (localUidCursor != null) {
256                localUidCursor.close();
257            }
258        }
259
260        // Open the remote folder and create the remote folder if necessary
261
262        Pop3Store remoteStore = (Pop3Store)Store.getInstance(account, context);
263        // The account might have been deleted
264        if (remoteStore == null)
265            return;
266        Pop3Folder remoteFolder = (Pop3Folder)remoteStore.getFolder(mailbox.mServerId);
267
268        // Open the remote folder. This pre-loads certain metadata like message
269        // count.
270        remoteFolder.open(OpenMode.READ_WRITE);
271
272        String[] accountIdArgs = new String[] { Long.toString(account.mId) };
273        long trashMailboxId = Mailbox.findMailboxOfType(context, account.mId, Mailbox.TYPE_TRASH);
274        Cursor updates = resolver.query(
275                EmailContent.Message.UPDATED_CONTENT_URI,
276                EmailContent.Message.ID_COLUMN_PROJECTION,
277                EmailContent.MessageColumns.ACCOUNT_KEY + "=?", accountIdArgs,
278                null);
279        try {
280            // loop through messages marked as deleted
281            while (updates.moveToNext()) {
282                long id = updates.getLong(Message.ID_COLUMNS_ID_COLUMN);
283                EmailContent.Message currentMsg =
284                        EmailContent.Message.restoreMessageWithId(context, id);
285                if (currentMsg.mMailboxKey == trashMailboxId) {
286                    // Delete this on the server
287                    Pop3Message popMessage =
288                            (Pop3Message)remoteFolder.getMessage(currentMsg.mServerId);
289                    if (popMessage != null) {
290                        remoteFolder.deleteMessage(popMessage);
291                    }
292                }
293                // Finally, delete the update
294                Uri uri = ContentUris.withAppendedId(EmailContent.Message.UPDATED_CONTENT_URI, id);
295                context.getContentResolver().delete(uri, null, null);
296            }
297        } finally {
298            updates.close();
299        }
300
301        // Get the remote message count.
302        final int remoteMessageCount = remoteFolder.getMessageCount();
303
304        // Save the folder message count.
305        mailbox.updateMessageCount(context, remoteMessageCount);
306
307        // Create a list of messages to download
308        Pop3Message[] remoteMessages = new Pop3Message[0];
309        final ArrayList<Pop3Message> unsyncedMessages = new ArrayList<Pop3Message>();
310        HashMap<String, Pop3Message> remoteUidMap = new HashMap<String, Pop3Message>();
311
312        if (remoteMessageCount > 0) {
313            /*
314             * Get all messageIds in the mailbox.
315             * We don't necessarily need to sync all of them.
316             */
317            remoteMessages = remoteFolder.getMessages(remoteMessageCount, remoteMessageCount);
318            LogUtils.d(Logging.LOG_TAG, "remoteMessageCount " + remoteMessageCount);
319
320            /*
321             * TODO: It would be nicer if the default sync window were time based rather than
322             * count based, but POP3 does not support time based queries, and the UIDL command
323             * does not report timestamps. To handle this, we would need to load a block of
324             * Ids, sync those messages to get the timestamps, and then load more Ids until we
325             * have filled out our window.
326             */
327            int count = 0;
328            int countNeeded = DEFAULT_SYNC_COUNT;
329            for (final Pop3Message message : remoteMessages) {
330                final String uid = message.getUid();
331                remoteUidMap.put(uid, message);
332            }
333
334            /*
335             * Figure out which messages we need to sync. Start at the most recent ones, and keep
336             * going until we hit one of four end conditions:
337             * 1. We currently have zero local messages. In this case, we will sync the most recent
338             * DEFAULT_SYNC_COUNT, then stop.
339             * 2. We have some local messages, and after encountering them, we find some older
340             * messages that do not yet exist locally. In this case, we will load whichever came
341             * before the ones we already had locally, and also deltaMessageCount additional
342             * older messages.
343             * 3. We have some local messages, but after examining the most recent
344             * DEFAULT_SYNC_COUNT remote messages, we still have not encountered any that exist
345             * locally. In this case, we'll stop adding new messages to sync, leaving a gap between
346             * the ones we've just loaded and the ones we already had.
347             * 4. We examine all of the remote messages before running into any of our count
348             * limitations.
349             */
350            for (final Pop3Message message : remoteMessages) {
351                final String uid = message.getUid();
352                final LocalMessageInfo localMessage = localMessageMap.get(uid);
353                if (localMessage == null) {
354                    count++;
355                } else {
356                    // We have found a message that already exists locally. We may or may not
357                    // need to keep looking, depending on what deltaMessageCount is.
358                    LogUtils.d(Logging.LOG_TAG, "found a local message, need " +
359                            deltaMessageCount + " more remote messages");
360                    countNeeded = deltaMessageCount;
361                    count = 0;
362                }
363
364                // localMessage == null -> message has never been created (not even headers)
365                // mFlagLoaded != FLAG_LOADED_COMPLETE -> message failed to sync completely
366                if (localMessage == null ||
367                        (localMessage.mFlagLoaded != EmailContent.Message.FLAG_LOADED_COMPLETE &&
368                                localMessage.mFlagLoaded != Message.FLAG_LOADED_PARTIAL)) {
369                    LogUtils.d(Logging.LOG_TAG, "need to sync " + uid);
370                    unsyncedMessages.add(message);
371                } else {
372                    LogUtils.d(Logging.LOG_TAG, "don't need to sync " + uid);
373                }
374
375                if (count >= countNeeded) {
376                    LogUtils.d(Logging.LOG_TAG, "loaded " + count + " messages, stopping");
377                    break;
378                }
379            }
380        } else {
381            if (MailActivityEmail.DEBUG) {
382                LogUtils.d(TAG, "*** Message count is zero??");
383            }
384            remoteFolder.close(false);
385            return;
386        }
387
388        // Get "attachments" to be loaded
389        Cursor c = resolver.query(Attachment.CONTENT_URI, Attachment.CONTENT_PROJECTION,
390                AttachmentColumns.ACCOUNT_KEY + "=? AND " +
391                        AttachmentColumns.UI_STATE + "=" + AttachmentState.DOWNLOADING,
392                new String[] {Long.toString(account.mId)}, null);
393        try {
394            final ContentValues values = new ContentValues();
395            while (c.moveToNext()) {
396                values.put(AttachmentColumns.UI_STATE, UIProvider.AttachmentState.SAVED);
397                Attachment att = new Attachment();
398                att.restore(c);
399                Message msg = Message.restoreMessageWithId(context, att.mMessageKey);
400                if (msg == null || (msg.mFlagLoaded == Message.FLAG_LOADED_COMPLETE)) {
401                    values.put(AttachmentColumns.UI_DOWNLOADED_SIZE, att.mSize);
402                    resolver.update(ContentUris.withAppendedId(Attachment.CONTENT_URI, att.mId),
403                            values, null, null);
404                    continue;
405                } else {
406                    String uid = msg.mServerId;
407                    Pop3Message popMessage = remoteUidMap.get(uid);
408                    if (popMessage != null) {
409                        Uri attUri = ContentUris.withAppendedId(Attachment.CONTENT_URI, att.mId);
410                        try {
411                            remoteFolder.fetchBody(popMessage, -1,
412                                    new FetchCallback(resolver, attUri));
413                        } catch (IOException e) {
414                            throw new MessagingException(MessagingException.IOERROR);
415                        }
416
417                        // Say we've downloaded the attachment
418                        values.put(AttachmentColumns.UI_STATE, AttachmentState.SAVED);
419                        resolver.update(attUri, values, null, null);
420
421                        int flag = EmailContent.Message.FLAG_LOADED_COMPLETE;
422                        if (!popMessage.isComplete()) {
423                            LogUtils.e(TAG, "How is this possible?");
424                        }
425                        Utilities.copyOneMessageToProvider(
426                                context, popMessage, account, mailbox, flag);
427                        // Get rid of the temporary attachment
428                        resolver.delete(attUri, null, null);
429
430                    } else {
431                        // TODO: Should we mark this attachment as failed so we don't
432                        // keep trying to download?
433                        LogUtils.e(TAG, "Could not find message for attachment " + uid);
434                    }
435                }
436            }
437        } finally {
438            c.close();
439        }
440
441        // Remove any messages that are in the local store but no longer on the remote store.
442        HashSet<String> localUidsToDelete = new HashSet<String>(localMessageMap.keySet());
443        localUidsToDelete.removeAll(remoteUidMap.keySet());
444        for (String uidToDelete : localUidsToDelete) {
445            LogUtils.d(Logging.LOG_TAG, "need to delete " + uidToDelete);
446            LocalMessageInfo infoToDelete = localMessageMap.get(uidToDelete);
447
448            // Delete associated data (attachment files)
449            // Attachment & Body records are auto-deleted when we delete the
450            // Message record
451            AttachmentUtilities.deleteAllAttachmentFiles(context, account.mId,
452                    infoToDelete.mId);
453
454            // Delete the message itself
455            Uri uriToDelete = ContentUris.withAppendedId(
456                    EmailContent.Message.CONTENT_URI, infoToDelete.mId);
457            resolver.delete(uriToDelete, null, null);
458
459            // Delete extra rows (e.g. synced or deleted)
460            Uri updateRowToDelete = ContentUris.withAppendedId(
461                    EmailContent.Message.UPDATED_CONTENT_URI, infoToDelete.mId);
462            resolver.delete(updateRowToDelete, null, null);
463            Uri deleteRowToDelete = ContentUris.withAppendedId(
464                    EmailContent.Message.DELETED_CONTENT_URI, infoToDelete.mId);
465            resolver.delete(deleteRowToDelete, null, null);
466        }
467
468        LogUtils.d(TAG, "loadUnsynchedMessages " + unsyncedMessages.size());
469        // Load messages we need to sync
470        loadUnsyncedMessages(context, account, remoteFolder, unsyncedMessages, mailbox);
471
472        // Clean up and report results
473        remoteFolder.close(false);
474    }
475}
476