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     /**
    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