1 /* 2 * Copyright (C) 2009 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 17 package com.android.email; 18 19 import android.content.ContentUris; 20 import android.content.ContentValues; 21 import android.content.Context; 22 import android.database.Cursor; 23 import android.net.Uri; 24 import android.text.TextUtils; 25 26 import com.android.emailcommon.Logging; 27 import com.android.emailcommon.internet.MimeBodyPart; 28 import com.android.emailcommon.internet.MimeHeader; 29 import com.android.emailcommon.internet.MimeMessage; 30 import com.android.emailcommon.internet.MimeMultipart; 31 import com.android.emailcommon.internet.MimeUtility; 32 import com.android.emailcommon.internet.TextBody; 33 import com.android.emailcommon.mail.Address; 34 import com.android.emailcommon.mail.Base64Body; 35 import com.android.emailcommon.mail.Flag; 36 import com.android.emailcommon.mail.Message; 37 import com.android.emailcommon.mail.Message.RecipientType; 38 import com.android.emailcommon.mail.MessagingException; 39 import com.android.emailcommon.mail.Multipart; 40 import com.android.emailcommon.mail.Part; 41 import com.android.emailcommon.provider.EmailContent; 42 import com.android.emailcommon.provider.EmailContent.Attachment; 43 import com.android.emailcommon.provider.EmailContent.AttachmentColumns; 44 import com.android.emailcommon.provider.Mailbox; 45 import com.android.emailcommon.utility.AttachmentUtilities; 46 import com.android.mail.providers.UIProvider; 47 import com.android.mail.utils.LogUtils; 48 import com.google.common.annotations.VisibleForTesting; 49 50 import org.apache.commons.io.IOUtils; 51 52 import java.io.ByteArrayInputStream; 53 import java.io.File; 54 import java.io.FileNotFoundException; 55 import java.io.FileOutputStream; 56 import java.io.IOException; 57 import java.io.InputStream; 58 import java.util.ArrayList; 59 import java.util.Date; 60 import java.util.HashMap; 61 62 public class LegacyConversions { 63 64 /** DO NOT CHECK IN "TRUE" */ 65 private static final boolean DEBUG_ATTACHMENTS = false; 66 67 /** Used for mapping folder names to type codes (e.g. inbox, drafts, trash) */ 68 private static final HashMap<String, Integer> 69 sServerMailboxNames = new HashMap<String, Integer>(); 70 71 /** 72 * Copy field-by-field from a "store" message to a "provider" message 73 * 74 * @param message The message we've just downloaded (must be a MimeMessage) 75 * @param localMessage The message we'd like to write into the DB 76 * @return true if dirty (changes were made) 77 */ 78 public static boolean updateMessageFields(final EmailContent.Message localMessage, 79 final Message message, final long accountId, final long mailboxId) 80 throws MessagingException { 81 82 final Address[] from = message.getFrom(); 83 final Address[] to = message.getRecipients(Message.RecipientType.TO); 84 final Address[] cc = message.getRecipients(Message.RecipientType.CC); 85 final Address[] bcc = message.getRecipients(Message.RecipientType.BCC); 86 final Address[] replyTo = message.getReplyTo(); 87 final String subject = message.getSubject(); 88 final Date sentDate = message.getSentDate(); 89 final Date internalDate = message.getInternalDate(); 90 91 if (from != null && from.length > 0) { 92 localMessage.mDisplayName = from[0].toFriendly(); 93 } 94 if (sentDate != null) { 95 localMessage.mTimeStamp = sentDate.getTime(); 96 } else if (internalDate != null) { 97 LogUtils.w(Logging.LOG_TAG, "No sentDate, falling back to internalDate"); 98 localMessage.mTimeStamp = internalDate.getTime(); 99 } 100 if (subject != null) { 101 localMessage.mSubject = subject; 102 } 103 localMessage.mFlagRead = message.isSet(Flag.SEEN); 104 if (message.isSet(Flag.ANSWERED)) { 105 localMessage.mFlags |= EmailContent.Message.FLAG_REPLIED_TO; 106 } 107 108 // Keep the message in the "unloaded" state until it has (at least) a display name. 109 // This prevents early flickering of empty messages in POP download. 110 if (localMessage.mFlagLoaded != EmailContent.Message.FLAG_LOADED_COMPLETE) { 111 if (localMessage.mDisplayName == null || "".equals(localMessage.mDisplayName)) { 112 localMessage.mFlagLoaded = EmailContent.Message.FLAG_LOADED_UNLOADED; 113 } else { 114 localMessage.mFlagLoaded = EmailContent.Message.FLAG_LOADED_PARTIAL; 115 } 116 } 117 localMessage.mFlagFavorite = message.isSet(Flag.FLAGGED); 118 // public boolean mFlagAttachment = false; 119 // public int mFlags = 0; 120 121 localMessage.mServerId = message.getUid(); 122 if (internalDate != null) { 123 localMessage.mServerTimeStamp = internalDate.getTime(); 124 } 125 // public String mClientId; 126 127 // Only replace the local message-id if a new one was found. This is seen in some ISP's 128 // which may deliver messages w/o a message-id header. 129 final String messageId = message.getMessageId(); 130 if (messageId != null) { 131 localMessage.mMessageId = messageId; 132 } 133 134 // public long mBodyKey; 135 localMessage.mMailboxKey = mailboxId; 136 localMessage.mAccountKey = accountId; 137 138 if (from != null && from.length > 0) { 139 localMessage.mFrom = Address.toString(from); 140 } 141 142 localMessage.mTo = Address.toString(to); 143 localMessage.mCc = Address.toString(cc); 144 localMessage.mBcc = Address.toString(bcc); 145 localMessage.mReplyTo = Address.toString(replyTo); 146 147 // public String mText; 148 // public String mHtml; 149 // public String mTextReply; 150 // public String mHtmlReply; 151 152 // // Can be used while building messages, but is NOT saved by the Provider 153 // transient public ArrayList<Attachment> mAttachments = null; 154 155 return true; 156 } 157 158 /** 159 * Copy attachments from MimeMessage to provider Message. 160 * 161 * @param context a context for file operations 162 * @param localMessage the attachments will be built against this message 163 * @param attachments the attachments to add 164 */ 165 public static void updateAttachments(final Context context, 166 final EmailContent.Message localMessage, final ArrayList<Part> attachments) 167 throws MessagingException, IOException { 168 localMessage.mAttachments = null; 169 for (Part attachmentPart : attachments) { 170 addOneAttachment(context, localMessage, attachmentPart); 171 } 172 } 173 174 public static void updateInlineAttachments(final Context context, 175 final EmailContent.Message localMessage, final ArrayList<Part> inlineAttachments) 176 throws MessagingException, IOException { 177 for (final Part inlinePart : inlineAttachments) { 178 final String disposition = MimeUtility.getHeaderParameter( 179 MimeUtility.unfoldAndDecode(inlinePart.getDisposition()), null); 180 if (!TextUtils.isEmpty(disposition)) { 181 // Treat inline parts as attachments 182 addOneAttachment(context, localMessage, inlinePart); 183 } 184 } 185 } 186 187 /** 188 * Convert a MIME Part object into an Attachment object. Separated for unit testing. 189 * 190 * @param part MIME part object to convert 191 * @return Populated Account object 192 * @throws MessagingException 193 */ 194 @VisibleForTesting 195 protected static Attachment mimePartToAttachment(final Part part) throws MessagingException { 196 // Transfer fields from mime format to provider format 197 final String contentType = MimeUtility.unfoldAndDecode(part.getContentType()); 198 199 String name = MimeUtility.getHeaderParameter(contentType, "name"); 200 if (TextUtils.isEmpty(name)) { 201 final String contentDisposition = MimeUtility.unfoldAndDecode(part.getDisposition()); 202 name = MimeUtility.getHeaderParameter(contentDisposition, "filename"); 203 } 204 205 // Incoming attachment: Try to pull size from disposition (if not downloaded yet) 206 long size = 0; 207 final String disposition = part.getDisposition(); 208 if (!TextUtils.isEmpty(disposition)) { 209 String s = MimeUtility.getHeaderParameter(disposition, "size"); 210 if (!TextUtils.isEmpty(s)) { 211 try { 212 size = Long.parseLong(s); 213 } catch (final NumberFormatException e) { 214 LogUtils.d(LogUtils.TAG, e, "Could not decode size \"%s\" from attachment part", 215 size); 216 } 217 } 218 } 219 220 // Get partId for unloaded IMAP attachments (if any) 221 // This is only provided (and used) when we have structure but not the actual attachment 222 final String[] partIds = part.getHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA); 223 final String partId = partIds != null ? partIds[0] : null; 224 225 final Attachment localAttachment = new Attachment(); 226 227 // Run the mime type through inferMimeType in case we have something generic and can do 228 // better using the filename extension 229 localAttachment.mMimeType = AttachmentUtilities.inferMimeType(name, part.getMimeType()); 230 localAttachment.mFileName = name; 231 localAttachment.mSize = size; 232 localAttachment.mContentId = part.getContentId(); 233 localAttachment.setContentUri(null); // Will be rewritten by saveAttachmentBody 234 localAttachment.mLocation = partId; 235 localAttachment.mEncoding = "B"; // TODO - convert other known encodings 236 237 return localAttachment; 238 } 239 240 /** 241 * Add a single attachment part to the message 242 * 243 * This will skip adding attachments if they are already found in the attachments table. 244 * The heuristic for this will fail (false-positive) if two identical attachments are 245 * included in a single POP3 message. 246 * TODO: Fix that, by (elsewhere) simulating an mLocation value based on the attachments 247 * position within the list of multipart/mixed elements. This would make every POP3 attachment 248 * unique, and might also simplify the code (since we could just look at the positions, and 249 * ignore the filename, etc.) 250 * 251 * TODO: Take a closer look at encoding and deal with it if necessary. 252 * 253 * @param context a context for file operations 254 * @param localMessage the attachments will be built against this message 255 * @param part a single attachment part from POP or IMAP 256 */ 257 public static void addOneAttachment(final Context context, 258 final EmailContent.Message localMessage, final Part part) 259 throws MessagingException, IOException { 260 final Attachment localAttachment = mimePartToAttachment(part); 261 localAttachment.mMessageKey = localMessage.mId; 262 localAttachment.mAccountKey = localMessage.mAccountKey; 263 264 if (DEBUG_ATTACHMENTS) { 265 LogUtils.d(Logging.LOG_TAG, "Add attachment " + localAttachment); 266 } 267 268 // To prevent duplication - do we already have a matching attachment? 269 // The fields we'll check for equality are: 270 // mFileName, mMimeType, mContentId, mMessageKey, mLocation 271 // NOTE: This will false-positive if you attach the exact same file, twice, to a POP3 272 // message. We can live with that - you'll get one of the copies. 273 final Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, localMessage.mId); 274 final Cursor cursor = context.getContentResolver().query(uri, Attachment.CONTENT_PROJECTION, 275 null, null, null); 276 boolean attachmentFoundInDb = false; 277 try { 278 while (cursor.moveToNext()) { 279 final Attachment dbAttachment = new Attachment(); 280 dbAttachment.restore(cursor); 281 // We test each of the fields here (instead of in SQL) because they may be 282 // null, or may be strings. 283 if (!TextUtils.equals(dbAttachment.mFileName, localAttachment.mFileName) || 284 !TextUtils.equals(dbAttachment.mMimeType, localAttachment.mMimeType) || 285 !TextUtils.equals(dbAttachment.mContentId, localAttachment.mContentId) || 286 !TextUtils.equals(dbAttachment.mLocation, localAttachment.mLocation)) { 287 continue; 288 } 289 // We found a match, so use the existing attachment id, and stop looking/looping 290 attachmentFoundInDb = true; 291 localAttachment.mId = dbAttachment.mId; 292 if (DEBUG_ATTACHMENTS) { 293 LogUtils.d(Logging.LOG_TAG, "Skipped, found db attachment " + dbAttachment); 294 } 295 break; 296 } 297 } finally { 298 cursor.close(); 299 } 300 301 // Save the attachment (so far) in order to obtain an id 302 if (!attachmentFoundInDb) { 303 localAttachment.save(context); 304 } 305 306 // If an attachment body was actually provided, we need to write the file now 307 saveAttachmentBody(context, part, localAttachment, localMessage.mAccountKey); 308 309 if (localMessage.mAttachments == null) { 310 localMessage.mAttachments = new ArrayList<Attachment>(); 311 } 312 localMessage.mAttachments.add(localAttachment); 313 localMessage.mFlagAttachment = true; 314 } 315 316 /** 317 * Save the body part of a single attachment, to a file in the attachments directory. 318 */ 319 public static void saveAttachmentBody(final Context context, final Part part, 320 final Attachment localAttachment, long accountId) 321 throws MessagingException, IOException { 322 if (part.getBody() != null) { 323 final long attachmentId = localAttachment.mId; 324 325 final File saveIn = AttachmentUtilities.getAttachmentDirectory(context, accountId); 326 327 if (!saveIn.isDirectory() && !saveIn.mkdirs()) { 328 throw new IOException("Could not create attachment directory"); 329 } 330 final File saveAs = AttachmentUtilities.getAttachmentFilename(context, accountId, 331 attachmentId); 332 333 InputStream in = null; 334 FileOutputStream out = null; 335 final long copySize; 336 try { 337 in = part.getBody().getInputStream(); 338 out = new FileOutputStream(saveAs); 339 copySize = IOUtils.copyLarge(in, out); 340 } finally { 341 if (in != null) { 342 in.close(); 343 } 344 if (out != null) { 345 out.close(); 346 } 347 } 348 349 // update the attachment with the extra information we now know 350 final String contentUriString = AttachmentUtilities.getAttachmentUri( 351 accountId, attachmentId).toString(); 352 353 localAttachment.mSize = copySize; 354 localAttachment.setContentUri(contentUriString); 355 356 // update the attachment in the database as well 357 final ContentValues cv = new ContentValues(3); 358 cv.put(AttachmentColumns.SIZE, copySize); 359 cv.put(AttachmentColumns.CONTENT_URI, contentUriString); 360 cv.put(AttachmentColumns.UI_STATE, UIProvider.AttachmentState.SAVED); 361 final Uri uri = ContentUris.withAppendedId(Attachment.CONTENT_URI, attachmentId); 362 context.getContentResolver().update(uri, cv, null, null); 363 } 364 } 365 366 /** 367 * Read a complete Provider message into a legacy message (for IMAP upload). This 368 * is basically the equivalent of LocalFolder.getMessages() + LocalFolder.fetch(). 369 */ 370 public static Message makeMessage(final Context context, 371 final EmailContent.Message localMessage) 372 throws MessagingException { 373 final MimeMessage message = new MimeMessage(); 374 375 // LocalFolder.getMessages() equivalent: Copy message fields 376 message.setSubject(localMessage.mSubject == null ? "" : localMessage.mSubject); 377 final Address[] from = Address.fromHeader(localMessage.mFrom); 378 if (from.length > 0) { 379 message.setFrom(from[0]); 380 } 381 message.setSentDate(new Date(localMessage.mTimeStamp)); 382 message.setUid(localMessage.mServerId); 383 message.setFlag(Flag.DELETED, 384 localMessage.mFlagLoaded == EmailContent.Message.FLAG_LOADED_DELETED); 385 message.setFlag(Flag.SEEN, localMessage.mFlagRead); 386 message.setFlag(Flag.FLAGGED, localMessage.mFlagFavorite); 387 // message.setFlag(Flag.DRAFT, localMessage.mMailboxKey == draftMailboxKey); 388 message.setRecipients(RecipientType.TO, Address.fromHeader(localMessage.mTo)); 389 message.setRecipients(RecipientType.CC, Address.fromHeader(localMessage.mCc)); 390 message.setRecipients(RecipientType.BCC, Address.fromHeader(localMessage.mBcc)); 391 message.setReplyTo(Address.fromHeader(localMessage.mReplyTo)); 392 message.setInternalDate(new Date(localMessage.mServerTimeStamp)); 393 message.setMessageId(localMessage.mMessageId); 394 395 // LocalFolder.fetch() equivalent: build body parts 396 message.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "multipart/mixed"); 397 final MimeMultipart mp = new MimeMultipart(); 398 mp.setSubType("mixed"); 399 message.setBody(mp); 400 401 try { 402 addTextBodyPart(mp, "text/html", 403 EmailContent.Body.restoreBodyHtmlWithMessageId(context, localMessage.mId)); 404 } catch (RuntimeException rte) { 405 LogUtils.d(Logging.LOG_TAG, "Exception while reading html body " + rte.toString()); 406 } 407 408 try { 409 addTextBodyPart(mp, "text/plain", 410 EmailContent.Body.restoreBodyTextWithMessageId(context, localMessage.mId)); 411 } catch (RuntimeException rte) { 412 LogUtils.d(Logging.LOG_TAG, "Exception while reading text body " + rte.toString()); 413 } 414 415 // Attachments 416 final Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, localMessage.mId); 417 final Cursor attachments = 418 context.getContentResolver().query(uri, Attachment.CONTENT_PROJECTION, 419 null, null, null); 420 421 try { 422 while (attachments != null && attachments.moveToNext()) { 423 final Attachment att = new Attachment(); 424 att.restore(attachments); 425 try { 426 final InputStream content; 427 if (att.mContentBytes != null) { 428 // This is generally only the case for synthetic attachments, such as those 429 // generated by unit tests or calendar invites 430 content = new ByteArrayInputStream(att.mContentBytes); 431 } else { 432 String contentUriString = att.getCachedFileUri(); 433 if (TextUtils.isEmpty(contentUriString)) { 434 contentUriString = att.getContentUri(); 435 } 436 if (TextUtils.isEmpty(contentUriString)) { 437 content = null; 438 } else { 439 final Uri contentUri = Uri.parse(contentUriString); 440 content = context.getContentResolver().openInputStream(contentUri); 441 } 442 } 443 final String mimeType = att.mMimeType; 444 final Long contentSize = att.mSize; 445 final String contentId = att.mContentId; 446 final String filename = att.mFileName; 447 if (content != null) { 448 addAttachmentPart(mp, mimeType, contentSize, filename, contentId, content); 449 } else { 450 LogUtils.e(LogUtils.TAG, "Could not open attachment file for upsync"); 451 } 452 } catch (final FileNotFoundException e) { 453 LogUtils.e(LogUtils.TAG, "File Not Found error on %s while upsyncing message", 454 att.getCachedFileUri()); 455 } 456 } 457 } finally { 458 if (attachments != null) { 459 attachments.close(); 460 } 461 } 462 463 return message; 464 } 465 466 /** 467 * Helper method to add a body part for a given type of text, if found 468 * 469 * @param mp The text body part will be added to this multipart 470 * @param contentType The content-type of the text being added 471 * @param partText The text to add. If null, nothing happens 472 */ 473 private static void addTextBodyPart(final MimeMultipart mp, final String contentType, 474 final String partText) 475 throws MessagingException { 476 if (partText == null) { 477 return; 478 } 479 final TextBody body = new TextBody(partText); 480 final MimeBodyPart bp = new MimeBodyPart(body, contentType); 481 mp.addBodyPart(bp); 482 } 483 484 /** 485 * Helper method to add an attachment part 486 * 487 * @param mp Multipart message to append attachment part to 488 * @param contentType Mime type 489 * @param contentSize Attachment metadata: unencoded file size 490 * @param filename Attachment metadata: file name 491 * @param contentId as referenced from cid: uris in the message body (if applicable) 492 * @param content unencoded bytes 493 */ 494 @VisibleForTesting 495 protected static void addAttachmentPart(final Multipart mp, final String contentType, 496 final Long contentSize, final String filename, final String contentId, 497 final InputStream content) throws MessagingException { 498 final Base64Body body = new Base64Body(content); 499 final MimeBodyPart bp = new MimeBodyPart(body, contentType); 500 bp.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64"); 501 bp.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, "attachment;\n " 502 + (!TextUtils.isEmpty(filename) ? "filename=\"" + filename + "\";" : "") 503 + "size=" + contentSize); 504 if (contentId != null) { 505 bp.setHeader(MimeHeader.HEADER_CONTENT_ID, contentId); 506 } 507 mp.addBodyPart(bp); 508 } 509 510 /** 511 * Infer mailbox type from mailbox name. Used by MessagingController (for live folder sync). 512 * 513 * Deprecation: this should be configured in the UI, in conjunction with RF6154 support 514 */ 515 @Deprecated 516 public static synchronized int inferMailboxTypeFromName(Context context, String mailboxName) { 517 if (sServerMailboxNames.size() == 0) { 518 // preload the hashmap, one time only 519 sServerMailboxNames.put( 520 context.getString(R.string.mailbox_name_server_inbox), 521 Mailbox.TYPE_INBOX); 522 sServerMailboxNames.put( 523 context.getString(R.string.mailbox_name_server_outbox), 524 Mailbox.TYPE_OUTBOX); 525 sServerMailboxNames.put( 526 context.getString(R.string.mailbox_name_server_drafts), 527 Mailbox.TYPE_DRAFTS); 528 sServerMailboxNames.put( 529 context.getString(R.string.mailbox_name_server_trash), 530 Mailbox.TYPE_TRASH); 531 sServerMailboxNames.put( 532 context.getString(R.string.mailbox_name_server_sent), 533 Mailbox.TYPE_SENT); 534 sServerMailboxNames.put( 535 context.getString(R.string.mailbox_name_server_junk), 536 Mailbox.TYPE_JUNK); 537 } 538 if (mailboxName == null || mailboxName.length() == 0) { 539 return Mailbox.TYPE_MAIL; 540 } 541 Integer type = sServerMailboxNames.get(mailboxName); 542 if (type != null) { 543 return type; 544 } 545 return Mailbox.TYPE_MAIL; 546 } 547 } 548