| /* |
| * Copyright (C) 2014 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.NotificationControllerCreatorHolder; |
| import com.android.email.NotificationController; |
| 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 com.google.common.annotations.VisibleForTesting; |
| |
| import java.io.File; |
| import java.io.FileDescriptor; |
| import java.io.PrintWriter; |
| import java.util.Collection; |
| import java.util.Comparator; |
| import java.util.HashMap; |
| import java.util.PriorityQueue; |
| import java.util.Queue; |
| import java.util.concurrent.ConcurrentHashMap; |
| import java.util.concurrent.ConcurrentLinkedQueue; |
| |
| public class AttachmentService extends Service implements Runnable { |
| // For logging. |
| public static final String LOG_TAG = "AttachmentService"; |
| |
| // STOPSHIP Set this to 0 before shipping. |
| private static final int ENABLE_ATTACHMENT_SERVICE_DEBUG = 0; |
| |
| // 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 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; |
| |
| static final int PRIORITY_NONE = -1; |
| // High priority is for user requests |
| static final int PRIORITY_FOREGROUND = 0; |
| static final int PRIORITY_HIGHEST = PRIORITY_FOREGROUND; |
| // Normal priority is for forwarded downloads in outgoing mail |
| static final int PRIORITY_SEND_MAIL = 1; |
| // Low priority will be used for opportunistic downloads |
| static final int PRIORITY_BACKGROUND = 2; |
| static final int PRIORITY_LOWEST = PRIORITY_BACKGROUND; |
| |
| // 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_ID = |
| "com.android.email.AttachmentService.attachment_id"; |
| private static final String EXTRA_ATTACHMENT_FLAGS = |
| "com.android.email.AttachmentService.attachment_flags"; |
| |
| // This callback is invoked by the various service implementations to give us download progress |
| // since those modules are responsible for the actual download. |
| final ServiceCallback mServiceCallback = new ServiceCallback(); |
| |
| // sRunningService is only set in the UI thread; it's visibility elsewhere is guaranteed |
| // by the use of "volatile" |
| static volatile AttachmentService sRunningService = null; |
| |
| // Signify that we are being shut down & destroyed. |
| private volatile boolean mStop = false; |
| |
| EmailConnectivityManager mConnectivityManager; |
| |
| // Helper class that keeps track of in progress downloads to make sure that they |
| // are progressing well. |
| final AttachmentWatchdog mWatchdog = new AttachmentWatchdog(); |
| |
| private final Object mLock = new Object(); |
| |
| // A map of attachment storage used per account as we have account based maximums to follow. |
| // 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 loaded). If and when we reach the per-account |
| // limit, we recalculate the actual usage |
| final ConcurrentHashMap<Long, Long> mAttachmentStorageMap = new ConcurrentHashMap<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 |
| final ConcurrentHashMap<Long, Integer> mAttachmentFailureMap = |
| new ConcurrentHashMap<Long, Integer>(); |
| |
| // Keeps tracks of downloads in progress based on an attachment ID to DownloadRequest mapping. |
| final ConcurrentHashMap<Long, DownloadRequest> mDownloadsInProgress = |
| new ConcurrentHashMap<Long, DownloadRequest>(); |
| |
| final DownloadQueue mDownloadQueue = new DownloadQueue(); |
| |
| // The queue entries here are entries of the form {id, flags}, with the values passed in to |
| // attachmentChanged(). Entries in the queue are picked off in processQueue(). |
| private static final Queue<long[]> sAttachmentChangedQueue = |
| new ConcurrentLinkedQueue<long[]>(); |
| |
| // Extra layer of control over debug logging that should only be enabled when |
| // we need to take an extra deep dive at debugging the workflow in this class. |
| static private void debugTrace(final String format, final Object... args) { |
| if (ENABLE_ATTACHMENT_SERVICE_DEBUG > 0) { |
| LogUtils.d(LOG_TAG, String.format(format, args)); |
| } |
| } |
| |
| /** |
| * This class is used to contain the details and state of a particular request to download |
| * an attachment. These objects are constructed and either placed in the {@link DownloadQueue} |
| * or in the in-progress map used to keep track of downloads that are currently happening |
| * in the system |
| */ |
| static class DownloadRequest { |
| // Details of the request. |
| final int mPriority; |
| final long mCreatedTime; |
| final long mAttachmentId; |
| final long mMessageId; |
| final long mAccountId; |
| |
| // Status of the request. |
| boolean mInProgress = false; |
| int mLastStatusCode; |
| int mLastProgress; |
| long mLastCallbackTime; |
| long mStartTime; |
| long mRetryCount; |
| long mRetryStartTime; |
| |
| /** |
| * This constructor is mainly used for tests |
| * @param attPriority The priority of this attachment |
| * @param attId The id of the row in the attachment table. |
| */ |
| @VisibleForTesting |
| DownloadRequest(final int attPriority, final long attId) { |
| // This constructor should only be used for unit tests. |
| mCreatedTime = SystemClock.elapsedRealtime(); |
| mPriority = attPriority; |
| mAttachmentId = attId; |
| mAccountId = -1; |
| mMessageId = -1; |
| } |
| |
| private DownloadRequest(final Context context, final Attachment attachment) { |
| mAttachmentId = attachment.mId; |
| final Message msg = Message.restoreMessageWithId(context, attachment.mMessageKey); |
| if (msg != null) { |
| mAccountId = msg.mAccountKey; |
| mMessageId = msg.mId; |
| } else { |
| mAccountId = mMessageId = -1; |
| } |
| mPriority = getAttachmentPriority(attachment); |
| mCreatedTime = SystemClock.elapsedRealtime(); |
| } |
| |
| private DownloadRequest(final DownloadRequest orig, final long newTime) { |
| mPriority = orig.mPriority; |
| mAttachmentId = orig.mAttachmentId; |
| mMessageId = orig.mMessageId; |
| mAccountId = orig.mAccountId; |
| mCreatedTime = newTime; |
| mInProgress = orig.mInProgress; |
| mLastStatusCode = orig.mLastStatusCode; |
| mLastProgress = orig.mLastProgress; |
| mLastCallbackTime = orig.mLastCallbackTime; |
| mStartTime = orig.mStartTime; |
| mRetryCount = orig.mRetryCount; |
| mRetryStartTime = orig.mRetryStartTime; |
| } |
| |
| @Override |
| public int hashCode() { |
| return (int)mAttachmentId; |
| } |
| |
| /** |
| * Two download requests are equals if their attachment id's are equals |
| */ |
| @Override |
| public boolean equals(final Object object) { |
| if (!(object instanceof DownloadRequest)) return false; |
| final DownloadRequest req = (DownloadRequest)object; |
| return req.mAttachmentId == mAttachmentId; |
| } |
| } |
| |
| /** |
| * This class is used to organize the various download requests that are pending. |
| * We need a class that allows us to prioritize a collection of {@link DownloadRequest} objects |
| * while being able to pull off request with the highest priority but we also need |
| * to be able to find a particular {@link DownloadRequest} by id or by reference for retrieval. |
| * Bonus points for an implementation that does not require an iterator to accomplish its tasks |
| * as we can avoid pesky ConcurrentModificationException when one thread has the iterator |
| * and another thread modifies the collection. |
| */ |
| static class DownloadQueue { |
| private final int DEFAULT_SIZE = 10; |
| |
| // For synchronization |
| private final Object mLock = new Object(); |
| |
| /** |
| * 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) |
| */ |
| private static class DownloadComparator implements Comparator<DownloadRequest> { |
| @Override |
| public int compare(DownloadRequest req1, DownloadRequest req2) { |
| int res; |
| if (req1.mPriority != req2.mPriority) { |
| res = (req1.mPriority < req2.mPriority) ? -1 : 1; |
| } else { |
| if (req1.mCreatedTime == req2.mCreatedTime) { |
| res = 0; |
| } else { |
| res = (req1.mCreatedTime < req2.mCreatedTime) ? -1 : 1; |
| } |
| } |
| return res; |
| } |
| } |
| |
| // For prioritization of DownloadRequests. |
| final PriorityQueue<DownloadRequest> mRequestQueue = |
| new PriorityQueue<DownloadRequest>(DEFAULT_SIZE, new DownloadComparator()); |
| |
| // Secondary collection to quickly find objects w/o the help of an iterator. |
| // This class should be kept in lock step with the priority queue. |
| final ConcurrentHashMap<Long, DownloadRequest> mRequestMap = |
| new ConcurrentHashMap<Long, DownloadRequest>(); |
| |
| /** |
| * This function will add the request to our collections if it does not already |
| * exist. If it does exist, the function will silently succeed. |
| * @param request The {@link DownloadRequest} that should be added to our queue |
| * @return true if it was added (or already exists), false otherwise |
| */ |
| public boolean addRequest(final DownloadRequest request) |
| throws NullPointerException { |
| // It is key to keep the map and queue in lock step |
| if (request == null) { |
| // We can't add a null entry into the queue so let's throw what the underlying |
| // data structure would throw. |
| throw new NullPointerException(); |
| } |
| final long requestId = request.mAttachmentId; |
| if (requestId < 0) { |
| // Invalid request |
| LogUtils.d(LOG_TAG, "Not adding a DownloadRequest with an invalid attachment id"); |
| return false; |
| } |
| debugTrace("Queuing DownloadRequest #%d", requestId); |
| synchronized (mLock) { |
| // Check to see if this request is is already in the queue |
| final boolean exists = mRequestMap.containsKey(requestId); |
| if (!exists) { |
| mRequestQueue.offer(request); |
| mRequestMap.put(requestId, request); |
| } else { |
| debugTrace("DownloadRequest #%d was already in the queue"); |
| } |
| } |
| return true; |
| } |
| |
| /** |
| * This function will remove the specified request from the internal collections. |
| * @param request The {@link DownloadRequest} that should be removed from our queue |
| * @return true if it was removed or the request was invalid (meaning that the request |
| * is not in our queue), false otherwise. |
| */ |
| public boolean removeRequest(final DownloadRequest request) { |
| if (request == null) { |
| // If it is invalid, its not in the queue. |
| return true; |
| } |
| debugTrace("Removing DownloadRequest #%d", request.mAttachmentId); |
| final boolean result; |
| synchronized (mLock) { |
| // It is key to keep the map and queue in lock step |
| result = mRequestQueue.remove(request); |
| if (result) { |
| mRequestMap.remove(request.mAttachmentId); |
| } |
| return result; |
| } |
| } |
| |
| /** |
| * Return the next request from our queue. |
| * @return The next {@link DownloadRequest} object or null if the queue is empty |
| */ |
| public DownloadRequest getNextRequest() { |
| // It is key to keep the map and queue in lock step |
| final DownloadRequest returnRequest; |
| synchronized (mLock) { |
| returnRequest = mRequestQueue.poll(); |
| if (returnRequest != null) { |
| final long requestId = returnRequest.mAttachmentId; |
| mRequestMap.remove(requestId); |
| } |
| } |
| if (returnRequest != null) { |
| debugTrace("Retrieved DownloadRequest #%d", returnRequest.mAttachmentId); |
| } |
| return returnRequest; |
| } |
| |
| /** |
| * Return the {@link DownloadRequest} with the given ID (attachment ID) |
| * @param requestId The ID of the request in question |
| * @return The associated {@link DownloadRequest} object or null if it does not exist |
| */ |
| public DownloadRequest findRequestById(final long requestId) { |
| if (requestId < 0) { |
| return null; |
| } |
| synchronized (mLock) { |
| return mRequestMap.get(requestId); |
| } |
| } |
| |
| public int getSize() { |
| synchronized (mLock) { |
| return mRequestMap.size(); |
| } |
| } |
| |
| public boolean isEmpty() { |
| synchronized (mLock) { |
| return mRequestMap.isEmpty(); |
| } |
| } |
| } |
| |
| /** |
| * 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 AttachmentWatchdog extends BroadcastReceiver { |
| // How often our watchdog checks for callback timeouts |
| private static final int WATCHDOG_CHECK_INTERVAL = 20 * ((int)DateUtils.SECOND_IN_MILLIS); |
| public static final String EXTRA_CALLBACK_TIMEOUT = "callback_timeout"; |
| private PendingIntent mWatchdogPendingIntent; |
| |
| public void setWatchdogAlarm(final Context context, final long delay, |
| final int callbackTimeout) { |
| // Lazily initialize the pending intent |
| if (mWatchdogPendingIntent == null) { |
| Intent intent = new Intent(context, AttachmentWatchdog.class); |
| intent.putExtra(EXTRA_CALLBACK_TIMEOUT, callbackTimeout); |
| mWatchdogPendingIntent = |
| PendingIntent.getBroadcast(context, 0, intent, 0); |
| } |
| // Set the alarm |
| final AlarmManager am = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); |
| am.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + delay, |
| mWatchdogPendingIntent); |
| debugTrace("Set up a watchdog for %d millis in the future", delay); |
| } |
| |
| public void setWatchdogAlarm(final Context context) { |
| // Call the real function with default values. |
| setWatchdogAlarm(context, WATCHDOG_CHECK_INTERVAL, CALLBACK_TIMEOUT); |
| } |
| |
| @Override |
| public void onReceive(final Context context, final Intent intent) { |
| final int callbackTimeout = intent.getIntExtra(EXTRA_CALLBACK_TIMEOUT, |
| CALLBACK_TIMEOUT); |
| new Thread(new Runnable() { |
| @Override |
| public void run() { |
| // TODO: Really don't like hard coding the AttachmentService reference here |
| // as it makes testing harder if we are trying to mock out the service |
| // We should change this with some sort of getter that returns the |
| // static (or test) AttachmentService instance to use. |
| final AttachmentService service = AttachmentService.sRunningService; |
| if (service != null) { |
| // If our service instance is gone, just leave |
| if (service.mStop) { |
| return; |
| } |
| // Get the timeout time from the intent. |
| watchdogAlarm(service, callbackTimeout); |
| } |
| } |
| }, "AttachmentService AttachmentWatchdog").start(); |
| } |
| |
| boolean validateDownloadRequest(final DownloadRequest dr, final int callbackTimeout, |
| final long now) { |
| // Check how long it's been since receiving a callback |
| final long timeSinceCallback = now - dr.mLastCallbackTime; |
| if (timeSinceCallback > callbackTimeout) { |
| LogUtils.d(LOG_TAG, "Timeout for DownloadRequest #%d ", dr.mAttachmentId); |
| return true; |
| } |
| return false; |
| } |
| |
| /** |
| * Watchdog for downloads; we use this in case we are hanging on a download, which might |
| * have failed silently (the connection dropped, for example) |
| */ |
| void watchdogAlarm(final AttachmentService service, final int callbackTimeout) { |
| debugTrace("Received a timer callback in the watchdog"); |
| |
| // We want to iterate on each of the downloads that are currently in progress and |
| // cancel the ones that seem to be taking too long. |
| final Collection<DownloadRequest> inProgressRequests = |
| service.mDownloadsInProgress.values(); |
| for (DownloadRequest req: inProgressRequests) { |
| debugTrace("Checking in-progress request with id: %d", req.mAttachmentId); |
| final boolean shouldCancelDownload = validateDownloadRequest(req, callbackTimeout, |
| System.currentTimeMillis()); |
| if (shouldCancelDownload) { |
| LogUtils.w(LOG_TAG, "Cancelling DownloadRequest #%d", req.mAttachmentId); |
| service.cancelDownload(req); |
| // TODO: Should we also mark the attachment as failed at this point in time? |
| } |
| } |
| // Check whether we can start new downloads... |
| if (service.isConnected()) { |
| service.processQueue(); |
| } |
| issueNextWatchdogAlarm(service); |
| } |
| |
| void issueNextWatchdogAlarm(final AttachmentService service) { |
| if (!service.mDownloadsInProgress.isEmpty()) { |
| debugTrace("Rescheduling watchdog..."); |
| setWatchdogAlarm(service); |
| } |
| } |
| } |
| |
| /** |
| * We use an EmailServiceCallback to keep track of the progress of downloads. These callbacks |
| * come from either Controller (IMAP/POP) or ExchangeService (EAS). Note that we only |
| * implement the single callback that's defined by the EmailServiceCallback interface. |
| */ |
| class ServiceCallback extends IEmailServiceCallback.Stub { |
| |
| /** |
| * Simple routine to generate updated status values for the Attachment based on the |
| * service callback. Right now it is very simple but factoring out this code allows us |
| * to test easier and very easy to expand in the future. |
| */ |
| ContentValues getAttachmentUpdateValues(final Attachment attachment, |
| final int statusCode, final int progress) { |
| final ContentValues values = new ContentValues(); |
| if (attachment != null) { |
| if (statusCode == EmailServiceStatus.IN_PROGRESS) { |
| // TODO: What else do we want to expose about this in-progress download through |
| // the provider? If there is more, make sure that the service implementation |
| // reports it and make sure that we add it here. |
| values.put(AttachmentColumns.UI_STATE, AttachmentState.DOWNLOADING); |
| values.put(AttachmentColumns.UI_DOWNLOADED_SIZE, |
| attachment.mSize * progress / 100); |
| } |
| } |
| return values; |
| } |
| |
| @Override |
| public void loadAttachmentStatus(final long messageId, final long attachmentId, |
| final int statusCode, final int progress) { |
| debugTrace(LOG_TAG, "ServiceCallback for attachment #%d", attachmentId); |
| |
| // Record status and progress |
| final DownloadRequest req = mDownloadsInProgress.get(attachmentId); |
| if (req != null) { |
| final long now = System.currentTimeMillis(); |
| debugTrace("ServiceCallback: status code changing from %d to %d", |
| req.mLastStatusCode, statusCode); |
| debugTrace("ServiceCallback: progress changing from %d to %d", |
| req.mLastProgress,progress); |
| debugTrace("ServiceCallback: last callback time changing from %d to %d", |
| req.mLastCallbackTime, now); |
| |
| // Update some state to keep track of the progress of the download |
| req.mLastStatusCode = statusCode; |
| req.mLastProgress = progress; |
| req.mLastCallbackTime = now; |
| |
| // Update the attachment status in the provider. |
| final Attachment attachment = |
| Attachment.restoreAttachmentWithId(AttachmentService.this, attachmentId); |
| final ContentValues values = getAttachmentUpdateValues(attachment, statusCode, |
| progress); |
| if (values.size() > 0) { |
| attachment.update(AttachmentService.this, values); |
| } |
| |
| switch (statusCode) { |
| case EmailServiceStatus.IN_PROGRESS: |
| break; |
| default: |
| // It is assumed that any other error is either a success or an error |
| // Either way, the final updates to the DownloadRequest and attachment |
| // objects will be handed there. |
| LogUtils.d(LOG_TAG, "Attachment #%d is done", attachmentId); |
| endDownload(attachmentId, statusCode); |
| break; |
| } |
| } else { |
| // The only way that we can get a callback from the service implementation for |
| // an attachment that doesn't exist is if it was cancelled due to the |
| // AttachmentWatchdog. This is a valid scenario and the Watchdog should have already |
| // marked this attachment as failed/cancelled. |
| } |
| } |
| } |
| |
| /** |
| * Called directly by EmailProvider whenever an attachment is inserted or changed. Since this |
| * call is being invoked on the UI thread, we need to make sure that the downloads are |
| * happening in the background. |
| * @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) { |
| LogUtils.d(LOG_TAG, "Attachment with id: %d will potentially be queued for download", id); |
| // Throw this info into an intent and send it to the attachment service. |
| final Intent intent = new Intent(context, AttachmentService.class); |
| debugTrace("Calling startService with extras %d & %d", id, flags); |
| intent.putExtra(EXTRA_ATTACHMENT_ID, id); |
| intent.putExtra(EXTRA_ATTACHMENT_FLAGS, flags); |
| context.startService(intent); |
| } |
| |
| /** |
| * The main entry point for this service, the attachment to download can be identified |
| * by the EXTRA_ATTACHMENT extra in the intent. |
| */ |
| @Override |
| public int onStartCommand(final Intent intent, final int flags, final int startId) { |
| if (sRunningService == null) { |
| sRunningService = this; |
| } |
| if (intent != null) { |
| // Let's add this id/flags combo to the list of potential attachments to process. |
| final long attachment_id = intent.getLongExtra(EXTRA_ATTACHMENT_ID, -1); |
| final int attachment_flags = intent.getIntExtra(EXTRA_ATTACHMENT_FLAGS, -1); |
| if ((attachment_id >= 0) && (attachment_flags >= 0)) { |
| sAttachmentChangedQueue.add(new long[]{attachment_id, attachment_flags}); |
| // Process the queue if we're in a wait |
| kick(); |
| } else { |
| debugTrace("Received an invalid intent w/o the required extras %d & %d", |
| attachment_id, attachment_flags); |
| } |
| } else { |
| debugTrace("Received a null intent in onStartCommand"); |
| } |
| return Service.START_STICKY; |
| } |
| |
| /** |
| * Most of the leg work is done by our service thread that is created when this |
| * service is created. |
| */ |
| @Override |
| public void onCreate() { |
| // Start up our service thread. |
| new Thread(this, "AttachmentService").start(); |
| } |
| |
| @Override |
| public IBinder onBind(final Intent intent) { |
| return null; |
| } |
| |
| @Override |
| public void onDestroy() { |
| debugTrace("Destroying AttachmentService object"); |
| dumpInProgressDownloads(); |
| |
| // Mark this instance of the service as stopped. Our main loop for the AttachmentService |
| // checks for this flag along with the AttachmentWatchdog. |
| mStop = true; |
| if (sRunningService != null) { |
| // Kick it awake to get it to realize that we are stopping. |
| kick(); |
| sRunningService = null; |
| } |
| if (mConnectivityManager != null) { |
| mConnectivityManager.unregister(); |
| mConnectivityManager.stopWait(); |
| mConnectivityManager = null; |
| } |
| } |
| |
| /** |
| * The main routine for our AttachmentService service thread. |
| */ |
| @Override |
| public void run() { |
| // These fields are only used within the service thread |
| mConnectivityManager = new EmailConnectivityManager(this, LOG_TAG); |
| mAccountManagerStub = new AccountManagerStub(this); |
| |
| // Run through all attachments in the database that require download and add them to |
| // the queue. This is the case where a previous AttachmentService may have been notified |
| // to stop before processing everything in its queue. |
| final int mask = Attachment.FLAG_DOWNLOAD_FORWARD | Attachment.FLAG_DOWNLOAD_USER_REQUEST; |
| final Cursor c = getContentResolver().query(Attachment.CONTENT_URI, |
| EmailContent.ID_PROJECTION, "(" + AttachmentColumns.FLAGS + " & ?) != 0", |
| new String[] {Integer.toString(mask)}, null); |
| try { |
| LogUtils.d(LOG_TAG, |
| "Count of previous downloads to resume (from db): %d", c.getCount()); |
| while (c.moveToNext()) { |
| final Attachment attachment = Attachment.restoreAttachmentWithId( |
| this, c.getLong(EmailContent.ID_PROJECTION_COLUMN)); |
| if (attachment != null) { |
| debugTrace("Attempting to download attachment #%d again.", attachment.mId); |
| 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 |
| LogUtils.d(LOG_TAG, "AttachmentService has been instructed to stop"); |
| break; |
| } |
| |
| // In advanced debug mode, let's look at the state of all in-progress downloads |
| // after processQueue() runs. |
| debugTrace("In progress downloads before processQueue"); |
| dumpInProgressDownloads(); |
| processQueue(); |
| debugTrace("In progress downloads after processQueue"); |
| dumpInProgressDownloads(); |
| |
| if (mDownloadQueue.isEmpty() && (mDownloadsInProgress.size() < 1)) { |
| LogUtils.d(LOG_TAG, "Shutting down service. No in-progress or pending downloads."); |
| stopSelf(); |
| break; |
| } |
| debugTrace("Run() idle, wait for mLock (something to do)"); |
| synchronized(mLock) { |
| try { |
| mLock.wait(PROCESS_QUEUE_WAIT_TIME); |
| } catch (InterruptedException e) { |
| // That's ok; we'll just keep looping |
| } |
| } |
| debugTrace("Run() got mLock (there is work to do or we timed out)"); |
| } |
| |
| // 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(); |
| } |
| } |
| |
| /* |
| * Function that kicks the service into action as it may be waiting for this object |
| * as it processed the last round of attachments. |
| */ |
| private void kick() { |
| synchronized(mLock) { |
| mLock.notify(); |
| } |
| } |
| |
| /** |
| * 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(final Context context, final Attachment att) { |
| debugTrace("onChange() for Attachment: #%d", att.mId); |
| DownloadRequest req = mDownloadQueue.findRequestById(att.mId); |
| final long priority = getAttachmentPriority(att); |
| if (priority == PRIORITY_NONE) { |
| LogUtils.d(LOG_TAG, "Attachment #%d has no priority and will not be downloaded", |
| 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 |
| mDownloadQueue.removeRequest(req); |
| } |
| } else { |
| // Ignore changes that occur during download |
| if (mDownloadsInProgress.containsKey(att.mId)) { |
| debugTrace("Attachment #%d was already in the queue", att.mId); |
| return; |
| } |
| // If this is new, add the request to the queue |
| if (req == null) { |
| LogUtils.d(LOG_TAG, "Attachment #%d is a new download request", att.mId); |
| req = new DownloadRequest(context, att); |
| final AttachmentInfo attachInfo = new AttachmentInfo(context, att); |
| if (!attachInfo.isEligibleForDownload()) { |
| LogUtils.w(LOG_TAG, "Attachment #%d is not eligible for download", att.mId); |
| // We can't download this file due to policy, depending on what type |
| // of request we received, we handle the response differently. |
| if (((att.mFlags & Attachment.FLAG_DOWNLOAD_USER_REQUEST) != 0) || |
| ((att.mFlags & Attachment.FLAG_POLICY_DISALLOWS_DOWNLOAD) != 0)) { |
| LogUtils.w(LOG_TAG, "Attachment #%d cannot be downloaded ever", att.mId); |
| // There are a couple of situations where we will not even allow this |
| // request to go in the queue because we can already process it as a |
| // failure. |
| // 1. The user explicitly wants to download this attachment from the |
| // email view but they should not be able to...either because there is |
| // no app to view it or because its been marked as a policy violation. |
| // 2. The user is forwarding an email and the attachment has been |
| // marked as a policy violation. If the attachment is non viewable |
| // that is OK for forwarding a message so we'll let it pass through |
| markAttachmentAsFailed(att); |
| return; |
| } |
| // If we get this far it a forward of an attachment that is only |
| // ineligible because we can't view it or process it. Not because we |
| // can't download it for policy reasons. Let's let this go through because |
| // the final recipient of this forward email might be able to process it. |
| } |
| mDownloadQueue.addRequest(req); |
| } |
| // TODO: If the request already existed, we'll update the priority (so that the time is |
| // up-to-date); otherwise, create a new request |
| LogUtils.d(LOG_TAG, |
| "Attachment #%d queued for download, priority: %d, created time: %d", |
| att.mId, req.mPriority, req.mCreatedTime); |
| } |
| // Process the queue if we're in a wait |
| kick(); |
| } |
| |
| /** |
| * Set the bits in the provider to mark this download as failed. |
| * @param att The attachment that failed to download. |
| */ |
| void markAttachmentAsFailed(final Attachment att) { |
| final ContentValues cv = new ContentValues(); |
| final int flags = Attachment.FLAG_DOWNLOAD_FORWARD | Attachment.FLAG_DOWNLOAD_USER_REQUEST; |
| cv.put(AttachmentColumns.FLAGS, att.mFlags &= ~flags); |
| cv.put(AttachmentColumns.UI_STATE, AttachmentState.FAILED); |
| att.update(this, cv); |
| } |
| |
| /** |
| * Set the bits in the provider to mark this download as completed. |
| * @param att The attachment that was downloaded. |
| */ |
| void markAttachmentAsCompleted(final Attachment att) { |
| final ContentValues cv = new ContentValues(); |
| final int flags = Attachment.FLAG_DOWNLOAD_FORWARD | Attachment.FLAG_DOWNLOAD_USER_REQUEST; |
| cv.put(AttachmentColumns.FLAGS, att.mFlags &= ~flags); |
| cv.put(AttachmentColumns.UI_STATE, AttachmentState.SAVED); |
| att.update(this, cv); |
| } |
| |
| /** |
| * Run through the AttachmentMap and find DownloadRequests that can be executed, enforcing |
| * the limit on maximum downloads |
| */ |
| synchronized void processQueue() { |
| debugTrace("Processing changed queue, num entries: %d", sAttachmentChangedQueue.size()); |
| |
| // First thing we need to do is process the list of "potential downloads" that we |
| // added to sAttachmentChangedQueue |
| long[] change = sAttachmentChangedQueue.poll(); |
| while (change != null) { |
| // Process this change |
| final long id = change[0]; |
| final long flags = change[1]; |
| final Attachment attachment = Attachment.restoreAttachmentWithId(this, id); |
| if (attachment == null) { |
| LogUtils.w(LOG_TAG, "Could not restore attachment #%d", id); |
| } else { |
| attachment.mFlags = (int) flags; |
| onChange(this, attachment); |
| } |
| change = sAttachmentChangedQueue.poll(); |
| } |
| |
| debugTrace("Processing download queue, num entries: %d", mDownloadQueue.getSize()); |
| |
| while (mDownloadsInProgress.size() < MAX_SIMULTANEOUS_DOWNLOADS) { |
| final DownloadRequest req = mDownloadQueue.getNextRequest(); |
| if (req == null) { |
| // No more queued requests? We are done for now. |
| break; |
| } |
| // Enforce per-account limit here |
| if (getDownloadsForAccount(req.mAccountId) >= MAX_SIMULTANEOUS_DOWNLOADS_PER_ACCOUNT) { |
| LogUtils.w(LOG_TAG, "Skipping #%d; maxed for acct %d", |
| req.mAttachmentId, req.mAccountId); |
| continue; |
| } |
| if (Attachment.restoreAttachmentWithId(this, req.mAttachmentId) == null) { |
| LogUtils.e(LOG_TAG, "Could not load attachment: #%d", req.mAttachmentId); |
| continue; |
| } |
| if (!req.mInProgress) { |
| final long currentTime = SystemClock.elapsedRealtime(); |
| if (req.mRetryCount > 0 && req.mRetryStartTime > currentTime) { |
| debugTrace("Need to wait before retrying attachment #%d", req.mAttachmentId); |
| mWatchdog.setWatchdogAlarm(this, CONNECTION_ERROR_RETRY_MILLIS, |
| CALLBACK_TIMEOUT); |
| continue; |
| } |
| // TODO: We try to gate ineligible downloads from entering the queue but its |
| // always possible that they made it in here regardless in the future. In a |
| // perfect world, we would make it bullet proof with a check for eligibility |
| // here instead/also. |
| tryStartDownload(req); |
| } |
| } |
| |
| // Check our ability to be opportunistic regarding background downloads. |
| final EmailConnectivityManager ecm = mConnectivityManager; |
| if ((ecm == null) || !ecm.isAutoSyncAllowed() || |
| (ecm.getActiveNetworkType() != ConnectivityManager.TYPE_WIFI)) { |
| // Only prefetch if it if connectivity is available, prefetch is enabled |
| // and we are on WIFI |
| LogUtils.d(LOG_TAG, "Skipping opportunistic downloads since WIFI is not available"); |
| return; |
| } |
| |
| // Then, try opportunistic download of appropriate attachments |
| final int availableBackgroundThreads = |
| MAX_SIMULTANEOUS_DOWNLOADS - mDownloadsInProgress.size(); |
| if (availableBackgroundThreads < 1) { |
| // We want to leave one spot open for a user requested download that we haven't |
| // started processing yet. |
| LogUtils.d(LOG_TAG, "Skipping opportunistic downloads, %d threads available", |
| availableBackgroundThreads); |
| dumpInProgressDownloads(); |
| return; |
| } |
| |
| debugTrace("Launching up to %d opportunistic downloads", availableBackgroundThreads); |
| |
| // We'll load up the newest 25 attachments that aren't loaded or queued |
| // TODO: We are always looking for MAX_ATTACHMENTS_TO_CHECK, shouldn't this be |
| // backgroundDownloads instead? We should fix and test this. |
| final Uri lookupUri = EmailContent.uriWithLimit(Attachment.CONTENT_URI, |
| MAX_ATTACHMENTS_TO_CHECK); |
| final Cursor c = this.getContentResolver().query(lookupUri, |
| Attachment.CONTENT_PROJECTION, |
| EmailContent.Attachment.PRECACHE_INBOX_SELECTION, |
| null, AttachmentColumns._ID + " DESC"); |
| File cacheDir = this.getCacheDir(); |
| try { |
| while (c.moveToNext()) { |
| final Attachment att = new Attachment(); |
| att.restore(c); |
| final Account account = Account.restoreAccountWithId(this, att.mAccountKey); |
| if (account == null) { |
| // Clean up this orphaned attachment; there's no point in keeping it |
| // around; then try to find another one |
| debugTrace("Found orphaned attachment #%d", att.mId); |
| EmailContent.delete(this, Attachment.CONTENT_URI, att.mId); |
| } else { |
| // Check that the attachment meets system requirements for download |
| // Note that there couple be policy that does not allow this attachment |
| // to be downloaded. |
| final AttachmentInfo info = new AttachmentInfo(this, 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)) { |
| final Integer tryCount = mAttachmentFailureMap.get(att.mId); |
| if (tryCount != null && tryCount > MAX_DOWNLOAD_RETRIES) { |
| // move onto the next attachment |
| LogUtils.w(LOG_TAG, |
| "Too many failed attempts for attachment #%d ", att.mId); |
| continue; |
| } |
| // Start this download and we're done |
| final DownloadRequest req = new DownloadRequest(this, att); |
| tryStartDownload(req); |
| break; |
| } |
| } else { |
| // If this attachment was ineligible for download |
| // because of policy related issues, its flags would be set to |
| // FLAG_POLICY_DISALLOWS_DOWNLOAD and would not show up in the |
| // query results. We are most likely here for other reasons such |
| // as the inability to view the attachment. In that case, let's just |
| // skip it for now. |
| LogUtils.w(LOG_TAG, "Skipping attachment #%d, it is ineligible", att.mId); |
| } |
| } |
| } |
| } finally { |
| c.close(); |
| } |
| } |
| |
| /** |
| * Attempt to execute the DownloadRequest, enforcing the maximum downloads per account |
| * parameter |
| * @param req the DownloadRequest |
| * @return whether or not the download was started |
| */ |
| synchronized boolean tryStartDownload(final DownloadRequest req) { |
| final EmailServiceProxy service = EmailServiceUtils.getServiceForAccount( |
| AttachmentService.this, req.mAccountId); |
| |
| // Do not download the same attachment multiple times |
| boolean alreadyInProgress = mDownloadsInProgress.get(req.mAttachmentId) != null; |
| if (alreadyInProgress) { |
| debugTrace("This attachment #%d is already in progress", req.mAttachmentId); |
| return false; |
| } |
| |
| try { |
| 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; |
| } |
| |
| /** |
| * 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(final EmailServiceProxy service, final DownloadRequest req) |
| throws RemoteException { |
| LogUtils.d(LOG_TAG, "Starting download for Attachment #%d", req.mAttachmentId); |
| req.mStartTime = System.currentTimeMillis(); |
| req.mInProgress = true; |
| mDownloadsInProgress.put(req.mAttachmentId, req); |
| service.loadAttachment(mServiceCallback, req.mAccountId, req.mAttachmentId, |
| req.mPriority != PRIORITY_FOREGROUND); |
| mWatchdog.setWatchdogAlarm(this); |
| } |
| |
| synchronized void cancelDownload(final DownloadRequest req) { |
| LogUtils.d(LOG_TAG, "Cancelling download for Attachment #%d", req.mAttachmentId); |
| req.mInProgress = false; |
| mDownloadsInProgress.remove(req.mAttachmentId); |
| // Remove the download from our queue, and then decide whether or not to add it back. |
| mDownloadQueue.removeRequest(req); |
| req.mRetryCount++; |
| if (req.mRetryCount > CONNECTION_ERROR_MAX_RETRIES) { |
| LogUtils.w(LOG_TAG, "Too many failures giving up on Attachment #%d", req.mAttachmentId); |
| } else { |
| debugTrace("Moving to end of queue, will retry #%d", req.mAttachmentId); |
| // 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. |
| final DownloadRequest newReq = new DownloadRequest(req, SystemClock.elapsedRealtime()); |
| mDownloadQueue.addRequest(newReq); |
| } |
| } |
| |
| /** |
| * 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 |
| */ |
| synchronized void endDownload(final long attachmentId, final int statusCode) { |
| LogUtils.d(LOG_TAG, "Finishing download #%d", attachmentId); |
| |
| // 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; |
| LogUtils.w(LOG_TAG, "This attachment failed, adding #%d to failure map", attachmentId); |
| mAttachmentFailureMap.put(attachmentId, downloadCount); |
| } |
| |
| final DownloadRequest req = mDownloadQueue.findRequestById(attachmentId); |
| if (statusCode == EmailServiceStatus.CONNECTION_ERROR) { |
| // If this needs to be retried, just process the queue again |
| if (req != null) { |
| req.mRetryCount++; |
| if (req.mRetryCount > CONNECTION_ERROR_MAX_RETRIES) { |
| // We are done, we maxed out our total number of tries. |
| // Not that we do not flag this attachment with any special flags so the |
| // AttachmentService will try to download this attachment again the next time |
| // that it starts up. |
| LogUtils.w(LOG_TAG, "Too many tried for connection errors, giving up #%d", |
| attachmentId); |
| mDownloadQueue.removeRequest(req); |
| // Note that we are not doing anything with the attachment right now |
| // We will annotate it later in this function if needed. |
| } else if (req.mRetryCount > 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.w(LOG_TAG, "ConnectionError #%d, retried %d times, adding delay", |
| attachmentId, req.mRetryCount); |
| req.mInProgress = false; |
| req.mRetryStartTime = SystemClock.elapsedRealtime() + |
| CONNECTION_ERROR_RETRY_MILLIS; |
| mWatchdog.setWatchdogAlarm(this, CONNECTION_ERROR_RETRY_MILLIS, |
| CALLBACK_TIMEOUT); |
| } else { |
| LogUtils.w(LOG_TAG, "ConnectionError for #%d, retried %d times, adding delay", |
| attachmentId, req.mRetryCount); |
| req.mInProgress = false; |
| req.mRetryStartTime = 0; |
| kick(); |
| } |
| } |
| return; |
| } |
| |
| // If the request is still in the queue, remove it |
| if (req != null) { |
| mDownloadQueue.removeRequest(req); |
| } |
| |
| if (ENABLE_ATTACHMENT_SERVICE_DEBUG > 0) { |
| long secs = 0; |
| if (req != null) { |
| secs = (System.currentTimeMillis() - req.mCreatedTime) / 1000; |
| } |
| final String status = (statusCode == EmailServiceStatus.SUCCESS) ? "Success" : |
| "Error " + statusCode; |
| debugTrace("Download finished for attachment #%d; %d seconds from request, status: %s", |
| attachmentId, secs, status); |
| } |
| |
| final Attachment attachment = Attachment.restoreAttachmentWithId(this, attachmentId); |
| if (attachment != null) { |
| final 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(this, Attachment.CONTENT_URI, attachment.mId); |
| // TODO: Talk to UX about whether this is even worth doing |
| final NotificationController nc = |
| NotificationControllerCreatorHolder.getInstance(this); |
| if (nc != null) { |
| nc.showDownloadForwardFailedNotificationSynchronous(attachment); |
| } |
| deleted = true; |
| LogUtils.w(LOG_TAG, "Deleting forwarded attachment #%d for message #%d", |
| attachmentId, attachment.mMessageKey); |
| } |
| // 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(this, attachment.mMessageKey)) { |
| debugTrace("Downloads finished for outgoing msg #%d", req.mMessageId); |
| EmailServiceProxy service = EmailServiceUtils.getServiceForAccount( |
| this, accountId); |
| try { |
| service.sendMail(accountId); |
| } catch (RemoteException e) { |
| LogUtils.e(LOG_TAG, "RemoteException while trying to send message: #%d, %s", |
| req.mMessageId, e.toString()); |
| } |
| } |
| } |
| if (statusCode == EmailServiceStatus.MESSAGE_NOT_FOUND) { |
| Message msg = Message.restoreMessageWithId(this, attachment.mMessageKey); |
| if (msg == null) { |
| LogUtils.w(LOG_TAG, "Deleting attachment #%d with no associated message #%d", |
| attachment.mId, attachment.mMessageKey); |
| // If there's no associated message, delete the attachment |
| EmailContent.delete(this, 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? |
| LogUtils.w(LOG_TAG, "Retrying attachment #%d with associated message #%d", |
| attachment.mId, attachment.mMessageKey); |
| 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 |
| LogUtils.d(LOG_TAG, "Attachment #%d successfully downloaded!", attachment.mId); |
| markAttachmentAsCompleted(attachment); |
| } |
| } |
| // Process the queue |
| kick(); |
| } |
| |
| /** |
| * Count the number of running downloads in progress for this account |
| * @param accountId the id of the account |
| * @return the count of running downloads |
| */ |
| synchronized int getDownloadsForAccount(final long accountId) { |
| int count = 0; |
| for (final DownloadRequest req: mDownloadsInProgress.values()) { |
| if (req.mAccountId == accountId) { |
| count++; |
| } |
| } |
| return count; |
| } |
| |
| /** |
| * 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 getAttachmentPriority(final Attachment att) { |
| int priorityClass = PRIORITY_NONE; |
| final 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; |
| } |
| |
| /** |
| * Determine whether an attachment can be prefetched for the given account based on |
| * total download size restrictions tied to the account. |
| * @return true if download is allowed, false otherwise |
| */ |
| public boolean canPrefetchForAccount(final Account account, final 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) { |
| debugTrace("Prefetch is not allowed for this account: %d", account.getId()); |
| return false; |
| } |
| |
| final long totalStorage = dir.getTotalSpace(); |
| final long usableStorage = dir.getUsableSpace(); |
| final long minAvailable = (long)(totalStorage * PREFETCH_MINIMUM_STORAGE_AVAILABLE); |
| |
| // If there's not enough overall storage available, stop now |
| if (usableStorage < minAvailable) { |
| debugTrace("Not enough physical storage for prefetch"); |
| return false; |
| } |
| |
| final int numberOfAccounts = mAccountManagerStub.getNumberOfAccounts(); |
| // Calculate an even per-account storage although it would make a lot of sense to not |
| // do this as you may assign more storage to your corporate account rather than a personal |
| // account. |
| final 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. No locking here since this is a concurrent collection object. |
| mAttachmentStorageMap.put(account.mId, accountStorage); |
| } |
| |
| // Return true if we're using less than the maximum per account |
| if (accountStorage >= perAccountMaxStorage) { |
| debugTrace("Prefetch not allowed for account %d; used: %d, limit %d", |
| account.mId, accountStorage, perAccountMaxStorage); |
| return false; |
| } |
| return true; |
| } |
| |
| boolean isConnected() { |
| if (mConnectivityManager != null) { |
| return mConnectivityManager.hasConnectivity(); |
| } |
| return false; |
| } |
| |
| // For Debugging. |
| synchronized public void dumpInProgressDownloads() { |
| if (ENABLE_ATTACHMENT_SERVICE_DEBUG < 1) { |
| LogUtils.d(LOG_TAG, "Advanced logging not configured."); |
| } |
| LogUtils.d(LOG_TAG, "Here are the in-progress downloads..."); |
| for (final DownloadRequest req : mDownloadsInProgress.values()) { |
| LogUtils.d(LOG_TAG, "--BEGIN DownloadRequest DUMP--"); |
| LogUtils.d(LOG_TAG, "Account: #%d", req.mAccountId); |
| LogUtils.d(LOG_TAG, "Message: #%d", req.mMessageId); |
| LogUtils.d(LOG_TAG, "Attachment: #%d", req.mAttachmentId); |
| LogUtils.d(LOG_TAG, "Created Time: %d", req.mCreatedTime); |
| LogUtils.d(LOG_TAG, "Priority: %d", req.mPriority); |
| if (req.mInProgress == true) { |
| LogUtils.d(LOG_TAG, "This download is in progress"); |
| } else { |
| LogUtils.d(LOG_TAG, "This download is not in progress"); |
| } |
| LogUtils.d(LOG_TAG, "Start Time: %d", req.mStartTime); |
| LogUtils.d(LOG_TAG, "Retry Count: %d", req.mRetryCount); |
| LogUtils.d(LOG_TAG, "Retry Start Tiome: %d", req.mRetryStartTime); |
| LogUtils.d(LOG_TAG, "Last Status Code: %d", req.mLastStatusCode); |
| LogUtils.d(LOG_TAG, "Last Progress: %d", req.mLastProgress); |
| LogUtils.d(LOG_TAG, "Last Callback Time: %d", req.mLastCallbackTime); |
| LogUtils.d(LOG_TAG, "------------------------------"); |
| } |
| LogUtils.d(LOG_TAG, "Done reporting in-progress downloads..."); |
| } |
| |
| |
| @Override |
| public void dump(final FileDescriptor fd, final PrintWriter pw, final String[] args) { |
| pw.println("AttachmentService"); |
| final long time = System.currentTimeMillis(); |
| synchronized(mDownloadQueue) { |
| pw.println(" Queue, " + mDownloadQueue.getSize() + " entries"); |
| // If you iterate over the queue either via iterator or collection, they are not |
| // returned in any particular order. With all things being equal its better to go with |
| // a collection to avoid any potential ConcurrentModificationExceptions. |
| // If we really want this sorted, we can sort it manually since performance isn't a big |
| // concern with this debug method. |
| for (final DownloadRequest req : mDownloadQueue.mRequestMap.values()) { |
| pw.println(" Account: " + req.mAccountId + ", Attachment: " + req.mAttachmentId); |
| pw.println(" Priority: " + req.mPriority + ", Time: " + req.mCreatedTime + |
| (req.mInProgress ? " [In progress]" : "")); |
| final Attachment att = Attachment.restoreAttachmentWithId(this, req.mAttachmentId); |
| if (att == null) { |
| pw.println(" Attachment not in database?"); |
| } else if (att.mFileName != null) { |
| final String fileName = att.mFileName; |
| final String suffix; |
| final int lastDot = fileName.lastIndexOf('.'); |
| if (lastDot >= 0) { |
| suffix = fileName.substring(lastDot); |
| } else { |
| suffix = "[none]"; |
| } |
| 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.mInProgress) { |
| pw.println(" Status: " + req.mLastStatusCode + ", Progress: " + |
| req.mLastProgress); |
| pw.println(" Started: " + req.mStartTime + ", Callback: " + |
| req.mLastCallbackTime); |
| pw.println(" Elapsed: " + ((time - req.mStartTime) / 1000L) + "s"); |
| if (req.mLastCallbackTime > 0) { |
| pw.println(" CB: " + ((time - req.mLastCallbackTime) / 1000L) + "s"); |
| } |
| } |
| } |
| } |
| } |
| |
| // For Testing |
| AccountManagerStub mAccountManagerStub; |
| private final HashMap<Long, Intent> mAccountServiceMap = new HashMap<Long, Intent>(); |
| |
| void addServiceIntentForTest(final long accountId, final Intent intent) { |
| mAccountServiceMap.put(accountId, intent); |
| } |
| |
| /** |
| * 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 |
| */ |
| static class AccountManagerStub { |
| private int mNumberOfAccounts; |
| private final AccountManager mAccountManager; |
| |
| AccountManagerStub(final Context context) { |
| if (context != null) { |
| mAccountManager = AccountManager.get(context); |
| } else { |
| mAccountManager = null; |
| } |
| } |
| |
| int getNumberOfAccounts() { |
| if (mAccountManager != null) { |
| return mAccountManager.getAccounts().length; |
| } else { |
| return mNumberOfAccounts; |
| } |
| } |
| |
| void setNumberOfAccounts(final int numberOfAccounts) { |
| mNumberOfAccounts = numberOfAccounts; |
| } |
| } |
| } |