Home | History | Annotate | Download | only in utility
      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