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.Message;
35import com.android.emailcommon.provider.EmailContent.MessageColumns;
36import com.android.mail.providers.UIProvider;
37import com.android.mail.utils.LogUtils;
38
39import org.apache.commons.io.IOUtils;
40
41import java.io.File;
42import java.io.FileOutputStream;
43import java.io.IOException;
44import java.io.InputStream;
45import java.io.OutputStream;
46
47public 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