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