EasOutboxSyncHandler.java revision adc77ca7aae4d6f50d40379141e4398ca052d5d5
1package com.android.exchange.service; 2 3import android.content.ContentUris; 4import android.content.Context; 5import android.database.Cursor; 6import android.net.TrafficStats; 7import android.net.Uri; 8import android.text.format.DateUtils; 9import android.util.Log; 10 11import com.android.emailcommon.TrafficFlags; 12import com.android.emailcommon.internet.Rfc822Output; 13import com.android.emailcommon.provider.Account; 14import com.android.emailcommon.provider.EmailContent.Attachment; 15import com.android.emailcommon.provider.EmailContent.Body; 16import com.android.emailcommon.provider.EmailContent.BodyColumns; 17import com.android.emailcommon.provider.EmailContent.MailboxColumns; 18import com.android.emailcommon.provider.EmailContent.Message; 19import com.android.emailcommon.provider.EmailContent.MessageColumns; 20import com.android.emailcommon.provider.EmailContent.SyncColumns; 21import com.android.emailcommon.provider.Mailbox; 22import com.android.emailcommon.utility.Utility; 23import com.android.exchange.CommandStatusException.CommandStatus; 24import com.android.exchange.Eas; 25import com.android.exchange.EasResponse; 26import com.android.exchange.adapter.Parser; 27import com.android.exchange.adapter.Parser.EmptyStreamException; 28import com.android.exchange.adapter.Serializer; 29import com.android.exchange.adapter.Tags; 30import com.android.mail.utils.LogUtils; 31 32import org.apache.http.HttpEntity; 33import org.apache.http.HttpStatus; 34import org.apache.http.entity.InputStreamEntity; 35 36import java.io.ByteArrayOutputStream; 37import java.io.File; 38import java.io.FileInputStream; 39import java.io.FileNotFoundException; 40import java.io.FileOutputStream; 41import java.io.IOException; 42import java.io.InputStream; 43import java.io.OutputStream; 44import java.util.ArrayList; 45 46/** 47 * Performs an Exchange Outbox sync, i.e. sends all mail from the Outbox. 48 */ 49public class EasOutboxSyncHandler extends EasServerConnection { 50 // Value for a message's server id when sending fails. 51 public static final int SEND_FAILED = 1; 52 53 // WHERE clause to query for unsent messages. 54 // TODO: Is the SEND_FAILED check actually what we want? 55 public static final String MAILBOX_KEY_AND_NOT_SEND_FAILED = 56 MessageColumns.MAILBOX_KEY + "=? and (" + SyncColumns.SERVER_ID + " is null or " + 57 SyncColumns.SERVER_ID + "!=" + SEND_FAILED + ')'; 58 59 // This needs to be long enough to send the longest reasonable message, without being so long 60 // as to effectively "hang" sending of mail. The standard 30 second timeout isn't long enough 61 // for pictures and the like. For now, we'll use 15 minutes, in the knowledge that any socket 62 // failure would probably generate an Exception before timing out anyway 63 public static final long SEND_MAIL_TIMEOUT = 15 * DateUtils.MINUTE_IN_MILLIS; 64 65 private final Mailbox mMailbox; 66 private final File mCacheDir; 67 68 public EasOutboxSyncHandler(final Context context, final Account account, 69 final Mailbox mailbox) { 70 super(context, account); 71 mMailbox = mailbox; 72 mCacheDir = context.getCacheDir(); 73 } 74 75 public void performSync() { 76 // Use SMTP flags for sending mail 77 TrafficStats.setThreadStatsTag(TrafficFlags.getSmtpFlags(mContext, mAccount)); 78 // Get a cursor to Outbox messages 79 final Cursor c = mContext.getContentResolver().query(Message.CONTENT_URI, 80 Message.CONTENT_PROJECTION, MAILBOX_KEY_AND_NOT_SEND_FAILED, 81 new String[] {Long.toString(mMailbox.mId)}, null); 82 try { 83 // Loop through the messages, sending each one 84 while (c.moveToNext()) { 85 final Message message = new Message(); 86 message.restore(c); 87 if (Utility.hasUnloadedAttachments(mContext, message.mId)) { 88 // We'll just have to wait on this... 89 continue; 90 } 91 92 // TODO: Fix -- how do we want to signal to UI that we started syncing? 93 // Note the entire callback mechanism here needs improving. 94 //sendMessageStatus(message.mId, null, EmailServiceStatus.IN_PROGRESS, 0); 95 96 if (!sendOneMessage(message, 97 SmartSendInfo.getSmartSendInfo(mContext, mAccount, message))) { 98 break; 99 } 100 } 101 } finally { 102 // TODO: Some sort of sendMessageStatus() is needed here. 103 c.close(); 104 } 105 } 106 107 /** 108 * Information needed for SmartReply/SmartForward. 109 */ 110 private static class SmartSendInfo { 111 public static final String[] BODY_SOURCE_PROJECTION = 112 new String[] {BodyColumns.SOURCE_MESSAGE_KEY}; 113 public static final String WHERE_MESSAGE_KEY = Body.MESSAGE_KEY + "=?"; 114 115 final String mItemId; 116 final String mCollectionId; 117 final boolean mIsReply; 118 final ArrayList<Attachment> mRequiredAtts; 119 120 private SmartSendInfo(final String itemId, final String collectionId, final boolean isReply, 121 final ArrayList<Attachment> requiredAtts) { 122 mItemId = itemId; 123 mCollectionId = collectionId; 124 mIsReply = isReply; 125 mRequiredAtts = requiredAtts; 126 } 127 128 public String generateSmartSendCmd() { 129 final StringBuilder sb = new StringBuilder(); 130 sb.append(isForward() ? "SmartForward" : "SmartReply"); 131 sb.append("&ItemId="); 132 sb.append(Uri.encode(mItemId, ":")); 133 sb.append("&CollectionId="); 134 sb.append(Uri.encode(mCollectionId, ":")); 135 return sb.toString(); 136 } 137 138 public boolean isForward() { 139 return !mIsReply; 140 } 141 142 /** 143 * See if a given attachment is among an array of attachments; it is if the locations of 144 * both are the same (we're looking to see if they represent the same attachment on the 145 * server. Note that an attachment that isn't on the server (e.g. an outbound attachment 146 * picked from the gallery) won't have a location, so the result will always be false. 147 * 148 * @param att the attachment to test 149 * @param atts the array of attachments to look in 150 * @return whether the test attachment is among the array of attachments 151 */ 152 private static boolean amongAttachments(final Attachment att, final Attachment[] atts) { 153 final String location = att.mLocation; 154 if (location == null) return false; 155 for (final Attachment a: atts) { 156 if (location.equals(a.mLocation)) { 157 return true; 158 } 159 } 160 return false; 161 } 162 163 /** 164 * If this message should use SmartReply or SmartForward, return an object with the data 165 * for the smart send. 166 * 167 * @param context the caller's context 168 * @param account the Account we're sending from 169 * @param message the Message being sent 170 * @return an object to support smart sending, or null if not applicable. 171 */ 172 public static SmartSendInfo getSmartSendInfo(final Context context, 173 final Account account, final Message message) { 174 final int flags = message.mFlags; 175 // We only care about the original message if we include quoted text. 176 if ((flags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) != 0) { 177 return null; 178 } 179 final boolean reply = (flags & Message.FLAG_TYPE_REPLY) != 0; 180 final boolean forward = (flags & Message.FLAG_TYPE_FORWARD) != 0; 181 // We also only care for replies or forwards. 182 if (!reply && !forward) { 183 return null; 184 } 185 // Just a sanity check here, since we assume that reply and forward are mutually 186 // exclusive throughout this class. 187 if (reply && forward) { 188 return null; 189 } 190 // If we don't support SmartForward and it's a forward, then don't proceed. 191 if (forward && (account.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) == 0) { 192 return null; 193 } 194 195 // Note: itemId and collectionId are the terms used by EAS to refer to the serverId and 196 // mailboxId of a Message 197 String itemId = null; 198 String collectionId = null; 199 200 // First, we need to get the id of the reply/forward message 201 String[] cols = Utility.getRowColumns(context, Body.CONTENT_URI, BODY_SOURCE_PROJECTION, 202 WHERE_MESSAGE_KEY, new String[] {Long.toString(message.mId)}); 203 long refId = 0; 204 // TODO: We can probably just write a smarter query to do this all at once. 205 if (cols != null && cols[0] != null) { 206 refId = Long.parseLong(cols[0]); 207 // Then, we need the serverId and mailboxKey of the message 208 cols = Utility.getRowColumns(context, Message.CONTENT_URI, refId, 209 SyncColumns.SERVER_ID, MessageColumns.MAILBOX_KEY, 210 MessageColumns.PROTOCOL_SEARCH_INFO); 211 if (cols != null) { 212 itemId = cols[0]; 213 final long boxId = Long.parseLong(cols[1]); 214 // Then, we need the serverId of the mailbox 215 cols = Utility.getRowColumns(context, Mailbox.CONTENT_URI, boxId, 216 MailboxColumns.SERVER_ID); 217 if (cols != null) { 218 collectionId = cols[0]; 219 } 220 } 221 } 222 // We need either a longId or both itemId (serverId) and collectionId (mailboxId) to 223 // process a smart reply or a smart forward 224 if (itemId != null && collectionId != null) { 225 final ArrayList<Attachment> requiredAtts; 226 if (forward) { 227 // See if we can really smart forward (all reference attachments must be sent) 228 final Attachment[] outAtts = 229 Attachment.restoreAttachmentsWithMessageId(context, message.mId); 230 final Attachment[] refAtts = 231 Attachment.restoreAttachmentsWithMessageId(context, refId); 232 for (final Attachment refAtt: refAtts) { 233 // If an original attachment isn't among what's going out, we can't be smart 234 if (!amongAttachments(refAtt, outAtts)) { 235 return null; 236 } 237 } 238 requiredAtts = new ArrayList<Attachment>(); 239 for (final Attachment outAtt: outAtts) { 240 // If an outgoing attachment isn't in original message, we must send it 241 if (!amongAttachments(outAtt, refAtts)) { 242 requiredAtts.add(outAtt); 243 } 244 } 245 } else { 246 requiredAtts = null; 247 } 248 return new SmartSendInfo(itemId, collectionId, reply, requiredAtts); 249 } 250 return null; 251 } 252 } 253 254 /** 255 * Our own HttpEntity subclass that is able to insert opaque data (in this case the MIME 256 * representation of the message body as stored in a temporary file) into the serializer stream 257 */ 258 private static class SendMailEntity extends InputStreamEntity { 259 private final FileInputStream mFileStream; 260 private final long mFileLength; 261 private final int mSendTag; 262 private final Message mMessage; 263 private final SmartSendInfo mSmartSendInfo; 264 265 public SendMailEntity(final FileInputStream instream, final long length, final int tag, 266 final Message message, final SmartSendInfo smartSendInfo) { 267 super(instream, length); 268 mFileStream = instream; 269 mFileLength = length; 270 mSendTag = tag; 271 mMessage = message; 272 mSmartSendInfo = smartSendInfo; 273 } 274 275 /** 276 * We always return -1 because we don't know the actual length of the POST data (this 277 * causes HttpClient to send the data in "chunked" mode) 278 */ 279 @Override 280 public long getContentLength() { 281 final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 282 try { 283 // Calculate the overhead for the WBXML data 284 writeTo(baos, false); 285 // Return the actual size that will be sent 286 return baos.size() + mFileLength; 287 } catch (final IOException e) { 288 // Just return -1 (unknown) 289 } finally { 290 try { 291 baos.close(); 292 } catch (final IOException e) { 293 // Ignore 294 } 295 } 296 return -1; 297 } 298 299 @Override 300 public void writeTo(final OutputStream outstream) throws IOException { 301 writeTo(outstream, true); 302 } 303 304 /** 305 * Write the message to the output stream 306 * @param outstream the output stream to write 307 * @param withData whether or not the actual data is to be written; true when sending 308 * mail; false when calculating size only 309 * @throws IOException 310 */ 311 public void writeTo(final OutputStream outstream, final boolean withData) 312 throws IOException { 313 // Not sure if this is possible; the check is taken from the superclass 314 if (outstream == null) { 315 throw new IllegalArgumentException("Output stream may not be null"); 316 } 317 318 // We'll serialize directly into the output stream 319 final Serializer s = new Serializer(outstream); 320 // Send the appropriate initial tag 321 s.start(mSendTag); 322 // The Message-Id for this message (note that we cannot use the messageId stored in 323 // the message, as EAS 14 limits the length to 40 chars and we use 70+) 324 s.data(Tags.COMPOSE_CLIENT_ID, "SendMail-" + System.nanoTime()); 325 // We always save sent mail 326 s.tag(Tags.COMPOSE_SAVE_IN_SENT_ITEMS); 327 328 // If we're using smart reply/forward, we need info about the original message 329 if (mSendTag != Tags.COMPOSE_SEND_MAIL) { 330 if (mSmartSendInfo != null) { 331 s.start(Tags.COMPOSE_SOURCE); 332 // For search results, use the long id (stored in mProtocolSearchInfo); else, 333 // use folder id/item id combo 334 if (mMessage.mProtocolSearchInfo != null) { 335 s.data(Tags.COMPOSE_LONG_ID, mMessage.mProtocolSearchInfo); 336 } else { 337 s.data(Tags.COMPOSE_ITEM_ID, mSmartSendInfo.mItemId); 338 s.data(Tags.COMPOSE_FOLDER_ID, mSmartSendInfo.mCollectionId); 339 } 340 s.end(); // Tags.COMPOSE_SOURCE 341 } 342 } 343 344 // Start the MIME tag; this is followed by "opaque" data (byte array) 345 s.start(Tags.COMPOSE_MIME); 346 // Send opaque data from the file stream 347 if (withData) { 348 s.opaque(mFileStream, (int)mFileLength); 349 } else { 350 s.opaqueWithoutData((int)mFileLength); 351 } 352 // And we're done 353 s.end().end().done(); 354 } 355 } 356 357 private static class SendMailParser extends Parser { 358 private final int mStartTag; 359 private int mStatus; 360 361 public SendMailParser(final InputStream in, final int startTag) throws IOException { 362 super(in); 363 mStartTag = startTag; 364 } 365 366 public int getStatus() { 367 return mStatus; 368 } 369 370 /** 371 * The only useful info in the SendMail response is the status; we capture and save it 372 */ 373 @Override 374 public boolean parse() throws IOException { 375 if (nextTag(START_DOCUMENT) != mStartTag) { 376 throw new IOException(); 377 } 378 while (nextTag(START_DOCUMENT) != END_DOCUMENT) { 379 if (tag == Tags.COMPOSE_STATUS) { 380 mStatus = getValueInt(); 381 } else { 382 skipTag(); 383 } 384 } 385 return true; 386 } 387 } 388 389 /** 390 * Attempt to send one message. 391 * @param message The message to send. 392 * @param smartSendInfo The SmartSendInfo for this message, or null if we don't have or don't 393 * want to use smart send. 394 * @return Whether or not sending this message succeeded. 395 * TODO: Improve how we handle the types of failures. I've left the old error codes in as TODOs 396 * for future reference. 397 */ 398 private boolean sendOneMessage(final Message message, final SmartSendInfo smartSendInfo) { 399 final File tmpFile; 400 try { 401 tmpFile = File.createTempFile("eas_", "tmp", mCacheDir); 402 } catch (final IOException e) { 403 return false; // TODO: Handle SyncStatus.FAILURE_IO; 404 } 405 406 final EasResponse resp; 407 // Send behavior differs pre and post EAS14. 408 final boolean isEas14 = (Double.parseDouble(mAccount.mProtocolVersion) >= 409 Eas.SUPPORTED_PROTOCOL_EX2010_DOUBLE); 410 final int modeTag = getModeTag(isEas14, smartSendInfo); 411 try { 412 if (!writeMessageToTempFile(tmpFile, message, smartSendInfo)) { 413 return false; // TODO: Handle SyncStatus.FAILURE_IO; 414 } 415 416 final FileInputStream fileStream; 417 try { 418 fileStream = new FileInputStream(tmpFile); 419 } catch (final FileNotFoundException e) { 420 return false; // TODO: Handle SyncStatus.FAILURE_IO; 421 } 422 try { 423 424 final long fileLength = tmpFile.length(); 425 final HttpEntity entity; 426 if (isEas14) { 427 entity = new SendMailEntity(fileStream, fileLength, modeTag, message, 428 smartSendInfo); 429 } else { 430 entity = new InputStreamEntity(fileStream, fileLength); 431 } 432 433 // Create the appropriate command. 434 String cmd = "SendMail"; 435 if (smartSendInfo != null) { 436 // In EAS 14, we don't send itemId and collectionId in the command 437 if (isEas14) { 438 cmd = smartSendInfo.isForward() ? "SmartForward" : "SmartReply"; 439 } else { 440 cmd = smartSendInfo.generateSmartSendCmd(); 441 } 442 } 443 // If we're not EAS 14, add our save-in-sent setting here 444 if (!isEas14) { 445 cmd += "&SaveInSent=T"; 446 } 447 // Finally, post SendMail to the server 448 try { 449 resp = sendHttpClientPost(cmd, entity, SEND_MAIL_TIMEOUT); 450 } catch (final IOException e) { 451 return false; // TODO: Handle SyncStatus.FAILURE_IO; 452 } 453 454 } finally { 455 try { 456 fileStream.close(); 457 } catch (final IOException e) { 458 // TODO: Should we do anything here, or is it ok to just proceed? 459 } 460 } 461 } finally { 462 if (tmpFile.exists()) { 463 tmpFile.delete(); 464 } 465 } 466 467 try { 468 final int code = resp.getStatus(); 469 if (code == HttpStatus.SC_OK) { 470 // HTTP OK before EAS 14 is a thumbs up; in EAS 14, we've got to parse 471 // the reply 472 if (isEas14) { 473 try { 474 // Try to parse the result 475 final SendMailParser p = new SendMailParser(resp.getInputStream(), modeTag); 476 // If we get here, the SendMail failed; go figure 477 p.parse(); 478 // The parser holds the status 479 final int status = p.getStatus(); 480 if (CommandStatus.isNeedsProvisioning(status)) { 481 return false; // TODO: Handle SyncStatus.FAILURE_SECURITY; 482 } else if (status == CommandStatus.ITEM_NOT_FOUND && 483 smartSendInfo != null) { 484 // Let's retry without "smart" commands. 485 return sendOneMessage(message, null); 486 } 487 // TODO: Set syncServerId = SEND_FAILED in DB? 488 return false; // TODO: Handle SyncStatus.FAILURE_MESSAGE; 489 } catch (final EmptyStreamException e) { 490 // This is actually fine; an empty stream means SendMail succeeded 491 } catch (final IOException e) { 492 // Parsing failed in some other way. 493 return false; // TODO: Handle SyncStatus.FAILURE_IO; 494 } 495 } 496 } else if (code == HttpStatus.SC_INTERNAL_SERVER_ERROR && smartSendInfo != null) { 497 // Let's retry without "smart" commands. 498 return sendOneMessage(message, null); 499 } else { 500 if (resp.isAuthError()) { 501 LogUtils.d(LogUtils.TAG, "Got auth error from server during outbox sync"); 502 return false; // TODO: Handle SyncStatus.FAILURE_LOGIN; 503 } else if (resp.isProvisionError()) { 504 LogUtils.d(LogUtils.TAG, "Got provision error from server during outbox sync."); 505 return false; // TODO: Handle SyncStatus.FAILURE_SECURITY; 506 } else { 507 // TODO: Handle some other error 508 LogUtils.d(LogUtils.TAG, 509 "Got other HTTP error from server during outbox sync: %d", code); 510 return false; 511 } 512 } 513 } finally { 514 resp.close(); 515 } 516 517 // If we manage to get here, the message sent successfully. Hooray! 518 // Delete the sent message. 519 mContext.getContentResolver().delete( 520 ContentUris.withAppendedId(Message.CONTENT_URI, message.mId), null, null); 521 return true; 522 } 523 524 /** 525 * Writes message to the temp file. 526 * @param tmpFile The temp file to use. 527 * @param message The {@link Message} to write. 528 * @param smartSendInfo The {@link SmartSendInfo} for this message send attempt. 529 * @return Whether we could successfully write the file. 530 */ 531 private boolean writeMessageToTempFile(final File tmpFile, final Message message, 532 final SmartSendInfo smartSendInfo) { 533 final FileOutputStream fileStream; 534 try { 535 fileStream = new FileOutputStream(tmpFile); 536 } catch (final FileNotFoundException e) { 537 Log.e(LogUtils.TAG, "Failed to create message file", e); 538 return false; 539 } 540 try { 541 final boolean smartSend = smartSendInfo != null; 542 final ArrayList<Attachment> attachments = 543 smartSend ? smartSendInfo.mRequiredAtts : null; 544 Rfc822Output.writeTo(mContext, message, fileStream, smartSend, true, attachments); 545 } catch (final Exception e) { 546 Log.e(LogUtils.TAG, "Failed to write message file", e); 547 return false; 548 } finally { 549 try { 550 fileStream.close(); 551 } catch (final IOException e) { 552 // should not happen 553 Log.e(LogUtils.TAG, "Failed to close file - should not happen", e); 554 } 555 } 556 return true; 557 } 558 559 private static int getModeTag(final boolean isEas14, final SmartSendInfo smartSendInfo) { 560 if (isEas14) { 561 if (smartSendInfo == null) { 562 return Tags.COMPOSE_SEND_MAIL; 563 } else if (smartSendInfo.isForward()) { 564 return Tags.COMPOSE_SMART_FORWARD; 565 } else { 566 return Tags.COMPOSE_SMART_REPLY; 567 } 568 } 569 return 0; 570 } 571} 572