AttachmentDownloadService.java revision 0d4fc55861ed4393aa82f124f2865695ef564641
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.AttachmentInfo;
20import com.android.email.Email;
21import com.android.email.EmailConnectivityManager;
22import com.android.email.NotificationController;
23import com.android.email.Utility;
24import com.android.email.Controller.ControllerService;
25import com.android.email.ExchangeUtils.NullEmailService;
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.emailcommon.service.EmailServiceProxy;
32import com.android.emailcommon.service.EmailServiceStatus;
33import com.android.emailcommon.service.IEmailServiceCallback;
34import com.android.exchange.ExchangeService;
35
36import android.accounts.AccountManager;
37import android.app.AlarmManager;
38import android.app.PendingIntent;
39import android.app.Service;
40import android.content.BroadcastReceiver;
41import android.content.ContentValues;
42import android.content.Context;
43import android.content.Intent;
44import android.database.Cursor;
45import android.net.Uri;
46import android.os.IBinder;
47import android.os.RemoteException;
48import android.text.format.DateUtils;
49import android.util.Log;
50
51import java.io.File;
52import java.io.FileDescriptor;
53import java.io.PrintWriter;
54import java.util.Comparator;
55import java.util.HashMap;
56import java.util.Iterator;
57import java.util.TreeSet;
58import java.util.concurrent.ConcurrentHashMap;
59
60public class AttachmentDownloadService extends Service implements Runnable {
61    public static final String TAG = "AttachmentService";
62
63    // Our idle time, waiting for notifications; this is something of a failsafe
64    private static final int PROCESS_QUEUE_WAIT_TIME = 30 * ((int)DateUtils.MINUTE_IN_MILLIS);
65    // How often our watchdog checks for callback timeouts
66    private static final int WATCHDOG_CHECK_INTERVAL = 15 * ((int)DateUtils.SECOND_IN_MILLIS);
67    // How long we'll wait for a callback before canceling a download and retrying
68    private static final int CALLBACK_TIMEOUT = 30 * ((int)DateUtils.SECOND_IN_MILLIS);
69    // Try to download an attachment in the background this many times before giving up
70    private static final int MAX_DOWNLOAD_RETRIES = 5;
71    private static final int PRIORITY_NONE = -1;
72    @SuppressWarnings("unused")
73    // Low priority will be used for opportunistic downloads
74    private static final int PRIORITY_BACKGROUND = 0;
75    // Normal priority is for forwarded downloads in outgoing mail
76    private static final int PRIORITY_SEND_MAIL = 1;
77    // High priority is for user requests
78    private static final int PRIORITY_FOREGROUND = 2;
79
80    // Minimum free storage in order to perform prefetch (25% of total memory)
81    private static final float PREFETCH_MINIMUM_STORAGE_AVAILABLE = 0.25F;
82    // Maximum prefetch storage (also 25% of total memory)
83    private static final float PREFETCH_MAXIMUM_ATTACHMENT_STORAGE = 0.25F;
84
85    // We can try various values here; I think 2 is completely reasonable as a first pass
86    private static final int MAX_SIMULTANEOUS_DOWNLOADS = 2;
87    // Limit on the number of simultaneous downloads per account
88    // Note that a limit of 1 is currently enforced by both Services (MailService and Controller)
89    private static final int MAX_SIMULTANEOUS_DOWNLOADS_PER_ACCOUNT = 1;
90    // Limit on the number of attachments we'll check for background download
91    private static final int MAX_ATTACHMENTS_TO_CHECK = 25;
92
93    // sRunningService is only set in the UI thread; it's visibility elsewhere is guaranteed
94    // by the use of "volatile"
95    /*package*/ static volatile AttachmentDownloadService sRunningService = null;
96
97    /*package*/ Context mContext;
98    /*package*/ EmailConnectivityManager mConnectivityManager;
99
100    /*package*/ final DownloadSet mDownloadSet = new DownloadSet(new DownloadComparator());
101
102    private final HashMap<Long, Class<? extends Service>> mAccountServiceMap =
103        new HashMap<Long, Class<? extends Service>>();
104    // A map of attachment storage used per account
105    // NOTE: This map is not kept current in terms of deletions (i.e. it stores the last calculated
106    // amount plus the size of any new attachments laoded).  If and when we reach the per-account
107    // limit, we recalculate the actual usage
108    /*package*/ final HashMap<Long, Long> mAttachmentStorageMap = new HashMap<Long, Long>();
109    // A map of attachment ids to the number of failed attempts to download the attachment
110    // NOTE: We do not want to persist this. This allows us to retry background downloading
111    // if any transient network errors are fixed & and the app is restarted
112    /* package */ final HashMap<Long, Integer> mAttachmentFailureMap = new HashMap<Long, Integer>();
113    private final ServiceCallback mServiceCallback = new ServiceCallback();
114
115    private final Object mLock = new Object();
116    private volatile boolean mStop = false;
117
118    /*package*/ AccountManagerStub mAccountManagerStub;
119
120    /**
121     * We only use the getAccounts() call from AccountManager, so this class wraps that call and
122     * allows us to build a mock account manager stub in the unit tests
123     */
124    /*package*/ static class AccountManagerStub {
125        private int mNumberOfAccounts;
126        private final AccountManager mAccountManager;
127
128        AccountManagerStub(Context context) {
129            if (context != null) {
130                mAccountManager = AccountManager.get(context);
131            } else {
132                mAccountManager = null;
133            }
134        }
135
136        /*package*/ int getNumberOfAccounts() {
137            if (mAccountManager != null) {
138                return mAccountManager.getAccounts().length;
139            } else {
140                return mNumberOfAccounts;
141            }
142        }
143
144        /*package*/ void setNumberOfAccounts(int numberOfAccounts) {
145            mNumberOfAccounts = numberOfAccounts;
146        }
147    }
148
149    /**
150     * Watchdog alarm receiver; responsible for making sure that downloads in progress are not
151     * stalled, as determined by the timing of the most recent service callback
152     */
153    public static class Watchdog extends BroadcastReceiver {
154        @Override
155        public void onReceive(final Context context, Intent intent) {
156            new Thread(new Runnable() {
157                public void run() {
158                    watchdogAlarm();
159                }
160            }, "AttachmentDownloadService Watchdog").start();
161        }
162    }
163
164    public static class DownloadRequest {
165        final int priority;
166        final long time;
167        final long attachmentId;
168        final long messageId;
169        final long accountId;
170        boolean inProgress = false;
171        int lastStatusCode;
172        int lastProgress;
173        long lastCallbackTime;
174        long startTime;
175
176        private DownloadRequest(Context context, Attachment attachment) {
177            attachmentId = attachment.mId;
178            Message msg = Message.restoreMessageWithId(context, attachment.mMessageKey);
179            if (msg != null) {
180                accountId = msg.mAccountKey;
181                messageId = msg.mId;
182            } else {
183                accountId = messageId = -1;
184            }
185            priority = getPriority(attachment);
186            time = System.currentTimeMillis();
187        }
188
189        @Override
190        public int hashCode() {
191            return (int)attachmentId;
192        }
193
194        /**
195         * Two download requests are equals if their attachment id's are equals
196         */
197        @Override
198        public boolean equals(Object object) {
199            if (!(object instanceof DownloadRequest)) return false;
200            DownloadRequest req = (DownloadRequest)object;
201            return req.attachmentId == attachmentId;
202        }
203    }
204
205    /**
206     * Comparator class for the download set; we first compare by priority.  Requests with equal
207     * priority are compared by the time the request was created (older requests come first)
208     */
209    /*protected*/ static class DownloadComparator implements Comparator<DownloadRequest> {
210        @Override
211        public int compare(DownloadRequest req1, DownloadRequest req2) {
212            int res;
213            if (req1.priority != req2.priority) {
214                res = (req1.priority < req2.priority) ? -1 : 1;
215            } else {
216                if (req1.time == req2.time) {
217                    res = 0;
218                } else {
219                    res = (req1.time > req2.time) ? -1 : 1;
220                }
221            }
222            return res;
223        }
224    }
225
226    /**
227     * The DownloadSet is a TreeSet sorted by priority class (e.g. low, high, etc.) and the
228     * time of the request.  Higher priority requests
229     * are always processed first; among equals, the oldest request is processed first.  The
230     * priority key represents this ordering.  Note: All methods that change the attachment map are
231     * synchronized on the map itself
232     */
233    /*package*/ class DownloadSet extends TreeSet<DownloadRequest> {
234        private static final long serialVersionUID = 1L;
235        private PendingIntent mWatchdogPendingIntent;
236        private AlarmManager mAlarmManager;
237
238        /*package*/ DownloadSet(Comparator<? super DownloadRequest> comparator) {
239            super(comparator);
240        }
241
242        /**
243         * Maps attachment id to DownloadRequest
244         */
245        /*package*/ final ConcurrentHashMap<Long, DownloadRequest> mDownloadsInProgress =
246            new ConcurrentHashMap<Long, DownloadRequest>();
247
248        /**
249         * onChange is called by the AttachmentReceiver upon receipt of a valid notification from
250         * EmailProvider that an attachment has been inserted or modified.  It's not strictly
251         * necessary that we detect a deleted attachment, as the code always checks for the
252         * existence of an attachment before acting on it.
253         */
254        public synchronized void onChange(Context context, Attachment att) {
255            DownloadRequest req = findDownloadRequest(att.mId);
256            long priority = getPriority(att);
257            if (priority == PRIORITY_NONE) {
258                if (Email.DEBUG) {
259                    Log.d(TAG, "== Attachment changed: " + att.mId);
260                }
261                // In this case, there is no download priority for this attachment
262                if (req != null) {
263                    // If it exists in the map, remove it
264                    // NOTE: We don't yet support deleting downloads in progress
265                    if (Email.DEBUG) {
266                        Log.d(TAG, "== Attachment " + att.mId + " was in queue, removing");
267                    }
268                    remove(req);
269                }
270            } else {
271                // Ignore changes that occur during download
272                if (mDownloadsInProgress.containsKey(att.mId)) return;
273                // If this is new, add the request to the queue
274                if (req == null) {
275                    req = new DownloadRequest(context, att);
276                    add(req);
277                }
278                // If the request already existed, we'll update the priority (so that the time is
279                // up-to-date); otherwise, we create a new request
280                if (Email.DEBUG) {
281                    Log.d(TAG, "== Download queued for attachment " + att.mId + ", class " +
282                            req.priority + ", priority time " + req.time);
283                }
284            }
285            // Process the queue if we're in a wait
286            kick();
287        }
288
289        /**
290         * Find a queued DownloadRequest, given the attachment's id
291         * @param id the id of the attachment
292         * @return the DownloadRequest for that attachment (or null, if none)
293         */
294        /*package*/ synchronized DownloadRequest findDownloadRequest(long id) {
295            Iterator<DownloadRequest> iterator = iterator();
296            while(iterator.hasNext()) {
297                DownloadRequest req = iterator.next();
298                if (req.attachmentId == id) {
299                    return req;
300                }
301            }
302            return null;
303        }
304
305        /**
306         * Run through the AttachmentMap and find DownloadRequests that can be executed, enforcing
307         * the limit on maximum downloads
308         */
309        /*package*/ synchronized void processQueue() {
310            if (Email.DEBUG) {
311                Log.d(TAG, "== Checking attachment queue, " + mDownloadSet.size() + " entries");
312            }
313
314            // Don't run unless/until we have connectivity
315            mConnectivityManager.waitForConnectivity();
316
317            Iterator<DownloadRequest> iterator = mDownloadSet.descendingIterator();
318            // First, start up any required downloads, in priority order
319            while (iterator.hasNext() &&
320                    (mDownloadsInProgress.size() < MAX_SIMULTANEOUS_DOWNLOADS)) {
321                DownloadRequest req = iterator.next();
322                 // Enforce per-account limit here
323                if (downloadsForAccount(req.accountId) >= MAX_SIMULTANEOUS_DOWNLOADS_PER_ACCOUNT) {
324                    if (Email.DEBUG) {
325                        Log.d(TAG, "== Skip #" + req.attachmentId + "; maxed for acct #" +
326                                req.accountId);
327                    }
328                    continue;
329                }
330
331                if (!req.inProgress) {
332                    mDownloadSet.tryStartDownload(req);
333                }
334            }
335
336            // Don't prefetch if background downloading is disallowed
337            if (!mConnectivityManager.isBackgroundDataAllowed()) return;
338            // Then, try opportunistic download of appropriate attachments
339            int backgroundDownloads = MAX_SIMULTANEOUS_DOWNLOADS - mDownloadsInProgress.size();
340            // Always leave one slot for user requested download
341            if (backgroundDownloads > (MAX_SIMULTANEOUS_DOWNLOADS - 1)) {
342                // We'll load up the newest 25 attachments that aren't loaded or queued
343                Uri lookupUri = EmailContent.uriWithLimit(Attachment.CONTENT_URI,
344                        MAX_ATTACHMENTS_TO_CHECK);
345                Cursor c = mContext.getContentResolver().query(lookupUri, AttachmentInfo.PROJECTION,
346                        EmailContent.Attachment.EMPTY_URI_INBOX_SELECTION,
347                        null, Attachment.RECORD_ID + " DESC");
348                File cacheDir = mContext.getCacheDir();
349                try {
350                    while (c.moveToNext()) {
351                        long accountKey = c.getLong(AttachmentInfo.COLUMN_ACCOUNT_KEY);
352                        long id = c.getLong(AttachmentInfo.COLUMN_ID);
353                        if (getServiceClassForAccount(accountKey) == null) {
354                            // Clean up this orphaned attachment; there's no point in keeping it
355                            // around; then try to find another one
356                            EmailContent.delete(mContext, Attachment.CONTENT_URI, id);
357                        } else if (canPrefetchForAccount(accountKey, cacheDir)) {
358                            // Check that the attachment meets system requirements for download
359                            AttachmentInfo info = new AttachmentInfo(mContext, c);
360                            if (info.isEligibleForDownload()) {
361                                Attachment att = Attachment.restoreAttachmentWithId(mContext, id);
362                                if (att != null) {
363                                    Integer tryCount;
364                                    tryCount = mAttachmentFailureMap.get(att.mId);
365                                    if (tryCount != null && tryCount > MAX_DOWNLOAD_RETRIES) {
366                                        // move onto the next attachment
367                                        continue;
368                                    }
369                                    // Start this download and we're done
370                                    DownloadRequest req = new DownloadRequest(mContext, att);
371                                    mDownloadSet.tryStartDownload(req);
372                                    break;
373                                }
374                            }
375                        }
376                    }
377                } finally {
378                    c.close();
379                }
380            }
381        }
382
383        /**
384         * Count the number of running downloads in progress for this account
385         * @param accountId the id of the account
386         * @return the count of running downloads
387         */
388        /*package*/ synchronized int downloadsForAccount(long accountId) {
389            int count = 0;
390            for (DownloadRequest req: mDownloadsInProgress.values()) {
391                if (req.accountId == accountId) {
392                    count++;
393                }
394            }
395            return count;
396        }
397
398        private void onWatchdogAlarm() {
399            long now = System.currentTimeMillis();
400            for (DownloadRequest req: mDownloadsInProgress.values()) {
401                // Check how long it's been since receiving a callback
402                long timeSinceCallback = now - req.lastCallbackTime;
403                if (timeSinceCallback > CALLBACK_TIMEOUT) {
404                    if (Email.DEBUG) {
405                        Log.d(TAG, "== Download of " + req.attachmentId + " timed out");
406                    }
407                   cancelDownload(req);
408                }
409            }
410            // If there are downloads in progress, reset alarm
411            if (mDownloadsInProgress.isEmpty()) {
412                if (mAlarmManager != null && mWatchdogPendingIntent != null) {
413                    mAlarmManager.cancel(mWatchdogPendingIntent);
414                }
415            }
416            // Check whether we can start new downloads...
417            processQueue();
418        }
419
420        /**
421         * Do the work of starting an attachment download using the EmailService interface, and
422         * set our watchdog alarm
423         *
424         * @param serviceClass the class that will attempt the download
425         * @param req the DownloadRequest
426         * @throws RemoteException
427         */
428        private void startDownload(Class<? extends Service> serviceClass, DownloadRequest req)
429                throws RemoteException {
430            File file = AttachmentProvider.getAttachmentFilename(mContext, req.accountId,
431                    req.attachmentId);
432            req.startTime = System.currentTimeMillis();
433            req.inProgress = true;
434            mDownloadsInProgress.put(req.attachmentId, req);
435            if (serviceClass.equals(NullEmailService.class)) return;
436            // Now, call the service
437            EmailServiceProxy proxy =
438                new EmailServiceProxy(mContext, serviceClass, mServiceCallback);
439            proxy.loadAttachment(req.attachmentId, file.getAbsolutePath(),
440                    AttachmentProvider.getAttachmentUri(req.accountId, req.attachmentId)
441                    .toString(), req.priority != PRIORITY_FOREGROUND);
442            // Lazily initialize our (reusable) pending intent
443            if (mWatchdogPendingIntent == null) {
444                Intent alarmIntent = new Intent(mContext, Watchdog.class);
445                mWatchdogPendingIntent = PendingIntent.getBroadcast(mContext, 0, alarmIntent, 0);
446                mAlarmManager = (AlarmManager)getSystemService(Context.ALARM_SERVICE);
447            }
448            // Set the alarm
449            mAlarmManager.setRepeating(AlarmManager.RTC_WAKEUP,
450                    System.currentTimeMillis() + WATCHDOG_CHECK_INTERVAL, WATCHDOG_CHECK_INTERVAL,
451                    mWatchdogPendingIntent);
452        }
453
454        private synchronized DownloadRequest getDownloadInProgress(long attachmentId) {
455            return mDownloadsInProgress.get(attachmentId);
456        }
457
458        /**
459         * Attempt to execute the DownloadRequest, enforcing the maximum downloads per account
460         * parameter
461         * @param req the DownloadRequest
462         * @return whether or not the download was started
463         */
464        /*package*/ synchronized boolean tryStartDownload(DownloadRequest req) {
465            Class<? extends Service> serviceClass = getServiceClassForAccount(req.accountId);
466            if (serviceClass == null) return false;
467
468            // Do not download the same attachment multiple times
469            boolean alreadyInProgress = mDownloadsInProgress.get(req.attachmentId) != null;
470            if (alreadyInProgress) return false;
471
472            try {
473                if (Email.DEBUG) {
474                    Log.d(TAG, ">> Starting download for attachment #" + req.attachmentId);
475                }
476                startDownload(serviceClass, req);
477            } catch (RemoteException e) {
478                // TODO: Consider whether we need to do more in this case...
479                // For now, fix up our data to reflect the failure
480                cancelDownload(req);
481            }
482            return true;
483        }
484
485        private void cancelDownload(DownloadRequest req) {
486            mDownloadsInProgress.remove(req.attachmentId);
487            req.inProgress = false;
488        }
489
490        /**
491         * Called when a download is finished; we get notified of this via our EmailServiceCallback
492         * @param attachmentId the id of the attachment whose download is finished
493         * @param statusCode the EmailServiceStatus code returned by the Service
494         */
495        /*package*/ synchronized void endDownload(long attachmentId, int statusCode) {
496            // Say we're no longer downloading this
497            mDownloadsInProgress.remove(attachmentId);
498
499            // TODO: This code is conservative and treats connection issues as failures.
500            // Since we have no mechanism to throttle reconnection attempts, it makes
501            // sense to be cautious here. Once logic is in place to prevent connecting
502            // in a tight loop, we can exclude counting connection issues as "failures".
503
504            // Update the attachment failure list if needed
505            Integer downloadCount;
506            downloadCount = mAttachmentFailureMap.remove(attachmentId);
507            if (statusCode != EmailServiceStatus.SUCCESS) {
508                if (downloadCount == null) {
509                    downloadCount = 0;
510                }
511                downloadCount += 1;
512                mAttachmentFailureMap.put(attachmentId, downloadCount);
513            }
514
515            DownloadRequest req = mDownloadSet.findDownloadRequest(attachmentId);
516            if (statusCode == EmailServiceStatus.CONNECTION_ERROR) {
517                // If this needs to be retried, just process the queue again
518                if (Email.DEBUG) {
519                    Log.d(TAG, "== The download for attachment #" + attachmentId +
520                            " will be retried");
521                }
522                if (req != null) {
523                    req.inProgress = false;
524                }
525                kick();
526                return;
527            }
528
529            // If the request is still in the queue, remove it
530            if (req != null) {
531                remove(req);
532            }
533            if (Email.DEBUG) {
534                long secs = 0;
535                if (req != null) {
536                    secs = (System.currentTimeMillis() - req.time) / 1000;
537                }
538                String status = (statusCode == EmailServiceStatus.SUCCESS) ? "Success" :
539                    "Error " + statusCode;
540                Log.d(TAG, "<< Download finished for attachment #" + attachmentId + "; " + secs +
541                           " seconds from request, status: " + status);
542            }
543
544            Attachment attachment = Attachment.restoreAttachmentWithId(mContext, attachmentId);
545            if (attachment != null) {
546                long accountId = attachment.mAccountKey;
547                // Update our attachment storage for this account
548                Long currentStorage = mAttachmentStorageMap.get(accountId);
549                if (currentStorage == null) {
550                    currentStorage = 0L;
551                }
552                mAttachmentStorageMap.put(accountId, currentStorage + attachment.mSize);
553                boolean deleted = false;
554                if ((attachment.mFlags & Attachment.FLAG_DOWNLOAD_FORWARD) != 0) {
555                    if (statusCode == EmailServiceStatus.ATTACHMENT_NOT_FOUND) {
556                        // If this is a forwarding download, and the attachment doesn't exist (or
557                        // can't be downloaded) delete it from the outgoing message, lest that
558                        // message never get sent
559                        EmailContent.delete(mContext, Attachment.CONTENT_URI, attachment.mId);
560                        // TODO: Talk to UX about whether this is even worth doing
561                        NotificationController nc = NotificationController.getInstance(mContext);
562                        nc.showDownloadForwardFailedNotification(attachment);
563                        deleted = true;
564                    }
565                    // If we're an attachment on forwarded mail, and if we're not still blocked,
566                    // try to send pending mail now (as mediated by MailService)
567                    if ((req != null) &&
568                            !Utility.hasUnloadedAttachments(mContext, attachment.mMessageKey)) {
569                        if (Email.DEBUG) {
570                            Log.d(TAG, "== Downloads finished for outgoing msg #" + req.messageId);
571                        }
572                        MailService.actionSendPendingMail(mContext, req.accountId);
573                    }
574                }
575                if (statusCode == EmailServiceStatus.MESSAGE_NOT_FOUND) {
576                    // If there's no associated message, delete the attachment
577                    EmailContent.delete(mContext, Attachment.CONTENT_URI, attachment.mId);
578                } else if (!deleted) {
579                    // Clear the download flags, since we're done for now.  Note that this happens
580                    // only for non-recoverable errors.  When these occur for forwarded mail, we can
581                    // ignore it and continue; otherwise, it was either 1) a user request, in which
582                    // case the user can retry manually or 2) an opportunistic download, in which
583                    // case the download wasn't critical
584                    ContentValues cv = new ContentValues();
585                    int flags =
586                        Attachment.FLAG_DOWNLOAD_FORWARD | Attachment.FLAG_DOWNLOAD_USER_REQUEST;
587                    cv.put(Attachment.FLAGS, attachment.mFlags &= ~flags);
588                    attachment.update(mContext, cv);
589                }
590            }
591            // Process the queue
592            kick();
593        }
594    }
595
596    /**
597     * Calculate the download priority of an Attachment.  A priority of zero means that the
598     * attachment is not marked for download.
599     * @param att the Attachment
600     * @return the priority key of the Attachment
601     */
602    private static int getPriority(Attachment att) {
603        int priorityClass = PRIORITY_NONE;
604        int flags = att.mFlags;
605        if ((flags & Attachment.FLAG_DOWNLOAD_FORWARD) != 0) {
606            priorityClass = PRIORITY_SEND_MAIL;
607        } else if ((flags & Attachment.FLAG_DOWNLOAD_USER_REQUEST) != 0) {
608            priorityClass = PRIORITY_FOREGROUND;
609        }
610        return priorityClass;
611    }
612
613    private void kick() {
614        synchronized(mLock) {
615            mLock.notify();
616        }
617    }
618
619    /**
620     * We use an EmailServiceCallback to keep track of the progress of downloads.  These callbacks
621     * come from either Controller (IMAP) or ExchangeService (EAS).  Note that we only implement the
622     * single callback that's defined by the EmailServiceCallback interface.
623     */
624    private class ServiceCallback extends IEmailServiceCallback.Stub {
625        public void loadAttachmentStatus(long messageId, long attachmentId, int statusCode,
626                int progress) {
627            // Record status and progress
628            DownloadRequest req = mDownloadSet.getDownloadInProgress(attachmentId);
629            if (req != null) {
630                if (Email.DEBUG) {
631                    String code;
632                    switch(statusCode) {
633                        case EmailServiceStatus.SUCCESS: code = "Success"; break;
634                        case EmailServiceStatus.IN_PROGRESS: code = "In progress"; break;
635                        default: code = Integer.toString(statusCode); break;
636                    }
637                    if (statusCode != EmailServiceStatus.IN_PROGRESS) {
638                        Log.d(TAG, ">> Attachment " + attachmentId + ": " + code);
639                    } else if (progress >= (req.lastProgress + 15)) {
640                        Log.d(TAG, ">> Attachment " + attachmentId + ": " + progress + "%");
641                    }
642                }
643                req.lastStatusCode = statusCode;
644                req.lastProgress = progress;
645                req.lastCallbackTime = System.currentTimeMillis();
646            }
647            switch (statusCode) {
648                case EmailServiceStatus.IN_PROGRESS:
649                    break;
650                default:
651                    mDownloadSet.endDownload(attachmentId, statusCode);
652                    break;
653            }
654        }
655
656        @Override
657        public void sendMessageStatus(long messageId, String subject, int statusCode, int progress)
658                throws RemoteException {
659        }
660
661        @Override
662        public void syncMailboxListStatus(long accountId, int statusCode, int progress)
663                throws RemoteException {
664        }
665
666        @Override
667        public void syncMailboxStatus(long mailboxId, int statusCode, int progress)
668                throws RemoteException {
669        }
670    }
671
672    /**
673     * Return the class of the service used by the account type of the provided account id.  We
674     * cache the results to avoid repeated database access
675     * @param accountId the id of the account
676     * @return the service class for the account or null (if the account no longer exists)
677     */
678    private synchronized Class<? extends Service> getServiceClassForAccount(long accountId) {
679        // TODO: We should have some more data-driven way of determining the service class. I'd
680        // suggest adding an attribute in the stores.xml file
681        Class<? extends Service> serviceClass = mAccountServiceMap.get(accountId);
682        if (serviceClass == null) {
683            String protocol = Account.getProtocol(mContext, accountId);
684            if (protocol == null) return null;
685            serviceClass = ControllerService.class;
686            // EXCHANGE-REMOVE-SECTION-START
687            if (protocol.equals("eas")) {
688                serviceClass = ExchangeService.class;
689            }
690            // EXCHANGE-REMOVE-SECTION-END
691            mAccountServiceMap.put(accountId, serviceClass);
692        }
693        return serviceClass;
694    }
695
696    /*protected*/ void addServiceClass(long accountId, Class<? extends Service> serviceClass) {
697        mAccountServiceMap.put(accountId, serviceClass);
698    }
699
700    /*package*/ void onChange(Attachment att) {
701        mDownloadSet.onChange(this, att);
702    }
703
704    /*package*/ boolean isQueued(long attachmentId) {
705        return mDownloadSet.findDownloadRequest(attachmentId) != null;
706    }
707
708    /*package*/ int getSize() {
709        return mDownloadSet.size();
710    }
711
712    /*package*/ boolean dequeue(long attachmentId) {
713        DownloadRequest req = mDownloadSet.findDownloadRequest(attachmentId);
714        if (req != null) {
715            if (Email.DEBUG) {
716                Log.d(TAG, "Dequeued attachmentId:  " + attachmentId);
717            }
718            mDownloadSet.remove(req);
719            return true;
720        }
721        return false;
722    }
723
724    /**
725     * Ask the service for the number of items in the download queue
726     * @return the number of items queued for download
727     */
728    public static int getQueueSize() {
729        AttachmentDownloadService service = sRunningService;
730        if (service != null) {
731            return service.getSize();
732        }
733        return 0;
734    }
735
736    /**
737     * Ask the service whether a particular attachment is queued for download
738     * @param attachmentId the id of the Attachment (as stored by EmailProvider)
739     * @return whether or not the attachment is queued for download
740     */
741    public static boolean isAttachmentQueued(long attachmentId) {
742        AttachmentDownloadService service = sRunningService;
743        if (service != null) {
744            return service.isQueued(attachmentId);
745        }
746        return false;
747    }
748
749    /**
750     * Ask the service to remove an attachment from the download queue
751     * @param attachmentId the id of the Attachment (as stored by EmailProvider)
752     * @return whether or not the attachment was removed from the queue
753     */
754    public static boolean cancelQueuedAttachment(long attachmentId) {
755        AttachmentDownloadService service = sRunningService;
756        if (service != null) {
757            return service.dequeue(attachmentId);
758        }
759        return false;
760    }
761
762    public static void watchdogAlarm() {
763        AttachmentDownloadService service = sRunningService;
764        if (service != null) {
765            service.mDownloadSet.onWatchdogAlarm();
766        }
767    }
768
769    /**
770     * Called directly by EmailProvider whenever an attachment is inserted or changed
771     * @param id the attachment's id
772     * @param flags the new flags for the attachment
773     */
774    public static void attachmentChanged(final long id, final int flags) {
775        final AttachmentDownloadService service = sRunningService;
776        if (service == null) return;
777        Utility.runAsync(new Runnable() {
778            public void run() {
779                final Attachment attachment =
780                    Attachment.restoreAttachmentWithId(service, id);
781                if (attachment != null) {
782                    // Store the flags we got from EmailProvider; given that all of this
783                    // activity is asynchronous, we need to use the newest data from
784                    // EmailProvider
785                    attachment.mFlags = flags;
786                    service.onChange(attachment);
787                }
788            }});
789    }
790
791    /**
792     * Determine whether an attachment can be prefetched for the given account
793     * @return true if download is allowed, false otherwise
794     */
795    /*package*/ boolean canPrefetchForAccount(long accountId, File dir) {
796        Account account = Account.restoreAccountWithId(mContext, accountId);
797        // Check account, just in case
798        if (account == null) return false;
799        // First, check preference and quickly return if prefetch isn't allowed
800        if ((account.mFlags & Account.FLAGS_BACKGROUND_ATTACHMENTS) == 0) return false;
801
802        long totalStorage = dir.getTotalSpace();
803        long usableStorage = dir.getUsableSpace();
804        long minAvailable = (long)(totalStorage * PREFETCH_MINIMUM_STORAGE_AVAILABLE);
805
806        // If there's not enough overall storage available, stop now
807        if (usableStorage < minAvailable) {
808            return false;
809        }
810
811        int numberOfAccounts = mAccountManagerStub.getNumberOfAccounts();
812        long perAccountMaxStorage =
813            (long)(totalStorage * PREFETCH_MAXIMUM_ATTACHMENT_STORAGE / numberOfAccounts);
814
815        // Retrieve our idea of currently used attachment storage; since we don't track deletions,
816        // this number is the "worst case".  If the number is greater than what's allowed per
817        // account, we walk the directory to determine the actual number
818        Long accountStorage = mAttachmentStorageMap.get(accountId);
819        if (accountStorage == null || (accountStorage > perAccountMaxStorage)) {
820            // Calculate the exact figure for attachment storage for this account
821            accountStorage = 0L;
822            File[] files = dir.listFiles();
823            if (files != null) {
824                for (File file : files) {
825                    accountStorage += file.length();
826                }
827            }
828            // Cache the value
829            mAttachmentStorageMap.put(accountId, accountStorage);
830        }
831
832        // Return true if we're using less than the maximum per account
833        if (accountStorage < perAccountMaxStorage) {
834            return true;
835        } else {
836            if (Email.DEBUG) {
837                Log.d(TAG, ">> Prefetch not allowed for account " + accountId + "; used " +
838                        accountStorage + ", limit " + perAccountMaxStorage);
839            }
840            return false;
841        }
842    }
843
844    public void run() {
845        // These fields are only used within the service thread
846        mContext = this;
847        mConnectivityManager = new EmailConnectivityManager(this, TAG);
848        mAccountManagerStub = new AccountManagerStub(this);
849
850        // Run through all attachments in the database that require download and add them to
851        // the queue
852        int mask = Attachment.FLAG_DOWNLOAD_FORWARD | Attachment.FLAG_DOWNLOAD_USER_REQUEST;
853        Cursor c = getContentResolver().query(Attachment.CONTENT_URI,
854                EmailContent.ID_PROJECTION, "(" + Attachment.FLAGS + " & ?) != 0",
855                new String[] {Integer.toString(mask)}, null);
856        try {
857            Log.d(TAG, "Count: " + c.getCount());
858            while (c.moveToNext()) {
859                Attachment attachment = Attachment.restoreAttachmentWithId(
860                        this, c.getLong(EmailContent.ID_PROJECTION_COLUMN));
861                if (attachment != null) {
862                    mDownloadSet.onChange(this, attachment);
863                }
864            }
865        } catch (Exception e) {
866            e.printStackTrace();
867        }
868        finally {
869            c.close();
870        }
871
872        // Loop until stopped, with a 30 minute wait loop
873        while (!mStop) {
874            // Here's where we run our attachment loading logic...
875            mDownloadSet.processQueue();
876            synchronized(mLock) {
877                try {
878                    mLock.wait(PROCESS_QUEUE_WAIT_TIME);
879                } catch (InterruptedException e) {
880                    // That's ok; we'll just keep looping
881                }
882            }
883        }
884
885        // Unregister now that we're done
886        mConnectivityManager.unregister();
887    }
888
889    @Override
890    public int onStartCommand(Intent intent, int flags, int startId) {
891        sRunningService = this;
892        return Service.START_STICKY;
893    }
894
895    /**
896     * The lifecycle of this service is managed by Email.setServicesEnabled(), which is called
897     * throughout the code, in particular 1) after boot and 2) after accounts are added or removed
898     * The goal is that this service should be running at all times when there's at least one
899     * email account present.
900     */
901    @Override
902    public void onCreate() {
903        // Start up our service thread
904        new Thread(this, "AttachmentDownloadService").start();
905    }
906    @Override
907    public IBinder onBind(Intent intent) {
908        return null;
909    }
910
911    @Override
912    public void onDestroy() {
913        Log.d(TAG, "**** ON DESTROY!");
914        if (sRunningService != null) {
915            mStop = true;
916            kick();
917        }
918        sRunningService = null;
919    }
920
921    @Override
922    public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
923        pw.println("AttachmentDownloadService");
924        long time = System.currentTimeMillis();
925        synchronized(mDownloadSet) {
926            pw.println("  Queue, " + mDownloadSet.size() + " entries");
927            Iterator<DownloadRequest> iterator = mDownloadSet.descendingIterator();
928            // First, start up any required downloads, in priority order
929            while (iterator.hasNext()) {
930                DownloadRequest req = iterator.next();
931                pw.println("    Account: " + req.accountId + ", Attachment: " + req.attachmentId);
932                pw.println("      Priority: " + req.priority + ", Time: " + req.time +
933                        (req.inProgress ? " [In progress]" : ""));
934                Attachment att = Attachment.restoreAttachmentWithId(this, req.attachmentId);
935                if (att == null) {
936                    pw.println("      Attachment not in database?");
937                } else if (att.mFileName != null) {
938                    String fileName = att.mFileName;
939                    String suffix = "[none]";
940                    int lastDot = fileName.lastIndexOf('.');
941                    if (lastDot >= 0) {
942                        suffix = fileName.substring(lastDot);
943                    }
944                    pw.print("      Suffix: " + suffix);
945                    if (att.mContentUri != null) {
946                        pw.print(" ContentUri: " + att.mContentUri);
947                    }
948                    pw.print(" Mime: ");
949                    if (att.mMimeType != null) {
950                        pw.print(att.mMimeType);
951                    } else {
952                        pw.print(AttachmentProvider.inferMimeType(fileName, null));
953                        pw.print(" [inferred]");
954                    }
955                    pw.println(" Size: " + att.mSize);
956                }
957                if (req.inProgress) {
958                    pw.println("      Status: " + req.lastStatusCode + ", Progress: " +
959                            req.lastProgress);
960                    pw.println("      Started: " + req.startTime + ", Callback: " +
961                            req.lastCallbackTime);
962                    pw.println("      Elapsed: " + ((time - req.startTime) / 1000L) + "s");
963                    if (req.lastCallbackTime > 0) {
964                        pw.println("      CB: " + ((time - req.lastCallbackTime) / 1000L) + "s");
965                    }
966                }
967            }
968        }
969    }
970}
971