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.util.Log;
30import android.webkit.MimeTypeMap;
31
32import com.android.emailcommon.Logging;
33import com.android.emailcommon.provider.EmailContent.Attachment;
34import com.android.emailcommon.provider.EmailContent.AttachmentColumns;
35import com.android.emailcommon.provider.EmailContent.Message;
36import com.android.emailcommon.provider.EmailContent.MessageColumns;
37import com.android.mail.providers.UIProvider;
38
39import org.apache.commons.io.IOUtils;
40
41import java.io.File;
42import java.io.FileOutputStream;
43import java.io.IOException;
44import java.io.InputStream;
45
46public class AttachmentUtilities {
47    public static final String AUTHORITY = "com.android.email.attachmentprovider";
48    public static final Uri CONTENT_URI = Uri.parse( "content://" + AUTHORITY);
49
50    public static final String FORMAT_RAW = "RAW";
51    public static final String FORMAT_THUMBNAIL = "THUMBNAIL";
52
53    public static class Columns {
54        public static final String _ID = "_id";
55        public static final String DATA = "_data";
56        public static final String DISPLAY_NAME = "_display_name";
57        public static final String SIZE = "_size";
58    }
59
60    /**
61     * The MIME type(s) of attachments we're willing to send via attachments.
62     *
63     * Any attachments may be added via Intents with Intent.ACTION_SEND or ACTION_SEND_MULTIPLE.
64     */
65    public static final String[] ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES = new String[] {
66        "*/*",
67    };
68    /**
69     * The MIME type(s) of attachments we're willing to send from the internal UI.
70     *
71     * NOTE:  At the moment it is not possible to open a chooser with a list of filter types, so
72     * the chooser is only opened with the first item in the list.
73     */
74    public static final String[] ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES = new String[] {
75        "image/*",
76        "video/*",
77    };
78    /**
79     * The MIME type(s) of attachments we're willing to view.
80     */
81    public static final String[] ACCEPTABLE_ATTACHMENT_VIEW_TYPES = new String[] {
82        "*/*",
83    };
84    /**
85     * The MIME type(s) of attachments we're not willing to view.
86     */
87    public static final String[] UNACCEPTABLE_ATTACHMENT_VIEW_TYPES = new String[] {
88    };
89    /**
90     * The MIME type(s) of attachments we're willing to download to SD.
91     */
92    public static final String[] ACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES = new String[] {
93        "*/*",
94    };
95    /**
96     * The MIME type(s) of attachments we're not willing to download to SD.
97     */
98    public static final String[] UNACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES = new String[] {
99    };
100    /**
101     * Filename extensions of attachments we're never willing to download (potential malware).
102     * Entries in this list are compared to the end of the lower-cased filename, so they must
103     * be lower case, and should not include a "."
104     */
105    public static final String[] UNACCEPTABLE_ATTACHMENT_EXTENSIONS = new String[] {
106        // File types that contain malware
107        "ade", "adp", "bat", "chm", "cmd", "com", "cpl", "dll", "exe",
108        "hta", "ins", "isp", "jse", "lib", "mde", "msc", "msp",
109        "mst", "pif", "scr", "sct", "shb", "sys", "vb", "vbe",
110        "vbs", "vxd", "wsc", "wsf", "wsh",
111        // File types of common compression/container formats (again, to avoid malware)
112        "zip", "gz", "z", "tar", "tgz", "bz2",
113    };
114    /**
115     * Filename extensions of attachments that can be installed.
116     * Entries in this list are compared to the end of the lower-cased filename, so they must
117     * be lower case, and should not include a "."
118     */
119    public static final String[] INSTALLABLE_ATTACHMENT_EXTENSIONS = new String[] {
120        "apk",
121    };
122    /**
123     * The maximum size of an attachment we're willing to download (either View or Save)
124     * Attachments that are base64 encoded (most) will be about 1.375x their actual size
125     * so we should probably factor that in. A 5MB attachment will generally be around
126     * 6.8MB downloaded but only 5MB saved.
127     */
128    public static final int MAX_ATTACHMENT_DOWNLOAD_SIZE = (5 * 1024 * 1024);
129    /**
130     * The maximum size of an attachment we're willing to upload (measured as stored on disk).
131     * Attachments that are base64 encoded (most) will be about 1.375x their actual size
132     * so we should probably factor that in. A 5MB attachment will generally be around
133     * 6.8MB uploaded.
134     */
135    public static final int MAX_ATTACHMENT_UPLOAD_SIZE = (5 * 1024 * 1024);
136
137    public static Uri getAttachmentUri(long accountId, long id) {
138        return CONTENT_URI.buildUpon()
139        .appendPath(Long.toString(accountId))
140        .appendPath(Long.toString(id))
141        .appendPath(FORMAT_RAW)
142        .build();
143    }
144
145    public static Uri getAttachmentThumbnailUri(long accountId, long id,
146            int width, int height) {
147        return CONTENT_URI.buildUpon()
148        .appendPath(Long.toString(accountId))
149        .appendPath(Long.toString(id))
150        .appendPath(FORMAT_THUMBNAIL)
151        .appendPath(Integer.toString(width))
152        .appendPath(Integer.toString(height))
153        .build();
154    }
155
156    /**
157     * Return the filename for a given attachment.  This should be used by any code that is
158     * going to *write* attachments.
159     *
160     * This does not create or write the file, or even the directories.  It simply builds
161     * the filename that should be used.
162     */
163    public static File getAttachmentFilename(Context context, long accountId, long attachmentId) {
164        return new File(getAttachmentDirectory(context, accountId), Long.toString(attachmentId));
165    }
166
167    /**
168     * Return the directory for a given attachment.  This should be used by any code that is
169     * going to *write* attachments.
170     *
171     * This does not create or write the directory.  It simply builds the pathname that should be
172     * used.
173     */
174    public static File getAttachmentDirectory(Context context, long accountId) {
175        return context.getDatabasePath(accountId + ".db_att");
176    }
177
178    /**
179     * Helper to convert unknown or unmapped attachments to something useful based on filename
180     * extensions. The mime type is inferred based upon the table below. It's not perfect, but
181     * it helps.
182     *
183     * <pre>
184     *                   |---------------------------------------------------------|
185     *                   |                  E X T E N S I O N                      |
186     *                   |---------------------------------------------------------|
187     *                   | .eml        | known(.png) | unknown(.abc) | none        |
188     * | M |-----------------------------------------------------------------------|
189     * | I | none        | msg/rfc822  | image/png   | app/abc       | app/oct-str |
190     * | M |-------------| (always     |             |               |             |
191     * | E | app/oct-str |  overrides  |             |               |             |
192     * | T |-------------|             |             |-----------------------------|
193     * | Y | text/plain  |             |             | text/plain                  |
194     * | P |-------------|             |-------------------------------------------|
195     * | E | any/type    |             | any/type                                  |
196     * |---|-----------------------------------------------------------------------|
197     * </pre>
198     *
199     * NOTE: Since mime types on Android are case-*sensitive*, return values are always in
200     * lower case.
201     *
202     * @param fileName The given filename
203     * @param mimeType The given mime type
204     * @return A likely mime type for the attachment
205     */
206    public static String inferMimeType(final String fileName, final String mimeType) {
207        String resultType = null;
208        String fileExtension = getFilenameExtension(fileName);
209        boolean isTextPlain = "text/plain".equalsIgnoreCase(mimeType);
210
211        if ("eml".equals(fileExtension)) {
212            resultType = "message/rfc822";
213        } else {
214            boolean isGenericType =
215                    isTextPlain || "application/octet-stream".equalsIgnoreCase(mimeType);
216            // If the given mime type is non-empty and non-generic, return it
217            if (isGenericType || TextUtils.isEmpty(mimeType)) {
218                if (!TextUtils.isEmpty(fileExtension)) {
219                    // Otherwise, try to find a mime type based upon the file extension
220                    resultType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension);
221                    if (TextUtils.isEmpty(resultType)) {
222                        // Finally, if original mimetype is text/plain, use it; otherwise synthesize
223                        resultType = isTextPlain ? mimeType : "application/" + fileExtension;
224                    }
225                }
226            } else {
227                resultType = mimeType;
228            }
229        }
230
231        // No good guess could be made; use an appropriate generic type
232        if (TextUtils.isEmpty(resultType)) {
233            resultType = isTextPlain ? "text/plain" : "application/octet-stream";
234        }
235        return resultType.toLowerCase();
236    }
237
238    /**
239     * @return mime-type for a {@link Uri}.
240     *    - Use {@link ContentResolver#getType} for a content: URI.
241     *    - Use {@link #inferMimeType} for a file: URI.
242     *    - Otherwise returns null.
243     */
244    public static String inferMimeTypeForUri(Context context, Uri uri) {
245        final String scheme = uri.getScheme();
246        if (ContentResolver.SCHEME_CONTENT.equals(scheme)) {
247            return context.getContentResolver().getType(uri);
248        } else if (ContentResolver.SCHEME_FILE.equals(scheme)) {
249            return inferMimeType(uri.getLastPathSegment(), "");
250        } else {
251            Log.e(Logging.LOG_TAG, "Unable to determine MIME type for uri=" + uri, new Error());
252            return null;
253        }
254    }
255
256    /**
257     * Extract and return filename's extension, converted to lower case, and not including the "."
258     *
259     * @return extension, or null if not found (or null/empty filename)
260     */
261    public static String getFilenameExtension(String fileName) {
262        String extension = null;
263        if (!TextUtils.isEmpty(fileName)) {
264            int lastDot = fileName.lastIndexOf('.');
265            if ((lastDot > 0) && (lastDot < fileName.length() - 1)) {
266                extension = fileName.substring(lastDot + 1).toLowerCase();
267            }
268        }
269        return extension;
270    }
271
272    /**
273     * Resolve attachment id to content URI.  Returns the resolved content URI (from the attachment
274     * DB) or, if not found, simply returns the incoming value.
275     *
276     * @param attachmentUri
277     * @return resolved content URI
278     *
279     * TODO:  Throws an SQLite exception on a missing DB file (e.g. unknown URI) instead of just
280     * returning the incoming uri, as it should.
281     */
282    public static Uri resolveAttachmentIdToContentUri(ContentResolver resolver, Uri attachmentUri) {
283        Cursor c = resolver.query(attachmentUri,
284                new String[] { Columns.DATA },
285                null, null, null);
286        if (c != null) {
287            try {
288                if (c.moveToFirst()) {
289                    final String strUri = c.getString(0);
290                    if (strUri != null) {
291                        return Uri.parse(strUri);
292                    }
293                }
294            } finally {
295                c.close();
296            }
297        }
298        return attachmentUri;
299    }
300
301    /**
302     * In support of deleting a message, find all attachments and delete associated attachment
303     * files.
304     * @param context
305     * @param accountId the account for the message
306     * @param messageId the message
307     */
308    public static void deleteAllAttachmentFiles(Context context, long accountId, long messageId) {
309        Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId);
310        Cursor c = context.getContentResolver().query(uri, Attachment.ID_PROJECTION,
311                null, null, null);
312        try {
313            while (c.moveToNext()) {
314                long attachmentId = c.getLong(Attachment.ID_PROJECTION_COLUMN);
315                File attachmentFile = getAttachmentFilename(context, accountId, attachmentId);
316                // Note, delete() throws no exceptions for basic FS errors (e.g. file not found)
317                // it just returns false, which we ignore, and proceed to the next file.
318                // This entire loop is best-effort only.
319                attachmentFile.delete();
320            }
321        } finally {
322            c.close();
323        }
324    }
325
326    /**
327     * In support of deleting a mailbox, find all messages and delete their attachments.
328     *
329     * @param context
330     * @param accountId the account for the mailbox
331     * @param mailboxId the mailbox for the messages
332     */
333    public static void deleteAllMailboxAttachmentFiles(Context context, long accountId,
334            long mailboxId) {
335        Cursor c = context.getContentResolver().query(Message.CONTENT_URI,
336                Message.ID_COLUMN_PROJECTION, MessageColumns.MAILBOX_KEY + "=?",
337                new String[] { Long.toString(mailboxId) }, null);
338        try {
339            while (c.moveToNext()) {
340                long messageId = c.getLong(Message.ID_PROJECTION_COLUMN);
341                deleteAllAttachmentFiles(context, accountId, messageId);
342            }
343        } finally {
344            c.close();
345        }
346    }
347
348    /**
349     * In support of deleting or wiping an account, delete all related attachments.
350     *
351     * @param context
352     * @param accountId the account to scrub
353     */
354    public static void deleteAllAccountAttachmentFiles(Context context, long accountId) {
355        File[] files = getAttachmentDirectory(context, accountId).listFiles();
356        if (files == null) return;
357        for (File file : files) {
358            boolean result = file.delete();
359            if (!result) {
360                Log.e(Logging.LOG_TAG, "Failed to delete attachment file " + file.getName());
361            }
362        }
363    }
364
365    private static long copyFile(InputStream in, File file) throws IOException {
366        FileOutputStream out = new FileOutputStream(file);
367        long size = IOUtils.copy(in, out);
368        in.close();
369        out.flush();
370        out.close();
371        return size;
372    }
373
374    /**
375     * Save the attachment to its final resting place (cache or sd card)
376     */
377    public static void saveAttachment(Context context, InputStream in, Attachment attachment) {
378        Uri uri = ContentUris.withAppendedId(Attachment.CONTENT_URI, attachment.mId);
379        ContentValues cv = new ContentValues();
380        long attachmentId = attachment.mId;
381        long accountId = attachment.mAccountKey;
382        String contentUri;
383        long size;
384        try {
385            if (attachment.mUiDestination == UIProvider.AttachmentDestination.CACHE) {
386                File saveIn = getAttachmentDirectory(context, accountId);
387                if (!saveIn.exists()) {
388                    saveIn.mkdirs();
389                }
390                File file = getAttachmentFilename(context, accountId, attachmentId);
391                file.createNewFile();
392                size = copyFile(in, file);
393                contentUri = getAttachmentUri(accountId, attachmentId).toString();
394            } else if (Utility.isExternalStorageMounted()) {
395                File downloads = Environment.getExternalStoragePublicDirectory(
396                        Environment.DIRECTORY_DOWNLOADS);
397                downloads.mkdirs();
398                File file = Utility.createUniqueFile(downloads, attachment.mFileName);
399                size = copyFile(in, file);
400                String absolutePath = file.getAbsolutePath();
401
402                // Although the download manager can scan media files, scanning only happens
403                // after the user clicks on the item in the Downloads app. So, we run the
404                // attachment through the media scanner ourselves so it gets added to
405                // gallery / music immediately.
406                MediaScannerConnection.scanFile(context, new String[] {absolutePath},
407                        null, null);
408
409                DownloadManager dm =
410                        (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
411                long id = dm.addCompletedDownload(attachment.mFileName, attachment.mFileName,
412                        false /* do not use media scanner */,
413                        attachment.mMimeType, absolutePath, size,
414                        true /* show notification */);
415                contentUri = dm.getUriForDownloadedFile(id).toString();
416
417            } else {
418                Log.w(Logging.LOG_TAG, "Trying to save an attachment without external storage?");
419                throw new IOException();
420            }
421
422            // Update the attachment
423            cv.put(AttachmentColumns.SIZE, size);
424            cv.put(AttachmentColumns.CONTENT_URI, contentUri);
425            cv.put(AttachmentColumns.UI_STATE, UIProvider.AttachmentState.SAVED);
426        } catch (IOException e) {
427            // Handle failures here...
428            cv.put(AttachmentColumns.UI_STATE, UIProvider.AttachmentState.FAILED);
429        }
430        context.getContentResolver().update(uri, cv, null, null);
431
432    }
433}
434