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