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