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 /** 155 * Return the filename for a given attachment. This should be used by any code that is 156 * going to *write* attachments. 157 * 158 * This does not create or write the file, or even the directories. It simply builds 159 * the filename that should be used. 160 */ 161 public static File getAttachmentFilename(Context context, long accountId, long attachmentId) { 162 return new File(getAttachmentDirectory(context, accountId), Long.toString(attachmentId)); 163 } 164 165 /** 166 * Return the directory for a given attachment. This should be used by any code that is 167 * going to *write* attachments. 168 * 169 * This does not create or write the directory. It simply builds the pathname that should be 170 * used. 171 */ 172 public static File getAttachmentDirectory(Context context, long accountId) { 173 return context.getDatabasePath(accountId + ".db_att"); 174 } 175 176 /** 177 * Helper to convert unknown or unmapped attachments to something useful based on filename 178 * extensions. The mime type is inferred based upon the table below. It's not perfect, but 179 * it helps. 180 * 181 * <pre> 182 * |---------------------------------------------------------| 183 * | E X T E N S I O N | 184 * |---------------------------------------------------------| 185 * | .eml | known(.png) | unknown(.abc) | none | 186 * | M |-----------------------------------------------------------------------| 187 * | I | none | msg/rfc822 | image/png | app/abc | app/oct-str | 188 * | M |-------------| (always | | | | 189 * | E | app/oct-str | overrides | | | | 190 * | T |-------------| | |-----------------------------| 191 * | Y | text/plain | | | text/plain | 192 * | P |-------------| |-------------------------------------------| 193 * | E | any/type | | any/type | 194 * |---|-----------------------------------------------------------------------| 195 * </pre> 196 * 197 * NOTE: Since mime types on Android are case-*sensitive*, return values are always in 198 * lower case. 199 * 200 * @param fileName The given filename 201 * @param mimeType The given mime type 202 * @return A likely mime type for the attachment 203 */ 204 public static String inferMimeType(final String fileName, final String mimeType) { 205 String resultType = null; 206 String fileExtension = getFilenameExtension(fileName); 207 boolean isTextPlain = "text/plain".equalsIgnoreCase(mimeType); 208 209 if ("eml".equals(fileExtension)) { 210 resultType = "message/rfc822"; 211 } else { 212 boolean isGenericType = 213 isTextPlain || "application/octet-stream".equalsIgnoreCase(mimeType); 214 // If the given mime type is non-empty and non-generic, return it 215 if (isGenericType || TextUtils.isEmpty(mimeType)) { 216 if (!TextUtils.isEmpty(fileExtension)) { 217 // Otherwise, try to find a mime type based upon the file extension 218 resultType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension); 219 if (TextUtils.isEmpty(resultType)) { 220 // Finally, if original mimetype is text/plain, use it; otherwise synthesize 221 resultType = isTextPlain ? mimeType : "application/" + fileExtension; 222 } 223 } 224 } else { 225 resultType = mimeType; 226 } 227 } 228 229 // No good guess could be made; use an appropriate generic type 230 if (TextUtils.isEmpty(resultType)) { 231 resultType = isTextPlain ? "text/plain" : "application/octet-stream"; 232 } 233 return resultType.toLowerCase(); 234 } 235 236 /** 237 * Extract and return filename's extension, converted to lower case, and not including the "." 238 * 239 * @return extension, or null if not found (or null/empty filename) 240 */ 241 public static String getFilenameExtension(String fileName) { 242 String extension = null; 243 if (!TextUtils.isEmpty(fileName)) { 244 int lastDot = fileName.lastIndexOf('.'); 245 if ((lastDot > 0) && (lastDot < fileName.length() - 1)) { 246 extension = fileName.substring(lastDot + 1).toLowerCase(); 247 } 248 } 249 return extension; 250 } 251 252 /** 253 * Resolve attachment id to content URI. Returns the resolved content URI (from the attachment 254 * DB) or, if not found, simply returns the incoming value. 255 * 256 * @param attachmentUri 257 * @return resolved content URI 258 * 259 * TODO: Throws an SQLite exception on a missing DB file (e.g. unknown URI) instead of just 260 * returning the incoming uri, as it should. 261 */ 262 public static Uri resolveAttachmentIdToContentUri(ContentResolver resolver, Uri attachmentUri) { 263 Cursor c = resolver.query(attachmentUri, 264 new String[] { Columns.DATA }, 265 null, null, null); 266 if (c != null) { 267 try { 268 if (c.moveToFirst()) { 269 final String strUri = c.getString(0); 270 if (strUri != null) { 271 return Uri.parse(strUri); 272 } 273 } 274 } finally { 275 c.close(); 276 } 277 } 278 return attachmentUri; 279 } 280 281 /** 282 * In support of deleting a message, find all attachments and delete associated attachment 283 * files. 284 * @param context 285 * @param accountId the account for the message 286 * @param messageId the message 287 */ 288 public static void deleteAllAttachmentFiles(Context context, long accountId, long messageId) { 289 Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId); 290 Cursor c = context.getContentResolver().query(uri, Attachment.ID_PROJECTION, 291 null, null, null); 292 try { 293 while (c.moveToNext()) { 294 long attachmentId = c.getLong(Attachment.ID_PROJECTION_COLUMN); 295 File attachmentFile = getAttachmentFilename(context, accountId, attachmentId); 296 // Note, delete() throws no exceptions for basic FS errors (e.g. file not found) 297 // it just returns false, which we ignore, and proceed to the next file. 298 // This entire loop is best-effort only. 299 attachmentFile.delete(); 300 } 301 } finally { 302 c.close(); 303 } 304 } 305 306 /** 307 * In support of deleting a message, find all attachments and delete associated cached 308 * attachment files. 309 * @param context 310 * @param accountId the account for the message 311 * @param messageId the message 312 */ 313 public static void deleteAllCachedAttachmentFiles(Context context, long accountId, 314 long messageId) { 315 final Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId); 316 final Cursor c = context.getContentResolver().query(uri, ATTACHMENT_CACHED_FILE_PROJECTION, 317 null, null, null); 318 try { 319 while (c.moveToNext()) { 320 final String fileName = c.getString(0); 321 if (!TextUtils.isEmpty(fileName)) { 322 final File cachedFile = new File(fileName); 323 // Note, delete() throws no exceptions for basic FS errors (e.g. file not found) 324 // it just returns false, which we ignore, and proceed to the next file. 325 // This entire loop is best-effort only. 326 cachedFile.delete(); 327 } 328 } 329 } finally { 330 c.close(); 331 } 332 } 333 334 /** 335 * In support of deleting a mailbox, find all messages and delete their attachments. 336 * 337 * @param context 338 * @param accountId the account for the mailbox 339 * @param mailboxId the mailbox for the messages 340 */ 341 public static void deleteAllMailboxAttachmentFiles(Context context, long accountId, 342 long mailboxId) { 343 Cursor c = context.getContentResolver().query(Message.CONTENT_URI, 344 Message.ID_COLUMN_PROJECTION, MessageColumns.MAILBOX_KEY + "=?", 345 new String[] { Long.toString(mailboxId) }, null); 346 try { 347 while (c.moveToNext()) { 348 long messageId = c.getLong(Message.ID_PROJECTION_COLUMN); 349 deleteAllAttachmentFiles(context, accountId, messageId); 350 } 351 } finally { 352 c.close(); 353 } 354 } 355 356 /** 357 * In support of deleting or wiping an account, delete all related attachments. 358 * 359 * @param context 360 * @param accountId the account to scrub 361 */ 362 public static void deleteAllAccountAttachmentFiles(Context context, long accountId) { 363 File[] files = getAttachmentDirectory(context, accountId).listFiles(); 364 if (files == null) return; 365 for (File file : files) { 366 boolean result = file.delete(); 367 if (!result) { 368 LogUtils.e(Logging.LOG_TAG, "Failed to delete attachment file " + file.getName()); 369 } 370 } 371 } 372 373 private static long copyFile(InputStream in, OutputStream out) throws IOException { 374 long size = IOUtils.copy(in, out); 375 in.close(); 376 out.flush(); 377 out.close(); 378 return size; 379 } 380 381 /** 382 * Save the attachment to its final resting place (cache or sd card) 383 */ 384 public static void saveAttachment(Context context, InputStream in, Attachment attachment) { 385 Uri uri = ContentUris.withAppendedId(Attachment.CONTENT_URI, attachment.mId); 386 ContentValues cv = new ContentValues(); 387 long attachmentId = attachment.mId; 388 long accountId = attachment.mAccountKey; 389 String contentUri = null; 390 long size; 391 try { 392 ContentResolver resolver = context.getContentResolver(); 393 if (attachment.mUiDestination == UIProvider.AttachmentDestination.CACHE) { 394 Uri attUri = getAttachmentUri(accountId, attachmentId); 395 size = copyFile(in, resolver.openOutputStream(attUri)); 396 contentUri = attUri.toString(); 397 } else if (Utility.isExternalStorageMounted()) { 398 File downloads = Environment.getExternalStoragePublicDirectory( 399 Environment.DIRECTORY_DOWNLOADS); 400 downloads.mkdirs(); 401 File file = Utility.createUniqueFile(downloads, attachment.mFileName); 402 size = copyFile(in, new FileOutputStream(file)); 403 String absolutePath = file.getAbsolutePath(); 404 405 // Although the download manager can scan media files, scanning only happens 406 // after the user clicks on the item in the Downloads app. So, we run the 407 // attachment through the media scanner ourselves so it gets added to 408 // gallery / music immediately. 409 MediaScannerConnection.scanFile(context, new String[] {absolutePath}, 410 null, null); 411 412 DownloadManager dm = 413 (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); 414 long id = dm.addCompletedDownload(attachment.mFileName, attachment.mFileName, 415 false /* do not use media scanner */, 416 attachment.mMimeType, absolutePath, size, 417 true /* show notification */); 418 contentUri = dm.getUriForDownloadedFile(id).toString(); 419 420 } else { 421 LogUtils.w(Logging.LOG_TAG, 422 "Trying to save an attachment without external storage?"); 423 throw new IOException(); 424 } 425 426 // Update the attachment 427 cv.put(AttachmentColumns.SIZE, size); 428 cv.put(AttachmentColumns.CONTENT_URI, contentUri); 429 cv.put(AttachmentColumns.UI_STATE, UIProvider.AttachmentState.SAVED); 430 } catch (IOException e) { 431 // Handle failures here... 432 cv.put(AttachmentColumns.UI_STATE, UIProvider.AttachmentState.FAILED); 433 } 434 context.getContentResolver().update(uri, cv, null, null); 435 436 // If this is an inline attachment, update the body 437 if (contentUri != null && attachment.mContentId != null) { 438 Body body = Body.restoreBodyWithMessageId(context, attachment.mMessageKey); 439 if (body != null && body.mHtmlContent != null) { 440 cv.clear(); 441 String html = body.mHtmlContent; 442 String contentIdRe = 443 "\\s+(?i)src=\"cid(?-i):\\Q" + attachment.mContentId + "\\E\""; 444 String srcContentUri = " src=\"" + contentUri + "\""; 445 html = html.replaceAll(contentIdRe, srcContentUri); 446 cv.put(BodyColumns.HTML_CONTENT, html); 447 context.getContentResolver().update( 448 ContentUris.withAppendedId(Body.CONTENT_URI, body.mId), cv, null, null); 449 } 450 } 451 } 452 } 453