AttachmentUtilities.java revision 17d3a29c9d8f7a27c463239f190bdcc4e0804527
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
17package com.android.emailcommon.utility;
18
19import android.app.DownloadManager;
20import android.content.ContentResolver;
21import android.content.ContentUris;
22import android.content.ContentValues;
23import android.content.Context;
24import android.database.Cursor;
25import android.media.MediaScannerConnection;
26import android.net.Uri;
27import android.os.Environment;
28import android.text.TextUtils;
29import android.webkit.MimeTypeMap;
30
31import com.android.emailcommon.Logging;
32import com.android.emailcommon.provider.EmailContent.Attachment;
33import com.android.emailcommon.provider.EmailContent.AttachmentColumns;
34import com.android.emailcommon.provider.EmailContent.Body;
35import com.android.emailcommon.provider.EmailContent.BodyColumns;
36import com.android.emailcommon.provider.EmailContent.Message;
37import com.android.emailcommon.provider.EmailContent.MessageColumns;
38import com.android.mail.providers.UIProvider;
39import com.android.mail.utils.LogUtils;
40
41import org.apache.commons.io.IOUtils;
42
43import java.io.File;
44import java.io.FileOutputStream;
45import java.io.IOException;
46import java.io.InputStream;
47import java.io.OutputStream;
48
49public 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