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