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.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