| /* |
| * Copyright (C) 2010 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.email.service; |
| |
| import android.accounts.AccountManager; |
| import android.app.AlarmManager; |
| import android.app.PendingIntent; |
| import android.app.Service; |
| import android.content.BroadcastReceiver; |
| import android.content.ContentValues; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.database.Cursor; |
| import android.net.ConnectivityManager; |
| import android.net.Uri; |
| import android.os.IBinder; |
| import android.os.RemoteException; |
| import android.os.SystemClock; |
| import android.text.format.DateUtils; |
| |
| import com.android.email.AttachmentInfo; |
| import com.android.email.EmailConnectivityManager; |
| import com.android.email.NotificationController; |
| import com.android.email2.ui.MailActivityEmail; |
| import com.android.emailcommon.provider.Account; |
| import com.android.emailcommon.provider.EmailContent; |
| import com.android.emailcommon.provider.EmailContent.Attachment; |
| import com.android.emailcommon.provider.EmailContent.AttachmentColumns; |
| import com.android.emailcommon.provider.EmailContent.Message; |
| import com.android.emailcommon.service.EmailServiceProxy; |
| import com.android.emailcommon.service.EmailServiceStatus; |
| import com.android.emailcommon.service.IEmailServiceCallback; |
| import com.android.emailcommon.utility.AttachmentUtilities; |
| import com.android.emailcommon.utility.Utility; |
| import com.android.mail.providers.UIProvider.AttachmentState; |
| import com.android.mail.utils.LogUtils; |
| |
| import java.io.File; |
| import java.io.FileDescriptor; |
| import java.io.PrintWriter; |
| import java.util.Comparator; |
| import java.util.HashMap; |
| import java.util.Iterator; |
| import java.util.TreeSet; |
| import java.util.concurrent.ConcurrentHashMap; |
| |
| public class AttachmentDownloadService extends Service implements Runnable { |
| public static final String TAG = LogUtils.TAG; |
| |
| // Minimum wait time before retrying a download that failed due to connection error |
| private static final long CONNECTION_ERROR_RETRY_MILLIS = 10 * DateUtils.SECOND_IN_MILLIS; |
| // Number of retries before we start delaying between |
| private static final long CONNECTION_ERROR_DELAY_THRESHOLD = 5; |
| // Maximum time to retry for connection errors. |
| private static final long CONNECTION_ERROR_MAX_RETRIES = 10; |
| |
| // Our idle time, waiting for notifications; this is something of a failsafe |
| private static final int PROCESS_QUEUE_WAIT_TIME = 30 * ((int)DateUtils.MINUTE_IN_MILLIS); |
| // How often our watchdog checks for callback timeouts |
| private static final int WATCHDOG_CHECK_INTERVAL = 20 * ((int)DateUtils.SECOND_IN_MILLIS); |
| // How long we'll wait for a callback before canceling a download and retrying |
| private static final int CALLBACK_TIMEOUT = 30 * ((int)DateUtils.SECOND_IN_MILLIS); |
| // Try to download an attachment in the background this many times before giving up |
| private static final int MAX_DOWNLOAD_RETRIES = 5; |
| private static final int PRIORITY_NONE = -1; |
| @SuppressWarnings("unused") |
| // Low priority will be used for opportunistic downloads |
| private static final int PRIORITY_BACKGROUND = 0; |
| // Normal priority is for forwarded downloads in outgoing mail |
| private static final int PRIORITY_SEND_MAIL = 1; |
| // High priority is for user requests |
| private static final int PRIORITY_FOREGROUND = 2; |
| |
| // Minimum free storage in order to perform prefetch (25% of total memory) |
| private static final float PREFETCH_MINIMUM_STORAGE_AVAILABLE = 0.25F; |
| // Maximum prefetch storage (also 25% of total memory) |
| private static final float PREFETCH_MAXIMUM_ATTACHMENT_STORAGE = 0.25F; |
| |
| // We can try various values here; I think 2 is completely reasonable as a first pass |
| private static final int MAX_SIMULTANEOUS_DOWNLOADS = 2; |
| // Limit on the number of simultaneous downloads per account |
| // Note that a limit of 1 is currently enforced by both Services (MailService and Controller) |
| private static final int MAX_SIMULTANEOUS_DOWNLOADS_PER_ACCOUNT = 1; |
| // Limit on the number of attachments we'll check for background download |
| private static final int MAX_ATTACHMENTS_TO_CHECK = 25; |
| |
| private static final String EXTRA_ATTACHMENT = |
| "com.android.email.AttachmentDownloadService.attachment"; |
| |
| // sRunningService is only set in the UI thread; it's visibility elsewhere is guaranteed |
| // by the use of "volatile" |
| /*package*/ static volatile AttachmentDownloadService sRunningService = null; |
| |
| /*package*/ Context mContext; |
| /*package*/ EmailConnectivityManager mConnectivityManager; |
| |
| /*package*/ final DownloadSet mDownloadSet = new DownloadSet(new DownloadComparator()); |
| |
| private final HashMap<Long, Intent> mAccountServiceMap = new HashMap<Long, Intent>(); |
| // A map of attachment storage used per account |
| // NOTE: This map is not kept current in terms of deletions (i.e. it stores the last calculated |
| // amount plus the size of any new attachments laoded). If and when we reach the per-account |
| // limit, we recalculate the actual usage |
| /*package*/ final HashMap<Long, Long> mAttachmentStorageMap = new HashMap<Long, Long>(); |
| // A map of attachment ids to the number of failed attempts to download the attachment |
| // NOTE: We do not want to persist this. This allows us to retry background downloading |
| // if any transient network errors are fixed & and the app is restarted |
| /* package */ final HashMap<Long, Integer> mAttachmentFailureMap = new HashMap<Long, Integer>(); |
| private final ServiceCallback mServiceCallback = new ServiceCallback(); |
| |
| private final Object mLock = new Object(); |
| private volatile boolean mStop = false; |
| |
| /*package*/ AccountManagerStub mAccountManagerStub; |
| |
| /** |
| * We only use the getAccounts() call from AccountManager, so this class wraps that call and |
| * allows us to build a mock account manager stub in the unit tests |
| */ |
| /*package*/ static class AccountManagerStub { |
| private int mNumberOfAccounts; |
| private final AccountManager mAccountManager; |
| |
| AccountManagerStub(Context context) { |
| if (context != null) { |
| mAccountManager = AccountManager.get(context); |
| } else { |
| mAccountManager = null; |
| } |
| } |
| |
| /*package*/ int getNumberOfAccounts() { |
| if (mAccountManager != null) { |
| return mAccountManager.getAccounts().length; |
| } else { |
| return mNumberOfAccounts; |
| } |
| } |
| |
| /*package*/ void setNumberOfAccounts(int numberOfAccounts) { |
| mNumberOfAccounts = numberOfAccounts; |
| } |
| } |
| |
| /** |
| * Watchdog alarm receiver; responsible for making sure that downloads in progress are not |
| * stalled, as determined by the timing of the most recent service callback |
| */ |
| public static class Watchdog extends BroadcastReceiver { |
| @Override |
| public void onReceive(final Context context, Intent intent) { |
| new Thread(new Runnable() { |
| @Override |
| public void run() { |
| watchdogAlarm(); |
| } |
| }, "AttachmentDownloadService Watchdog").start(); |
| } |
| } |
| |
| public static class DownloadRequest { |
| final int priority; |
| final long time; |
| final long attachmentId; |
| final long messageId; |
| final long accountId; |
| boolean inProgress = false; |
| int lastStatusCode; |
| int lastProgress; |
| long lastCallbackTime; |
| long startTime; |
| long retryCount; |
| long retryStartTime; |
| |
| private DownloadRequest(Context context, Attachment attachment) { |
| attachmentId = attachment.mId; |
| Message msg = Message.restoreMessageWithId(context, attachment.mMessageKey); |
| if (msg != null) { |
| accountId = msg.mAccountKey; |
| messageId = msg.mId; |
| } else { |
| accountId = messageId = -1; |
| } |
| priority = getPriority(attachment); |
| time = SystemClock.elapsedRealtime(); |
| } |
| |
| private DownloadRequest(DownloadRequest orig, long newTime) { |
| priority = orig.priority; |
| attachmentId = orig.attachmentId; |
| messageId = orig.messageId; |
| accountId = orig.accountId; |
| time = newTime; |
| inProgress = orig.inProgress; |
| lastStatusCode = orig.lastStatusCode; |
| lastProgress = orig.lastProgress; |
| lastCallbackTime = orig.lastCallbackTime; |
| startTime = orig.startTime; |
| retryCount = orig.retryCount; |
| retryStartTime = orig.retryStartTime; |
| } |
| |
| |
| @Override |
| public int hashCode() { |
| return (int)attachmentId; |
| } |
| |
| /** |
| * Two download requests are equals if their attachment id's are equals |
| */ |
| @Override |
| public boolean equals(Object object) { |
| if (!(object instanceof DownloadRequest)) return false; |
| DownloadRequest req = (DownloadRequest)object; |
| return req.attachmentId == attachmentId; |
| } |
| } |
| |
| /** |
| * Comparator class for the download set; we first compare by priority. Requests with equal |
| * priority are compared by the time the request was created (older requests come first) |
| */ |
| /*protected*/ static class DownloadComparator implements Comparator<DownloadRequest> { |
| @Override |
| public int compare(DownloadRequest req1, DownloadRequest req2) { |
| int res; |
| if (req1.priority != req2.priority) { |
| res = (req1.priority < req2.priority) ? -1 : 1; |
| } else { |
| if (req1.time == req2.time) { |
| res = 0; |
| } else { |
| res = (req1.time > req2.time) ? -1 : 1; |
| } |
| } |
| return res; |
| } |
| } |
| |
| /** |
| * The DownloadSet is a TreeSet sorted by priority class (e.g. low, high, etc.) and the |
| * time of the request. Higher priority requests |
| * are always processed first; among equals, the oldest request is processed first. The |
| * priority key represents this ordering. Note: All methods that change the attachment map are |
| * synchronized on the map itself |
| */ |
| /*package*/ class DownloadSet extends TreeSet<DownloadRequest> { |
| private static final long serialVersionUID = 1L; |
| private PendingIntent mWatchdogPendingIntent; |
| |
| /*package*/ DownloadSet(Comparator<? super DownloadRequest> comparator) { |
| super(comparator); |
| } |
| |
| /** |
| * Maps attachment id to DownloadRequest |
| */ |
| /*package*/ final ConcurrentHashMap<Long, DownloadRequest> mDownloadsInProgress = |
| new ConcurrentHashMap<Long, DownloadRequest>(); |
| |
| /** |
| * onChange is called by the AttachmentReceiver upon receipt of a valid notification from |
| * EmailProvider that an attachment has been inserted or modified. It's not strictly |
| * necessary that we detect a deleted attachment, as the code always checks for the |
| * existence of an attachment before acting on it. |
| */ |
| public synchronized void onChange(Context context, Attachment att) { |
| DownloadRequest req = findDownloadRequest(att.mId); |
| long priority = getPriority(att); |
| if (priority == PRIORITY_NONE) { |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| LogUtils.d(TAG, "== Attachment changed: " + att.mId); |
| } |
| // In this case, there is no download priority for this attachment |
| if (req != null) { |
| // If it exists in the map, remove it |
| // NOTE: We don't yet support deleting downloads in progress |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| LogUtils.d(TAG, "== Attachment " + att.mId + " was in queue, removing"); |
| } |
| remove(req); |
| } |
| } else { |
| // Ignore changes that occur during download |
| if (mDownloadsInProgress.containsKey(att.mId)) return; |
| // If this is new, add the request to the queue |
| if (req == null) { |
| req = new DownloadRequest(context, att); |
| add(req); |
| } |
| // If the request already existed, we'll update the priority (so that the time is |
| // up-to-date); otherwise, we create a new request |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| LogUtils.d(TAG, "== Download queued for attachment " + att.mId + ", class " + |
| req.priority + ", priority time " + req.time); |
| } |
| } |
| // Process the queue if we're in a wait |
| kick(); |
| } |
| |
| /** |
| * Find a queued DownloadRequest, given the attachment's id |
| * @param id the id of the attachment |
| * @return the DownloadRequest for that attachment (or null, if none) |
| */ |
| /*package*/ synchronized DownloadRequest findDownloadRequest(long id) { |
| Iterator<DownloadRequest> iterator = iterator(); |
| while(iterator.hasNext()) { |
| DownloadRequest req = iterator.next(); |
| if (req.attachmentId == id) { |
| return req; |
| } |
| } |
| return null; |
| } |
| |
| @Override |
| public synchronized boolean isEmpty() { |
| return super.isEmpty() && mDownloadsInProgress.isEmpty(); |
| } |
| |
| /** |
| * Run through the AttachmentMap and find DownloadRequests that can be executed, enforcing |
| * the limit on maximum downloads |
| */ |
| /*package*/ synchronized void processQueue() { |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| LogUtils.d(TAG, "== Checking attachment queue, " + mDownloadSet.size() |
| + " entries"); |
| } |
| |
| Iterator<DownloadRequest> iterator = mDownloadSet.descendingIterator(); |
| // First, start up any required downloads, in priority order |
| while (iterator.hasNext() && |
| (mDownloadsInProgress.size() < MAX_SIMULTANEOUS_DOWNLOADS)) { |
| DownloadRequest req = iterator.next(); |
| // Enforce per-account limit here |
| if (downloadsForAccount(req.accountId) >= MAX_SIMULTANEOUS_DOWNLOADS_PER_ACCOUNT) { |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| LogUtils.d(TAG, "== Skip #" + req.attachmentId + "; maxed for acct #" + |
| req.accountId); |
| } |
| continue; |
| } else if (Attachment.restoreAttachmentWithId(mContext, req.attachmentId) == null) { |
| continue; |
| } |
| if (!req.inProgress) { |
| final long currentTime = SystemClock.elapsedRealtime(); |
| if (req.retryCount > 0 && req.retryStartTime > currentTime) { |
| LogUtils.d(TAG, "== waiting to retry attachment %d", req.attachmentId); |
| setWatchdogAlarm(CONNECTION_ERROR_RETRY_MILLIS); |
| continue; |
| } |
| mDownloadSet.tryStartDownload(req); |
| } |
| } |
| |
| // Don't prefetch if background downloading is disallowed |
| EmailConnectivityManager ecm = mConnectivityManager; |
| if (ecm == null) return; |
| if (!ecm.isAutoSyncAllowed()) return; |
| // Don't prefetch unless we're on a WiFi network |
| if (ecm.getActiveNetworkType() != ConnectivityManager.TYPE_WIFI) { |
| return; |
| } |
| // Then, try opportunistic download of appropriate attachments |
| int backgroundDownloads = MAX_SIMULTANEOUS_DOWNLOADS - mDownloadsInProgress.size(); |
| // Always leave one slot for user requested download |
| if (backgroundDownloads > (MAX_SIMULTANEOUS_DOWNLOADS - 1)) { |
| // We'll load up the newest 25 attachments that aren't loaded or queued |
| Uri lookupUri = EmailContent.uriWithLimit(Attachment.CONTENT_URI, |
| MAX_ATTACHMENTS_TO_CHECK); |
| Cursor c = mContext.getContentResolver().query(lookupUri, |
| Attachment.CONTENT_PROJECTION, |
| EmailContent.Attachment.PRECACHE_INBOX_SELECTION, |
| null, Attachment.RECORD_ID + " DESC"); |
| File cacheDir = mContext.getCacheDir(); |
| try { |
| while (c.moveToNext()) { |
| Attachment att = new Attachment(); |
| att.restore(c); |
| Account account = Account.restoreAccountWithId(mContext, att.mAccountKey); |
| if (account == null) { |
| // Clean up this orphaned attachment; there's no point in keeping it |
| // around; then try to find another one |
| EmailContent.delete(mContext, Attachment.CONTENT_URI, att.mId); |
| } else { |
| // Check that the attachment meets system requirements for download |
| AttachmentInfo info = new AttachmentInfo(mContext, att); |
| if (info.isEligibleForDownload()) { |
| // Either the account must be able to prefetch or this must be |
| // an inline attachment |
| if (att.mContentId != null || |
| (canPrefetchForAccount(account, cacheDir))) { |
| Integer tryCount; |
| tryCount = mAttachmentFailureMap.get(att.mId); |
| if (tryCount != null && tryCount > MAX_DOWNLOAD_RETRIES) { |
| // move onto the next attachment |
| continue; |
| } |
| // Start this download and we're done |
| DownloadRequest req = new DownloadRequest(mContext, att); |
| mDownloadSet.tryStartDownload(req); |
| break; |
| } |
| } |
| } |
| } |
| } finally { |
| c.close(); |
| } |
| } |
| } |
| |
| /** |
| * Count the number of running downloads in progress for this account |
| * @param accountId the id of the account |
| * @return the count of running downloads |
| */ |
| /*package*/ synchronized int downloadsForAccount(long accountId) { |
| int count = 0; |
| for (DownloadRequest req: mDownloadsInProgress.values()) { |
| if (req.accountId == accountId) { |
| count++; |
| } |
| } |
| return count; |
| } |
| |
| /** |
| * Watchdog for downloads; we use this in case we are hanging on a download, which might |
| * have failed silently (the connection dropped, for example) |
| */ |
| private void onWatchdogAlarm() { |
| // If our service instance is gone, just leave |
| if (mStop) { |
| return; |
| } |
| long now = System.currentTimeMillis(); |
| for (DownloadRequest req: mDownloadsInProgress.values()) { |
| // Check how long it's been since receiving a callback |
| long timeSinceCallback = now - req.lastCallbackTime; |
| if (timeSinceCallback > CALLBACK_TIMEOUT) { |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| LogUtils.d(TAG, "== Download of " + req.attachmentId + " timed out"); |
| } |
| cancelDownload(req); |
| } |
| } |
| // Check whether we can start new downloads... |
| if (mConnectivityManager != null && mConnectivityManager.hasConnectivity()) { |
| processQueue(); |
| } |
| // If there are downloads in progress, reset alarm |
| if (!mDownloadsInProgress.isEmpty()) { |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| LogUtils.d(TAG, "Reschedule watchdog..."); |
| } |
| setWatchdogAlarm(); |
| } |
| } |
| |
| /** |
| * Attempt to execute the DownloadRequest, enforcing the maximum downloads per account |
| * parameter |
| * @param req the DownloadRequest |
| * @return whether or not the download was started |
| */ |
| /*package*/ synchronized boolean tryStartDownload(DownloadRequest req) { |
| EmailServiceProxy service = EmailServiceUtils.getServiceForAccount( |
| AttachmentDownloadService.this, req.accountId); |
| |
| // Do not download the same attachment multiple times |
| boolean alreadyInProgress = mDownloadsInProgress.get(req.attachmentId) != null; |
| if (alreadyInProgress) return false; |
| |
| try { |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| LogUtils.d(TAG, ">> Starting download for attachment #" + req.attachmentId); |
| } |
| startDownload(service, req); |
| } catch (RemoteException e) { |
| // TODO: Consider whether we need to do more in this case... |
| // For now, fix up our data to reflect the failure |
| cancelDownload(req); |
| } |
| return true; |
| } |
| |
| private synchronized DownloadRequest getDownloadInProgress(long attachmentId) { |
| return mDownloadsInProgress.get(attachmentId); |
| } |
| |
| private void setWatchdogAlarm(final long delay) { |
| // Lazily initialize the pending intent |
| if (mWatchdogPendingIntent == null) { |
| Intent intent = new Intent(mContext, Watchdog.class); |
| mWatchdogPendingIntent = |
| PendingIntent.getBroadcast(mContext, 0, intent, 0); |
| } |
| // Set the alarm |
| AlarmManager am = (AlarmManager)mContext.getSystemService(Context.ALARM_SERVICE); |
| am.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + delay, |
| mWatchdogPendingIntent); |
| } |
| |
| private void setWatchdogAlarm() { |
| setWatchdogAlarm(WATCHDOG_CHECK_INTERVAL); |
| } |
| |
| /** |
| * Do the work of starting an attachment download using the EmailService interface, and |
| * set our watchdog alarm |
| * |
| * @param service the service handling the download |
| * @param req the DownloadRequest |
| * @throws RemoteException |
| */ |
| private void startDownload(EmailServiceProxy service, DownloadRequest req) |
| throws RemoteException { |
| req.startTime = System.currentTimeMillis(); |
| req.inProgress = true; |
| mDownloadsInProgress.put(req.attachmentId, req); |
| service.loadAttachment(mServiceCallback, req.accountId, req.attachmentId, |
| req.priority != PRIORITY_FOREGROUND); |
| setWatchdogAlarm(); |
| } |
| |
| private void cancelDownload(DownloadRequest req) { |
| LogUtils.d(TAG, "cancelDownload #%d", req.attachmentId); |
| req.inProgress = false; |
| mDownloadsInProgress.remove(req.attachmentId); |
| // Remove the download from our queue, and then decide whether or not to add it back. |
| remove(req); |
| req.retryCount++; |
| if (req.retryCount > CONNECTION_ERROR_MAX_RETRIES) { |
| LogUtils.d(TAG, "too many failures, giving up"); |
| } else { |
| LogUtils.d(TAG, "moving to end of queue, will retry"); |
| // The time field of DownloadRequest is final, because it's unsafe to change it |
| // as long as the DownloadRequest is in the DownloadSet. It's needed for the |
| // comparator, so changing time would make the request unfindable. |
| // Instead, we'll create a new DownloadRequest with an updated time. |
| // This will sort at the end of the set. |
| req = new DownloadRequest(req, SystemClock.elapsedRealtime()); |
| add(req); |
| } |
| } |
| |
| /** |
| * Called when a download is finished; we get notified of this via our EmailServiceCallback |
| * @param attachmentId the id of the attachment whose download is finished |
| * @param statusCode the EmailServiceStatus code returned by the Service |
| */ |
| /*package*/ synchronized void endDownload(long attachmentId, int statusCode) { |
| // Say we're no longer downloading this |
| mDownloadsInProgress.remove(attachmentId); |
| |
| // TODO: This code is conservative and treats connection issues as failures. |
| // Since we have no mechanism to throttle reconnection attempts, it makes |
| // sense to be cautious here. Once logic is in place to prevent connecting |
| // in a tight loop, we can exclude counting connection issues as "failures". |
| |
| // Update the attachment failure list if needed |
| Integer downloadCount; |
| downloadCount = mAttachmentFailureMap.remove(attachmentId); |
| if (statusCode != EmailServiceStatus.SUCCESS) { |
| if (downloadCount == null) { |
| downloadCount = 0; |
| } |
| downloadCount += 1; |
| mAttachmentFailureMap.put(attachmentId, downloadCount); |
| } |
| |
| DownloadRequest req = mDownloadSet.findDownloadRequest(attachmentId); |
| if (statusCode == EmailServiceStatus.CONNECTION_ERROR) { |
| // If this needs to be retried, just process the queue again |
| if (req != null) { |
| req.retryCount++; |
| if (req.retryCount > CONNECTION_ERROR_MAX_RETRIES) { |
| LogUtils.d(TAG, "Connection Error #%d, giving up", attachmentId); |
| remove(req); |
| } else if (req.retryCount > CONNECTION_ERROR_DELAY_THRESHOLD) { |
| // TODO: I'm not sure this is a great retry/backoff policy, but we're |
| // afraid of changing behavior too much in case something relies upon it. |
| // So now, for the first five errors, we'll retry immediately. For the next |
| // five tries, we'll add a ten second delay between each. After that, we'll |
| // give up. |
| LogUtils.d(TAG, "ConnectionError #%d, retried %d times, adding delay", |
| attachmentId, req.retryCount); |
| req.inProgress = false; |
| req.retryStartTime = SystemClock.elapsedRealtime() + |
| CONNECTION_ERROR_RETRY_MILLIS; |
| setWatchdogAlarm(CONNECTION_ERROR_RETRY_MILLIS); |
| } else { |
| LogUtils.d(TAG, "ConnectionError #%d, retried %d times, adding delay", |
| attachmentId, req.retryCount); |
| req.inProgress = false; |
| req.retryStartTime = 0; |
| kick(); |
| } |
| } |
| return; |
| } |
| |
| // If the request is still in the queue, remove it |
| if (req != null) { |
| remove(req); |
| } |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| long secs = 0; |
| if (req != null) { |
| secs = (System.currentTimeMillis() - req.time) / 1000; |
| } |
| String status = (statusCode == EmailServiceStatus.SUCCESS) ? "Success" : |
| "Error " + statusCode; |
| LogUtils.d(TAG, "<< Download finished for attachment #" + attachmentId + "; " + secs |
| + " seconds from request, status: " + status); |
| } |
| |
| Attachment attachment = Attachment.restoreAttachmentWithId(mContext, attachmentId); |
| if (attachment != null) { |
| long accountId = attachment.mAccountKey; |
| // Update our attachment storage for this account |
| Long currentStorage = mAttachmentStorageMap.get(accountId); |
| if (currentStorage == null) { |
| currentStorage = 0L; |
| } |
| mAttachmentStorageMap.put(accountId, currentStorage + attachment.mSize); |
| boolean deleted = false; |
| if ((attachment.mFlags & Attachment.FLAG_DOWNLOAD_FORWARD) != 0) { |
| if (statusCode == EmailServiceStatus.ATTACHMENT_NOT_FOUND) { |
| // If this is a forwarding download, and the attachment doesn't exist (or |
| // can't be downloaded) delete it from the outgoing message, lest that |
| // message never get sent |
| EmailContent.delete(mContext, Attachment.CONTENT_URI, attachment.mId); |
| // TODO: Talk to UX about whether this is even worth doing |
| NotificationController nc = NotificationController.getInstance(mContext); |
| nc.showDownloadForwardFailedNotification(attachment); |
| deleted = true; |
| } |
| // If we're an attachment on forwarded mail, and if we're not still blocked, |
| // try to send pending mail now (as mediated by MailService) |
| if ((req != null) && |
| !Utility.hasUnloadedAttachments(mContext, attachment.mMessageKey)) { |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| LogUtils.d(TAG, "== Downloads finished for outgoing msg #" |
| + req.messageId); |
| } |
| EmailServiceProxy service = EmailServiceUtils.getServiceForAccount( |
| mContext, accountId); |
| try { |
| service.sendMail(accountId); |
| } catch (RemoteException e) { |
| // We tried |
| } |
| } |
| } |
| if (statusCode == EmailServiceStatus.MESSAGE_NOT_FOUND) { |
| Message msg = Message.restoreMessageWithId(mContext, attachment.mMessageKey); |
| if (msg == null) { |
| // If there's no associated message, delete the attachment |
| EmailContent.delete(mContext, Attachment.CONTENT_URI, attachment.mId); |
| } else { |
| // If there really is a message, retry |
| // TODO: How will this get retried? It's still marked as inProgress? |
| kick(); |
| return; |
| } |
| } else if (!deleted) { |
| // Clear the download flags, since we're done for now. Note that this happens |
| // only for non-recoverable errors. When these occur for forwarded mail, we can |
| // ignore it and continue; otherwise, it was either 1) a user request, in which |
| // case the user can retry manually or 2) an opportunistic download, in which |
| // case the download wasn't critical |
| ContentValues cv = new ContentValues(); |
| int flags = |
| Attachment.FLAG_DOWNLOAD_FORWARD | Attachment.FLAG_DOWNLOAD_USER_REQUEST; |
| cv.put(Attachment.FLAGS, attachment.mFlags &= ~flags); |
| cv.put(Attachment.UI_STATE, AttachmentState.SAVED); |
| attachment.update(mContext, cv); |
| } |
| } |
| // Process the queue |
| kick(); |
| } |
| } |
| |
| /** |
| * Calculate the download priority of an Attachment. A priority of zero means that the |
| * attachment is not marked for download. |
| * @param att the Attachment |
| * @return the priority key of the Attachment |
| */ |
| private static int getPriority(Attachment att) { |
| int priorityClass = PRIORITY_NONE; |
| int flags = att.mFlags; |
| if ((flags & Attachment.FLAG_DOWNLOAD_FORWARD) != 0) { |
| priorityClass = PRIORITY_SEND_MAIL; |
| } else if ((flags & Attachment.FLAG_DOWNLOAD_USER_REQUEST) != 0) { |
| priorityClass = PRIORITY_FOREGROUND; |
| } |
| return priorityClass; |
| } |
| |
| private void kick() { |
| synchronized(mLock) { |
| mLock.notify(); |
| } |
| } |
| |
| /** |
| * We use an EmailServiceCallback to keep track of the progress of downloads. These callbacks |
| * come from either Controller (IMAP) or ExchangeService (EAS). Note that we only implement the |
| * single callback that's defined by the EmailServiceCallback interface. |
| */ |
| private class ServiceCallback extends IEmailServiceCallback.Stub { |
| @Override |
| public void loadAttachmentStatus(long messageId, long attachmentId, int statusCode, |
| int progress) { |
| // Record status and progress |
| DownloadRequest req = mDownloadSet.getDownloadInProgress(attachmentId); |
| if (req != null) { |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| String code; |
| switch(statusCode) { |
| case EmailServiceStatus.SUCCESS: code = "Success"; break; |
| case EmailServiceStatus.IN_PROGRESS: code = "In progress"; break; |
| default: code = Integer.toString(statusCode); break; |
| } |
| if (statusCode != EmailServiceStatus.IN_PROGRESS) { |
| LogUtils.d(TAG, ">> Attachment status " + attachmentId + ": " + code); |
| } else if (progress >= (req.lastProgress + 10)) { |
| LogUtils.d(TAG, ">> Attachment progress %d: %d%%", attachmentId, progress); |
| } |
| } |
| req.lastStatusCode = statusCode; |
| req.lastProgress = progress; |
| req.lastCallbackTime = System.currentTimeMillis(); |
| Attachment attachment = Attachment.restoreAttachmentWithId(mContext, attachmentId); |
| if (attachment != null && statusCode == EmailServiceStatus.IN_PROGRESS) { |
| ContentValues values = new ContentValues(); |
| values.put(AttachmentColumns.UI_DOWNLOADED_SIZE, |
| attachment.mSize * progress / 100); |
| // Update UIProvider with updated download size |
| // Individual services will set contentUri and state when finished |
| attachment.update(mContext, values); |
| } |
| } |
| switch (statusCode) { |
| case EmailServiceStatus.IN_PROGRESS: |
| break; |
| default: |
| mDownloadSet.endDownload(attachmentId, statusCode); |
| break; |
| } |
| } |
| } |
| |
| /*package*/ void addServiceIntentForTest(long accountId, Intent intent) { |
| mAccountServiceMap.put(accountId, intent); |
| } |
| |
| /*package*/ void onChange(Attachment att) { |
| mDownloadSet.onChange(this, att); |
| } |
| |
| /*package*/ boolean isQueued(long attachmentId) { |
| return mDownloadSet.findDownloadRequest(attachmentId) != null; |
| } |
| |
| /*package*/ int getSize() { |
| return mDownloadSet.size(); |
| } |
| |
| /*package*/ boolean dequeue(long attachmentId) { |
| DownloadRequest req = mDownloadSet.findDownloadRequest(attachmentId); |
| if (req != null) { |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| LogUtils.d(TAG, "Dequeued attachmentId: " + attachmentId); |
| } |
| mDownloadSet.remove(req); |
| return true; |
| } |
| return false; |
| } |
| |
| /** |
| * Ask the service for the number of items in the download queue |
| * @return the number of items queued for download |
| */ |
| public static int getQueueSize() { |
| AttachmentDownloadService service = sRunningService; |
| if (service != null) { |
| return service.getSize(); |
| } |
| return 0; |
| } |
| |
| /** |
| * Ask the service whether a particular attachment is queued for download |
| * @param attachmentId the id of the Attachment (as stored by EmailProvider) |
| * @return whether or not the attachment is queued for download |
| */ |
| public static boolean isAttachmentQueued(long attachmentId) { |
| AttachmentDownloadService service = sRunningService; |
| if (service != null) { |
| return service.isQueued(attachmentId); |
| } |
| return false; |
| } |
| |
| /** |
| * Ask the service to remove an attachment from the download queue |
| * @param attachmentId the id of the Attachment (as stored by EmailProvider) |
| * @return whether or not the attachment was removed from the queue |
| */ |
| public static boolean cancelQueuedAttachment(long attachmentId) { |
| AttachmentDownloadService service = sRunningService; |
| if (service != null) { |
| return service.dequeue(attachmentId); |
| } |
| return false; |
| } |
| |
| public static void watchdogAlarm() { |
| AttachmentDownloadService service = sRunningService; |
| if (service != null) { |
| service.mDownloadSet.onWatchdogAlarm(); |
| } |
| } |
| |
| /** |
| * Called directly by EmailProvider whenever an attachment is inserted or changed |
| * @param context the caller's context |
| * @param id the attachment's id |
| * @param flags the new flags for the attachment |
| */ |
| public static void attachmentChanged(final Context context, final long id, final int flags) { |
| Utility.runAsync(new Runnable() { |
| @Override |
| public void run() { |
| Attachment attachment = Attachment.restoreAttachmentWithId(context, id); |
| if (attachment != null) { |
| // Store the flags we got from EmailProvider; given that all of this |
| // activity is asynchronous, we need to use the newest data from |
| // EmailProvider |
| attachment.mFlags = flags; |
| Intent intent = new Intent(context, AttachmentDownloadService.class); |
| intent.putExtra(EXTRA_ATTACHMENT, attachment); |
| context.startService(intent); |
| } |
| }}); |
| } |
| |
| /** |
| * Determine whether an attachment can be prefetched for the given account |
| * @return true if download is allowed, false otherwise |
| */ |
| public boolean canPrefetchForAccount(Account account, File dir) { |
| // Check account, just in case |
| if (account == null) return false; |
| // First, check preference and quickly return if prefetch isn't allowed |
| if ((account.mFlags & Account.FLAGS_BACKGROUND_ATTACHMENTS) == 0) return false; |
| |
| long totalStorage = dir.getTotalSpace(); |
| long usableStorage = dir.getUsableSpace(); |
| long minAvailable = (long)(totalStorage * PREFETCH_MINIMUM_STORAGE_AVAILABLE); |
| |
| // If there's not enough overall storage available, stop now |
| if (usableStorage < minAvailable) { |
| return false; |
| } |
| |
| int numberOfAccounts = mAccountManagerStub.getNumberOfAccounts(); |
| long perAccountMaxStorage = |
| (long)(totalStorage * PREFETCH_MAXIMUM_ATTACHMENT_STORAGE / numberOfAccounts); |
| |
| // Retrieve our idea of currently used attachment storage; since we don't track deletions, |
| // this number is the "worst case". If the number is greater than what's allowed per |
| // account, we walk the directory to determine the actual number |
| Long accountStorage = mAttachmentStorageMap.get(account.mId); |
| if (accountStorage == null || (accountStorage > perAccountMaxStorage)) { |
| // Calculate the exact figure for attachment storage for this account |
| accountStorage = 0L; |
| File[] files = dir.listFiles(); |
| if (files != null) { |
| for (File file : files) { |
| accountStorage += file.length(); |
| } |
| } |
| // Cache the value |
| mAttachmentStorageMap.put(account.mId, accountStorage); |
| } |
| |
| // Return true if we're using less than the maximum per account |
| if (accountStorage < perAccountMaxStorage) { |
| return true; |
| } else { |
| if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { |
| LogUtils.d(TAG, ">> Prefetch not allowed for account " + account.mId + "; used " + |
| accountStorage + ", limit " + perAccountMaxStorage); |
| } |
| return false; |
| } |
| } |
| |
| @Override |
| public void run() { |
| // These fields are only used within the service thread |
| mContext = this; |
| mConnectivityManager = new EmailConnectivityManager(this, TAG); |
| mAccountManagerStub = new AccountManagerStub(this); |
| |
| // Run through all attachments in the database that require download and add them to |
| // the queue |
| int mask = Attachment.FLAG_DOWNLOAD_FORWARD | Attachment.FLAG_DOWNLOAD_USER_REQUEST; |
| Cursor c = getContentResolver().query(Attachment.CONTENT_URI, |
| EmailContent.ID_PROJECTION, "(" + Attachment.FLAGS + " & ?) != 0", |
| new String[] {Integer.toString(mask)}, null); |
| try { |
| LogUtils.d(TAG, "Count: " + c.getCount()); |
| while (c.moveToNext()) { |
| Attachment attachment = Attachment.restoreAttachmentWithId( |
| this, c.getLong(EmailContent.ID_PROJECTION_COLUMN)); |
| if (attachment != null) { |
| mDownloadSet.onChange(this, attachment); |
| } |
| } |
| } catch (Exception e) { |
| e.printStackTrace(); |
| } |
| finally { |
| c.close(); |
| } |
| |
| // Loop until stopped, with a 30 minute wait loop |
| while (!mStop) { |
| // Here's where we run our attachment loading logic... |
| // Make a local copy of the variable so we don't null-crash on service shutdown |
| final EmailConnectivityManager ecm = mConnectivityManager; |
| if (ecm != null) { |
| ecm.waitForConnectivity(); |
| } |
| if (mStop) { |
| // We might be bailing out here due to the service shutting down |
| break; |
| } |
| mDownloadSet.processQueue(); |
| if (mDownloadSet.isEmpty()) { |
| LogUtils.d(TAG, "*** All done; shutting down service"); |
| stopSelf(); |
| break; |
| } |
| synchronized(mLock) { |
| try { |
| mLock.wait(PROCESS_QUEUE_WAIT_TIME); |
| } catch (InterruptedException e) { |
| // That's ok; we'll just keep looping |
| } |
| } |
| } |
| |
| // Unregister now that we're done |
| // Make a local copy of the variable so we don't null-crash on service shutdown |
| final EmailConnectivityManager ecm = mConnectivityManager; |
| if (ecm != null) { |
| ecm.unregister(); |
| } |
| } |
| |
| @Override |
| public int onStartCommand(Intent intent, int flags, int startId) { |
| if (sRunningService == null) { |
| sRunningService = this; |
| } |
| if (intent != null && intent.hasExtra(EXTRA_ATTACHMENT)) { |
| Attachment att = (Attachment)intent.getParcelableExtra(EXTRA_ATTACHMENT); |
| onChange(att); |
| } |
| return Service.START_STICKY; |
| } |
| |
| @Override |
| public void onCreate() { |
| // Start up our service thread |
| new Thread(this, "AttachmentDownloadService").start(); |
| } |
| @Override |
| public IBinder onBind(Intent intent) { |
| return null; |
| } |
| |
| @Override |
| public void onDestroy() { |
| // Mark this instance of the service as stopped |
| mStop = true; |
| if (sRunningService != null) { |
| kick(); |
| sRunningService = null; |
| } |
| if (mConnectivityManager != null) { |
| mConnectivityManager.unregister(); |
| mConnectivityManager.stopWait(); |
| mConnectivityManager = null; |
| } |
| } |
| |
| @Override |
| public void dump(FileDescriptor fd, PrintWriter pw, String[] args) { |
| pw.println("AttachmentDownloadService"); |
| long time = System.currentTimeMillis(); |
| synchronized(mDownloadSet) { |
| pw.println(" Queue, " + mDownloadSet.size() + " entries"); |
| Iterator<DownloadRequest> iterator = mDownloadSet.descendingIterator(); |
| // First, start up any required downloads, in priority order |
| while (iterator.hasNext()) { |
| DownloadRequest req = iterator.next(); |
| pw.println(" Account: " + req.accountId + ", Attachment: " + req.attachmentId); |
| pw.println(" Priority: " + req.priority + ", Time: " + req.time + |
| (req.inProgress ? " [In progress]" : "")); |
| Attachment att = Attachment.restoreAttachmentWithId(this, req.attachmentId); |
| if (att == null) { |
| pw.println(" Attachment not in database?"); |
| } else if (att.mFileName != null) { |
| String fileName = att.mFileName; |
| String suffix = "[none]"; |
| int lastDot = fileName.lastIndexOf('.'); |
| if (lastDot >= 0) { |
| suffix = fileName.substring(lastDot); |
| } |
| pw.print(" Suffix: " + suffix); |
| if (att.getContentUri() != null) { |
| pw.print(" ContentUri: " + att.getContentUri()); |
| } |
| pw.print(" Mime: "); |
| if (att.mMimeType != null) { |
| pw.print(att.mMimeType); |
| } else { |
| pw.print(AttachmentUtilities.inferMimeType(fileName, null)); |
| pw.print(" [inferred]"); |
| } |
| pw.println(" Size: " + att.mSize); |
| } |
| if (req.inProgress) { |
| pw.println(" Status: " + req.lastStatusCode + ", Progress: " + |
| req.lastProgress); |
| pw.println(" Started: " + req.startTime + ", Callback: " + |
| req.lastCallbackTime); |
| pw.println(" Elapsed: " + ((time - req.startTime) / 1000L) + "s"); |
| if (req.lastCallbackTime > 0) { |
| pw.println(" CB: " + ((time - req.lastCallbackTime) / 1000L) + "s"); |
| } |
| } |
| } |
| } |
| } |
| } |