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