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