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