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 given mime type is non-empty and anything other than "application/octet-stream",
188     * just return it.  (This is the most common case.)
189     * If the filename has a recognizable extension and it converts to a mime type, return that.
190     * If the filename has an unrecognized extension, return "application/extension"
191     * Otherwise return "application/octet-stream".
192     *
193     * @param fileName The given filename
194     * @param mimeType The given mime type
195     * @return A likely mime type for the attachment
196     */
197    public static String inferMimeType(String fileName, String mimeType) {
198        // If the given mime type appears to be non-empty and non-generic - return it
199        if (!TextUtils.isEmpty(mimeType) &&
200                !"application/octet-stream".equalsIgnoreCase(mimeType)) {
201            return mimeType;
202        }
203
204        // Try to find an extension in the filename
205        if (!TextUtils.isEmpty(fileName)) {
206            int lastDot = fileName.lastIndexOf('.');
207            String extension = null;
208            if ((lastDot > 0) && (lastDot < fileName.length() - 1)) {
209                extension = fileName.substring(lastDot + 1).toLowerCase();
210            }
211            if (!TextUtils.isEmpty(extension)) {
212                // Extension found.  Look up mime type, or synthesize if none found.
213                mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
214                if (mimeType == null) {
215                    mimeType = "application/" + extension;
216                }
217                return mimeType;
218            }
219        }
220
221        // Fallback case - no good guess could be made.
222        return "application/octet-stream";
223    }
224
225    /**
226     * Open an attachment file.  There are two "modes" - "raw", which returns an actual file,
227     * and "thumbnail", which attempts to generate a thumbnail image.
228     *
229     * Thumbnails are cached for easy space recovery and cleanup.
230     *
231     * TODO:  The thumbnail mode returns null for its failure cases, instead of throwing
232     * FileNotFoundException, and should be fixed for consistency.
233     *
234     *  @throws FileNotFoundException
235     */
236    @Override
237    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
238        long callingId = Binder.clearCallingIdentity();
239        try {
240            List<String> segments = uri.getPathSegments();
241            String accountId = segments.get(0);
242            String id = segments.get(1);
243            String format = segments.get(2);
244            if (FORMAT_THUMBNAIL.equals(format)) {
245                int width = Integer.parseInt(segments.get(3));
246                int height = Integer.parseInt(segments.get(4));
247                String filename = "thmb_" + accountId + "_" + id;
248                File dir = getContext().getCacheDir();
249                File file = new File(dir, filename);
250                if (!file.exists()) {
251                    Uri attachmentUri =
252                        getAttachmentUri(Long.parseLong(accountId), Long.parseLong(id));
253                    Cursor c = query(attachmentUri,
254                            new String[] { AttachmentProviderColumns.DATA }, null, null, null);
255                    if (c != null) {
256                        try {
257                            if (c.moveToFirst()) {
258                                attachmentUri = Uri.parse(c.getString(0));
259                            } else {
260                                return null;
261                            }
262                        } finally {
263                            c.close();
264                        }
265                    }
266                    String type = getContext().getContentResolver().getType(attachmentUri);
267                    try {
268                        InputStream in =
269                            getContext().getContentResolver().openInputStream(attachmentUri);
270                        Bitmap thumbnail = createThumbnail(type, in);
271                        if (thumbnail == null) {
272                            return null;
273                        }
274                        thumbnail = Bitmap.createScaledBitmap(thumbnail, width, height, true);
275                        FileOutputStream out = new FileOutputStream(file);
276                        thumbnail.compress(Bitmap.CompressFormat.PNG, 100, out);
277                        out.close();
278                        in.close();
279                    } catch (IOException ioe) {
280                        Log.d(Email.LOG_TAG, "openFile/thumbnail failed with " + ioe.getMessage());
281                        return null;
282                    } catch (OutOfMemoryError oome) {
283                        Log.d(Email.LOG_TAG, "openFile/thumbnail failed with " + oome.getMessage());
284                        return null;
285                    }
286                }
287                return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
288            }
289            else {
290                return ParcelFileDescriptor.open(
291                        new File(getContext().getDatabasePath(accountId + ".db_att"), id),
292                        ParcelFileDescriptor.MODE_READ_ONLY);
293            }
294        } finally {
295            Binder.restoreCallingIdentity(callingId);
296        }
297    }
298
299    @Override
300    public int delete(Uri uri, String arg1, String[] arg2) {
301        return 0;
302    }
303
304    @Override
305    public Uri insert(Uri uri, ContentValues values) {
306        return null;
307    }
308
309    /**
310     * Returns a cursor based on the data in the attachments table, or null if the attachment
311     * is not recorded in the table.
312     *
313     * Supports REST Uri only, for a single row - selection, selection args, and sortOrder are
314     * ignored (non-null values should probably throw an exception....)
315     */
316    @Override
317    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
318            String sortOrder) {
319        long callingId = Binder.clearCallingIdentity();
320        try {
321            if (projection == null) {
322                projection =
323                    new String[] {
324                        AttachmentProviderColumns._ID,
325                        AttachmentProviderColumns.DATA,
326                };
327            }
328
329            List<String> segments = uri.getPathSegments();
330            String accountId = segments.get(0);
331            String id = segments.get(1);
332            String format = segments.get(2);
333            String name = null;
334            int size = -1;
335            String contentUri = null;
336
337            uri = ContentUris.withAppendedId(Attachment.CONTENT_URI, Long.parseLong(id));
338            Cursor c = getContext().getContentResolver().query(uri, PROJECTION_QUERY,
339                    null, null, null);
340            try {
341                if (c.moveToFirst()) {
342                    name = c.getString(0);
343                    size = c.getInt(1);
344                    contentUri = c.getString(2);
345                } else {
346                    return null;
347                }
348            } finally {
349                c.close();
350            }
351
352            MatrixCursor ret = new MatrixCursor(projection);
353            Object[] values = new Object[projection.length];
354            for (int i = 0, count = projection.length; i < count; i++) {
355                String column = projection[i];
356                if (AttachmentProviderColumns._ID.equals(column)) {
357                    values[i] = id;
358                }
359                else if (AttachmentProviderColumns.DATA.equals(column)) {
360                    values[i] = contentUri;
361                }
362                else if (AttachmentProviderColumns.DISPLAY_NAME.equals(column)) {
363                    values[i] = name;
364                }
365                else if (AttachmentProviderColumns.SIZE.equals(column)) {
366                    values[i] = size;
367                }
368            }
369            ret.addRow(values);
370            return ret;
371        } finally {
372            Binder.restoreCallingIdentity(callingId);
373        }
374    }
375
376    @Override
377    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
378        return 0;
379    }
380
381    private Bitmap createThumbnail(String type, InputStream data) {
382        if(MimeUtility.mimeTypeMatches(type, "image/*")) {
383            return createImageThumbnail(data);
384        }
385        return null;
386    }
387
388    private Bitmap createImageThumbnail(InputStream data) {
389        try {
390            Bitmap bitmap = BitmapFactory.decodeStream(data);
391            return bitmap;
392        } catch (OutOfMemoryError oome) {
393            Log.d(Email.LOG_TAG, "createImageThumbnail failed with " + oome.getMessage());
394            return null;
395        } catch (Exception e) {
396            Log.d(Email.LOG_TAG, "createImageThumbnail failed with " + e.getMessage());
397            return null;
398        }
399    }
400
401    /**
402     * Resolve attachment id to content URI.  Returns the resolved content URI (from the attachment
403     * DB) or, if not found, simply returns the incoming value.
404     *
405     * @param attachmentUri
406     * @return resolved content URI
407     *
408     * TODO:  Throws an SQLite exception on a missing DB file (e.g. unknown URI) instead of just
409     * returning the incoming uri, as it should.
410     */
411    public static Uri resolveAttachmentIdToContentUri(ContentResolver resolver, Uri attachmentUri) {
412        Cursor c = resolver.query(attachmentUri,
413                new String[] { AttachmentProvider.AttachmentProviderColumns.DATA },
414                null, null, null);
415        if (c != null) {
416            try {
417                if (c.moveToFirst()) {
418                    final String strUri = c.getString(0);
419                    if (strUri != null) {
420                        return Uri.parse(strUri);
421                    } else {
422                        Email.log("AttachmentProvider: attachment with null contentUri");
423                    }
424                }
425            } finally {
426                c.close();
427            }
428        }
429        return attachmentUri;
430    }
431
432    /**
433     * In support of deleting a message, find all attachments and delete associated attachment
434     * files.
435     * @param context
436     * @param accountId the account for the message
437     * @param messageId the message
438     */
439    public static void deleteAllAttachmentFiles(Context context, long accountId, long messageId) {
440        Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId);
441        Cursor c = context.getContentResolver().query(uri, Attachment.ID_PROJECTION,
442                null, null, null);
443        try {
444            while (c.moveToNext()) {
445                long attachmentId = c.getLong(Attachment.ID_PROJECTION_COLUMN);
446                File attachmentFile = getAttachmentFilename(context, accountId, attachmentId);
447                // Note, delete() throws no exceptions for basic FS errors (e.g. file not found)
448                // it just returns false, which we ignore, and proceed to the next file.
449                // This entire loop is best-effort only.
450                attachmentFile.delete();
451            }
452        } finally {
453            c.close();
454        }
455    }
456
457    /**
458     * In support of deleting a mailbox, find all messages and delete their attachments.
459     *
460     * @param context
461     * @param accountId the account for the mailbox
462     * @param mailboxId the mailbox for the messages
463     */
464    public static void deleteAllMailboxAttachmentFiles(Context context, long accountId,
465            long mailboxId) {
466        Cursor c = context.getContentResolver().query(Message.CONTENT_URI,
467                Message.ID_COLUMN_PROJECTION, MessageColumns.MAILBOX_KEY + "=?",
468                new String[] { Long.toString(mailboxId) }, null);
469        try {
470            while (c.moveToNext()) {
471                long messageId = c.getLong(Message.ID_PROJECTION_COLUMN);
472                deleteAllAttachmentFiles(context, accountId, messageId);
473            }
474        } finally {
475            c.close();
476        }
477    }
478}
479