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