1 /* 2 * Copyright (C) 2011 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.emailcommon.utility; 18 19 import android.app.DownloadManager; 20 import android.content.ContentResolver; 21 import android.content.ContentUris; 22 import android.content.ContentValues; 23 import android.content.Context; 24 import android.database.Cursor; 25 import android.media.MediaScannerConnection; 26 import android.net.Uri; 27 import android.os.Environment; 28 import android.text.TextUtils; 29 import android.webkit.MimeTypeMap; 30 31 import com.android.emailcommon.Logging; 32 import com.android.emailcommon.provider.EmailContent.Attachment; 33 import com.android.emailcommon.provider.EmailContent.AttachmentColumns; 34 import com.android.emailcommon.provider.EmailContent.Body; 35 import com.android.emailcommon.provider.EmailContent.BodyColumns; 36 import com.android.emailcommon.provider.EmailContent.Message; 37 import com.android.emailcommon.provider.EmailContent.MessageColumns; 38 import com.android.mail.providers.UIProvider; 39 import com.android.mail.utils.LogUtils; 40 41 import org.apache.commons.io.IOUtils; 42 43 import java.io.File; 44 import java.io.FileOutputStream; 45 import java.io.IOException; 46 import java.io.InputStream; 47 import java.io.OutputStream; 48 49 public class AttachmentUtilities { 50 51 public static final String FORMAT_RAW = "RAW"; 52 public static final String FORMAT_THUMBNAIL = "THUMBNAIL"; 53 54 public static class Columns { 55 public static final String _ID = "_id"; 56 public static final String DATA = "_data"; 57 public static final String DISPLAY_NAME = "_display_name"; 58 public static final String SIZE = "_size"; 59 } 60 61 private static final String[] ATTACHMENT_CACHED_FILE_PROJECTION = new String[] { 62 AttachmentColumns.CACHED_FILE 63 }; 64 65 /** 66 * The MIME type(s) of attachments we're willing to send via attachments. 67 * 68 * Any attachments may be added via Intents with Intent.ACTION_SEND or ACTION_SEND_MULTIPLE. 69 */ 70 public static final String[] ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES = new String[] { 71 "*/*", 72 }; 73 /** 74 * The MIME type(s) of attachments we're willing to send from the internal UI. 75 * 76 * NOTE: At the moment it is not possible to open a chooser with a list of filter types, so 77 * the chooser is only opened with the first item in the list. 78 */ 79 public static final String[] ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES = new String[] { 80 "image/*", 81 "video/*", 82 }; 83 /** 84 * The MIME type(s) of attachments we're willing to view. 85 */ 86 public static final String[] ACCEPTABLE_ATTACHMENT_VIEW_TYPES = new String[] { 87 "*/*", 88 }; 89 /** 90 * The MIME type(s) of attachments we're not willing to view. 91 */ 92 public static final String[] UNACCEPTABLE_ATTACHMENT_VIEW_TYPES = new String[] { 93 }; 94 /** 95 * The MIME type(s) of attachments we're willing to download to SD. 96 */ 97 public static final String[] ACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES = new String[] { 98 "*/*", 99 }; 100 /** 101 * The MIME type(s) of attachments we're not willing to download to SD. 102 */ 103 public static final String[] UNACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES = new String[] { 104 }; 105 /** 106 * Filename extensions of attachments we're never willing to download (potential malware). 107 * Entries in this list are compared to the end of the lower-cased filename, so they must 108 * be lower case, and should not include a "." 109 */ 110 public static final String[] UNACCEPTABLE_ATTACHMENT_EXTENSIONS = new String[] { 111 // File types that contain malware 112 "ade", "adp", "bat", "chm", "cmd", "com", "cpl", "dll", "exe", 113 "hta", "ins", "isp", "jse", "lib", "mde", "msc", "msp", 114 "mst", "pif", "scr", "sct", "shb", "sys", "vb", "vbe", 115 "vbs", "vxd", "wsc", "wsf", "wsh", 116 // File types of common compression/container formats (again, to avoid malware) 117 "zip", "gz", "z", "tar", "tgz", "bz2", 118 }; 119 /** 120 * Filename extensions of attachments that can be installed. 121 * Entries in this list are compared to the end of the lower-cased filename, so they must 122 * be lower case, and should not include a "." 123 */ 124 public static final String[] INSTALLABLE_ATTACHMENT_EXTENSIONS = new String[] { 125 "apk", 126 }; 127 /** 128 * The maximum size of an attachment we're willing to download (either View or Save) 129 * Attachments that are base64 encoded (most) will be about 1.375x their actual size 130 * so we should probably factor that in. A 5MB attachment will generally be around 131 * 6.8MB downloaded but only 5MB saved. 132 */ 133 public static final int MAX_ATTACHMENT_DOWNLOAD_SIZE = (5 * 1024 * 1024); 134 /** 135 * The maximum size of an attachment we're willing to upload (measured as stored on disk). 136 * Attachments that are base64 encoded (most) will be about 1.375x their actual size 137 * so we should probably factor that in. A 5MB attachment will generally be around 138 * 6.8MB uploaded. 139 */ 140 public static final int MAX_ATTACHMENT_UPLOAD_SIZE = (5 * 1024 * 1024); 141 142 private static Uri sUri; 143 public static Uri getAttachmentUri(long accountId, long id) { 144 if (sUri == null) { 145 sUri = Uri.parse(Attachment.ATTACHMENT_PROVIDER_URI_PREFIX); 146 } 147 return sUri.buildUpon() 148 .appendPath(Long.toString(accountId)) 149 .appendPath(Long.toString(id)) 150 .appendPath(FORMAT_RAW) 151 .build(); 152 } 153 154 // exposed for testing 155 public static Uri getAttachmentThumbnailUri(long accountId, long id, long width, long height) { 156 if (sUri == null) { 157 sUri = Uri.parse(Attachment.ATTACHMENT_PROVIDER_URI_PREFIX); 158 } 159 return sUri.buildUpon() 160 .appendPath(Long.toString(accountId)) 161 .appendPath(Long.toString(id)) 162 .appendPath(FORMAT_THUMBNAIL) 163 .appendPath(Long.toString(width)) 164 .appendPath(Long.toString(height)) 165 .build(); 166 } 167 168 /** 169 * Return the filename for a given attachment. This should be used by any code that is 170 * going to *write* attachments. 171 * 172 * This does not create or write the file, or even the directories. It simply builds 173 * the filename that should be used. 174 */ 175 public static File getAttachmentFilename(Context context, long accountId, long attachmentId) { 176 return new File(getAttachmentDirectory(context, accountId), Long.toString(attachmentId)); 177 } 178 179 /** 180 * Return the directory for a given attachment. This should be used by any code that is 181 * going to *write* attachments. 182 * 183 * This does not create or write the directory. It simply builds the pathname that should be 184 * used. 185 */ 186 public static File getAttachmentDirectory(Context context, long accountId) { 187 return context.getDatabasePath(accountId + ".db_att"); 188 } 189 190 /** 191 * Helper to convert unknown or unmapped attachments to something useful based on filename 192 * extensions. The mime type is inferred based upon the table below. It's not perfect, but 193 * it helps. 194 * 195 * <pre> 196 * |---------------------------------------------------------| 197 * | E X T E N S I O N | 198 * |---------------------------------------------------------| 199 * | .eml | known(.png) | unknown(.abc) | none | 200 * | M |-----------------------------------------------------------------------| 201 * | I | none | msg/rfc822 | image/png | app/abc | app/oct-str | 202 * | M |-------------| (always | | | | 203 * | E | app/oct-str | overrides | | | | 204 * | T |-------------| | |-----------------------------| 205 * | Y | text/plain | | | text/plain | 206 * | P |-------------| |-------------------------------------------| 207 * | E | any/type | | any/type | 208 * |---|-----------------------------------------------------------------------| 209 * </pre> 210 * 211 * NOTE: Since mime types on Android are case-*sensitive*, return values are always in 212 * lower case. 213 * 214 * @param fileName The given filename 215 * @param mimeType The given mime type 216 * @return A likely mime type for the attachment 217 */ 218 public static String inferMimeType(final String fileName, final String mimeType) { 219 String resultType = null; 220 String fileExtension = getFilenameExtension(fileName); 221 boolean isTextPlain = "text/plain".equalsIgnoreCase(mimeType); 222 223 if ("eml".equals(fileExtension)) { 224 resultType = "message/rfc822"; 225 } else { 226 boolean isGenericType = 227 isTextPlain || "application/octet-stream".equalsIgnoreCase(mimeType); 228 // If the given mime type is non-empty and non-generic, return it 229 if (isGenericType || TextUtils.isEmpty(mimeType)) { 230 if (!TextUtils.isEmpty(fileExtension)) { 231 // Otherwise, try to find a mime type based upon the file extension 232 resultType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension); 233 if (TextUtils.isEmpty(resultType)) { 234 // Finally, if original mimetype is text/plain, use it; otherwise synthesize 235 resultType = isTextPlain ? mimeType : "application/" + fileExtension; 236 } 237 } 238 } else { 239 resultType = mimeType; 240 } 241 } 242 243 // No good guess could be made; use an appropriate generic type 244 if (TextUtils.isEmpty(resultType)) { 245 resultType = isTextPlain ? "text/plain" : "application/octet-stream"; 246 } 247 return resultType.toLowerCase(); 248 } 249 250 /** 251 * Extract and return filename's extension, converted to lower case, and not including the "." 252 * 253 * @return extension, or null if not found (or null/empty filename) 254 */ 255 public static String getFilenameExtension(String fileName) { 256 String extension = null; 257 if (!TextUtils.isEmpty(fileName)) { 258 int lastDot = fileName.lastIndexOf('.'); 259 if ((lastDot > 0) && (lastDot < fileName.length() - 1)) { 260 extension = fileName.substring(lastDot + 1).toLowerCase(); 261 } 262 } 263 return extension; 264 } 265 266 /** 267 * Resolve attachment id to content URI. Returns the resolved content URI (from the attachment 268 * DB) or, if not found, simply returns the incoming value. 269 * 270 * @param attachmentUri 271 * @return resolved content URI 272 * 273 * TODO: Throws an SQLite exception on a missing DB file (e.g. unknown URI) instead of just 274 * returning the incoming uri, as it should. 275 */ 276 public static Uri resolveAttachmentIdToContentUri(ContentResolver resolver, Uri attachmentUri) { 277 Cursor c = resolver.query(attachmentUri, 278 new String[] { Columns.DATA }, 279 null, null, null); 280 if (c != null) { 281 try { 282 if (c.moveToFirst()) { 283 final String strUri = c.getString(0); 284 if (strUri != null) { 285 return Uri.parse(strUri); 286 } 287 } 288 } finally { 289 c.close(); 290 } 291 } 292 return attachmentUri; 293 } 294 295 /** 296 * In support of deleting a message, find all attachments and delete associated attachment 297 * files. 298 * @param context 299 * @param accountId the account for the message 300 * @param messageId the message 301 */ 302 public static void deleteAllAttachmentFiles(Context context, long accountId, long messageId) { 303 Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId); 304 Cursor c = context.getContentResolver().query(uri, Attachment.ID_PROJECTION, 305 null, null, null); 306 try { 307 while (c.moveToNext()) { 308 long attachmentId = c.getLong(Attachment.ID_PROJECTION_COLUMN); 309 File attachmentFile = getAttachmentFilename(context, accountId, attachmentId); 310 // Note, delete() throws no exceptions for basic FS errors (e.g. file not found) 311 // it just returns false, which we ignore, and proceed to the next file. 312 // This entire loop is best-effort only. 313 attachmentFile.delete(); 314 } 315 } finally { 316 c.close(); 317 } 318 } 319 320 /** 321 * In support of deleting a message, find all attachments and delete associated cached 322 * attachment files. 323 * @param context 324 * @param accountId the account for the message 325 * @param messageId the message 326 */ 327 public static void deleteAllCachedAttachmentFiles(Context context, long accountId, 328 long messageId) { 329 final Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId); 330 final Cursor c = context.getContentResolver().query(uri, ATTACHMENT_CACHED_FILE_PROJECTION, 331 null, null, null); 332 try { 333 while (c.moveToNext()) { 334 final String fileName = c.getString(0); 335 if (!TextUtils.isEmpty(fileName)) { 336 final File cachedFile = new File(fileName); 337 // Note, delete() throws no exceptions for basic FS errors (e.g. file not found) 338 // it just returns false, which we ignore, and proceed to the next file. 339 // This entire loop is best-effort only. 340 cachedFile.delete(); 341 } 342 } 343 } finally { 344 c.close(); 345 } 346 } 347 348 /** 349 * In support of deleting a mailbox, find all messages and delete their attachments. 350 * 351 * @param context 352 * @param accountId the account for the mailbox 353 * @param mailboxId the mailbox for the messages 354 */ 355 public static void deleteAllMailboxAttachmentFiles(Context context, long accountId, 356 long mailboxId) { 357 Cursor c = context.getContentResolver().query(Message.CONTENT_URI, 358 Message.ID_COLUMN_PROJECTION, MessageColumns.MAILBOX_KEY + "=?", 359 new String[] { Long.toString(mailboxId) }, null); 360 try { 361 while (c.moveToNext()) { 362 long messageId = c.getLong(Message.ID_PROJECTION_COLUMN); 363 deleteAllAttachmentFiles(context, accountId, messageId); 364 } 365 } finally { 366 c.close(); 367 } 368 } 369 370 /** 371 * In support of deleting or wiping an account, delete all related attachments. 372 * 373 * @param context 374 * @param accountId the account to scrub 375 */ 376 public static void deleteAllAccountAttachmentFiles(Context context, long accountId) { 377 File[] files = getAttachmentDirectory(context, accountId).listFiles(); 378 if (files == null) return; 379 for (File file : files) { 380 boolean result = file.delete(); 381 if (!result) { 382 LogUtils.e(Logging.LOG_TAG, "Failed to delete attachment file " + file.getName()); 383 } 384 } 385 } 386 387 private static long copyFile(InputStream in, OutputStream out) throws IOException { 388 long size = IOUtils.copy(in, out); 389 in.close(); 390 out.flush(); 391 out.close(); 392 return size; 393 } 394 395 /** 396 * Save the attachment to its final resting place (cache or sd card) 397 */ 398 public static void saveAttachment(Context context, InputStream in, Attachment attachment) { 399 Uri uri = ContentUris.withAppendedId(Attachment.CONTENT_URI, attachment.mId); 400 ContentValues cv = new ContentValues(); 401 long attachmentId = attachment.mId; 402 long accountId = attachment.mAccountKey; 403 String contentUri = null; 404 long size; 405 try { 406 ContentResolver resolver = context.getContentResolver(); 407 if (attachment.mUiDestination == UIProvider.AttachmentDestination.CACHE) { 408 Uri attUri = getAttachmentUri(accountId, attachmentId); 409 size = copyFile(in, resolver.openOutputStream(attUri)); 410 contentUri = attUri.toString(); 411 } else if (Utility.isExternalStorageMounted()) { 412 if (attachment.mFileName == null) { 413 // TODO: This will prevent a crash but does not surface the underlying problem 414 // to the user correctly. 415 LogUtils.w(Logging.LOG_TAG, "Trying to save an attachment with no name: %d", 416 attachmentId); 417 throw new IOException("Can't save an attachment with no name"); 418 } 419 File downloads = Environment.getExternalStoragePublicDirectory( 420 Environment.DIRECTORY_DOWNLOADS); 421 downloads.mkdirs(); 422 File file = Utility.createUniqueFile(downloads, attachment.mFileName); 423 size = copyFile(in, new FileOutputStream(file)); 424 String absolutePath = file.getAbsolutePath(); 425 426 // Although the download manager can scan media files, scanning only happens 427 // after the user clicks on the item in the Downloads app. So, we run the 428 // attachment through the media scanner ourselves so it gets added to 429 // gallery / music immediately. 430 MediaScannerConnection.scanFile(context, new String[] {absolutePath}, 431 null, null); 432 433 DownloadManager dm = 434 (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); 435 long id = dm.addCompletedDownload(attachment.mFileName, attachment.mFileName, 436 false /* do not use media scanner */, 437 attachment.mMimeType, absolutePath, size, 438 true /* show notification */); 439 contentUri = dm.getUriForDownloadedFile(id).toString(); 440 441 } else { 442 LogUtils.w(Logging.LOG_TAG, 443 "Trying to save an attachment without external storage?"); 444 throw new IOException(); 445 } 446 447 // Update the attachment 448 cv.put(AttachmentColumns.SIZE, size); 449 cv.put(AttachmentColumns.CONTENT_URI, contentUri); 450 cv.put(AttachmentColumns.UI_STATE, UIProvider.AttachmentState.SAVED); 451 } catch (IOException e) { 452 // Handle failures here... 453 cv.put(AttachmentColumns.UI_STATE, UIProvider.AttachmentState.FAILED); 454 } 455 context.getContentResolver().update(uri, cv, null, null); 456 457 // If this is an inline attachment, update the body 458 if (contentUri != null && attachment.mContentId != null) { 459 Body body = Body.restoreBodyWithMessageId(context, attachment.mMessageKey); 460 if (body != null && body.mHtmlContent != null) { 461 cv.clear(); 462 String html = body.mHtmlContent; 463 String contentIdRe = 464 "\\s+(?i)src=\"cid(?-i):\\Q" + attachment.mContentId + "\\E\""; 465 String srcContentUri = " src=\"" + contentUri + "\""; 466 html = html.replaceAll(contentIdRe, srcContentUri); 467 cv.put(BodyColumns.HTML_CONTENT, html); 468 context.getContentResolver().update( 469 ContentUris.withAppendedId(Body.CONTENT_URI, body.mId), cv, null, null); 470 } 471 } 472 } 473 } 474