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