AttachmentDownloadService.java revision f19f9cf4d3e5229715da77fe05a1a2bbd8da3f41
1/* 2 * Copyright (C) 2010 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.email.service; 18 19import com.android.email.Controller; 20import com.android.email.Email; 21import com.android.email.R; 22import com.android.email.Utility; 23import com.android.email.ExchangeUtils.NullEmailService; 24import com.android.email.activity.Welcome; 25import com.android.email.provider.AttachmentProvider; 26import com.android.email.provider.EmailContent; 27import com.android.email.provider.EmailContent.Account; 28import com.android.email.provider.EmailContent.Attachment; 29import com.android.email.provider.EmailContent.Message; 30import com.android.exchange.SyncManager; 31 32import android.app.Notification; 33import android.app.NotificationManager; 34import android.app.PendingIntent; 35import android.app.Service; 36import android.content.ContentValues; 37import android.content.Context; 38import android.content.Intent; 39import android.database.Cursor; 40import android.os.IBinder; 41import android.os.RemoteException; 42import android.text.format.DateUtils; 43import android.util.Log; 44import android.widget.RemoteViews; 45 46import java.io.File; 47import java.util.Comparator; 48import java.util.HashMap; 49import java.util.Iterator; 50import java.util.TreeSet; 51 52public class AttachmentDownloadService extends Service implements Runnable { 53 public static final String TAG = "AttachmentService"; 54 55 // Our idle time, waiting for notifications; this is something of a failsafe 56 private static final int PROCESS_QUEUE_WAIT_TIME = 30 * ((int)DateUtils.MINUTE_IN_MILLIS); 57 58 private static final int PRIORITY_NONE = -1; 59 @SuppressWarnings("unused") 60 // Low priority will be used for opportunistic downloads 61 private static final int PRIORITY_LOW = 0; 62 // Normal priority is for forwarded downloads in outgoing mail 63 private static final int PRIORITY_NORMAL = 1; 64 // High priority is for user requests 65 private static final int PRIORITY_HIGH = 2; 66 67 // We can try various values here; I think 2 is completely reasonable as a first pass 68 private static final int MAX_SIMULTANEOUS_DOWNLOADS = 2; 69 // Limit on the number of simultaneous downloads per account 70 // Note that a limit of 1 is currently enforced by both Services (MailService and Controller) 71 private static final int MAX_SIMULTANEOUS_DOWNLOADS_PER_ACCOUNT = 1; 72 73 /*package*/ static AttachmentDownloadService sRunningService = null; 74 75 /*package*/ Context mContext; 76 /*package*/ final DownloadSet mDownloadSet = new DownloadSet(new DownloadComparator()); 77 private final HashMap<Long, Class<? extends Service>> mAccountServiceMap = 78 new HashMap<Long, Class<? extends Service>>(); 79 private final ServiceCallback mServiceCallback = new ServiceCallback(); 80 private final Object mLock = new Object(); 81 private volatile boolean mStop = false; 82 83 public static class DownloadRequest { 84 final int priority; 85 final long time; 86 final long attachmentId; 87 final long messageId; 88 final long accountId; 89 boolean inProgress = false; 90 91 private DownloadRequest(Context context, Attachment attachment) { 92 attachmentId = attachment.mId; 93 Message msg = Message.restoreMessageWithId(context, attachment.mMessageKey); 94 if (msg != null) { 95 accountId = msg.mAccountKey; 96 messageId = msg.mId; 97 } else { 98 accountId = messageId = -1; 99 } 100 priority = getPriority(attachment); 101 time = System.currentTimeMillis(); 102 } 103 104 @Override 105 public int hashCode() { 106 return (int)attachmentId; 107 } 108 109 /** 110 * Two download requests are equals if their attachment id's are equals 111 */ 112 @Override 113 public boolean equals(Object object) { 114 if (!(object instanceof DownloadRequest)) return false; 115 DownloadRequest req = (DownloadRequest)object; 116 return req.attachmentId == attachmentId; 117 } 118 } 119 120 /** 121 * Comparator class for the download set; we first compare by priority. Requests with equal 122 * priority are compared by the time the request was created (older requests come first) 123 */ 124 /*protected*/ static class DownloadComparator implements Comparator<DownloadRequest> { 125 @Override 126 public int compare(DownloadRequest req1, DownloadRequest req2) { 127 int res; 128 if (req1.priority != req2.priority) { 129 res = (req1.priority < req2.priority) ? -1 : 1; 130 } else { 131 if (req1.time == req2.time) { 132 res = 0; 133 } else { 134 res = (req1.time > req2.time) ? -1 : 1; 135 } 136 } 137 //Log.d(TAG, "Compare " + req1.attachmentId + " to " + req2.attachmentId + " = " + res); 138 return res; 139 } 140 } 141 142 /** 143 * The DownloadSet is a TreeSet sorted by priority class (e.g. low, high, etc.) and the 144 * time of the request. Higher priority requests 145 * are always processed first; among equals, the oldest request is processed first. The 146 * priority key represents this ordering. Note: All methods that change the attachment map are 147 * synchronized on the map itself 148 */ 149 /*package*/ class DownloadSet extends TreeSet<DownloadRequest> { 150 private static final long serialVersionUID = 1L; 151 152 /*package*/ DownloadSet(Comparator<? super DownloadRequest> comparator) { 153 super(comparator); 154 } 155 156 /** 157 * Maps attachment id to DownloadRequest 158 */ 159 /*package*/ final HashMap<Long, DownloadRequest> mDownloadsInProgress = 160 new HashMap<Long, DownloadRequest>(); 161 162 /** 163 * onChange is called by the AttachmentReceiver upon receipt of a valid notification from 164 * EmailProvider that an attachment has been inserted or modified. It's not strictly 165 * necessary that we detect a deleted attachment, as the code always checks for the 166 * existence of an attachment before acting on it. 167 */ 168 public synchronized void onChange(Attachment att) { 169 DownloadRequest req = findDownloadRequest(att.mId); 170 long priority = getPriority(att); 171 if (priority == PRIORITY_NONE) { 172 if (Email.DEBUG) { 173 Log.d(TAG, "== Attachment changed: " + att.mId); 174 } 175 // In this case, there is no download priority for this attachment 176 if (req != null) { 177 // If it exists in the map, remove it 178 // NOTE: We don't yet support deleting downloads in progress 179 if (Email.DEBUG) { 180 Log.d(TAG, "== Attachment " + att.mId + " was in queue, removing"); 181 } 182 remove(req); 183 } 184 } else { 185 // Ignore changes that occur during download 186 if (mDownloadsInProgress.containsKey(att.mId)) return; 187 // If this is new, add the request to the queue 188 if (req == null) { 189 req = new DownloadRequest(mContext, att); 190 add(req); 191 } 192 // If the request already existed, we'll update the priority (so that the time is 193 // up-to-date); otherwise, we create a new request 194 if (Email.DEBUG) { 195 Log.d(TAG, "== Download queued for attachment " + att.mId + ", class " + 196 req.priority + ", priority time " + req.time); 197 } 198 } 199 // Process the queue if we're in a wait 200 kick(); 201 } 202 203 /** 204 * Find a queued DownloadRequest, given the attachment's id 205 * @param id the id of the attachment 206 * @return the DownloadRequest for that attachment (or null, if none) 207 */ 208 /*package*/ synchronized DownloadRequest findDownloadRequest(long id) { 209 Iterator<DownloadRequest> iterator = iterator(); 210 while(iterator.hasNext()) { 211 DownloadRequest req = iterator.next(); 212 if (req.attachmentId == id) { 213 return req; 214 } 215 } 216 return null; 217 } 218 219 /** 220 * Run through the AttachmentMap and find DownloadRequests that can be executed, enforcing 221 * the limit on maximum downloads 222 */ 223 /*package*/ synchronized void processQueue() { 224 if (Email.DEBUG) { 225 Log.d(TAG, "== Checking attachment queue, " + mDownloadSet.size() + " entries"); 226 } 227 Iterator<DownloadRequest> iterator = mDownloadSet.descendingIterator(); 228 // First, start up any required downloads, in priority order 229 while (iterator.hasNext() && 230 (mDownloadsInProgress.size() < MAX_SIMULTANEOUS_DOWNLOADS)) { 231 DownloadRequest req = iterator.next(); 232 if (!req.inProgress) { 233 mDownloadSet.tryStartDownload(req); 234 } 235 } 236 // Then, try opportunistic download of appropriate attachments 237 int backgroundDownloads = MAX_SIMULTANEOUS_DOWNLOADS - mDownloadsInProgress.size(); 238 if (backgroundDownloads > 0) { 239 // TODO Code for background downloads here 240 if (Email.DEBUG) { 241 Log.d(TAG, "== We'd look for up to " + backgroundDownloads + 242 " background download(s) now..."); 243 } 244 } 245 } 246 247 /** 248 * Count the number of running downloads in progress for this account 249 * @param accountId the id of the account 250 * @return the count of running downloads 251 */ 252 /*package*/ synchronized int downloadsForAccount(long accountId) { 253 int count = 0; 254 for (DownloadRequest req: mDownloadsInProgress.values()) { 255 if (req.accountId == accountId) { 256 count++; 257 } 258 } 259 return count; 260 } 261 262 /** 263 * Attempt to execute the DownloadRequest, enforcing the maximum downloads per account 264 * parameter 265 * @param req the DownloadRequest 266 * @return whether or not the download was started 267 */ 268 /*package*/ synchronized boolean tryStartDownload(DownloadRequest req) { 269 // Enforce per-account limit 270 if (downloadsForAccount(req.accountId) >= MAX_SIMULTANEOUS_DOWNLOADS_PER_ACCOUNT) { 271 if (Email.DEBUG) { 272 Log.d(TAG, "== Skip #" + req.attachmentId + "; maxed for acct #" + 273 req.accountId); 274 } 275 return false; 276 } 277 Class<? extends Service> serviceClass = getServiceClassForAccount(req.accountId); 278 if (serviceClass == null) return false; 279 EmailServiceProxy proxy = 280 new EmailServiceProxy(mContext, serviceClass, mServiceCallback); 281 try { 282 File file = AttachmentProvider.getAttachmentFilename(mContext, req.accountId, 283 req.attachmentId); 284 if (Email.DEBUG) { 285 Log.d(TAG, ">> Starting download for attachment #" + req.attachmentId); 286 } 287 // Don't actually run the load if this is the NullEmailService (used in unit tests) 288 if (!serviceClass.equals(NullEmailService.class)) { 289 proxy.loadAttachment(req.attachmentId, file.getAbsolutePath(), 290 AttachmentProvider.getAttachmentUri(req.accountId, req.attachmentId) 291 .toString()); 292 } 293 mDownloadsInProgress.put(req.attachmentId, req); 294 req.inProgress = true; 295 } catch (RemoteException e) { 296 // TODO: Consider whether we need to do more in this case... 297 // For now, fix up our data to reflect the failure 298 mDownloadsInProgress.remove(req.attachmentId); 299 req.inProgress = false; 300 } 301 return true; 302 } 303 304 /** 305 * Called when a download is finished; we get notified of this via our EmailServiceCallback 306 * @param attachmentId the id of the attachment whose download is finished 307 * @param statusCode the EmailServiceStatus code returned by the Service 308 */ 309 /*package*/ synchronized void endDownload(long attachmentId, int statusCode) { 310 // Say we're no longer downloading this 311 mDownloadsInProgress.remove(attachmentId); 312 DownloadRequest req = mDownloadSet.findDownloadRequest(attachmentId); 313 if (statusCode == EmailServiceStatus.CONNECTION_ERROR) { 314 // If this needs to be retried, just process the queue again 315 if (Email.DEBUG) { 316 Log.d(TAG, "== The download for attachment #" + attachmentId + 317 " will be retried"); 318 } 319 if (req != null) { 320 req.inProgress = false; 321 } 322 kick(); 323 return; 324 } 325 // Remove the request from the queue 326 remove(req); 327 if (Email.DEBUG) { 328 long secs = (System.currentTimeMillis() - req.time) / 1000; 329 String status = (statusCode == EmailServiceStatus.SUCCESS) ? "Success" : 330 "Error " + statusCode; 331 Log.d(TAG, "<< Download finished for attachment #" + attachmentId + "; " + secs + 332 " seconds from request, status: " + status); 333 } 334 Attachment attachment = Attachment.restoreAttachmentWithId(mContext, attachmentId); 335 if (attachment != null) { 336 boolean deleted = false; 337 if ((attachment.mFlags & Attachment.FLAG_DOWNLOAD_FORWARD) != 0) { 338 if (statusCode == EmailServiceStatus.ATTACHMENT_NOT_FOUND) { 339 // If this is a forwarding download, and the attachment doesn't exist (or 340 // can't be downloaded) delete it from the outgoing message, lest that 341 // message never get sent 342 EmailContent.delete(mContext, Attachment.CONTENT_URI, attachment.mId); 343 // TODO: Talk to UX about whether this is even worth doing 344 showDownloadForwardFailedNotification(attachment); 345 deleted = true; 346 } 347 // If we're an attachment on forwarded mail, and if we're not still blocked, 348 // try to send pending mail now (as mediated by MailService) 349 if (!Utility.hasUnloadedAttachments(mContext, attachment.mMessageKey)) { 350 if (Email.DEBUG) { 351 Log.d(TAG, "== Downloads finished for outgoing msg #" + req.messageId); 352 } 353 MailService.actionSendPendingMail(mContext, req.accountId); 354 } 355 } 356 if (!deleted) { 357 // Clear the download flags, since we're done for now. Note that this happens 358 // only for non-recoverable errors. When these occur for forwarded mail, we can 359 // ignore it and continue; otherwise, it was either 1) a user request, in which 360 // case the user can retry manually or 2) an opportunistic download, in which 361 // case the download wasn't critical 362 ContentValues cv = new ContentValues(); 363 int flags = 364 Attachment.FLAG_DOWNLOAD_FORWARD | Attachment.FLAG_DOWNLOAD_USER_REQUEST; 365 cv.put(Attachment.FLAGS, attachment.mFlags &= ~flags); 366 attachment.update(mContext, cv); 367 } 368 } 369 // Process the queue 370 kick(); 371 } 372 } 373 374 /** 375 * Calculate the download priority of an Attachment. A priority of zero means that the 376 * attachment is not marked for download. 377 * @param att the Attachment 378 * @return the priority key of the Attachment 379 */ 380 private static int getPriority(Attachment att) { 381 int priorityClass = PRIORITY_NONE; 382 int flags = att.mFlags; 383 if ((flags & Attachment.FLAG_DOWNLOAD_FORWARD) != 0) { 384 priorityClass = PRIORITY_NORMAL; 385 } else if ((flags & Attachment.FLAG_DOWNLOAD_USER_REQUEST) != 0) { 386 priorityClass = PRIORITY_HIGH; 387 } 388 return priorityClass; 389 } 390 391 private void kick() { 392 synchronized(mLock) { 393 mLock.notify(); 394 } 395 } 396 397 /** 398 * We use an EmailServiceCallback to keep track of the progress of downloads. These callbacks 399 * come from either Controller (IMAP) or SyncManager (EAS). Note that we only implement the 400 * single callback that's defined by the EmailServiceCallback interface. 401 */ 402 private class ServiceCallback extends IEmailServiceCallback.Stub { 403 public void loadAttachmentStatus(long messageId, long attachmentId, int statusCode, 404 int progress) { 405 if (Email.DEBUG) { 406 String code; 407 switch(statusCode) { 408 case EmailServiceStatus.SUCCESS: 409 code = "Success"; 410 break; 411 case EmailServiceStatus.IN_PROGRESS: 412 code = "In progress"; 413 break; 414 default: 415 code = Integer.toString(statusCode); 416 } 417 Log.d(TAG, "loadAttachmentStatus, id = " + attachmentId + " code = "+ code + 418 ", " + progress + "%"); 419 } 420 // The only thing we're interested in here is whether the download is finished and, if 421 // so, what the result was. 422 switch (statusCode) { 423 case EmailServiceStatus.IN_PROGRESS: 424 break; 425 default: 426 mDownloadSet.endDownload(attachmentId, statusCode); 427 break; 428 } 429 } 430 431 @Override 432 public void sendMessageStatus(long messageId, String subject, int statusCode, int progress) 433 throws RemoteException { 434 } 435 436 @Override 437 public void syncMailboxListStatus(long accountId, int statusCode, int progress) 438 throws RemoteException { 439 } 440 441 @Override 442 public void syncMailboxStatus(long mailboxId, int statusCode, int progress) 443 throws RemoteException { 444 } 445 } 446 447 /** 448 * Alert the user that an attachment couldn't be forwarded. This is a very unusual case, and 449 * perhaps we shouldn't even send a notification. For now, it's helpful for debugging. 450 * Note the STOPSHIP below... 451 */ 452 void showDownloadForwardFailedNotification(Attachment att) { 453 // STOPSHIP: Tentative UI; if we use a notification, replace this text with a resource 454 RemoteViews contentView = new RemoteViews(getPackageName(), 455 R.layout.attachment_forward_failed_notification); 456 contentView.setImageViewResource(R.id.image, R.drawable.ic_email_attachment); 457 contentView.setTextViewText(R.id.text, 458 getString(R.string.forward_download_failed_notification, att.mFileName)); 459 Notification n = new Notification(R.drawable.stat_notify_email_generic, 460 getString(R.string.forward_download_failed_ticker), System.currentTimeMillis()); 461 n.contentView = contentView; 462 Intent i = new Intent(mContext, Welcome.class); 463 PendingIntent pending = PendingIntent.getActivity(mContext, 0, i, 464 PendingIntent.FLAG_UPDATE_CURRENT); 465 n.contentIntent = pending; 466 n.flags = Notification.FLAG_AUTO_CANCEL; 467 NotificationManager nm = 468 (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); 469 nm.notify(MailService.NOTIFICATION_ID_WARNING, n); 470 } 471 472 /** 473 * Return the class of the service used by the account type of the provided account id. We 474 * cache the results to avoid repeated database access 475 * @param accountId the id of the account 476 * @return the service class for the account 477 */ 478 private synchronized Class<? extends Service> getServiceClassForAccount(long accountId) { 479 // TODO: We should have some more data-driven way of determining the service class. I'd 480 // suggest adding an attribute in the stores.xml file 481 Class<? extends Service> serviceClass = mAccountServiceMap.get(accountId); 482 if (serviceClass == null) { 483 String protocol = Account.getProtocol(mContext, accountId); 484 if (protocol.equals("eas")) { 485 serviceClass = SyncManager.class; 486 } else { 487 serviceClass = Controller.class; 488 } 489 mAccountServiceMap.put(accountId, serviceClass); 490 } 491 return serviceClass; 492 } 493 494 /*protected*/ void addServiceClass(long accountId, Class<? extends Service> serviceClass) { 495 mAccountServiceMap.put(accountId, serviceClass); 496 } 497 498 /*package*/ void onChange(Attachment att) { 499 mDownloadSet.onChange(att); 500 } 501 502 /*package*/ boolean isQueued(long attachmentId) { 503 return mDownloadSet.findDownloadRequest(attachmentId) != null; 504 } 505 506 /*package*/ boolean dequeue(long attachmentId) { 507 DownloadRequest req = mDownloadSet.findDownloadRequest(attachmentId); 508 if (req != null) { 509 mDownloadSet.remove(req); 510 return true; 511 } 512 return false; 513 } 514 515 /** 516 * Ask the service whether a particular attachment is queued for download 517 * @param attachmentId the id of the Attachment (as stored by EmailProvider) 518 * @return whether or not the attachment is queued for download 519 */ 520 public static boolean isAttachmentQueued(long attachmentId) { 521 if (sRunningService != null) { 522 return sRunningService.isQueued(attachmentId); 523 } 524 return false; 525 } 526 527 /** 528 * Ask the service to remove an attachment from the download queue 529 * @param attachmentId the id of the Attachment (as stored by EmailProvider) 530 * @return whether or not the attachment was removed from the queue 531 */ 532 public static boolean cancelQueuedAttachment(long attachmentId) { 533 if (sRunningService != null) { 534 return sRunningService.dequeue(attachmentId); 535 } 536 return false; 537 } 538 539 /** 540 * Called directly by EmailProvider whenever an attachment is inserted or changed 541 * @param id the attachment's id 542 * @param flags the new flags for the attachment 543 */ 544 public static void attachmentChanged(final long id, final int flags) { 545 if (sRunningService == null) return; 546 Utility.runAsync(new Runnable() { 547 public void run() { 548 final Attachment attachment = 549 Attachment.restoreAttachmentWithId(sRunningService, id); 550 if (attachment != null) { 551 // Store the flags we got from EmailProvider; given that all of this 552 // activity is asynchronous, we need to use the newest data from 553 // EmailProvider 554 attachment.mFlags = flags; 555 sRunningService.onChange(attachment); 556 } 557 }}); 558 } 559 560 public void run() { 561 mContext = this; 562 // Run through all attachments in the database that require download and add them to 563 // the queue 564 int mask = Attachment.FLAG_DOWNLOAD_FORWARD | Attachment.FLAG_DOWNLOAD_USER_REQUEST; 565 Cursor c = getContentResolver().query(Attachment.CONTENT_URI, 566 EmailContent.ID_PROJECTION, "(" + Attachment.FLAGS + " & ?) != 0", 567 new String[] {Integer.toString(mask)}, null); 568 try { 569 Log.d(TAG, "Count: " + c.getCount()); 570 while (c.moveToNext()) { 571 Attachment attachment = Attachment.restoreAttachmentWithId( 572 this, c.getLong(EmailContent.ID_PROJECTION_COLUMN)); 573 if (attachment != null) { 574 mDownloadSet.onChange(attachment); 575 } 576 } 577 } catch (Exception e) { 578 e.printStackTrace(); 579 } 580 finally { 581 c.close(); 582 } 583 584 // Loop until stopped, with a 30 minute wait loop 585 while (!mStop) { 586 // Here's where we run our attachment loading logic... 587 mDownloadSet.processQueue(); 588 synchronized(mLock) { 589 try { 590 mLock.wait(PROCESS_QUEUE_WAIT_TIME); 591 } catch (InterruptedException e) { 592 // That's ok; we'll just keep looping 593 } 594 } 595 } 596 } 597 598 @Override 599 public int onStartCommand(Intent intent, int flags, int startId) { 600 sRunningService = this; 601 return Service.START_STICKY; 602 } 603 604 /** 605 * The lifecycle of this service is managed by Email.setServicesEnabled(), which is called 606 * throughout the code, in particular 1) after boot and 2) after accounts are added or removed 607 * The goal is that this service should be running at all times when there's at least one 608 * email account present. 609 */ 610 @Override 611 public void onCreate() { 612 // Start up our service thread 613 new Thread(this, "AttachmentDownloadService").start(); 614 } 615 @Override 616 public IBinder onBind(Intent intent) { 617 return null; 618 } 619 620 @Override 621 public void onDestroy() { 622 Log.d(TAG, "**** ON DESTROY!"); 623 if (sRunningService != null) { 624 mStop = true; 625 kick(); 626 } 627 sRunningService = null; 628 } 629} 630