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