AttachmentDownloadService.java revision 899c5b866192a4c4a12413446d10e5d98dbf94fa
1/*
2 * Copyright (C) 2010 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 com.android.email.Controller.ControllerService;
20import com.android.email.Email;
21import com.android.email.ExchangeUtils.NullEmailService;
22import com.android.email.NotificationController;
23import com.android.email.R;
24import com.android.email.Utility;
25import com.android.email.activity.Welcome;
26import com.android.email.provider.AttachmentProvider;
27import com.android.email.provider.EmailContent;
28import com.android.email.provider.EmailContent.Account;
29import com.android.email.provider.EmailContent.Attachment;
30import com.android.email.provider.EmailContent.Message;
31import com.android.exchange.ExchangeService;
32
33import android.app.Notification;
34import android.app.NotificationManager;
35import android.app.PendingIntent;
36import android.app.Service;
37import android.content.ContentValues;
38import android.content.Context;
39import android.content.Intent;
40import android.database.Cursor;
41import android.os.IBinder;
42import android.os.RemoteException;
43import android.text.format.DateUtils;
44import android.util.Log;
45import android.widget.RemoteViews;
46
47import java.io.File;
48import java.util.Comparator;
49import java.util.HashMap;
50import java.util.Iterator;
51import java.util.TreeSet;
52
53public class AttachmentDownloadService extends Service implements Runnable {
54    public static final String TAG = "AttachmentService";
55
56    // Our idle time, waiting for notifications; this is something of a failsafe
57    private static final int PROCESS_QUEUE_WAIT_TIME = 30 * ((int)DateUtils.MINUTE_IN_MILLIS);
58
59    private static final int PRIORITY_NONE = -1;
60    @SuppressWarnings("unused")
61    // Low priority will be used for opportunistic downloads
62    private static final int PRIORITY_LOW = 0;
63    // Normal priority is for forwarded downloads in outgoing mail
64    private static final int PRIORITY_NORMAL = 1;
65    // High priority is for user requests
66    private static final int PRIORITY_HIGH = 2;
67
68    // We can try various values here; I think 2 is completely reasonable as a first pass
69    private static final int MAX_SIMULTANEOUS_DOWNLOADS = 2;
70    // Limit on the number of simultaneous downloads per account
71    // Note that a limit of 1 is currently enforced by both Services (MailService and Controller)
72    private static final int MAX_SIMULTANEOUS_DOWNLOADS_PER_ACCOUNT = 1;
73
74    /*package*/ static AttachmentDownloadService sRunningService = null;
75
76    /*package*/ Context mContext;
77    /*package*/ final DownloadSet mDownloadSet = new DownloadSet(new DownloadComparator());
78    private final HashMap<Long, Class<? extends Service>> mAccountServiceMap =
79        new HashMap<Long, Class<? extends Service>>();
80    private final ServiceCallback mServiceCallback = new ServiceCallback();
81    private final Object mLock = new Object();
82    private volatile boolean mStop = false;
83
84    public static class DownloadRequest {
85        final int priority;
86        final long time;
87        final long attachmentId;
88        final long messageId;
89        final long accountId;
90        boolean inProgress = false;
91
92        private DownloadRequest(Context context, Attachment attachment) {
93            attachmentId = attachment.mId;
94            Message msg = Message.restoreMessageWithId(context, attachment.mMessageKey);
95            if (msg != null) {
96                accountId = msg.mAccountKey;
97                messageId = msg.mId;
98            } else {
99                accountId = messageId = -1;
100            }
101            priority = getPriority(attachment);
102            time = System.currentTimeMillis();
103        }
104
105        @Override
106        public int hashCode() {
107            return (int)attachmentId;
108        }
109
110        /**
111         * Two download requests are equals if their attachment id's are equals
112         */
113        @Override
114        public boolean equals(Object object) {
115            if (!(object instanceof DownloadRequest)) return false;
116            DownloadRequest req = (DownloadRequest)object;
117            return req.attachmentId == attachmentId;
118        }
119    }
120
121    /**
122     * Comparator class for the download set; we first compare by priority.  Requests with equal
123     * priority are compared by the time the request was created (older requests come first)
124     */
125    /*protected*/ static class DownloadComparator implements Comparator<DownloadRequest> {
126        @Override
127        public int compare(DownloadRequest req1, DownloadRequest req2) {
128            int res;
129            if (req1.priority != req2.priority) {
130                res = (req1.priority < req2.priority) ? -1 : 1;
131            } else {
132                if (req1.time == req2.time) {
133                    res = 0;
134                } else {
135                    res = (req1.time > req2.time) ? -1 : 1;
136                }
137            }
138            //Log.d(TAG, "Compare " + req1.attachmentId + " to " + req2.attachmentId + " = " + res);
139            return res;
140        }
141    }
142
143    /**
144     * The DownloadSet is a TreeSet sorted by priority class (e.g. low, high, etc.) and the
145     * time of the request.  Higher priority requests
146     * are always processed first; among equals, the oldest request is processed first.  The
147     * priority key represents this ordering.  Note: All methods that change the attachment map are
148     * synchronized on the map itself
149     */
150    /*package*/ class DownloadSet extends TreeSet<DownloadRequest> {
151        private static final long serialVersionUID = 1L;
152
153        /*package*/ DownloadSet(Comparator<? super DownloadRequest> comparator) {
154            super(comparator);
155        }
156
157        /**
158         * Maps attachment id to DownloadRequest
159         */
160        /*package*/ final HashMap<Long, DownloadRequest> mDownloadsInProgress =
161            new HashMap<Long, DownloadRequest>();
162
163        /**
164         * onChange is called by the AttachmentReceiver upon receipt of a valid notification from
165         * EmailProvider that an attachment has been inserted or modified.  It's not strictly
166         * necessary that we detect a deleted attachment, as the code always checks for the
167         * existence of an attachment before acting on it.
168         */
169        public synchronized void onChange(Attachment att) {
170            DownloadRequest req = findDownloadRequest(att.mId);
171            long priority = getPriority(att);
172            if (priority == PRIORITY_NONE) {
173                if (Email.DEBUG) {
174                    Log.d(TAG, "== Attachment changed: " + att.mId);
175                }
176                // In this case, there is no download priority for this attachment
177                if (req != null) {
178                    // If it exists in the map, remove it
179                    // NOTE: We don't yet support deleting downloads in progress
180                    if (Email.DEBUG) {
181                        Log.d(TAG, "== Attachment " + att.mId + " was in queue, removing");
182                    }
183                    remove(req);
184                }
185            } else {
186                // Ignore changes that occur during download
187                if (mDownloadsInProgress.containsKey(att.mId)) return;
188                // If this is new, add the request to the queue
189                if (req == null) {
190                    req = new DownloadRequest(mContext, att);
191                    add(req);
192                }
193                // If the request already existed, we'll update the priority (so that the time is
194                // up-to-date); otherwise, we create a new request
195                if (Email.DEBUG) {
196                    Log.d(TAG, "== Download queued for attachment " + att.mId + ", class " +
197                            req.priority + ", priority time " + req.time);
198                }
199            }
200            // Process the queue if we're in a wait
201            kick();
202        }
203
204        /**
205         * Find a queued DownloadRequest, given the attachment's id
206         * @param id the id of the attachment
207         * @return the DownloadRequest for that attachment (or null, if none)
208         */
209        /*package*/ synchronized DownloadRequest findDownloadRequest(long id) {
210            Iterator<DownloadRequest> iterator = iterator();
211            while(iterator.hasNext()) {
212                DownloadRequest req = iterator.next();
213                if (req.attachmentId == id) {
214                    return req;
215                }
216            }
217            return null;
218        }
219
220        /**
221         * Run through the AttachmentMap and find DownloadRequests that can be executed, enforcing
222         * the limit on maximum downloads
223         */
224        /*package*/ synchronized void processQueue() {
225            if (Email.DEBUG) {
226                Log.d(TAG, "== Checking attachment queue, " + mDownloadSet.size() + " entries");
227            }
228            Iterator<DownloadRequest> iterator = mDownloadSet.descendingIterator();
229            // First, start up any required downloads, in priority order
230            while (iterator.hasNext() &&
231                    (mDownloadsInProgress.size() < MAX_SIMULTANEOUS_DOWNLOADS)) {
232                DownloadRequest req = iterator.next();
233                if (!req.inProgress) {
234                    mDownloadSet.tryStartDownload(req);
235                }
236            }
237            // Then, try opportunistic download of appropriate attachments
238            int backgroundDownloads = MAX_SIMULTANEOUS_DOWNLOADS - mDownloadsInProgress.size();
239            if (backgroundDownloads > 0) {
240                // TODO Code for background downloads here
241                if (Email.DEBUG) {
242                    Log.d(TAG, "== We'd look for up to " + backgroundDownloads +
243                            " background download(s) now...");
244                }
245            }
246        }
247
248        /**
249         * Count the number of running downloads in progress for this account
250         * @param accountId the id of the account
251         * @return the count of running downloads
252         */
253        /*package*/ synchronized int downloadsForAccount(long accountId) {
254            int count = 0;
255            for (DownloadRequest req: mDownloadsInProgress.values()) {
256                if (req.accountId == accountId) {
257                    count++;
258                }
259            }
260            return count;
261        }
262
263        /**
264         * Attempt to execute the DownloadRequest, enforcing the maximum downloads per account
265         * parameter
266         * @param req the DownloadRequest
267         * @return whether or not the download was started
268         */
269        /*package*/ synchronized boolean tryStartDownload(DownloadRequest req) {
270            // Enforce per-account limit
271            if (downloadsForAccount(req.accountId) >= MAX_SIMULTANEOUS_DOWNLOADS_PER_ACCOUNT) {
272                if (Email.DEBUG) {
273                    Log.d(TAG, "== Skip #" + req.attachmentId + "; maxed for acct #" +
274                            req.accountId);
275                }
276                return false;
277            }
278            Class<? extends Service> serviceClass = getServiceClassForAccount(req.accountId);
279            if (serviceClass == null) return false;
280            EmailServiceProxy proxy =
281                new EmailServiceProxy(mContext, serviceClass, mServiceCallback);
282            try {
283                File file = AttachmentProvider.getAttachmentFilename(mContext, req.accountId,
284                        req.attachmentId);
285                if (Email.DEBUG) {
286                    Log.d(TAG, ">> Starting download for attachment #" + req.attachmentId);
287                }
288                // Don't actually run the load if this is the NullEmailService (used in unit tests)
289                if (!serviceClass.equals(NullEmailService.class)) {
290                    proxy.loadAttachment(req.attachmentId, file.getAbsolutePath(),
291                            AttachmentProvider.getAttachmentUri(req.accountId, req.attachmentId)
292                            .toString());
293                }
294                mDownloadsInProgress.put(req.attachmentId, req);
295                req.inProgress = true;
296            } catch (RemoteException e) {
297                // TODO: Consider whether we need to do more in this case...
298                // For now, fix up our data to reflect the failure
299                mDownloadsInProgress.remove(req.attachmentId);
300                req.inProgress = false;
301            }
302            return true;
303        }
304
305        /**
306         * Called when a download is finished; we get notified of this via our EmailServiceCallback
307         * @param attachmentId the id of the attachment whose download is finished
308         * @param statusCode the EmailServiceStatus code returned by the Service
309         */
310        /*package*/ synchronized void endDownload(long attachmentId, int statusCode) {
311            // Say we're no longer downloading this
312            mDownloadsInProgress.remove(attachmentId);
313            DownloadRequest req = mDownloadSet.findDownloadRequest(attachmentId);
314            if (statusCode == EmailServiceStatus.CONNECTION_ERROR) {
315                // If this needs to be retried, just process the queue again
316                if (Email.DEBUG) {
317                    Log.d(TAG, "== The download for attachment #" + attachmentId +
318                            " will be retried");
319                }
320                if (req != null) {
321                    req.inProgress = false;
322                }
323                kick();
324                return;
325            }
326            // Remove the request from the queue
327            remove(req);
328            if (Email.DEBUG) {
329                long secs = (System.currentTimeMillis() - req.time) / 1000;
330                String status = (statusCode == EmailServiceStatus.SUCCESS) ? "Success" :
331                    "Error " + statusCode;
332                Log.d(TAG, "<< Download finished for attachment #" + attachmentId + "; " + secs +
333                           " seconds from request, status: " + status);
334            }
335            Attachment attachment = Attachment.restoreAttachmentWithId(mContext, attachmentId);
336            if (attachment != null) {
337                boolean deleted = false;
338                if ((attachment.mFlags & Attachment.FLAG_DOWNLOAD_FORWARD) != 0) {
339                    if (statusCode == EmailServiceStatus.ATTACHMENT_NOT_FOUND) {
340                        // If this is a forwarding download, and the attachment doesn't exist (or
341                        // can't be downloaded) delete it from the outgoing message, lest that
342                        // message never get sent
343                        EmailContent.delete(mContext, Attachment.CONTENT_URI, attachment.mId);
344                        // TODO: Talk to UX about whether this is even worth doing
345                        showDownloadForwardFailedNotification(attachment);
346                        deleted = true;
347                    }
348                    // If we're an attachment on forwarded mail, and if we're not still blocked,
349                    // try to send pending mail now (as mediated by MailService)
350                    if (!Utility.hasUnloadedAttachments(mContext, attachment.mMessageKey)) {
351                        if (Email.DEBUG) {
352                            Log.d(TAG, "== Downloads finished for outgoing msg #" + req.messageId);
353                        }
354                        MailService.actionSendPendingMail(mContext, req.accountId);
355                    }
356                }
357                if (!deleted) {
358                    // Clear the download flags, since we're done for now.  Note that this happens
359                    // only for non-recoverable errors.  When these occur for forwarded mail, we can
360                    // ignore it and continue; otherwise, it was either 1) a user request, in which
361                    // case the user can retry manually or 2) an opportunistic download, in which
362                    // case the download wasn't critical
363                    ContentValues cv = new ContentValues();
364                    int flags =
365                        Attachment.FLAG_DOWNLOAD_FORWARD | Attachment.FLAG_DOWNLOAD_USER_REQUEST;
366                    cv.put(Attachment.FLAGS, attachment.mFlags &= ~flags);
367                    attachment.update(mContext, cv);
368                }
369            }
370            // Process the queue
371            kick();
372        }
373    }
374
375    /**
376     * Calculate the download priority of an Attachment.  A priority of zero means that the
377     * attachment is not marked for download.
378     * @param att the Attachment
379     * @return the priority key of the Attachment
380     */
381    private static int getPriority(Attachment att) {
382        int priorityClass = PRIORITY_NONE;
383        int flags = att.mFlags;
384        if ((flags & Attachment.FLAG_DOWNLOAD_FORWARD) != 0) {
385            priorityClass = PRIORITY_NORMAL;
386        } else if ((flags & Attachment.FLAG_DOWNLOAD_USER_REQUEST) != 0) {
387            priorityClass = PRIORITY_HIGH;
388        }
389        return priorityClass;
390    }
391
392    private void kick() {
393        synchronized(mLock) {
394            mLock.notify();
395        }
396    }
397
398    /**
399     * We use an EmailServiceCallback to keep track of the progress of downloads.  These callbacks
400     * come from either Controller (IMAP) or ExchangeService (EAS).  Note that we only implement the
401     * single callback that's defined by the EmailServiceCallback interface.
402     */
403    private class ServiceCallback extends IEmailServiceCallback.Stub {
404        public void loadAttachmentStatus(long messageId, long attachmentId, int statusCode,
405                int progress) {
406            if (Email.DEBUG) {
407                String code;
408                switch(statusCode) {
409                    case EmailServiceStatus.SUCCESS:
410                        code = "Success";
411                        break;
412                    case EmailServiceStatus.IN_PROGRESS:
413                        code = "In progress";
414                        break;
415                    default:
416                        code = Integer.toString(statusCode);
417                }
418                Log.d(TAG, "loadAttachmentStatus, id = " + attachmentId + " code = "+ code +
419                        ", " + progress + "%");
420            }
421            // The only thing we're interested in here is whether the download is finished and, if
422            // so, what the result was.
423            switch (statusCode) {
424                case EmailServiceStatus.IN_PROGRESS:
425                    break;
426                default:
427                    mDownloadSet.endDownload(attachmentId, statusCode);
428                    break;
429            }
430        }
431
432        @Override
433        public void sendMessageStatus(long messageId, String subject, int statusCode, int progress)
434                throws RemoteException {
435        }
436
437        @Override
438        public void syncMailboxListStatus(long accountId, int statusCode, int progress)
439                throws RemoteException {
440        }
441
442        @Override
443        public void syncMailboxStatus(long mailboxId, int statusCode, int progress)
444                throws RemoteException {
445        }
446    }
447
448    /**
449     * Alert the user that an attachment couldn't be forwarded.  This is a very unusual case, and
450     * perhaps we shouldn't even send a notification. For now, it's helpful for debugging.
451     * Note the STOPSHIP below...
452     */
453    void showDownloadForwardFailedNotification(Attachment att) {
454        // STOPSHIP: Tentative UI; if we use a notification, replace this text with a resource
455        RemoteViews contentView = new RemoteViews(getPackageName(),
456                R.layout.attachment_forward_failed_notification);
457        contentView.setImageViewResource(R.id.image, R.drawable.ic_email_attachment);
458        contentView.setTextViewText(R.id.text,
459                getString(R.string.forward_download_failed_notification, att.mFileName));
460        Notification n = new Notification(R.drawable.stat_notify_email_generic,
461                getString(R.string.forward_download_failed_ticker), System.currentTimeMillis());
462        n.contentView = contentView;
463        Intent i = new Intent(mContext, Welcome.class);
464        PendingIntent pending = PendingIntent.getActivity(mContext, 0, i,
465                PendingIntent.FLAG_UPDATE_CURRENT);
466        n.contentIntent = pending;
467        n.flags = Notification.FLAG_AUTO_CANCEL;
468        NotificationManager nm =
469                (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
470        nm.notify(NotificationController.NOTIFICATION_ID_WARNING, n);
471    }
472
473    /**
474     * Return the class of the service used by the account type of the provided account id.  We
475     * cache the results to avoid repeated database access
476     * @param accountId the id of the account
477     * @return the service class for the account
478     */
479    private synchronized Class<? extends Service> getServiceClassForAccount(long accountId) {
480        // TODO: We should have some more data-driven way of determining the service class. I'd
481        // suggest adding an attribute in the stores.xml file
482        Class<? extends Service> serviceClass = mAccountServiceMap.get(accountId);
483        if (serviceClass == null) {
484            String protocol = Account.getProtocol(mContext, accountId);
485            if (protocol.equals("eas")) {
486                serviceClass = ExchangeService.class;
487            } else {
488                serviceClass = ControllerService.class;
489            }
490            mAccountServiceMap.put(accountId, serviceClass);
491        }
492        return serviceClass;
493    }
494
495    /*protected*/ void addServiceClass(long accountId, Class<? extends Service> serviceClass) {
496        mAccountServiceMap.put(accountId, serviceClass);
497    }
498
499    /*package*/ void onChange(Attachment att) {
500        mDownloadSet.onChange(att);
501    }
502
503    /*package*/ boolean isQueued(long attachmentId) {
504        return mDownloadSet.findDownloadRequest(attachmentId) != null;
505    }
506
507    /*package*/ boolean dequeue(long attachmentId) {
508        DownloadRequest req = mDownloadSet.findDownloadRequest(attachmentId);
509        if (req != null) {
510            mDownloadSet.remove(req);
511            return true;
512        }
513        return false;
514    }
515
516    /**
517     * Ask the service whether a particular attachment is queued for download
518     * @param attachmentId the id of the Attachment (as stored by EmailProvider)
519     * @return whether or not the attachment is queued for download
520     */
521    public static boolean isAttachmentQueued(long attachmentId) {
522        if (sRunningService != null) {
523            return sRunningService.isQueued(attachmentId);
524        }
525        return false;
526    }
527
528    /**
529     * Ask the service to remove an attachment from the download queue
530     * @param attachmentId the id of the Attachment (as stored by EmailProvider)
531     * @return whether or not the attachment was removed from the queue
532     */
533    public static boolean cancelQueuedAttachment(long attachmentId) {
534        if (sRunningService != null) {
535            return sRunningService.dequeue(attachmentId);
536        }
537        return false;
538    }
539
540    /**
541     * Called directly by EmailProvider whenever an attachment is inserted or changed
542     * @param id the attachment's id
543     * @param flags the new flags for the attachment
544     */
545    public static void attachmentChanged(final long id, final int flags) {
546        if (sRunningService == null) return;
547        Utility.runAsync(new Runnable() {
548            public void run() {
549                final Attachment attachment =
550                    Attachment.restoreAttachmentWithId(sRunningService, id);
551                if (attachment != null) {
552                    // Store the flags we got from EmailProvider; given that all of this
553                    // activity is asynchronous, we need to use the newest data from
554                    // EmailProvider
555                    attachment.mFlags = flags;
556                    sRunningService.onChange(attachment);
557                }
558            }});
559    }
560
561    public void run() {
562        mContext = this;
563        // Run through all attachments in the database that require download and add them to
564        // the queue
565        int mask = Attachment.FLAG_DOWNLOAD_FORWARD | Attachment.FLAG_DOWNLOAD_USER_REQUEST;
566        Cursor c = getContentResolver().query(Attachment.CONTENT_URI,
567                EmailContent.ID_PROJECTION, "(" + Attachment.FLAGS + " & ?) != 0",
568                new String[] {Integer.toString(mask)}, null);
569        try {
570            Log.d(TAG, "Count: " + c.getCount());
571            while (c.moveToNext()) {
572                Attachment attachment = Attachment.restoreAttachmentWithId(
573                        this, c.getLong(EmailContent.ID_PROJECTION_COLUMN));
574                if (attachment != null) {
575                    mDownloadSet.onChange(attachment);
576                }
577            }
578        } catch (Exception e) {
579            e.printStackTrace();
580        }
581        finally {
582            c.close();
583        }
584
585        // Loop until stopped, with a 30 minute wait loop
586        while (!mStop) {
587            // Here's where we run our attachment loading logic...
588            mDownloadSet.processQueue();
589            synchronized(mLock) {
590                try {
591                    mLock.wait(PROCESS_QUEUE_WAIT_TIME);
592                } catch (InterruptedException e) {
593                    // That's ok; we'll just keep looping
594                }
595            }
596        }
597    }
598
599    @Override
600    public int onStartCommand(Intent intent, int flags, int startId) {
601        sRunningService = this;
602        return Service.START_STICKY;
603    }
604
605    /**
606     * The lifecycle of this service is managed by Email.setServicesEnabled(), which is called
607     * throughout the code, in particular 1) after boot and 2) after accounts are added or removed
608     * The goal is that this service should be running at all times when there's at least one
609     * email account present.
610     */
611    @Override
612    public void onCreate() {
613        // Start up our service thread
614        new Thread(this, "AttachmentDownloadService").start();
615    }
616    @Override
617    public IBinder onBind(Intent intent) {
618        return null;
619    }
620
621    @Override
622    public void onDestroy() {
623        Log.d(TAG, "**** ON DESTROY!");
624        if (sRunningService != null) {
625            mStop = true;
626            kick();
627        }
628        sRunningService = null;
629    }
630}
631