• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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;
getAttachmentUri(long accountId, long id)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
getAttachmentThumbnailUri(long accountId, long id, long width, long height)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      */
getAttachmentFilename(Context context, long accountId, long attachmentId)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      */
getAttachmentDirectory(Context context, long accountId)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      */
inferMimeType(final String fileName, final String mimeType)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      */
getFilenameExtension(String fileName)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      */
resolveAttachmentIdToContentUri(ContentResolver resolver, Uri attachmentUri)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      */
deleteAllAttachmentFiles(Context context, long accountId, long messageId)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      */
deleteAllCachedAttachmentFiles(Context context, long accountId, long messageId)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      */
deleteAllMailboxAttachmentFiles(Context context, long accountId, long mailboxId)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      */
deleteAllAccountAttachmentFiles(Context context, long accountId)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 
copyFile(InputStream in, OutputStream out)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      */
saveAttachment(Context context, InputStream in, Attachment attachment)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