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