AttachmentProvider.java revision 1ec89e6290df7853990517ebe869bdb212d7e337
1/*
2 * Copyright (C) 2008 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.email.provider;
18
19import com.android.email.Email;
20import com.android.email.mail.internet.MimeUtility;
21import com.android.email.provider.EmailContent.Attachment;
22import com.android.email.provider.EmailContent.AttachmentColumns;
23import com.android.email.provider.EmailContent.Message;
24import com.android.email.provider.EmailContent.MessageColumns;
25
26import android.content.ContentProvider;
27import android.content.ContentResolver;
28import android.content.ContentUris;
29import android.content.ContentValues;
30import android.content.Context;
31import android.database.Cursor;
32import android.database.MatrixCursor;
33import android.graphics.Bitmap;
34import android.graphics.BitmapFactory;
35import android.net.Uri;
36import android.os.Binder;
37import android.os.ParcelFileDescriptor;
38import android.text.TextUtils;
39import android.webkit.MimeTypeMap;
40
41import java.io.File;
42import java.io.FileNotFoundException;
43import java.io.FileOutputStream;
44import java.io.IOException;
45import java.io.InputStream;
46import java.util.List;
47
48/*
49 * A simple ContentProvider that allows file access to Email's attachments.
50 *
51 * The URI scheme is as follows.  For raw file access:
52 *   content://com.android.email.attachmentprovider/acct#/attach#/RAW
53 *
54 * And for access to thumbnails:
55 *   content://com.android.email.attachmentprovider/acct#/attach#/THUMBNAIL/width#/height#
56 *
57 * The on-disk (storage) schema is as follows.
58 *
59 * Attachments are stored at:  <database-path>/account#.db_att/item#
60 * Thumbnails are stored at:   <cache-path>/thmb_account#_item#
61 *
62 * Using the standard application context, account #10 and attachment # 20, this would be:
63 *      /data/data/com.android.email/databases/10.db_att/20
64 *      /data/data/com.android.email/cache/thmb_10_20
65 */
66public class AttachmentProvider extends ContentProvider {
67
68    public static final String AUTHORITY = "com.android.email.attachmentprovider";
69    public static final Uri CONTENT_URI = Uri.parse( "content://" + AUTHORITY);
70
71    private static final String FORMAT_RAW = "RAW";
72    private static final String FORMAT_THUMBNAIL = "THUMBNAIL";
73
74    public static class AttachmentProviderColumns {
75        public static final String _ID = "_id";
76        public static final String DATA = "_data";
77        public static final String DISPLAY_NAME = "_display_name";
78        public static final String SIZE = "_size";
79    }
80
81    private static final String[] MIME_TYPE_PROJECTION = new String[] {
82            AttachmentColumns.MIME_TYPE, AttachmentColumns.FILENAME };
83    private static final int MIME_TYPE_COLUMN_MIME_TYPE = 0;
84    private static final int MIME_TYPE_COLUMN_FILENAME = 1;
85
86    private static final String[] PROJECTION_QUERY = new String[] { AttachmentColumns.FILENAME,
87            AttachmentColumns.SIZE, AttachmentColumns.CONTENT_URI };
88
89    public static Uri getAttachmentUri(long accountId, long id) {
90        return CONTENT_URI.buildUpon()
91                .appendPath(Long.toString(accountId))
92                .appendPath(Long.toString(id))
93                .appendPath(FORMAT_RAW)
94                .build();
95    }
96
97    public static Uri getAttachmentThumbnailUri(long accountId, long id,
98            int width, int height) {
99        return CONTENT_URI.buildUpon()
100                .appendPath(Long.toString(accountId))
101                .appendPath(Long.toString(id))
102                .appendPath(FORMAT_THUMBNAIL)
103                .appendPath(Integer.toString(width))
104                .appendPath(Integer.toString(height))
105                .build();
106    }
107
108    /**
109     * Return the filename for a given attachment.  This should be used by any code that is
110     * going to *write* attachments.
111     *
112     * This does not create or write the file, or even the directories.  It simply builds
113     * the filename that should be used.
114     */
115    public static File getAttachmentFilename(Context context, long accountId, long attachmentId) {
116        return new File(getAttachmentDirectory(context, accountId), Long.toString(attachmentId));
117    }
118
119    /**
120     * Return the directory for a given attachment.  This should be used by any code that is
121     * going to *write* attachments.
122     *
123     * This does not create or write the directory.  It simply builds the pathname that should be
124     * used.
125     */
126    public static File getAttachmentDirectory(Context context, long accountId) {
127        return context.getDatabasePath(accountId + ".db_att");
128    }
129
130    @Override
131    public boolean onCreate() {
132        /*
133         * We use the cache dir as a temporary directory (since Android doesn't give us one) so
134         * on startup we'll clean up any .tmp files from the last run.
135         */
136        File[] files = getContext().getCacheDir().listFiles();
137        for (File file : files) {
138            String filename = file.getName();
139            if (filename.endsWith(".tmp") || filename.startsWith("thmb_")) {
140                file.delete();
141            }
142        }
143        return true;
144    }
145
146    /**
147     * Returns the mime type for a given attachment.  There are three possible results:
148     *  - If thumbnail Uri, always returns "image/png" (even if there's no attachment)
149     *  - If the attachment does not exist, returns null
150     *  - Returns the mime type of the attachment
151     */
152    @Override
153    public String getType(Uri uri) {
154        long callingId = Binder.clearCallingIdentity();
155        try {
156            List<String> segments = uri.getPathSegments();
157            String id = segments.get(1);
158            String format = segments.get(2);
159            if (FORMAT_THUMBNAIL.equals(format)) {
160                return "image/png";
161            } else {
162                uri = ContentUris.withAppendedId(Attachment.CONTENT_URI, Long.parseLong(id));
163                Cursor c = getContext().getContentResolver().query(uri, MIME_TYPE_PROJECTION,
164                        null, null, null);
165                try {
166                    if (c.moveToFirst()) {
167                        String mimeType = c.getString(MIME_TYPE_COLUMN_MIME_TYPE);
168                        String fileName = c.getString(MIME_TYPE_COLUMN_FILENAME);
169                        mimeType = inferMimeType(fileName, mimeType);
170                        return mimeType;
171                    }
172                } finally {
173                    c.close();
174                }
175                return null;
176            }
177        } finally {
178            Binder.restoreCallingIdentity(callingId);
179        }
180    }
181
182    /**
183     * Helper to convert unknown or unmapped attachments to something useful based on filename
184     * extensions.  Imperfect, but helps.
185     *
186     * If the file extension is ".eml", return "message/rfc822", which is necessary for the email
187     * app to open it.
188     * If the given mime type is non-empty and anything other than "application/octet-stream",
189     * just return it.  (This is the most common case.)
190     * If the filename has a recognizable extension and it converts to a mime type, return that.
191     * If the filename has an unrecognized extension, return "application/extension"
192     * Otherwise return "application/octet-stream".
193     *
194     * @param fileName The given filename
195     * @param mimeType The given mime type
196     * @return A likely mime type for the attachment
197     */
198    public static String inferMimeType(String fileName, String mimeType) {
199        if (fileName != null && fileName.toLowerCase().endsWith(".eml")) {
200            return "message/rfc822";
201        }
202        // If the given mime type appears to be non-empty and non-generic - return it
203        if (!TextUtils.isEmpty(mimeType) &&
204                !"application/octet-stream".equalsIgnoreCase(mimeType)) {
205            return mimeType;
206        }
207
208        // Try to find an extension in the filename
209        if (!TextUtils.isEmpty(fileName)) {
210            int lastDot = fileName.lastIndexOf('.');
211            String extension = null;
212            if ((lastDot > 0) && (lastDot < fileName.length() - 1)) {
213                extension = fileName.substring(lastDot + 1).toLowerCase();
214            }
215            if (!TextUtils.isEmpty(extension)) {
216                // Extension found.  Look up mime type, or synthesize if none found.
217                mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
218                if (mimeType == null) {
219                    mimeType = "application/" + extension;
220                }
221                return mimeType;
222            }
223        }
224
225        // Fallback case - no good guess could be made.
226        return "application/octet-stream";
227    }
228
229    /**
230     * Open an attachment file.  There are two "modes" - "raw", which returns an actual file,
231     * and "thumbnail", which attempts to generate a thumbnail image.
232     *
233     * Thumbnails are cached for easy space recovery and cleanup.
234     *
235     * TODO:  The thumbnail mode returns null for its failure cases, instead of throwing
236     * FileNotFoundException, and should be fixed for consistency.
237     *
238     *  @throws FileNotFoundException
239     */
240    @Override
241    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
242        long callingId = Binder.clearCallingIdentity();
243        try {
244            List<String> segments = uri.getPathSegments();
245            String accountId = segments.get(0);
246            String id = segments.get(1);
247            String format = segments.get(2);
248            if (FORMAT_THUMBNAIL.equals(format)) {
249                int width = Integer.parseInt(segments.get(3));
250                int height = Integer.parseInt(segments.get(4));
251                String filename = "thmb_" + accountId + "_" + id;
252                File dir = getContext().getCacheDir();
253                File file = new File(dir, filename);
254                if (!file.exists()) {
255                    Uri attachmentUri =
256                        getAttachmentUri(Long.parseLong(accountId), Long.parseLong(id));
257                    Cursor c = query(attachmentUri,
258                            new String[] { AttachmentProviderColumns.DATA }, null, null, null);
259                    if (c != null) {
260                        try {
261                            if (c.moveToFirst()) {
262                                attachmentUri = Uri.parse(c.getString(0));
263                            } else {
264                                return null;
265                            }
266                        } finally {
267                            c.close();
268                        }
269                    }
270                    String type = getContext().getContentResolver().getType(attachmentUri);
271                    try {
272                        InputStream in =
273                            getContext().getContentResolver().openInputStream(attachmentUri);
274                        Bitmap thumbnail = createThumbnail(type, in);
275                        thumbnail = Bitmap.createScaledBitmap(thumbnail, width, height, true);
276                        FileOutputStream out = new FileOutputStream(file);
277                        thumbnail.compress(Bitmap.CompressFormat.PNG, 100, out);
278                        out.close();
279                        in.close();
280                    }
281                    catch (IOException ioe) {
282                        return null;
283                    }
284                }
285                return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
286            }
287            else {
288                return ParcelFileDescriptor.open(
289                        new File(getContext().getDatabasePath(accountId + ".db_att"), id),
290                        ParcelFileDescriptor.MODE_READ_ONLY);
291            }
292        } finally {
293            Binder.restoreCallingIdentity(callingId);
294        }
295    }
296
297    @Override
298    public int delete(Uri uri, String arg1, String[] arg2) {
299        return 0;
300    }
301
302    @Override
303    public Uri insert(Uri uri, ContentValues values) {
304        return null;
305    }
306
307    /**
308     * Returns a cursor based on the data in the attachments table, or null if the attachment
309     * is not recorded in the table.
310     *
311     * Supports REST Uri only, for a single row - selection, selection args, and sortOrder are
312     * ignored (non-null values should probably throw an exception....)
313     */
314    @Override
315    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
316            String sortOrder) {
317        long callingId = Binder.clearCallingIdentity();
318        try {
319            if (projection == null) {
320                projection =
321                    new String[] {
322                        AttachmentProviderColumns._ID,
323                        AttachmentProviderColumns.DATA,
324                };
325            }
326
327            List<String> segments = uri.getPathSegments();
328            String accountId = segments.get(0);
329            String id = segments.get(1);
330            String format = segments.get(2);
331            String name = null;
332            int size = -1;
333            String contentUri = null;
334
335            uri = ContentUris.withAppendedId(Attachment.CONTENT_URI, Long.parseLong(id));
336            Cursor c = getContext().getContentResolver().query(uri, PROJECTION_QUERY,
337                    null, null, null);
338            try {
339                if (c.moveToFirst()) {
340                    name = c.getString(0);
341                    size = c.getInt(1);
342                    contentUri = c.getString(2);
343                } else {
344                    return null;
345                }
346            } finally {
347                c.close();
348            }
349
350            MatrixCursor ret = new MatrixCursor(projection);
351            Object[] values = new Object[projection.length];
352            for (int i = 0, count = projection.length; i < count; i++) {
353                String column = projection[i];
354                if (AttachmentProviderColumns._ID.equals(column)) {
355                    values[i] = id;
356                }
357                else if (AttachmentProviderColumns.DATA.equals(column)) {
358                    values[i] = contentUri;
359                }
360                else if (AttachmentProviderColumns.DISPLAY_NAME.equals(column)) {
361                    values[i] = name;
362                }
363                else if (AttachmentProviderColumns.SIZE.equals(column)) {
364                    values[i] = size;
365                }
366            }
367            ret.addRow(values);
368            return ret;
369        } finally {
370            Binder.restoreCallingIdentity(callingId);
371        }
372    }
373
374    @Override
375    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
376        return 0;
377    }
378
379    private Bitmap createThumbnail(String type, InputStream data) {
380        if(MimeUtility.mimeTypeMatches(type, "image/*")) {
381            return createImageThumbnail(data);
382        }
383        return null;
384    }
385
386    private Bitmap createImageThumbnail(InputStream data) {
387        try {
388            Bitmap bitmap = BitmapFactory.decodeStream(data);
389            return bitmap;
390        }
391        catch (OutOfMemoryError oome) {
392            /*
393             * Improperly downloaded images, corrupt bitmaps and the like can commonly
394             * cause OOME due to invalid allocation sizes. We're happy with a null bitmap in
395             * that case. If the system is really out of memory we'll know about it soon
396             * enough.
397             */
398            return null;
399        }
400        catch (Exception e) {
401            return null;
402        }
403    }
404    /**
405     * Resolve attachment id to content URI.  Returns the resolved content URI (from the attachment
406     * DB) or, if not found, simply returns the incoming value.
407     *
408     * @param attachmentUri
409     * @return resolved content URI
410     *
411     * TODO:  Throws an SQLite exception on a missing DB file (e.g. unknown URI) instead of just
412     * returning the incoming uri, as it should.
413     */
414    public static Uri resolveAttachmentIdToContentUri(ContentResolver resolver, Uri attachmentUri) {
415        Cursor c = resolver.query(attachmentUri,
416                new String[] { AttachmentProvider.AttachmentProviderColumns.DATA },
417                null, null, null);
418        if (c != null) {
419            try {
420                if (c.moveToFirst()) {
421                    final String strUri = c.getString(0);
422                    if (strUri != null) {
423                        return Uri.parse(strUri);
424                    } else {
425                        Email.log("AttachmentProvider: attachment with null contentUri");
426                    }
427                }
428            } finally {
429                c.close();
430            }
431        }
432        return attachmentUri;
433    }
434
435    /**
436     * In support of deleting a message, find all attachments and delete associated attachment
437     * files.
438     * @param context
439     * @param accountId the account for the message
440     * @param messageId the message
441     */
442    public static void deleteAllAttachmentFiles(Context context, long accountId, long messageId) {
443        Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId);
444        Cursor c = context.getContentResolver().query(uri, Attachment.ID_PROJECTION,
445                null, null, null);
446        try {
447            while (c.moveToNext()) {
448                long attachmentId = c.getLong(Attachment.ID_PROJECTION_COLUMN);
449                File attachmentFile = getAttachmentFilename(context, accountId, attachmentId);
450                // Note, delete() throws no exceptions for basic FS errors (e.g. file not found)
451                // it just returns false, which we ignore, and proceed to the next file.
452                // This entire loop is best-effort only.
453                attachmentFile.delete();
454            }
455        } finally {
456            c.close();
457        }
458    }
459
460    /**
461     * In support of deleting a mailbox, find all messages and delete their attachments.
462     *
463     * @param context
464     * @param accountId the account for the mailbox
465     * @param mailboxId the mailbox for the messages
466     */
467    public static void deleteAllMailboxAttachmentFiles(Context context, long accountId,
468            long mailboxId) {
469        Cursor c = context.getContentResolver().query(Message.CONTENT_URI,
470                Message.ID_COLUMN_PROJECTION, MessageColumns.MAILBOX_KEY + "=?",
471                new String[] { Long.toString(mailboxId) }, null);
472        try {
473            while (c.moveToNext()) {
474                long messageId = c.getLong(Message.ID_PROJECTION_COLUMN);
475                deleteAllAttachmentFiles(context, accountId, messageId);
476            }
477        } finally {
478            c.close();
479        }
480    }
481}
482