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